mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
152
Commits
No files matched your search
@@ -6,7 +6,6 @@
|
||||
/.github/ @ludovic111
|
||||
/tools/ci/ @ludovic111
|
||||
/tools/release_train.py @ludovic111
|
||||
/tools/release_channels.py @ludovic111
|
||||
/crates/horizon-release-sign/ @ludovic111
|
||||
/crates/horizon-server/src/update.rs @ludovic111
|
||||
/crates/horizon-server/Cargo.toml @ludovic111
|
||||
|
||||
@@ -64,9 +64,13 @@ jobs:
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
|
||||
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: cargo +stable test --workspace
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests
|
||||
cargo +stable test --workspace --doc
|
||||
parity:
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: ./.github/workflows/parity.yml
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
name: promote
|
||||
# Beta game releases become stable once a day (docs/release-flow.md, "Channels"): at 20:00 UTC (21:00 in Paris in
|
||||
# winter time, 22:00 in summer) tools/release_channels.py promotes the newest beta that had been out 6 hours by then,
|
||||
# passed its release smoke test and isn't covered by an issue labelled release-blocker. Launchers on the Alpha
|
||||
# channel install every beta when it is published; Stable gets the day's promotion. The later runs of the evening are
|
||||
# retries for a run GitHub starts late or skips: they promote the same beta, so stable still moves once.
|
||||
# By hand, from GitHub's app too (Actions > promote > Run workflow):
|
||||
# gh workflow run promote -f tag=v0.2.3 # that beta now, without waiting for 20:00
|
||||
# gh workflow run promote -f tag=v0.2.3 -f force=true # even blocked or without its smoke test
|
||||
# gh workflow run promote -f dry_run=true # say what would be promoted
|
||||
# Holding betas back: label an issue release-blocker. While it is open it holds every beta; closed as completed it
|
||||
# still holds the betas published before it closed. The issue's text is never read (its author could edit it).
|
||||
# When the oldest waiting beta is 30 hours old (it missed a promotion it was due for), an issue "Promotion to stable
|
||||
# has stalled" says so; it closes itself after the next promotion.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '7,37 20-21 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: The beta to promote now (empty = the newest beta that is due)
|
||||
type: string
|
||||
default: ''
|
||||
force:
|
||||
description: Promote that tag even if a release-blocker covers it or its smoke test didn't run
|
||||
type: boolean
|
||||
default: false
|
||||
dry_run:
|
||||
description: Say what would be promoted, change nothing
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
concurrency:
|
||||
group: promote
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The notes of a promoted release cover every pull request since the last stable one.
|
||||
fetch-depth: 0
|
||||
# gh uses GH_TOKEN below; nothing needs git credentials on disk.
|
||||
persist-credentials: false
|
||||
- name: Promote
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
FORCE: ${{ inputs.force && 'yes' || '' }}
|
||||
DRY_RUN: ${{ (inputs.dry_run || github.ref != 'refs/heads/main') && 'yes' || '' }}
|
||||
run: python3 tools/release_channels.py promote ${TAG:+--tag "$TAG"} ${FORCE:+--force} ${DRY_RUN:+--dry-run}
|
||||
@@ -3,12 +3,15 @@ name: release
|
||||
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides:
|
||||
# a release when pull requests merged since the last one changed what players run and the newest merged 5 minutes
|
||||
# ago (the oldest 20 minutes ago, if merges never pause), or at once for a pull request labelled release-now.
|
||||
# RELEASES_PAUSED=1 (repository variable) stops them.
|
||||
# RELEASES_PAUSED=1 (repository variable) stops them; so does an open issue labelled release-blocker, forced runs too.
|
||||
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
|
||||
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then
|
||||
# publishes vX.Y.Z on the release line in .github/release-line.txt (docs/versioning.md; without it, the dated
|
||||
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as a beta (a pre-release) that promote.yml makes
|
||||
# stable in its daily promotion. macOS: with the repository variable MACOS_SIGN_IDENTITY set, the programs are signed with
|
||||
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as the release and GitHub's latest: one
|
||||
# channel, which launchers, `horizon-server update` and install.sh follow at once. So it is published only after the
|
||||
# checks that used to gate a promotion pass in this run: the Mac mini's smoke test drew a world, and no release-blocker
|
||||
# is open (tools/release_train.py gate); otherwise nothing is published. macOS: with the repository variable
|
||||
# MACOS_SIGN_IDENTITY set, the programs are signed with
|
||||
# Developer ID and the hardened runtime and the zip is notarized, from the runner's signing keychain
|
||||
# (packaging/macos/README.md); unset (forks), ad hoc.
|
||||
# gh workflow run release --ref <branch> # dry run: builds and notes, publishes nothing
|
||||
@@ -32,6 +35,8 @@ on:
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
# The open release-blocker issues (decide).
|
||||
issues: read
|
||||
concurrency:
|
||||
group: game-release
|
||||
cancel-in-progress: false
|
||||
@@ -203,7 +208,7 @@ jobs:
|
||||
or not report.get("meshed_sections") or not report.get("player"):
|
||||
sys.exit("the smoke run did not draw a world")
|
||||
EOF
|
||||
# Published with the release: a beta becomes stable only with it (tools/release_channels.py).
|
||||
# Published with the release; the publish job checks it again before it publishes anything.
|
||||
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: runner.environment == 'self-hosted'
|
||||
@@ -319,6 +324,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
issues: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -341,6 +347,17 @@ jobs:
|
||||
run: |
|
||||
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
|
||||
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
|
||||
- name: Ready to publish (the smoke test passed, no release-blocker is open)
|
||||
# Right before publishing: an issue labelled while the builds ran still holds this release. A dry run only
|
||||
# reports what a real run would do.
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DRY_RUN: ${{ needs.decide.outputs.dry_run }}
|
||||
run: |
|
||||
if ! python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json --blockers; then
|
||||
[[ "$DRY_RUN" == true ]] || { echo "::error::Nothing published"; exit 1; }
|
||||
echo "::warning::A real run would publish nothing"
|
||||
fi
|
||||
- name: Publish ${{ needs.decide.outputs.tag }}
|
||||
if: needs.decide.outputs.dry_run != 'true'
|
||||
env:
|
||||
@@ -348,17 +365,15 @@ jobs:
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
smoke=(); [[ -f dist/smoke-test.json ]] && smoke=(dist/smoke-test.json)
|
||||
# A beta (pre-release), the Alpha channel's: promote.yml makes it stable and latest in its daily promotion
|
||||
# (docs/release-flow.md).
|
||||
# The release and the latest at once: there is one channel (docs/release-flow.md).
|
||||
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
|
||||
--notes-file notes/body.md --prerelease --latest=false dist/horizon-linux-x86_64.tar.gz \
|
||||
--notes-file notes/body.md --latest dist/horizon-linux-x86_64.tar.gz \
|
||||
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz \
|
||||
dist/SHA256SUMS "${signature[@]}" "${smoke[@]}"
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
- name: Dry run, the release that would be published
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
run: |
|
||||
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }}: $(cat notes/title.txt)"
|
||||
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }} as the release and latest: $(cat notes/title.txt)"
|
||||
ls -l dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# Horizon: a map for agents
|
||||
|
||||
Minecraft: Java Edition 26.3 rewritten in Rust (wgpu). Vanilla is the specification wherever it has the
|
||||
feature. Read [CONTRIBUTING.md](CONTRIBUTING.md) once; this page tells you where things are.
|
||||
Feature → files → harness for the most-changed areas: [docs/architecture.md](docs/architecture.md).
|
||||
|
||||
## Rules that bite
|
||||
|
||||
- **Never commit Mojang's files**: game jars, assets, decompiled source, prepared resources (`.local/`).
|
||||
`tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit.
|
||||
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
|
||||
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
|
||||
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Never write to
|
||||
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
|
||||
- **Mark what an agent posts.** A PR body starts with
|
||||
`> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.` (issues: "Filed by", reviews:
|
||||
"Review by"); every comment starts with `🤖 Agent:`. Never write as Ludovic.
|
||||
- **Attribution.** Commits end with `Co-Authored-By: Claude <model> <[email protected]>`; PR bodies end
|
||||
with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
||||
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
|
||||
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
|
||||
they would change the vanilla look.
|
||||
- Small PRs (< ~500 lines). Labels: `feature` (players get something new), `skip-release` (they see no
|
||||
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Every merge to main ships
|
||||
([docs/release-flow.md](docs/release-flow.md)).
|
||||
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
|
||||
|
||||
## Crates
|
||||
|
||||
Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-server` are separate workspaces.
|
||||
|
||||
| Crate | Owns |
|
||||
|---|---|
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~190 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
|
||||
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
|
||||
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
|
||||
| `horizon-assets` | Resource decoding: block-state and item models, baking, atlases |
|
||||
| `horizon-prepare` | Builds `.local/horizon-client/26.3` from the player's own Minecraft install (runs the Java exporters) |
|
||||
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
|
||||
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-shaderpack` | OptiFine/Iris packs: preprocessor, GLSL → WGSL ([docs/shader-packs.md](docs/shader-packs.md)) |
|
||||
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
|
||||
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `voxel-kernels`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, occupancy kernels, repo tasks |
|
||||
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
|
||||
|
||||
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
|
||||
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
|
||||
`website/` (playhorizon.dev; its paths are relative to `website/`), `docs/`, `.github/workflows/`.
|
||||
|
||||
## Build and test fast
|
||||
|
||||
```sh
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
python3 tools/parity.py mob_ai sculk # named harnesses
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (host in-frame); --server-thread for the host on its server thread
|
||||
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
```
|
||||
|
||||
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
|
||||
`tools/`): see [docs/parity-workflow.md](docs/parity-workflow.md). Without it, pr_ready lists the
|
||||
harnesses for CI.
|
||||
- A harness = `tools/test_<name>_parity.py` (324 of them) + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`)
|
||||
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
|
||||
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
|
||||
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
|
||||
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
|
||||
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
|
||||
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
|
||||
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude horizon-mods-test-guest`.
|
||||
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
|
||||
then `horizon-ctl --socket … state|keys|turn|command|screenshot|quit`
|
||||
([docs/horizon-ctl-commands.md](docs/horizon-ctl-commands.md)).
|
||||
|
||||
## Vanilla reference
|
||||
|
||||
- Decompiled sources: `.local/minecraft/26.3/sources/{client,server}/` (`python3 tools/lab.py decompile`).
|
||||
- Bytecode queries (callers through lambdas, every overload):
|
||||
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`
|
||||
([docs/decompilation-status.md](docs/decompilation-status.md)).
|
||||
- A harness runs the oracle against the real 26.3 jars and the Rust probe on the same inputs, then compares
|
||||
their JSON. Oracle runs are cached by `tools/parity_shim.py`; `--fresh` reruns them.
|
||||
- Mirror vanilla's `Math` calls with `horizon_core::jmath`, and vanilla's RNG with `horizon_core::random`:
|
||||
random draws must happen in vanilla's order.
|
||||
|
||||
## horizon-client: where things live
|
||||
|
||||
`L:` = lib (`crates/horizon-client/src/`), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
|
||||
`Game`; bin code reaches the lib as `horizon_client::<module>`.
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `window.rs`, `spans.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
|
||||
| Commands | parse: L: `command/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`, `nbt_path.rs`; G: `functions.rs`, `command_blocks.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
|
||||
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
|
||||
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
|
||||
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `block_entity_nbt.rs`, `options_file.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Shader packs | L: `shader_pack.rs` (Horizon WGSL packs), `java_pack.rs` + crate `horizon-shaderpack` (Iris/OptiFine), `gfx/pack_stages.rs`, `gfx/java_stages.rs`; G: `shader_packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
|
||||
## How `Game` is organised
|
||||
|
||||
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
|
||||
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
|
||||
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
|
||||
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
|
||||
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
|
||||
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
|
||||
single player on its server thread), `lan` (`LanHost`: this world is open to the network), `integrated`
|
||||
(the window's own server thread), `serving` (this `Game` *is* that server thread), `options.dedicated`.
|
||||
`fn tick` returns `tick_remote()` when `remote` is set; nothing else of the local simulation runs.
|
||||
- A server's players run through the same gameplay code as the local player: `as_guest` swaps the guest's
|
||||
state (position, inventory, mode, mining, item use) into `Game` for one call (`game/lan_actions.rs`), and
|
||||
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
|
||||
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
|
||||
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
|
||||
(`pack`), both in game.rs.
|
||||
|
||||
## The two single-player paths
|
||||
|
||||
1. **In-frame (default since #1428):** the window's `Game` ticks the world itself, one level that follows
|
||||
the player (`fn enter_dimension`). This is what Stable players run and what harnesses test.
|
||||
2. **Server thread (`--server-thread` or `HORIZON_SERVER_THREAD=1`):** `game/integrated.rs` runs a second
|
||||
`Game` (`serving`) on a "Server thread" with one level per dimension; the window joins it as a client
|
||||
through memory (`remote`), exactly as a LAN guest or a dedicated server's player does. Not at parity yet:
|
||||
open `[server-thread]` issues list the gaps.
|
||||
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
|
||||
when you touch player-facing gameplay:
|
||||
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # in-frame
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --server-thread # server thread
|
||||
python3 tools/lan_e2e.py --check <name> # host in-frame, guest is a server client
|
||||
python3 tools/lan_e2e.py --server-thread --check <name> # host on its server thread (portals, levels, distance, lava need it)
|
||||
python3 tools/parity.py dedicated_server server_entities server_player_damage
|
||||
```
|
||||
|
||||
## Docs worth knowing
|
||||
|
||||
[docs/parity-workflow.md](docs/parity-workflow.md) (runner, gate, shards, scopes) ·
|
||||
[docs/native-client.md](docs/native-client.md) (options, controls, horizon-ctl) ·
|
||||
[docs/server-levels.md](docs/server-levels.md) (levels per dimension) ·
|
||||
[docs/rust-parity.md](docs/rust-parity.md) (what is ported) · [docs/known-issues.md](docs/known-issues.md) ·
|
||||
[docs/release-flow.md](docs/release-flow.md) · [docs/README.md](docs/README.md) (index).
|
||||
@@ -30,7 +30,9 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
# Shader packs: pack.toml, and WGSL checked before it reaches wgpu.
|
||||
toml = "1.1"
|
||||
naga = { version = "30.0.1", features = ["wgsl-in"] }
|
||||
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
|
||||
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
# Java (OptiFine/Iris) shader packs, translated to WGSL.
|
||||
horizon-shaderpack = { path = "../horizon-shaderpack" }
|
||||
# Sound (ALSA on Linux); the `audio` feature.
|
||||
@@ -81,7 +83,3 @@ rng-trace = ["horizon-core/rng-trace"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
@@ -111,6 +111,8 @@ struct World<'a> {
|
||||
slime: f32,
|
||||
starts: Vec<Start>,
|
||||
players: Vec<([f64; 3], String)>,
|
||||
/// Where the non-spectators among them are.
|
||||
nearest: Vec<[f64; 3]>,
|
||||
respawn: Option<[i32; 3]>,
|
||||
unloaded: HashSet<(i32, i32)>,
|
||||
spawn_mobs: bool,
|
||||
@@ -302,19 +304,8 @@ impl NaturalLevel for World<'_> {
|
||||
fn structure_spawns(&self) -> &StructureSpawns {
|
||||
self.s.structures
|
||||
}
|
||||
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
|
||||
let mut best: Option<f64> = None;
|
||||
for (p, mode) in &self.players {
|
||||
if mode == "spectator" {
|
||||
continue;
|
||||
}
|
||||
let d = [p[0] - x, p[1] - y, p[2] - z];
|
||||
let d = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
if best.is_none_or(|b| d < b) {
|
||||
best = Some(d);
|
||||
}
|
||||
}
|
||||
best
|
||||
fn nearest_player_positions(&self) -> &[[f64; 3]] {
|
||||
&self.nearest
|
||||
}
|
||||
fn respawn(&self) -> Option<[i32; 3]> {
|
||||
self.respawn
|
||||
@@ -730,6 +721,11 @@ fn main() -> horizon_client::Result<()> {
|
||||
dimension_id: dimension_id.clone(),
|
||||
slime: get("slime", "0.0").parse()?,
|
||||
starts,
|
||||
nearest: players
|
||||
.iter()
|
||||
.filter(|(_, mode)| mode != "spectator")
|
||||
.map(|(p, _)| *p)
|
||||
.collect(),
|
||||
players,
|
||||
respawn,
|
||||
unloaded,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Random-tick sections companion for tools/test_random_tick_sections_parity.py:
|
||||
//! the game's `RandomTickSections` choosing the sections of a chunk that
|
||||
//! `Behaviours::tick_chunk_random` ticks, after the case's edits (see
|
||||
//! VanillaRandomTickSectionsOracle for the format; the `#resources` line names
|
||||
//! the prepared resources). The level the ticks run against reads as air
|
||||
//! everywhere, so nothing ticks for real: its reads are the drawn positions.
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, Section},
|
||||
random_tick_sections::RandomTickSections,
|
||||
};
|
||||
use horizon_core::{
|
||||
position::{BlockPos, ChunkPos},
|
||||
random::LegacyRandom,
|
||||
};
|
||||
use horizon_world::{
|
||||
fluid::FluidKind,
|
||||
level::{GameRules, LevelAccess, LevelEvent, Ticks, data},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
use std::{cell::RefCell, error::Error, fmt::Write, path::PathBuf, sync::Arc};
|
||||
|
||||
struct Air {
|
||||
reads: RefCell<Vec<BlockPos>>,
|
||||
random: LegacyRandom,
|
||||
speed: i32,
|
||||
}
|
||||
impl LevelAccess for Air {
|
||||
fn block_state(&self, pos: BlockPos) -> BlockStateId {
|
||||
self.reads.borrow_mut().push(pos);
|
||||
BlockStateId(0)
|
||||
}
|
||||
fn set_raw(&mut self, _: BlockPos, _: BlockStateId) -> Option<BlockStateId> {
|
||||
None
|
||||
}
|
||||
fn is_loaded(&self, _: BlockPos) -> bool {
|
||||
true
|
||||
}
|
||||
fn random(&mut self) -> &mut LegacyRandom {
|
||||
&mut self.random
|
||||
}
|
||||
fn game_time(&self) -> i64 {
|
||||
0
|
||||
}
|
||||
fn game_rules(&self) -> GameRules {
|
||||
GameRules {
|
||||
random_tick_speed: self.speed,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn emit(&mut self, _: LevelEvent) {}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = std::fs::read_to_string(&args[1])?;
|
||||
let root = input
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("#resources "))
|
||||
.map(PathBuf::from)
|
||||
.ok_or("no #resources line")?;
|
||||
let registry = data::load_registry(&root)?;
|
||||
let (facts, behaviours) = BlockFacts::load_pair(&root, ®istry)?;
|
||||
let mut output = String::new();
|
||||
for line in input.lines().filter(|l| !l.starts_with('#')) {
|
||||
let mut words = line.split(' ');
|
||||
let mut ticks = Ticks::new(0);
|
||||
ticks.random_tick_value = words.next().ok_or("no randValue")?.parse()?;
|
||||
let mut level = Air {
|
||||
reads: RefCell::default(),
|
||||
random: LegacyRandom::new(0),
|
||||
speed: words.next().ok_or("no speed")?.parse()?,
|
||||
};
|
||||
let mut chunk = Arc::new(Chunk::empty(0, 0));
|
||||
let mut cache = RandomTickSections::default();
|
||||
for word in words {
|
||||
let op: Vec<&str> = word.split(':').collect();
|
||||
let int = |i: usize| -> Result<i32, Box<dyn Error>> { Ok(op[i].parse()?) };
|
||||
match op[0] {
|
||||
"s" => {
|
||||
let (section, index) = (int(1)?, int(2)?);
|
||||
Arc::make_mut(&mut chunk).set(
|
||||
index & 15,
|
||||
MIN_Y + section * 16 + (index >> 8),
|
||||
index >> 4 & 15,
|
||||
int(3)? as u32,
|
||||
);
|
||||
}
|
||||
"f" => {
|
||||
let (a, b, m) = (int(2)? as u32, int(3)? as u32, int(4)? as usize);
|
||||
let values = std::array::from_fn(|i| if i % m == m - 1 { b } else { a });
|
||||
Arc::make_mut(&mut Arc::make_mut(&mut chunk).sections[int(1)? as usize])
|
||||
.blocks = Section::from_blocks(&values);
|
||||
}
|
||||
"t" => {
|
||||
output.push('|');
|
||||
level.reads.borrow_mut().clear();
|
||||
behaviours.tick_chunk_random(
|
||||
&mut level,
|
||||
&mut ticks,
|
||||
ChunkPos::new(0, 0),
|
||||
|_, from| cache.get((0, 0), &chunk, &facts).from(from),
|
||||
);
|
||||
for pos in level.reads.borrow().iter() {
|
||||
let state = chunk.block(pos.x, pos.y, pos.z);
|
||||
let fluid = matches!(
|
||||
behaviours.fluid(state).kind(),
|
||||
FluidKind::Lava | FluidKind::FlowingLava
|
||||
);
|
||||
write!(
|
||||
output,
|
||||
" {},{},{},{}{}",
|
||||
pos.x,
|
||||
pos.y,
|
||||
pos.z,
|
||||
if behaviours.is_randomly_ticking(state) {
|
||||
'B'
|
||||
} else {
|
||||
'-'
|
||||
},
|
||||
if fluid { 'F' } else { '-' },
|
||||
)?;
|
||||
}
|
||||
}
|
||||
other => return Err(format!("unknown op {other}").into()),
|
||||
}
|
||||
}
|
||||
writeln!(output, " | {}", ticks.random_tick_value)?;
|
||||
}
|
||||
std::fs::write(&args[2], output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -22,6 +22,11 @@ impl PlayerMotion {
|
||||
pub fn refresh_attributes(&mut self) -> Result<()> {
|
||||
live_updates::refresh(self)
|
||||
}
|
||||
/// `Player.internalSetAbsorptionAmount`: the amount as given, unclamped
|
||||
/// (the saved `AbsorptionAmount`, or what a client's server sent).
|
||||
pub fn internal_set_absorption(&mut self, value: f32) -> Result<()> {
|
||||
self.store_absorption(value)
|
||||
}
|
||||
}
|
||||
impl HealthWorld for PlayerMotion {
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
|
||||
@@ -167,6 +167,7 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
style.underlined = flag("underlined");
|
||||
style.strikethrough = flag("strikethrough");
|
||||
style.obfuscated = flag("obfuscated");
|
||||
style.font = o.get("font").and_then(crate::text::font_id);
|
||||
style
|
||||
}
|
||||
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
|
||||
@@ -266,4 +267,26 @@ mod tests {
|
||||
let (v, _) = parse_snbt("[\"a\",{\"text\":\"b\"}]").unwrap();
|
||||
assert_eq!(to_text(&v, &mut |_| Text::empty()).plain(), "ab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_is_inherited_and_kept() {
|
||||
let (v, _) =
|
||||
parse_snbt(r#"{text:"a",font:"alt",extra:["b",{text:"c",font:"minecraft:uniform"}]}"#)
|
||||
.unwrap();
|
||||
let t = to_text(&v, &mut |_| Text::empty());
|
||||
let fonts: Vec<_> = t.chars().map(|(_, s)| s.font.clone()).collect();
|
||||
let (alt, uniform) = (
|
||||
Some("minecraft:alt".into()),
|
||||
Some("minecraft:uniform".into()),
|
||||
);
|
||||
assert_eq!(fonts, [alt.clone(), alt, uniform]);
|
||||
// Back to JSON with the field, which a re-read keeps.
|
||||
let again = to_text(&t.component(), &mut |_| Text::empty());
|
||||
let kept: Vec<_> = again.chars().map(|(_, s)| s.font.clone()).collect();
|
||||
assert_eq!(kept, fonts);
|
||||
// Not an identifier: no font.
|
||||
let (v, _) = parse_snbt(r#"{text:"a",font:"Bad Font"}"#).unwrap();
|
||||
let t = to_text(&v, &mut |_| Text::empty());
|
||||
assert_eq!(t.spans[0].style.font, None);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,11 @@ use std::{
|
||||
io::{self, BufRead, BufReader, Read, Write},
|
||||
net::{Ipv4Addr, TcpListener, TcpStream},
|
||||
path::{Path, PathBuf},
|
||||
sync::mpsc,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
thread,
|
||||
};
|
||||
|
||||
@@ -151,6 +155,12 @@ pub struct Server {
|
||||
path: PathBuf,
|
||||
requests: mpsc::Receiver<Request>,
|
||||
sender: mpsc::Sender<Request>,
|
||||
/// Clients connected now (an MCP server keeps one open; horizon-ctl one per command).
|
||||
clients: Arc<AtomicUsize>,
|
||||
/// Set when the server is dropped: the listener stops accepting and exits.
|
||||
closed: Arc<AtomicBool>,
|
||||
/// The TCP port, to wake the listener when closing.
|
||||
port: Option<u16>,
|
||||
}
|
||||
|
||||
/// `$HORIZON_CONTROL`, else `horizon-control.sock` in the user's runtime
|
||||
@@ -174,10 +184,15 @@ impl Server {
|
||||
}
|
||||
let (sender, requests) = mpsc::channel();
|
||||
let local = sender.clone();
|
||||
let clients = Arc::new(AtomicUsize::new(0));
|
||||
let counted = clients.clone();
|
||||
let closed = Arc::new(AtomicBool::new(false));
|
||||
let mut tcp_port = None;
|
||||
if uses_tcp(path) {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
let token = random_token();
|
||||
let port = listener.local_addr()?.port();
|
||||
tcp_port = Some(port);
|
||||
write_private(
|
||||
path,
|
||||
&serde_json::json!({ "transport": "tcp", "port": port, "token": token })
|
||||
@@ -186,6 +201,8 @@ impl Server {
|
||||
spawn_listener(
|
||||
move || listener.accept().map(|(s, _)| s),
|
||||
sender,
|
||||
counted,
|
||||
closed.clone(),
|
||||
move |stream: TcpStream| {
|
||||
let _ = stream.set_nodelay(true);
|
||||
let mut stream = Stream::Tcp(stream);
|
||||
@@ -199,6 +216,8 @@ impl Server {
|
||||
spawn_listener(
|
||||
move || listener.accept().map(|(s, _)| s),
|
||||
sender,
|
||||
counted,
|
||||
closed.clone(),
|
||||
|s| Some(Stream::Unix(s)),
|
||||
)?;
|
||||
}
|
||||
@@ -207,6 +226,9 @@ impl Server {
|
||||
path: path.to_owned(),
|
||||
requests,
|
||||
sender: local,
|
||||
clients,
|
||||
closed,
|
||||
port: tcp_port,
|
||||
})
|
||||
}
|
||||
/// Commands from inside the process only (`--benchmark` without `--control`).
|
||||
@@ -216,8 +238,15 @@ impl Server {
|
||||
path: PathBuf::new(),
|
||||
requests,
|
||||
sender,
|
||||
clients: Arc::default(),
|
||||
closed: Arc::default(),
|
||||
port: None,
|
||||
}
|
||||
}
|
||||
/// How many clients are connected.
|
||||
pub fn clients(&self) -> usize {
|
||||
self.clients.load(Ordering::Relaxed)
|
||||
}
|
||||
/// Sends commands as a socket client would, from another thread.
|
||||
pub fn sender(&self) -> mpsc::Sender<Request> {
|
||||
self.sender.clone()
|
||||
@@ -234,6 +263,8 @@ impl Server {
|
||||
fn spawn_listener<S: Send + 'static>(
|
||||
mut accept_next: impl FnMut() -> io::Result<S> + Send + 'static,
|
||||
sender: mpsc::Sender<Request>,
|
||||
clients: Arc<AtomicUsize>,
|
||||
closed: Arc<AtomicBool>,
|
||||
accept: impl Fn(S) -> Option<Stream> + Send + Sync + 'static,
|
||||
) -> io::Result<()> {
|
||||
let accept = std::sync::Arc::new(accept);
|
||||
@@ -241,17 +272,24 @@ fn spawn_listener<S: Send + 'static>(
|
||||
.name("horizon-control".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
let Ok(stream) = accept_next() else {
|
||||
let next = accept_next();
|
||||
if closed.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let Ok(stream) = next else {
|
||||
thread::sleep(std::time::Duration::from_millis(10));
|
||||
continue;
|
||||
};
|
||||
let sender = sender.clone();
|
||||
let accept = accept.clone();
|
||||
let clients = clients.clone();
|
||||
let _ = thread::Builder::new()
|
||||
.name("horizon-control-client".into())
|
||||
.spawn(move || {
|
||||
if let Some(stream) = accept(stream) {
|
||||
clients.fetch_add(1, Ordering::Relaxed);
|
||||
serve(stream, &sender);
|
||||
clients.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -287,6 +325,18 @@ fn authenticate(stream: &mut Stream, token: &str) -> bool {
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
if self.path.as_os_str().is_empty() {
|
||||
return;
|
||||
}
|
||||
// Wake the listener so it sees `closed` and exits (agent control turned off in-game).
|
||||
self.closed.store(true, Ordering::Relaxed);
|
||||
match self.port {
|
||||
Some(port) => drop(TcpStream::connect((Ipv4Addr::LOCALHOST, port))),
|
||||
#[cfg(unix)]
|
||||
None => drop(UnixStream::connect(&self.path)),
|
||||
#[cfg(not(unix))]
|
||||
None => {}
|
||||
}
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,12 +98,73 @@ impl ItemEntity {
|
||||
lava_hurt,
|
||||
}
|
||||
}
|
||||
/// Returns false when the item should be removed.
|
||||
/// `ItemEntity.tick` on a server: an item whose box is inside a block
|
||||
/// (one placed where it lay) has no physics this tick
|
||||
/// (`level.noCollision(box.deflate(1.0E-7))`).
|
||||
pub fn inside_block(&self, world: &World, collision: &BlockCollisionRegistry<'_>) -> bool {
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
let inner = self.bounds().deflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
|
||||
!crate::collisions::no_collision_empty(&mut scene, inner)
|
||||
}
|
||||
/// `Entity.moveTowardsClosestSpace` from the middle of the item's box:
|
||||
/// towards the nearest side of its block (north, south, west, east or
|
||||
/// up) without a full collision cube, at `speed` (`nextFloat() * 0.2 +
|
||||
/// 0.1`).
|
||||
pub fn move_towards_closest_space(&mut self, world: &World, facts: &BlockFacts, speed: f32) {
|
||||
let bounds = self.bounds();
|
||||
let at = Vec3::new(
|
||||
self.position.x,
|
||||
(bounds.min_position().y + bounds.max_position().y) / 2.0,
|
||||
self.position.z,
|
||||
);
|
||||
let pos = [
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
];
|
||||
let delta = [
|
||||
at.x - f64::from(pos[0]),
|
||||
at.y - f64::from(pos[1]),
|
||||
at.z - f64::from(pos[2]),
|
||||
];
|
||||
// (axis, step): NORTH, SOUTH, WEST, EAST, UP.
|
||||
let mut closest = (1, 1);
|
||||
let mut nearest = f64::MAX;
|
||||
for (axis, step) in [(2, -1), (2, 1), (0, -1), (0, 1), (1, 1)] {
|
||||
let mut n = pos;
|
||||
n[axis] += step;
|
||||
if !facts.get(world.block(n[0], n[1], n[2])).full_collision {
|
||||
let d = if step > 0 {
|
||||
1.0 - delta[axis]
|
||||
} else {
|
||||
delta[axis]
|
||||
};
|
||||
if d < nearest {
|
||||
nearest = d;
|
||||
closest = (axis, step);
|
||||
}
|
||||
}
|
||||
}
|
||||
let push = f64::from(closest.1 as f32 * speed);
|
||||
let v = self.velocity.scale(0.75);
|
||||
self.velocity = match closest.0 {
|
||||
0 => Vec3::new(push, v.y, v.z),
|
||||
1 => Vec3::new(v.x, push, v.z),
|
||||
_ => Vec3::new(v.x, v.y, push),
|
||||
};
|
||||
}
|
||||
/// Returns false when the item should be removed. `no_physics`: the item
|
||||
/// moves through blocks this tick ([`ItemEntity::inside_block`]).
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
no_physics: bool,
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -140,15 +201,22 @@ impl ItemEntity {
|
||||
subject: None,
|
||||
};
|
||||
let wanted = self.velocity;
|
||||
let moved = scene
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO);
|
||||
// Entity.move with noPhysics: straight through, the ground as it was.
|
||||
let moved = if no_physics {
|
||||
wanted
|
||||
} else {
|
||||
scene
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
};
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
|
||||
if !no_physics {
|
||||
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
|
||||
}
|
||||
if moved.x != wanted.x {
|
||||
self.velocity.x = 0.0;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,47 @@ impl Base<'_> {
|
||||
}
|
||||
|
||||
/// `LivingEntity` + `Mob` (+ `AgeableMob`, creeper and skeleton specifics).
|
||||
/// The mobs whose class extends vanilla's `Animal` (and so save `InLove`):
|
||||
/// not allays, villagers, golems, dolphins or fish.
|
||||
fn is_animal(id: &str) -> bool {
|
||||
matches!(
|
||||
id.strip_prefix("minecraft:").unwrap_or(id),
|
||||
"pig"
|
||||
| "cow"
|
||||
| "mooshroom"
|
||||
| "sheep"
|
||||
| "chicken"
|
||||
| "rabbit"
|
||||
| "horse"
|
||||
| "donkey"
|
||||
| "mule"
|
||||
| "zombie_horse"
|
||||
| "skeleton_horse"
|
||||
| "llama"
|
||||
| "trader_llama"
|
||||
| "goat"
|
||||
| "panda"
|
||||
| "polar_bear"
|
||||
| "fox"
|
||||
| "wolf"
|
||||
| "cat"
|
||||
| "ocelot"
|
||||
| "parrot"
|
||||
| "bee"
|
||||
| "turtle"
|
||||
| "armadillo"
|
||||
| "frog"
|
||||
| "axolotl"
|
||||
| "hoglin"
|
||||
| "strider"
|
||||
| "camel"
|
||||
| "camel_husk"
|
||||
| "sniffer"
|
||||
| "happy_ghast"
|
||||
| "nautilus"
|
||||
| "zombie_nautilus"
|
||||
)
|
||||
}
|
||||
pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let base = Base {
|
||||
id: &m.kind.id,
|
||||
@@ -175,12 +216,15 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
for (key, value) in [
|
||||
("Age", Tag::Int(0)),
|
||||
("ForcedAge", Tag::Int(0)),
|
||||
("InLove", Tag::Int(0)),
|
||||
("AgeLocked", Tag::Byte(0)),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
|
||||
if is_animal(id) {
|
||||
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
|
||||
}
|
||||
match id {
|
||||
"minecraft:creeper" => {
|
||||
for (key, value) in [("Fuse", Tag::Short(30)), ("ExplosionRadius", Tag::Byte(3))] {
|
||||
@@ -445,10 +489,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
// Sheep.addAdditionalSaveData: its wool as it is now.
|
||||
"minecraft:sheep" => {
|
||||
for (key, value) in [("Color", Tag::Byte(0)), ("Sheared", Tag::Byte(0))] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
map.insert("Color".into(), Tag::Byte((m.extra.color & 15) as i8));
|
||||
map.insert("Sheared".into(), Tag::Byte(i8::from(m.extra.sheared)));
|
||||
}
|
||||
// CopperGolem.addAdditionalSaveData.
|
||||
"minecraft:copper_golem" => {
|
||||
@@ -621,6 +665,9 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
|
||||
m.extra.age_locked = flag(tag, "AgeLocked");
|
||||
}
|
||||
if is_animal(&m.kind.id) {
|
||||
m.extra.in_love = number(tag.get("InLove")).unwrap_or(0) as i32;
|
||||
}
|
||||
m.name_visible = flag(tag, "CustomNameVisible");
|
||||
m.no_ai = flag(tag, "NoAI");
|
||||
m.no_gravity = flag(tag, "NoGravity");
|
||||
@@ -832,6 +879,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
}
|
||||
m.extra.egg_time = number(tag.get("life_ticks")).map_or(-1, |v| v as i32);
|
||||
}
|
||||
// Sheep.readAdditionalSaveData: white and unsheared when absent.
|
||||
if m.species() == crate::mobs::Species::Sheep {
|
||||
m.extra.color = number(tag.get("Color")).map_or(0, |c| (c as u8) & 15);
|
||||
m.extra.sheared = flag(tag, "Sheared");
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Shulker {
|
||||
m.extra.variant3 = number(tag.get("AttachFace")).unwrap_or(0).clamp(0, 5) as u8;
|
||||
m.extra.counter = number(tag.get("Peek")).unwrap_or(0).clamp(0, 100) as i32;
|
||||
@@ -2051,3 +2103,39 @@ mod axolotl_nbt_tests {
|
||||
assert_eq!(mm.temptation_cooldown_ticks.value(), Some(42));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sheep_nbt_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn a_sheep_keeps_its_dyed_and_sheared_wool() {
|
||||
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 mut m = Mob::new(
|
||||
1,
|
||||
Arc::new(data.types["minecraft:sheep"].clone()),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
// Summoned white, then dyed red and sheared: the save has it now.
|
||||
let raw = Tag::compound([("Color", Tag::Byte(0)), ("Sheared", Tag::Byte(0))]);
|
||||
(m.extra.color, m.extra.sheared) = (14, true);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw));
|
||||
assert_eq!(
|
||||
(number(tag.get("Color")), number(tag.get("Sheared"))),
|
||||
(Some(14), Some(1))
|
||||
);
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,9 @@ pub struct Extras {
|
||||
pub sort_key: String,
|
||||
/// No AI: every AI feature hidden and off (`horizon_core::no_ai`; the launcher writes it too).
|
||||
pub no_ai: bool,
|
||||
/// Agents may control the game the player starts: it opens its control socket for
|
||||
/// horizon-ctl and MCP clients (off by default; an agent's own launch always opens it).
|
||||
pub agent_control: bool,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -138,6 +141,7 @@ impl Default for Extras {
|
||||
sort: false,
|
||||
sort_key: "key.keyboard.unknown".into(),
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +206,7 @@ impl Extras {
|
||||
"sort" => e.sort = flag(e.sort),
|
||||
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -209,7 +214,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -240,6 +245,7 @@ impl Extras {
|
||||
self.sort_key,
|
||||
horizon_core::no_ai::KEY,
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
|
||||
@@ -457,6 +463,9 @@ pub enum Item {
|
||||
MouseTweaksWheel,
|
||||
Sort,
|
||||
SortKey,
|
||||
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
|
||||
NoAi,
|
||||
AgentControl,
|
||||
}
|
||||
/// The Extras page: headers and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
@@ -535,6 +544,8 @@ impl Item {
|
||||
Item::FpsLows => "Minimum and 1% low",
|
||||
Item::Keystrokes => "Keystrokes",
|
||||
Item::KeystrokesCps => "Clicks per second",
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
}
|
||||
}
|
||||
/// The switch this setting belongs to: it does nothing while that is off.
|
||||
@@ -548,7 +559,7 @@ impl Item {
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort
|
||||
| AutoReconnect => {
|
||||
| AutoReconnect | NoAi | AgentControl => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -592,6 +603,12 @@ impl Item {
|
||||
}
|
||||
Item::Keystrokes => "Movement keys, jump and mouse buttons as you press them.",
|
||||
Item::KeystrokesCps => "Clicks in the last second under each mouse button.",
|
||||
Item::NoAi => {
|
||||
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
|
||||
}
|
||||
Item::AgentControl => {
|
||||
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The frame-time cost the main switch of an extra shows.
|
||||
@@ -622,6 +639,8 @@ impl Item {
|
||||
Item::MouseTweaks => e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort,
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -677,6 +696,8 @@ impl Item {
|
||||
}
|
||||
Item::MinimapShape => e.minimap_round = !e.minimap_round,
|
||||
Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back),
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::ZoomKey | Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
|
||||
+824
-225
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,7 @@
|
||||
mod advancements;
|
||||
mod advancements_screen;
|
||||
mod agent;
|
||||
mod agent_settings;
|
||||
mod allays;
|
||||
mod armor_stands;
|
||||
mod banners;
|
||||
@@ -83,6 +84,7 @@ mod level_particles;
|
||||
mod levels;
|
||||
mod lightning;
|
||||
mod llamas;
|
||||
mod locator_bar;
|
||||
mod loom;
|
||||
mod mace;
|
||||
mod machines;
|
||||
@@ -119,6 +121,7 @@ mod pumpkin;
|
||||
mod raids;
|
||||
mod recipe_book;
|
||||
mod remote;
|
||||
mod remote_mob_data;
|
||||
mod remote_objects;
|
||||
mod remote_projectiles;
|
||||
mod remote_stations;
|
||||
@@ -160,6 +163,7 @@ mod ui_control;
|
||||
mod vault;
|
||||
mod villagers;
|
||||
mod wandering_trader;
|
||||
mod win;
|
||||
mod wind;
|
||||
mod wither;
|
||||
mod world_loading;
|
||||
@@ -264,6 +268,9 @@ struct Options {
|
||||
/// Render into a texture instead of the window (`--offscreen`), so a
|
||||
/// GPU the display cannot present from can still be benchmarked.
|
||||
offscreen: bool,
|
||||
/// `--width` and `--height` (vanilla's own arguments): the window's
|
||||
/// starting size, 1280x720 by default.
|
||||
window_size: [u32; 2],
|
||||
/// Multiplayer: `--server HOST[:PORT]` joins at start; `--name` is the offline player name.
|
||||
server: Option<String>,
|
||||
/// `--name`, `HORIZON_PLAYER_NAME` or the signed-in account's name; else
|
||||
@@ -276,9 +283,11 @@ struct Options {
|
||||
benchmark: Option<String>,
|
||||
/// `--dev-mod PATH` (repeatable): mods always on and reloaded when rebuilt.
|
||||
dev_mods: Vec<PathBuf>,
|
||||
/// Single player runs on an integrated server thread (game/integrated.rs),
|
||||
/// as vanilla's does: the default. `--no-server-thread` or
|
||||
/// `HORIZON_SERVER_THREAD=0` runs the world in the frame instead.
|
||||
/// `--server-thread` or `HORIZON_SERVER_THREAD=1`: single player runs on an
|
||||
/// integrated server thread (game/integrated.rs), as vanilla's does. Off by
|
||||
/// default again until a server's players reach parity with the in-frame
|
||||
/// world (signs, books, totems, keep_inventory...); `--no-server-thread`
|
||||
/// keeps it off.
|
||||
server_thread: bool,
|
||||
/// Set on the integrated server's own options: it draws nothing, so it skips meshing.
|
||||
integrated: bool,
|
||||
@@ -323,6 +332,7 @@ impl Options {
|
||||
no_pause: false,
|
||||
control: None,
|
||||
offscreen: false,
|
||||
window_size: [1280, 720],
|
||||
spikes: std::env::var("HORIZON_SPIKES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok()),
|
||||
@@ -335,7 +345,7 @@ impl Options {
|
||||
dev_mods: Vec::new(),
|
||||
// The command harness (HORIZON_COMMAND_SCRIPT) drives the world
|
||||
// itself, without the event loop: it stays in the frame.
|
||||
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0")
|
||||
server_thread: std::env::var("HORIZON_SERVER_THREAD").is_ok_and(|v| v == "1")
|
||||
&& std::env::var_os("HORIZON_COMMAND_SCRIPT").is_none(),
|
||||
integrated: false,
|
||||
};
|
||||
@@ -345,7 +355,7 @@ impl Options {
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --no-server-thread Run single player's world in the frame, not on its server thread\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --server-thread Run single player's world on its server thread (not at parity yet)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
horizon_core::VANILLA_VERSION
|
||||
);
|
||||
return Ok(None);
|
||||
@@ -394,6 +404,8 @@ impl Options {
|
||||
"--timeout-seconds" => o.timeout = Duration::from_secs(value.parse()?),
|
||||
"--smoke-report" => o.report = Some(value.into()),
|
||||
"--screenshot-after" => o.screenshot_after = Some(value.parse()?),
|
||||
"--width" => o.window_size[0] = value.parse::<u32>()?.max(1),
|
||||
"--height" => o.window_size[1] = value.parse::<u32>()?.max(1),
|
||||
"--world" => o.world = Some(value),
|
||||
"--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?),
|
||||
"--benchmark" => {
|
||||
@@ -462,6 +474,8 @@ enum Screen {
|
||||
Friends,
|
||||
/// CommandBlockEditScreen (game/command_blocks.rs).
|
||||
CommandBlock,
|
||||
/// WinScreen: the end poem and the credits (game/win.rs).
|
||||
Win,
|
||||
}
|
||||
struct Mining {
|
||||
pos: [i32; 3],
|
||||
@@ -524,6 +538,7 @@ struct GameLevel<'w> {
|
||||
spill: &'w mut spill::Spill,
|
||||
/// Where the chunk tickets let blocks tick.
|
||||
tickets: horizon_client::ticket_levels::Levels,
|
||||
random_tick_sections: &'w mut horizon_client::random_tick_sections::RandomTickSections,
|
||||
}
|
||||
struct PlayerCollision {
|
||||
y: f64,
|
||||
@@ -1006,9 +1021,11 @@ pub struct UiResources {
|
||||
pub panorama: Arc<horizon_client::gfx::panorama::Faces>,
|
||||
}
|
||||
impl UiResources {
|
||||
pub fn load(root: &std::path::Path) -> Result<Self> {
|
||||
/// From a resource root layering packs over `defaults`, the game's own
|
||||
/// resources (`root` itself when no pack is on).
|
||||
pub fn load(root: &std::path::Path, defaults: &std::path::Path) -> Result<Self> {
|
||||
Ok(Self {
|
||||
atlas: HudAtlas::build(root)?,
|
||||
atlas: HudAtlas::build_over(root, defaults)?,
|
||||
panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(root)),
|
||||
})
|
||||
}
|
||||
@@ -1354,6 +1371,8 @@ struct Game<'a> {
|
||||
respawn_travel: Option<respawn_anchor::RespawnConfig>,
|
||||
/// The next enter_dimension call comes from an End portal.
|
||||
end_portal_travel: bool,
|
||||
/// The open WinScreen (game/win.rs).
|
||||
win: Option<win::Win>,
|
||||
end: end::EndState,
|
||||
block_ticks: Ticks,
|
||||
tick_chunks: horizon_client::tick_chunk_membership::TickChunkMembership,
|
||||
@@ -1373,6 +1392,14 @@ struct Game<'a> {
|
||||
/// Entities of types Horizon does not simulate.
|
||||
others: Vec<commands::OtherEntity>,
|
||||
armor_stand_assets: armor_stands::ArmorStandAssets,
|
||||
/// `WaypointStyleManager`.
|
||||
waypoint_styles: horizon_client::locator_bar::WaypointStyles,
|
||||
/// `ClientWaypointManager`: the locator bar's waypoints.
|
||||
waypoints: horizon_client::locator_bar::ClientWaypoints,
|
||||
/// The camera the last frame drew from (position, yaw, pitch, fov), for the locator bar.
|
||||
locator_camera: Option<([f64; 3], f32, f32, f32)>,
|
||||
/// `LocalPlayer.experienceDisplayStartTick`: (last progress, seen a change, tick).
|
||||
experience_display: (f32, bool, u64),
|
||||
boats: Vec<boats::Boat>,
|
||||
boat_models: boats::BoatModels,
|
||||
/// EndCrystalModel's baked layer.
|
||||
@@ -1395,6 +1422,8 @@ struct Game<'a> {
|
||||
/// (least recently shown first).
|
||||
skin_downloads: RefCell<horizon_client::skins::Downloader>,
|
||||
skin_slots: Vec<(String, usize)>,
|
||||
/// Atlas trim slots in use: (sprite name, slot), least recently drawn first.
|
||||
trim_slots: Vec<(String, usize)>,
|
||||
cart_models: minecarts::CartModels,
|
||||
/// The boat the player rides.
|
||||
riding: Option<u32>,
|
||||
@@ -1427,6 +1456,10 @@ struct Game<'a> {
|
||||
item_columns: Option<machines::ItemColumns>,
|
||||
creative_screen: Creative,
|
||||
control: Option<horizon_client::control::Server>,
|
||||
/// The Agent Control setting the socket was last opened or closed for (`agent_settings`).
|
||||
agent_control_on: bool,
|
||||
/// When an agent's last command came.
|
||||
agent_last: Option<Instant>,
|
||||
frame_stats: horizon_client::control::FrameStats,
|
||||
/// This play session's frame and tick times, for opt-in performance statistics.
|
||||
perf: horizon_client::telemetry::Session,
|
||||
@@ -2084,7 +2117,14 @@ impl Game<'_> {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let alive = item.tick(&self.world, self.res.facts(), self.collision);
|
||||
// ItemEntity.tick's noPhysics (servers only): out of a block
|
||||
// placed on it, towards the closest open side (#1252).
|
||||
let no_physics = merging && item.inside_block(&self.world, self.collision);
|
||||
if no_physics {
|
||||
let speed = self.world.random.next_float() * 0.2 + 0.1;
|
||||
item.move_towards_closest_space(&self.world, self.res.facts(), speed);
|
||||
}
|
||||
let alive = item.tick(&self.world, self.res.facts(), self.collision, no_physics);
|
||||
cells.moved(i, item.previous, item.position);
|
||||
// ItemEntity.tick: merge every 2 ticks while moving between
|
||||
// blocks, every 40 otherwise, before the lifetime check.
|
||||
@@ -2291,6 +2331,7 @@ impl Game<'_> {
|
||||
pending: &mut self.pending,
|
||||
spill: &mut self.spill,
|
||||
tickets: self.tickets.levels(),
|
||||
random_tick_sections: &mut self.random_tick_sections,
|
||||
};
|
||||
let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks);
|
||||
self.flush_spill();
|
||||
@@ -2385,18 +2426,15 @@ impl Game<'_> {
|
||||
self.sync_tick_chunks();
|
||||
self.start_ticking_chunks();
|
||||
let levels = self.tickets.levels();
|
||||
let mut ticking = Vec::new();
|
||||
let facts = self.res.facts();
|
||||
for (&(x, z), chunk) in &self.world.chunks {
|
||||
if !levels.random_ticking(x, z) || !ticking_chunk(&self.world, x, z) {
|
||||
continue;
|
||||
}
|
||||
// Capture eligibility before inside-block and scheduled updates, as
|
||||
// before. Keep the world map's chunk order, including empty chunks
|
||||
// (tick_chunk_random still draws for precipitation in those).
|
||||
let sections = self.random_tick_sections.get((x, z), chunk, facts);
|
||||
ticking.push((x, z, sections));
|
||||
}
|
||||
// Keep the world map's chunk order, including empty chunks
|
||||
// (tick_chunk_random still draws for precipitation in those).
|
||||
let ticking: Vec<_> = self
|
||||
.world
|
||||
.chunks
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|&(x, z)| levels.random_ticking(x, z) && ticking_chunk(&self.world, x, z))
|
||||
.collect();
|
||||
// Entity.checkInsideBlocks for the plates, tripwires and detector rails
|
||||
// under the players (the host and the LAN guests) and the minecarts.
|
||||
let mut inside = Vec::new();
|
||||
@@ -2438,13 +2476,24 @@ impl Game<'_> {
|
||||
self.process_level_events()?;
|
||||
self.tick_scheduled()?;
|
||||
self.with_level(|b, level, ticks| {
|
||||
for (x, z, sections) in ticking {
|
||||
b.tick_chunk_random(
|
||||
level,
|
||||
ticks,
|
||||
horizon_core::position::ChunkPos::new(x, z),
|
||||
sections,
|
||||
);
|
||||
for (x, z) in ticking {
|
||||
// The sections are read again only after a block changed.
|
||||
let mut read = None;
|
||||
let mut sections = Default::default();
|
||||
let pos = horizon_core::position::ChunkPos::new(x, z);
|
||||
b.tick_chunk_random(level, ticks, pos, |level, from| {
|
||||
if read != Some(level.world.revision) {
|
||||
read = Some(level.world.revision);
|
||||
sections = level
|
||||
.world
|
||||
.chunks
|
||||
.get(&(x, z))
|
||||
.map_or_else(Default::default, |chunk| {
|
||||
level.random_tick_sections.get((x, z), chunk, level.facts)
|
||||
});
|
||||
}
|
||||
sections.from(from)
|
||||
});
|
||||
}
|
||||
b.run_block_events(level, ticks);
|
||||
b.tick_block_entities(level, ticks);
|
||||
@@ -3555,8 +3604,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
fn tick_using(&mut self) {
|
||||
// ItemCooldowns.tick for the shield.
|
||||
self.shield_cooldown = self.shield_cooldown.saturating_sub(1);
|
||||
let Some(hand) = self.used_hand() else {
|
||||
return;
|
||||
};
|
||||
@@ -3772,6 +3819,9 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((_, target)) = &self.travelling {
|
||||
if self.win_screen_holds_arrival() {
|
||||
return Ok(());
|
||||
}
|
||||
let (cx, cz) = (
|
||||
(target.x.floor() as i32) >> 4,
|
||||
(target.z.floor() as i32) >> 4,
|
||||
@@ -3899,6 +3949,7 @@ impl Game<'_> {
|
||||
None => 0.0,
|
||||
};
|
||||
self.ticks += 1;
|
||||
self.track_experience_display();
|
||||
// `/tick freeze` stops the world but not the player.
|
||||
let running = self.clock.world_runs();
|
||||
if running {
|
||||
@@ -4257,7 +4308,10 @@ impl Game<'_> {
|
||||
/// world's entities and blocks, and the network hub.
|
||||
fn tick_world(&mut self, running: bool) -> Result<()> {
|
||||
self.update_chunk_tickets();
|
||||
horizon_client::spans::time("tick_effects", || self.tick_effects());
|
||||
horizon_client::spans::time("tick_effects", || self.tick_effects(running));
|
||||
// Player.tick's ItemCooldowns.tick for the window's player (a
|
||||
// server's players count theirs down in `tick_guests`).
|
||||
self.shield_cooldown = self.shield_cooldown.saturating_sub(1);
|
||||
horizon_client::spans::time("tick_using", || self.tick_using());
|
||||
horizon_client::spans::time("tick_hud", || self.tick_hud());
|
||||
// The player's digging comes in with its packets, before the level
|
||||
@@ -4458,7 +4512,8 @@ impl Game<'_> {
|
||||
| Screen::WorldOptions
|
||||
| Screen::Friends
|
||||
| Screen::CommandBlock
|
||||
) || self.book.is_some())
|
||||
) || self.book.is_some()
|
||||
|| self.win_screen_pauses())
|
||||
}
|
||||
/// `MouseHandler.grabMouse` / `releaseMouse`. Both put the pointer at
|
||||
/// the window's centre, so a screen opened from gameplay (inventory,
|
||||
@@ -4673,6 +4728,10 @@ impl Game<'_> {
|
||||
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
let mut batch = HudBatch::new(size);
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
if self.screen == Screen::Win {
|
||||
self.draw_win_screen(&mut batch);
|
||||
return batch;
|
||||
}
|
||||
if self.screen == Screen::Options {
|
||||
self.draw_options(&mut batch);
|
||||
return batch;
|
||||
@@ -4753,12 +4812,41 @@ impl Game<'_> {
|
||||
self.draw_extras(&mut batch);
|
||||
}
|
||||
if show_hud {
|
||||
// The mount's hearts (and the jump bar that replaces the experience bar).
|
||||
let (vehicle_hearts, jump_bar) = if spectator {
|
||||
(0, false)
|
||||
// Hud.nextContextualInfoState: the jump bar, the experience bar or
|
||||
// the locator bar above the hotbar.
|
||||
let has_experience = !self.creative && !spectator;
|
||||
let contextual = horizon_client::locator_bar::ContextualBar::next(
|
||||
self.waypoints.has_waypoints(),
|
||||
!spectator && self.jumpable_mount(),
|
||||
self.mount_jumping(),
|
||||
has_experience,
|
||||
self.experience_recent(),
|
||||
);
|
||||
use horizon_client::locator_bar::ContextualBar;
|
||||
// The mount's hearts (and the jump bar).
|
||||
let vehicle_hearts = if spectator {
|
||||
0
|
||||
} else {
|
||||
self.draw_mount_hud(&mut batch, wi, hi)
|
||||
self.draw_mount_hud(
|
||||
&mut batch,
|
||||
wi,
|
||||
hi,
|
||||
contextual == ContextualBar::JumpableVehicle,
|
||||
)
|
||||
};
|
||||
if contextual == ContextualBar::Locator {
|
||||
let (sprite, [x, y, w, h]) = horizon_client::locator_bar::background(wi, hi);
|
||||
horizon_client::ui::nine_slice(
|
||||
&mut batch,
|
||||
&self.gui.atlas,
|
||||
sprite,
|
||||
x as f32,
|
||||
y as f32,
|
||||
w as f32,
|
||||
h as f32,
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
if !self.creative
|
||||
&& !spectator
|
||||
&& let Some(p) = &mut self.player
|
||||
@@ -4770,7 +4858,7 @@ impl Game<'_> {
|
||||
hi,
|
||||
self.experience.progress,
|
||||
self.experience.level,
|
||||
!jump_bar,
|
||||
contextual == ContextualBar::Experience,
|
||||
&width,
|
||||
);
|
||||
hud_layout::paint(&mut batch, atlas, &draws);
|
||||
@@ -4820,6 +4908,10 @@ impl Game<'_> {
|
||||
let draws = self.health_hud.layout(&inputs, wi, hi);
|
||||
hud_layout::paint(&mut batch, atlas, &draws);
|
||||
}
|
||||
if contextual == ContextualBar::Locator {
|
||||
let draws = self.locator_bar_draws(wi, hi);
|
||||
hud_layout::paint(&mut batch, &self.gui.atlas, &draws);
|
||||
}
|
||||
if !spectator {
|
||||
self.draw_selected_name(&mut batch, !self.creative);
|
||||
}
|
||||
@@ -4874,7 +4966,7 @@ impl Game<'_> {
|
||||
self.draw_creative(&mut batch);
|
||||
}
|
||||
Screen::Chat => self.draw_chat_screen(&mut batch),
|
||||
Screen::Playing | Screen::Options => {}
|
||||
Screen::Playing | Screen::Options | Screen::Win => {}
|
||||
}
|
||||
self.draw_toasts(&mut batch);
|
||||
self.remote_hud(&mut batch);
|
||||
@@ -6461,6 +6553,7 @@ impl Game<'_> {
|
||||
mut out: Vec<Vertex>,
|
||||
) -> Vec<Vertex> {
|
||||
self.prepare_entity_culling(camera);
|
||||
self.prepare_trims();
|
||||
let fire_yaw = if self.camera_type == perspective::CameraType::ThirdPersonFront {
|
||||
self.yaw + 180.0
|
||||
} else {
|
||||
@@ -6946,10 +7039,16 @@ impl Game<'_> {
|
||||
// Item.getAttackDamageBonus (a mace's smash), before the critical hit.
|
||||
amount += self.smash_damage_bonus(target_kind);
|
||||
let player = self.player.as_ref();
|
||||
// Player.canCriticalAttack: falling, off the ground, not sprinting.
|
||||
let falling =
|
||||
player.is_some_and(|p| !p.ground.on_ground && p.fall_distance > 0.0 && !p.sprinting);
|
||||
let crit = scale > 0.9 && falling;
|
||||
// Player.canCriticalAttack: falling, off the ground, not in water,
|
||||
// not blind (isMobilityRestricted), not riding, not sprinting.
|
||||
let falling = player.is_some_and(|p| {
|
||||
!p.ground.on_ground
|
||||
&& p.fall_distance > 0.0
|
||||
&& !p.fluids.was_touching_water
|
||||
&& !p.sprinting
|
||||
});
|
||||
let restricted = self.potions.effects.get("blindness").is_some() || self.riding.is_some();
|
||||
let crit = scale > 0.9 && falling && !restricted;
|
||||
if crit {
|
||||
amount *= 1.5;
|
||||
}
|
||||
@@ -7458,16 +7557,11 @@ impl Game<'_> {
|
||||
self.sleep_timer = 0;
|
||||
self.spawn = None;
|
||||
self.respawn_travel = None;
|
||||
self.respawn_config =
|
||||
self.level
|
||||
.respawn_elsewhere
|
||||
.as_ref()
|
||||
.map(|(dim, pos, yaw, forced)| respawn_anchor::RespawnConfig {
|
||||
dim: Dim::from_id(dim),
|
||||
pos: *pos,
|
||||
yaw: *yaw,
|
||||
forced: *forced,
|
||||
});
|
||||
self.respawn_config = self
|
||||
.level
|
||||
.respawn_elsewhere
|
||||
.as_ref()
|
||||
.map(respawn_anchor::RespawnConfig::from_saved);
|
||||
self.spawn_origin = None;
|
||||
self.spawn_spiral = Default::default();
|
||||
self.player_spawn = None;
|
||||
@@ -7563,6 +7657,8 @@ impl Game<'_> {
|
||||
world.game_time = self.world.game_time;
|
||||
world.day_time = self.world.day_time;
|
||||
world.weather = self.world.weather;
|
||||
// The game rules and the difficulty hold in every dimension.
|
||||
world.health_rules = self.world.health_rules;
|
||||
// An open hub keeps hearing the new level's block changes.
|
||||
world.block_log = self.world.block_log.as_ref().map(|_| Vec::new());
|
||||
world.light_log = self.world.light_log.as_ref().map(|_| Default::default());
|
||||
@@ -7753,6 +7849,7 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
fn close_world(&mut self) -> Result<()> {
|
||||
self.clear_waypoints();
|
||||
self.begin_closing_save(false)
|
||||
}
|
||||
fn finish_close_world(&mut self) -> Result<()> {
|
||||
@@ -9068,31 +9165,41 @@ impl Game<'_> {
|
||||
} else {
|
||||
pos
|
||||
};
|
||||
self.spawn = Some(Vec3::new(
|
||||
f64::from(head[0]) + 0.5,
|
||||
f64::from(head[1]) + 0.6,
|
||||
f64::from(head[2]) + 0.5,
|
||||
));
|
||||
// The bed replaces a respawn anchor.
|
||||
self.respawn_config = None;
|
||||
self.level.spawn = Some([
|
||||
f64::from(head[0]),
|
||||
f64::from(head[1]) + 1.0,
|
||||
f64::from(head[2]),
|
||||
]);
|
||||
let dist = ((p.position.x - f64::from(head[0]) - 0.5).powi(2)
|
||||
+ (p.position.z - f64::from(head[2]) - 0.5).powi(2))
|
||||
.sqrt();
|
||||
if !night {
|
||||
if dist > 3.0 {
|
||||
self.message = Some((
|
||||
"You can sleep only at night or during thunderstorms".into(),
|
||||
"You may not rest now; the bed is too far away".into(),
|
||||
Instant::now(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
if dist > 3.0 {
|
||||
// In reach (bedInRange), day or night: setRespawnPosition(
|
||||
// RespawnConfig(the head, the player's yaw), not forced). It replaces
|
||||
// an anchor or a spawnpoint; respawning checks the bed is still there
|
||||
// and finds where to stand up (findRespawnAndUseSpawnBlock).
|
||||
// Straw beds never set it (`straw_bed_rule`).
|
||||
if self.bed_sets_spawn(state) {
|
||||
self.respawn_config = Some(respawn_anchor::RespawnConfig::wrapped(
|
||||
self.dim, head, p.yaw, false,
|
||||
));
|
||||
// The fallback becomes the world spawn: a guest's is none of its
|
||||
// own, so it follows `setworldspawn`.
|
||||
self.spawn = if self.lan_acting.is_some() {
|
||||
None
|
||||
} else {
|
||||
self.level
|
||||
.world_spawn
|
||||
.map(|[x, y, z]| {
|
||||
Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5)
|
||||
})
|
||||
.or(self.spawn)
|
||||
};
|
||||
}
|
||||
if !night {
|
||||
self.message = Some((
|
||||
"You may not rest now; the bed is too far away".into(),
|
||||
"You can sleep only at night or during thunderstorms".into(),
|
||||
Instant::now(),
|
||||
));
|
||||
return;
|
||||
@@ -9444,9 +9551,7 @@ impl Game<'_> {
|
||||
level.weather = self.world.weather;
|
||||
level.day_time = self.world.day_time;
|
||||
level.spawn = self.spawn.map(|s| [s.x, s.y, s.z]);
|
||||
level.respawn_elsewhere = self
|
||||
.respawn_config
|
||||
.map(|c| (c.dim.id().to_owned(), c.pos, c.yaw, c.forced));
|
||||
level.respawn_elsewhere = self.respawn_config.map(|c| c.saved());
|
||||
level.rotation = [self.yaw, self.pitch];
|
||||
level.xp_level = self.experience.level;
|
||||
level.xp_progress = self.experience.progress;
|
||||
@@ -9454,6 +9559,7 @@ impl Game<'_> {
|
||||
if let Some(p) = &self.player {
|
||||
level.position = Some([p.position.x, p.position.y, p.position.z]);
|
||||
level.health = p.health.health();
|
||||
level.absorption = p.health.absorption();
|
||||
let extra = level
|
||||
.player_extra
|
||||
.get_or_insert_with(|| horizon_world::nbt::Tag::Compound(Default::default()));
|
||||
@@ -9466,6 +9572,7 @@ impl Game<'_> {
|
||||
);
|
||||
map.insert("Air".into(), horizon_world::nbt::Tag::Short(p.air as i16));
|
||||
}
|
||||
level.fall_distance = p.fall_distance;
|
||||
|
||||
level.food = p.health.food.food;
|
||||
level.saturation = p.health.food.saturation;
|
||||
@@ -9771,6 +9878,8 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.fov_degrees(camera_alpha as f32, camera.to_array())
|
||||
};
|
||||
self.locator_camera =
|
||||
(!title).then_some((camera.to_array(), view_yaw, view_pitch, fov_degrees));
|
||||
let bob = if title {
|
||||
glam::Mat4::IDENTITY
|
||||
} else {
|
||||
@@ -10034,12 +10143,13 @@ impl Game<'_> {
|
||||
let loading = self.loading_reason() == Some(horizon_client::level_loading::Reason::Other);
|
||||
let blurred = loading
|
||||
|| self.title.as_ref().is_some_and(|t| {
|
||||
self.screen == Screen::Options
|
||||
matches!(self.screen, Screen::Options | Screen::Win)
|
||||
|| !matches!(t.mode, horizon_client::title::Mode::Main)
|
||||
})
|
||||
|| (self.title.is_none()
|
||||
&& self.player.is_some()
|
||||
&& self.screen.blurs_background(self.pause_menu));
|
||||
&& (self.screen.blurs_background(self.pause_menu)
|
||||
|| (self.screen == Screen::Win && self.win_screen_pauses())));
|
||||
let radius = self.settings.menu_blur as f32;
|
||||
(blurred && radius >= 1.0).then_some(radius)
|
||||
}
|
||||
@@ -10123,6 +10233,7 @@ impl Game<'_> {
|
||||
Screen::WorldOptions => self.world_options_escape(),
|
||||
Screen::CommandBlock => self.close_command_block(),
|
||||
Screen::Chat => self.close_chat(),
|
||||
Screen::Win => self.close_win_screen()?,
|
||||
},
|
||||
KeyCode::Digit1
|
||||
| KeyCode::Digit2
|
||||
@@ -10454,6 +10565,10 @@ impl Game<'_> {
|
||||
if let (Some(lod), Some(player)) = (&mut self.lod, &self.player) {
|
||||
let lod_distance = self.settings.horizon.lod_distance_on(&renderer.info);
|
||||
lod.set_near_loading(self.streaming.busy());
|
||||
lod.set_reduced_detail(
|
||||
self.settings.horizon.far_detail
|
||||
== horizon_client::graphics::FarDetail::Reduced,
|
||||
);
|
||||
// The covered columns change only with the complete ones and
|
||||
// the render area: rebuilt then, not every frame.
|
||||
let at = (
|
||||
@@ -10498,6 +10613,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
// The Window constructor: created fullscreen when options.txt says so.
|
||||
let fullscreen = self.settings.fullscreen;
|
||||
let size = self.options.window_size.map(f64::from);
|
||||
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
|
||||
let attributes = Window::default_attributes()
|
||||
.with_title("Horizon")
|
||||
@@ -10509,7 +10625,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
);
|
||||
let window = match event_loop.create_window(
|
||||
attributes
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(1280.0, 720.0))
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1]))
|
||||
.with_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None))),
|
||||
) {
|
||||
Ok(w) => Arc::new(w),
|
||||
@@ -10624,6 +10740,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
WindowEvent::CloseRequested => {
|
||||
self.pending_save.as_mut().unwrap().quit = true;
|
||||
}
|
||||
WindowEvent::DroppedFile(path) => {
|
||||
self.drop_shader_pack(&path);
|
||||
}
|
||||
WindowEvent::Focused(focused) => {
|
||||
self.focused = focused;
|
||||
if !focused {
|
||||
@@ -10718,7 +10837,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
}) * wheel_sensitivity
|
||||
};
|
||||
let d = scaled(raw_y) as f32;
|
||||
if let Some(title) = &mut self.title {
|
||||
if let Some(title) = &mut self.title
|
||||
&& self.screen != Screen::Win
|
||||
{
|
||||
let size = self
|
||||
.window
|
||||
.as_ref()
|
||||
@@ -10776,6 +10897,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.advancements_scroll(scaled(raw_x) as f32, d);
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
|
||||
if self.options_waiting_for_key() && state == ElementState::Pressed {
|
||||
self.options_bind_mouse(button)?;
|
||||
@@ -11032,6 +11154,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
{
|
||||
self.world_options_key(&event)
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::Win => {
|
||||
self.win_key(&event)?
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::CommandBlock => {
|
||||
self.command_block_key(&event)
|
||||
}
|
||||
@@ -11165,6 +11290,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.tick_ambience();
|
||||
self.tick_fov();
|
||||
}
|
||||
self.tick_win_screen()?;
|
||||
self.tick_music();
|
||||
self.tick_sound_engine();
|
||||
self.clock.record(started.elapsed());
|
||||
@@ -11592,6 +11718,7 @@ impl<'a> Game<'a> {
|
||||
respawn_config: None,
|
||||
respawn_travel: None,
|
||||
end_portal_travel: false,
|
||||
win: None,
|
||||
end: Default::default(),
|
||||
block_ticks: Ticks::new(0),
|
||||
tick_chunks: Default::default(),
|
||||
@@ -11609,6 +11736,10 @@ impl<'a> Game<'a> {
|
||||
armor_stands: Vec::new(),
|
||||
others: Vec::new(),
|
||||
armor_stand_assets: prepared.armor_stand_assets,
|
||||
waypoint_styles: prepared.waypoint_styles,
|
||||
waypoints: Default::default(),
|
||||
locator_camera: None,
|
||||
experience_display: Default::default(),
|
||||
boats: Vec::new(),
|
||||
boat_models: prepared.boat_models,
|
||||
crystal_model: prepared.crystal_model,
|
||||
@@ -11629,6 +11760,7 @@ impl<'a> Game<'a> {
|
||||
.join("assets/skins"),
|
||||
)),
|
||||
skin_slots: Vec::new(),
|
||||
trim_slots: Vec::new(),
|
||||
cart_models: prepared.cart_models,
|
||||
riding: None,
|
||||
ender_items: vec![None; 27],
|
||||
@@ -11650,6 +11782,8 @@ impl<'a> Game<'a> {
|
||||
item_columns: None,
|
||||
creative_screen: prepared.creative_screen,
|
||||
control: None,
|
||||
agent_control_on: false,
|
||||
agent_last: None,
|
||||
frame_stats: Default::default(),
|
||||
perf: Default::default(),
|
||||
pending_screenshot: None,
|
||||
@@ -11733,6 +11867,7 @@ struct GameCpuResources {
|
||||
map_frame_model: frames::FrameModel,
|
||||
glow_map_frame_model: frames::FrameModel,
|
||||
armor_stand_assets: armor_stands::ArmorStandAssets,
|
||||
waypoint_styles: horizon_client::locator_bar::WaypointStyles,
|
||||
boat_models: boats::BoatModels,
|
||||
cart_models: minecarts::CartModels,
|
||||
crystal_model: Option<horizon_client::mobs::Part>,
|
||||
@@ -11752,6 +11887,7 @@ impl GameCpuResources {
|
||||
map_frame_model: frames::bake_frame(root, "item_frame_map"),
|
||||
glow_map_frame_model: frames::bake_frame(root, "glow_item_frame_map"),
|
||||
armor_stand_assets: armor_stands::ArmorStandAssets::load(root, document.as_ref()),
|
||||
waypoint_styles: horizon_client::locator_bar::WaypointStyles::load(root),
|
||||
boat_models: boats::BoatModels::load(document.as_ref()),
|
||||
cart_models: minecarts::CartModels::load(document.as_ref()),
|
||||
crystal_model: model("minecraft:end_crystal#main"),
|
||||
@@ -11975,7 +12111,20 @@ fn load_resources(
|
||||
// loads; the synchronous launch instead carries it in `Loaded`.
|
||||
let mut loaded_gui = None;
|
||||
let mut ui_ready = false;
|
||||
if let Ok(ui) = UiResources::load(&root) {
|
||||
let mut pack_error = None;
|
||||
let ui = match UiResources::load(&root, vanilla_root) {
|
||||
Ok(ui) => Some(ui),
|
||||
// Minecraft.rollbackResourcePacks: the packs' textures don't fit, start
|
||||
// with the game's own resources.
|
||||
Err(e) if root != vanilla_root => {
|
||||
log::warn!("Horizon: resource packs failed to load ({e}); using the default resources");
|
||||
pack_error = Some(e.to_string());
|
||||
root = vanilla_root.to_path_buf();
|
||||
None
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
if let Some(ui) = ui {
|
||||
if menu {
|
||||
horizon_client::telemetry::mark_ui_ready();
|
||||
log::info!(
|
||||
@@ -11989,7 +12138,6 @@ fn load_resources(
|
||||
ui_ready = true;
|
||||
}
|
||||
progress.set(0);
|
||||
let mut pack_error = None;
|
||||
let (assets, render) = match Assets::load(&root).and_then(|assets| {
|
||||
let render = assets.reload(&root)?;
|
||||
Ok((assets, render))
|
||||
@@ -12016,7 +12164,7 @@ fn load_resources(
|
||||
let render = Arc::new(render);
|
||||
// The menu bundle must come from the same root as the world bundle.
|
||||
if !ui_ready || root != requested_root {
|
||||
let ui = UiResources::load(&root)?;
|
||||
let ui = UiResources::load(&root, vanilla_root)?;
|
||||
if menu {
|
||||
horizon_client::telemetry::mark_ui_ready();
|
||||
log::info!(
|
||||
@@ -12427,6 +12575,10 @@ fn start_game<'a>(
|
||||
game.rest_loader = rest_loader;
|
||||
game.ui.vanilla_root = vanilla_root;
|
||||
if let Some(error) = pack_error {
|
||||
// Minecraft.clearResourcePacksOnError: the selection is cleared and
|
||||
// saved, so the next launch starts without the packs.
|
||||
game.settings.resource_packs.clear();
|
||||
game.settings.save(&Game::options_path());
|
||||
game.open_options_at(true, horizon_client::options_ui::Page::Packs);
|
||||
game.ui.options.resource_error = Some(error);
|
||||
}
|
||||
@@ -12444,18 +12596,7 @@ fn start_game<'a>(
|
||||
game.mods.dev = game.options.dev_mods.clone();
|
||||
game.start_mods();
|
||||
}
|
||||
if game.options.control.is_some() && game.settings.extras.no_ai {
|
||||
// No AI turns agent control off: the socket is never opened.
|
||||
log::warn!(
|
||||
"Horizon: control socket not opened: {}",
|
||||
horizon_core::no_ai::REFUSAL
|
||||
);
|
||||
} else if let Some(path) = &game.options.control {
|
||||
let server = horizon_client::control::Server::start(path)
|
||||
.map_err(|e| format!("control socket {}: {e}", path.display()))?;
|
||||
log::info!("Horizon: control socket {}", server.path().display());
|
||||
game.control = Some(server);
|
||||
}
|
||||
game.start_agent_control()?;
|
||||
if let Some(scenario) = game.options.benchmark.clone() {
|
||||
let control = game
|
||||
.control
|
||||
@@ -12596,6 +12737,11 @@ impl Loading {
|
||||
}
|
||||
// The Window constructor: created fullscreen when options.txt says so.
|
||||
let fullscreen = self.settings.fullscreen;
|
||||
let size = self
|
||||
.options
|
||||
.as_ref()
|
||||
.map_or([1280, 720], |o| o.window_size)
|
||||
.map(f64::from);
|
||||
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
|
||||
let attributes = Window::default_attributes()
|
||||
.with_title("Horizon")
|
||||
@@ -12607,7 +12753,7 @@ impl Loading {
|
||||
);
|
||||
let window = match event_loop.create_window(
|
||||
attributes
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(1280.0, 720.0))
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1]))
|
||||
.with_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None))),
|
||||
) {
|
||||
Ok(w) => Arc::new(w),
|
||||
|
||||
@@ -405,6 +405,7 @@ impl Game<'_> {
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
}
|
||||
}
|
||||
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
|
||||
@@ -459,8 +460,11 @@ impl Game<'_> {
|
||||
"sneaking": self.keys.contains(&KeyCode::ShiftLeft),
|
||||
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
|
||||
"health": p.health.health(),
|
||||
"absorption": p.health.absorption(),
|
||||
"air": p.air,
|
||||
"food": p.health.food.food,
|
||||
"saturation": p.health.food.saturation,
|
||||
"shield_cooldown": self.shield_cooldown,
|
||||
"xp": { "level": self.experience.level, "progress": self.experience.progress, "total": self.experience.total },
|
||||
"effects": self.potions.effects.list.iter().map(|e| json!({ "id": e.id, "duration_ticks": e.duration, "amplifier": e.amplifier })).collect::<Vec<_>>(),
|
||||
})
|
||||
@@ -478,6 +482,8 @@ impl Game<'_> {
|
||||
"sleeping": self.sleeping,
|
||||
// The item in use: its slot and for how many ticks.
|
||||
"using": self.using.map(|(slot, ticks)| json!({ "slot": slot, "ticks": ticks })),
|
||||
// The block being dug: how far along, after how many ticks.
|
||||
"mining": self.mining.as_ref().map(|m| json!({ "pos": m.pos, "progress": m.progress, "ticks": m.ticks })),
|
||||
// The arm swings started (LivingEntity.swing).
|
||||
"swings": self.swings_started,
|
||||
"time": { "day_time": self.world.day_time, "day": self.world.day_time.div_euclid(24000), "time_of_day": self.world.day_time.rem_euclid(24000) },
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Agent control as a setting (Horizon Settings > AI Agent): when the control socket is open, who
|
||||
//! is connected, and the details an agent needs. The socket opens when the game was started for
|
||||
//! an agent (`--control`, `--control-socket`: horizon-ctl launch, the launcher's MCP `play`) or
|
||||
//! when the player turned Agent Control on, and never under No AI (`horizon_core::no_ai`).
|
||||
//! Turning either switch in the game opens or closes it at once.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, control};
|
||||
use std::path::PathBuf;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The endpoint: the one the command line gave, else the default horizon-ctl looks for.
|
||||
fn agent_socket_path(&self) -> PathBuf {
|
||||
self.options
|
||||
.control
|
||||
.clone()
|
||||
.unwrap_or_else(control::default_path)
|
||||
}
|
||||
|
||||
/// At startup: open the socket if the game was started for an agent or Agent Control is on.
|
||||
pub(super) fn start_agent_control(&mut self) -> Result<()> {
|
||||
self.agent_control_on = self.settings.extras.agent_control;
|
||||
if self.options.benchmark.is_some() && self.options.control.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.settings.extras.no_ai {
|
||||
if self.options.control.is_some() {
|
||||
log::warn!(
|
||||
"Horizon: control socket not opened: {}",
|
||||
horizon_core::no_ai::REFUSAL
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(path) = &self.options.control {
|
||||
// Asked for on the command line: failing to listen is an error.
|
||||
let server = control::Server::start(path)
|
||||
.map_err(|e| format!("control socket {}: {e}", path.display()))?;
|
||||
log::info!("Horizon: control socket {}", server.path().display());
|
||||
self.control = Some(server);
|
||||
} else if self.agent_control_on {
|
||||
self.open_agent_socket();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The setting's socket: a failure (another game holds the default endpoint) is logged and
|
||||
/// shown on the AI Agent page, not fatal.
|
||||
fn open_agent_socket(&mut self) {
|
||||
let path = self.agent_socket_path();
|
||||
match control::Server::start(&path) {
|
||||
Ok(server) => {
|
||||
log::info!("Horizon: control socket {}", server.path().display());
|
||||
self.control = Some(server);
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: control socket {}: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
/// After a settings change: No AI closes the socket; Agent Control turned on or off opens or
|
||||
/// closes it.
|
||||
pub(super) fn sync_agent_control(&mut self) {
|
||||
let wanted = self.settings.extras.agent_control;
|
||||
let changed = wanted != self.agent_control_on;
|
||||
self.agent_control_on = wanted;
|
||||
let open = self
|
||||
.control
|
||||
.as_ref()
|
||||
.is_some_and(|c| !c.path().as_os_str().is_empty());
|
||||
if self.settings.extras.no_ai || (changed && !wanted) {
|
||||
if open {
|
||||
log::info!("Horizon: control socket closed");
|
||||
self.control = None;
|
||||
}
|
||||
} else if changed && wanted && self.control.is_none() {
|
||||
self.open_agent_socket();
|
||||
}
|
||||
}
|
||||
|
||||
/// An agent's command arrived.
|
||||
pub(super) fn agent_command_seen(&mut self) {
|
||||
self.agent_last = Some(std::time::Instant::now());
|
||||
}
|
||||
|
||||
/// The AI Agent page's lines: the state, then where agents connect.
|
||||
pub(super) fn agent_status_lines(&self) -> Vec<String> {
|
||||
let server = self
|
||||
.control
|
||||
.as_ref()
|
||||
.filter(|c| !c.path().as_os_str().is_empty());
|
||||
let mut lines = vec![match server {
|
||||
Some(s) if s.clients() > 0 => {
|
||||
let ago = self
|
||||
.agent_last
|
||||
.map(|t| format!(", last command {} s ago", t.elapsed().as_secs()))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"Connected: {} agent connection{}{ago}",
|
||||
s.clients(),
|
||||
if s.clients() == 1 { "" } else { "s" }
|
||||
)
|
||||
}
|
||||
Some(_) => "Waiting for an agent.".into(),
|
||||
None => "Off: agents can't reach this game.".into(),
|
||||
}];
|
||||
if let Some(s) = server {
|
||||
if self.options.control.is_some() && !self.agent_control_on {
|
||||
lines.push("Opened for this session by the program that started the game.".into());
|
||||
}
|
||||
// The full path is too wide for the page: Copy Connection Details has it.
|
||||
let name = s.path().file_name().unwrap_or_default().to_string_lossy();
|
||||
lines.push(format!(
|
||||
"Socket: {name} (copy the details for its full path)"
|
||||
));
|
||||
}
|
||||
lines.push("Local only: agents on this computer, never the network.".into());
|
||||
lines
|
||||
}
|
||||
|
||||
/// What Copy Connection Details puts on the clipboard: the socket, a horizon-ctl line to try,
|
||||
/// and an MCP client's config for this game.
|
||||
pub(super) fn agent_details(&self) -> String {
|
||||
let socket = self.agent_socket_path();
|
||||
let ctl = std::env::current_exe()
|
||||
.unwrap_or_default()
|
||||
.with_file_name(if cfg!(windows) {
|
||||
"horizon-ctl.exe"
|
||||
} else {
|
||||
"horizon-ctl"
|
||||
});
|
||||
let config = serde_json::json!({ "mcpServers": { "horizon-game": {
|
||||
"command": ctl, "args": ["--socket", socket, "mcp"] } } });
|
||||
format!(
|
||||
"Horizon agent control (this computer only)\nSocket: {}\nTry it: \"{}\" --socket \"{}\" state\nMCP config:\n{}\n",
|
||||
socket.display(),
|
||||
ctl.display(),
|
||||
socket.display(),
|
||||
serde_json::to_string_pretty(&config).unwrap_or_default()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,13 @@ use horizon_client::{
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Mob, Part, anim::Poses},
|
||||
trims::{TrimOverride, TrimRegistry, TrimTexture},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::RandomSource,
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc};
|
||||
|
||||
/// Armor stand models and the humanoid layers of every equipment asset.
|
||||
pub(super) struct ArmorStandAssets {
|
||||
@@ -25,6 +26,11 @@ pub(super) struct ArmorStandAssets {
|
||||
pub player: HashMap<&'static str, Part>,
|
||||
/// asset id -> layer kind (humanoid / humanoid_leggings) -> (texture, undyed colour).
|
||||
pub equipment: HashMap<String, HashMap<String, (String, Option<u32>)>>,
|
||||
/// asset id -> `trim_overrides`.
|
||||
pub trim_overrides: HashMap<String, Vec<TrimOverride>>,
|
||||
pub trims: TrimRegistry,
|
||||
/// The resources the trim textures are made from.
|
||||
pub root: PathBuf,
|
||||
}
|
||||
impl ArmorStandAssets {
|
||||
pub fn load(
|
||||
@@ -48,6 +54,7 @@ impl ArmorStandAssets {
|
||||
player.insert("elytra", part);
|
||||
}
|
||||
let mut equipment = HashMap::new();
|
||||
let mut trim_overrides = HashMap::new();
|
||||
if let Ok(entries) = std::fs::read_dir(root.join("assets/minecraft/equipment")) {
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
@@ -76,6 +83,7 @@ impl ArmorStandAssets {
|
||||
);
|
||||
}
|
||||
}
|
||||
trim_overrides.insert(format!("minecraft:{name}"), TrimOverride::list(&v));
|
||||
equipment.insert(format!("minecraft:{name}"), kinds);
|
||||
}
|
||||
}
|
||||
@@ -85,30 +93,67 @@ impl ArmorStandAssets {
|
||||
layers,
|
||||
player,
|
||||
equipment,
|
||||
trim_overrides,
|
||||
trims: TrimRegistry::load(root),
|
||||
root: root.to_owned(),
|
||||
}
|
||||
}
|
||||
/// Trim data of reloaded resources (the models stay).
|
||||
pub fn reload_trims(&mut self, root: &std::path::Path) {
|
||||
let fresh = Self::load(root, None);
|
||||
self.trim_overrides = fresh.trim_overrides;
|
||||
self.trims = fresh.trims;
|
||||
self.root = fresh.root;
|
||||
}
|
||||
/// `TrimTextureKey`: the trim texture of `stack` on its humanoid
|
||||
/// layer (`humanoid` or `humanoid_leggings`), if it has a trim.
|
||||
fn trim_texture(&self, asset: &str, kind: &str, stack: &ItemStack) -> Option<TrimTexture> {
|
||||
let (material, pattern) = stack.extra.as_ref()?.trim.as_ref()?;
|
||||
let overrides = self
|
||||
.trim_overrides
|
||||
.get(asset)
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
TrimTexture::resolve(&self.trims, overrides, material, pattern, kind)
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `HumanoidArmorLayer` for the player: (model, texture rect, colour) of
|
||||
/// each worn piece, head to feet.
|
||||
pub(super) fn player_armor_layers(&self) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
self.armor_layers(self.armor.each_ref().map(|s| s.as_ref().map(|s| &*s.item)))
|
||||
self.stack_armor_layers(&self.armor)
|
||||
}
|
||||
/// `HumanoidArmorLayer` for worn stacks (head to feet), trims included.
|
||||
pub(super) fn stack_armor_layers(
|
||||
&self,
|
||||
armor: &[Option<ItemStack>; 4],
|
||||
) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
self.armor_layers(
|
||||
armor
|
||||
.each_ref()
|
||||
.map(|s| s.as_ref().map(|s| (&*s.item, Some(s)))),
|
||||
)
|
||||
}
|
||||
/// `HumanoidArmorLayer` for another player's model: the armour it wears
|
||||
/// (`extra.head` .. `extra.feet`).
|
||||
pub(super) fn worn_armor_layers(&self, m: &Mob) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
let e = &m.extra;
|
||||
self.armor_layers([&e.head, &e.chest, &e.legs, &e.feet].map(|s| s.as_deref()))
|
||||
self.armor_layers(
|
||||
[&e.head, &e.chest, &e.legs, &e.feet].map(|s| s.as_deref().map(|i| (i, None))),
|
||||
)
|
||||
}
|
||||
/// The layers of the armour items `worn`, head to feet.
|
||||
fn armor_layers(&self, worn: [Option<&str>; 4]) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
/// The layers of the armour items `worn` (with their stacks when known,
|
||||
/// for trims), head to feet.
|
||||
fn armor_layers(
|
||||
&self,
|
||||
worn: [Option<(&str, Option<&ItemStack>)>; 4],
|
||||
) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
let assets = &self.armor_stand_assets;
|
||||
let mut out = Vec::new();
|
||||
for (item, layer) in worn
|
||||
.into_iter()
|
||||
.zip(["helmet", "chestplate", "leggings", "boots"])
|
||||
{
|
||||
let Some(item) = item else {
|
||||
let Some((item, stack)) = item else {
|
||||
continue;
|
||||
};
|
||||
let Some(asset) = self
|
||||
@@ -138,9 +183,117 @@ impl Game<'_> {
|
||||
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
|
||||
});
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], color));
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
if let Some(r) = stack
|
||||
.and_then(|s| assets.trim_texture(&asset, kind, s))
|
||||
.and_then(|t| self.gui.atlas.sprites.get(&t.sprite()))
|
||||
{
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], 0xFFFF_FFFF));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `PalettedTextureManager.getOrPrepare` for the trims drawn this frame
|
||||
/// (the player's armour and the armour stands'): each generated texture
|
||||
/// goes into an atlas trim slot, a free one, else the least recently
|
||||
/// drawn.
|
||||
pub(super) fn prepare_trims(&mut self) {
|
||||
use horizon_client::hud::TRIM_SLOTS;
|
||||
let assets = &self.armor_stand_assets;
|
||||
let items = self.res.items();
|
||||
let mut wanted: Vec<TrimTexture> = Vec::new();
|
||||
let guests = self.lan.iter().flat_map(|host| host.hub.guests.iter());
|
||||
let stacks = self
|
||||
.armor
|
||||
.iter()
|
||||
.chain(guests.flat_map(|g| g.armor.iter()))
|
||||
.zip(
|
||||
["helmet", "chestplate", "leggings", "boots"]
|
||||
.into_iter()
|
||||
.cycle(),
|
||||
)
|
||||
.chain(self.armor_stands.iter().flat_map(|a| {
|
||||
[5usize, 4, 3, 2]
|
||||
.map(|slot| &a.equipment[slot])
|
||||
.into_iter()
|
||||
.zip(["helmet", "chestplate", "leggings", "boots"])
|
||||
}));
|
||||
for (stack, layer) in stacks {
|
||||
let Some(stack) = stack.as_ref().filter(|s| s.extra.is_some()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(asset) = items
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.equip_asset.as_deref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let kind = if layer == "leggings" {
|
||||
"humanoid_leggings"
|
||||
} else {
|
||||
"humanoid"
|
||||
};
|
||||
if let Some(t) = assets.trim_texture(asset, kind, stack)
|
||||
&& !wanted.contains(&t)
|
||||
{
|
||||
wanted.push(t);
|
||||
}
|
||||
}
|
||||
for t in wanted {
|
||||
let name = t.sprite();
|
||||
// Entries the atlas no longer has (rebuilt by a resource reload) are free.
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
self.trim_slots.retain(|(n, _)| sprites.contains_key(n));
|
||||
if let Some(i) = self.trim_slots.iter().position(|e| e.0 == name) {
|
||||
let e = self.trim_slots.remove(i);
|
||||
self.trim_slots.push(e);
|
||||
continue;
|
||||
}
|
||||
let Some((w, h, rgba)) = t.generate(&self.armor_stand_assets.root) else {
|
||||
continue;
|
||||
};
|
||||
let used: Vec<usize> = self.trim_slots.iter().map(|e| e.1).collect();
|
||||
let slot = match (0..TRIM_SLOTS).find(|i| !used.contains(i)) {
|
||||
Some(free) => free,
|
||||
None => {
|
||||
let (old, slot) = self.trim_slots.remove(0);
|
||||
self.gui.atlas.sprites.remove(&old);
|
||||
slot
|
||||
}
|
||||
};
|
||||
let Some(area) = self
|
||||
.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.get(&format!("horizon/trim{slot}"))
|
||||
.copied()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if w > area.w || h > area.h {
|
||||
continue;
|
||||
}
|
||||
let (aw, ah) = (self.gui.atlas.width as f32, self.gui.atlas.height as f32);
|
||||
let (x, y) = ((area.u0 * aw).round(), (area.v0 * ah).round());
|
||||
self.map_patches.push(horizon_client::font::Patch {
|
||||
x: x as u32,
|
||||
y: y as u32,
|
||||
width: w,
|
||||
height: h,
|
||||
rgba,
|
||||
});
|
||||
let rect = horizon_client::hud::Rect {
|
||||
u0: area.u0,
|
||||
v0: area.v0,
|
||||
u1: (x + w as f32) / aw,
|
||||
v1: (y + h as f32) / ah,
|
||||
w,
|
||||
h,
|
||||
};
|
||||
self.gui.atlas.sprites.insert(name.clone(), rect);
|
||||
self.trim_slots.push((name, slot));
|
||||
}
|
||||
}
|
||||
/// `ArmorStandItem.useOn`.
|
||||
pub(super) fn place_armor_stand(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:armor_stand" || hit.face == [0, -1, 0] {
|
||||
@@ -564,6 +717,22 @@ impl Game<'_> {
|
||||
0,
|
||||
out,
|
||||
);
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
if let Some(trim) = assets
|
||||
.trim_texture(&asset, kind, stack)
|
||||
.and_then(|t| uv_of(&t.sprite()))
|
||||
{
|
||||
horizon_client::mobs::render::draw(
|
||||
part,
|
||||
base,
|
||||
&layer_poses,
|
||||
trim,
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
|
||||
@@ -294,6 +294,18 @@ impl Game<'_> {
|
||||
}
|
||||
// ---- entities -------------------------------------------------------
|
||||
|
||||
/// The level `t` is in (`Entity.level`): a server's players have their
|
||||
/// own, which need not be the one in place; everything else is in the
|
||||
/// level in place.
|
||||
pub(super) fn target_level(&self, t: Target) -> Dim {
|
||||
let conn = match t {
|
||||
Target::Player => self.lan_acting,
|
||||
Target::Guest(conn) => Some(conn),
|
||||
_ => None,
|
||||
};
|
||||
conn.and_then(|c| self.command_guest(c))
|
||||
.map_or(self.dim, |g| Dim::from_id(&g.dimension))
|
||||
}
|
||||
pub(super) fn target_position(&self, t: Target) -> Option<[f64; 3]> {
|
||||
match t {
|
||||
Target::Player => self
|
||||
@@ -1191,15 +1203,12 @@ impl Game<'_> {
|
||||
pos: [f64; 3],
|
||||
rotation: Option<[f32; 2]>,
|
||||
) -> bool {
|
||||
let (conn, at) = match t {
|
||||
Target::Player => (self.lan_acting, self.dim),
|
||||
Target::Guest(conn) => (
|
||||
Some(conn),
|
||||
self.command_guest(conn)
|
||||
.map_or(self.dim, |g| super::Dim::from_id(&g.dimension)),
|
||||
),
|
||||
_ => (None, self.dim),
|
||||
let conn = match t {
|
||||
Target::Player => self.lan_acting,
|
||||
Target::Guest(conn) => Some(conn),
|
||||
_ => None,
|
||||
};
|
||||
let at = self.target_level(t);
|
||||
let Some(conn) = conn.filter(|_| at != level && self.options.dedicated.is_some()) else {
|
||||
return false;
|
||||
};
|
||||
@@ -1222,12 +1231,7 @@ impl Game<'_> {
|
||||
let dest = self.entities(ctx, "destination", false)?[0];
|
||||
let pos = self.target_position(dest).unwrap_or(self.source_position());
|
||||
// Into the destination's level.
|
||||
let level = match dest {
|
||||
Target::Guest(conn) => self
|
||||
.command_guest(conn)
|
||||
.map_or(self.dim, |g| super::Dim::from_id(&g.dimension)),
|
||||
_ => self.dim,
|
||||
};
|
||||
let level = self.target_level(dest);
|
||||
// TeleportCommand.teleportToEntity: the destination's rotation too.
|
||||
let rotation = Some(self.target_rotation(dest));
|
||||
for &t in &targets {
|
||||
@@ -2281,12 +2285,23 @@ impl Game<'_> {
|
||||
};
|
||||
let yaw = ((rot[0] + 180.0).rem_euclid(360.0)) - 180.0;
|
||||
let pitch = rot[1].clamp(-90.0, 90.0);
|
||||
self.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
));
|
||||
self.respawn_config = None;
|
||||
// SetSpawnCommand: a forced point in the source's level.
|
||||
if self.dim == Dim::Overworld {
|
||||
self.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
));
|
||||
self.respawn_config = None;
|
||||
} else {
|
||||
self.spawn = None;
|
||||
self.respawn_config = Some(super::respawn_anchor::RespawnConfig {
|
||||
dim: self.dim,
|
||||
pos,
|
||||
yaw,
|
||||
forced: true,
|
||||
});
|
||||
}
|
||||
let dim = Text::literal(self.dim.id());
|
||||
let p = pos.map(|v| Text::literal(v.to_string()));
|
||||
let mut args = vec![
|
||||
@@ -2322,7 +2337,20 @@ impl Game<'_> {
|
||||
Some(Arg::Coords(c)) => c.rotation(self.source_rotation()),
|
||||
_ => [0.0, 0.0],
|
||||
};
|
||||
self.level.spawn = Some([f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])]);
|
||||
// LevelData.setRespawnData: the world's spawn (level.dat `spawn`).
|
||||
let old = self.level.world_spawn.replace(pos);
|
||||
self.level.world_spawn_angle = rot;
|
||||
// A player whose fallback is the world spawn (no spawnpoint of its
|
||||
// own; a bed or an anchor that is gone falls back to it) respawns at
|
||||
// the new one.
|
||||
let floor = |s: Vec3| [s.x.floor() as i32, s.y.floor() as i32, s.z.floor() as i32];
|
||||
if self.spawn.map(floor) == old {
|
||||
self.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
));
|
||||
}
|
||||
let args = [
|
||||
Text::literal(pos[0].to_string()),
|
||||
Text::literal(pos[1].to_string()),
|
||||
|
||||
@@ -155,14 +155,20 @@ impl Game<'_> {
|
||||
_ => s.pos,
|
||||
}
|
||||
}
|
||||
/// Evaluate `f` with `source` as the command source.
|
||||
/// Evaluate `f` with `source` as the command source, in the source's
|
||||
/// level (`CommandSourceStack.getLevel`): a server puts that level in
|
||||
/// place for it.
|
||||
pub(in crate::game) fn with_source<T>(
|
||||
&mut self,
|
||||
source: Source,
|
||||
f: impl FnOnce(&mut Self) -> T,
|
||||
) -> T {
|
||||
let previous = self.command_source.replace(source);
|
||||
let back = self.enter_command_level(source.dimension);
|
||||
let out = f(self);
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
self.command_source = previous;
|
||||
out
|
||||
}
|
||||
@@ -447,11 +453,13 @@ impl Game<'_> {
|
||||
"at" => targets(self)?
|
||||
.into_iter()
|
||||
.filter_map(|t| {
|
||||
// CommandSourceStack.withLevel(entity.level()).
|
||||
let level = self.target_level(t);
|
||||
Some(Source {
|
||||
pos: self.target_position(t)?,
|
||||
rot: self.target_rotation(t),
|
||||
dimension: self.dim,
|
||||
level: None,
|
||||
dimension: level,
|
||||
level: (level != self.dim).then_some(level),
|
||||
..s
|
||||
})
|
||||
})
|
||||
|
||||
@@ -810,7 +810,10 @@ impl Game<'_> {
|
||||
None => break,
|
||||
},
|
||||
};
|
||||
if self.slot_get(slot) != item && self.slot_set(slot, item) {
|
||||
// ItemStack.isEmpty: air (or a count of 0) is an empty slot.
|
||||
// SlotAccess.set counts every slot it sets, the same item too.
|
||||
let item = item.filter(|s| &*s.item != "minecraft:air" && s.count > 0);
|
||||
if self.slot_set(slot, item) {
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ const HELP: &[&str] = &[
|
||||
"chat MESSAGE send chat or a /command to the server",
|
||||
"disconnect leave the server for the server list",
|
||||
"respawn respawn after death on a server",
|
||||
"credits [poem] [SCROLL] roll the credits (with the end poem), or hold them SCROLL pixels in",
|
||||
"quit save and exit",
|
||||
];
|
||||
|
||||
@@ -117,6 +118,7 @@ impl Game<'_> {
|
||||
self.answer(request, event_loop);
|
||||
}
|
||||
while let Some(request) = self.control.as_ref().and_then(|c| c.try_recv()) {
|
||||
self.agent_command_seen();
|
||||
self.answer(request, event_loop);
|
||||
if self.pending_save.is_some() {
|
||||
break;
|
||||
@@ -125,6 +127,11 @@ impl Game<'_> {
|
||||
if self.control.is_some() {
|
||||
self.watch_events();
|
||||
}
|
||||
if self.screen == Screen::Options
|
||||
&& self.ui.options.page() == horizon_client::options_ui::Page::Agent
|
||||
{
|
||||
self.ui.options.agent_status = self.agent_status_lines();
|
||||
}
|
||||
}
|
||||
|
||||
fn answer(&mut self, request: horizon_client::control::Request, event_loop: &ActiveEventLoop) {
|
||||
@@ -568,7 +575,9 @@ impl Game<'_> {
|
||||
"graphics" => Page::Graphics,
|
||||
"graphics-advanced" => Page::GraphicsAdvanced,
|
||||
"extras" => Page::Extras,
|
||||
"credits" => Page::Credits,
|
||||
"horizon" => Page::Horizon,
|
||||
"agent" => Page::Agent,
|
||||
"shader-packs" => Page::ShaderPacks,
|
||||
other => return Err(format!("unknown options page {other}").into()),
|
||||
};
|
||||
@@ -645,6 +654,25 @@ impl Game<'_> {
|
||||
self.connect_server(arg(1)?, &name)?;
|
||||
ok()
|
||||
}
|
||||
"credits" => {
|
||||
let poem = words.get(1) == Some(&"poem");
|
||||
let hold = words
|
||||
.get(1 + usize::from(poem))
|
||||
.map(|s| s.parse::<f32>())
|
||||
.transpose()?;
|
||||
if self.screen != Screen::Win {
|
||||
let finish = if self.screen == Screen::Options {
|
||||
super::win::Finish::Options
|
||||
} else {
|
||||
super::win::Finish::Closed
|
||||
};
|
||||
self.open_win_screen(poem, finish);
|
||||
}
|
||||
if let Some(scroll) = hold {
|
||||
self.hold_win_screen(scroll);
|
||||
}
|
||||
ok()
|
||||
}
|
||||
"respawn" => {
|
||||
if self.remote.is_none() || self.screen != Screen::Dead {
|
||||
return Err("respawn needs the death screen on a server".into());
|
||||
@@ -727,6 +755,9 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
fn screen_name(&self) -> &'static str {
|
||||
if self.screen == Screen::Win {
|
||||
return "win";
|
||||
}
|
||||
if self.title.is_some() {
|
||||
return "title";
|
||||
}
|
||||
@@ -744,6 +775,7 @@ impl Game<'_> {
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -854,6 +886,8 @@ impl Game<'_> {
|
||||
"source_attempted": self.lod_source_attempted,
|
||||
"active_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().0),
|
||||
"cached_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().1),
|
||||
"far_detail": self.settings.horizon.far_detail.name(),
|
||||
"resident_quads": self.renderer.as_ref().map_or(0, |r| r.lod_quads()),
|
||||
"pending_observations": self.lod_observe.len(),
|
||||
"refined_samples": self.lod.as_ref().map_or(0, |lod| lod.refined_samples()),
|
||||
});
|
||||
|
||||
@@ -343,8 +343,12 @@ impl Game<'_> {
|
||||
"on_ground": m.on_ground,
|
||||
"variant": m.extra.variant,
|
||||
"color": m.extra.color,
|
||||
"sheared": m.extra.sheared,
|
||||
"burning": m.fire_ticks > 0,
|
||||
"size": m.extra.size,
|
||||
"leash": m.extra.leash.map(|h| format!("{h:?}")),
|
||||
"tame": m.extra.tame,
|
||||
"sitting": m.extra.sitting,
|
||||
"temper": m.extra.variant3,
|
||||
"chested": m.extra.chested,
|
||||
"head": m.extra.head.as_deref(),
|
||||
|
||||
@@ -291,12 +291,12 @@ impl Game<'_> {
|
||||
used = self.fixture_use_item(origin, op)?;
|
||||
} else if let Some(op) = case.get("respawn_at") {
|
||||
// ServerPlayer.findRespawnAndUseSpawnBlock for a respawn point at the origin.
|
||||
let config = super::respawn_anchor::RespawnConfig {
|
||||
dim: self.dim,
|
||||
pos: [origin.x, origin.y, origin.z],
|
||||
yaw: 0.0,
|
||||
forced: op["forced"].as_bool().unwrap_or(false),
|
||||
};
|
||||
let config = super::respawn_anchor::RespawnConfig::wrapped(
|
||||
self.dim,
|
||||
[origin.x, origin.y, origin.z],
|
||||
op["yaw"].as_f64().map_or(0.0, |v| v as f32),
|
||||
op["forced"].as_bool().unwrap_or(false),
|
||||
);
|
||||
let found =
|
||||
self.find_respawn_and_use(config, op["consume"].as_bool().unwrap_or(true))?;
|
||||
used.push(match found {
|
||||
|
||||
@@ -2697,9 +2697,12 @@ impl Game<'_> {
|
||||
velocity: [d.velocity.x, d.velocity.y, d.velocity.z],
|
||||
data: 0,
|
||||
meta,
|
||||
defaults: Vec::new(),
|
||||
equipment: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
precise: false,
|
||||
needs_sync: false,
|
||||
leash: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,15 @@ use super::Game;
|
||||
use horizon_client::{enchant::EffectContext, items::ItemStack};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
|
||||
#[derive(Clone, Copy)]
|
||||
enum MendSlot {
|
||||
Main,
|
||||
Off,
|
||||
/// An `armor` index (head first).
|
||||
Armor(usize),
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Player.getDestroySpeed` after the tool speed: mining efficiency, haste
|
||||
/// and mining fatigue.
|
||||
@@ -314,36 +323,29 @@ impl Game<'_> {
|
||||
pub(super) fn mending(&mut self, amount: u32) -> u32 {
|
||||
let mut amount = amount;
|
||||
loop {
|
||||
// EnchantmentHelper.getRandomItemWith(REPAIR_WITH_XP, damaged): held and armor.
|
||||
let mut candidates: Vec<(bool, usize)> = Vec::new();
|
||||
if let Some(s) = &self.inventory[self.selected]
|
||||
&& s.damage > 0
|
||||
&& s.enchantment_level("minecraft:mending") > 0
|
||||
{
|
||||
candidates.push((false, self.selected));
|
||||
}
|
||||
for (i, s) in self.armor.iter().enumerate() {
|
||||
if let Some(s) = s
|
||||
&& s.damage > 0
|
||||
&& s.enchantment_level("minecraft:mending") > 0
|
||||
{
|
||||
candidates.push((true, i));
|
||||
}
|
||||
}
|
||||
// EnchantmentHelper.getRandomItemWith(REPAIR_WITH_XP, damaged), in
|
||||
// EquipmentSlot order: the main hand, the off hand, then feet, legs,
|
||||
// chest and head (`armor` holds head first).
|
||||
let mending = |s: &Option<ItemStack>| {
|
||||
s.as_ref()
|
||||
.is_some_and(|s| s.damage > 0 && s.enchantment_level("minecraft:mending") > 0)
|
||||
};
|
||||
let candidates: Vec<MendSlot> = [MendSlot::Main, MendSlot::Off]
|
||||
.into_iter()
|
||||
.chain((0..self.armor.len()).rev().map(MendSlot::Armor))
|
||||
.filter(|&slot| mending(self.mend_slot(slot)))
|
||||
.collect();
|
||||
if candidates.is_empty() {
|
||||
return amount;
|
||||
}
|
||||
let (armor, i) = candidates[self
|
||||
let slot = candidates[self
|
||||
.world
|
||||
.random
|
||||
.next_int_bound(candidates.len() as i32)
|
||||
.unwrap_or(0) as usize];
|
||||
let stack = if armor {
|
||||
self.armor[i].clone()
|
||||
} else {
|
||||
self.inventory[i].clone()
|
||||
let Some(stack) = self.mend_slot(slot).clone() else {
|
||||
return amount;
|
||||
};
|
||||
let Some(stack) = stack else { return amount };
|
||||
let ctx = EffectContext {
|
||||
items: Some(self.res.items()),
|
||||
..Default::default()
|
||||
@@ -356,12 +358,7 @@ impl Game<'_> {
|
||||
amount as f32,
|
||||
) as u32;
|
||||
let repaired = to_repair_xp.min(stack.damage);
|
||||
let slot = if armor {
|
||||
&mut self.armor[i]
|
||||
} else {
|
||||
&mut self.inventory[i]
|
||||
};
|
||||
if let Some(s) = slot.as_mut() {
|
||||
if let Some(s) = self.mend_slot_mut(slot).as_mut() {
|
||||
s.damage -= repaired;
|
||||
}
|
||||
if repaired == 0 || to_repair_xp == 0 {
|
||||
@@ -374,6 +371,20 @@ impl Game<'_> {
|
||||
amount = remaining;
|
||||
}
|
||||
}
|
||||
fn mend_slot(&self, slot: MendSlot) -> &Option<ItemStack> {
|
||||
match slot {
|
||||
MendSlot::Main => &self.inventory[self.selected],
|
||||
MendSlot::Off => &self.offhand,
|
||||
MendSlot::Armor(i) => &self.armor[i],
|
||||
}
|
||||
}
|
||||
fn mend_slot_mut(&mut self, slot: MendSlot) -> &mut Option<ItemStack> {
|
||||
match slot {
|
||||
MendSlot::Main => &mut self.inventory[self.selected],
|
||||
MendSlot::Off => &mut self.offhand,
|
||||
MendSlot::Armor(i) => &mut self.armor[i],
|
||||
}
|
||||
}
|
||||
/// Bow enchantments at release: (extra base damage, infinity keeps the arrow).
|
||||
pub(super) fn bow_enchantments(&mut self) -> (f64, bool) {
|
||||
let Some(bow) = self.inventory[self.selected].clone() else {
|
||||
|
||||
@@ -23,7 +23,7 @@ const GATEWAY_FILE: &str = "horizon-end-gateways.json";
|
||||
|
||||
/// A gateway teleport waiting for its destination chunks.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum GatewayJob {
|
||||
pub(super) enum GatewayJob {
|
||||
/// TheEndGatewayBlockEntity.findOrCreateValidTeleportPos from this gateway.
|
||||
Find { gateway: [i32; 3] },
|
||||
/// A known exit (exact, or findExitPosition around it).
|
||||
@@ -342,7 +342,10 @@ impl Game<'_> {
|
||||
f64::from(p.dimensions.width),
|
||||
f64::from(p.dimensions.height),
|
||||
);
|
||||
if portal {
|
||||
if portal && self.dim == Dim::End && !self.level.seen_credits {
|
||||
// EndPortalBlock.entityInside: the first exit shows the credits.
|
||||
self.show_end_credits()?;
|
||||
} else if portal {
|
||||
if self.portals.player.cooldown == 0 {
|
||||
let target = if self.dim == Dim::End {
|
||||
Dim::Overworld
|
||||
@@ -413,7 +416,15 @@ impl Game<'_> {
|
||||
f64::from(END_SPAWN[2]) + 0.5,
|
||||
));
|
||||
}
|
||||
// Leaving the End: the respawn position (the world spawn here).
|
||||
// Leaving the End: the respawn position, a bed's found once it has
|
||||
// loaded (findRespawnPositionAndUseSpawnBlock, not using it up).
|
||||
if let Some(c) = self.respawn_config.filter(|c| c.dim == target) {
|
||||
return Some(Vec3::new(
|
||||
f64::from(c.pos[0]) + 0.5,
|
||||
f64::from(c.pos[1]),
|
||||
f64::from(c.pos[2]) + 0.5,
|
||||
));
|
||||
}
|
||||
let s = self.level.spawn.unwrap_or([0.0, 64.0, 0.0]);
|
||||
Some(Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5))
|
||||
}
|
||||
@@ -484,7 +495,16 @@ impl Game<'_> {
|
||||
// Direction.WEST.toYRot().
|
||||
return Ok((target, Some(90.0)));
|
||||
}
|
||||
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: onto the spawn column.
|
||||
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: where the bed or
|
||||
// anchor puts the player, facing it; else onto the spawn column.
|
||||
let mut target = target;
|
||||
if let Some(c) = self.respawn_config.filter(|c| c.dim == self.dim) {
|
||||
if let Some((at, yaw)) = self.find_respawn_and_use(c, false)? {
|
||||
return Ok((at, Some(yaw)));
|
||||
}
|
||||
let s = self.level.spawn.unwrap_or([0.0, 64.0, 0.0]);
|
||||
target = Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5);
|
||||
}
|
||||
let y = self
|
||||
.land_column(target.x.floor() as i32, target.z.floor() as i32)
|
||||
.map_or(target.y, f64::from);
|
||||
@@ -700,6 +720,20 @@ impl Game<'_> {
|
||||
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
|
||||
// TheEndGatewayBlockEntity.triggerCooldown: 40 ticks.
|
||||
self.end.gateway_cooldown = 40;
|
||||
let (job, destination) = self.gateway_job(gateway);
|
||||
let Some(player) = self.player.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
self.end.job = Some(job);
|
||||
self.end_travel = true;
|
||||
self.travelling = Some((player, destination));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where `gateway` leads (TheEndGatewayBlockEntity.getPortalPosition):
|
||||
/// 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 known = self.gateway_exits().get(&gateway).copied();
|
||||
let job = match known {
|
||||
Some((exit, exact)) => GatewayJob::Known { exit, exact },
|
||||
@@ -725,13 +759,7 @@ impl Game<'_> {
|
||||
Vec3::new(d.x, 75.0, d.y)
|
||||
}
|
||||
};
|
||||
let Some(player) = self.player.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
self.end.job = Some(job);
|
||||
self.end_travel = true;
|
||||
self.travelling = Some((player, destination));
|
||||
Ok(())
|
||||
(job, destination)
|
||||
}
|
||||
|
||||
fn chunk_empty(&self, x: f64, z: f64) -> Option<bool> {
|
||||
@@ -768,7 +796,7 @@ impl Game<'_> {
|
||||
|
||||
/// The teleport position of a gateway job, creating the exit gateway (and
|
||||
/// an island when the chunk has no landing spot) for new destinations.
|
||||
fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
|
||||
pub(super) fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
|
||||
let bottom_center = |p: [i32; 3]| {
|
||||
Vec3::new(
|
||||
f64::from(p[0]) + 0.5,
|
||||
|
||||
@@ -224,7 +224,7 @@ impl Game<'_> {
|
||||
}
|
||||
set.entries().map(|(k, _)| k.0).collect()
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT);
|
||||
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT, minecarts);
|
||||
/// the player takes `damage_type`.
|
||||
/// `ServerExplosion.hurtEntities` for the player: damage scaled by
|
||||
/// distance and exposure, through a raised shield (`hurtServer` blocks
|
||||
@@ -460,6 +460,34 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
self.tnts = tnts;
|
||||
// Minecarts: VehicleEntity.hurtServer (explosions ignite TNT carts),
|
||||
// then the push from their eyes (0.85 of their height).
|
||||
let ids: Vec<u32> = self.carts.iter().map(|c| c.id).collect();
|
||||
for id in ids {
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let bounds = self.carts[i].bounds();
|
||||
let feet = self.carts[i].position;
|
||||
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
self.hurt_cart(i, damage(dist, exposure), false, true);
|
||||
let Some(c) = self.carts.iter_mut().find(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let eye_height = f64::from((hi.y - lo.y) as f32 * 0.85f32);
|
||||
let dir = direction([feet.x, feet.y + eye_height, feet.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
c.velocity = Vec3::new(
|
||||
c.velocity.x + dir[0] * power,
|
||||
c.velocity.y + dir[1] * power,
|
||||
c.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `Level.explode` on the server: positions, entities, blocks, fire; then
|
||||
/// the client's explosion sound.
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
//! the world's options) with [`Integrated::call`], as vanilla's client calls
|
||||
//! into its `IntegratedServer`.
|
||||
//!
|
||||
//! On by default. `--no-server-thread` or `HORIZON_SERVER_THREAD=0` runs
|
||||
//! the world in the frame instead (the escape hatch while the network
|
||||
//! player's side catches up with the in-frame one).
|
||||
//! Opt-in again (`--server-thread` or `HORIZON_SERVER_THREAD=1`) until a
|
||||
//! server's players reach parity with the in-frame world: on the server
|
||||
//! thread, signs open no editor, books don't open, a totem doesn't save its
|
||||
//! holder and death keeps the inventory (#1380, #1366, #1292, #1284 and the
|
||||
//! other `[server-thread]` issues). LAN guests and dedicated servers use the
|
||||
//! same code either way.
|
||||
use super::{
|
||||
Game, Options, PackResources, Resources, UiResources,
|
||||
lan::{LanSettings, Owner},
|
||||
|
||||
@@ -14,7 +14,7 @@ use horizon_client::{
|
||||
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
playerdata,
|
||||
},
|
||||
mobs::Mob,
|
||||
mobs::{Mob, leash::Holder},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
@@ -91,7 +91,7 @@ pub(super) fn entity_uuid(id: u32) -> u128 {
|
||||
/// The SynchedEntityData index of the baby flag: AgeableMob.DATA_BABY_ID and
|
||||
/// Zombie.DATA_BABY_ID are both the first entry after Mob's (16); Piglin's
|
||||
/// comes after AbstractPiglin's zombification flag (17).
|
||||
fn baby_index(kind: &str) -> Option<u8> {
|
||||
pub(super) fn baby_index(kind: &str) -> Option<u8> {
|
||||
match kind {
|
||||
"minecraft:piglin" => Some(17),
|
||||
"minecraft:cow"
|
||||
@@ -139,7 +139,13 @@ fn baby_index(kind: &str) -> Option<u8> {
|
||||
/// A mob's synched data as ServerEntity sends it (only the values that differ
|
||||
/// from the defaults the client constructs), with vanilla's entry indices:
|
||||
/// Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses.
|
||||
fn mob_meta(m: &Mob, hub: &Hub, meta: &mut Vec<(u8, i32, Value)>) {
|
||||
/// `defaults` gets the defaults of the entries that can go back to theirs.
|
||||
fn mob_meta(
|
||||
m: &Mob,
|
||||
hub: &Hub,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
let kind = m.kind.id.as_str();
|
||||
if m.fire_ticks > 0 {
|
||||
meta.push((0, serializer::BYTE, Value::Byte(1)));
|
||||
@@ -159,15 +165,18 @@ fn mob_meta(m: &Mob, hub: &Hub, meta: &mut Vec<(u8, i32, Value)>) {
|
||||
if health != 1.0 {
|
||||
meta.push((9, serializer::FLOAT, Value::Float(health)));
|
||||
}
|
||||
defaults.push((9, serializer::FLOAT, Value::Float(1.0)));
|
||||
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2.
|
||||
let flags = i8::from(m.no_ai) | i8::from(m.extra.left_handed) << 1;
|
||||
if flags != 0 {
|
||||
meta.push((15, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
if m.is_baby()
|
||||
&& let Some(index) = baby_index(kind)
|
||||
{
|
||||
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
|
||||
// AgeableMob.DATA_BABY_ID (and Zombie's, Piglin's).
|
||||
if let Some(index) = baby_index(kind) {
|
||||
if m.is_baby() {
|
||||
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((index, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
let e = &m.extra;
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
@@ -178,6 +187,7 @@ fn mob_meta(m: &Mob, hub: &Hub, meta: &mut Vec<(u8, i32, Value)>) {
|
||||
if value != 0 {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(value as i8)));
|
||||
}
|
||||
defaults.push((18, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
"minecraft:cat" | "minecraft:wolf" => {
|
||||
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
|
||||
@@ -200,6 +210,13 @@ fn mob_meta(m: &Mob, hub: &Hub, meta: &mut Vec<(u8, i32, Value)>) {
|
||||
if let Some(v) = id(registry, name) {
|
||||
meta.push((index, serializer, Value::Id(v)));
|
||||
}
|
||||
// Wolf.DATA_COLLAR_COLOR and Cat's, red by default.
|
||||
let collar = if kind == "minecraft:cat" { 23 } else { 21 };
|
||||
let red = i32::from(horizon_client::mobs::tame::RED);
|
||||
if i32::from(e.color & 15) != red {
|
||||
meta.push((collar, serializer::INT, Value::Int(i32::from(e.color & 15))));
|
||||
}
|
||||
defaults.push((collar, serializer::INT, Value::Int(red)));
|
||||
}
|
||||
"minecraft:horse"
|
||||
| "minecraft:donkey"
|
||||
@@ -390,9 +407,14 @@ impl Game<'_> {
|
||||
"placed": g.placed,
|
||||
"loaded": g.loaded,
|
||||
"game_mode": g.game_mode,
|
||||
// ItemCooldowns: the ticks the shield an axe disabled stays down.
|
||||
"shield_cooldown": h.play.get(&g.conn).map_or(0, |p| p.shield_cooldown),
|
||||
"sneaking": g.sneaking,
|
||||
"sleeping": g.bed,
|
||||
"health": g.health,
|
||||
// The server's body: its air supply and golden hearts.
|
||||
"air": g.motion.as_ref().map(|m| m.air),
|
||||
"absorption": g.motion.as_ref().map(|m| m.health.absorption()),
|
||||
// FoodData: food level, saturation and exhaustion.
|
||||
"food": [
|
||||
f64::from(g.food),
|
||||
@@ -752,17 +774,37 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// The entities guests track: mobs, items and the rest (game/lan_entities.rs).
|
||||
fn lan_entities(&self, host_entity: Option<i32>, hub: &mut Hub, out: &mut States) {
|
||||
/// `mob_players` is the guest each mob sees as its player (game/lan_mobs.rs).
|
||||
fn lan_entities(
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
mob_players: &HashMap<u32, u32>,
|
||||
hub: &mut Hub,
|
||||
out: &mut States,
|
||||
) {
|
||||
out.clear();
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
// The lead's holder: the player the mob sees (a guest, else the
|
||||
// host) or another mob. A fence knot isn't an entity here.
|
||||
let leash = m.extra.leash.and_then(|h| match h {
|
||||
Holder::Player => mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id)
|
||||
.or(host_entity),
|
||||
Holder::Mob(id) => Some(id as i32),
|
||||
Holder::Knot(_) => None,
|
||||
});
|
||||
let e = out.add(m.id as i32, entity_uuid(m.id), m.kind.id.as_str());
|
||||
e.position = [m.position.x, m.position.y, m.position.z];
|
||||
(e.yaw, e.pitch, e.head_yaw) = (m.yaw, m.head_pitch, m.head_yaw);
|
||||
e.on_ground = m.on_ground;
|
||||
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
|
||||
mob_meta(m, hub, &mut e.meta);
|
||||
e.needs_sync = m.needs_sync;
|
||||
mob_meta(m, hub, &mut e.meta, &mut e.defaults);
|
||||
mob_equipment(m, hub, &mut e.equipment);
|
||||
self.mob_attributes(m, &mut e.attributes);
|
||||
e.leash = leash;
|
||||
}
|
||||
for d in &self.dropped {
|
||||
let stack = hub.stack(&Some(d.stack.clone()));
|
||||
@@ -824,10 +866,14 @@ impl Game<'_> {
|
||||
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
|
||||
.collect();
|
||||
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
|
||||
self.lan_entities(host_entity, &mut host.hub, &mut states);
|
||||
self.lan_entities(host_entity, &host.mob_players, &mut host.hub, &mut states);
|
||||
} else {
|
||||
states.clear();
|
||||
}
|
||||
// ServerEntity.sendChanges clears needsSync once it has looked.
|
||||
for m in &mut self.mobs {
|
||||
m.needs_sync = false;
|
||||
}
|
||||
let entities = states.as_slice();
|
||||
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
|
||||
let events = {
|
||||
@@ -1008,7 +1054,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
// A dedicated server has no host player.
|
||||
self.lan_entities(None, &mut host.hub, &mut theirs);
|
||||
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs);
|
||||
theirs.as_slice()
|
||||
} else {
|
||||
entities
|
||||
@@ -1243,7 +1289,7 @@ impl Game<'_> {
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
for m in host.models.values() {
|
||||
for (conn, m) in &host.models {
|
||||
let entity_start = out.len();
|
||||
let light = self.world.light(
|
||||
m.position.x.floor() as i32,
|
||||
@@ -1258,7 +1304,10 @@ impl Game<'_> {
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
&self.worn_armor_layers(m),
|
||||
&host.hub.guest(*conn).map_or_else(
|
||||
|| self.worn_armor_layers(m),
|
||||
|g| self.stack_armor_layers(&g.armor),
|
||||
),
|
||||
out,
|
||||
);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! blocks, attacking and the commands all behave exactly as they do for the
|
||||
//! host, and every world change they cause reaches all players through the
|
||||
//! block log, the level events and the entity tracker.
|
||||
use super::{Game, GuestUse, Hit, Mining, Screen, lan::LanHost};
|
||||
use super::{Game, GuestUse, Hit, Mining, Screen, lan::LanHost, respawn_anchor::RespawnConfig};
|
||||
use horizon_client::{
|
||||
command::vanilla,
|
||||
containers::BlockEntity,
|
||||
@@ -34,6 +34,8 @@ pub(super) struct GuestPlay {
|
||||
destroy_delay: u32,
|
||||
using: Option<(usize, u32)>,
|
||||
use_delay: u32,
|
||||
/// The shield's ItemCooldowns entry: an axe disabled it for this many ticks.
|
||||
pub(super) shield_cooldown: u32,
|
||||
attack_ticks: u32,
|
||||
/// The main hand's item at the guest's last tick (`Game::attack_item`).
|
||||
attack_item: Option<std::sync::Arc<str>>,
|
||||
@@ -117,6 +119,7 @@ pub(super) struct GuestSwap {
|
||||
destroy_delay: u32,
|
||||
using: Option<(usize, u32)>,
|
||||
use_delay: u32,
|
||||
pub(super) shield_cooldown: u32,
|
||||
attack_ticks: u32,
|
||||
attack_item: Option<std::sync::Arc<str>>,
|
||||
swing: horizon_client::first_person::SwingState,
|
||||
@@ -217,6 +220,18 @@ impl Game<'_> {
|
||||
host.hub.guests[i].position[2],
|
||||
);
|
||||
host.hub.guests[i].motion = PlayerMotion::new(self.motion, p).ok().map(Box::new);
|
||||
// The first body after joining burns, breathes and falls on as saved.
|
||||
if let Some(saved) = host.hub.guests[i].loaded_body.take()
|
||||
&& let Some(m) = host.hub.guests[i].motion.as_mut()
|
||||
{
|
||||
saved.apply(m);
|
||||
}
|
||||
// The saved golden hearts go on the first body of a player who
|
||||
// has been here before.
|
||||
let saved = host.hub.guests[i].saved_absorption.take();
|
||||
if let (Some(m), Some(amount)) = (host.hub.guests[i].motion.as_mut(), saved) {
|
||||
let _ = m.internal_set_absorption(amount);
|
||||
}
|
||||
let kept = host.hub.guests[i].logs_kept.take();
|
||||
if let (Some(m), Some((hurts, attacks))) = (host.hub.guests[i].motion.as_mut(), kept) {
|
||||
m.hurt_log = hurts;
|
||||
@@ -290,6 +305,7 @@ impl Game<'_> {
|
||||
let destroy_delay = std::mem::replace(&mut self.destroy_delay, play.destroy_delay);
|
||||
let using = std::mem::replace(&mut self.using, play.using.take());
|
||||
let use_delay = std::mem::replace(&mut self.use_delay, play.use_delay);
|
||||
let shield_cooldown = std::mem::replace(&mut self.shield_cooldown, play.shield_cooldown);
|
||||
let attack_ticks = std::mem::replace(&mut self.attack_ticks, play.attack_ticks);
|
||||
let attack_item = std::mem::replace(&mut self.attack_item, play.attack_item.take());
|
||||
let swing = std::mem::replace(&mut self.swing, play.swing);
|
||||
@@ -309,6 +325,7 @@ impl Game<'_> {
|
||||
g.spawn.map(|s| Vec3::new(s[0], s[1], s[2])),
|
||||
);
|
||||
let respawn = (self.respawn_config.take(), self.level.spawn);
|
||||
self.respawn_config = g.respawn_elsewhere.as_ref().map(RespawnConfig::from_saved);
|
||||
let message = self.message.take();
|
||||
// The guest's own ender inventory; one the host has open goes back
|
||||
// into the host's while the guest acts.
|
||||
@@ -369,6 +386,7 @@ impl Game<'_> {
|
||||
destroy_delay,
|
||||
using,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
attack_ticks,
|
||||
attack_item,
|
||||
swing,
|
||||
@@ -421,6 +439,7 @@ impl Game<'_> {
|
||||
destroy_delay,
|
||||
using,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
attack_ticks,
|
||||
attack_item,
|
||||
swing,
|
||||
@@ -470,7 +489,8 @@ impl Game<'_> {
|
||||
let bed = std::mem::replace(&mut self.sleeping, sleeping);
|
||||
let guest_sleep_timer = std::mem::replace(&mut self.sleep_timer, sleep_timer);
|
||||
let guest_spawn = std::mem::replace(&mut self.spawn, spawn).map(|s| [s.x, s.y, s.z]);
|
||||
(self.respawn_config, self.level.spawn) = respawn;
|
||||
let guest_respawn = std::mem::replace(&mut self.respawn_config, respawn.0);
|
||||
self.level.spawn = respawn.1;
|
||||
// ServerPlayer.displayClientMessage(_, true): the guest's action bar.
|
||||
let guest_message = std::mem::replace(&mut self.message, message);
|
||||
let guest_vehicle = std::mem::replace(&mut self.riding, riding);
|
||||
@@ -480,6 +500,7 @@ impl Game<'_> {
|
||||
play.destroy_delay = std::mem::replace(&mut self.destroy_delay, destroy_delay);
|
||||
play.using = std::mem::replace(&mut self.using, using);
|
||||
play.use_delay = std::mem::replace(&mut self.use_delay, use_delay);
|
||||
play.shield_cooldown = std::mem::replace(&mut self.shield_cooldown, shield_cooldown);
|
||||
play.attack_ticks = std::mem::replace(&mut self.attack_ticks, attack_ticks);
|
||||
play.attack_item = std::mem::replace(&mut self.attack_item, attack_item);
|
||||
play.swing = std::mem::replace(&mut self.swing, swing);
|
||||
@@ -507,6 +528,7 @@ impl Game<'_> {
|
||||
g.vehicle = guest_vehicle;
|
||||
let fell_asleep = bed.is_some() && g.bed.is_none();
|
||||
(g.bed, g.sleep_timer, g.spawn) = (bed, guest_sleep_timer, guest_spawn);
|
||||
g.respawn_elsewhere = guest_respawn.map(|c| c.saved());
|
||||
g.sleeping = g.bed.is_some();
|
||||
if let Some((text, _)) = guest_message {
|
||||
g.send(&C::SystemChat(play::SystemChat {
|
||||
@@ -1486,17 +1508,43 @@ impl Game<'_> {
|
||||
if !host.hub.guests[i].dead {
|
||||
return;
|
||||
}
|
||||
// PlayerList.respawn: a server's player comes back in the Overworld,
|
||||
// where the world spawn is (respawn anchors and beds aside).
|
||||
// PlayerList.respawn: a server's player comes back in its respawn
|
||||
// point's level: the Overworld, where the world spawn and beds are,
|
||||
// or another one's anchor or forced point, which its block resolves
|
||||
// once the level has loaded around it.
|
||||
let server = self.options.dedicated.is_some();
|
||||
let own = host.hub.guests[i]
|
||||
.respawn_elsewhere
|
||||
.as_ref()
|
||||
.map(RespawnConfig::from_saved);
|
||||
let elsewhere = own.filter(|_| server);
|
||||
// In the window's world a guest has no level of its own to wait in:
|
||||
// its bed is resolved at once when its chunk is there, else it
|
||||
// comes back on the bed.
|
||||
let in_frame_bed = own.filter(|c| !server && c.dim == super::Dim::Overworld);
|
||||
let spawn = in_frame_bed.map_or(spawn, |c| {
|
||||
[
|
||||
f64::from(c.pos[0]) + 0.5,
|
||||
f64::from(c.pos[1]) + 0.6,
|
||||
f64::from(c.pos[2]) + 0.5,
|
||||
]
|
||||
});
|
||||
let level = elsewhere.map_or(super::Dim::Overworld, |c| c.dim);
|
||||
let spawn = elsewhere.map_or(spawn, |c| {
|
||||
[
|
||||
f64::from(c.pos[0]) + 0.5,
|
||||
f64::from(c.pos[1]),
|
||||
f64::from(c.pos[2]) + 0.5,
|
||||
]
|
||||
});
|
||||
let back = self.dim;
|
||||
let away = self.options.dedicated.is_some()
|
||||
&& host.hub.guests[i].dimension != super::Dim::Overworld.id();
|
||||
if away {
|
||||
let away = server && host.hub.guests[i].dimension != level.id();
|
||||
if server {
|
||||
self.server_trips.retain(|t| !t.is(conn));
|
||||
if let Err(e) = self.enter_level(Some(host), super::Dim::Overworld) {
|
||||
log::error!("could not open the Overworld: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if away && let Err(e) = self.enter_level(Some(host), level) {
|
||||
log::error!("could not open {}: {e}", level.id());
|
||||
return;
|
||||
}
|
||||
let g = &mut host.hub.guests[i];
|
||||
g.dead = false;
|
||||
@@ -1535,6 +1583,28 @@ impl Game<'_> {
|
||||
collision: Some(self.collision),
|
||||
};
|
||||
host.hub.respawn(i, spawn, &ctx);
|
||||
if let Some(config) = elsewhere {
|
||||
self.respawn_trip(conn, config);
|
||||
}
|
||||
if let Some(c) = in_frame_bed
|
||||
&& self.dim == super::Dim::Overworld
|
||||
&& self.world.loaded(c.pos[0], c.pos[2])
|
||||
{
|
||||
let found = self
|
||||
.as_guest(host, conn, |g| g.resolve_server_respawn(c))
|
||||
.flatten();
|
||||
let world = self
|
||||
.level
|
||||
.world_spawn
|
||||
.map_or([0.5, 80.0, 0.5], |[x, y, z]| {
|
||||
[f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5]
|
||||
});
|
||||
let at = found.map_or(world, |(at, yaw)| {
|
||||
host.hub.guests[i].yaw = yaw;
|
||||
[at.x, at.y, at.z]
|
||||
});
|
||||
host.hub.teleport(i, Some(at));
|
||||
}
|
||||
if away && let Err(e) = self.enter_level(Some(host), back) {
|
||||
log::error!("could not reopen {}: {e}", back.id());
|
||||
}
|
||||
@@ -1562,6 +1632,9 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
// Player.tick's ItemCooldowns.tick, once a tick for each player
|
||||
// (the shield an axe disabled).
|
||||
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
|
||||
let digging = play.digging;
|
||||
let using = play.using.is_some();
|
||||
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
|
||||
@@ -1616,6 +1689,9 @@ impl Game<'_> {
|
||||
self.guest_dig_feedback(host, conn);
|
||||
self.guest_pickups(host, gi);
|
||||
self.guest_orbs(host, gi);
|
||||
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
|
||||
self.guest_forget_digging(host, conn);
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if g.health <= 0.0 && !g.dead {
|
||||
g.dead = true;
|
||||
@@ -1765,6 +1841,7 @@ impl Game<'_> {
|
||||
}
|
||||
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
|
||||
let at = g.position;
|
||||
let sneaking = g.sneaking;
|
||||
let sounds = self.as_guest(host, conn, |game| {
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
@@ -1783,6 +1860,26 @@ impl Game<'_> {
|
||||
let _ = w.fluid_base_tick();
|
||||
w.player.ground.on_ground = from_ground;
|
||||
let travelled: Vec<[f64; 3]> = moves.iter().map(|m| m.0).collect();
|
||||
// Entity.move's movementThisTick: each move, from where the
|
||||
// tick's first one started.
|
||||
let end = w.player.position;
|
||||
let start = travelled
|
||||
.iter()
|
||||
.fold(end, |p, d| p.subtract(Vec3::new(d[0], d[1], d[2])));
|
||||
w.player.history.clear();
|
||||
let mut from = start;
|
||||
for d in &travelled {
|
||||
let delta = Vec3::new(d[0], d[1], d[2]);
|
||||
let to = from.add_vector(delta);
|
||||
w.player
|
||||
.history
|
||||
.add(horizon_world::entity_move::MovementRecord {
|
||||
from,
|
||||
to,
|
||||
original: Some(delta),
|
||||
});
|
||||
from = to;
|
||||
}
|
||||
for (moved, on_ground) in moves {
|
||||
let movement = Vec3::new(moved[0], moved[1], moved[2]);
|
||||
if w.player.ground.on_ground && !on_ground && moved[1] > 0.0 {
|
||||
@@ -1808,6 +1905,10 @@ impl Game<'_> {
|
||||
if w.climbable().unwrap_or(false) {
|
||||
w.player.fall_distance = 0.0;
|
||||
}
|
||||
// Entity.applyEffectsFromBlocks along those moves: magma, cactus,
|
||||
// lit campfires and grown sweet berry bushes hurt (#1305).
|
||||
w.player.shift_key_down = sneaking;
|
||||
let _ = w.server_block_damage(start);
|
||||
let _ = w.tick_environment_damage();
|
||||
let _ = w.apply_fire_contact();
|
||||
let _ = w.server_hazards_tick(in_wall);
|
||||
@@ -1968,6 +2069,23 @@ impl Game<'_> {
|
||||
|
||||
/// `level.destroyBlockProgress(id, pos, -1)` for a guest: its crack on `pos` is gone, for the
|
||||
/// host and the other guests.
|
||||
/// A player who dies digs and uses nothing any more: the block it was
|
||||
/// breaking, or had left to break (`hasDelayedDestroy`), stays, and its
|
||||
/// crack goes. Respawning gives it a new ServerPlayerGameMode (#1315).
|
||||
fn guest_forget_digging(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(play) = host.play.get_mut(&conn) else {
|
||||
return;
|
||||
};
|
||||
let cracked = play.digging.take().filter(|_| play.last_progress >= 0);
|
||||
play.mining = None;
|
||||
play.delayed = None;
|
||||
play.destroy_delay = 0;
|
||||
play.using = None;
|
||||
play.last_progress = -1;
|
||||
if let Some((pos, _)) = cracked {
|
||||
self.guest_crack_gone(host, conn, pos);
|
||||
}
|
||||
}
|
||||
fn guest_crack_gone(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
|
||||
let Some(id) = host.hub.guest(conn).map(|g| g.entity_id) else {
|
||||
return;
|
||||
|
||||
@@ -36,9 +36,12 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
|
||||
velocity: [0.0; 3],
|
||||
data: 0,
|
||||
meta: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
equipment: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
precise: false,
|
||||
needs_sync: false,
|
||||
leash: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ use horizon_client::{
|
||||
},
|
||||
};
|
||||
|
||||
/// The host player's connection id in the waypoint managers: its packets
|
||||
/// go to this client's locator bar rather than to a guest.
|
||||
const HOST_CONN: u32 = u32::MAX;
|
||||
|
||||
/// `waypoint_transmit_range`: hidden by crouching, invisibility or a head worn.
|
||||
fn transmit_range(crouching: bool, invisible: bool, head: Option<&ItemStack>) -> f64 {
|
||||
let hiding_head = head.is_some_and(|s| HIDING_HEADS.contains(&&*s.item));
|
||||
@@ -47,7 +51,7 @@ impl Game<'_> {
|
||||
self.dim.id(),
|
||||
waypoints::Player {
|
||||
uuid: h.uuid,
|
||||
conn: None,
|
||||
conn: Some(HOST_CONN),
|
||||
position: h.position,
|
||||
spectator: self.spectator(),
|
||||
transmit_range: transmit_range(
|
||||
@@ -123,7 +127,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
for (conn, packet) in out {
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
if conn == HOST_CONN {
|
||||
// The host's own connection: its locator bar.
|
||||
if let horizon_protocol::packets::play::Clientbound::TrackedWaypoint(w) = &packet {
|
||||
self.handle_waypoint(w);
|
||||
}
|
||||
} else if let Some(g) = host.hub.guest(conn) {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +232,9 @@ impl Game<'_> {
|
||||
p.world.game_time = self.world.game_time;
|
||||
p.world.day_time = self.world.day_time;
|
||||
p.world.weather = self.world.weather;
|
||||
// The game rules and the difficulty are the server's, not a
|
||||
// level's: natural regeneration holds in every dimension.
|
||||
p.world.health_rules = self.world.health_rules;
|
||||
// The sculk tables' watched blocks are built once per game.
|
||||
p.world.watched = std::mem::take(&mut self.world.watched);
|
||||
self.swap_level(&mut p);
|
||||
@@ -253,6 +256,24 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put `dim`'s level in place for a command whose source is there
|
||||
/// (`execute in`, `execute at` a player in another level), opening it if
|
||||
/// nobody is in it; returns the level to put back afterwards. Only a
|
||||
/// server holds other levels.
|
||||
pub(super) fn enter_command_level(&mut self, dim: Dim) -> Option<Dim> {
|
||||
if self.options.dedicated.is_none() || dim == self.dim {
|
||||
return None;
|
||||
}
|
||||
let back = self.dim;
|
||||
match self.enter_level(None, dim) {
|
||||
Ok(()) => Some(back),
|
||||
Err(e) => {
|
||||
log::error!("could not open {}: {e}", dim.id());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One server tick (`MinecraftServer.tickServer`): each level with
|
||||
/// players in turn (`tickChildren`), its chunks streamed and loaded and
|
||||
/// its world ticked. The first pass reads the network and runs the
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! The locator bar on this client (horizon_client::locator_bar): the
|
||||
//! waypoints a server tracks for the player (`handleWaypoint`; on a served
|
||||
//! world the host's own connection in lan/waypoints.rs), the experience
|
||||
//! bar's priority after a change, and the dots drawn from the last frame's
|
||||
//! camera.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
hud_layout::GuiDraw,
|
||||
locator_bar::{ClientWaypoints, View},
|
||||
};
|
||||
use horizon_protocol::packets::play::TrackedWaypoint;
|
||||
|
||||
/// `Player.getEyeHeight` while standing.
|
||||
const PLAYER_EYES: f64 = 1.62;
|
||||
|
||||
fn uuid(ints: [i32; 4]) -> u128 {
|
||||
ints.iter()
|
||||
.fold(0u128, |acc, &i| acc << 32 | u128::from(i as u32))
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ClientPacketListener.handleWaypoint`.
|
||||
pub(super) fn handle_waypoint(&mut self, packet: &TrackedWaypoint) {
|
||||
self.waypoints.apply(packet);
|
||||
}
|
||||
/// A new connection or world starts with no waypoints.
|
||||
pub(super) fn clear_waypoints(&mut self) {
|
||||
self.waypoints = ClientWaypoints::default();
|
||||
}
|
||||
/// `LocalPlayer.setExperienceValues`: a change of progress shows the
|
||||
/// experience bar for 100 ticks, except the first one
|
||||
/// (`experienceDisplayStartTick` starts at `Integer.MIN_VALUE`).
|
||||
pub(super) fn track_experience_display(&mut self) {
|
||||
let progress = self.experience.progress;
|
||||
let (last, seen, _) = &mut self.experience_display;
|
||||
if progress != *last {
|
||||
*last = progress;
|
||||
if *seen {
|
||||
self.experience_display.2 = self.ticks;
|
||||
} else {
|
||||
*seen = true;
|
||||
}
|
||||
}
|
||||
// EnchantmentScreen and AnvilScreen.containerTick keep it up.
|
||||
if self.station.as_ref().is_some_and(|s| {
|
||||
matches!(
|
||||
s.kind,
|
||||
super::stations::Kind::Enchanting | super::stations::Kind::Anvil
|
||||
)
|
||||
}) {
|
||||
self.experience_display.2 = self.ticks;
|
||||
}
|
||||
}
|
||||
/// `Hud.willPrioritizeExperienceInfo`.
|
||||
pub(super) fn experience_recent(&self) -> bool {
|
||||
let (_, _, start) = self.experience_display;
|
||||
start != 0 && start + 100 > self.ticks
|
||||
}
|
||||
/// The local player's UUID, as servers know it.
|
||||
fn own_uuid(&self) -> u128 {
|
||||
match &self.remote {
|
||||
Some(r) => r.profile(),
|
||||
None => self.level.player_uuid.map_or(0, uuid),
|
||||
}
|
||||
}
|
||||
/// An entity's block position and (interpolated) eyes, by UUID.
|
||||
fn waypoint_entity(&self, id: u128, alpha: f64) -> Option<([i32; 3], [f64; 3])> {
|
||||
let at = |p: [f64; 3], q: [f64; 3], eyes: f64| {
|
||||
let pos: [f64; 3] = std::array::from_fn(|i| q[i] + (p[i] - q[i]) * alpha);
|
||||
(p.map(|c| c.floor() as i32), [pos[0], pos[1] + eyes, pos[2]])
|
||||
};
|
||||
if let Some(r) = &self.remote {
|
||||
let entity = r.player_entity(id)?;
|
||||
let m = self.mobs.iter().find(|m| m.id == entity)?;
|
||||
let v = |v: horizon_core::geometry::Vec3| [v.x, v.y, v.z];
|
||||
return Some(at(v(m.position), v(m.previous), m.eye_height()));
|
||||
}
|
||||
let host = self.lan.as_ref()?;
|
||||
let g = host.hub.guests.iter().find(|g| g.uuid == id && g.placed)?;
|
||||
Some(at(g.position, g.previous_position, PLAYER_EYES))
|
||||
}
|
||||
/// `LocatorBar.extractRenderState` from the last frame's camera.
|
||||
pub(super) fn locator_bar_draws(&self, gui_width: i32, gui_height: i32) -> Vec<GuiDraw> {
|
||||
let (Some((camera_pos, yaw, pitch, fov)), Some(p)) = (self.locator_camera, &self.player)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let alpha = self.clock.camera_fraction();
|
||||
// Camera.getViewRotationProjectionMatrix, through view space: the
|
||||
// depth along the look direction and the height on screen.
|
||||
let (sy, cy) = (
|
||||
f64::from(yaw.to_radians()).sin(),
|
||||
f64::from(yaw.to_radians()).cos(),
|
||||
);
|
||||
let (sp, cp) = (
|
||||
f64::from(pitch.to_radians()).sin(),
|
||||
f64::from(pitch.to_radians()).cos(),
|
||||
);
|
||||
let forward = [-sy * cp, -sp, cy * cp];
|
||||
let up = [-sy * sp, cp, cy * sp];
|
||||
let focal = 1.0 / f64::from((fov / 2.0).to_radians()).tan();
|
||||
let project = |o: [f64; 3]| {
|
||||
let dot = |a: [f64; 3]| a[0] * o[0] + a[1] * o[1] + a[2] * o[2];
|
||||
let depth = dot(forward);
|
||||
let y = dot(up) * focal / depth;
|
||||
// Points behind the camera land past the far plane (z > 1).
|
||||
[0.0, y, if depth < 0.0 { 2.0 } else { 0.5 }]
|
||||
};
|
||||
let entity = |id: u128| self.waypoint_entity(id, alpha);
|
||||
let view = View {
|
||||
gui_width,
|
||||
gui_height,
|
||||
camera_uuid: self.own_uuid(),
|
||||
camera_entity_pos: [p.position.x, p.position.y, p.position.z],
|
||||
camera_pos,
|
||||
yaw,
|
||||
pitch,
|
||||
fov,
|
||||
project: &project,
|
||||
entity: &entity,
|
||||
styles: &self.waypoint_styles,
|
||||
tick: self.ticks,
|
||||
};
|
||||
self.waypoints.draw(&view)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use horizon_client::{
|
||||
sound_engine::{CartSound, SoundEngine, SoundInstance, Sounds},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
geometry::{Aabb, Vec3, trig},
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
@@ -637,6 +637,9 @@ impl Game<'_> {
|
||||
let r = self.world.random.next_double();
|
||||
let power = (4.0 + r * 1.5 * speed) as f32;
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
// The cart is the explosion's source: it is gone before the blast
|
||||
// looks for entities, so it can't hit itself.
|
||||
self.carts.retain(|x| x.id != c.id);
|
||||
let _ = self.server_explode(at, power, BlockInteraction::Destroy, false);
|
||||
}
|
||||
/// `MinecartHopper.suckInItems`: items above, into the five slots.
|
||||
@@ -683,8 +686,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AbstractMinecart.push(player)` when the player walks into a cart,
|
||||
/// and carts pushing each other (`pushOtherMinecart`).
|
||||
/// `AbstractMinecart.push(player)` when the player walks into a cart.
|
||||
fn push_carts(&mut self) {
|
||||
let player = self.player.as_ref().map(|p| p.position);
|
||||
let riding = self.riding;
|
||||
@@ -719,55 +721,71 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
for j in i + 1..self.carts.len() {
|
||||
let (a, b) = (&self.carts[i], &self.carts[j]);
|
||||
if !a
|
||||
.bounds()
|
||||
.inflate(Vec3::new(0.2, 0.0, 0.2))
|
||||
.intersects(b.bounds())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let (mut xa, mut za) = (b.position.x - a.position.x, b.position.z - a.position.z);
|
||||
let dd = xa * xa + za * za;
|
||||
if dd < 1.0e-4 {
|
||||
continue;
|
||||
}
|
||||
let d = dd.sqrt();
|
||||
let pow = (1.0 / d).min(1.0);
|
||||
xa = xa / d * pow * f64::from(0.1f32) * 0.5;
|
||||
za = za / d * pow * f64::from(0.1f32) * 0.5;
|
||||
let dir =
|
||||
glam::DVec2::new(b.position.x - a.position.x, b.position.z - a.position.z)
|
||||
.normalize_or_zero();
|
||||
let r = a.yaw.to_radians();
|
||||
let facing =
|
||||
glam::DVec2::new(f64::from(r.cos()), f64::from(r.sin())).normalize_or_zero();
|
||||
if dir.dot(facing).abs() < f64::from(0.8f32) {
|
||||
continue;
|
||||
}
|
||||
let (ma, mb) = (a.velocity, b.velocity);
|
||||
let (fa, fb) = (a.kind == CartKind::Furnace, b.kind == CartKind::Furnace);
|
||||
let (na, nb) = if fb && !fa {
|
||||
(
|
||||
Vec3::new(ma.x * 0.2 + mb.x - xa, ma.y, ma.z * 0.2 + mb.z - za),
|
||||
Vec3::new(mb.x * 0.95, mb.y, mb.z * 0.95),
|
||||
)
|
||||
} else if fa && !fb {
|
||||
(
|
||||
Vec3::new(ma.x * 0.95, ma.y, ma.z * 0.95),
|
||||
Vec3::new(mb.x * 0.2 + ma.x + xa, mb.y, mb.z * 0.2 + ma.z + za),
|
||||
)
|
||||
} else {
|
||||
let (xdd, zdd) = ((mb.x + ma.x) / 2.0, (mb.z + ma.z) / 2.0);
|
||||
(
|
||||
Vec3::new(ma.x * 0.2 + xdd - xa, ma.y, ma.z * 0.2 + zdd - za),
|
||||
Vec3::new(mb.x * 0.2 + xdd + xa, mb.y, mb.z * 0.2 + zdd + za),
|
||||
)
|
||||
};
|
||||
self.carts[i].velocity = na;
|
||||
self.carts[j].velocity = nb;
|
||||
}
|
||||
}
|
||||
/// `OldMinecartBehavior.pushAndPickupEntities` for carts, at the end of
|
||||
/// cart `i`'s own tick: each other cart in its box (0.2 wider on each
|
||||
/// side) gets `push(cart i)`. Running it per cart, after that cart moved
|
||||
/// and before the next one does, is what splits stacked carts apart.
|
||||
fn push_from_cart(&mut self, i: usize) {
|
||||
let reach = f64::from(0.2f32);
|
||||
let hitbox = self.carts[i].bounds().inflate(Vec3::new(reach, 0.0, reach));
|
||||
for j in 0..self.carts.len() {
|
||||
if j == i || !hitbox.intersects(self.carts[j].bounds()) {
|
||||
continue;
|
||||
}
|
||||
// AbstractMinecart.push(entity): `this` is cart j, `entity` cart i.
|
||||
let (this, other) = (&self.carts[j], &self.carts[i]);
|
||||
let (mut xa, mut za) = (
|
||||
other.position.x - this.position.x,
|
||||
other.position.z - this.position.z,
|
||||
);
|
||||
let dd = xa * xa + za * za;
|
||||
if dd < f64::from(1.0e-4f32) {
|
||||
continue;
|
||||
}
|
||||
let d = dd.sqrt();
|
||||
xa /= d;
|
||||
za /= d;
|
||||
let pow = (1.0 / d).min(1.0);
|
||||
xa = xa * pow * f64::from(0.1f32) * 0.5;
|
||||
za = za * pow * f64::from(0.1f32) * 0.5;
|
||||
// pushOtherMinecart (old movement): only along this cart's facing.
|
||||
let dir = glam::DVec2::new(
|
||||
other.position.x - this.position.x,
|
||||
other.position.z - this.position.z,
|
||||
)
|
||||
.normalize_or_zero();
|
||||
let r = f64::from(this.yaw * 0.017453292f32);
|
||||
let facing = glam::DVec2::new(f64::from(trig::cos(r)), f64::from(trig::sin(r)))
|
||||
.normalize_or_zero();
|
||||
if dir.dot(facing).abs() < f64::from(0.8f32) {
|
||||
continue;
|
||||
}
|
||||
let (mt, mo) = (this.velocity, other.velocity);
|
||||
let (ft, fo) = (
|
||||
this.kind == CartKind::Furnace,
|
||||
other.kind == CartKind::Furnace,
|
||||
);
|
||||
let (nt, no) = if fo && !ft {
|
||||
(
|
||||
Vec3::new(mt.x * 0.2 + (mo.x - xa), mt.y, mt.z * 0.2 + (mo.z - za)),
|
||||
Vec3::new(mo.x * 0.95, mo.y, mo.z * 0.95),
|
||||
)
|
||||
} else if !fo && ft {
|
||||
(
|
||||
Vec3::new(mt.x * 0.95, mt.y, mt.z * 0.95),
|
||||
Vec3::new(mo.x * 0.2 + (mt.x + xa), mo.y, mo.z * 0.2 + (mt.z + za)),
|
||||
)
|
||||
} else {
|
||||
let (xdd, zdd) = ((mo.x + mt.x) / 2.0, (mo.z + mt.z) / 2.0);
|
||||
(
|
||||
Vec3::new(mt.x * 0.2 + (xdd - xa), mt.y, mt.z * 0.2 + (zdd - za)),
|
||||
Vec3::new(mo.x * 0.2 + (xdd + xa), mo.y, mo.z * 0.2 + (zdd + za)),
|
||||
)
|
||||
};
|
||||
self.carts[j].velocity = nt;
|
||||
self.carts[i].velocity = no;
|
||||
}
|
||||
}
|
||||
/// Every cart but the ridden one (which ticks with its rider).
|
||||
@@ -777,14 +795,17 @@ impl Game<'_> {
|
||||
let ticking = self.entity_ticking_area();
|
||||
// A server player's minecart moves with its rider (lan_vehicles.rs).
|
||||
let ridden = self.guest_vehicles();
|
||||
let mut i = 0;
|
||||
while i < self.carts.len() {
|
||||
// By id: an exploding cart can destroy or prime others mid-loop.
|
||||
let ids: Vec<u32> = self.carts.iter().map(|c| c.id).collect();
|
||||
for id in ids {
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let p = self.carts[i].position;
|
||||
if Some(self.carts[i].id) == self.riding
|
||||
|| ridden.contains(&self.carts[i].id)
|
||||
if Some(id) == self.riding
|
||||
|| ridden.contains(&id)
|
||||
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
|
||||
{
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut c = self.carts[i].clone();
|
||||
@@ -792,9 +813,12 @@ impl Game<'_> {
|
||||
if c.kind == CartKind::Hopper {
|
||||
self.hopper_cart_pickup(&mut c);
|
||||
}
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
if alive {
|
||||
self.carts[i] = c;
|
||||
i += 1;
|
||||
self.push_from_cart(i);
|
||||
} else {
|
||||
self.carts.remove(i);
|
||||
}
|
||||
@@ -865,8 +889,13 @@ impl Game<'_> {
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
let Some(i) = self.carts.iter().position(|x| x.id == c.id) else {
|
||||
self.riding = None;
|
||||
return true;
|
||||
};
|
||||
if alive {
|
||||
self.carts[i] = c;
|
||||
self.push_from_cart(i);
|
||||
} else {
|
||||
self.carts.remove(i);
|
||||
self.riding = None;
|
||||
@@ -1121,8 +1150,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// `VehicleEntity.hurtServer`: shake, add the damage, break past 40 (at
|
||||
/// once for creative players); TNT minecarts prime instead when moving
|
||||
/// or hurt by fire.
|
||||
fn hurt_cart(&mut self, i: usize, damage: f32, creative: bool, fire: bool) {
|
||||
/// or hurt by fire or an explosion (`damageSourceIgnitesTnt`).
|
||||
pub(super) fn hurt_cart(&mut self, i: usize, damage: f32, creative: bool, fire: bool) {
|
||||
let c = &mut self.carts[i];
|
||||
c.hurt_dir = -c.hurt_dir;
|
||||
c.hurt_time = 10;
|
||||
@@ -1131,7 +1160,7 @@ impl Game<'_> {
|
||||
if destroyed {
|
||||
let mut c = self.carts.remove(i);
|
||||
let speed = c.velocity.x * c.velocity.x + c.velocity.z * c.velocity.z;
|
||||
if c.kind == CartKind::Tnt && (fire || speed >= 0.01) {
|
||||
if c.kind == CartKind::Tnt && (fire || speed >= f64::from(0.01f32)) {
|
||||
// MinecartTNT.destroy: prime instead of breaking.
|
||||
if c.fuse < 0 {
|
||||
self.prime_cart(&mut c);
|
||||
|
||||
@@ -463,25 +463,40 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `Hud.extractVehicleHealth` and the jump bar that replaces the
|
||||
/// experience bar on a saddled jumper. Returns the mount's heart count
|
||||
/// (`getVehicleMaxHearts`) and whether the jump bar shows.
|
||||
pub(super) fn draw_mount_hud(&self, batch: &mut HudBatch, w: i32, h: i32) -> (i32, bool) {
|
||||
/// `Player.jumpableVehicle`: a saddled mount that jumps.
|
||||
pub(super) fn jumpable_mount(&self) -> bool {
|
||||
self.mount_index()
|
||||
.is_some_and(|i| self.mobs[i].extra.saddled)
|
||||
}
|
||||
/// `willPrioritizeJumpInfo`: charging a jump or recharging.
|
||||
pub(super) fn mount_jumping(&self) -> bool {
|
||||
self.mount.jump_scale > 0.0 || self.mount.jump_ticks < 0
|
||||
}
|
||||
/// `Hud.extractVehicleHealth`, and the jump bar when it is the
|
||||
/// contextual bar (`jump_bar`). Returns the mount's heart count
|
||||
/// (`getVehicleMaxHearts`).
|
||||
pub(super) fn draw_mount_hud(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
w: i32,
|
||||
h: i32,
|
||||
jump_bar: bool,
|
||||
) -> i32 {
|
||||
let Some(i) = self.mount_index() else {
|
||||
return (0, false);
|
||||
return 0;
|
||||
};
|
||||
let m = &self.mobs[i];
|
||||
let atlas = &self.gui.atlas;
|
||||
let hearts = horizon_client::hud_layout::vehicle_max_hearts(m.attrs.max_health);
|
||||
let draws = horizon_client::hud_layout::vehicle_health(w, h, hearts, m.health);
|
||||
horizon_client::hud_layout::paint(batch, atlas, &draws);
|
||||
if m.extra.saddled {
|
||||
if jump_bar {
|
||||
// JumpableVehicleBar: the cooldown shows while the jump recharges.
|
||||
let cooldown = i32::from(self.mount.jump_ticks < 0);
|
||||
let draws =
|
||||
horizon_client::hud_overlays::jump_bar(w, h, cooldown, self.mount.jump_scale);
|
||||
horizon_client::hud_layout::paint(batch, atlas, &draws);
|
||||
}
|
||||
(hearts, m.extra.saddled)
|
||||
hearts
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ impl Game<'_> {
|
||||
pub(super) fn tick_music(&mut self) {
|
||||
let (music, volume) = self.situational_music();
|
||||
// LevelLoadingScreen: no new track before the player is in the world.
|
||||
let loading = self.title.is_none() && self.player.is_none();
|
||||
let loading = self.title.is_none() && self.player.is_none() && self.win.is_none();
|
||||
self.music
|
||||
.tick(music.as_ref(), volume, loading, &mut self.audio);
|
||||
}
|
||||
@@ -59,7 +59,11 @@ impl Game<'_> {
|
||||
None => (empty, 1.0),
|
||||
};
|
||||
let situation = Situation {
|
||||
screen_music: None,
|
||||
// WinScreen.getBackgroundMusic.
|
||||
screen_music: self
|
||||
.win
|
||||
.is_some()
|
||||
.then(horizon_client::music::Music::credits),
|
||||
in_world: eye.is_some(),
|
||||
in_end: self.dim == super::Dim::End,
|
||||
boss_music: self.boss_music(),
|
||||
|
||||
@@ -72,6 +72,8 @@ pub(super) struct GameLevel<'g, 'a> {
|
||||
pub(super) w: MobWorld<'g>,
|
||||
/// Every player in the level ([`Game::level_players`]).
|
||||
pub(super) players: Vec<LevelPlayer>,
|
||||
/// Where the non-spectators among them are (`getNearestPlayer`).
|
||||
pub(super) nearest: Vec<[f64; 3]>,
|
||||
/// Boxes of the mobs this pass created, not yet in the game's list.
|
||||
pub(super) pending: std::cell::RefCell<Vec<Aabb>>,
|
||||
/// The types and boxes of what a spawner created this tick.
|
||||
@@ -255,14 +257,8 @@ impl NaturalLevel for GameLevel<'_, '_> {
|
||||
fn structure_spawns(&self) -> &StructureSpawns {
|
||||
StructureSpawns::shared(&self.game.options.resources)
|
||||
}
|
||||
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
|
||||
// The nearest player's distance (`nearest_player`), without finding
|
||||
// which player it is: every spawn attempt asks.
|
||||
self.players
|
||||
.iter()
|
||||
.filter(|p| !p.spectator)
|
||||
.map(|p| p.distance_sqr(x, y, z))
|
||||
.reduce(f64::min)
|
||||
fn nearest_player_positions(&self) -> &[[f64; 3]] {
|
||||
&self.nearest
|
||||
}
|
||||
fn respawn(&self) -> Option<[i32; 3]> {
|
||||
// The world spawn point lives in the overworld.
|
||||
@@ -363,10 +359,17 @@ impl horizon_client::mobs::spawner::SpawnerLevel for GameLevel<'_, '_> {
|
||||
}
|
||||
impl<'a> Game<'a> {
|
||||
pub(super) fn natural_level(&self) -> GameLevel<'_, 'a> {
|
||||
let players = self.level_players();
|
||||
let nearest = players
|
||||
.iter()
|
||||
.filter(|p| !p.spectator)
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z])
|
||||
.collect();
|
||||
GameLevel {
|
||||
game: self,
|
||||
w: self.mob_world_base(&[]),
|
||||
players: self.level_players(),
|
||||
players,
|
||||
nearest,
|
||||
pending: Default::default(),
|
||||
pending_ids: Default::default(),
|
||||
boxes: Default::default(),
|
||||
|
||||
@@ -200,6 +200,13 @@ impl Game<'_> {
|
||||
self.settings.save(&Self::options_path());
|
||||
self.apply_settings();
|
||||
self.sync_mods();
|
||||
self.sync_agent_control();
|
||||
self.ui.options.agent_status = self.agent_status_lines();
|
||||
}
|
||||
Event::CopyAgentDetails => {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
let text = self.agent_details();
|
||||
self.clipboard.set(&text);
|
||||
}
|
||||
Event::Close if self.ui.options.resource_error.is_some() => {}
|
||||
Event::Close => {
|
||||
@@ -235,6 +242,10 @@ impl Game<'_> {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
|
||||
Event::ReloadShaderPack => {
|
||||
self.load_shader_pack();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -133,7 +133,7 @@ impl Game<'_> {
|
||||
}
|
||||
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
|
||||
let render = std::sync::Arc::new(self.pack.assets.reload(root)?);
|
||||
let mut atlas = HudAtlas::build(root)?;
|
||||
let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?;
|
||||
atlas.font.uniform = self.settings.force_unicode;
|
||||
atlas.font.japanese = self.settings.japanese_glyphs;
|
||||
let gui = super::UiResources {
|
||||
@@ -171,6 +171,8 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
// Everything loaded: switch over.
|
||||
self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(root);
|
||||
self.armor_stand_assets.reload_trims(root);
|
||||
self.random_tick_sections.clear();
|
||||
self.portals.sections.clear();
|
||||
self.options.resources = root.to_path_buf();
|
||||
|
||||
@@ -297,10 +297,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// The local player's effects (`tick_mob_effects`), then the potions
|
||||
/// in flight, which a server without a local player still has.
|
||||
pub(super) fn tick_effects(&mut self) {
|
||||
/// in flight, which a server without a local player still has. A frozen
|
||||
/// tick (`/tick freeze`) leaves the potions where they are
|
||||
/// (`TickRateManager.isEntityFrozen`); players' effects still run.
|
||||
pub(super) fn tick_effects(&mut self, running: bool) {
|
||||
self.tick_mob_effects();
|
||||
self.tick_thrown_potions();
|
||||
if running {
|
||||
self.tick_thrown_potions();
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.tickEffects` (server side) for `self.player`, the local
|
||||
/// player or a guest acting (`as_guest`), then its attribute modifiers
|
||||
|
||||
@@ -40,7 +40,11 @@ use horizon_protocol::{
|
||||
registry::{Builtin, DimensionType, Dynamic, ceil_log2},
|
||||
text::{Language, format_java, plain},
|
||||
};
|
||||
use horizon_world::{nbt::Tag, registry::BlockStateId};
|
||||
use horizon_world::{
|
||||
nbt::Tag,
|
||||
registry::BlockStateId,
|
||||
stepped_interpolation::{Path, PosRot, Start, SteppedInterpolation},
|
||||
};
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
sync::Arc,
|
||||
@@ -85,6 +89,15 @@ struct Lerp {
|
||||
steps: u32,
|
||||
}
|
||||
|
||||
/// `SteppedInterpolationHandler.doInterpolate`'s `setPos` and `setRot` on a
|
||||
/// mob: its yaw kept continuous (the same turn as vanilla's, without the
|
||||
/// wrap that would spin it in the frame between ticks), its pitch clamped.
|
||||
fn place_interpolated(m: &mut Mob, at: PosRot) {
|
||||
m.position = Vec3::new(at.position[0], at.position[1], at.position[2]);
|
||||
m.yaw += wrap_degrees(at.yaw - m.yaw);
|
||||
m.head_pitch = (at.pitch % 360.0).clamp(-90.0, 90.0);
|
||||
}
|
||||
|
||||
pub(super) struct Remote {
|
||||
pub session: Session,
|
||||
pub brand: String,
|
||||
@@ -123,6 +136,8 @@ pub(super) struct Remote {
|
||||
use_target: Option<[i32; 3]>,
|
||||
entities: HashMap<i32, Tracked>,
|
||||
lerps: HashMap<i32, Lerp>,
|
||||
/// Living entities' `SteppedInterpolationHandler`s.
|
||||
stepped: HashMap<i32, SteppedInterpolation>,
|
||||
/// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it.
|
||||
head_lerps: HashMap<i32, (f32, u32)>,
|
||||
/// Item entities waiting for their item data.
|
||||
@@ -351,6 +366,7 @@ impl Game<'_> {
|
||||
r.clear_sections();
|
||||
}
|
||||
self.apply_settings();
|
||||
self.clear_waypoints();
|
||||
self.remote = Some(Remote {
|
||||
session,
|
||||
brand: "unknown".into(),
|
||||
@@ -379,6 +395,7 @@ impl Game<'_> {
|
||||
use_target: None,
|
||||
entities: HashMap::new(),
|
||||
lerps: HashMap::new(),
|
||||
stepped: HashMap::new(),
|
||||
head_lerps: HashMap::new(),
|
||||
pending_items: HashMap::new(),
|
||||
health: (20.0, 20, 5.0),
|
||||
@@ -429,6 +446,7 @@ impl Game<'_> {
|
||||
// Leaving on purpose: nothing for auto-reconnect to retry.
|
||||
self.extras_left();
|
||||
}
|
||||
self.clear_waypoints();
|
||||
if let Some(mut r) = self.remote.take() {
|
||||
r.session.disconnect();
|
||||
}
|
||||
@@ -929,6 +947,7 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.entities.clear();
|
||||
r.lerps.clear();
|
||||
r.stepped.clear();
|
||||
r.head_lerps.clear();
|
||||
r.pending_items.clear();
|
||||
// A new ClientLevel: a new chunk storage, centred on 0, 0.
|
||||
@@ -1315,11 +1334,15 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(t) = mob_type {
|
||||
let mut mob = Mob::new(id as u32, t, at, head);
|
||||
super::remote_mob_data::client_defaults(&mut mob);
|
||||
(mob.yaw, mob.previous_yaw) = (yaw, yaw);
|
||||
(mob.head_pitch, mob.previous_head_pitch) = (pitch, pitch);
|
||||
mob.persistent = true;
|
||||
self.mobs.retain(|m| m.id != id as u32);
|
||||
self.mobs.push(mob);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.stepped.remove(&id);
|
||||
}
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
if kind == "minecraft:item" {
|
||||
@@ -1334,23 +1357,77 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// A move packet for entity `id`: to the end of `path` (None: a
|
||||
/// rotation alone), turned to `rotation` if it has one. `far_snaps`:
|
||||
/// an `EntityPositionSync`, which puts an entity more than 64 blocks
|
||||
/// away there at once.
|
||||
fn move_entity(
|
||||
&mut self,
|
||||
id: i32,
|
||||
position: [f64; 3],
|
||||
path: Option<Path>,
|
||||
rotation: Option<(f32, f32)>,
|
||||
on_ground: bool,
|
||||
far_snaps: bool,
|
||||
) {
|
||||
let mob = self.mobs.iter_mut().find(|m| m.id == id as u32);
|
||||
let tracked = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.entities.get(&id))
|
||||
.map(|t| t.position);
|
||||
let position = path
|
||||
.as_ref()
|
||||
.map(Path::end)
|
||||
.or(tracked)
|
||||
.or_else(|| {
|
||||
mob.as_ref()
|
||||
.map(|m| [m.position.x, m.position.y, m.position.z])
|
||||
})
|
||||
.unwrap_or([0.0; 3]);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
if let Some(t) = r.entities.get_mut(&id) {
|
||||
t.position = position;
|
||||
}
|
||||
if let Some(m) = mob {
|
||||
// A living entity: LivingEntity.createInterpolationHandler's
|
||||
// SteppedInterpolationHandler, over its type's update interval.
|
||||
let entity = PosRot {
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
yaw: m.yaw,
|
||||
pitch: m.head_pitch,
|
||||
};
|
||||
let far = far_snaps && {
|
||||
let d = [0, 1, 2].map(|i| position[i] - entity.position[i]);
|
||||
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > 4096.0
|
||||
};
|
||||
let handler = r.stepped.entry(id).or_insert_with(|| {
|
||||
let interval = horizon_client::lan::tracking::settings(&m.kind.id).1;
|
||||
SteppedInterpolation::new(interval as i32)
|
||||
});
|
||||
let snap = if far {
|
||||
handler.cancel();
|
||||
let (yaw, pitch) = rotation.unwrap_or((m.yaw, m.head_pitch));
|
||||
Some(PosRot {
|
||||
position,
|
||||
yaw,
|
||||
pitch,
|
||||
})
|
||||
} else {
|
||||
match handler.interpolate_to(entity, path, rotation) {
|
||||
Start::Snap(at) => Some(at),
|
||||
Start::Interpolating | Start::Unchanged => None,
|
||||
}
|
||||
};
|
||||
if let Some(at) = snap {
|
||||
place_interpolated(m, at);
|
||||
m.previous = m.position;
|
||||
}
|
||||
m.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
// AbstractInterpolationHandler.interpolateTo: without a rotation,
|
||||
// the one it was heading for (or has).
|
||||
let rotation = rotation
|
||||
.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation))
|
||||
.or_else(|| mob.as_ref().map(|m| (m.yaw, m.head_pitch)));
|
||||
// the one it was heading for.
|
||||
let rotation = rotation.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation));
|
||||
r.lerps.insert(
|
||||
id,
|
||||
Lerp {
|
||||
@@ -1363,9 +1440,6 @@ impl Game<'_> {
|
||||
*p = position;
|
||||
}
|
||||
}
|
||||
if let Some(m) = mob {
|
||||
m.on_ground = on_ground;
|
||||
}
|
||||
self.turn_remote_object(id, rotation.map(|(yaw, _)| yaw), on_ground);
|
||||
self.turn_remote_projectile(id, rotation);
|
||||
}
|
||||
@@ -1382,6 +1456,7 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.entities.remove(&id);
|
||||
r.lerps.remove(&id);
|
||||
r.stepped.remove(&id);
|
||||
r.head_lerps.remove(&id);
|
||||
r.pending_items.remove(&id);
|
||||
}
|
||||
@@ -1741,17 +1816,36 @@ impl Game<'_> {
|
||||
.and_then(|r| r.entities.get(&m.id))
|
||||
.map(|t| t.position);
|
||||
let Some(base) = base else { return Ok(()) };
|
||||
let position = m.delta.as_ref().map_or(base, |d| d.end(base));
|
||||
// VecDeltaCodec.decode: each step's position from the last.
|
||||
let path = m.delta.as_ref().map(|d| match d {
|
||||
play::VecDelta::Linear(_) => Path::Linear(d.end(base)),
|
||||
play::VecDelta::Stepped(steps) => {
|
||||
let mut at = base;
|
||||
Path::Stepped(
|
||||
steps
|
||||
.iter()
|
||||
.map(|(delta, ticks)| {
|
||||
at = play::VecDelta::apply(at, *delta);
|
||||
(at, *ticks)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
});
|
||||
let rotation = m.rotation.map(|(y, x)| {
|
||||
(
|
||||
horizon_protocol::buf::unpack_degrees(y),
|
||||
horizon_protocol::buf::unpack_degrees(x),
|
||||
)
|
||||
});
|
||||
self.move_entity(m.id, position, rotation, m.on_ground);
|
||||
self.move_entity(m.id, path, rotation, m.on_ground, false);
|
||||
}
|
||||
C::EntityPositionSync(p) => {
|
||||
self.move_entity(p.id, p.path.end(), Some((p.yaw, p.pitch)), p.on_ground)
|
||||
let path = match p.path {
|
||||
play::PositionPath::Linear(at) => Path::Linear(at),
|
||||
play::PositionPath::Stepped(steps) => Path::Stepped(steps),
|
||||
};
|
||||
self.move_entity(p.id, Some(path), Some((p.yaw, p.pitch)), p.on_ground, true)
|
||||
}
|
||||
C::TeleportEntity(p) => {
|
||||
let base = self
|
||||
@@ -1771,9 +1865,28 @@ impl Game<'_> {
|
||||
rel(play::relative::Y, 1),
|
||||
rel(play::relative::Z, 2),
|
||||
];
|
||||
self.move_entity(p.id, position, Some((p.yaw, p.pitch)), p.on_ground);
|
||||
self.move_entity(
|
||||
p.id,
|
||||
Some(Path::Linear(position)),
|
||||
Some((p.yaw, p.pitch)),
|
||||
p.on_ground,
|
||||
false,
|
||||
);
|
||||
}
|
||||
C::SetPassengers(p) => self.remote_set_passengers(p.vehicle, &p.passengers),
|
||||
// ClientPacketListener.handleEntityLinkPacket: who holds a mob's
|
||||
// lead (this player, another player or a mob), for the rope.
|
||||
C::SetEntityLink(p) => {
|
||||
let me = self.remote.as_ref().map(|r| r.entity_id);
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.source as u32) {
|
||||
use horizon_client::mobs::leash::Holder;
|
||||
m.extra.leash = match p.dest {
|
||||
0 => None,
|
||||
d if Some(d) == me => Some(Holder::Player),
|
||||
d => Some(Holder::Mob(d as u32)),
|
||||
};
|
||||
}
|
||||
}
|
||||
C::MoveVehicle(p) => self.remote_move_vehicle(p.position, p.yaw),
|
||||
// ClientPacketListener.handleRotateMob: lerpHeadTo(yHeadRot, 3).
|
||||
C::RotateHead(p) => {
|
||||
@@ -1810,13 +1923,12 @@ impl Game<'_> {
|
||||
&& m.species() == horizon_client::mobs::Species::Rabbit
|
||||
{
|
||||
for (index, _, value) in &p.values {
|
||||
match (index, value) {
|
||||
(15, Value::Byte(flags)) => m.no_ai = flags & 1 != 0,
|
||||
(16, Value::Bool(baby)) => m.extra.age = if *baby { -1 } else { 0 },
|
||||
_ => {}
|
||||
if let (15, Value::Byte(flags)) = (index, value) {
|
||||
m.no_ai = flags & 1 != 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.remote_mob_data(p.id, &p.values);
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& m.species() == horizon_client::mobs::Species::Creeper
|
||||
{
|
||||
@@ -1865,11 +1977,27 @@ impl Game<'_> {
|
||||
// player lies down in the bed, or gets up.
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
for (index, _, value) in &p.values {
|
||||
if let (14, Value::OptPos(bed)) = (index, value) {
|
||||
if bed.is_some() && self.sleeping.is_none() {
|
||||
self.sleep_timer = 0;
|
||||
match (index, value) {
|
||||
(14, Value::OptPos(bed)) => {
|
||||
if bed.is_some() && self.sleeping.is_none() {
|
||||
self.sleep_timer = 0;
|
||||
}
|
||||
self.sleeping = *bed;
|
||||
}
|
||||
self.sleeping = *bed;
|
||||
// Entity.DATA_AIR_SUPPLY_ID and
|
||||
// Player.DATA_PLAYER_ABSORPTION_ID: the bubbles
|
||||
// and golden hearts the server's player has.
|
||||
(1, Value::Int(air)) => {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.air = *air;
|
||||
}
|
||||
}
|
||||
(17, Value::Float(amount)) => {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
let _ = pl.internal_set_absorption(*amount);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2061,6 +2189,7 @@ impl Game<'_> {
|
||||
self.capture(false);
|
||||
}
|
||||
}
|
||||
C::TrackedWaypoint(p) => self.handle_waypoint(&p),
|
||||
C::SetExperience(p) => {
|
||||
self.experience = Experience {
|
||||
level: p.level,
|
||||
@@ -2114,6 +2243,10 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
// WIN_GAME: the poem and credits, then PERFORM_RESPAWN.
|
||||
4 if self.screen != Screen::Win => {
|
||||
self.open_win_screen(true, super::win::Finish::RemoteRespawn);
|
||||
}
|
||||
1 => {
|
||||
self.world.weather.raining = true;
|
||||
self.world.weather.rain_level = (0.0, 0.0);
|
||||
@@ -2884,6 +3017,7 @@ impl Game<'_> {
|
||||
|
||||
fn tick_remote_level(&mut self) -> Result<()> {
|
||||
self.ticks += 1;
|
||||
self.track_experience_display();
|
||||
// Options.broadcastOptions: a changed render distance is sent again.
|
||||
let view = client_view_distance(self.settings.render_distance);
|
||||
if let Some(r) = self.remote.as_mut()
|
||||
@@ -2993,6 +3127,13 @@ impl Game<'_> {
|
||||
);
|
||||
let was_flying = player.flying;
|
||||
player.yaw = self.yaw;
|
||||
// As the window's own tick has them: the helmet's Respiration
|
||||
// (decreaseAirSupply) and the dimension's FAST_LAVA, the Nether's
|
||||
// stronger lava current.
|
||||
player.respiration = self.armor[0]
|
||||
.as_ref()
|
||||
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
|
||||
player.fluids.fast_lava = self.dim == Dim::Nether;
|
||||
let dead = self.screen == Screen::Dead;
|
||||
// LivingEntity.isImmobile: a sleeper lies still in the bed.
|
||||
if let Some(head) = self.sleeping {
|
||||
@@ -3145,6 +3286,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let mut lerps = std::mem::take(&mut r.lerps);
|
||||
let stepped = &mut r.stepped;
|
||||
let head_lerps = &mut r.head_lerps;
|
||||
let players = &r.level_players;
|
||||
let mut using = Vec::new();
|
||||
@@ -3175,22 +3317,12 @@ impl Game<'_> {
|
||||
// LivingEntity.updateSwingTime.
|
||||
m.anim.attack_anim_o = m.anim.attack_anim;
|
||||
horizon_client::mobs::ai::tick_swing(m);
|
||||
// Entity.commonTick: LinearInterpolationHandler.interpolate.
|
||||
if let Some(l) = lerps.get_mut(&(m.id as i32))
|
||||
&& l.steps > 0
|
||||
// Entity.tick: the SteppedInterpolationHandler's step.
|
||||
if let Some(at) = stepped
|
||||
.get_mut(&(m.id as i32))
|
||||
.and_then(|h| h.interpolate())
|
||||
{
|
||||
let n = f64::from(l.steps);
|
||||
m.position = Vec3::new(
|
||||
m.position.x + (l.target[0] - m.position.x) / n,
|
||||
m.position.y + (l.target[1] - m.position.y) / n,
|
||||
m.position.z + (l.target[2] - m.position.z) / n,
|
||||
);
|
||||
if let Some((yaw, pitch)) = l.rotation {
|
||||
let alpha = 1.0 / l.steps as f32;
|
||||
m.yaw += wrap_degrees(yaw - m.yaw) * alpha;
|
||||
m.head_pitch += (pitch - m.head_pitch) * alpha;
|
||||
}
|
||||
l.steps -= 1;
|
||||
place_interpolated(m, at);
|
||||
}
|
||||
// LivingEntity.aiStep: the head's own interpolation.
|
||||
if let Some((yaw, steps)) = head_lerps.get_mut(&(m.id as i32))
|
||||
@@ -3465,22 +3597,13 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let facts = self.res.facts().get(state).clone();
|
||||
let held = self.held().cloned();
|
||||
let mut speed = self.res.items().mining_speed(held.as_ref(), &facts.name);
|
||||
if self.player.as_ref().is_some_and(|p| !p.ground.on_ground) {
|
||||
speed /= 5.0;
|
||||
}
|
||||
let harvest = !facts.requires_tool
|
||||
|| self
|
||||
.res
|
||||
.items()
|
||||
.correct_for_drops(held.as_ref(), &facts.name);
|
||||
let progress = if facts.hardness < 0.0 {
|
||||
0.0
|
||||
} else if facts.hardness == 0.0 {
|
||||
// The server's own getDestroyProgress: under water, off the ground,
|
||||
// with haste, fatigue and efficiency, so the client stops digging
|
||||
// when the server is done too (#1254).
|
||||
let progress = if facts.hardness == 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
speed / facts.hardness / if harvest { 30.0 } else { 100.0 }
|
||||
self.destroy_progress(hit.pos)
|
||||
};
|
||||
let same = self
|
||||
.remote
|
||||
@@ -5023,6 +5146,20 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
|
||||
}
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// The local player's profile UUID.
|
||||
pub(super) fn profile(&self) -> u128 {
|
||||
self.profile
|
||||
}
|
||||
/// The entity id of a player in the level, by profile UUID.
|
||||
pub(super) fn player_entity(&self, uuid: u128) -> Option<u32> {
|
||||
self.level_players
|
||||
.iter()
|
||||
.find(|(_, u)| **u == uuid)
|
||||
.map(|(id, _)| *id as u32)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Game;
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! A server's mobs as their synched data has them
|
||||
//! (ClientPacketListener.handleSetEntityData, then each entity's
|
||||
//! `onSyncedDataUpdated`): what a client draws from it rather than from its
|
||||
//! own simulation, which a client of a server doesn't run.
|
||||
use super::Game;
|
||||
use horizon_client::mobs::{Mob, Species, tame};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
|
||||
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
|
||||
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
|
||||
let species = m.species();
|
||||
let baby = super::lan::baby_index(&m.kind.id);
|
||||
for (index, _, value) in values {
|
||||
match (*index, value) {
|
||||
// Entity.DATA_SHARED_FLAGS_ID's FLAG_ONFIRE: Entity.isOnFire on a
|
||||
// client, which draws the flames. A client doesn't count the
|
||||
// fire down: the flag goes off when the server's fire is out.
|
||||
(0, Value::Byte(flags)) => m.fire_ticks = i32::from(flags & 1),
|
||||
// LivingEntity.DATA_HEALTH_ID: the boss bars and the death
|
||||
// animation follow it.
|
||||
(9, Value::Float(health)) => m.health = *health,
|
||||
(i, Value::Bool(b)) if Some(i) == baby => m.extra.age = if *b { -1 } else { 0 },
|
||||
_ => {}
|
||||
}
|
||||
match (species, *index, value) {
|
||||
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 sheared.
|
||||
(Species::Sheep, 18, Value::Byte(wool)) => {
|
||||
m.extra.color = (*wool & 15) as u8;
|
||||
m.extra.sheared = wool & 16 != 0;
|
||||
}
|
||||
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
|
||||
(Species::Wolf | Species::Cat | Species::Parrot, 18, Value::Byte(flags)) => {
|
||||
m.extra.sitting = flags & 1 != 0;
|
||||
m.extra.tame = flags & 4 != 0;
|
||||
}
|
||||
// Wolf.DATA_COLLAR_COLOR and Cat's.
|
||||
(Species::Wolf, 21, Value::Int(c)) | (Species::Cat, 23, Value::Int(c)) => {
|
||||
m.extra.color = (*c & 15) as u8;
|
||||
}
|
||||
// AbstractCubeMob.ID_SIZE: its size and box (refreshDimensions).
|
||||
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
|
||||
m.extra.size = (*size).clamp(1, 127);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// SetEntityData for a mob of the server's.
|
||||
pub(super) fn remote_mob_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
apply(m, values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A mob as a client constructs it before its data arrives: the synched
|
||||
/// defaults the server leaves out (`getNonDefaultValues`) that differ from
|
||||
/// what `Mob::new` gives, such as a wolf's and a cat's red collar.
|
||||
pub(super) fn client_defaults(m: &mut Mob) {
|
||||
if matches!(m.species(), Species::Wolf | Species::Cat) {
|
||||
m.extra.color = tame::RED;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_protocol::entity_data::serializer as s;
|
||||
|
||||
/// A mob of the prepared resources' type `kind`, as a client spawns it
|
||||
/// (None without the resources).
|
||||
fn mob(kind: &str) -> Option<Mob> {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
let types = horizon_client::mobs::MobTypes::load(&root).ok()?;
|
||||
let t = std::sync::Arc::new(types.types.get(kind)?.clone());
|
||||
let mut m = Mob::new(1, t, horizon_core::geometry::Vec3::ZERO, 0.0);
|
||||
client_defaults(&mut m);
|
||||
Some(m)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sheep_takes_its_wool_and_a_calf_its_size() {
|
||||
let Some(mut m) = mob("minecraft:sheep") else {
|
||||
return;
|
||||
};
|
||||
apply(&mut m, &[(18, s::BYTE, Value::Byte(14 | 16))]);
|
||||
assert_eq!((m.extra.color, m.extra.sheared), (14, true));
|
||||
apply(&mut m, &[(18, s::BYTE, Value::Byte(0))]);
|
||||
assert_eq!((m.extra.color, m.extra.sheared), (0, false));
|
||||
let mut calf = mob("minecraft:cow").unwrap();
|
||||
apply(&mut calf, &[(16, s::BOOLEAN, Value::Bool(true))]);
|
||||
assert!(calf.is_baby());
|
||||
apply(&mut calf, &[(16, s::BOOLEAN, Value::Bool(false))]);
|
||||
assert!(!calf.is_baby());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wolf_takes_its_owner_flags_and_collar() {
|
||||
let Some(mut m) = mob("minecraft:wolf") else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(m.extra.color, tame::RED);
|
||||
apply(
|
||||
&mut m,
|
||||
&[
|
||||
(18, s::BYTE, Value::Byte(4 | 1)),
|
||||
(21, s::INT, Value::Int(11)),
|
||||
],
|
||||
);
|
||||
assert!(m.extra.tame && m.extra.sitting);
|
||||
assert_eq!(m.extra.color, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fire_health_and_size_follow_the_server() {
|
||||
let Some(mut m) = mob("minecraft:slime") else {
|
||||
return;
|
||||
};
|
||||
apply(
|
||||
&mut m,
|
||||
&[
|
||||
(0, s::BYTE, Value::Byte(1)),
|
||||
(9, s::FLOAT, Value::Float(12.5)),
|
||||
(18, s::INT, Value::Int(4)),
|
||||
],
|
||||
);
|
||||
assert_eq!((m.fire_ticks, m.health, m.extra.size), (1, 12.5, 4));
|
||||
apply(&mut m, &[(0, s::BYTE, Value::Byte(0))]);
|
||||
assert_eq!(m.fire_ticks, 0);
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,7 @@ impl Game<'_> {
|
||||
hud_overlays::camera_overlays(size[0] as i32, size[1] as i32, &overlays, &mut scope);
|
||||
self.screen_fx.build(&ScreenInputs {
|
||||
sprites: &self.gui.atlas.sprites,
|
||||
flags: &self.gui.atlas.flags,
|
||||
aspect: size[0] as f32 / size[1].max(1) as f32,
|
||||
partial,
|
||||
block_overlay,
|
||||
|
||||
@@ -31,6 +31,33 @@ pub(super) struct RespawnConfig {
|
||||
pub forced: bool,
|
||||
}
|
||||
|
||||
/// A respawn point as a player's save keeps it (`respawn`): dimension,
|
||||
/// block, yaw and whether it is forced.
|
||||
pub(super) type SavedRespawn = (String, [i32; 3], f32, bool);
|
||||
|
||||
impl RespawnConfig {
|
||||
/// `RespawnData.of`: the yaw wrapped to [-180, 180).
|
||||
pub(super) fn wrapped(dim: Dim, pos: [i32; 3], yaw: f32, forced: bool) -> Self {
|
||||
Self {
|
||||
dim,
|
||||
pos,
|
||||
yaw: wrap_degrees(f64::from(yaw)) as f32,
|
||||
forced,
|
||||
}
|
||||
}
|
||||
pub(super) fn from_saved((dim, pos, yaw, forced): &SavedRespawn) -> Self {
|
||||
Self {
|
||||
dim: Dim::from_id(dim),
|
||||
pos: *pos,
|
||||
yaw: *yaw,
|
||||
forced: *forced,
|
||||
}
|
||||
}
|
||||
pub(super) fn saved(&self) -> SavedRespawn {
|
||||
(self.dim.id().to_owned(), self.pos, self.yaw, self.forced)
|
||||
}
|
||||
}
|
||||
|
||||
/// `RespawnAnchorBlock.RESPAWN_HORIZONTAL_OFFSETS`, then the same a block
|
||||
/// below, a block above, and straight above.
|
||||
const HORIZONTAL: [[i32; 3]; 8] = [
|
||||
@@ -62,7 +89,7 @@ pub(super) enum AnchorUse {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn is_anchor(&self, state: BlockStateId) -> bool {
|
||||
pub(super) fn is_anchor(&self, state: BlockStateId) -> bool {
|
||||
&*self.res.facts().get(state).name == "minecraft:respawn_anchor"
|
||||
}
|
||||
fn anchor_charges(&self, state: BlockStateId) -> i32 {
|
||||
@@ -415,6 +442,16 @@ impl Game<'_> {
|
||||
let (lz, hz) = (bounds.min(Axis::Z), bounds.max(Axis::Z) - 1.0e-5);
|
||||
(self.border.contains(lo, lz) && self.border.contains(hi, hz)).then_some(position)
|
||||
}
|
||||
/// `Block.isPossibleToRespawnInThis`: neither solid nor liquid; banners,
|
||||
/// signs and pressure plates override it to always.
|
||||
fn possible_to_respawn_in(&self, state: BlockStateId) -> bool {
|
||||
let f = self.res.facts().get(state);
|
||||
let name = &*f.name;
|
||||
name.ends_with("_banner")
|
||||
|| name.ends_with("_sign")
|
||||
|| name.ends_with("_pressure_plate")
|
||||
|| !(f.legacy_solid || matches!(name, "minecraft:water" | "minecraft:lava"))
|
||||
}
|
||||
/// `RespawnAnchorBlock.findStandUpPosition`: a safe spot around the
|
||||
/// anchor, else any spot.
|
||||
pub(super) fn anchor_stand_up(&self, pos: [i32; 3]) -> Option<Vec3> {
|
||||
@@ -428,8 +465,87 @@ impl Game<'_> {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `ServerPlayer.findRespawnAndUseSpawnBlock` for an anchor config in the
|
||||
/// loaded dimension: the position and yaw, using a charge.
|
||||
/// `AbstractBedBlock.findStandUpPosition` for a bed whose head is at
|
||||
/// `pos`, facing `forward`, set from `yaw`: beside the bed on the side
|
||||
/// the player faced away from, around it, then on it; a safe spot
|
||||
/// first, else any spot (a bunk bed: around it, a block lower, then on
|
||||
/// it).
|
||||
pub(super) fn bed_stand_up(&self, pos: [i32; 3], forward: [i32; 3], yaw: f32) -> Option<Vec3> {
|
||||
// Direction.getClockWise.
|
||||
let right = [-forward[2], 0, forward[0]];
|
||||
// Direction.isFacingAngle.
|
||||
let radians = yaw * 0.017_453_292;
|
||||
let (dx, dz) = (
|
||||
-horizon_core::geometry::trig::sin(f64::from(radians)),
|
||||
horizon_core::geometry::trig::cos(f64::from(radians)),
|
||||
);
|
||||
let facing = right[0] as f32 * dx + right[2] as f32 * dz > 0.0;
|
||||
let side = if facing {
|
||||
[-right[0], 0, -right[2]]
|
||||
} else {
|
||||
right
|
||||
};
|
||||
let (f, s) = ([forward[0], forward[2]], [side[0], side[2]]);
|
||||
// bedSurroundStandUpOffsets and bedAboveStandUpOffsets.
|
||||
let surround = [
|
||||
[s[0], s[1]],
|
||||
[s[0] - f[0], s[1] - f[1]],
|
||||
[s[0] - f[0] * 2, s[1] - f[1] * 2],
|
||||
[-f[0] * 2, -f[1] * 2],
|
||||
[-s[0] - f[0] * 2, -s[1] - f[1] * 2],
|
||||
[-s[0] - f[0], -s[1] - f[1]],
|
||||
[-s[0], -s[1]],
|
||||
[-s[0] + f[0], -s[1] + f[1]],
|
||||
[f[0], f[1]],
|
||||
[s[0] + f[0], s[1] + f[1]],
|
||||
];
|
||||
let above = [[0, 0], [-f[0], -f[1]]];
|
||||
let at = |base: [i32; 3], offsets: &[[i32; 2]], check: bool| {
|
||||
offsets.iter().find_map(|o| {
|
||||
self.safe_dismount_location([base[0] + o[0], base[1], base[2] + o[1]], check)
|
||||
})
|
||||
};
|
||||
let below = [pos[0], pos[1] - 1, pos[2]];
|
||||
// isBunkBed: a bed below.
|
||||
if self.is_bed(self.world.block(below[0], below[1], below[2])) {
|
||||
return at(pos, &surround, true)
|
||||
.or_else(|| at(below, &surround, true))
|
||||
.or_else(|| at(pos, &above, true))
|
||||
.or_else(|| at(pos, &surround, false))
|
||||
.or_else(|| at(below, &surround, false))
|
||||
.or_else(|| at(pos, &above, false));
|
||||
}
|
||||
let all: Vec<[i32; 2]> = surround.into_iter().chain(above).collect();
|
||||
at(pos, &all, true).or_else(|| at(pos, &all, false))
|
||||
}
|
||||
pub(super) fn is_bed(&self, state: BlockStateId) -> bool {
|
||||
self.res.facts().get(state).name.ends_with("_bed")
|
||||
}
|
||||
/// The bed's `facing`, as a step.
|
||||
fn bed_forward(&self, state: BlockStateId) -> [i32; 3] {
|
||||
let facing = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("facing").cloned());
|
||||
match facing.as_deref() {
|
||||
Some("south") => [0, 0, 1],
|
||||
Some("west") => [-1, 0, 0],
|
||||
Some("east") => [1, 0, 0],
|
||||
_ => [0, 0, -1],
|
||||
}
|
||||
}
|
||||
/// `BedRule.canSetSpawn` of the bed's rule attribute (`bed_rule`,
|
||||
/// `straw_bed_rule`): only the Overworld's beds set the spawn, straw
|
||||
/// beds never.
|
||||
pub(super) fn bed_sets_spawn(&self, state: BlockStateId) -> bool {
|
||||
self.dim == Dim::Overworld && &*self.res.facts().get(state).name != "minecraft:straw_bed"
|
||||
}
|
||||
/// `ServerPlayer.findRespawnAndUseSpawnBlock` for a config in the loaded
|
||||
/// dimension: the position and yaw, using an anchor's charge; a bed's
|
||||
/// stand-up spot. A forced point (`spawnpoint`) that is no anchor or bed
|
||||
/// holds if a player fits in it; anything else is gone.
|
||||
pub(super) fn find_respawn_and_use(
|
||||
&mut self,
|
||||
c: RespawnConfig,
|
||||
@@ -440,7 +556,19 @@ impl Game<'_> {
|
||||
&& (c.forced || self.anchor_charges(state) > 0)
|
||||
&& self.anchor_works())
|
||||
{
|
||||
return Ok(None);
|
||||
// A bed where its bed rule lets it set the spawn.
|
||||
if self.is_bed(state) && self.bed_sets_spawn(state) {
|
||||
let facing = self.bed_forward(state);
|
||||
let stand = self.bed_stand_up(c.pos, facing, c.yaw);
|
||||
return Ok(stand.map(|p| (p, look_at_yaw(p, c.pos))));
|
||||
}
|
||||
let [x, y, z] = c.pos;
|
||||
let free = c.forced
|
||||
&& !self.is_anchor(state)
|
||||
&& self.possible_to_respawn_in(state)
|
||||
&& self.possible_to_respawn_in(self.world.block(x, y + 1, z));
|
||||
let at = Vec3::new(f64::from(x) + 0.5, f64::from(y) + 0.1, f64::from(z) + 0.5);
|
||||
return Ok(free.then_some((at, c.yaw)));
|
||||
}
|
||||
let stand = self.anchor_stand_up(c.pos);
|
||||
if !c.forced
|
||||
|
||||
@@ -25,6 +25,9 @@ impl Screen {
|
||||
| Self::Inventory
|
||||
| Self::Dead
|
||||
| Self::CommandBlock => false,
|
||||
// WinScreen.extractBackground: the poem fills the screen with the End portal, the
|
||||
// credits alone blur. Game::menu_blur knows which one rolls.
|
||||
Self::Win => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,6 +362,7 @@ mod tests {
|
||||
Screen::Inventory,
|
||||
Screen::Dead,
|
||||
Screen::CommandBlock,
|
||||
Screen::Win,
|
||||
] {
|
||||
assert!(!screen.blurs_background(true));
|
||||
assert!(!screen.blurs_background(false));
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
//! another level: a Respawn packet keeping all data, then a teleport to the
|
||||
//! exit once the destination has loaded around them.
|
||||
|
||||
use super::end::GatewayJob;
|
||||
use super::nether_portal::{PLAYER_DELAY, Plan};
|
||||
use super::respawn_anchor::RespawnConfig;
|
||||
use super::{Dim, Game, lan::LanHost};
|
||||
use horizon_client::lan::hub::TickCtx;
|
||||
use horizon_client::{lan::hub::TickCtx, text::Text};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
|
||||
/// A trip under way: the player is in the destination's level, whose
|
||||
@@ -22,6 +24,12 @@ pub(super) struct Trip {
|
||||
end: bool,
|
||||
/// The Nether portal plan, whose exit is found or built on arrival.
|
||||
plan: Option<Plan>,
|
||||
/// Through an End gateway, within the level: its exit, found or built
|
||||
/// on arrival.
|
||||
gateway: Option<GatewayJob>,
|
||||
/// A respawn at this point outside the Overworld, resolved through its
|
||||
/// block on arrival.
|
||||
respawn: Option<RespawnConfig>,
|
||||
/// Sent by a command: the player is already where it goes.
|
||||
placed: bool,
|
||||
}
|
||||
@@ -95,7 +103,7 @@ impl Game<'_> {
|
||||
if let Some(pos) = self.nether_portal_inside(at) {
|
||||
self.portals.player.set_inside(pos, PLAYER_DELAY);
|
||||
}
|
||||
let (end_portal, _) = self.inside_end_portals(at, WIDTH, HEIGHT);
|
||||
let (end_portal, gateway) = self.inside_end_portals(at, WIDTH, HEIGHT);
|
||||
if end_portal {
|
||||
if self.portals.player.cooldown == 0 {
|
||||
let target = if self.dim == Dim::End {
|
||||
@@ -109,6 +117,19 @@ impl Game<'_> {
|
||||
self.portals.player.cooldown = 10;
|
||||
return;
|
||||
}
|
||||
// EndGatewayBlock.entityInside: a gateway that is not cooling down
|
||||
// takes the player at once (no transition time) and cools down
|
||||
// (triggerCooldown), even for a player still on its own cooldown.
|
||||
if let Some(g) = gateway
|
||||
&& self.gateway_enter(g)
|
||||
{
|
||||
if self.portals.player.cooldown == 0 {
|
||||
self.start_gateway_trip(host, conn, g);
|
||||
}
|
||||
// setPortalCooldown: after the trip, or again while cooling down.
|
||||
self.portals.player.cooldown = 10;
|
||||
return;
|
||||
}
|
||||
let rule = if invulnerable {
|
||||
"players_nether_portal_creative_delay"
|
||||
} else {
|
||||
@@ -172,11 +193,16 @@ impl Game<'_> {
|
||||
_ => 1.0,
|
||||
};
|
||||
self.end_portal_travel = end;
|
||||
let scaled = self.end_destination(target).unwrap_or(Vec3::new(
|
||||
from.x * scale,
|
||||
from.y,
|
||||
from.z * scale,
|
||||
));
|
||||
// Leaving the End: the player's own respawn point.
|
||||
let own = if end {
|
||||
self.as_guest(host, conn, |g| g.end_destination(target))
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let scaled = own
|
||||
.or_else(|| self.end_destination(target))
|
||||
.unwrap_or(Vec3::new(from.x * scale, from.y, from.z * scale));
|
||||
self.end_portal_travel = false;
|
||||
if let Err(e) = self.enter_level(Some(host), target) {
|
||||
log::error!("could not open {}: {e}", target.id());
|
||||
@@ -197,6 +223,8 @@ impl Game<'_> {
|
||||
target: destination,
|
||||
end,
|
||||
plan,
|
||||
gateway: None,
|
||||
respawn: None,
|
||||
placed: false,
|
||||
});
|
||||
if let Err(e) = self.enter_level(Some(host), source) {
|
||||
@@ -204,6 +232,93 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A respawned player waits at its point outside the Overworld for the
|
||||
/// level to load around it.
|
||||
pub(super) fn respawn_trip(&mut self, conn: u32, config: RespawnConfig) {
|
||||
let [x, y, z] = config.pos;
|
||||
self.server_trips.push(Trip {
|
||||
conn,
|
||||
dim: config.dim,
|
||||
target: Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5),
|
||||
end: false,
|
||||
plan: None,
|
||||
gateway: None,
|
||||
respawn: Some(config),
|
||||
placed: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// `findRespawnAndUseSpawnBlock` for the player in place, at its point
|
||||
/// in the level in place: where it stands up, facing the anchor (with
|
||||
/// the deplete sound) or the bed; else the point is forgotten
|
||||
/// (`block.minecraft.spawn.not_valid`).
|
||||
pub(super) fn resolve_server_respawn(&mut self, config: RespawnConfig) -> Option<(Vec3, f32)> {
|
||||
let [x, y, z] = config.pos;
|
||||
let anchor = self.is_anchor(self.world.block(x, y, z));
|
||||
match self.find_respawn_and_use(config, true) {
|
||||
Ok(Some((at, yaw))) => {
|
||||
if !config.forced && anchor {
|
||||
let [x, y, z] = config.pos.map(f64::from);
|
||||
self.audio.play(
|
||||
"minecraft:block.respawn_anchor.deplete",
|
||||
Some([x, y, z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
Some((at, yaw))
|
||||
}
|
||||
Ok(None) | Err(_) => {
|
||||
self.respawn_config = None;
|
||||
self.system_message(Text::translate("block.minecraft.spawn.not_valid", &[]));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TeleportTransition.missingRespawnBlock: the world spawn, in the
|
||||
/// Overworld.
|
||||
fn respawn_at_world_spawn(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let at = self.level.world_spawn.map_or(
|
||||
self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]),
|
||||
|[x, y, z]| [f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5],
|
||||
);
|
||||
self.level_teleports.push(LevelTeleport {
|
||||
conn,
|
||||
dim: Dim::Overworld,
|
||||
at,
|
||||
rotation: None,
|
||||
});
|
||||
self.run_level_teleports(host);
|
||||
}
|
||||
|
||||
/// `TheEndGatewayBlockEntity.getPortalPosition` and the teleport within
|
||||
/// the End (keeping the player's motion and look): straight to an exact
|
||||
/// exit, else once the destination has loaded around the player, who
|
||||
/// waits there (its exit is found among those blocks, or built).
|
||||
fn start_gateway_trip(&mut self, host: &mut LanHost, conn: u32, gateway: [i32; 3]) {
|
||||
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let (job, destination) = self.gateway_job(gateway);
|
||||
let at = [destination.x, destination.y, destination.z];
|
||||
// Relative.union(DELTA, ROTATION).
|
||||
host.hub.teleport_keeping_motion(i, at);
|
||||
if let GatewayJob::Known { exact: true, .. } = job {
|
||||
return;
|
||||
}
|
||||
self.server_trips.push(Trip {
|
||||
conn,
|
||||
dim: self.dim,
|
||||
target: destination,
|
||||
end: false,
|
||||
plan: None,
|
||||
gateway: Some(job),
|
||||
respawn: None,
|
||||
placed: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// The trips into the level in place whose destination has loaded
|
||||
/// around their target: the exit (the Nether portal found or built, the
|
||||
/// End platform, the Overworld's spawn column) and the teleport there.
|
||||
@@ -232,8 +347,28 @@ impl Game<'_> {
|
||||
}
|
||||
self.resolve_arrivals();
|
||||
let yaw = host.hub.guests[i].yaw;
|
||||
let (position, yaw) = if trip.end {
|
||||
match self.end_arrival(trip.target) {
|
||||
let (position, yaw) = if let Some(config) = trip.respawn {
|
||||
match self.as_guest(host, trip.conn, |g| g.resolve_server_respawn(config)) {
|
||||
Some(Some(found)) => found,
|
||||
_ => {
|
||||
self.respawn_at_world_spawn(host, trip.conn);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else if let Some(job) = trip.gateway {
|
||||
match self.resolve_gateway(job, trip.target) {
|
||||
Ok(at) => (at, yaw),
|
||||
Err(e) => {
|
||||
log::error!("End gateway exit: {e}");
|
||||
(trip.target, yaw)
|
||||
}
|
||||
}
|
||||
} else if trip.end {
|
||||
let arrival = match self.as_guest(host, trip.conn, |g| g.end_arrival(trip.target)) {
|
||||
Some(arrival) => arrival,
|
||||
None => self.end_arrival(trip.target),
|
||||
};
|
||||
match arrival {
|
||||
Ok((at, turned)) => (at, turned.unwrap_or(yaw)),
|
||||
Err(e) => {
|
||||
log::error!("End arrival: {e}");
|
||||
@@ -293,6 +428,8 @@ impl Game<'_> {
|
||||
target: Vec3::new(t.at[0], t.at[1], t.at[2]),
|
||||
end: false,
|
||||
plan: None,
|
||||
gateway: None,
|
||||
respawn: None,
|
||||
placed: true,
|
||||
});
|
||||
if let Err(e) = self.enter_level(Some(host), source) {
|
||||
|
||||
@@ -101,6 +101,21 @@ impl Game<'_> {
|
||||
self.load_shader_pack();
|
||||
}
|
||||
}
|
||||
/// A file dropped on the window: a shader pack while the Shader Packs
|
||||
/// page shows (`OptionsUi::drop_shader_pack`).
|
||||
pub(super) fn drop_shader_pack(&mut self, path: &std::path::Path) {
|
||||
match self.ui.options.drop_shader_pack(&mut self.settings, path) {
|
||||
Some(Ok(())) => {
|
||||
self.settings.save(&Self::options_path());
|
||||
self.load_shader_pack();
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::warn!("Horizon: shader pack {}: {e}", path.display());
|
||||
self.system_message(Text::literal(format!("Shader pack: {e}")));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
fn shader_pack_error(&mut self, id: &str, error: &str) {
|
||||
log::warn!("Horizon: shader pack {id}: {error}");
|
||||
// The Shader Packs page says why on the pack's row: the first line,
|
||||
|
||||
@@ -12,6 +12,8 @@ pub(super) struct StartupMenu {
|
||||
title: Option<Title>,
|
||||
options: OptionsUi,
|
||||
options_open: bool,
|
||||
/// The credits from Credits & Attribution, and their last frame.
|
||||
win: Option<(horizon_client::win_screen::WinScreen, Instant)>,
|
||||
root: PathBuf,
|
||||
cursor: (f32, f32),
|
||||
pointer: (f64, f64),
|
||||
@@ -370,7 +372,26 @@ impl Loading {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
packs::open_folder(&dir);
|
||||
}
|
||||
Event::ButtonClick => {}
|
||||
Event::Credits => {
|
||||
if let Some(gui) = &self.gui {
|
||||
let name = self.options.as_ref().map_or_else(
|
||||
|| horizon_client::offline_name::generate(0),
|
||||
Options::player_name,
|
||||
);
|
||||
let screen = horizon_client::win_screen::WinScreen::new(
|
||||
false,
|
||||
&self.menu.root,
|
||||
&name,
|
||||
&gui.atlas.font,
|
||||
);
|
||||
self.menu.win = Some((screen, Instant::now()));
|
||||
}
|
||||
}
|
||||
Event::ReloadShaderPack => {
|
||||
self.save_menu_settings();
|
||||
}
|
||||
// The startup menu runs before the game opens a control socket.
|
||||
Event::ButtonClick | Event::CopyAgentDetails => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -434,6 +455,17 @@ impl Loading {
|
||||
}
|
||||
self.apply_menu_settings();
|
||||
}
|
||||
WindowEvent::DroppedFile(path) => {
|
||||
match self
|
||||
.menu
|
||||
.options
|
||||
.drop_shader_pack(&mut self.settings, &path)
|
||||
{
|
||||
Some(Ok(())) => self.save_menu_settings(),
|
||||
Some(Err(e)) => log::warn!("Horizon: shader pack {}: {e}", path.display()),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
WindowEvent::RedrawRequested => self.draw(),
|
||||
WindowEvent::ModifiersChanged(modifiers) => self.menu.modifiers = modifiers.state(),
|
||||
WindowEvent::Focused(false) => {
|
||||
@@ -456,6 +488,20 @@ impl Loading {
|
||||
}
|
||||
}
|
||||
_ if self.gui.is_none() || self.menu.pending.is_some() => {}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.menu.win.is_some() => {
|
||||
if let PhysicalKey::Code(code) = event.physical_key {
|
||||
let pressed = event.state == ElementState::Pressed;
|
||||
if code == KeyCode::Escape && pressed {
|
||||
self.menu.win = None;
|
||||
} else if let Some(key) = horizon_client::win_screen::Key::from_code(code)
|
||||
&& let Some((win, _)) = &mut self.menu.win
|
||||
{
|
||||
win.key(key, pressed);
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { .. } | WindowEvent::MouseWheel { .. }
|
||||
if self.menu.win.is_some() => {}
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
self.menu.pointer = (position.x, position.y);
|
||||
self.update_menu_cursor();
|
||||
@@ -575,6 +621,15 @@ impl Loading {
|
||||
self.progress.line(),
|
||||
self.progress.fraction(),
|
||||
);
|
||||
} else if let Some((win, frame)) = &mut self.menu.win {
|
||||
let now = Instant::now();
|
||||
// 20 ticks a second: the startup menu runs no tick rate.
|
||||
let delta_ticks = now.duration_since(*frame).as_secs_f32() * 20.0;
|
||||
*frame = now;
|
||||
win.draw(batch, &gui.atlas, delta_ticks, Default::default());
|
||||
if win.finished(batch.height) {
|
||||
self.menu.win = None;
|
||||
}
|
||||
} else if self.menu.options_open {
|
||||
self.menu
|
||||
.options
|
||||
|
||||
@@ -306,9 +306,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `PhantomSpawner.tick`: tired players under an open night sky draw
|
||||
/// phantoms.
|
||||
/// phantoms. A custom spawner ticks only while `spawn_mobs` is on
|
||||
/// (`ServerChunkCache.tickChunks`), and this one only spawns enemies
|
||||
/// (`ServerLevel.isSpawningMonsters`: `spawn_monsters` too). Every
|
||||
/// player of the level counts: the game's own and the hosted ones (a
|
||||
/// server thread's players are all hosted).
|
||||
pub(super) fn spawn_phantoms(&mut self) {
|
||||
if !self.ui.rules.bool("spawn_phantoms") || self.dim != Dim::Overworld {
|
||||
let rules = &self.ui.rules;
|
||||
if !rules.bool("spawn_mobs")
|
||||
|| !rules.bool("spawn_monsters")
|
||||
|| !rules.bool("spawn_phantoms")
|
||||
|| self.dim != Dim::Overworld
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.stats.phantom_tick -= 1;
|
||||
@@ -317,16 +326,42 @@ impl Game<'_> {
|
||||
}
|
||||
let r = &mut self.world.random;
|
||||
self.stats.phantom_tick += (60 + r.next_int_bound(60).unwrap_or(0)) * 20;
|
||||
if self.sky_darken() < 5 || self.spectator() {
|
||||
if self.sky_darken() < 5 {
|
||||
return;
|
||||
}
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let rest = |s: &Stats| s.get("custom", "minecraft:time_since_rest");
|
||||
// `level.players()`: (block position, time since rest) of each player
|
||||
// who isn't a spectator.
|
||||
let mut players = Vec::new();
|
||||
if let Some(p) = self.player.as_ref()
|
||||
&& !self.spectator()
|
||||
{
|
||||
players.push((p.position, rest(&self.stats)));
|
||||
}
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
let level = self.dim.id();
|
||||
for g in host.hub.guests.iter().filter(|g| {
|
||||
g.placed
|
||||
&& g.dimension == level
|
||||
&& g.game_mode != horizon_client::lan::guest::SPECTATOR
|
||||
}) {
|
||||
let since = host.play.get(&g.conn).map_or(0, |p| rest(&p.stats));
|
||||
players.push((
|
||||
Vec3::new(g.position[0], g.position[1], g.position[2]),
|
||||
since,
|
||||
));
|
||||
}
|
||||
}
|
||||
for (position, since) in players {
|
||||
self.spawn_phantoms_near(position, since);
|
||||
}
|
||||
}
|
||||
/// One player's turn of `PhantomSpawner.tick`.
|
||||
fn spawn_phantoms_near(&mut self, position: Vec3, since_rest: i32) {
|
||||
let pos = [
|
||||
p.position.x.floor() as i32,
|
||||
p.position.y.floor() as i32,
|
||||
p.position.z.floor() as i32,
|
||||
position.x.floor() as i32,
|
||||
position.y.floor() as i32,
|
||||
position.z.floor() as i32,
|
||||
];
|
||||
if pos[1] < 63 || !self.can_see_sky(pos) {
|
||||
return;
|
||||
@@ -336,15 +371,15 @@ impl Game<'_> {
|
||||
if r.next_float() * 3.0 >= 2.0 {
|
||||
return;
|
||||
}
|
||||
let rest = self.stats.get("custom", "minecraft:time_since_rest").max(1);
|
||||
if r.next_int_bound(rest).unwrap_or(0) < 72000 {
|
||||
if r.next_int_bound(since_rest.max(1)).unwrap_or(0) < 72000 {
|
||||
return;
|
||||
}
|
||||
let at = [
|
||||
pos[0] - 10 + r.next_int_bound(21).unwrap_or(10),
|
||||
pos[1] + 20 + r.next_int_bound(15).unwrap_or(0),
|
||||
pos[2] - 10 + r.next_int_bound(21).unwrap_or(10),
|
||||
];
|
||||
// `playerPos.above(20 + nextInt(15)).east(-10 + nextInt(21)).south(...)`:
|
||||
// the height is drawn first.
|
||||
let up = 20 + r.next_int_bound(15).unwrap_or(0);
|
||||
let east = -10 + r.next_int_bound(21).unwrap_or(10);
|
||||
let south = -10 + r.next_int_bound(21).unwrap_or(10);
|
||||
let at = [pos[0] + east, pos[1] + up, pos[2] + south];
|
||||
if !self
|
||||
.res
|
||||
.facts()
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! `WinScreen` in the game: the poem and credits after leaving the End for
|
||||
//! the first time (`EndPortalBlock.entityInside` -> `ServerPlayer.showEndCredits`,
|
||||
//! or a server's `WIN_GAME` event), and the credits from Credits &
|
||||
//! Attribution.
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
hud::{EndPortalFill, HudBatch},
|
||||
win_screen::{Key, WinScreen},
|
||||
};
|
||||
use std::time::Instant;
|
||||
use winit::{
|
||||
event::{ElementState, KeyEvent},
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
};
|
||||
|
||||
/// What closing the screen does (`WinScreen.onFinished`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Finish {
|
||||
/// `PERFORM_RESPAWN` in single player: the player, out of the End since
|
||||
/// the credits began, arrives in the Overworld.
|
||||
Respawn,
|
||||
/// `PERFORM_RESPAWN` sent to the server.
|
||||
RemoteRespawn,
|
||||
/// Back to Credits & Attribution.
|
||||
Options,
|
||||
/// Back to the game (`horizon-ctl credits`).
|
||||
Closed,
|
||||
}
|
||||
pub(super) struct Win {
|
||||
screen: WinScreen,
|
||||
finish: Finish,
|
||||
/// The last drawn frame, for the scroll's game-time delta.
|
||||
frame: Instant,
|
||||
}
|
||||
impl Game<'_> {
|
||||
pub(super) fn open_win_screen(&mut self, poem: bool, finish: Finish) {
|
||||
let screen = WinScreen::new(
|
||||
poem,
|
||||
&self.options.resources,
|
||||
&self.options.player_name(),
|
||||
&self.gui.atlas.font,
|
||||
);
|
||||
self.win = Some(Win {
|
||||
screen,
|
||||
finish,
|
||||
frame: Instant::now(),
|
||||
});
|
||||
self.keys.clear();
|
||||
self.screen = Screen::Win;
|
||||
self.capture(false);
|
||||
}
|
||||
/// `ServerPlayer.showEndCredits`: the player leaves the End now (no
|
||||
/// longer in any level until the credits end) and has seen the credits.
|
||||
pub(super) fn show_end_credits(&mut self) -> Result<()> {
|
||||
self.level.seen_credits = true;
|
||||
self.open_win_screen(true, Finish::Respawn);
|
||||
self.end_portal_travel = true;
|
||||
let result = self.enter_dimension(super::Dim::Overworld);
|
||||
self.end_portal_travel = false;
|
||||
result
|
||||
}
|
||||
/// Stop the scroll `scroll` pixels in (`horizon-ctl credits ... SCROLL`).
|
||||
pub(super) fn hold_win_screen(&mut self, scroll: f32) {
|
||||
if let Some(win) = self.win.as_mut() {
|
||||
win.screen.hold(scroll);
|
||||
}
|
||||
}
|
||||
/// `WinScreen.isPauseScreen`: only the credits without the poem pause.
|
||||
pub(super) fn win_screen_pauses(&self) -> bool {
|
||||
self.win.as_ref().is_some_and(|w| !w.screen.poem)
|
||||
}
|
||||
/// The player waits out of the world while the poem rolls.
|
||||
pub(super) fn win_screen_holds_arrival(&self) -> bool {
|
||||
self.win
|
||||
.as_ref()
|
||||
.is_some_and(|w| w.finish == Finish::Respawn)
|
||||
}
|
||||
pub(super) fn draw_win_screen(&mut self, batch: &mut HudBatch) {
|
||||
let Some(win) = self.win.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let now = Instant::now();
|
||||
// DeltaTracker.getGameTimeDeltaTicks: the frame's length in ticks.
|
||||
let delta_ticks =
|
||||
now.duration_since(win.frame).as_secs_f32() / self.clock.tick_duration().as_secs_f32();
|
||||
win.frame = now;
|
||||
let sprite = |n: &str| {
|
||||
self.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.get(n)
|
||||
.map_or([0.0; 4], |r| [r.u0, r.v0, r.u1 - r.u0, r.v1 - r.v0])
|
||||
};
|
||||
let fill = EndPortalFill {
|
||||
sprites: [
|
||||
sprite("environment/end_sky"),
|
||||
sprite("entity/end_portal/end_portal"),
|
||||
],
|
||||
game_time: (self.world.game_time % 24000) as f32 / 24000.0,
|
||||
};
|
||||
win.screen.draw(batch, &self.gui.atlas, delta_ticks, fill);
|
||||
}
|
||||
/// `WinScreen.keyPressed` / `keyReleased`; Escape closes it (`onClose`).
|
||||
pub(super) fn win_key(&mut self, event: &KeyEvent) -> Result<()> {
|
||||
let PhysicalKey::Code(code) = event.physical_key else {
|
||||
return Ok(());
|
||||
};
|
||||
let pressed = event.state == ElementState::Pressed;
|
||||
if code == KeyCode::Escape && pressed {
|
||||
return self.close_win_screen();
|
||||
}
|
||||
let Some(key) = Key::from_code(code) else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(win) = self.win.as_mut() {
|
||||
win.screen.key(key, pressed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `WinScreen.tick`: done once everything has rolled past the top.
|
||||
pub(super) fn tick_win_screen(&mut self) -> Result<()> {
|
||||
let height = self.gui_size().1;
|
||||
if self.win.as_ref().is_some_and(|w| w.screen.finished(height)) {
|
||||
self.close_win_screen()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `WinScreen.respawn` (`onFinished`) and `removed`: the credits music stops.
|
||||
pub(super) fn close_win_screen(&mut self) -> Result<()> {
|
||||
let Some(win) = self.win.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
let credits = horizon_client::music::Music::credits();
|
||||
self.music
|
||||
.stop_playing_music(&credits, None, &mut self.audio);
|
||||
match win.finish {
|
||||
Finish::Options => self.screen = Screen::Options,
|
||||
Finish::Closed if self.title.is_some() => self.screen = Screen::Playing,
|
||||
Finish::Respawn | Finish::RemoteRespawn | Finish::Closed => {
|
||||
if win.finish == Finish::RemoteRespawn {
|
||||
self.remote_respawn();
|
||||
}
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+489
-454
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,590 @@
|
||||
//! Animated block sprites, drawn on the GPU as vanilla 26.3 draws them:
|
||||
//! `SpriteContents.AnimatedTexture` keeps every frame in video memory, and
|
||||
//! each tick `animate_sprite_blit` or `animate_sprite_interpolate` draws the
|
||||
//! frame shown (mixed with the next one for `interpolate`) into the atlas.
|
||||
//!
|
||||
//! Every frame's mip chain is uploaded once, at the sprite's own size. A tick
|
||||
//! that changes sprites writes one small job per changed sprite and runs one
|
||||
//! compute dispatch per mip level (`animate.wgsl`), which repeats the chain
|
||||
//! into the array's larger layers by whole pixels and mixes interpolated
|
||||
//! frames. Blending on the CPU at the array's size and copying every changed
|
||||
//! sprite level by level (a copy command each, a blit each in Mesa) was about
|
||||
//! a fifth of the render thread on an integrated GPU.
|
||||
use super::upload::Uploads;
|
||||
use crate::mipmap::Settings;
|
||||
use crate::sprites::{FrameBlend, SpriteAnimation};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
/// Bytes of `animate.wgsl`'s `Job`.
|
||||
const JOB_BYTES: usize = 20;
|
||||
/// `animate.wgsl`'s workgroup edge.
|
||||
const WORKGROUP: u32 = 8;
|
||||
|
||||
struct Sprite {
|
||||
animation: SpriteAnimation,
|
||||
/// What its layer shows (`None`: the frame the array was created with,
|
||||
/// rewritten on the first tick).
|
||||
shown: Option<FrameBlend>,
|
||||
/// Its layer in its sprite array.
|
||||
layer: u32,
|
||||
/// Each frame's mip chain: its offset in words in the group's frames.
|
||||
frames: Vec<u32>,
|
||||
/// Edge of the chains' first level.
|
||||
edge: u32,
|
||||
group: usize,
|
||||
}
|
||||
|
||||
/// The animated sprites of one sprite array whose frames share one buffer.
|
||||
struct Group {
|
||||
/// Where its jobs start in `SpriteAnimator::jobs`, in bytes.
|
||||
offset: u64,
|
||||
/// One bind group per mip level of the array.
|
||||
levels: Vec<wgpu::BindGroup>,
|
||||
/// This tick's jobs.
|
||||
jobs: Vec<u8>,
|
||||
}
|
||||
|
||||
pub(super) struct SpriteAnimator {
|
||||
sprites: Vec<Sprite>,
|
||||
groups: Vec<Group>,
|
||||
jobs: wgpu::Buffer,
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
/// Edge of the arrays' layers.
|
||||
size: u32,
|
||||
}
|
||||
|
||||
impl SpriteAnimator {
|
||||
/// `textures` are the sprite arrays, `split` layers each, `size` pixels
|
||||
/// wide; `animations` hold their frames at that size.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
animations: &[SpriteAnimation],
|
||||
size: u32,
|
||||
settings: &[Settings],
|
||||
textures: &[wgpu::Texture],
|
||||
split: u32,
|
||||
) -> Self {
|
||||
let limits = device.limits();
|
||||
let limit = limits
|
||||
.max_storage_buffer_binding_size
|
||||
.min(limits.max_buffer_size);
|
||||
let mut frames: Vec<Vec<u8>> = Vec::new();
|
||||
// Each group's sprite array and frames buffer.
|
||||
let mut keys: Vec<(u32, usize)> = Vec::new();
|
||||
let mut counts: Vec<usize> = Vec::new();
|
||||
let mut sprites = Vec::with_capacity(animations.len());
|
||||
for animation in animations {
|
||||
let chains: Vec<_> = (0..animation.frame_count())
|
||||
.map(|frame| source_chain(animation.pixels(frame), size, settings, animation.layer))
|
||||
.collect();
|
||||
let bytes: u64 = chains.iter().map(|(chain, _)| chain.len() as u64).sum();
|
||||
if bytes > limit {
|
||||
log::warn!(
|
||||
"Horizon: layer {}'s animation ({bytes} bytes) does not fit in a storage buffer; it stays still",
|
||||
animation.layer
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if frames
|
||||
.last()
|
||||
.is_none_or(|data| data.len() as u64 + bytes > limit)
|
||||
{
|
||||
frames.push(Vec::new());
|
||||
}
|
||||
let buffer = frames.len() - 1;
|
||||
let data = &mut frames[buffer];
|
||||
let offsets = chains
|
||||
.iter()
|
||||
.map(|(chain, _)| {
|
||||
let offset = (data.len() / 4) as u32;
|
||||
data.extend_from_slice(chain);
|
||||
offset
|
||||
})
|
||||
.collect();
|
||||
let key = (animation.layer / split, buffer);
|
||||
let group = keys.iter().position(|&k| k == key).unwrap_or_else(|| {
|
||||
keys.push(key);
|
||||
counts.push(0);
|
||||
keys.len() - 1
|
||||
});
|
||||
counts[group] += 1;
|
||||
sprites.push(Sprite {
|
||||
animation: animation.clone(),
|
||||
shown: None,
|
||||
layer: animation.layer % split,
|
||||
frames: offsets,
|
||||
edge: chains[0].1,
|
||||
group,
|
||||
});
|
||||
}
|
||||
let frames: Vec<_> = frames
|
||||
.iter()
|
||||
.map(|data| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Animated sprite frames"),
|
||||
contents: data,
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let align = u64::from(limits.min_storage_buffer_offset_alignment);
|
||||
let mut offsets = Vec::with_capacity(counts.len());
|
||||
let mut total = 0;
|
||||
for &count in &counts {
|
||||
offsets.push(total);
|
||||
total = (total + (count * JOB_BYTES) as u64).next_multiple_of(align);
|
||||
}
|
||||
let jobs = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Animated sprite jobs"),
|
||||
size: total.max(align),
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("animate.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("animate"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let layout = pipeline.get_bind_group_layout(0);
|
||||
let groups = keys
|
||||
.iter()
|
||||
.zip(&counts)
|
||||
.zip(offsets)
|
||||
.map(|((&(array, buffer), &count), offset)| {
|
||||
let texture = &textures[array as usize];
|
||||
let levels = (0..texture.mip_level_count())
|
||||
.map(|level| {
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
||||
base_mip_level: level,
|
||||
mip_level_count: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
layout: &layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: frames[buffer].as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &jobs,
|
||||
offset,
|
||||
size: wgpu::BufferSize::new((count * JOB_BYTES) as u64),
|
||||
}),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Group {
|
||||
offset,
|
||||
levels,
|
||||
jobs: Vec::with_capacity(count * JOB_BYTES),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
sprites,
|
||||
groups,
|
||||
jobs,
|
||||
pipeline,
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Show every sprite's frame at `tick`, rewriting only the sprites whose
|
||||
/// frame or mix changed (`SpriteContents.AnimationState.tick`).
|
||||
pub fn animate(
|
||||
&mut self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut Uploads,
|
||||
tick: u64,
|
||||
) {
|
||||
if self.collect(tick) == 0 {
|
||||
return;
|
||||
}
|
||||
for group in &self.groups {
|
||||
uploads.buffer(encoder, &self.jobs, group.offset, &group.jobs);
|
||||
}
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
for group in &mut self.groups {
|
||||
let count = (group.jobs.len() / JOB_BYTES) as u32;
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
for (level, bind) in group.levels.iter().enumerate() {
|
||||
let groups = (self.size >> level).div_ceil(WORKGROUP);
|
||||
pass.set_bind_group(0, bind, &[]);
|
||||
pass.dispatch_workgroups(groups, groups, count);
|
||||
}
|
||||
group.jobs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let mut count = 0;
|
||||
for sprite in &mut self.sprites {
|
||||
let blend = sprite.animation.blend(tick);
|
||||
if sprite.shown == Some(blend) {
|
||||
continue;
|
||||
}
|
||||
sprite.shown = Some(blend);
|
||||
let job = [
|
||||
sprite.layer,
|
||||
sprite.frames[blend.frame],
|
||||
sprite.frames[blend.next],
|
||||
blend.millis,
|
||||
sprite.edge,
|
||||
];
|
||||
let jobs = &mut self.groups[sprite.group].jobs;
|
||||
for word in job {
|
||||
jobs.extend_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
/// The mip chain a frame's layer is made of, and its first level's edge.
|
||||
/// `mipmap::generate` makes a sprite scaled up by whole pixels to the
|
||||
/// array's size from its own size's chain, its first level repeated into the
|
||||
/// larger levels: that chain, which the shader repeats. Any other sprite: its
|
||||
/// chain at the array's size.
|
||||
fn source_chain(pixels: &[u8], size: u32, settings: &[Settings], layer: u32) -> (Vec<u8>, u32) {
|
||||
let native = settings.get(layer as usize).map_or(0, |s| s.native_size);
|
||||
let edge = if native.is_power_of_two() && native < size {
|
||||
native
|
||||
} else {
|
||||
size
|
||||
};
|
||||
let frame = crate::sprites::resample(pixels, size, edge);
|
||||
(super::mip_chain(&frame, edge, settings, layer, 1).0, edge)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mipmap::Strategy;
|
||||
|
||||
/// `animate.wgsl` on the CPU: `chain` (first level `edge` wide) as the
|
||||
/// full chain of a `size` layer, mixed `millis` thousandths of the way
|
||||
/// towards `next`.
|
||||
fn reference(chain: &[u8], next: &[u8], millis: u32, edge: u32, size: u32) -> Vec<u8> {
|
||||
let levels = size.trailing_zeros() + 1;
|
||||
let mut out = Vec::new();
|
||||
for level in 0..levels {
|
||||
let width = size >> level;
|
||||
for y in 0..width {
|
||||
for x in 0..width {
|
||||
let texel = if width >= edge {
|
||||
let scale = width / edge;
|
||||
y / scale * edge + x / scale
|
||||
} else {
|
||||
4 * (edge * edge - width * width) / 3 + y * width + x
|
||||
} as usize;
|
||||
for c in 0..4 {
|
||||
let (a, b) = (
|
||||
u32::from(chain[texel * 4 + c]),
|
||||
u32::from(next[texel * 4 + c]),
|
||||
);
|
||||
out.push(((a * (1000 - millis) + b * millis + 500) / 1000) as u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn frame(size: u32, seed: u32) -> Vec<u8> {
|
||||
(0..size * size)
|
||||
.flat_map(|i| {
|
||||
let v = i
|
||||
.wrapping_mul(2654435761)
|
||||
.wrapping_add(seed.wrapping_mul(40503));
|
||||
let alpha = match v >> 29 {
|
||||
0 => 0,
|
||||
1 => 77,
|
||||
_ => 255,
|
||||
};
|
||||
[(v >> 3) as u8, (v >> 11) as u8, (v >> 19) as u8, alpha]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn settings(native: u32, strategy: Strategy) -> Settings {
|
||||
Settings {
|
||||
native_size: native,
|
||||
strategy,
|
||||
alpha_cutoff_bias: -0.1,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_chains_repeat_into_the_array_chain() {
|
||||
// Sprites scaled up by whole pixels, at the array's size, and of a
|
||||
// size that is not a power of two (scaled up to the next one).
|
||||
for (native, size) in [(16, 16), (16, 64), (8, 32), (32, 64), (1, 16), (24, 32)] {
|
||||
for strategy in [
|
||||
Strategy::Auto,
|
||||
Strategy::Mean,
|
||||
Strategy::Cutout,
|
||||
Strategy::StrictCutout,
|
||||
Strategy::DarkCutout,
|
||||
] {
|
||||
for item in [false, true] {
|
||||
let settings = [
|
||||
Settings::default(),
|
||||
Settings {
|
||||
item,
|
||||
..settings(native, strategy)
|
||||
},
|
||||
];
|
||||
let pixels = crate::sprites::resample(&frame(native, 3), native, size);
|
||||
let (expected, _) = super::super::mip_chain(&pixels, size, &settings, 1, 1);
|
||||
let (chain, edge) = source_chain(&pixels, size, &settings, 1);
|
||||
let fits = native.is_power_of_two() && native < size;
|
||||
assert_eq!(edge, if fits { native } else { size });
|
||||
assert_eq!(
|
||||
reference(&chain, &chain, 0, edge, size),
|
||||
expected,
|
||||
"{native} in {size}, {strategy:?}, item {item}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_animation_chains_repeat_into_the_array_chain() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root
|
||||
.join("assets/minecraft/textures/block/water_still.png")
|
||||
.exists()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut sprites = crate::sprites::Sprites::default();
|
||||
for name in [
|
||||
"water_still",
|
||||
"water_flow",
|
||||
"lava_still",
|
||||
"fire_0",
|
||||
"nether_portal",
|
||||
"sea_lantern",
|
||||
"prismarine",
|
||||
"magma",
|
||||
"kelp",
|
||||
"straw_bed",
|
||||
] {
|
||||
sprites
|
||||
.insert(&root, &format!("minecraft:block/{name}"))
|
||||
.unwrap();
|
||||
}
|
||||
sprites.finish();
|
||||
assert_eq!(sprites.size, 64);
|
||||
assert_eq!(sprites.animations.len(), 9);
|
||||
for animation in &sprites.animations {
|
||||
for frame in 0..animation.frame_count() {
|
||||
let pixels = animation.pixels(frame);
|
||||
let (expected, _) =
|
||||
super::super::mip_chain(pixels, 64, &sprites.mips, animation.layer, 1);
|
||||
let (chain, edge) = source_chain(pixels, 64, &sprites.mips, animation.layer);
|
||||
assert!(edge < 64, "layer {}", animation.layer);
|
||||
assert_eq!(reference(&chain, &chain, 0, edge, 64), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounding_matches_f32_mix_but_for_exact_halves() {
|
||||
// The CPU blend this replaced: f32 mix, then rounded. They differ
|
||||
// only where the exact mix is a half, which f32 rounded either way.
|
||||
for millis in 1..1000u32 {
|
||||
let t = millis as f32 / 1000.0;
|
||||
for a in 0..=255u32 {
|
||||
for b in (0..=255u32).step_by(3) {
|
||||
let f = ((a as f32 / 255.0) * (1.0 - t) + (b as f32 / 255.0) * t) * 255.0;
|
||||
let exact = a * (1000 - millis) + b * millis;
|
||||
let ours = (exact + 500) / 1000;
|
||||
if exact % 1000 != 500 {
|
||||
assert_eq!(ours, f.round() as u32, "{a} {b} {millis}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
/// Every layer's full mip chain, layer major.
|
||||
fn read_back(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
texture: &wgpu::Texture,
|
||||
) -> Vec<Vec<u8>> {
|
||||
let layers = texture.depth_or_array_layers();
|
||||
let mut out = vec![Vec::new(); layers as usize];
|
||||
for level in 0..texture.mip_level_count() {
|
||||
let edge = texture.width() >> level;
|
||||
let padded = (edge * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(padded * edge * layers),
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture,
|
||||
mip_level: level,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(padded),
|
||||
rows_per_image: Some(edge),
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: edge,
|
||||
height: edge,
|
||||
depth_or_array_layers: layers,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = buffer.slice(..).get_mapped_range().unwrap();
|
||||
for (layer, image) in data.chunks((padded * edge) as usize).enumerate() {
|
||||
for row in image.chunks(padded as usize) {
|
||||
out[layer].extend_from_slice(&row[..edge as usize * 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_layers_match_the_cpu_chains_every_tick() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let size = 32;
|
||||
// Layers 1 and 3: 8-pixel sprites with and without interpolation;
|
||||
// layer 4: one at the array's size. Two layers per array.
|
||||
let clips = [
|
||||
(
|
||||
1,
|
||||
8,
|
||||
r#"{"frametime":3,"interpolate":true,"frames":[0,2,1]}"#,
|
||||
),
|
||||
(3, 8, r#"{"frametime":2}"#),
|
||||
(4, 32, r#"{"frametime":5,"interpolate":true}"#),
|
||||
];
|
||||
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();
|
||||
let split = 2;
|
||||
let textures: Vec<_> = (0..3)
|
||||
.map(|_| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: split,
|
||||
},
|
||||
mip_level_count: size.trailing_zeros() + 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut animator = SpriteAnimator::new(&device, &animations, size, &mips, &textures, split);
|
||||
let mut uploads = Uploads::new(&device);
|
||||
let mut writes = Vec::new();
|
||||
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);
|
||||
writes.push(
|
||||
animator
|
||||
.sprites
|
||||
.iter()
|
||||
.zip(shown)
|
||||
.filter(|(s, shown)| s.shown != *shown)
|
||||
.count(),
|
||||
);
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
for animation in &animations {
|
||||
let blend = animation.blend(tick);
|
||||
let chain = |f| source_chain(animation.pixels(f), size, &mips, animation.layer);
|
||||
let ((current, edge), (next, _)) = (chain(blend.frame), chain(blend.next));
|
||||
let layers = read_back(
|
||||
&device,
|
||||
&queue,
|
||||
&textures[(animation.layer / split) as usize],
|
||||
);
|
||||
assert_eq!(
|
||||
layers[(animation.layer % split) as usize],
|
||||
reference(¤t, &next, blend.millis, edge, size),
|
||||
"layer {} at tick {tick}",
|
||||
animation.layer
|
||||
);
|
||||
}
|
||||
}
|
||||
// Every sprite on the first tick, then only those whose frame or mix
|
||||
// changed since they were written: the interpolated ones every tick
|
||||
// but layer 1 at tick 30 (two 9-tick loops after 12), layer 3 when
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Animated block sprites (gfx/animate.rs), as vanilla's animate_sprite_blit
|
||||
// and animate_sprite_interpolate shaders draw them: each job writes its
|
||||
// frame, or the frame mixed with the next one, into one mip level of its
|
||||
// sprite array layer. One invocation per texel; the job is the z coordinate.
|
||||
struct Job {
|
||||
layer: u32,
|
||||
// Word offsets of the two frames' mip chains in `frames`.
|
||||
current: u32,
|
||||
next: u32,
|
||||
// Thousandths of the way from `current` to `next` (0: `current` alone).
|
||||
millis: u32,
|
||||
// Edge of the chains' first level.
|
||||
edge: u32,
|
||||
}
|
||||
@group(0) @binding(0) var<storage, read> frames: array<u32>;
|
||||
@group(0) @binding(1) var<storage, read> jobs: array<Job>;
|
||||
@group(0) @binding(2) var level: texture_storage_2d_array<rgba8unorm, write>;
|
||||
|
||||
fn channels(rgba: u32) -> vec4<u32> {
|
||||
return vec4(rgba & 0xffu, (rgba >> 8u) & 0xffu, (rgba >> 16u) & 0xffu, rgba >> 24u);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(8, 8)
|
||||
fn animate(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let size = textureDimensions(level).x;
|
||||
if id.x >= size || id.y >= size {
|
||||
return;
|
||||
}
|
||||
let job = jobs[id.z];
|
||||
// mipmap::generate: the levels at least as large as the chain's first
|
||||
// repeat it by whole pixels, the smaller ones are the chain's own.
|
||||
var texel: u32;
|
||||
if size >= job.edge {
|
||||
let scale = size / job.edge;
|
||||
texel = id.y / scale * job.edge + id.x / scale;
|
||||
} else {
|
||||
// Every level above this one, each a quarter of the one before.
|
||||
texel = 4u * (job.edge * job.edge - size * size) / 3u + id.y * size + id.x;
|
||||
}
|
||||
var color = channels(frames[job.current + texel]);
|
||||
if job.millis > 0u {
|
||||
// mix(current, next, millis / 1000), rounded to the nearest 8-bit
|
||||
// value (halves up) in integers: the same on every GPU.
|
||||
let next = channels(frames[job.next + texel]);
|
||||
color = (color * (1000u - job.millis) + next * job.millis + 500u) / 1000u;
|
||||
}
|
||||
textureStore(level, id.xy, job.layer, vec4<f32>(color) / 255.0);
|
||||
}
|
||||
@@ -22,8 +22,16 @@
|
||||
//! Every solid draw is an entry of one of the two lists, so the CPU records
|
||||
//! as many draws as without culling: drivers that unroll an indirect
|
||||
//! multi-draw on the CPU (Mesa's on Intel Gen9) pay per entry. Phase 2's
|
||||
//! counts come from the GPU, which needs a native multi-draw and forced
|
||||
//! early depth tests: Vulkan. Translucent draws are not culled.
|
||||
//! counts come from the GPU, which reads them from the draws themselves:
|
||||
//! Vulkan's native multi-draw, and Metal's, which wgpu replays as one
|
||||
//! indirect draw per entry. Translucent draws are not culled.
|
||||
//!
|
||||
//! Metal cannot force the depth test before a fragment shader that writes
|
||||
//! storage (naga writes no `[[early_fragment_tests]]`), so there the boxes
|
||||
//! are drawn in a pass of their own between the phases, which reads phase
|
||||
//! 1's depth as a texture and makes the same comparison in the shader: the
|
||||
//! fragment's depth against the stored one, greater or equal passing. The
|
||||
//! same fragments pass, so the same sections are seen.
|
||||
use std::num::NonZeroU64;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
@@ -38,6 +46,13 @@ const RETEST: u32 = 8;
|
||||
/// more than they save. Else one frame in `PROBE` tests every section.
|
||||
const MIN_SHARE: f32 = 0.1;
|
||||
const PROBE: u32 = 64;
|
||||
/// On Metal, section draws from which a frame culls. Apple's GPUs store and
|
||||
/// reload the whole frame at each of the passes the test adds, a fixed cost
|
||||
/// that the sections it hides repay only at high render distances: on an M4
|
||||
/// Pro at 1080p and render distance 12 (1,000–1,600 draws) culling added
|
||||
/// about 0.05 ms of GPU time while hiding 15–30% of the solid indices; at
|
||||
/// 32 (drawn flat, over this many draws) it saved 0.25–1.6 ms.
|
||||
pub const TILED_MIN_DRAWS: usize = 4096;
|
||||
/// Boxes over more than this share of the screen count as seen untested.
|
||||
const BIG: f32 = 0.25;
|
||||
/// Results read back at once, some frames late so the CPU never waits.
|
||||
@@ -74,10 +89,40 @@ struct Group {
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
/// Where the boxes' depth test runs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DepthTest {
|
||||
/// Forced before the fragment shader, in phase 1's pass (Vulkan).
|
||||
Early,
|
||||
/// In the fragment shader, against phase 1's depth read as a texture,
|
||||
/// in a pass of its own (Metal).
|
||||
InShader,
|
||||
}
|
||||
|
||||
/// The Vulkan entry point: the depth test forced early, which naga cannot
|
||||
/// write for Metal (so it is not in `cull.wgsl`, which every backend takes).
|
||||
const EARLY_TEST: &str = "
|
||||
@fragment
|
||||
@early_depth_test(force)
|
||||
fn fs_box_early(in: Box) -> @location(0) vec4<f32> {
|
||||
see(in.test);
|
||||
return vec4(0.0);
|
||||
}
|
||||
";
|
||||
|
||||
/// The test's depth texture binding (`InShader`), for the view it was made with.
|
||||
struct DepthGroup {
|
||||
view: wgpu::TextureView,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
pub struct Cull {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
group: Option<Group>,
|
||||
depth_test: DepthTest,
|
||||
depth_layout: Option<wgpu::BindGroupLayout>,
|
||||
depth_group: Option<DepthGroup>,
|
||||
params: wgpu::Buffer,
|
||||
/// Phase 2's draws: the solid draws after phase 1's in the frame's list.
|
||||
pub phase2: wgpu::Buffer,
|
||||
@@ -103,6 +148,8 @@ pub struct Cull {
|
||||
pub hidden: ([usize; 2], f32),
|
||||
/// Cull every frame, whatever the hidden share (A/B checks).
|
||||
pub always: bool,
|
||||
/// Frames with fewer section draws than this do not cull.
|
||||
pub min_draws: usize,
|
||||
}
|
||||
|
||||
fn buffer(
|
||||
@@ -134,20 +181,33 @@ impl Cull {
|
||||
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
|
||||
adapter.features() & wgpu::Features::SHADER_EARLY_DEPTH_TEST
|
||||
}
|
||||
/// Whether the device draws an indirect multi-draw natively and runs
|
||||
/// fragment shaders that write storage only past an early depth test.
|
||||
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
adapter.get_info().backend == wgpu::Backend::Vulkan
|
||||
&& device.features().contains(
|
||||
wgpu::Features::SHADER_EARLY_DEPTH_TEST | wgpu::Features::INDIRECT_FIRST_INSTANCE,
|
||||
)
|
||||
/// Where the device can run the test, if it can: Vulkan with early
|
||||
/// depth tests, or Metal, both drawing an indirect multi-draw's entries
|
||||
/// with the instance counts the GPU wrote, with fragment shaders that
|
||||
/// write storage.
|
||||
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> Option<DepthTest> {
|
||||
let depth_test = match adapter.get_info().backend {
|
||||
wgpu::Backend::Vulkan
|
||||
if device
|
||||
.features()
|
||||
.contains(wgpu::Features::SHADER_EARLY_DEPTH_TEST) =>
|
||||
{
|
||||
DepthTest::Early
|
||||
}
|
||||
wgpu::Backend::Metal => DepthTest::InShader,
|
||||
_ => return None,
|
||||
};
|
||||
(device
|
||||
.features()
|
||||
.contains(wgpu::Features::INDIRECT_FIRST_INSTANCE)
|
||||
&& adapter.get_downlevel_capabilities().flags.contains(
|
||||
wgpu::DownlevelFlags::VERTEX_STORAGE
|
||||
| wgpu::DownlevelFlags::FRAGMENT_WRITABLE_STORAGE,
|
||||
)
|
||||
))
|
||||
.then_some(depth_test)
|
||||
}
|
||||
/// `color` is the world pass's colour format.
|
||||
pub fn new(device: &wgpu::Device, color: wgpu::TextureFormat) -> Self {
|
||||
pub fn new(device: &wgpu::Device, color: wgpu::TextureFormat, depth_test: DepthTest) -> Self {
|
||||
let entry = |binding, visibility, ty| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility,
|
||||
@@ -180,13 +240,41 @@ impl Cull {
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Section culling"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("cull.wgsl").into()),
|
||||
source: wgpu::ShaderSource::Wgsl(source(depth_test).into()),
|
||||
});
|
||||
let depth_layout = (depth_test == DepthTest::InShader).then(|| {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Section culling depth"),
|
||||
entries: &[entry(
|
||||
0,
|
||||
fragment,
|
||||
wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
)],
|
||||
})
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Section culling"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
bind_group_layouts: &[Some(&layout), depth_layout.as_ref()],
|
||||
..Default::default()
|
||||
});
|
||||
// In the shader: no colour target, and phase 1's depth attached read
|
||||
// only (a pass needs an attachment), its test left to the shader.
|
||||
let (fragment_entry, targets, depth_compare) = match depth_test {
|
||||
DepthTest::Early => (
|
||||
"fs_box_early",
|
||||
&[Some(wgpu::ColorTargetState {
|
||||
format: color,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})][..],
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
),
|
||||
DepthTest::InShader => ("fs_box", &[][..], wgpu::CompareFunction::Always),
|
||||
};
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Section culling"),
|
||||
layout: Some(&pipeline_layout),
|
||||
@@ -204,20 +292,16 @@ impl Cull {
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
depth_compare: Some(depth_compare),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs_box"),
|
||||
entry_point: Some(fragment_entry),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: color,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})],
|
||||
targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
@@ -226,6 +310,9 @@ impl Cull {
|
||||
pipeline,
|
||||
layout,
|
||||
group: None,
|
||||
depth_test,
|
||||
depth_layout,
|
||||
depth_group: None,
|
||||
params: buffer(
|
||||
device,
|
||||
"Section culling parameters",
|
||||
@@ -259,6 +346,7 @@ impl Cull {
|
||||
stamp: 0,
|
||||
hidden: ([0; 2], 1.0),
|
||||
always: false,
|
||||
min_draws: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,17 +487,69 @@ impl Cull {
|
||||
}
|
||||
}
|
||||
|
||||
/// In the world pass after phase 1: draw the tested boxes against its
|
||||
/// depth. Leaves the pass's pipeline and group 0 changed.
|
||||
pub fn depth_test(&self) -> DepthTest {
|
||||
self.depth_test
|
||||
}
|
||||
|
||||
/// In the world pass after phase 1 (`Early`): draw the tested boxes
|
||||
/// against its depth. Leaves the pass's pipeline and group 0 changed.
|
||||
pub fn test(&self, pass: &mut wgpu::RenderPass<'_>) {
|
||||
let Some(Group { group, .. }) = &self.group else {
|
||||
return;
|
||||
};
|
||||
if self.depth_test != DepthTest::Early || self.test_data.is_empty() {
|
||||
return;
|
||||
}
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.draw(0..36, 0..self.test_data.len() as u32);
|
||||
}
|
||||
|
||||
/// Before the world pass (`InShader`): the binding of the depth the
|
||||
/// world is drawn with this frame.
|
||||
pub fn bind_depth(&mut self, device: &wgpu::Device, depth: &wgpu::TextureView) {
|
||||
let Some(layout) = &self.depth_layout else {
|
||||
return;
|
||||
};
|
||||
if self.depth_group.as_ref().is_none_or(|g| g.view != *depth) {
|
||||
self.depth_group = Some(DepthGroup {
|
||||
view: depth.clone(),
|
||||
group: device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Section culling depth"),
|
||||
layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(depth),
|
||||
}],
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Between the phases (`InShader`): draw the tested boxes in a pass of
|
||||
/// their own that reads phase 1's depth, bound by `bind_depth`.
|
||||
pub fn test_pass(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let (Some(Group { group, .. }), Some(DepthGroup { view, group: depth })) =
|
||||
(&self.group, &self.depth_group)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.test_data.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Section culling"),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view,
|
||||
depth_ops: None,
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.set_bind_group(1, depth, &[]);
|
||||
pass.draw(0..36, 0..self.test_data.len() as u32);
|
||||
}
|
||||
|
||||
@@ -498,16 +638,33 @@ impl Cull {
|
||||
}
|
||||
}
|
||||
|
||||
/// The test's shader for `depth_test`.
|
||||
fn source(depth_test: DepthTest) -> String {
|
||||
let common = include_str!("cull.wgsl");
|
||||
match depth_test {
|
||||
DepthTest::Early => format!("{common}{EARLY_TEST}"),
|
||||
DepthTest::InShader => common.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shader_validates() {
|
||||
let module = naga::front::wgsl::parse_str(include_str!("cull.wgsl")).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
fn shaders_validate() {
|
||||
for (depth_test, capabilities) in [
|
||||
(DepthTest::Early, naga::valid::Capabilities::all()),
|
||||
// What wgpu's Metal backend allows: no early depth test.
|
||||
(
|
||||
DepthTest::InShader,
|
||||
naga::valid::Capabilities::all() - naga::valid::Capabilities::EARLY_DEPTH_TEST,
|
||||
),
|
||||
] {
|
||||
let module = naga::front::wgsl::parse_str(&source(depth_test)).unwrap();
|
||||
naga::valid::Validator::new(naga::valid::ValidationFlags::all(), capabilities)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{depth_test:?}: {e:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,16 +86,28 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
|
||||
return out;
|
||||
}
|
||||
|
||||
// Runs only for fragments that pass the depth test (forced early).
|
||||
@fragment
|
||||
@early_depth_test(force)
|
||||
fn fs_box(in: Box) -> @location(0) vec4<f32> {
|
||||
if results[in.test] != params.stamp {
|
||||
results[in.test] = params.stamp;
|
||||
let test = tests[in.test];
|
||||
// The box is seen at this fragment: store the stamp, and give its phase 2
|
||||
// draws their instance.
|
||||
fn see(t: u32) {
|
||||
if results[t] != params.stamp {
|
||||
results[t] = params.stamp;
|
||||
let test = tests[t];
|
||||
for (var k = 0u; k < test.z; k++) {
|
||||
phase2[(test.y + k) * 5u + 1u] = 1u;
|
||||
}
|
||||
}
|
||||
return vec4(0.0);
|
||||
}
|
||||
|
||||
// Phase 1's depth, where the test runs in the shader (gfx/cull.rs).
|
||||
@group(1) @binding(0) var depth: texture_depth_2d;
|
||||
|
||||
// The world pass's reversed depth test, greater or equal passing, made here:
|
||||
// where it cannot be forced before a shader that writes storage, a fragment
|
||||
// that fails it would still write. Where it can, `fs_box_early` (cull.rs)
|
||||
// runs only for the fragments that pass it.
|
||||
@fragment
|
||||
fn fs_box(in: Box) {
|
||||
if in.position.z >= textureLoad(depth, vec2<u32>(in.position.xy), 0) {
|
||||
see(in.test);
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,37 @@
|
||||
//! so each block names one section: its id here, which indexes the frame's
|
||||
//! camera-relative origins (computed on the CPU exactly as for the instance
|
||||
//! attribute).
|
||||
//!
|
||||
//! With occlusion culling (`cull`), phase 1's draws and the translucent ones
|
||||
//! are copied before the world pass, as without it. Phase 2's draws are
|
||||
//! copied after the test, which tells which ones it saw: each run of a page
|
||||
//! reserves room for all of its draws, the copy packs the seen ones at its
|
||||
//! start, and the run is drawn indirectly with the count the copy reached.
|
||||
use super::arena::GRAIN;
|
||||
|
||||
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
|
||||
/// `QUADS` for a solid draw), base vertex, first index in the list.
|
||||
/// `QUADS` for a solid draw; phase 2: its entry in phase 2's draws), base
|
||||
/// vertex, first index in the list (phase 2: its draw call in phase 2).
|
||||
type Range = [u32; 4];
|
||||
|
||||
/// Where a group of ranges takes its indices.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Source {
|
||||
/// Solid draws: quads.
|
||||
Quads,
|
||||
/// Occlusion culling's phase 2: quads, of the sections its test saw.
|
||||
Seen,
|
||||
/// Translucent draws: this sorted index page.
|
||||
Sorted(u32),
|
||||
}
|
||||
|
||||
/// The frame's draw calls, as `plan` lays them out.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Plan {
|
||||
phase1_calls: usize,
|
||||
solid_calls: usize,
|
||||
total: u64,
|
||||
}
|
||||
const QUADS: u32 = u32::MAX;
|
||||
const GROUP_LIMIT: u32 = 65535;
|
||||
|
||||
@@ -38,6 +64,15 @@ pub struct Flat {
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
flatten: wgpu::ComputePipeline,
|
||||
compute_layout: wgpu::BindGroupLayout,
|
||||
flatten_seen: wgpu::ComputePipeline,
|
||||
seen_layout: wgpu::BindGroupLayout,
|
||||
/// Phase 2's draw calls (`DrawIndexedIndirectArgs`), whose index counts
|
||||
/// `flatten_seen` makes.
|
||||
phase2_calls: wgpu::Buffer,
|
||||
phase2_call_capacity: u64,
|
||||
phase2_call_data: Vec<[u32; 5]>,
|
||||
/// The copy of phase 2's draws, and its ranges.
|
||||
phase2_group: Option<(wgpu::BindGroup, u32)>,
|
||||
draw_layout: wgpu::BindGroupLayout,
|
||||
blocks_per_page: u64,
|
||||
pages: Vec<Option<PageTable>>,
|
||||
@@ -52,12 +87,14 @@ pub struct Flat {
|
||||
ranges: wgpu::Buffer,
|
||||
range_capacity: u64,
|
||||
range_data: Vec<Range>,
|
||||
/// Segments of `range_data` by index source: (sorted index page or
|
||||
/// `None` for solid draws, first range, ranges).
|
||||
segments: Vec<(Option<u32>, u64, u64)>,
|
||||
/// Segments of `range_data` by index source: (source, first range,
|
||||
/// ranges).
|
||||
segments: Vec<(Source, u64, u64)>,
|
||||
empty: wgpu::Buffer,
|
||||
/// This frame's draw calls: solid ones, then translucent ones.
|
||||
/// This frame's draw calls: solid ones (phase 1's, then phase 2's when
|
||||
/// culling), then translucent ones.
|
||||
pub calls: Vec<Call>,
|
||||
pub phase1_calls: usize,
|
||||
pub solid_calls: usize,
|
||||
/// Frames with fewer section draws keep the per-section draws.
|
||||
pub min_draws: usize,
|
||||
@@ -121,42 +158,60 @@ fn buffer(
|
||||
/// Lays out the frame's index list: each draw's range (destinations in draw
|
||||
/// order) grouped by index source into `ranges`, every group padded to
|
||||
/// `align` entries for binding, with `segments` naming each group; and the
|
||||
/// draw calls, one per run of draws in the same vertex page, solid and
|
||||
/// translucent apart. Returns the first translucent call and the list's
|
||||
/// length.
|
||||
/// draw calls, one per run of draws in the same vertex page, phase 1's solid
|
||||
/// ones, phase 2's (draws `phase1_draws..solid_draws`) and translucent ones
|
||||
/// apart. A phase 2 range names its draw in phase 2 and its call in phase 2
|
||||
/// instead of a destination: the call's room holds all of its draws.
|
||||
fn plan(
|
||||
draws: &[[u32; 5]],
|
||||
pages: &[(u32, Option<u32>)],
|
||||
phase1_draws: usize,
|
||||
solid_draws: usize,
|
||||
align: u64,
|
||||
calls: &mut Vec<Call>,
|
||||
ranges: &mut Vec<Range>,
|
||||
segments: &mut Vec<(Option<u32>, u64, u64)>,
|
||||
) -> (usize, u64) {
|
||||
segments: &mut Vec<(Source, u64, u64)>,
|
||||
) -> Plan {
|
||||
calls.clear();
|
||||
ranges.clear();
|
||||
segments.clear();
|
||||
let mut solid_calls = None;
|
||||
let (mut phase1_calls, mut solid_calls) = (None, None);
|
||||
let mut total = 0u64;
|
||||
let mut by_source: Vec<(Option<u32>, Vec<Range>)> = Vec::new();
|
||||
let mut by_source: Vec<(Source, Vec<Range>)> = Vec::new();
|
||||
for (i, (d, &(page, sorted))) in draws.iter().zip(pages).enumerate() {
|
||||
let first = if sorted.is_some() { d[2] } else { QUADS };
|
||||
let range = [d[0], first, d[3], total as u32];
|
||||
match by_source.iter_mut().find(|(s, _)| *s == sorted) {
|
||||
Some((_, list)) => list.push(range),
|
||||
None => by_source.push((sorted, vec![range])),
|
||||
if i == phase1_draws {
|
||||
phase1_calls.get_or_insert(calls.len());
|
||||
}
|
||||
if i == solid_draws {
|
||||
solid_calls = Some(calls.len());
|
||||
}
|
||||
match calls.last_mut() {
|
||||
Some(last) if last.page == page && i != solid_draws => last.count += d[0],
|
||||
Some(last) if last.page == page && i != phase1_draws && i != solid_draws => {
|
||||
last.count += d[0]
|
||||
}
|
||||
_ => calls.push(Call {
|
||||
page,
|
||||
first: total as u32,
|
||||
count: d[0],
|
||||
}),
|
||||
}
|
||||
let (source, range) = match sorted {
|
||||
Some(p) => (Source::Sorted(p), [d[0], d[2], d[3], total as u32]),
|
||||
None if (phase1_draws..solid_draws).contains(&i) => (
|
||||
Source::Seen,
|
||||
[
|
||||
d[0],
|
||||
(i - phase1_draws) as u32,
|
||||
d[3],
|
||||
(calls.len() - 1 - phase1_calls.unwrap_or(0)) as u32,
|
||||
],
|
||||
),
|
||||
None => (Source::Quads, [d[0], QUADS, d[3], total as u32]),
|
||||
};
|
||||
match by_source.iter_mut().find(|(s, _)| *s == source) {
|
||||
Some((_, list)) => list.push(range),
|
||||
None => by_source.push((source, vec![range])),
|
||||
}
|
||||
total += u64::from(d[0]);
|
||||
}
|
||||
for (source, list) in by_source {
|
||||
@@ -165,7 +220,12 @@ fn plan(
|
||||
let padded = (ranges.len() as u64).next_multiple_of(align);
|
||||
ranges.resize(padded as usize, [0; 4]);
|
||||
}
|
||||
(solid_calls.unwrap_or(calls.len()), total)
|
||||
let solid_calls = solid_calls.unwrap_or(calls.len());
|
||||
Plan {
|
||||
phase1_calls: phase1_calls.unwrap_or(calls.len()).min(solid_calls),
|
||||
solid_calls,
|
||||
total,
|
||||
}
|
||||
}
|
||||
|
||||
impl Flat {
|
||||
@@ -201,24 +261,37 @@ impl Flat {
|
||||
storage(2, compute, false),
|
||||
],
|
||||
});
|
||||
let seen_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Flatten phase 2 section draws"),
|
||||
entries: &[
|
||||
storage(0, compute, true),
|
||||
storage(2, compute, false),
|
||||
storage(3, compute, true),
|
||||
storage(4, compute, false),
|
||||
],
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("flatten.wgsl").into()),
|
||||
});
|
||||
let flatten = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
layout: Some(
|
||||
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
bind_group_layouts: &[Some(&compute_layout)],
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
module: &module,
|
||||
entry_point: Some("flatten"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let compute_pipeline = |layout: &wgpu::BindGroupLayout, entry| {
|
||||
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
layout: Some(
|
||||
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
bind_group_layouts: &[Some(layout)],
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
module: &module,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let flatten = compute_pipeline(&compute_layout, "flatten");
|
||||
let flatten_seen = compute_pipeline(&seen_layout, "flatten_seen");
|
||||
let storage_usage = wgpu::BufferUsages::STORAGE;
|
||||
Self {
|
||||
solid: pipeline(&layout, "Terrain solid (flat)", "fs_solid", None),
|
||||
@@ -230,6 +303,17 @@ impl Flat {
|
||||
),
|
||||
flatten,
|
||||
compute_layout,
|
||||
flatten_seen,
|
||||
seen_layout,
|
||||
phase2_calls: buffer(
|
||||
device,
|
||||
"Flat section draw calls, phase 2",
|
||||
20,
|
||||
wgpu::BufferUsages::INDIRECT | storage_usage,
|
||||
),
|
||||
phase2_call_capacity: 1,
|
||||
phase2_call_data: Vec::new(),
|
||||
phase2_group: None,
|
||||
draw_layout,
|
||||
blocks_per_page: page_vertices.div_ceil(GRAIN),
|
||||
pages: Vec::new(),
|
||||
@@ -251,6 +335,7 @@ impl Flat {
|
||||
segments: Vec::new(),
|
||||
empty: buffer(device, "No sorted indices", 16, storage_usage),
|
||||
calls: Vec::new(),
|
||||
phase1_calls: 0,
|
||||
solid_calls: 0,
|
||||
min_draws: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
|
||||
0
|
||||
@@ -321,6 +406,9 @@ impl Flat {
|
||||
/// the origins of the sections they draw by id. Records the copy into
|
||||
/// `encoder`; `calls` then lists the frame's draw calls. False when the
|
||||
/// frame cannot be drawn flat (the per-section draws then apply).
|
||||
///
|
||||
/// When culling, the solid draws from `phase1_draws` are phase 2's, which
|
||||
/// `phase2` holds as the test leaves them: `flatten_phase2` copies them.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
@@ -329,10 +417,18 @@ impl Flat {
|
||||
uploads: &mut super::upload::Uploads,
|
||||
draws: &[[u32; 5]],
|
||||
pages: &[(u32, Option<u32>)],
|
||||
phase1_draws: usize,
|
||||
solid_draws: usize,
|
||||
phase2: Option<&wgpu::Buffer>,
|
||||
origins: impl Iterator<Item = (u32, [f32; 4])>,
|
||||
sorted_page: impl Fn(u32) -> wgpu::Buffer,
|
||||
) -> bool {
|
||||
self.phase2_group = None;
|
||||
let phase1_draws = if phase2.is_some() {
|
||||
phase1_draws
|
||||
} else {
|
||||
solid_draws
|
||||
};
|
||||
// Origins by section id.
|
||||
self.origin_data.clear();
|
||||
for (id, o) in origins {
|
||||
@@ -362,18 +458,25 @@ impl Flat {
|
||||
bytemuck::cast_slice(&self.origin_data),
|
||||
);
|
||||
}
|
||||
let (solid_calls, total) = plan(
|
||||
let Plan {
|
||||
phase1_calls,
|
||||
solid_calls,
|
||||
total,
|
||||
} = plan(
|
||||
draws,
|
||||
pages,
|
||||
phase1_draws,
|
||||
solid_draws,
|
||||
self.range_align,
|
||||
&mut self.calls,
|
||||
&mut self.range_data,
|
||||
&mut self.segments,
|
||||
);
|
||||
self.phase1_calls = phase1_calls;
|
||||
self.solid_calls = solid_calls;
|
||||
if total == 0 || total * 4 > device.limits().max_storage_buffer_binding_size {
|
||||
self.calls.clear();
|
||||
self.phase1_calls = 0;
|
||||
self.solid_calls = 0;
|
||||
return false;
|
||||
}
|
||||
@@ -401,22 +504,82 @@ impl Flat {
|
||||
0,
|
||||
bytemuck::cast_slice(&self.range_data),
|
||||
);
|
||||
// Phase 2's calls start empty, each at the start of its room.
|
||||
self.phase2_call_data.clear();
|
||||
self.phase2_call_data.extend(
|
||||
self.calls[phase1_calls..solid_calls]
|
||||
.iter()
|
||||
.map(|c| [0, 1, c.first, 0, 0]),
|
||||
);
|
||||
let phase2_calls = self.phase2_call_data.len() as u64;
|
||||
if phase2_calls > self.phase2_call_capacity {
|
||||
self.phase2_call_capacity = phase2_calls.next_power_of_two();
|
||||
self.phase2_calls = buffer(
|
||||
device,
|
||||
"Flat section draw calls, phase 2",
|
||||
self.phase2_call_capacity * 20,
|
||||
wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
}
|
||||
if phase2_calls > 0 {
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
&self.phase2_calls,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.phase2_call_data),
|
||||
);
|
||||
}
|
||||
let ranges_at = |first: u64, count: u64| {
|
||||
wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &self.ranges,
|
||||
offset: first * 16,
|
||||
size: wgpu::BufferSize::new(count * 16),
|
||||
})
|
||||
};
|
||||
if let (Some(phase2), Some(&(_, first, count))) = (
|
||||
phase2,
|
||||
self.segments.iter().find(|(s, ..)| *s == Source::Seen),
|
||||
) {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Flatten phase 2 section draws"),
|
||||
layout: &self.seen_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: ranges_at(first, count),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.indices.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: phase2.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: self.phase2_calls.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.phase2_group = Some((group, count as u32));
|
||||
}
|
||||
let groups: Vec<(wgpu::BindGroup, u32)> = self
|
||||
.segments
|
||||
.iter()
|
||||
.map(|&(source, first, count)| {
|
||||
let sorted = source.map(&sorted_page);
|
||||
.filter_map(|&(source, first, count)| {
|
||||
let sorted = match source {
|
||||
Source::Quads => None,
|
||||
Source::Seen => return None,
|
||||
Source::Sorted(page) => Some(sorted_page(page)),
|
||||
};
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
layout: &self.compute_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &self.ranges,
|
||||
offset: first * 16,
|
||||
size: wgpu::BufferSize::new(count * 16),
|
||||
}),
|
||||
resource: ranges_at(first, count),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
@@ -428,7 +591,7 @@ impl Flat {
|
||||
},
|
||||
],
|
||||
});
|
||||
(group, count as u32)
|
||||
Some((group, count as u32))
|
||||
})
|
||||
.collect();
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
@@ -464,6 +627,19 @@ impl Flat {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// After occlusion culling's test: copies the phase 2 draws it saw.
|
||||
pub fn flatten_phase2(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some((group, count)) = &self.phase2_group else {
|
||||
return;
|
||||
};
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Flatten phase 2 section draws"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&self.flatten_seen);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
/// Draws `calls` with a flat pipeline set and `vertices` giving each
|
||||
/// page's vertex buffer.
|
||||
pub fn draw<'a>(
|
||||
@@ -474,15 +650,38 @@ impl Flat {
|
||||
) {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for call in calls {
|
||||
let table = self.pages[call.page as usize]
|
||||
.as_ref()
|
||||
.and_then(|t| t.group.as_ref())
|
||||
.expect("prepared page");
|
||||
pass.set_vertex_buffer(0, vertices(call.page).slice(..));
|
||||
pass.set_bind_group(1, table, &[]);
|
||||
self.bind_page(pass, call.page, &vertices);
|
||||
pass.draw_indexed(call.first..call.first + call.count, 0, 0..1);
|
||||
}
|
||||
}
|
||||
/// Draws phase 2's calls, as `flatten_phase2` left them.
|
||||
pub fn draw_phase2<'a>(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
) {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for (k, call) in self.calls[self.phase1_calls..self.solid_calls]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
self.bind_page(pass, call.page, &vertices);
|
||||
pass.draw_indexed_indirect(&self.phase2_calls, k as u64 * 20);
|
||||
}
|
||||
}
|
||||
fn bind_page<'a>(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
page: u32,
|
||||
vertices: &impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
) {
|
||||
let table = self.pages[page as usize]
|
||||
.as_ref()
|
||||
.and_then(|t| t.group.as_ref())
|
||||
.expect("prepared page");
|
||||
pass.set_vertex_buffer(0, vertices(page).slice(..));
|
||||
pass.set_bind_group(1, table, &[]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -516,16 +715,22 @@ mod tests {
|
||||
fn plan_draws_runs_of_a_page_in_order() {
|
||||
let (draws, pages, solid) = sample();
|
||||
let (mut calls, mut ranges, mut segments) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let (solid_calls, total) = plan(
|
||||
let Plan {
|
||||
phase1_calls,
|
||||
solid_calls,
|
||||
total,
|
||||
} = plan(
|
||||
&draws,
|
||||
&pages,
|
||||
solid,
|
||||
solid,
|
||||
4,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
);
|
||||
assert_eq!(total, 6 * 13);
|
||||
assert_eq!(phase1_calls, solid_calls);
|
||||
let call = |page, first, count| Call { page, first, count };
|
||||
// Pages 0, 0 | 1 | 0, then translucent 0, 0 (never merged with solid).
|
||||
assert_eq!(
|
||||
@@ -539,16 +744,65 @@ mod tests {
|
||||
);
|
||||
assert_eq!(solid_calls, 3);
|
||||
// Solid ranges, then each sorted page's, every group padded to 4.
|
||||
assert_eq!(segments, [(None, 0, 4), (Some(0), 4, 1), (Some(1), 8, 1)]);
|
||||
assert_eq!(
|
||||
segments,
|
||||
[
|
||||
(Source::Quads, 0, 4),
|
||||
(Source::Sorted(0), 4, 1),
|
||||
(Source::Sorted(1), 8, 1)
|
||||
]
|
||||
);
|
||||
assert_eq!(ranges[3], [24, QUADS, 1024, 36]);
|
||||
assert_eq!(ranges[4], [12, 6, 768, 60]);
|
||||
assert_eq!(ranges[8], [6, 0, 40, 72]);
|
||||
assert_eq!(ranges.len(), 12);
|
||||
}
|
||||
|
||||
/// Phase 2's draws (here the third and fourth) get calls of their own,
|
||||
/// each with room for all of its draws, and ranges naming their draw
|
||||
/// and call in phase 2.
|
||||
#[test]
|
||||
fn plan_gives_phase_2_calls_of_its_own() {
|
||||
let (draws, pages, solid) = sample();
|
||||
let (mut calls, mut ranges, mut segments) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let plan = plan(
|
||||
&draws,
|
||||
&pages,
|
||||
2,
|
||||
solid,
|
||||
4,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
);
|
||||
assert_eq!(
|
||||
plan,
|
||||
Plan {
|
||||
phase1_calls: 1,
|
||||
solid_calls: 3,
|
||||
total: 6 * 13
|
||||
}
|
||||
);
|
||||
let call = |page, first, count| Call { page, first, count };
|
||||
// Phase 1: page 0 | phase 2: page 1, page 0 | translucent: page 0.
|
||||
assert_eq!(
|
||||
calls,
|
||||
[
|
||||
call(0, 0, 30),
|
||||
call(1, 30, 6),
|
||||
call(0, 36, 24),
|
||||
call(0, 60, 18)
|
||||
]
|
||||
);
|
||||
assert_eq!(segments[1], (Source::Seen, 4, 2));
|
||||
assert_eq!(ranges[4], [6, 0, 256, 0]);
|
||||
assert_eq!(ranges[5], [24, 1, 1024, 1]);
|
||||
}
|
||||
|
||||
/// The compute pass writes exactly the indices the per-section draws
|
||||
/// read: quads 0 1 2 0 2 3 for solid draws, sorted indices otherwise,
|
||||
/// plus the base vertex.
|
||||
/// plus the base vertex. With culling, phase 2's runs hold the indices
|
||||
/// of the draws the test saw, in order, and their calls count them.
|
||||
#[test]
|
||||
fn flatten_writes_the_indices_of_the_per_section_draws() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
@@ -564,101 +818,170 @@ mod tests {
|
||||
use wgpu::util::DeviceExt;
|
||||
let (draws, pages, solid) = sample();
|
||||
let align = (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16).max(1);
|
||||
let (mut calls, mut ranges, mut segments) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let (_, total) = plan(
|
||||
&draws,
|
||||
&pages,
|
||||
solid,
|
||||
align,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
);
|
||||
let sorted: [Vec<u32>; 2] = [
|
||||
(0..32).map(|i| (i * 7) % 32).collect(),
|
||||
(0..8).rev().collect(),
|
||||
];
|
||||
let mut expected = Vec::new();
|
||||
for (d, (_, page)) in draws.iter().zip(&pages) {
|
||||
for k in 0..d[0] {
|
||||
let i = match page {
|
||||
None => (k / 6) * 4 + [0, 1, 2, 0, 2, 3][(k % 6) as usize],
|
||||
Some(p) => sorted[*p as usize][(d[2] + k) as usize],
|
||||
};
|
||||
expected.push(d[3] + i);
|
||||
}
|
||||
}
|
||||
let storage = |entries: &[u32]| {
|
||||
let indices = |d: &[u32; 5], sorted_page: Option<u32>| -> Vec<u32> {
|
||||
(0..d[0])
|
||||
.map(|k| {
|
||||
d[3] + match sorted_page {
|
||||
None => (k / 6) * 4 + [0, 1, 2, 0, 2, 3][(k % 6) as usize],
|
||||
Some(p) => sorted[p as usize][(d[2] + k) as usize],
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let storage = |entries: &[u32], usage| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(entries),
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
usage: wgpu::BufferUsages::STORAGE | usage,
|
||||
})
|
||||
};
|
||||
let range_buffer = storage(bytemuck::cast_slice(&ranges));
|
||||
let sorted_buffers = [storage(&sorted[0]), storage(&sorted[1]), storage(&[0; 4])];
|
||||
let out = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: total * 4,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: total * 4,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let none = wgpu::BufferUsages::empty();
|
||||
let sorted_buffers = [
|
||||
storage(&sorted[0], none),
|
||||
storage(&sorted[1], none),
|
||||
storage(&[0; 4], none),
|
||||
];
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("flatten.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("flatten"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
pass.set_pipeline(&pipeline);
|
||||
for &(source, first, count) in &segments {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &range_buffer,
|
||||
offset: first * 16,
|
||||
size: wgpu::BufferSize::new(count * 16),
|
||||
}),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: sorted_buffers[source.map_or(2, |p| p as usize)]
|
||||
.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: out.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.dispatch_workgroups(count as u32, 1, 1);
|
||||
let pipeline = |entry| {
|
||||
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let (flatten, flatten_seen) = (pipeline("flatten"), pipeline("flatten_seen"));
|
||||
let read_back = |buffer: &wgpu::Buffer, words: u64| -> Vec<u32> {
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: words * 4,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(buffer, 0, &read, 0, words * 4);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec()
|
||||
};
|
||||
// Without culling, then with the third and fourth draws in phase 2,
|
||||
// of which the test saw only the fourth.
|
||||
for phase1 in [solid, 2] {
|
||||
let (mut calls, mut ranges, mut segments) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let plan = plan(
|
||||
&draws,
|
||||
&pages,
|
||||
phase1,
|
||||
solid,
|
||||
align,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
);
|
||||
// Phase 2's draws as the test leaves them: instance counts of 0,
|
||||
// 1 where it saw the section.
|
||||
let mut phase2: Vec<[u32; 5]> = draws[phase1..solid]
|
||||
.iter()
|
||||
.map(|&[count, _, first, base, instance]| [count, 0, first, base, instance])
|
||||
.collect();
|
||||
if let Some(last) = phase2.last_mut() {
|
||||
last[1] = 1;
|
||||
}
|
||||
let phase2_calls: Vec<[u32; 5]> = calls[plan.phase1_calls..plan.solid_calls]
|
||||
.iter()
|
||||
.map(|c| [0, 1, c.first, 0, 0])
|
||||
.collect();
|
||||
let range_buffer = storage(bytemuck::cast_slice(&ranges), none);
|
||||
let phase2_buffer = storage(
|
||||
bytemuck::cast_slice(&[phase2.as_slice(), &[[0; 5]]].concat()),
|
||||
none,
|
||||
);
|
||||
let calls_buffer = storage(
|
||||
bytemuck::cast_slice(&[phase2_calls.as_slice(), &[[0; 5]]].concat()),
|
||||
wgpu::BufferUsages::COPY_SRC,
|
||||
);
|
||||
let out = storage(&vec![0; plan.total as usize], wgpu::BufferUsages::COPY_SRC);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
for &(source, first, count) in &segments {
|
||||
let ranges = wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &range_buffer,
|
||||
offset: first * 16,
|
||||
size: wgpu::BufferSize::new(count * 16),
|
||||
});
|
||||
let entry = |binding, resource| wgpu::BindGroupEntry { binding, resource };
|
||||
let (pipeline, entries) = match source {
|
||||
Source::Seen => (
|
||||
&flatten_seen,
|
||||
vec![
|
||||
entry(0, ranges),
|
||||
entry(2, out.as_entire_binding()),
|
||||
entry(3, phase2_buffer.as_entire_binding()),
|
||||
entry(4, calls_buffer.as_entire_binding()),
|
||||
],
|
||||
),
|
||||
Source::Quads | Source::Sorted(_) => {
|
||||
let page = match source {
|
||||
Source::Sorted(p) => p as usize,
|
||||
_ => 2,
|
||||
};
|
||||
(
|
||||
&flatten,
|
||||
vec![
|
||||
entry(0, ranges),
|
||||
entry(1, sorted_buffers[page].as_entire_binding()),
|
||||
entry(2, out.as_entire_binding()),
|
||||
],
|
||||
)
|
||||
}
|
||||
};
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &entries,
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.dispatch_workgroups(count as u32, 1, 1);
|
||||
}
|
||||
}
|
||||
queue.submit([encoder.finish()]);
|
||||
let got = read_back(&out, plan.total);
|
||||
// Phase 1's and the translucent calls: each of their draws, in order.
|
||||
let expected: Vec<u32> = (0..draws.len())
|
||||
.filter(|i| !(phase1..solid).contains(i))
|
||||
.flat_map(|i| indices(&draws[i], pages[i].1))
|
||||
.collect();
|
||||
let drawn: Vec<u32> = calls
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(c, _)| !(plan.phase1_calls..plan.solid_calls).contains(c))
|
||||
.flat_map(|(_, c)| got[c.first as usize..(c.first + c.count) as usize].to_vec())
|
||||
.collect();
|
||||
assert_eq!(drawn, expected);
|
||||
// Phase 2: page 1's run drew nothing; page 0's holds the fourth
|
||||
// draw's indices.
|
||||
if phase1 < solid {
|
||||
let counts = read_back(&calls_buffer, phase2_calls.len() as u64 * 5);
|
||||
assert_eq!(counts[0], 0);
|
||||
assert_eq!(counts[5], draws[3][0]);
|
||||
let run = &calls[plan.phase1_calls + 1];
|
||||
assert_eq!(
|
||||
got[run.first as usize..(run.first + draws[3][0]) as usize],
|
||||
indices(&draws[3], None)
|
||||
);
|
||||
}
|
||||
}
|
||||
encoder.copy_buffer_to_buffer(&out, 0, &read, 0, total * 4);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let got: Vec<u32> =
|
||||
bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec();
|
||||
assert_eq!(got, expected);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@
|
||||
// translucent ones read their sorted indices.
|
||||
struct Range {
|
||||
count: u32,
|
||||
// First sorted index, or QUADS for a solid draw.
|
||||
// First sorted index, or QUADS for a solid draw (`flatten_seen`: its
|
||||
// entry in occlusion culling's phase 2 draws).
|
||||
first: u32,
|
||||
base: u32,
|
||||
// First index in the list (`flatten_seen`: its draw call).
|
||||
dst: u32,
|
||||
}
|
||||
const QUADS: u32 = 0xffffffffu;
|
||||
@@ -14,6 +16,13 @@ const GROUP: u32 = 64u;
|
||||
@group(0) @binding(0) var<storage, read> ranges: array<Range>;
|
||||
@group(0) @binding(1) var<storage, read> sorted: array<u32>;
|
||||
@group(0) @binding(2) var<storage, read_write> out: array<u32>;
|
||||
|
||||
// Index `k` of a solid draw: quad k / 6's vertices 0 1 2 0 2 3.
|
||||
fn quad(k: u32) -> u32 {
|
||||
let c = k % 6u;
|
||||
return (k / 6u) * 4u + select(select(c - 2u, 0u, c == 3u), c, c < 3u);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn flatten(@builtin(workgroup_id) group: vec3<u32>,
|
||||
@builtin(local_invocation_index) lane: u32) {
|
||||
@@ -25,11 +34,43 @@ fn flatten(@builtin(workgroup_id) group: vec3<u32>,
|
||||
for (var k = lane; k < d.count; k += GROUP) {
|
||||
var i: u32;
|
||||
if d.first == QUADS {
|
||||
let c = k % 6u;
|
||||
i = (k / 6u) * 4u + select(select(c - 2u, 0u, c == 3u), c, c < 3u);
|
||||
i = quad(k);
|
||||
} else {
|
||||
i = sorted[d.first + k];
|
||||
}
|
||||
out[d.dst + k] = d.base + i;
|
||||
}
|
||||
}
|
||||
|
||||
// Occlusion culling's phase 2 (gfx/cull.rs), after its test: the draws as
|
||||
// the test left them (`DrawIndexedIndirectArgs`, the instance count 1 where
|
||||
// it saw the section), and phase 2's draw calls, whose index counts start
|
||||
// at 0. A draw the test saw takes the next indices of its call's run.
|
||||
@group(0) @binding(3) var<storage, read> draws: array<u32>;
|
||||
@group(0) @binding(4) var<storage, read_write> calls: array<atomic<u32>>;
|
||||
const UNSEEN: u32 = 0xffffffffu;
|
||||
var<workgroup> start: u32;
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn flatten_seen(@builtin(workgroup_id) group: vec3<u32>,
|
||||
@builtin(local_invocation_index) lane: u32) {
|
||||
let r = group.y * 65535u + group.x;
|
||||
if r >= arrayLength(&ranges) {
|
||||
return;
|
||||
}
|
||||
let d = ranges[r];
|
||||
if lane == 0u {
|
||||
var s = UNSEEN;
|
||||
if draws[d.first * 5u + 1u] != 0u {
|
||||
s = atomicLoad(&calls[d.dst * 5u + 2u]) + atomicAdd(&calls[d.dst * 5u], d.count);
|
||||
}
|
||||
start = s;
|
||||
}
|
||||
let s = workgroupUniformLoad(&start);
|
||||
if s == UNSEEN {
|
||||
return;
|
||||
}
|
||||
for (var k = lane; k < d.count; k += GROUP) {
|
||||
out[s + k] = d.base + quad(k);
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,21 @@
|
||||
//! Iris's colour targets (`colortex0..`, each with a main and an alternate
|
||||
//! side) and the pack's `begin`, `prepare`, `deferred`, `composite` and
|
||||
//! `final` programs as full-screen passes over them, as Iris's
|
||||
//! `CompositeRenderer` runs them. Horizon's world enters `colortex0` and
|
||||
//! `depthtex0..2` after `prepare`, where Iris's gbuffers programs would have
|
||||
//! drawn, and the last picture goes to `world_post`. The targets keep GL's
|
||||
//! layout (row 0 at the bottom, which the translated vertex programs
|
||||
//! produce), so the world is flipped going in and the picture coming out.
|
||||
use super::pack_stages::FrameUniform;
|
||||
use crate::java_pack::{JavaFolder, JavaPasses};
|
||||
//! `CompositeRenderer` runs them. Horizon's world enters the target the
|
||||
//! pack's terrain program draws to (`colortex0` for most packs) and
|
||||
//! `depthtex0` after `prepare`, where Iris's gbuffers programs would have
|
||||
//! drawn (`depthtex1`/`depthtex2`: its depth before translucent geometry),
|
||||
//! and the last picture goes to `world_post`. The
|
||||
//! targets keep GL's layout (row 0 at the bottom, which the translated vertex
|
||||
//! programs produce), so the world is flipped going in and the picture coming
|
||||
//! out.
|
||||
use super::pack_stages::{FrameUniform, noise, rgba_view};
|
||||
use crate::java_pack::{JavaFolder, JavaPasses, NOISE_KEY, texture_key};
|
||||
use crate::shader_pack::Rgba;
|
||||
use glam::{Mat3, Mat4, Vec3, Vec4};
|
||||
use horizon_shaderpack::pipeline::TargetSettings;
|
||||
use horizon_shaderpack::pipeline::{Directives, Program, target_index};
|
||||
use horizon_shaderpack::properties::Blend;
|
||||
use horizon_shaderpack::uniforms::{CustomUniforms, SmoothFloat, UniformState};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
@@ -27,14 +32,13 @@ pub struct JavaFrame {
|
||||
pub moon_angle: f32,
|
||||
}
|
||||
|
||||
/// Iris's `noisetex` size (`noiseTextureResolution`'s default).
|
||||
const NOISE_SIZE: u32 = 256;
|
||||
/// The vertex buffers a pass may read (wgpu's default limit).
|
||||
const MAX_ATTRIBUTES: usize = 8;
|
||||
|
||||
const BLIT: &str = r#"
|
||||
@group(0) @binding(0) var color: texture_2d<f32>;
|
||||
@group(0) @binding(1) var depth: texture_depth_2d;
|
||||
@group(0) @binding(2) var opaque_depth: texture_depth_2d;
|
||||
|
||||
@vertex
|
||||
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
|
||||
@@ -45,15 +49,20 @@ fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
|
||||
struct World {
|
||||
@location(0) color: vec4f,
|
||||
@location(1) depth: vec4f,
|
||||
@location(2) opaque_depth: vec4f,
|
||||
}
|
||||
|
||||
// The world's colour and depth, upside down into GL's layout; Horizon's
|
||||
// reversed depth (near 1) as GL's (near 0).
|
||||
// The world's colour, depth and pre-translucent depth, upside down into GL's
|
||||
// layout; Horizon's reversed depth (near 1) as GL's (near 0).
|
||||
@fragment
|
||||
fn world_in(@builtin(position) p: vec4f) -> World {
|
||||
let size = textureDimensions(depth);
|
||||
let xy = vec2u(u32(p.x), size.y - 1u - u32(p.y));
|
||||
return World(textureLoad(color, xy, 0), vec4f(1.0 - textureLoad(depth, xy, 0)));
|
||||
return World(
|
||||
textureLoad(color, xy, 0),
|
||||
vec4f(1.0 - textureLoad(depth, xy, 0)),
|
||||
vec4f(1.0 - textureLoad(opaque_depth, xy, 0)),
|
||||
);
|
||||
}
|
||||
|
||||
// A target, upside down back into wgpu's layout.
|
||||
@@ -64,6 +73,42 @@ fn present(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Convert a GL `Blend` override from `shaders.properties` into a wgpu `BlendState`.
|
||||
fn blend_state(blend: &Blend) -> Option<wgpu::BlendState> {
|
||||
let Blend::Factors(factors) = blend else {
|
||||
return None;
|
||||
};
|
||||
let map_factor = |name: &str| -> wgpu::BlendFactor {
|
||||
use wgpu::BlendFactor as B;
|
||||
match name {
|
||||
"ZERO" => B::Zero,
|
||||
"ONE" => B::One,
|
||||
"SRC_COLOR" => B::Src,
|
||||
"ONE_MINUS_SRC_COLOR" => B::OneMinusSrc,
|
||||
"DST_COLOR" => B::Dst,
|
||||
"ONE_MINUS_DST_COLOR" => B::OneMinusDst,
|
||||
"SRC_ALPHA" => B::SrcAlpha,
|
||||
"ONE_MINUS_SRC_ALPHA" => B::OneMinusSrcAlpha,
|
||||
"DST_ALPHA" => B::DstAlpha,
|
||||
"ONE_MINUS_DST_ALPHA" => B::OneMinusDstAlpha,
|
||||
"SRC_ALPHA_SATURATE" => B::SrcAlphaSaturated,
|
||||
_ => B::One,
|
||||
}
|
||||
};
|
||||
Some(wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: map_factor(&factors[0]),
|
||||
dst_factor: map_factor(&factors[1]),
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: map_factor(&factors[2]),
|
||||
dst_factor: map_factor(&factors[3]),
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// A wgpu format for each of Iris's (`InternalTextureFormat`), renderable
|
||||
/// everywhere: 8-bit unsigned normalized formats stay, signed normalized,
|
||||
/// 16-bit and packed float ones become half floats, integer ones 32-bit.
|
||||
@@ -123,8 +168,10 @@ struct Target {
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Source {
|
||||
Target(u32),
|
||||
/// `depthtex0..2`: the world's depth.
|
||||
/// `depthtex0` / `gdepthtex`: the world's depth (including translucent water).
|
||||
Depth,
|
||||
/// `depthtex1` / `depthtex2`: the opaque world's depth (before translucent water).
|
||||
OpaqueDepth,
|
||||
Noise,
|
||||
/// `shadowtex*`: no shadow map yet, so a far depth (nothing in shadow);
|
||||
/// also Distant Horizons' and Voxy's depth, as without those mods.
|
||||
@@ -135,7 +182,8 @@ enum Source {
|
||||
|
||||
fn source(name: &str) -> Source {
|
||||
match name {
|
||||
"depthtex0" | "depthtex1" | "depthtex2" | "gdepthtex" => Source::Depth,
|
||||
"depthtex0" | "gdepthtex" => Source::Depth,
|
||||
"depthtex1" | "depthtex2" => Source::OpaqueDepth,
|
||||
"noisetex" => Source::Noise,
|
||||
"shadowtex0" | "shadowtex1" | "shadow" | "watershadow" => Source::Shadow,
|
||||
n if n.starts_with("dhDepthTex") || n.starts_with("vxDepthTex") => Source::Shadow,
|
||||
@@ -146,6 +194,7 @@ fn source(name: &str) -> Source {
|
||||
|
||||
/// A sampler of a pass: its texture and sampler bindings.
|
||||
struct SamplerBinding {
|
||||
name: String,
|
||||
source: Source,
|
||||
dimension: wgpu::TextureViewDimension,
|
||||
/// `isampler` (true) or `usampler` (false).
|
||||
@@ -183,6 +232,7 @@ struct Common {
|
||||
repeat: wgpu::Sampler,
|
||||
compare: wgpu::Sampler,
|
||||
noise: wgpu::TextureView,
|
||||
custom_textures: BTreeMap<String, wgpu::TextureView>,
|
||||
/// Full-screen triangle data per attribute kind (see `PassGpu::attributes`).
|
||||
vertices: [wgpu::Buffer; 4],
|
||||
world_layout: wgpu::BindGroupLayout,
|
||||
@@ -194,12 +244,16 @@ struct Common {
|
||||
struct Bound {
|
||||
folder: usize,
|
||||
size: [u32; 2],
|
||||
/// `world_post`'s targets it binds (`Post::generation`).
|
||||
post_generation: u64,
|
||||
targets: BTreeMap<u32, Target>,
|
||||
depth: wgpu::TextureView,
|
||||
opaque_depth: wgpu::TextureView,
|
||||
/// Per pass, then `final`.
|
||||
groups: Vec<wgpu::BindGroup>,
|
||||
world_in: (wgpu::RenderPipeline, wgpu::BindGroup),
|
||||
/// The world goes to colortex0's alternate side.
|
||||
/// The world's target, and whether the world goes to its alternate side.
|
||||
world_target: u32,
|
||||
world_alt: bool,
|
||||
final_target: Option<wgpu::TextureView>,
|
||||
present: (wgpu::RenderPipeline, wgpu::BindGroup),
|
||||
@@ -212,6 +266,7 @@ struct Bound {
|
||||
struct Folder {
|
||||
targets: Vec<TargetSettings>,
|
||||
world_at: usize,
|
||||
world_target: u32,
|
||||
flipped_after_prepare: BTreeSet<u32>,
|
||||
flipped_at_end: BTreeSet<u32>,
|
||||
passes: Vec<PassGpu>,
|
||||
@@ -221,6 +276,9 @@ struct Folder {
|
||||
|
||||
pub struct JavaStages {
|
||||
pub id: String,
|
||||
/// A pass samples `depthtex1`/`depthtex2`: the world's depth is kept
|
||||
/// before translucent geometry draws.
|
||||
pub reads_opaque_depth: bool,
|
||||
format: wgpu::TextureFormat,
|
||||
common: Common,
|
||||
folders: Vec<Folder>,
|
||||
@@ -234,6 +292,9 @@ pub struct JavaStages {
|
||||
uniform_state: UniformState,
|
||||
/// `eyeBrightnessSmooth`, block then sky.
|
||||
eye_smooth: [SmoothFloat; 2],
|
||||
/// `wetness`: `rainStrength` smoothed with `wetnessHalflife` going up
|
||||
/// and `drynessHalflife` going down.
|
||||
wetness_smooth: SmoothFloat,
|
||||
}
|
||||
|
||||
impl JavaStages {
|
||||
@@ -245,7 +306,7 @@ impl JavaStages {
|
||||
id: &str,
|
||||
pack: &JavaPasses,
|
||||
) -> Result<Self, String> {
|
||||
let common = Common::new(device, queue);
|
||||
let common = Common::new(device, queue, &pack.custom_textures);
|
||||
let folder_for = [-1, 0, 1].map(|d| pack.folder_for(d));
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let folders = pack
|
||||
@@ -270,6 +331,7 @@ impl JavaStages {
|
||||
Ok::<_, String>(Folder {
|
||||
targets: plan.targets.clone(),
|
||||
world_at: plan.world_at,
|
||||
world_target: plan.world_target,
|
||||
flipped_after_prepare: plan.flipped_after_prepare.clone(),
|
||||
flipped_at_end: plan.flipped_at_end.clone(),
|
||||
passes,
|
||||
@@ -281,8 +343,14 @@ impl JavaStages {
|
||||
if let Some(error) = pollster::block_on(scope.pop()) {
|
||||
return Err(error.to_string());
|
||||
}
|
||||
let reads_opaque_depth = folders
|
||||
.iter()
|
||||
.flat_map(|f| f.passes.iter().chain(&f.final_pass))
|
||||
.flat_map(|p| &p.samplers)
|
||||
.any(|s| s.source == Source::OpaqueDepth);
|
||||
Ok(Self {
|
||||
id: id.to_owned(),
|
||||
reads_opaque_depth,
|
||||
format,
|
||||
common,
|
||||
folders,
|
||||
@@ -295,6 +363,7 @@ impl JavaStages {
|
||||
uniforms: pack.uniforms.clone(),
|
||||
uniform_state: UniformState::default(),
|
||||
eye_smooth: [SmoothFloat::default(); 2],
|
||||
wetness_smooth: SmoothFloat::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -315,11 +384,9 @@ impl JavaStages {
|
||||
) -> &wgpu::BindGroup {
|
||||
let dimension = (frame.dimension[0] as i32).clamp(-1, 1);
|
||||
let folder = self.folder_for[(dimension + 1) as usize].unwrap_or(0);
|
||||
if self
|
||||
.bound
|
||||
.as_ref()
|
||||
.is_none_or(|b| b.folder != folder || b.size != size)
|
||||
{
|
||||
if self.bound.as_ref().is_none_or(|b| {
|
||||
b.folder != folder || b.size != size || b.post_generation != post.generation()
|
||||
}) {
|
||||
self.bound = Some(self.bind(device, queue, folder, color, depth, size, post));
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
@@ -339,6 +406,12 @@ impl JavaStages {
|
||||
for (i, smooth) in self.eye_smooth.iter_mut().enumerate() {
|
||||
inputs.eye_smooth[i] = smooth.update(inputs.eye[i], half_life, half_life, frame_time);
|
||||
}
|
||||
inputs.wetness = self.wetness_smooth.update(
|
||||
frame.weather[0],
|
||||
directives.wetness_half_life,
|
||||
directives.dryness_half_life,
|
||||
frame_time,
|
||||
);
|
||||
self.uniforms
|
||||
.update(&mut self.uniform_state, frame_time, &|name| {
|
||||
inputs.builtin(name)
|
||||
@@ -417,8 +490,9 @@ impl JavaStages {
|
||||
let folder = &self.folders[folder_index];
|
||||
let plan = folder;
|
||||
let all = folder.passes.iter().chain(&folder.final_pass);
|
||||
// Every target a pass reads or writes, and colortex0 (the world).
|
||||
let mut used: BTreeSet<u32> = [0].into();
|
||||
// Every target a pass reads or writes, colortex0 (shown without
|
||||
// `final`) and the world's.
|
||||
let mut used: BTreeSet<u32> = [0, folder.world_target].into();
|
||||
for pass in all.clone() {
|
||||
used.extend(pass.draw_buffers.iter().copied());
|
||||
used.extend(pass.samplers.iter().filter_map(|s| match s.source {
|
||||
@@ -496,20 +570,41 @@ impl JavaStages {
|
||||
size,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let opaque_depth = texture(
|
||||
"Java shader pack opaque depth",
|
||||
wgpu::TextureFormat::R32Float,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
size,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let mut blank = Vec::new();
|
||||
let groups = all
|
||||
.clone()
|
||||
.map(|pass| pass.bind(device, queue, &targets, &depth, &self.common, &mut blank))
|
||||
.map(|pass| {
|
||||
pass.bind(
|
||||
device,
|
||||
queue,
|
||||
&targets,
|
||||
&depth,
|
||||
&opaque_depth,
|
||||
&self.common,
|
||||
&mut blank,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// The world goes into colortex0's side the gbuffers would write.
|
||||
let colortex0 = &targets[&0];
|
||||
// The world goes into its target's side the gbuffers would write.
|
||||
let world_target = &targets[&folder.world_target];
|
||||
let world_pipeline = blit_pipeline(
|
||||
device,
|
||||
&self.common,
|
||||
&self.common.world_layout,
|
||||
"world_in",
|
||||
&[colortex0.format, wgpu::TextureFormat::R32Float],
|
||||
&[
|
||||
world_target.format,
|
||||
wgpu::TextureFormat::R32Float,
|
||||
wgpu::TextureFormat::R32Float,
|
||||
],
|
||||
);
|
||||
let world_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Java shader pack world"),
|
||||
@@ -523,6 +618,10 @@ impl JavaStages {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(world_depth),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(post.opaque_depth()),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -536,6 +635,7 @@ impl JavaStages {
|
||||
.create_view(&Default::default())
|
||||
});
|
||||
// Without `final`, Iris shows colortex0 as the last pass left it.
|
||||
let colortex0 = &targets[&0];
|
||||
let shown = final_target.clone().unwrap_or_else(|| {
|
||||
if plan.flipped_at_end.contains(&0) {
|
||||
colortex0.alt_view.clone()
|
||||
@@ -569,11 +669,14 @@ impl JavaStages {
|
||||
Bound {
|
||||
folder: folder_index,
|
||||
size,
|
||||
post_generation: post.generation(),
|
||||
targets,
|
||||
depth,
|
||||
opaque_depth,
|
||||
groups,
|
||||
world_in: (world_pipeline, world_group),
|
||||
world_alt: plan.flipped_after_prepare.contains(&0),
|
||||
world_target: folder.world_target,
|
||||
world_alt: plan.flipped_after_prepare.contains(&folder.world_target),
|
||||
final_target,
|
||||
present: (present_pipeline, present_group),
|
||||
output_view,
|
||||
@@ -584,15 +687,16 @@ impl JavaStages {
|
||||
}
|
||||
|
||||
fn world_in(encoder: &mut wgpu::CommandEncoder, bound: &Bound) {
|
||||
let colortex0 = &bound.targets[&0];
|
||||
let target = &bound.targets[&bound.world_target];
|
||||
let side = if bound.world_alt {
|
||||
&colortex0.alt_view
|
||||
&target.alt_view
|
||||
} else {
|
||||
&colortex0.main_view
|
||||
&target.main_view
|
||||
};
|
||||
let attachments = [
|
||||
Some(color_attachment(side, None)),
|
||||
Some(color_attachment(&bound.depth, None)),
|
||||
Some(color_attachment(&bound.opaque_depth, None)),
|
||||
];
|
||||
let mut render = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Java shader pack world"),
|
||||
@@ -666,7 +770,8 @@ fn blit_pipeline(
|
||||
}
|
||||
|
||||
impl Common {
|
||||
fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
|
||||
/// `custom` holds the pack's `texture.*` images (`JavaPasses::custom_textures`).
|
||||
fn new(device: &wgpu::Device, queue: &wgpu::Queue, custom: &BTreeMap<String, Rgba>) -> Self {
|
||||
let sampler = |filter, address, compare| {
|
||||
device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Java shader pack"),
|
||||
@@ -680,40 +785,15 @@ impl Common {
|
||||
})
|
||||
};
|
||||
use wgpu::{AddressMode::*, FilterMode::*};
|
||||
// Iris's noise: random RGB, opaque.
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
let noise_bytes: Vec<u8> = (0..NOISE_SIZE * NOISE_SIZE * 4)
|
||||
.map(|i| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
if i % 4 == 3 { 255 } else { (seed >> 24) as u8 }
|
||||
})
|
||||
let noise = match custom.get(NOISE_KEY) {
|
||||
Some(image) => rgba_view(device, queue, "noisetex", image),
|
||||
None => rgba_view(device, queue, "noisetex", &noise()),
|
||||
};
|
||||
let custom_textures = custom
|
||||
.iter()
|
||||
.filter(|(key, _)| *key != NOISE_KEY)
|
||||
.map(|(key, image)| (key.clone(), rgba_view(device, queue, key, image)))
|
||||
.collect();
|
||||
let noise = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("noisetex"),
|
||||
size: wgpu::Extent3d {
|
||||
width: NOISE_SIZE,
|
||||
height: NOISE_SIZE,
|
||||
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::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
noise.as_image_copy(),
|
||||
&noise_bytes,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(NOISE_SIZE * 4),
|
||||
rows_per_image: None,
|
||||
},
|
||||
noise.size(),
|
||||
);
|
||||
// The full-screen triangle (0, 0), (2, 0), (0, 2): the pack's
|
||||
// projection maps 0..1 to the screen. Texture coordinates are the
|
||||
// position; colours white; normals up.
|
||||
@@ -753,13 +833,15 @@ impl Common {
|
||||
nearest: sampler(Nearest, ClampToEdge, None),
|
||||
repeat: sampler(Linear, Repeat, None),
|
||||
compare: sampler(Linear, ClampToEdge, Some(wgpu::CompareFunction::LessEqual)),
|
||||
noise: noise.create_view(&Default::default()),
|
||||
noise,
|
||||
custom_textures,
|
||||
vertices,
|
||||
world_layout: device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Java shader pack world"),
|
||||
entries: &[
|
||||
texture_entry(0, unfilterable),
|
||||
texture_entry(1, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(2, wgpu::TextureSampleType::Depth),
|
||||
],
|
||||
}),
|
||||
present_layout: device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -841,6 +923,7 @@ impl PassGpu {
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("{sampler}_s"));
|
||||
samplers.push(SamplerBinding {
|
||||
name: sampler.clone(),
|
||||
source: source(sampler),
|
||||
dimension,
|
||||
signed,
|
||||
@@ -885,7 +968,7 @@ impl PassGpu {
|
||||
)),
|
||||
)
|
||||
}
|
||||
(false, Source::Depth | Source::Shadow) => (
|
||||
(false, Source::Depth | Source::OpaqueDepth | Source::Shadow) => (
|
||||
wgpu::TextureSampleType::Float { filterable: false },
|
||||
Some(false),
|
||||
),
|
||||
@@ -979,10 +1062,11 @@ impl PassGpu {
|
||||
|
||||
// Output location `l` writes target `draw_buffers[l]` (or the screen).
|
||||
let outputs = &program.fragment.interface.outputs;
|
||||
let pass_blend = program.pass.blend.as_ref().and_then(blend_state);
|
||||
let target_states: Vec<Option<wgpu::ColorTargetState>> = match screen {
|
||||
Some(format) => vec![Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
blend: pass_blend,
|
||||
write_mask: if outputs.contains_key(&0) {
|
||||
wgpu::ColorWrites::ALL
|
||||
} else {
|
||||
@@ -994,12 +1078,25 @@ impl PassGpu {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(l, &i)| {
|
||||
let format = formats
|
||||
.get(i as usize)
|
||||
.copied()
|
||||
.unwrap_or(wgpu::TextureFormat::Rgba8Unorm);
|
||||
// Only filterable float/unorm formats support blending.
|
||||
let can_blend = matches!(
|
||||
sample_type(format),
|
||||
wgpu::TextureSampleType::Float { filterable: true }
|
||||
);
|
||||
let buf_blend = program
|
||||
.pass
|
||||
.buffer_blends
|
||||
.iter()
|
||||
.find(|(idx, _)| *idx == i)
|
||||
.map(|(_, b)| blend_state(b))
|
||||
.unwrap_or(pass_blend);
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: formats
|
||||
.get(i as usize)
|
||||
.copied()
|
||||
.unwrap_or(wgpu::TextureFormat::Rgba8Unorm),
|
||||
blend: None,
|
||||
format,
|
||||
blend: if can_blend { buf_blend } else { None },
|
||||
write_mask: if outputs.contains_key(&(l as u32)) {
|
||||
wgpu::ColorWrites::ALL
|
||||
} else {
|
||||
@@ -1068,37 +1165,55 @@ impl PassGpu {
|
||||
}
|
||||
|
||||
/// The pass's bind group over `targets`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn bind(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
targets: &BTreeMap<u32, Target>,
|
||||
depth: &wgpu::TextureView,
|
||||
opaque_depth: &wgpu::TextureView,
|
||||
common: &Common,
|
||||
blank: &mut Vec<wgpu::Texture>,
|
||||
) -> wgpu::BindGroup {
|
||||
let mut views = Vec::new();
|
||||
for s in &self.samplers {
|
||||
let view = match (s.compare, s.source) {
|
||||
(true, _) => blank_view(device, queue, blank, s, BlankKind::FarDepth),
|
||||
(false, Source::Target(i)) if s.dimension == wgpu::TextureViewDimension::D2 => {
|
||||
match targets.get(&i) {
|
||||
Some(t) if self.reads_alt.contains(&i) => t.alt_view.clone(),
|
||||
Some(t) => t.main_view.clone(),
|
||||
None => blank_view(device, queue, blank, s, BlankKind::Color(false)),
|
||||
let custom = (!s.compare && s.dimension == wgpu::TextureViewDimension::D2)
|
||||
.then(|| {
|
||||
common
|
||||
.custom_textures
|
||||
.get(&texture_key(&self.program, &s.name))
|
||||
})
|
||||
.flatten();
|
||||
let view = if let Some(custom) = custom {
|
||||
custom.clone()
|
||||
} else {
|
||||
match (s.compare, s.source) {
|
||||
(true, _) => blank_view(device, queue, blank, s, BlankKind::FarDepth),
|
||||
(false, Source::Target(i)) if s.dimension == wgpu::TextureViewDimension::D2 => {
|
||||
match targets.get(&i) {
|
||||
Some(t) if self.reads_alt.contains(&i) => t.alt_view.clone(),
|
||||
Some(t) => t.main_view.clone(),
|
||||
None => blank_view(device, queue, blank, s, BlankKind::Color(false)),
|
||||
}
|
||||
}
|
||||
(false, Source::Depth) if s.dimension == wgpu::TextureViewDimension::D2 => {
|
||||
depth.clone()
|
||||
}
|
||||
(false, Source::OpaqueDepth)
|
||||
if s.dimension == wgpu::TextureViewDimension::D2 =>
|
||||
{
|
||||
opaque_depth.clone()
|
||||
}
|
||||
(false, Source::Shadow) => blank_view(device, queue, blank, s, BlankKind::Far),
|
||||
(false, Source::Noise) if s.dimension == wgpu::TextureViewDimension::D2 => {
|
||||
common.noise.clone()
|
||||
}
|
||||
(false, Source::Blank(white)) => {
|
||||
blank_view(device, queue, blank, s, BlankKind::Color(white))
|
||||
}
|
||||
_ => blank_view(device, queue, blank, s, BlankKind::Color(false)),
|
||||
}
|
||||
(false, Source::Depth) if s.dimension == wgpu::TextureViewDimension::D2 => {
|
||||
depth.clone()
|
||||
}
|
||||
(false, Source::Shadow) => blank_view(device, queue, blank, s, BlankKind::Far),
|
||||
(false, Source::Noise) if s.dimension == wgpu::TextureViewDimension::D2 => {
|
||||
common.noise.clone()
|
||||
}
|
||||
(false, Source::Blank(white)) => {
|
||||
blank_view(device, queue, blank, s, BlankKind::Color(white))
|
||||
}
|
||||
_ => blank_view(device, queue, blank, s, BlankKind::Color(false)),
|
||||
};
|
||||
let sampler = match (s.compare, s.source) {
|
||||
_ if s.shared.is_some() => match s.shared.as_deref() {
|
||||
@@ -1110,7 +1225,7 @@ impl PassGpu {
|
||||
(true, _) => &common.compare,
|
||||
_ if s.signed.is_some() => &common.nearest,
|
||||
(false, Source::Noise) => &common.repeat,
|
||||
(false, Source::Depth | Source::Shadow) => &common.nearest,
|
||||
(false, Source::Depth | Source::OpaqueDepth | Source::Shadow) => &common.nearest,
|
||||
(false, Source::Target(i)) => {
|
||||
match targets.get(&i).map(|t| sample_type(t.format)) {
|
||||
Some(wgpu::TextureSampleType::Float { filterable: true }) | None => {
|
||||
@@ -1442,6 +1557,8 @@ struct Inputs {
|
||||
/// `eyeBrightness` and `eyeBrightnessSmooth`: block, sky light × 16.
|
||||
eye: [f32; 2],
|
||||
eye_smooth: [f32; 2],
|
||||
/// `wetness` (`JavaStages::wetness_smooth`).
|
||||
wetness: f32,
|
||||
/// `CelestialUniforms`, in view space.
|
||||
sun_position: Vec3,
|
||||
moon_position: Vec3,
|
||||
@@ -1499,6 +1616,7 @@ impl Inputs {
|
||||
frame: *frame,
|
||||
eye: [0.0; 2],
|
||||
eye_smooth: [0.0; 2],
|
||||
wetness: frame.weather[0],
|
||||
sun_position: Vec3::ZERO,
|
||||
moon_position: Vec3::ZERO,
|
||||
up_position: Vec3::ZERO,
|
||||
@@ -1591,7 +1709,8 @@ impl Inputs {
|
||||
}),
|
||||
"upPosition" => vec3(self.up_position),
|
||||
"moonPhase" => vec![f.moon[3]],
|
||||
"rainStrength" | "wetness" => vec![f.weather[0]],
|
||||
"rainStrength" => vec![f.weather[0]],
|
||||
"wetness" => vec![self.wetness],
|
||||
"thunderStrength" => vec![f.weather[1]],
|
||||
"fogColor" => f.fog_color[..3].to_vec(),
|
||||
"skyColor" => f.sky_color[..3].to_vec(),
|
||||
@@ -1651,6 +1770,7 @@ mod tests {
|
||||
assert_eq!(source("colortex4"), Source::Target(4));
|
||||
assert_eq!(source("gaux1"), Source::Target(4));
|
||||
assert_eq!(source("gdepthtex"), Source::Depth);
|
||||
assert_eq!(source("depthtex1"), Source::OpaqueDepth);
|
||||
assert_eq!(source("shadowtex1"), Source::Shadow);
|
||||
assert_eq!(source("dhDepthTex0"), Source::Shadow);
|
||||
assert_eq!(source("shadowcolor0"), Source::Blank(true));
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! A Horizon shader pack on the GPU (docs/shader-packs.md): each `composite`
|
||||
//! stage is one full-screen pass that reads the world's colour and depth and
|
||||
//! the previous stage's output and writes its own target; the last target
|
||||
//! goes to the screen through `world_post` (copy or FXAA) under the hand and
|
||||
//! HUD. Only exists while a pack is selected.
|
||||
use crate::shader_pack::{ShaderPack, VERTEX_ENTRY};
|
||||
//! A Horizon shader pack on the GPU (docs/shader-packs.md): each stage
|
||||
//! (`shadow`, `gbuffer`, `deferred`, `composite`, `final`) is a full-screen
|
||||
//! pass that reads the world's colour, depth, opaque depth, G-buffer normals
|
||||
//! and materials, shadow map, noise texture and the previous stage's output,
|
||||
//! and writes its own target; the last target goes to the screen through
|
||||
//! `world_post` (copy or FXAA) under the hand and HUD. Only exists while a
|
||||
//! pack is selected.
|
||||
use crate::shader_pack::{Rgba, ShaderPack, VERTEX_ENTRY};
|
||||
use horizon_shaderpack::uniforms::SmoothFloat;
|
||||
|
||||
/// The `Frame` uniform of the prelude (`shader_pack_prelude.wgsl`).
|
||||
#[repr(C)]
|
||||
@@ -27,10 +30,81 @@ pub struct FrameUniform {
|
||||
pub dimension: [f32; 4],
|
||||
}
|
||||
|
||||
/// The API 2 `FrameExt` uniform of the prelude (`shader_pack_prelude.wgsl`).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct FrameExtUniform {
|
||||
pub previous_view: [[f32; 4]; 4],
|
||||
pub previous_projection: [[f32; 4]; 4],
|
||||
pub shadow_view: [[f32; 4]; 4],
|
||||
pub shadow_projection: [[f32; 4]; 4],
|
||||
pub shadow_inverse_view: [[f32; 4]; 4],
|
||||
pub shadow_inverse_projection: [[f32; 4]; 4],
|
||||
pub previous_camera: [f32; 4],
|
||||
pub shadow_params: [f32; 4],
|
||||
pub environment: [f32; 4],
|
||||
}
|
||||
|
||||
/// Iris's `noisetex` without a `texture.noise`: 256² random RGB, opaque.
|
||||
/// Horizon packs get the same one unless `[textures] noise` replaces it.
|
||||
pub(super) fn noise() -> Rgba {
|
||||
const SIZE: u32 = 256;
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
let pixels = (0..SIZE * SIZE * 4)
|
||||
.map(|i| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
if i % 4 == 3 { 255 } else { (seed >> 24) as u8 }
|
||||
})
|
||||
.collect();
|
||||
Rgba {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
pixels,
|
||||
}
|
||||
}
|
||||
|
||||
/// `image` as a sampled RGBA8 texture.
|
||||
pub(super) fn rgba_view(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
label: &str,
|
||||
image: &Rgba,
|
||||
) -> wgpu::TextureView {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
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::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
texture.as_image_copy(),
|
||||
&image.pixels,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(image.width * 4),
|
||||
rows_per_image: None,
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
texture.create_view(&Default::default())
|
||||
}
|
||||
|
||||
struct Pass {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
scale: f32,
|
||||
target_format: wgpu::TextureFormat,
|
||||
uniform: wgpu::Buffer,
|
||||
uniform_ext: wgpu::Buffer,
|
||||
}
|
||||
/// A stage's output and the bindings that read the world into it.
|
||||
struct Target {
|
||||
@@ -41,24 +115,47 @@ struct Target {
|
||||
|
||||
pub struct PackStages {
|
||||
pub id: String,
|
||||
pub needs_shadow: bool,
|
||||
/// The stages read the G-buffer (`build_gbuffer`) or the depth before
|
||||
/// translucent geometry (`capture_opaque_depth`).
|
||||
pub needs_gbuffer: bool,
|
||||
pub needs_opaque_depth: bool,
|
||||
pub shadow_res: u32,
|
||||
pub shadow_dist: f32,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
linear: wgpu::Sampler,
|
||||
nearest: wgpu::Sampler,
|
||||
repeat: wgpu::Sampler,
|
||||
shadow_sampler: wgpu::Sampler,
|
||||
noise_view: wgpu::TextureView,
|
||||
passes: Vec<Pass>,
|
||||
format: wgpu::TextureFormat,
|
||||
/// Built for this world target size.
|
||||
/// Built for this world target size and `world_post` generation.
|
||||
targets: Vec<Target>,
|
||||
/// `world_post`'s bindings of the last stage's output.
|
||||
output: Option<wgpu::BindGroup>,
|
||||
world_size: [u32; 2],
|
||||
post_generation: u64,
|
||||
started: std::time::Instant,
|
||||
last: Option<std::time::Instant>,
|
||||
frames: u32,
|
||||
previous: Option<([[f32; 4]; 4], [[f32; 4]; 4], [f32; 3])>,
|
||||
wetness: SmoothFloat,
|
||||
}
|
||||
|
||||
fn stage_format(name: Option<&str>, default: wgpu::TextureFormat) -> wgpu::TextureFormat {
|
||||
match name.map(str::to_lowercase).as_deref() {
|
||||
Some("rgba16float" | "rgba16f") => wgpu::TextureFormat::Rgba16Float,
|
||||
Some("rgba8unorm" | "rgba8" | "rgba") => wgpu::TextureFormat::Rgba8Unorm,
|
||||
Some("rgb10a2unorm" | "rgb10_a2") => wgpu::TextureFormat::Rgb10a2Unorm,
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
|
||||
impl PackStages {
|
||||
/// The pipelines of a loaded (already validated) pack, or wgpu's error.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
format: wgpu::TextureFormat,
|
||||
pack: &ShaderPack,
|
||||
) -> Result<Self, String> {
|
||||
@@ -79,6 +176,16 @@ impl PackStages {
|
||||
ty: wgpu::BindingType::Sampler(ty),
|
||||
count: None,
|
||||
};
|
||||
let uniform_entry = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
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("Shader pack stage"),
|
||||
entries: &[
|
||||
@@ -87,16 +194,15 @@ impl PackStages {
|
||||
texture(2, float),
|
||||
sampler(3, wgpu::SamplerBindingType::Filtering),
|
||||
sampler(4, wgpu::SamplerBindingType::NonFiltering),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 5,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
uniform_entry(5),
|
||||
uniform_entry(6),
|
||||
texture(7, float),
|
||||
texture(8, float),
|
||||
texture(9, wgpu::TextureSampleType::Depth),
|
||||
texture(10, wgpu::TextureSampleType::Depth),
|
||||
sampler(11, wgpu::SamplerBindingType::Comparison),
|
||||
texture(12, float),
|
||||
sampler(13, wgpu::SamplerBindingType::Filtering),
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
@@ -106,10 +212,18 @@ impl PackStages {
|
||||
});
|
||||
let constants = pack.constants();
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let stage_count = pack.stages.len();
|
||||
let passes = pack
|
||||
.stages
|
||||
.iter()
|
||||
.map(|stage| {
|
||||
.enumerate()
|
||||
.map(|(idx, stage)| {
|
||||
let is_last = idx + 1 == stage_count;
|
||||
let target_format = if is_last {
|
||||
format
|
||||
} else {
|
||||
stage_format(stage.format.as_deref(), format)
|
||||
};
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&stage.file),
|
||||
source: wgpu::ShaderSource::Wgsl(stage.source.as_str().into()),
|
||||
@@ -140,7 +254,7 @@ impl PackStages {
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
format: target_format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
@@ -151,38 +265,77 @@ impl PackStages {
|
||||
Pass {
|
||||
pipeline,
|
||||
scale: stage.scale,
|
||||
target_format,
|
||||
uniform: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack frame"),
|
||||
size: std::mem::size_of::<FrameUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
uniform_ext: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack frame ext"),
|
||||
size: std::mem::size_of::<FrameExtUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if let Some(error) = pollster::block_on(scope.pop()) {
|
||||
return Err(error.to_string());
|
||||
}
|
||||
let sampler = |filter| {
|
||||
let make_sampler = |filter, address, compare| {
|
||||
device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Shader pack"),
|
||||
address_mode_u: address,
|
||||
address_mode_v: address,
|
||||
address_mode_w: address,
|
||||
mag_filter: filter,
|
||||
min_filter: filter,
|
||||
compare,
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let noise_view = match &pack.custom_noise {
|
||||
Some(image) => rgba_view(device, queue, "Shader pack noise", image),
|
||||
None => rgba_view(device, queue, "Shader pack noise", &noise()),
|
||||
};
|
||||
let (shadow_res, shadow_dist) = pack.shadow_config();
|
||||
Ok(Self {
|
||||
id: pack.id.clone(),
|
||||
needs_shadow: pack.needs_shadow(),
|
||||
needs_gbuffer: pack.needs_gbuffer(),
|
||||
needs_opaque_depth: pack.needs_opaque_depth(),
|
||||
shadow_res,
|
||||
shadow_dist,
|
||||
layout,
|
||||
linear: sampler(wgpu::FilterMode::Linear),
|
||||
nearest: sampler(wgpu::FilterMode::Nearest),
|
||||
linear: make_sampler(
|
||||
wgpu::FilterMode::Linear,
|
||||
wgpu::AddressMode::ClampToEdge,
|
||||
None,
|
||||
),
|
||||
nearest: make_sampler(
|
||||
wgpu::FilterMode::Nearest,
|
||||
wgpu::AddressMode::ClampToEdge,
|
||||
None,
|
||||
),
|
||||
repeat: make_sampler(wgpu::FilterMode::Linear, wgpu::AddressMode::Repeat, None),
|
||||
shadow_sampler: make_sampler(
|
||||
wgpu::FilterMode::Linear,
|
||||
wgpu::AddressMode::ClampToEdge,
|
||||
Some(wgpu::CompareFunction::GreaterEqual),
|
||||
),
|
||||
noise_view,
|
||||
passes,
|
||||
format,
|
||||
targets: Vec::new(),
|
||||
output: None,
|
||||
world_size: [0, 0],
|
||||
post_generation: u64::MAX,
|
||||
started: std::time::Instant::now(),
|
||||
last: None,
|
||||
frames: 0,
|
||||
previous: None,
|
||||
wetness: SmoothFloat::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -199,9 +352,14 @@ impl PackStages {
|
||||
size: [u32; 2],
|
||||
post: &super::world_post::Post,
|
||||
mut uniform: FrameUniform,
|
||||
eye_light: [u8; 2],
|
||||
) -> &wgpu::BindGroup {
|
||||
if self.world_size != size || self.targets.len() != self.passes.len() {
|
||||
if self.world_size != size
|
||||
|| self.post_generation != post.generation()
|
||||
|| self.targets.len() != self.passes.len()
|
||||
{
|
||||
self.world_size = size;
|
||||
self.post_generation = post.generation();
|
||||
self.targets.clear();
|
||||
for pass in &self.passes {
|
||||
let target_size = size.map(|v| ((v as f32 * pass.scale).round() as u32).max(1));
|
||||
@@ -216,7 +374,7 @@ impl PackStages {
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: self.format,
|
||||
format: pass.target_format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
@@ -251,6 +409,38 @@ impl PackStages {
|
||||
binding: 5,
|
||||
resource: pass.uniform.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: pass.uniform_ext.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(post.normal()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: wgpu::BindingResource::TextureView(post.material()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 9,
|
||||
resource: wgpu::BindingResource::TextureView(post.opaque_depth()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 10,
|
||||
resource: wgpu::BindingResource::TextureView(post.shadow_depth()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 11,
|
||||
resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 12,
|
||||
resource: wgpu::BindingResource::TextureView(&self.noise_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 13,
|
||||
resource: wgpu::BindingResource::Sampler(&self.repeat),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.targets.push(Target {
|
||||
@@ -262,6 +452,42 @@ impl PackStages {
|
||||
let last = &self.targets.last().expect("a pack has a stage").view;
|
||||
self.output = Some(post.bind(device, last));
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
let dt = self.last.map_or(0.0, |t| (now - t).as_secs_f32());
|
||||
self.last = Some(now);
|
||||
let wet = self.wetness.update(uniform.weather[0], 60.0, 120.0, dt);
|
||||
let cam = [
|
||||
uniform.camera_position[0],
|
||||
uniform.camera_position[1],
|
||||
uniform.camera_position[2],
|
||||
];
|
||||
let (prev_view, prev_proj, prev_cam) =
|
||||
self.previous
|
||||
.unwrap_or((uniform.view, uniform.projection, cam));
|
||||
self.previous = Some((uniform.view, uniform.projection, cam));
|
||||
let shadow_view = post.shadow_view;
|
||||
let shadow_proj = post.shadow_proj;
|
||||
let ext = FrameExtUniform {
|
||||
previous_view: prev_view,
|
||||
previous_projection: prev_proj,
|
||||
shadow_view: shadow_view.to_cols_array_2d(),
|
||||
shadow_projection: shadow_proj.to_cols_array_2d(),
|
||||
shadow_inverse_view: shadow_view.inverse().to_cols_array_2d(),
|
||||
shadow_inverse_projection: shadow_proj.inverse().to_cols_array_2d(),
|
||||
previous_camera: [prev_cam[0], prev_cam[1], prev_cam[2], dt],
|
||||
shadow_params: [
|
||||
post.shadow_resolution as f32,
|
||||
post.shadow_distance,
|
||||
0.0,
|
||||
if post.shadow_active { 1.0 } else { 0.0 },
|
||||
],
|
||||
environment: [
|
||||
f32::from(eye_light[0]) * 16.0,
|
||||
f32::from(eye_light[1]) * 16.0,
|
||||
wet,
|
||||
0.0,
|
||||
],
|
||||
};
|
||||
uniform.time[0] = self.started.elapsed().as_secs_f32();
|
||||
uniform.time[1] = self.frames as f32;
|
||||
self.frames = self.frames.wrapping_add(1);
|
||||
@@ -269,6 +495,7 @@ impl PackStages {
|
||||
let [w, h] = target.size.map(|v| v as f32);
|
||||
uniform.resolution = [w, h, 1.0 / w, 1.0 / h];
|
||||
queue.write_buffer(&pass.uniform, 0, bytemuck::bytes_of(&uniform));
|
||||
queue.write_buffer(&pass.uniform_ext, 0, bytemuck::bytes_of(&ext));
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target.view,
|
||||
resolve_target: None,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Solid draws grouped by vertex arena page within distance bands.
|
||||
//!
|
||||
//! Sections sit in arena pages of 256 MiB, so render distance 32 spreads
|
||||
//! them over half a dozen pages, and a list sorted only by distance switches
|
||||
//! page every few sections: about a thousand indirect multi-draws of two or
|
||||
//! three entries a frame. Mesa's Intel driver (and wgpu-core, for each
|
||||
//! vertex buffer change) pays per call, and below four entries Mesa unrolls
|
||||
//! the call on the CPU entry by entry. Grouping the sections of one band of
|
||||
//! distance by page keeps the list front to back to within the band (what
|
||||
//! the depth test needs to reject hidden fragments early) with one call per
|
||||
//! page and band.
|
||||
//!
|
||||
//! The picture does not change: solid draws write depth and are tested
|
||||
//! against it, so their order only decides which of two fragments at the
|
||||
//! same depth stays, and sections do not share faces.
|
||||
|
||||
/// The width of a band, in blocks.
|
||||
const BAND: f64 = 64.0;
|
||||
|
||||
/// One section's draws: `start..start + len` of the list.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct Run {
|
||||
key: u64,
|
||||
pub start: u32,
|
||||
pub len: u32,
|
||||
/// The caller's data, carried along (occlusion culling's test inputs).
|
||||
pub tag: [u32; 3],
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct PageRuns {
|
||||
runs: Vec<Run>,
|
||||
scratch: Vec<([u32; 5], (u32, Option<u32>))>,
|
||||
}
|
||||
|
||||
impl PageRuns {
|
||||
pub fn clear(&mut self) {
|
||||
self.runs.clear();
|
||||
}
|
||||
|
||||
/// The draws `start..start + len` of a section `distance` from the
|
||||
/// camera (squared, as bits) in arena `page`; runs come nearest first.
|
||||
pub fn push(&mut self, distance: u64, page: u32, start: usize, len: usize, tag: [u32; 3]) {
|
||||
if len > 0 {
|
||||
let band = (f64::from_bits(distance).sqrt() / BAND) as u64;
|
||||
self.runs.push(Run {
|
||||
key: band << 32 | u64::from(page),
|
||||
start: start as u32,
|
||||
len: len as u32,
|
||||
tag,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Reorders `draws[base..]` and their `pages`, which the pushed runs
|
||||
/// cover in order, so that each band's runs in one page are adjacent
|
||||
/// (in distance order); returns the runs at their new places.
|
||||
pub fn regroup(
|
||||
&mut self,
|
||||
draws: &mut [[u32; 5]],
|
||||
pages: &mut [(u32, Option<u32>)],
|
||||
base: usize,
|
||||
) -> &[Run] {
|
||||
let page = |r: &Run| r.key as u32;
|
||||
if self.runs.windows(2).all(|w| page(&w[0]) == page(&w[1])) {
|
||||
return &self.runs;
|
||||
}
|
||||
// Stable: within a band and page the runs stay nearest first.
|
||||
self.runs.sort_by_key(|r| r.key);
|
||||
self.scratch.clear();
|
||||
for r in &mut self.runs {
|
||||
let (s, e) = (r.start as usize, (r.start + r.len) as usize);
|
||||
r.start = (base + self.scratch.len()) as u32;
|
||||
self.scratch
|
||||
.extend(draws[s..e].iter().copied().zip(pages[s..e].iter().copied()));
|
||||
}
|
||||
for (i, &(draw, p)) in self.scratch.iter().enumerate() {
|
||||
draws[base + i] = draw;
|
||||
pages[base + i] = p;
|
||||
}
|
||||
&self.runs
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bits(blocks: f64) -> u64 {
|
||||
(blocks * blocks).to_bits()
|
||||
}
|
||||
|
||||
/// Runs of (distance in blocks, page, draws), as the list builder pushes them.
|
||||
fn list(runs: &[(f64, u32, u32)]) -> (PageRuns, Vec<[u32; 5]>, Vec<(u32, Option<u32>)>) {
|
||||
let mut grouped = PageRuns::default();
|
||||
let (mut draws, mut pages) = (Vec::new(), Vec::new());
|
||||
for (n, &(distance, page, count)) in runs.iter().enumerate() {
|
||||
let start = draws.len();
|
||||
for k in 0..count {
|
||||
draws.push([n as u32, k, 0, 0, 0]);
|
||||
pages.push((page, None));
|
||||
}
|
||||
grouped.push(
|
||||
bits(distance),
|
||||
page,
|
||||
start,
|
||||
count as usize,
|
||||
[n as u32, 0, 0],
|
||||
);
|
||||
}
|
||||
(grouped, draws, pages)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_band_draws_each_page_once_keeping_the_order_inside_it() {
|
||||
let (mut grouped, mut draws, mut pages) = list(&[
|
||||
(10.0, 1, 2),
|
||||
(20.0, 0, 1),
|
||||
(30.0, 1, 3),
|
||||
(40.0, 0, 2),
|
||||
(70.0, 1, 1),
|
||||
]);
|
||||
let runs: Vec<_> = grouped
|
||||
.regroup(&mut draws, &mut pages, 0)
|
||||
.iter()
|
||||
.map(|r| (r.tag[0], r.start, r.len))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
runs,
|
||||
[(1, 0, 1), (3, 1, 2), (0, 3, 2), (2, 5, 3), (4, 8, 1)]
|
||||
);
|
||||
let order: Vec<_> = draws.iter().map(|d| (d[0], d[1])).collect();
|
||||
assert_eq!(
|
||||
order,
|
||||
[
|
||||
(1, 0),
|
||||
(3, 0),
|
||||
(3, 1),
|
||||
(0, 0),
|
||||
(0, 1),
|
||||
(2, 0),
|
||||
(2, 1),
|
||||
(2, 2),
|
||||
(4, 0)
|
||||
]
|
||||
);
|
||||
let pages: Vec<_> = pages.iter().map(|p| p.0).collect();
|
||||
assert_eq!(pages, [0, 0, 0, 1, 1, 1, 1, 1, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bands_stay_in_distance_order() {
|
||||
let (mut grouped, mut draws, mut pages) =
|
||||
list(&[(10.0, 1, 1), (100.0, 0, 1), (200.0, 0, 1)]);
|
||||
grouped.regroup(&mut draws, &mut pages, 0);
|
||||
let order: Vec<_> = draws.iter().map(|d| d[0]).collect();
|
||||
assert_eq!(order, [0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_page_and_an_offset_list() {
|
||||
let (mut grouped, mut draws, mut pages) = list(&[(10.0, 3, 2), (90.0, 3, 1)]);
|
||||
let before = draws.clone();
|
||||
assert_eq!(grouped.regroup(&mut draws, &mut pages, 0)[1].start, 2);
|
||||
assert_eq!(draws, before);
|
||||
// A list after other draws: starts are absolute.
|
||||
let mut grouped = PageRuns::default();
|
||||
let mut draws = vec![[9; 5], [7, 0, 0, 0, 0], [8, 0, 0, 0, 0]];
|
||||
let mut pages = vec![(5, None), (1, None), (0, None)];
|
||||
grouped.push(bits(5.0), 1, 1, 1, [0; 3]);
|
||||
grouped.push(bits(6.0), 0, 2, 1, [1, 0, 0]);
|
||||
let runs = grouped.regroup(&mut draws, &mut pages, 1).to_vec();
|
||||
assert_eq!(draws, [[9; 5], [8, 0, 0, 0, 0], [7, 0, 0, 0, 0]]);
|
||||
assert_eq!(pages, [(5, None), (0, None), (1, None)]);
|
||||
assert_eq!((runs[0].tag[0], runs[0].start), (1, 1));
|
||||
// Empty runs are left out.
|
||||
grouped.clear();
|
||||
grouped.push(bits(1.0), 0, 0, 0, [0; 3]);
|
||||
assert!(grouped.regroup(&mut [], &mut [], 0).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -169,14 +169,20 @@ fn in_view(key: SectionKey, camera: DVec3, radius: i32, frustum: &Frustum) -> Op
|
||||
if !in_view_distance(camera_column(camera), radius, key.0, key.2) {
|
||||
return None;
|
||||
}
|
||||
in_frustum(key, camera, frustum)
|
||||
in_frustum(key, camera, frustum, false)
|
||||
}
|
||||
|
||||
/// `in_view` without the distance test.
|
||||
fn in_frustum(key: SectionKey, camera: DVec3, frustum: &Frustum) -> Option<(f64, Vec3)> {
|
||||
/// `in_view` without the distance test; `inside`: the section's column is
|
||||
/// entirely inside the frustum, so the section is too.
|
||||
fn in_frustum(
|
||||
key: SectionKey,
|
||||
camera: DVec3,
|
||||
frustum: &Frustum,
|
||||
inside: bool,
|
||||
) -> Option<(f64, Vec3)> {
|
||||
let origin = origin(key, camera);
|
||||
let min = origin.as_vec3();
|
||||
if !frustum.intersects(min, min + Vec3::splat(16.0)) {
|
||||
if !inside && !frustum.intersects(min, min + Vec3::splat(16.0)) {
|
||||
return None;
|
||||
}
|
||||
Some(((origin + DVec3::splat(8.0)).length_squared(), min))
|
||||
@@ -192,8 +198,9 @@ impl<T> Resident<T> {
|
||||
/// Columns are tested first, with the same distance test (it depends
|
||||
/// only on x and z) and a box spanning their sections: every section box
|
||||
/// lies inside it and the frustum test rounds monotonically, so a column
|
||||
/// the frustum rejects holds no section it would accept. `radius` is the
|
||||
/// render distance in chunks.
|
||||
/// the frustum rejects holds no section it would accept, and one
|
||||
/// entirely inside holds none it would reject (its sections skip the
|
||||
/// test). `radius` is the render distance in chunks.
|
||||
pub fn visible(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
@@ -211,7 +218,9 @@ impl<T> Resident<T> {
|
||||
}
|
||||
let min = origin((column.x, column.low, column.z), camera).as_vec3();
|
||||
let top = origin((column.x, column.high, column.z), camera).as_vec3();
|
||||
if !frustum.intersects(min, top + Vec3::splat(16.0)) {
|
||||
let max = top + Vec3::splat(16.0);
|
||||
let inside = frustum.contains(min, max);
|
||||
if !inside && !frustum.intersects(min, max) {
|
||||
continue;
|
||||
}
|
||||
for &(y, slot) in &column.sections {
|
||||
@@ -219,7 +228,7 @@ impl<T> Resident<T> {
|
||||
if near && !reachable(key) {
|
||||
continue;
|
||||
}
|
||||
if let Some((d, o)) = in_frustum(key, camera, frustum) {
|
||||
if let Some((d, o)) = in_frustum(key, camera, frustum, inside) {
|
||||
out.push(Visible {
|
||||
distance: d.to_bits(),
|
||||
key,
|
||||
@@ -277,7 +286,7 @@ mod tests {
|
||||
if near {
|
||||
in_view(k, camera, radius, &frustum).filter(|_| reachable(k))
|
||||
} else if beyond(k.0, k.2) {
|
||||
in_frustum(k, camera, &frustum)
|
||||
in_frustum(k, camera, &frustum, false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -298,6 +307,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_entirely_inside_skip_no_section() {
|
||||
// Short columns ahead of the camera: most lie entirely inside.
|
||||
let mut r = Resident::default();
|
||||
for x in -12i32..=12 {
|
||||
for z in 1..=12 {
|
||||
for y in 3..6 {
|
||||
if (x + y + z).rem_euclid(3) != 0 {
|
||||
r.insert((x, y, z), ());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let camera = DVec3::new(0.5, -8.0, 0.5);
|
||||
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
|
||||
let frustum = Frustum {
|
||||
planes: super::super::frustum(projection * crate::camera::view_matrix(0.0, 5.0)),
|
||||
};
|
||||
let inside = r
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
let min = origin((c.x, c.low, c.z), camera).as_vec3();
|
||||
let top = origin((c.x, c.high, c.z), camera).as_vec3();
|
||||
frustum.contains(min, top + Vec3::splat(16.0))
|
||||
})
|
||||
.count();
|
||||
assert!(inside > 50 && inside < r.columns.len(), "{inside}");
|
||||
let mut fast = Vec::new();
|
||||
r.visible(camera, 12, &frustum, |_| true, |_, _| false, &mut fast);
|
||||
let mut slow: Vec<_> = r
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|&(k, _)| in_view(k, camera, 12, &frustum).map(|(d, _)| (d, k)))
|
||||
.collect();
|
||||
slow.sort_unstable_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||
let fast: Vec<_> = fast
|
||||
.iter()
|
||||
.map(|v| (f64::from_bits(v.distance), v.key))
|
||||
.collect();
|
||||
assert_eq!(fast, slow);
|
||||
}
|
||||
|
||||
/// Every key reaches its value through its slot and column, and every
|
||||
/// column's bounds and sections match the keys.
|
||||
fn check(r: &Resident<u32>, model: &HashMap<SectionKey, u32>) {
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
//! GPU frame time from timestamp queries, read back a few frames late so the
|
||||
//! CPU never waits for it. Frames whose readback slot is still busy go untimed.
|
||||
//!
|
||||
//! Where the GPU can only time pass boundaries (Apple GPUs on Metal), the
|
||||
//! frame is bracketed by two one-thread compute passes that write the
|
||||
//! timestamps (Metal leaves an empty pass's timestamps at zero).
|
||||
//! Where the GPU can only time pass boundaries (Apple GPUs on Metal), a
|
||||
//! one-thread compute pass stamps the frame's start, and a render pass over
|
||||
//! the frame's target that shades one pixel with every write masked stamps
|
||||
//! its end (Metal leaves the timestamps of a pass that does nothing at zero).
|
||||
//! A pass that loads the target runs after every pass that draws into it; a
|
||||
//! compute pass, which depends on nothing, ran alongside them. Metal also
|
||||
//! resolves timestamps without waiting for the passes that write them, so
|
||||
//! they are resolved in a later frame, once the GPU has finished this one.
|
||||
//! Resolved in the frame itself, the end was mostly the one an earlier frame
|
||||
//! left in the query, and frames read about 0.01 ms.
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU8, Ordering},
|
||||
};
|
||||
|
||||
const SLOTS: usize = 4;
|
||||
/// Frames in flight at once; on Metal a slot is busy for about five frames.
|
||||
const SLOTS: usize = 8;
|
||||
/// Each slot's timestamps have their own queries and resolve range.
|
||||
const RESOLVE_STRIDE: u64 = wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT;
|
||||
/// The frame's target and its format.
|
||||
pub type Target<'a> = (&'a wgpu::TextureView, wgpu::TextureFormat);
|
||||
const FREE: u8 = 0;
|
||||
const PENDING: u8 = 1;
|
||||
const MAPPED: u8 = 2;
|
||||
/// Its frame is submitted; with markers, until the GPU has finished it.
|
||||
const RUNNING: u8 = 1;
|
||||
/// Its frame is finished: its timestamps can be resolved.
|
||||
const DONE: u8 = 2;
|
||||
/// Resolved and on its way back.
|
||||
const PENDING: u8 = 3;
|
||||
const MAPPED: u8 = 4;
|
||||
|
||||
struct Slot {
|
||||
buffer: wgpu::Buffer,
|
||||
@@ -27,10 +44,29 @@ pub struct GpuTimer {
|
||||
current: Option<usize>,
|
||||
next: usize,
|
||||
period_ns: f32,
|
||||
/// Timestamps between commands, or else this pass's boundaries.
|
||||
marker: Option<wgpu::ComputePipeline>,
|
||||
/// Timestamps between commands, or else these passes' boundaries.
|
||||
markers: Option<Markers>,
|
||||
/// Slots resolved in this frame's commands, read back once it is submitted.
|
||||
resolved: Vec<usize>,
|
||||
}
|
||||
|
||||
/// The marker passes: the start's compute pipeline, and the end's shader and
|
||||
/// pipeline, for the target format it was made for.
|
||||
struct Markers {
|
||||
module: wgpu::ShaderModule,
|
||||
start: wgpu::ComputePipeline,
|
||||
end: Option<(wgpu::TextureFormat, wgpu::RenderPipeline)>,
|
||||
}
|
||||
|
||||
/// The start's one thread; the end's triangle over the whole target (the
|
||||
/// pass's scissor keeps one pixel), shading nothing it writes.
|
||||
const MARKER: &str =
|
||||
"@vertex fn cover(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
return vec4<f32>(f32(i & 1u) * 4.0 - 1.0, f32(i >> 1u) * 4.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
@fragment fn nothing() -> @location(0) vec4<f32> { return vec4<f32>(0.0); }
|
||||
@compute @workgroup_size(1) fn mark() {}";
|
||||
|
||||
impl GpuTimer {
|
||||
/// The features timing uses that the adapter has (none: no timing).
|
||||
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
|
||||
@@ -44,11 +80,11 @@ impl GpuTimer {
|
||||
let queries = device.create_query_set(&wgpu::QuerySetDescriptor {
|
||||
label: Some("Frame timestamps"),
|
||||
ty: wgpu::QueryType::Timestamp,
|
||||
count: 2,
|
||||
count: 2 * SLOTS as u32,
|
||||
});
|
||||
let resolve = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Frame timestamp resolve"),
|
||||
size: 16,
|
||||
size: RESOLVE_STRIDE * SLOTS as u64,
|
||||
usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
@@ -70,42 +106,43 @@ impl GpuTimer {
|
||||
current: None,
|
||||
next: 0,
|
||||
period_ns: queue.get_timestamp_period(),
|
||||
marker: (!device
|
||||
markers: (!device
|
||||
.features()
|
||||
.contains(wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS))
|
||||
.then(|| {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Frame timestamp"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
"@compute @workgroup_size(1) fn mark() {}".into(),
|
||||
),
|
||||
source: wgpu::ShaderSource::Wgsl(MARKER.into()),
|
||||
});
|
||||
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Frame timestamp"),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("mark"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
})
|
||||
Markers {
|
||||
start: device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Frame timestamp"),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("mark"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
}),
|
||||
module,
|
||||
end: None,
|
||||
}
|
||||
}),
|
||||
resolved: Vec::new(),
|
||||
})
|
||||
}
|
||||
pub fn begin(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
if self.markers.is_some() {
|
||||
for slot in 0..SLOTS {
|
||||
if self.slots[slot].state.load(Ordering::Acquire) == DONE {
|
||||
self.resolve(encoder, slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
let slot = self.next;
|
||||
self.current = (self.slots[slot].state.load(Ordering::Acquire) == FREE).then_some(slot);
|
||||
if self.current.is_some() {
|
||||
self.stamp(encoder, 0);
|
||||
}
|
||||
}
|
||||
pub fn end(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(slot) = self.current else { return };
|
||||
self.stamp(encoder, 1);
|
||||
encoder.resolve_query_set(&self.queries, 0..2, &self.resolve, 0);
|
||||
encoder.copy_buffer_to_buffer(&self.resolve, 0, &self.slots[slot].buffer, 0, 16);
|
||||
}
|
||||
fn stamp(&self, encoder: &mut wgpu::CommandEncoder, index: u32) {
|
||||
let Some(marker) = &self.marker else {
|
||||
let index = 2 * slot as u32;
|
||||
let Some(markers) = &self.markers else {
|
||||
encoder.write_timestamp(&self.queries, index);
|
||||
return;
|
||||
};
|
||||
@@ -113,29 +150,85 @@ impl GpuTimer {
|
||||
label: Some("Frame timestamp"),
|
||||
timestamp_writes: Some(wgpu::ComputePassTimestampWrites {
|
||||
query_set: &self.queries,
|
||||
beginning_of_pass_write_index: (index == 0).then_some(0),
|
||||
end_of_pass_write_index: (index == 1).then_some(1),
|
||||
beginning_of_pass_write_index: Some(index),
|
||||
end_of_pass_write_index: None,
|
||||
}),
|
||||
});
|
||||
pass.set_pipeline(marker);
|
||||
pass.set_pipeline(&markers.start);
|
||||
pass.dispatch_workgroups(1, 1, 1);
|
||||
}
|
||||
/// After the frame's submission: start reading its timestamps back.
|
||||
pub fn submitted(&mut self) {
|
||||
/// After the frame's last pass into `target` (the frame's own, of `format`).
|
||||
pub fn end(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
(target, format): Target,
|
||||
) {
|
||||
let Some(slot) = self.current else { return };
|
||||
let index = 2 * slot as u32 + 1;
|
||||
let Some(markers) = &mut self.markers else {
|
||||
encoder.write_timestamp(&self.queries, index);
|
||||
self.resolve(encoder, slot);
|
||||
return;
|
||||
};
|
||||
if markers.end.as_ref().is_none_or(|(f, _)| *f != format) {
|
||||
markers.end = Some((format, end_pipeline(device, &markers.module, format)));
|
||||
}
|
||||
let (_, pipeline) = markers.end.as_ref().expect("made above");
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Frame timestamp"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
timestamp_writes: Some(wgpu::RenderPassTimestampWrites {
|
||||
query_set: &self.queries,
|
||||
beginning_of_pass_write_index: None,
|
||||
end_of_pass_write_index: Some(index),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_scissor_rect(0, 0, 1, 1);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
/// Copy `slot`'s timestamps to its readback buffer.
|
||||
fn resolve(&mut self, encoder: &mut wgpu::CommandEncoder, slot: usize) {
|
||||
let (first, at) = (2 * slot as u32, RESOLVE_STRIDE * slot as u64);
|
||||
encoder.resolve_query_set(&self.queries, first..first + 2, &self.resolve, at);
|
||||
encoder.copy_buffer_to_buffer(&self.resolve, at, &self.slots[slot].buffer, 0, 16);
|
||||
self.slots[slot].state.store(RUNNING, Ordering::Release);
|
||||
self.resolved.push(slot);
|
||||
}
|
||||
/// After the frame's submission: start reading back what it resolved,
|
||||
/// and with markers, wait for the GPU to finish the frame it timed.
|
||||
pub fn submitted(&mut self, queue: &wgpu::Queue) {
|
||||
for slot in self.resolved.drain(..) {
|
||||
let state = self.slots[slot].state.clone();
|
||||
state.store(PENDING, Ordering::Release);
|
||||
self.slots[slot]
|
||||
.buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
state.store(
|
||||
if result.is_ok() { MAPPED } else { FREE },
|
||||
Ordering::Release,
|
||||
);
|
||||
});
|
||||
}
|
||||
let Some(slot) = self.current.take() else {
|
||||
return;
|
||||
};
|
||||
let state = self.slots[slot].state.clone();
|
||||
state.store(PENDING, Ordering::Release);
|
||||
self.slots[slot]
|
||||
.buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
state.store(
|
||||
if result.is_ok() { MAPPED } else { FREE },
|
||||
Ordering::Release,
|
||||
);
|
||||
});
|
||||
if self.markers.is_some() {
|
||||
let state = self.slots[slot].state.clone();
|
||||
state.store(RUNNING, Ordering::Release);
|
||||
queue.on_submitted_work_done(move || state.store(DONE, Ordering::Release));
|
||||
}
|
||||
self.next = (slot + 1) % SLOTS;
|
||||
}
|
||||
/// GPU milliseconds of frames whose timestamps have arrived, oldest first.
|
||||
@@ -149,11 +242,46 @@ impl GpuTimer {
|
||||
if let Ok(data) = slot.buffer.slice(..).get_mapped_range() {
|
||||
// Read unaligned: the mapping isn't guaranteed to be 8-byte aligned.
|
||||
let stamps: [u64; 2] = bytemuck::pod_read_unaligned(&data[..16]);
|
||||
let ticks = stamps[1].saturating_sub(stamps[0]);
|
||||
out.push((ticks as f64 * f64::from(self.period_ns) / 1e6) as f32);
|
||||
// A start marker that ran late (rare on Metal) leaves the frame untimed.
|
||||
if let Some(ticks) = stamps[1].checked_sub(stamps[0]) {
|
||||
out.push((ticks as f64 * f64::from(self.period_ns) / 1e6) as f32);
|
||||
}
|
||||
}
|
||||
slot.buffer.unmap();
|
||||
slot.state.store(FREE, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The end marker's pipeline for a `format` target, every write masked.
|
||||
fn end_pipeline(
|
||||
device: &wgpu::Device,
|
||||
module: &wgpu::ShaderModule,
|
||||
format: wgpu::TextureFormat,
|
||||
) -> wgpu::RenderPipeline {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Frame timestamp"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module,
|
||||
entry_point: Some("cover"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module,
|
||||
entry_point: Some("nothing"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
@@ -1,26 +1,163 @@
|
||||
//! Resolution scale and FXAA (Horizon settings): the world renders into its
|
||||
//! own target at the scaled size, then one full-screen pass copies or
|
||||
//! anti-aliases it onto the screen, under the hand and HUD. A shader pack
|
||||
//! also needs the target (its stages read the world's colour and depth).
|
||||
//! Resolution scale, FXAA, and shader-pack G-buffer/shadow targets (Horizon
|
||||
//! settings): the world renders into its own target at the scaled size, then
|
||||
//! one full-screen pass copies or anti-aliases it onto the screen, under the
|
||||
//! hand and HUD. For a shader pack that reads them, `Post` also keeps:
|
||||
//! - `opaque_depth`: the depth before translucent terrain (water, stained
|
||||
//! glass, ice) draws, a Java pack's `depthtex1`/`depthtex2`;
|
||||
//! - `normal` and `material`, an api 2 pack's `scene_normal` and
|
||||
//! `scene_material`: surface normals and material properties
|
||||
//! reconstructed from depth and scene colour;
|
||||
//! - `shadow_depth`, an api 2 pack's: the sun/moon shadow map and its
|
||||
//! light-space `shadow_view` and `shadow_proj` matrices.
|
||||
//!
|
||||
//! With none of them on, the world renders straight to the screen as before.
|
||||
|
||||
use super::pack_stages::FrameUniform;
|
||||
use glam::{Mat4, Vec3};
|
||||
|
||||
const GBUFFER_WGSL: &str = r#"
|
||||
struct GbufferParams {
|
||||
inverse_view: mat4x4<f32>,
|
||||
inverse_projection: mat4x4<f32>,
|
||||
resolution: vec4<f32>,
|
||||
// x: eye block light (0..1), y: eye sky light (0..1), z: wetness (0..1), w: sun elevation
|
||||
env: vec4<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var world_color: texture_2d<f32>;
|
||||
@group(0) @binding(1) var world_depth: texture_depth_2d;
|
||||
@group(0) @binding(2) var opaque_depth: texture_depth_2d;
|
||||
@group(0) @binding(3) var nearest_sampler: sampler;
|
||||
@group(0) @binding(4) var<uniform> params: GbufferParams;
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_gbuffer(@builtin(vertex_index) i: u32) -> VsOut {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
var out: VsOut;
|
||||
out.clip = vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
out.uv = vec2<f32>(p.x, 1.0 - p.y);
|
||||
return out;
|
||||
}
|
||||
|
||||
struct GbufferOut {
|
||||
@location(0) normal: vec4<f32>,
|
||||
@location(1) material: vec4<f32>,
|
||||
}
|
||||
|
||||
fn world_pos(uv: vec2<f32>, d: f32) -> vec3<f32> {
|
||||
let v = params.inverse_projection * vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, d, 1.0);
|
||||
let view = v.xyz / max(v.w, 1e-6);
|
||||
return (params.inverse_view * vec4<f32>(view, 1.0)).xyz;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_gbuffer(in: VsOut) -> GbufferOut {
|
||||
let d_all = textureSampleLevel(world_depth, nearest_sampler, in.uv, 0);
|
||||
let d_opq = textureSampleLevel(opaque_depth, nearest_sampler, in.uv, 0);
|
||||
let color = textureSampleLevel(world_color, nearest_sampler, in.uv, 0.0);
|
||||
var out: GbufferOut;
|
||||
if d_all <= 0.0 && d_opq <= 0.0 {
|
||||
out.normal = vec4<f32>(0.5, 1.0, 0.5, 0.0);
|
||||
out.material = vec4<f32>(0.0, params.env.y, 0.0, 1.0);
|
||||
return out;
|
||||
}
|
||||
let d = select(d_all, d_opq, d_opq > 0.0);
|
||||
let step_uv = params.resolution.zw;
|
||||
let p0 = world_pos(in.uv, d);
|
||||
let uv_r = clamp(in.uv + vec2<f32>(step_uv.x, 0.0), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
let uv_l = clamp(in.uv - vec2<f32>(step_uv.x, 0.0), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
let uv_d = clamp(in.uv + vec2<f32>(0.0, step_uv.y), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
let uv_u = clamp(in.uv - vec2<f32>(0.0, step_uv.y), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
let dr = textureSampleLevel(opaque_depth, nearest_sampler, uv_r, 0);
|
||||
let dl = textureSampleLevel(opaque_depth, nearest_sampler, uv_l, 0);
|
||||
let dd = textureSampleLevel(opaque_depth, nearest_sampler, uv_d, 0);
|
||||
let du = textureSampleLevel(opaque_depth, nearest_sampler, uv_u, 0);
|
||||
let dx = select(world_pos(uv_r, dr) - p0, p0 - world_pos(uv_l, dl), abs(dl - d) < abs(dr - d));
|
||||
let dy = select(world_pos(uv_d, dd) - p0, p0 - world_pos(uv_u, du), abs(du - d) < abs(dd - d));
|
||||
let raw_n = cross(dy, dx);
|
||||
let n_len = length(raw_n);
|
||||
let n = select(vec3<f32>(0.0, 1.0, 0.0), raw_n / max(n_len, 1e-6), n_len > 1e-6);
|
||||
// Reversed depth: closer translucent geometry has larger depth than opaque geometry.
|
||||
let is_translucent = select(0.0, 1.0, d_all > d_opq + 1e-5);
|
||||
let luma = dot(color.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
let block_light = clamp(max(params.env.x, smoothstep(0.65, 0.98, luma)), 0.0, 1.0);
|
||||
let roughness = select(clamp(0.85 - params.env.z * 0.45, 0.1, 1.0), 0.05, is_translucent > 0.5);
|
||||
out.normal = vec4<f32>(n * 0.5 + 0.5, 1.0);
|
||||
out.material = vec4<f32>(block_light, params.env.y, is_translucent, roughness);
|
||||
return out;
|
||||
}
|
||||
"#;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct GbufferUniform {
|
||||
inverse_view: [[f32; 4]; 4],
|
||||
inverse_projection: [[f32; 4]; 4],
|
||||
resolution: [f32; 4],
|
||||
env: [f32; 4],
|
||||
}
|
||||
|
||||
/// What the frame's shader pack reads beyond the world's colour and depth:
|
||||
/// `Post` keeps these targets only while one does.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Extras {
|
||||
pub opaque_depth: bool,
|
||||
pub gbuffer: bool,
|
||||
}
|
||||
|
||||
struct WorldTarget {
|
||||
size: [u32; 2],
|
||||
extras: Extras,
|
||||
color: wgpu::TextureView,
|
||||
depth_tex: wgpu::Texture,
|
||||
depth: wgpu::TextureView,
|
||||
/// With `extras.opaque_depth` or `extras.gbuffer` (which reads it).
|
||||
opaque_depth: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
gbuffer: Option<Gbuffer>,
|
||||
group: wgpu::BindGroup,
|
||||
opaque_captured: bool,
|
||||
}
|
||||
|
||||
struct Gbuffer {
|
||||
normal: wgpu::TextureView,
|
||||
material: wgpu::TextureView,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
struct ShadowTarget {
|
||||
resolution: u32,
|
||||
depth: wgpu::TextureView,
|
||||
}
|
||||
|
||||
pub struct Post {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
nearest: wgpu::Sampler,
|
||||
copy: wgpu::RenderPipeline,
|
||||
fxaa: wgpu::RenderPipeline,
|
||||
gbuffer_layout: wgpu::BindGroupLayout,
|
||||
gbuffer_pipeline: wgpu::RenderPipeline,
|
||||
gbuffer_uniform: wgpu::Buffer,
|
||||
format: wgpu::TextureFormat,
|
||||
/// World resolution relative to the window (0.5..=2.0).
|
||||
pub scale: f32,
|
||||
pub antialias: bool,
|
||||
target: Option<WorldTarget>,
|
||||
shadow: Option<ShadowTarget>,
|
||||
/// Bumped whenever a target is made: bindings of the old ones are stale.
|
||||
generation: u64,
|
||||
fallback_shadow: wgpu::TextureView,
|
||||
fallback_color: wgpu::TextureView,
|
||||
pub shadow_view: Mat4,
|
||||
pub shadow_proj: Mat4,
|
||||
pub shadow_distance: f32,
|
||||
pub shadow_resolution: u32,
|
||||
pub shadow_active: bool,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
@@ -88,76 +225,482 @@ impl Post {
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
let nearest = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Horizon post nearest"),
|
||||
mag_filter: wgpu::FilterMode::Nearest,
|
||||
min_filter: wgpu::FilterMode::Nearest,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let gbuffer_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
source: wgpu::ShaderSource::Wgsl(GBUFFER_WGSL.into()),
|
||||
});
|
||||
let gbuffer_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon gbuffer layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 3,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let gbuffer_pipe_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
bind_group_layouts: &[Some(&gbuffer_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let gbuffer_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
layout: Some(&gbuffer_pipe_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &gbuffer_shader,
|
||||
entry_point: Some("vs_gbuffer"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &gbuffer_shader,
|
||||
entry_point: Some("fs_gbuffer"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let gbuffer_uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon gbuffer uniform"),
|
||||
size: std::mem::size_of::<GbufferUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let fallback_shadow = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon fallback shadow"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
let fallback_color = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon fallback gbuffer"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
Self {
|
||||
copy: pipeline("fs_copy"),
|
||||
fxaa: pipeline("fs_fxaa"),
|
||||
layout,
|
||||
sampler,
|
||||
nearest,
|
||||
gbuffer_layout,
|
||||
gbuffer_pipeline,
|
||||
gbuffer_uniform,
|
||||
format,
|
||||
scale: 1.0,
|
||||
antialias: false,
|
||||
target: None,
|
||||
shadow: None,
|
||||
generation: 0,
|
||||
fallback_shadow,
|
||||
fallback_color,
|
||||
shadow_view: Mat4::IDENTITY,
|
||||
shadow_proj: Mat4::IDENTITY,
|
||||
shadow_distance: 128.0,
|
||||
shadow_resolution: 2048,
|
||||
shadow_active: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The world's colour and depth targets this frame, or `None` to draw
|
||||
/// it straight to the screen. `pack`: a shader pack runs this frame.
|
||||
/// it straight to the screen. `pack`: a shader pack runs this frame,
|
||||
/// reading `extras`.
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
window: [u32; 2],
|
||||
pack: bool,
|
||||
extras: Extras,
|
||||
) -> Option<(wgpu::TextureView, wgpu::TextureView)> {
|
||||
if self.scale == 1.0 && !self.antialias && !pack {
|
||||
self.target = None;
|
||||
self.shadow_active = false;
|
||||
return None;
|
||||
}
|
||||
let size = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
if self.target.as_ref().is_none_or(|t| t.size != size) {
|
||||
if self
|
||||
.target
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size != size || t.extras != extras)
|
||||
{
|
||||
self.generation += 1;
|
||||
let extent = wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let texture = |label, format, usage| {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: extent,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
let make_tex = |label, format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: extent,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let color = texture(
|
||||
let color = make_tex(
|
||||
"Scaled world",
|
||||
self.format,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
);
|
||||
let depth = texture(
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let depth_tex = make_tex(
|
||||
"Scaled world depth",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let depth = depth_tex.create_view(&Default::default());
|
||||
let opaque_depth = (extras.opaque_depth || extras.gbuffer).then(|| {
|
||||
let tex = make_tex(
|
||||
"Opaque world depth",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
);
|
||||
let view = tex.create_view(&Default::default());
|
||||
(tex, view)
|
||||
});
|
||||
let gbuffer = opaque_depth
|
||||
.as_ref()
|
||||
.filter(|_| extras.gbuffer)
|
||||
.map(|(_, opaque)| {
|
||||
let color_target = |label| {
|
||||
make_tex(
|
||||
label,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
)
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
layout: &self.gbuffer_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&color),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&depth),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(opaque),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::Sampler(&self.nearest),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: self.gbuffer_uniform.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
Gbuffer {
|
||||
normal: color_target("World G-buffer normal"),
|
||||
material: color_target("World G-buffer material"),
|
||||
group,
|
||||
}
|
||||
});
|
||||
let group = self.bind(device, &color);
|
||||
self.target = Some(WorldTarget {
|
||||
size,
|
||||
extras,
|
||||
color,
|
||||
depth_tex,
|
||||
depth,
|
||||
opaque_depth,
|
||||
gbuffer,
|
||||
group,
|
||||
opaque_captured: false,
|
||||
});
|
||||
}
|
||||
if let Some(t) = &mut self.target {
|
||||
t.opaque_captured = false;
|
||||
}
|
||||
self.target
|
||||
.as_ref()
|
||||
.map(|t| (t.color.clone(), t.depth.clone()))
|
||||
}
|
||||
|
||||
/// Prepare the directional sun/moon shadow map target and compute its
|
||||
/// camera-relative orthographic `shadow_view` and `shadow_proj` matrices.
|
||||
pub fn prepare_shadow(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
resolution: u32,
|
||||
distance: f32,
|
||||
sun_dir: Vec3,
|
||||
moon_dir: Vec3,
|
||||
) -> &wgpu::TextureView {
|
||||
let resolution = resolution.clamp(256, 4096);
|
||||
let distance = distance.clamp(16.0, 512.0);
|
||||
if self
|
||||
.shadow
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.resolution != resolution)
|
||||
{
|
||||
let depth = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon shadow map"),
|
||||
size: wgpu::Extent3d {
|
||||
width: resolution,
|
||||
height: resolution,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
self.shadow = Some(ShadowTarget { resolution, depth });
|
||||
self.generation += 1;
|
||||
}
|
||||
let light_dir = if sun_dir.y >= -0.05 {
|
||||
sun_dir.normalize_or_zero()
|
||||
} else {
|
||||
moon_dir.normalize_or_zero()
|
||||
};
|
||||
let dir = if light_dir.length_squared() > 1e-4 {
|
||||
light_dir
|
||||
} else {
|
||||
Vec3::Y
|
||||
};
|
||||
let up = if dir.y.abs() > 0.99 { Vec3::Z } else { Vec3::Y };
|
||||
self.shadow_view = Mat4::look_at_rh(dir * distance, Vec3::ZERO, up);
|
||||
// Reversed-depth orthographic projection for the world's GreaterEqual
|
||||
// pipelines (near maps to 1.0, far maps to 0.0), and standard 0..1
|
||||
// projection for shader sampling.
|
||||
self.shadow_proj = Mat4::orthographic_rh(
|
||||
-distance,
|
||||
distance,
|
||||
-distance,
|
||||
distance,
|
||||
distance * 2.5,
|
||||
0.05,
|
||||
);
|
||||
self.shadow_distance = distance;
|
||||
self.shadow_resolution = resolution;
|
||||
self.shadow_active = true;
|
||||
&self.shadow.as_ref().expect("created above").depth
|
||||
}
|
||||
|
||||
/// Snapshot `world_depth` into `opaque_depth` (`depthtex1`/`depthtex2`)
|
||||
/// right before translucent water and weather draw.
|
||||
pub fn capture_opaque_depth(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(t) = &mut self.target else { return };
|
||||
let Some((opaque, _)) = &t.opaque_depth else {
|
||||
return;
|
||||
};
|
||||
encoder.copy_texture_to_texture(
|
||||
t.depth_tex.as_image_copy(),
|
||||
opaque.as_image_copy(),
|
||||
t.depth_tex.size(),
|
||||
);
|
||||
t.opaque_captured = true;
|
||||
}
|
||||
|
||||
/// Reconstruct the G-buffer `normal` and `material` textures after the
|
||||
/// world pass finishes (with `Extras::gbuffer`).
|
||||
pub fn build_gbuffer(
|
||||
&mut self,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &FrameUniform,
|
||||
eye_light: [u8; 2],
|
||||
wetness: f32,
|
||||
) {
|
||||
if !self.target.as_ref().is_some_and(|t| t.opaque_captured) {
|
||||
self.capture_opaque_depth(encoder);
|
||||
}
|
||||
let Some(t) = &self.target else { return };
|
||||
let Some(gbuffer) = &t.gbuffer else { return };
|
||||
let [w, h] = t.size.map(|v| v.max(1) as f32);
|
||||
let uniform = GbufferUniform {
|
||||
inverse_view: frame.inverse_view,
|
||||
inverse_projection: frame.inverse_projection,
|
||||
resolution: [w, h, 1.0 / w, 1.0 / h],
|
||||
env: [
|
||||
f32::from(eye_light[0]) / 15.0,
|
||||
f32::from(eye_light[1]) / 15.0,
|
||||
wetness,
|
||||
frame.sun[1],
|
||||
],
|
||||
};
|
||||
queue.write_buffer(&self.gbuffer_uniform, 0, bytemuck::bytes_of(&uniform));
|
||||
let attachments = [
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view: &gbuffer.normal,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
}),
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view: &gbuffer.material,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
}),
|
||||
];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.gbuffer_pipeline);
|
||||
pass.set_bind_group(0, &gbuffer.group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// The world target's size, once `prepare` made one.
|
||||
pub fn size(&self) -> Option<[u32; 2]> {
|
||||
self.target.as_ref().map(|t| t.size)
|
||||
}
|
||||
|
||||
/// Changes whenever `prepare` or `prepare_shadow` makes new targets:
|
||||
/// bindings made from the views below must be made again.
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
/// The depth before translucent geometry (`capture_opaque_depth`), or
|
||||
/// the world's depth when nothing reads it.
|
||||
pub fn opaque_depth(&self) -> &wgpu::TextureView {
|
||||
match &self.target {
|
||||
Some(WorldTarget {
|
||||
opaque_depth: Some((_, view)),
|
||||
..
|
||||
}) => view,
|
||||
Some(t) => &t.depth,
|
||||
None => &self.fallback_shadow,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstructed world-space normals (`scene_normal`).
|
||||
pub fn normal(&self) -> &wgpu::TextureView {
|
||||
self.gbuffer().map_or(&self.fallback_color, |g| &g.normal)
|
||||
}
|
||||
|
||||
/// Reconstructed material and light (`scene_material`).
|
||||
pub fn material(&self) -> &wgpu::TextureView {
|
||||
self.gbuffer().map_or(&self.fallback_color, |g| &g.material)
|
||||
}
|
||||
|
||||
fn gbuffer(&self) -> Option<&Gbuffer> {
|
||||
self.target.as_ref()?.gbuffer.as_ref()
|
||||
}
|
||||
|
||||
/// The sun/moon shadow map (`shadow_depth` of api 2 packs).
|
||||
pub fn shadow_depth(&self) -> &wgpu::TextureView {
|
||||
self.shadow
|
||||
.as_ref()
|
||||
.map_or(&self.fallback_shadow, |s| &s.depth)
|
||||
}
|
||||
|
||||
/// Bindings that copy (or anti-alias) `view` onto the screen in `finish_with`.
|
||||
pub fn bind(&self, device: &wgpu::Device, view: &wgpu::TextureView) -> wgpu::BindGroup {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
@@ -175,11 +718,13 @@ impl Post {
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// Draw the scaled world onto `screen`.
|
||||
pub fn finish(&self, encoder: &mut wgpu::CommandEncoder, screen: &wgpu::TextureView) {
|
||||
let Some(target) = &self.target else { return };
|
||||
self.finish_with(encoder, screen, &target.group);
|
||||
}
|
||||
|
||||
/// Draw what `group` binds (`bind`) onto `screen`.
|
||||
pub fn finish_with(
|
||||
&self,
|
||||
|
||||
@@ -156,6 +156,48 @@ pub enum Overlay {
|
||||
Graph,
|
||||
}
|
||||
|
||||
/// How finely far terrain is drawn (`FarDetail` in the LOD options).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum FarDetail {
|
||||
/// Each tile level starts at eight times its size from the camera.
|
||||
#[default]
|
||||
Full,
|
||||
/// Coarser levels start at half that distance: about a quarter of the
|
||||
/// far quads, for GPUs that far terrain slows down. Changes the picture.
|
||||
Reduced,
|
||||
}
|
||||
impl FarDetail {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
FarDetail::Full => "full",
|
||||
FarDetail::Reduced => "reduced",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FarDetail::Full => "Full",
|
||||
FarDetail::Reduced => "Reduced",
|
||||
}
|
||||
}
|
||||
fn parse(s: &str) -> Self {
|
||||
if s == "reduced" {
|
||||
FarDetail::Reduced
|
||||
} else {
|
||||
FarDetail::Full
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static REDUCED_SUGGESTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the settings suggest Reduced far detail: set when the renderer
|
||||
/// starts, on the GPUs `lod_capped` names (far terrain slows them most).
|
||||
pub fn suggest_reduced_far_detail(on: bool) {
|
||||
REDUCED_SUGGESTED.store(on, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
pub fn reduced_far_detail_suggested() -> bool {
|
||||
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
|
||||
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
|
||||
/// costs them more frame time than it is worth.
|
||||
@@ -181,6 +223,8 @@ pub struct Graphics {
|
||||
pub preset: Preset,
|
||||
/// Distant surface terrain range, in chunks; 0 disables LODs.
|
||||
pub lod_distance: u32,
|
||||
/// How finely far terrain is drawn; Full unless the player picks Reduced.
|
||||
pub far_detail: FarDetail,
|
||||
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
|
||||
pub cave_culling: bool,
|
||||
/// Hide shared leaf and mangrove-root faces, reducing foliage density.
|
||||
@@ -219,6 +263,7 @@ impl Default for Graphics {
|
||||
Self {
|
||||
preset: Preset::Custom,
|
||||
lod_distance: 0,
|
||||
far_detail: FarDetail::Full,
|
||||
cave_culling: true,
|
||||
cull_leaves: false,
|
||||
entity_culling: false,
|
||||
@@ -265,6 +310,7 @@ impl Graphics {
|
||||
match key.trim() {
|
||||
"preset" => g.preset = Preset::parse(value),
|
||||
"lodDistance" => g.lod_distance = number(g.lod_distance).min(1024),
|
||||
"farDetail" => g.far_detail = FarDetail::parse(value),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"cullLeaves" => g.cull_leaves = flag(g.cull_leaves),
|
||||
"entityCulling" => g.entity_culling = flag(g.entity_culling),
|
||||
@@ -302,9 +348,10 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\ncaveCulling:{}\ncullLeaves:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\nshaderPack:{}\ngraphicsApi:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\ncullLeaves:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\nshaderPack:{}\ngraphicsApi:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
bool_text(self.cave_culling),
|
||||
bool_text(self.cull_leaves),
|
||||
bool_text(self.entity_culling),
|
||||
@@ -405,6 +452,7 @@ mod tests {
|
||||
let g = Graphics {
|
||||
preset: Preset::Balanced,
|
||||
lod_distance: 256,
|
||||
far_detail: FarDetail::Reduced,
|
||||
cave_culling: false,
|
||||
cull_leaves: true,
|
||||
entity_culling: true,
|
||||
@@ -487,6 +535,24 @@ mod tests {
|
||||
assert_eq!(Graphics::parse("lodDistance:invalid\n").lod_distance, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn far_detail_is_full_unless_reduced_is_picked() {
|
||||
assert_eq!(Graphics::default().far_detail, FarDetail::Full);
|
||||
assert_eq!(
|
||||
Graphics::parse("farDetail:reduced\n").far_detail,
|
||||
FarDetail::Reduced
|
||||
);
|
||||
for text in ["farDetail:full\n", "farDetail:other\n", ""] {
|
||||
assert_eq!(Graphics::parse(text).far_detail, FarDetail::Full);
|
||||
}
|
||||
// No preset changes it.
|
||||
for preset in Preset::ALL {
|
||||
let mut s = crate::settings::Settings::default();
|
||||
apply_preset(preset, &mut s);
|
||||
assert_eq!(s.horizon.far_detail, FarDetail::Full, "{preset:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_workers_leave_cores_for_the_render_and_server_threads() {
|
||||
let g = Graphics::default();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! they show, in what groups, with what description and cost, and their
|
||||
//! input. How they look comes from `graphics_style`.
|
||||
use crate::{
|
||||
graphics::{GraphicsApi, Overlay, Pacing, PresentMode, Preset},
|
||||
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset},
|
||||
graphics_style::{Cost, STYLE},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
options_ui::Opt,
|
||||
@@ -22,6 +22,7 @@ pub enum Level {
|
||||
pub enum H {
|
||||
Preset,
|
||||
LodDistance,
|
||||
FarDetail,
|
||||
CaveCulling,
|
||||
CullLeaves,
|
||||
EntityCulling,
|
||||
@@ -85,6 +86,16 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
Medium,
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.",
|
||||
);
|
||||
// Suggested, never switched on, where far terrain costs the GPU most.
|
||||
let far = h(
|
||||
H::FarDetail,
|
||||
Medium,
|
||||
if crate::graphics::reduced_far_detail_suggested() {
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU."
|
||||
} else {
|
||||
"Reduced draws far terrain coarser, for GPUs it slows down."
|
||||
},
|
||||
);
|
||||
let fov = v(
|
||||
Opt::Fov,
|
||||
Low,
|
||||
@@ -101,17 +112,23 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
"Waits for the display's refresh: no tearing, a little latency.",
|
||||
);
|
||||
let gui = v(Opt::GuiScale, None, "Size of menus and the HUD.");
|
||||
let shader_pack = h(
|
||||
H::ShaderPack,
|
||||
High,
|
||||
"Redraws the world with a pack from the shaderpacks folder. Off is vanilla.",
|
||||
);
|
||||
let preset = h(
|
||||
H::Preset,
|
||||
None,
|
||||
"Sets the options below together. Changing one makes it Custom.",
|
||||
);
|
||||
let mut rows = match level {
|
||||
Level::Simple => vec![preset, render, lod, fov, fps, vsync, gui],
|
||||
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui, shader_pack],
|
||||
Level::Advanced => vec![
|
||||
Row::Header("Rendering"),
|
||||
render,
|
||||
lod,
|
||||
far,
|
||||
v(
|
||||
Opt::SimulationDistance,
|
||||
Medium,
|
||||
@@ -234,11 +251,7 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
None,
|
||||
"Lowers the frame rate when minimised, or idle too.",
|
||||
),
|
||||
h(
|
||||
H::ShaderPack,
|
||||
High,
|
||||
"Redraws the world with a pack from the shaderpacks folder. Off is vanilla.",
|
||||
),
|
||||
shader_pack,
|
||||
Row::Header("Preferences"),
|
||||
v(Opt::ViewBobbing, None, "Sways the view while walking."),
|
||||
v(Opt::ChunkFade, None, "Fades newly loaded chunks in."),
|
||||
@@ -335,6 +348,7 @@ impl H {
|
||||
0 => labelled("LOD Distance", "Off"),
|
||||
n => labelled("LOD Distance", format!("{n} chunks")),
|
||||
},
|
||||
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
H::CullLeaves => labelled("Cull Leaves", on_off(g.cull_leaves)),
|
||||
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
|
||||
@@ -411,6 +425,9 @@ impl H {
|
||||
H::LodDistance => {
|
||||
g.lod_distance = step(&[0, 64, 128, 256, 512, 1024], g.lod_distance, back)
|
||||
}
|
||||
H::FarDetail => {
|
||||
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
|
||||
}
|
||||
H::CaveCulling => g.cave_culling = !g.cave_culling,
|
||||
H::CullLeaves => g.cull_leaves = !g.cull_leaves,
|
||||
H::EntityCulling => g.entity_culling = !g.entity_culling,
|
||||
|
||||
@@ -9,6 +9,11 @@ use std::{
|
||||
};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
/// A sprite's atlas region, and its size in GUI pixels: the size of the
|
||||
/// default resources' texture of that name, whatever the resolution of the
|
||||
/// pack that replaced it (vanilla's GUI code draws in those units: `blit`
|
||||
/// divides by the texture size it is given, sprites by their `gui.scaling`
|
||||
/// size). Textures the default resources lack keep their own size.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Rect {
|
||||
pub u0: f32,
|
||||
@@ -18,11 +23,44 @@ pub struct Rect {
|
||||
pub w: u32,
|
||||
pub h: u32,
|
||||
}
|
||||
impl Rect {
|
||||
/// The region's size in texels of an atlas this large.
|
||||
pub fn texels(&self, (width, height): (u32, u32)) -> (u32, u32) {
|
||||
(
|
||||
((self.u1 - self.u0) * width as f32).round() as u32,
|
||||
((self.v1 - self.v0) * height as f32).round() as u32,
|
||||
)
|
||||
}
|
||||
}
|
||||
/// `TextureMetadataSection`'s `blur` and `clamp`: how vanilla samples a
|
||||
/// standalone texture (`ReloadableTexture.setSampler`: linear rather than
|
||||
/// nearest filtering, clamp-to-edge rather than repeat addressing).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct TextureFlags {
|
||||
pub blur: bool,
|
||||
pub clamp: bool,
|
||||
}
|
||||
impl TextureFlags {
|
||||
/// The `texture` section of a `.png.mcmeta` file, if it sets either flag.
|
||||
pub fn from_mcmeta(json: &str) -> Option<Self> {
|
||||
let meta: serde_json::Value = serde_json::from_str(json).ok()?;
|
||||
let section = meta.get("texture")?;
|
||||
let flag = |key| section.get(key).and_then(serde_json::Value::as_bool) == Some(true);
|
||||
let flags = Self {
|
||||
blur: flag("blur"),
|
||||
clamp: flag("clamp"),
|
||||
};
|
||||
(flags != Self::default()).then_some(flags)
|
||||
}
|
||||
}
|
||||
pub struct HudAtlas {
|
||||
pub pixels: Vec<u8>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub sprites: HashMap<String, Rect>,
|
||||
/// Sprites whose `.png.mcmeta` sets `blur` or `clamp`; the others
|
||||
/// sample as vanilla's defaults (nearest, repeat).
|
||||
pub flags: HashMap<String, TextureFlags>,
|
||||
pub fire_animations: Vec<(Rect, crate::sprites::SpriteAnimation)>,
|
||||
/// The default font (bitmap sheets in this atlas, unifont rasterized on demand).
|
||||
pub font: crate::font::Font,
|
||||
@@ -30,6 +68,9 @@ pub struct HudAtlas {
|
||||
}
|
||||
/// Atlas rows reserved for glyphs rasterized at run time.
|
||||
pub const GLYPH_ROWS: u32 = 512;
|
||||
/// The largest atlas side: the 2D texture limit of the GPUs Horizon runs on
|
||||
/// (Apple silicon, RADV and Intel allow 16384; NVIDIA 32768).
|
||||
pub const MAX_ATLAS_SIZE: u32 = 16384;
|
||||
/// 128x128 atlas slots for map textures. A lobby paints its signs as walls of
|
||||
/// framed maps, so the pool holds a wall; too few slots re-upload every map
|
||||
/// every frame. 26.3's `MapTextureManager` keeps a texture per map instead.
|
||||
@@ -40,6 +81,9 @@ pub const MINIMAP_SPRITE: &str = "horizon/minimap";
|
||||
pub const SKIN_SLOTS: usize = 32;
|
||||
/// Reused by the visible rows of the classic world selector.
|
||||
pub const WORLD_ICON_SLOTS: usize = 32;
|
||||
/// Atlas slots for armour trim textures made at run time (`trims`), each the
|
||||
/// size of the largest trim texture.
|
||||
pub const TRIM_SLOTS: usize = 32;
|
||||
pub fn decode(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
decode_bytes(&fs::read(path)?)
|
||||
}
|
||||
@@ -62,6 +106,15 @@ pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
|
||||
}
|
||||
Ok((info.width, info.height, rgba))
|
||||
}
|
||||
/// A PNG's width and height, from its header alone.
|
||||
fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
use std::io::Read;
|
||||
let mut header = [0u8; 24];
|
||||
fs::File::open(path).ok()?.read_exact(&mut header).ok()?;
|
||||
let field = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
|
||||
(header[..8] == *b"\x89PNG\r\n\x1a\n" && header[12..16] == *b"IHDR")
|
||||
.then(|| (field(16), field(20)))
|
||||
}
|
||||
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
@@ -77,6 +130,20 @@ fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
}
|
||||
/// A packed atlas image: sprite name, size and RGBA pixels.
|
||||
type PackedImage = (String, u32, u32, Vec<u8>);
|
||||
/// `image` inside a one-texel border copied from its opposite edges, the
|
||||
/// texels repeat addressing reads past each edge.
|
||||
fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
|
||||
let (bw, bh) = (w + 2, h + 2);
|
||||
let mut out = Vec::with_capacity((bw * bh * 4) as usize);
|
||||
for y in 0..bh {
|
||||
let row = (y + h - 1) % h;
|
||||
for x in 0..bw {
|
||||
let at = ((row * w + (x + w - 1) % w) * 4) as usize;
|
||||
out.extend_from_slice(&data[at..at + 4]);
|
||||
}
|
||||
}
|
||||
(name.clone(), bw, bh, out)
|
||||
}
|
||||
/// Sprites the atlas definitions (`atlases/*.json`) add beyond the texture
|
||||
/// files already packed: generated ones (`paletted_permutations` such as the
|
||||
/// armour trim sprites, `unstitch` regions) and files listed under another id.
|
||||
@@ -160,8 +227,14 @@ impl HudAtlas {
|
||||
(g.rect, g.advance)
|
||||
}
|
||||
pub fn build(root: &Path) -> Result<Self> {
|
||||
Self::build_over(root, root)
|
||||
}
|
||||
/// The atlas of a resource root that layers packs over `defaults` (the
|
||||
/// game's own resources), whose textures give the sprites' GUI sizes.
|
||||
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
|
||||
Self::pack(
|
||||
root,
|
||||
defaults,
|
||||
&[
|
||||
"gui",
|
||||
"item",
|
||||
@@ -180,7 +253,7 @@ impl HudAtlas {
|
||||
}
|
||||
/// Empty slots written at run time: map textures (game/maps.rs), the
|
||||
/// minimap, downloaded skins, world icons and the on-screen keyboard's text.
|
||||
fn slots(images: &mut Vec<PackedImage>) {
|
||||
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32)) {
|
||||
for i in 0..MAP_SLOTS {
|
||||
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
|
||||
}
|
||||
@@ -195,6 +268,14 @@ impl HudAtlas {
|
||||
for i in 0..SKIN_SLOTS {
|
||||
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
|
||||
}
|
||||
for i in 0..TRIM_SLOTS {
|
||||
images.push((
|
||||
format!("horizon/trim{i}"),
|
||||
trim.0,
|
||||
trim.1,
|
||||
vec![0; (trim.0 * trim.1 * 4) as usize],
|
||||
));
|
||||
}
|
||||
for i in 0..WORLD_ICON_SLOTS {
|
||||
images.push((
|
||||
format!("horizon/world-icon{i}"),
|
||||
@@ -216,14 +297,53 @@ impl HudAtlas {
|
||||
/// holding its textures. The pixels are dropped once the glyph widths are
|
||||
/// read, since nothing uploads them.
|
||||
pub fn fonts(root: &Path) -> Result<Self> {
|
||||
let mut atlas = Self::pack(root, &["font"], false)?;
|
||||
let mut atlas = Self::pack(root, root, &["font"], false)?;
|
||||
atlas.pixels = Vec::new();
|
||||
Ok(atlas)
|
||||
}
|
||||
/// Shelf placements of `images` (tallest first) in the narrowest atlas
|
||||
/// width from 4096 up that keeps the atlas, glyph rows included, within
|
||||
/// `max` texels a side; and the first row free for glyphs. High-resolution
|
||||
/// packs fill a 4096-wide atlas past what GPUs allow (Faithful 64x needs
|
||||
/// 32768 rows); vanilla's stitcher fails the reload when its atlases don't
|
||||
/// fit, and the packs are then turned off (`Minecraft.clearResourcePacksOnError`).
|
||||
fn layout(images: &[PackedImage], max: u32) -> Result<(u32, Vec<(u32, u32)>, u32)> {
|
||||
let mut width = 4096.min(max);
|
||||
loop {
|
||||
let mut placements = Vec::with_capacity(images.len());
|
||||
let (mut x, mut y, mut row) = (0u32, 0u32, 0u32);
|
||||
for (_, w, h, _) in images {
|
||||
if x + w + 1 > width {
|
||||
x = 0;
|
||||
y += row + 1;
|
||||
row = 0;
|
||||
}
|
||||
placements.push((x, y));
|
||||
x += w + 1;
|
||||
row = row.max(*h);
|
||||
}
|
||||
let glyph_region = y + row + 1;
|
||||
let wide_enough = images.iter().all(|i| i.1 < width);
|
||||
if wide_enough && glyph_region + GLYPH_ROWS <= max {
|
||||
return Ok((width, placements, glyph_region));
|
||||
}
|
||||
if width >= max {
|
||||
let texels: u64 = images.iter().map(|i| u64::from(i.1) * u64::from(i.2)).sum();
|
||||
return Err(format!(
|
||||
"the textures ({} images, {texels} texels) do not fit a {max}x{max} atlas",
|
||||
images.len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
width = (width * 2).min(max);
|
||||
}
|
||||
}
|
||||
/// Pack the textures under these folders of `textures/`, and for the
|
||||
/// `full` atlas the generated sprites and the run-time slots too.
|
||||
fn pack(root: &Path, folders: &[&str], full: bool) -> Result<Self> {
|
||||
fn pack(root: &Path, defaults: &Path, folders: &[&str], full: bool) -> Result<Self> {
|
||||
let textures = root.join("assets/minecraft/textures");
|
||||
let default_textures =
|
||||
(defaults != root).then(|| defaults.join("assets/minecraft/textures"));
|
||||
let mut files = Vec::new();
|
||||
for folder in folders {
|
||||
walk(&textures.join(folder), &mut files);
|
||||
@@ -232,7 +352,8 @@ impl HudAtlas {
|
||||
let s = p.to_string_lossy();
|
||||
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
|
||||
});
|
||||
let decode_one = |path: &PathBuf| -> Option<(String, u32, u32, Vec<u8>)> {
|
||||
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>);
|
||||
let decode_one = |path: &PathBuf| -> Option<Decoded> {
|
||||
let name = path
|
||||
.strip_prefix(&textures)
|
||||
.ok()?
|
||||
@@ -240,18 +361,33 @@ impl HudAtlas {
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let (w, mut h, pixels) = decode(path).ok()?;
|
||||
let is_frame_strip = (name.starts_with("block/") || name.starts_with("item/")) && h > w;
|
||||
let pixels = if is_frame_strip {
|
||||
// Animated strips keep their first frame.
|
||||
let frames = name.starts_with("block/") || name.starts_with("item/");
|
||||
let pixels = if frames && h > w {
|
||||
h = w;
|
||||
pixels[..(w * w * 4) as usize].to_vec()
|
||||
} else {
|
||||
pixels
|
||||
};
|
||||
(w <= 1024 && (h <= 512 || name.starts_with("font/"))).then_some((name, w, h, pixels))
|
||||
// The default texture's size, when a pack's differs from it.
|
||||
let gui_size = default_textures
|
||||
.as_ref()
|
||||
.and_then(|d| png_size(&d.join(format!("{name}.png"))))
|
||||
.map(|(dw, dh)| (dw, if frames { dh.min(dw) } else { dh }))
|
||||
.filter(|&size| size != (w, h));
|
||||
let flags = fs::read_to_string(path.with_extension("png.mcmeta"))
|
||||
.ok()
|
||||
.and_then(|json| TextureFlags::from_mcmeta(&json));
|
||||
let (gw, gh) = gui_size.unwrap_or((w, h));
|
||||
(gw <= 1024 && (gh <= 512 || name.starts_with("font/"))).then_some((
|
||||
(name, w, h, pixels),
|
||||
flags,
|
||||
gui_size,
|
||||
))
|
||||
};
|
||||
let threads = std::thread::available_parallelism().map_or(2, |n| n.get());
|
||||
let chunk = files.len().div_ceil(threads).max(1);
|
||||
let mut images: Vec<_> = std::thread::scope(|scope| {
|
||||
let decoded: Vec<_> = std::thread::scope(|scope| {
|
||||
let handles: Vec<_> = files
|
||||
.chunks(chunk)
|
||||
.map(|part| {
|
||||
@@ -263,6 +399,18 @@ impl HudAtlas {
|
||||
.flat_map(|h| h.join().unwrap_or_default())
|
||||
.collect()
|
||||
});
|
||||
let mut flags = HashMap::new();
|
||||
let mut gui_sizes = HashMap::new();
|
||||
let mut images: Vec<PackedImage> = Vec::with_capacity(decoded.len());
|
||||
for (image, image_flags, gui_size) in decoded {
|
||||
if let Some(f) = image_flags {
|
||||
flags.insert(image.0.clone(), f);
|
||||
}
|
||||
if let Some(size) = gui_size {
|
||||
gui_sizes.insert(image.0.clone(), size);
|
||||
}
|
||||
images.push(image);
|
||||
}
|
||||
if full {
|
||||
images.extend(atlas_sprites(root, &images));
|
||||
}
|
||||
@@ -277,24 +425,23 @@ impl HudAtlas {
|
||||
}
|
||||
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
|
||||
if full {
|
||||
Self::slots(&mut images);
|
||||
Self::slots(&mut images, crate::trims::slot_size(root));
|
||||
}
|
||||
// Blurred sprites are sampled linearly in the atlas (screen.wgsl): a
|
||||
// border wrapped from their own edges keeps the filter off their
|
||||
// neighbours, as repeat addressing of their own texture would.
|
||||
let mut bordered = std::collections::HashSet::new();
|
||||
for image in &mut images {
|
||||
if flags.get(&image.0).is_some_and(|f: &TextureFlags| f.blur) {
|
||||
*image = wrap_border(image);
|
||||
bordered.insert(image.0.clone());
|
||||
}
|
||||
}
|
||||
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
|
||||
let width = 4096u32;
|
||||
let mut placements = Vec::with_capacity(images.len());
|
||||
let (mut x, mut y, mut row) = (0u32, 0u32, 0u32);
|
||||
for (_, w, h, _) in &images {
|
||||
if x + w + 1 > width {
|
||||
x = 0;
|
||||
y += row + 1;
|
||||
row = 0;
|
||||
}
|
||||
placements.push((x, y));
|
||||
x += w + 1;
|
||||
row = row.max(*h);
|
||||
}
|
||||
let glyph_region = y + row + 1;
|
||||
let height = (glyph_region + GLYPH_ROWS).next_power_of_two();
|
||||
let (width, placements, glyph_region) = Self::layout(&images, MAX_ATLAS_SIZE)?;
|
||||
// A multiple of 1024 rather than a power of two: the default resources
|
||||
// need 4096x5120 (8192 rows before), Faithful 32x 4096x12288 (16384).
|
||||
let height = (glyph_region + GLYPH_ROWS).next_multiple_of(1024);
|
||||
let mut pixels = vec![0u8; (width * height * 4) as usize];
|
||||
let mut sprites = HashMap::new();
|
||||
for ((name, w, h, data), (px, py)) in images.iter().zip(placements) {
|
||||
@@ -303,6 +450,9 @@ impl HudAtlas {
|
||||
let dst = (((py + row) * width + px) * 4) as usize;
|
||||
pixels[dst..dst + (w * 4) as usize].copy_from_slice(src);
|
||||
}
|
||||
let b = u32::from(bordered.contains(name));
|
||||
let (px, py, w, h) = (px + b, py + b, w - 2 * b, h - 2 * b);
|
||||
let (gw, gh) = gui_sizes.get(name).copied().unwrap_or((w, h));
|
||||
sprites.insert(
|
||||
name.clone(),
|
||||
Rect {
|
||||
@@ -310,8 +460,8 @@ impl HudAtlas {
|
||||
v0: py as f32 / height as f32,
|
||||
u1: (px + w) as f32 / width as f32,
|
||||
v1: (py + h) as f32 / height as f32,
|
||||
w: *w,
|
||||
h: *h,
|
||||
w: gw,
|
||||
h: gh,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -331,14 +481,15 @@ impl HudAtlas {
|
||||
.find(|a| a.layer == layer)
|
||||
.cloned()
|
||||
{
|
||||
let image = animation.resized_pixels(animation.frame(0), rect.w, rect.h);
|
||||
let (tw, th) = rect.texels((width, height));
|
||||
let image = animation.resized_pixels(animation.frame(0), tw, th);
|
||||
let x = (rect.u0 * width as f32).round() as usize;
|
||||
let y = (rect.v0 * height as f32).round() as usize;
|
||||
for row in 0..rect.h as usize {
|
||||
let row_bytes = tw as usize * 4;
|
||||
for row in 0..th as usize {
|
||||
let dst = ((y + row) * width as usize + x) * 4;
|
||||
let src = row * rect.w as usize * 4;
|
||||
pixels[dst..dst + rect.w as usize * 4]
|
||||
.copy_from_slice(&image[src..src + rect.w as usize * 4]);
|
||||
let src = row * row_bytes;
|
||||
pixels[dst..dst + row_bytes].copy_from_slice(&image[src..src + row_bytes]);
|
||||
}
|
||||
fire_animations.push((rect, animation));
|
||||
}
|
||||
@@ -358,6 +509,7 @@ impl HudAtlas {
|
||||
width,
|
||||
height,
|
||||
sprites,
|
||||
flags,
|
||||
fire_animations,
|
||||
font,
|
||||
white,
|
||||
@@ -384,6 +536,8 @@ pub struct HudBatch {
|
||||
pub patches: Vec<crate::font::Patch>,
|
||||
/// Vertex ranges with another blend or depth/scissor state, in order.
|
||||
pub blends: Vec<(std::ops::Range<u32>, Blend)>,
|
||||
/// The textures and time of the `Blend::EndPortal` runs.
|
||||
pub end_portal: EndPortalFill,
|
||||
/// Screen's first vertex: the preceding HUD is part of its blurred background.
|
||||
pub blur_at: u32,
|
||||
}
|
||||
@@ -398,6 +552,19 @@ pub enum Blend {
|
||||
Additive,
|
||||
/// Alpha blending with an isolated depth buffer and a pixel scissor.
|
||||
Depth([u32; 4]),
|
||||
/// `RenderPipelines.VIGNETTE`: the destination darkened by the texture
|
||||
/// (`ZERO, ONE_MINUS_SRC_COLOR`).
|
||||
Vignette,
|
||||
/// `RenderPipelines.END_PORTAL` as a GUI fill: the End portal's layers
|
||||
/// projected from the screen (`HudBatch::end_portal` sets the textures).
|
||||
EndPortal,
|
||||
}
|
||||
/// What a `Blend::EndPortal` fill samples: the atlas rects (u0, v0, width,
|
||||
/// height) of `end_sky.png` and `end_portal.png`, and the `GameTime` uniform.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct EndPortalFill {
|
||||
pub sprites: [[f32; 4]; 2],
|
||||
pub game_time: f32,
|
||||
}
|
||||
pub fn rgba(r: u8, g: u8, b: u8, a: u8) -> u32 {
|
||||
u32::from(a) << 24 | u32::from(b) << 16 | u32::from(g) << 8 | u32::from(r)
|
||||
@@ -421,6 +588,7 @@ impl HudBatch {
|
||||
height: (window[1] as f32 / scale as f32).ceil(),
|
||||
patches: Vec::new(),
|
||||
blends: Vec::new(),
|
||||
end_portal: EndPortalFill::default(),
|
||||
blur_at: 0,
|
||||
}
|
||||
}
|
||||
@@ -727,7 +895,13 @@ impl HudBatch {
|
||||
let mut glyphs = Vec::new();
|
||||
let mut cursor = x;
|
||||
for (i, (ch, style)) in text.chars().enumerate() {
|
||||
let g = font.glyph(ch);
|
||||
// The style's font (`Style.getFont`), the default one unless set.
|
||||
let mut g = font.glyph_styled(ch, style);
|
||||
// Font.StringRenderOutput.accept: obfuscated characters draw a
|
||||
// random glyph of the same width each frame.
|
||||
if style.obfuscated == Some(true) && ch != ' ' {
|
||||
g = font.random_glyph(style, g.advance);
|
||||
}
|
||||
let c = style.color.map_or(color, |rgb| {
|
||||
rgba((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, alpha)
|
||||
});
|
||||
@@ -821,6 +995,20 @@ impl HudBatch {
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct HudUniform {
|
||||
size: [f32; 4],
|
||||
/// `EndPortalFill`: the two sprites, then the game time in x.
|
||||
end_portal: [[f32; 4]; 3],
|
||||
}
|
||||
impl HudUniform {
|
||||
fn new(size: [f32; 4], fill: EndPortalFill) -> Self {
|
||||
Self {
|
||||
size,
|
||||
end_portal: [
|
||||
fill.sprites[0],
|
||||
fill.sprites[1],
|
||||
[fill.game_time, 0.0, 0.0, 0.0],
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
pub struct HudRenderer {
|
||||
pub fire_animations: Vec<(Rect, crate::sprites::SpriteAnimation, usize)>,
|
||||
@@ -831,6 +1019,8 @@ pub struct HudRenderer {
|
||||
invert_pipeline: wgpu::RenderPipeline,
|
||||
additive_pipeline: wgpu::RenderPipeline,
|
||||
depth_pipeline: wgpu::RenderPipeline,
|
||||
vignette_pipeline: wgpu::RenderPipeline,
|
||||
end_portal_pipeline: wgpu::RenderPipeline,
|
||||
group: wgpu::BindGroup,
|
||||
uniform: wgpu::Buffer,
|
||||
buffer: wgpu::Buffer,
|
||||
@@ -853,7 +1043,7 @@ impl HudRenderer {
|
||||
});
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("HUD size"),
|
||||
contents: bytemuck::bytes_of(&HudUniform { size: [1.0; 4] }),
|
||||
contents: bytemuck::bytes_of(&HudUniform::new([1.0; 4], EndPortalFill::default())),
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -861,7 +1051,7 @@ impl HudRenderer {
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
@@ -989,6 +1179,31 @@ impl HudRenderer {
|
||||
"fs_main",
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.VIGNETTE: ZERO, ONE_MINUS_SRC_COLOR, ZERO, ONE.
|
||||
let vignette_pipeline = create(
|
||||
"HUD vignette",
|
||||
wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::OneMinusSrc,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
},
|
||||
"fs_main",
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.END_PORTAL: opaque.
|
||||
let end_portal_pipeline = create(
|
||||
"HUD end portal",
|
||||
wgpu::BlendState::REPLACE,
|
||||
"fs_end_portal",
|
||||
false,
|
||||
);
|
||||
let capacity = 1 << 16;
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("HUD vertices"),
|
||||
@@ -1003,6 +1218,8 @@ impl HudRenderer {
|
||||
invert_pipeline,
|
||||
additive_pipeline,
|
||||
depth_pipeline,
|
||||
vignette_pipeline,
|
||||
end_portal_pipeline,
|
||||
group,
|
||||
uniform,
|
||||
buffer,
|
||||
@@ -1023,7 +1240,8 @@ impl HudRenderer {
|
||||
if frame == *previous {
|
||||
continue;
|
||||
}
|
||||
let pixels = animation.resized_pixels(frame, rect.w, rect.h);
|
||||
let (w, h) = rect.texels((texture.width(), texture.height()));
|
||||
let pixels = animation.resized_pixels(frame, w, h);
|
||||
uploads.texture(
|
||||
encoder,
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
@@ -1036,8 +1254,8 @@ impl HudRenderer {
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
rect.w,
|
||||
rect.h,
|
||||
w,
|
||||
h,
|
||||
&pixels,
|
||||
);
|
||||
*previous = frame;
|
||||
@@ -1096,9 +1314,10 @@ impl HudRenderer {
|
||||
encoder,
|
||||
&self.uniform,
|
||||
0,
|
||||
bytemuck::bytes_of(&HudUniform {
|
||||
size: [size[0] as f32, size[1] as f32, batch.scale, 0.0],
|
||||
}),
|
||||
bytemuck::bytes_of(&HudUniform::new(
|
||||
[size[0] as f32, size[1] as f32, batch.scale, 0.0],
|
||||
batch.end_portal,
|
||||
)),
|
||||
);
|
||||
}
|
||||
/// Draw the batch `prepare` staged.
|
||||
@@ -1186,6 +1405,8 @@ impl HudRenderer {
|
||||
Blend::Invert => &self.invert_pipeline,
|
||||
Blend::Additive => &self.additive_pipeline,
|
||||
Blend::Depth(_) => &self.depth_pipeline,
|
||||
Blend::Vignette => &self.vignette_pipeline,
|
||||
Blend::EndPortal => &self.end_portal_pipeline,
|
||||
});
|
||||
pass.draw(range.clone(), 0..1);
|
||||
}
|
||||
@@ -1198,10 +1419,115 @@ impl HudRenderer {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `count` images of `w x h`; layout reads only their sizes.
|
||||
fn sized(count: usize, w: u32, h: u32) -> Vec<PackedImage> {
|
||||
(0..count)
|
||||
.map(|i| (format!("i{i}"), w, h, Vec::new()))
|
||||
.collect()
|
||||
}
|
||||
fn assert_inside(images: &[PackedImage], width: u32, placements: &[(u32, u32)], free: u32) {
|
||||
for ((_, w, h, _), (x, y)) in images.iter().zip(placements) {
|
||||
assert!(
|
||||
x + w <= width && y + h < free,
|
||||
"{x},{y} {w}x{h} outside {width}x{free}"
|
||||
);
|
||||
}
|
||||
}
|
||||
fn write_png(root: &Path, name: &str, w: u32, h: u32) {
|
||||
let path = root.join(format!("assets/minecraft/textures/{name}.png"));
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), w, h);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let pixels = vec![255; (w * h * 4) as usize];
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn the_fonts_only_atlas_measures_text_like_the_full_one() {
|
||||
// A synthetic default font: two 8x8 glyphs, 3 and 5 pixels wide.
|
||||
let root = std::env::temp_dir().join(format!("horizon-fonts-atlas-{}", std::process::id()));
|
||||
fn a_pack_texture_keeps_the_default_textures_gui_size() {
|
||||
let temp = std::env::temp_dir().join(format!("horizon-gui-size-{}", std::process::id()));
|
||||
let (defaults, pack) = (temp.join("defaults"), temp.join("pack"));
|
||||
write_png(&defaults, "gui/container/inventory", 256, 256);
|
||||
write_png(&defaults, "gui/sprites/widget/button", 200, 20);
|
||||
write_png(&defaults, "block/stone", 16, 16);
|
||||
write_png(&defaults, "item/compass", 16, 512);
|
||||
// Faithful 64x's inventory (past the 512 rows a texture may have),
|
||||
// 32x's button and stone, an animated strip, and a texture of its own.
|
||||
write_png(&pack, "gui/container/inventory", 1024, 1024);
|
||||
write_png(&pack, "gui/sprites/widget/button", 400, 40);
|
||||
write_png(&pack, "block/stone", 32, 32);
|
||||
write_png(&pack, "item/compass", 32, 1024);
|
||||
write_png(&pack, "gui/sprites/pack_only", 30, 10);
|
||||
let atlas = HudAtlas::build_over(&pack, &defaults).unwrap();
|
||||
let _ = fs::remove_dir_all(&temp);
|
||||
let size = (atlas.width, atlas.height);
|
||||
for (name, gui, texels) in [
|
||||
("gui/container/inventory", (256, 256), (1024, 1024)),
|
||||
("gui/sprites/widget/button", (200, 20), (400, 40)),
|
||||
("block/stone", (16, 16), (32, 32)),
|
||||
("item/compass", (16, 16), (32, 32)),
|
||||
("gui/sprites/pack_only", (30, 10), (30, 10)),
|
||||
] {
|
||||
let r = atlas.sprites[name];
|
||||
assert_eq!(((r.w, r.h), r.texels(size)), (gui, texels), "{name}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn the_default_textures_keep_a_4096_wide_atlas() {
|
||||
let images = sized(400, 64, 64);
|
||||
let (width, placements, free) = HudAtlas::layout(&images, MAX_ATLAS_SIZE).unwrap();
|
||||
assert_eq!(width, 4096);
|
||||
assert_inside(&images, width, &placements, free);
|
||||
}
|
||||
#[test]
|
||||
fn a_high_resolution_pack_widens_the_atlas_to_fit_the_gpu() {
|
||||
// 34 rows of three 1024x512 GUI textures: 17442 rows at 4096 wide,
|
||||
// past the 16384 GPUs allow (Faithful 64x needed 32768).
|
||||
let images = sized(100, 1024, 512);
|
||||
let (width, placements, free) = HudAtlas::layout(&images, MAX_ATLAS_SIZE).unwrap();
|
||||
assert_eq!(width, 8192);
|
||||
assert!(free + GLYPH_ROWS <= MAX_ATLAS_SIZE);
|
||||
assert_inside(&images, width, &placements, free);
|
||||
}
|
||||
#[test]
|
||||
fn textures_that_cannot_fit_are_an_error_not_a_gpu_panic() {
|
||||
let error = HudAtlas::layout(&sized(300, 1024, 1024), MAX_ATLAS_SIZE).unwrap_err();
|
||||
assert!(error.to_string().contains("do not fit a 16384x16384 atlas"));
|
||||
// One image wider than the largest atlas.
|
||||
assert!(HudAtlas::layout(&sized(1, 2048, 16), 2048).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrapped_border_repeats_the_opposite_edges() {
|
||||
// 2x2 texels a b / c d, one byte each in red.
|
||||
let image = (
|
||||
"x".to_owned(),
|
||||
2,
|
||||
2,
|
||||
[1u8, 2, 3, 4]
|
||||
.iter()
|
||||
.flat_map(|&v| [v, 0, 0, 255])
|
||||
.collect(),
|
||||
);
|
||||
let (_, w, h, data) = wrap_border(&image);
|
||||
assert_eq!((w, h), (4, 4));
|
||||
let red: Vec<u8> = data.chunks(4).map(|t| t[0]).collect();
|
||||
#[rustfmt::skip]
|
||||
assert_eq!(red, [
|
||||
4, 3, 4, 3,
|
||||
2, 1, 2, 1,
|
||||
4, 3, 4, 3,
|
||||
2, 1, 2, 1,
|
||||
]);
|
||||
}
|
||||
|
||||
/// A synthetic default font: two 8x8 glyphs, 3 and 5 pixels wide.
|
||||
fn two_glyph_font(name: &str) -> std::path::PathBuf {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-fonts-{name}-{}", std::process::id()));
|
||||
let font = root.join("assets/minecraft/font");
|
||||
let textures = root.join("assets/minecraft/textures/font");
|
||||
fs::create_dir_all(&font).unwrap();
|
||||
@@ -1226,6 +1552,11 @@ mod tests {
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
root
|
||||
}
|
||||
#[test]
|
||||
fn the_fonts_only_atlas_measures_text_like_the_full_one() {
|
||||
let root = two_glyph_font("atlas");
|
||||
let full = HudAtlas::build(&root).unwrap();
|
||||
let fonts = HudAtlas::fonts(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
@@ -1234,6 +1565,21 @@ mod tests {
|
||||
assert!(width > 0.0);
|
||||
assert_eq!(fonts.font.width("abba"), width);
|
||||
}
|
||||
#[test]
|
||||
fn obfuscated_glyphs_keep_the_width() {
|
||||
let root = two_glyph_font("obfuscated");
|
||||
let fonts = HudAtlas::fonts(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let (a, b) = (fonts.font.glyph('a').advance, fonts.font.glyph('b').advance);
|
||||
assert_ne!(a, b);
|
||||
for _ in 0..20 {
|
||||
assert_eq!(fonts.font.random_glyph(&Default::default(), a).advance, a);
|
||||
assert_eq!(fonts.font.random_glyph(&Default::default(), b).advance, b);
|
||||
}
|
||||
// No glyph that wide: FontSet's missing glyph.
|
||||
let missing = fonts.font.random_glyph(&Default::default(), 30.0);
|
||||
assert!(missing.rect.is_none() && missing.right == 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_icons_are_painted_from_the_mob_effect_directory() {
|
||||
@@ -1418,6 +1764,7 @@ mod tests {
|
||||
height: height as f32,
|
||||
patches: Vec::new(),
|
||||
blends: Vec::new(),
|
||||
end_portal: EndPortalFill::default(),
|
||||
blur_at: 0,
|
||||
};
|
||||
for (image_width, image_height) in [(195, 136), (176, 166), (176, 222)] {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
struct Hud { size: vec4<f32> }
|
||||
struct Hud {
|
||||
size: vec4<f32>,
|
||||
// Blend::EndPortal: atlas rects (u0, v0, width, height) of end_sky.png and
|
||||
// end_portal.png, then vanilla's GameTime in x.
|
||||
end_portal: array<vec4<f32>, 3>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> hud: Hud;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@group(0) @binding(2) var atlas_sampler: sampler;
|
||||
@@ -6,6 +11,8 @@ struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
// projection_from_position(gl_Position): the End portal's texProj0.
|
||||
@location(2) proj: vec4<f32>,
|
||||
}
|
||||
@vertex fn vs_main(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>, @location(2) color: vec4<f32>, @location(3) depth: f32) -> Out {
|
||||
let pixels = pos * hud.size.z;
|
||||
@@ -13,6 +20,7 @@ struct Out {
|
||||
out.clip = vec4(pixels.x / hud.size.x * 2.0 - 1.0, 1.0 - pixels.y / hud.size.y * 2.0, depth, 1.0);
|
||||
out.uv = uv;
|
||||
out.color = color;
|
||||
out.proj = vec4(out.clip.xy * 0.5 + 0.5, out.clip.zw);
|
||||
return out;
|
||||
}
|
||||
@fragment fn fs_main(in: Out) -> @location(0) vec4<f32> {
|
||||
@@ -31,3 +39,44 @@ struct Out {
|
||||
if c.a == 0.0 { discard; }
|
||||
return c;
|
||||
}
|
||||
// rendertype_end_portal.fsh (entity.wgsl has the same for portal blocks).
|
||||
const PORTAL_COLORS = array<vec3<f32>, 16>(
|
||||
vec3(0.022087, 0.098399, 0.110818),
|
||||
vec3(0.011892, 0.095924, 0.089485),
|
||||
vec3(0.027636, 0.101689, 0.100326),
|
||||
vec3(0.046564, 0.109883, 0.114838),
|
||||
vec3(0.064901, 0.117696, 0.097189),
|
||||
vec3(0.063761, 0.086895, 0.123646),
|
||||
vec3(0.084817, 0.111994, 0.166380),
|
||||
vec3(0.097489, 0.154120, 0.091064),
|
||||
vec3(0.106152, 0.131144, 0.195191),
|
||||
vec3(0.097721, 0.110188, 0.187229),
|
||||
vec3(0.133516, 0.138278, 0.148582),
|
||||
vec3(0.070006, 0.243332, 0.235792),
|
||||
vec3(0.196766, 0.142899, 0.214696),
|
||||
vec3(0.047281, 0.315338, 0.321970),
|
||||
vec3(0.204675, 0.390010, 0.302066),
|
||||
vec3(0.080955, 0.314821, 0.661491),
|
||||
);
|
||||
fn portal_sample(rect: vec4<f32>, p: vec4<f32>) -> vec3<f32> {
|
||||
let st = fract(p.xy / p.w);
|
||||
return textureSampleLevel(atlas, atlas_sampler, rect.xy + st * rect.zw, 0.0).rgb;
|
||||
}
|
||||
fn portal_layer(p: vec4<f32>, layer: f32) -> vec4<f32> {
|
||||
let a = radians((layer * layer * 4321.0 + layer * 9.0) * 2.0);
|
||||
let c = cos(a);
|
||||
let s = sin(a);
|
||||
let k = (4.5 - layer / 4.0) * 2.0;
|
||||
var v = vec4(k * (c * p.x - s * p.y), k * (s * p.x + c * p.y), p.z, p.w);
|
||||
v = vec4(v.x + 17.0 / layer * v.w, v.y + (2.0 + layer / 1.5) * (hud.end_portal[2].x * 1.5) * v.w, v.z, v.w);
|
||||
return vec4(0.5 * v.x + 0.25 * v.w, 0.5 * v.y + 0.25 * v.w, v.z, v.w);
|
||||
}
|
||||
// RenderPipelines.END_PORTAL (WinScreen's poem background): end_sky and 15
|
||||
// layers of end_portal.png.
|
||||
@fragment fn fs_end_portal(in: Out) -> @location(0) vec4<f32> {
|
||||
var c = portal_sample(hud.end_portal[0], in.proj) * PORTAL_COLORS[0];
|
||||
for (var i = 0; i < 15; i++) {
|
||||
c += portal_sample(hud.end_portal[1], portal_layer(in.proj, f32(i + 1))) * PORTAL_COLORS[i];
|
||||
}
|
||||
return vec4(c, 1.0);
|
||||
}
|
||||
@@ -23,17 +23,36 @@ pub struct JavaFolder {
|
||||
/// `begin*`, `prepare*`, `deferred*`, `composite*`, in order.
|
||||
pub passes: Vec<Program>,
|
||||
/// How many of `passes` run before the world (`begin*`, `prepare*`): the
|
||||
/// world's colour and depth enter `colortex0` and `depthtex*` after them.
|
||||
/// world's colour and depth enter `world_target` and `depthtex*` after them.
|
||||
pub world_at: usize,
|
||||
/// The colour target the world goes into, where the pack's terrain
|
||||
/// program draws (MakeUp: colortex1).
|
||||
pub world_target: u32,
|
||||
/// The targets whose alternate side the world is written to.
|
||||
pub flipped_after_prepare: BTreeSet<u32>,
|
||||
pub final_pass: Option<Program>,
|
||||
/// The targets whose alternate side holds the frame's last write.
|
||||
pub flipped_at_end: BTreeSet<u32>,
|
||||
/// `sunPathRotation` and `eyeBrightnessHalflife`.
|
||||
/// `sunPathRotation`, `eyeBrightnessHalflife`, `wetnessHalflife` and
|
||||
/// `drynessHalflife`.
|
||||
pub directives: Directives,
|
||||
}
|
||||
|
||||
/// The key of `texture.noise` (`noisetex`) in [`JavaPasses::custom_textures`].
|
||||
pub const NOISE_KEY: &str = "noise";
|
||||
|
||||
/// The key of `texture.<stage>.<sampler>` in [`JavaPasses::custom_textures`]
|
||||
/// for a pass of `program` (`world0/composite2`): Iris's texture stages, where
|
||||
/// `final` reads the `composite` ones.
|
||||
pub fn texture_key(program: &str, sampler: &str) -> String {
|
||||
let name = program.rsplit('/').next().unwrap_or(program);
|
||||
let stage = match name.trim_end_matches(|c: char| c.is_ascii_digit()) {
|
||||
"final" => "composite",
|
||||
stage => stage,
|
||||
};
|
||||
format!("{stage}.{sampler}")
|
||||
}
|
||||
|
||||
/// A loaded Java pack.
|
||||
pub struct JavaPasses {
|
||||
pub folders: Vec<JavaFolder>,
|
||||
@@ -41,6 +60,9 @@ pub struct JavaPasses {
|
||||
pub uniforms: CustomUniforms,
|
||||
/// What the pack keeps of the vanilla sky (`clouds`, `sun`, `moon`).
|
||||
pub sky: SkyDirectives,
|
||||
/// `texture.<stage>.<sampler>` and `texture.noise` images, by
|
||||
/// `"<stage>.<sampler>"` and [`NOISE_KEY`].
|
||||
pub custom_textures: BTreeMap<String, crate::shader_pack::Rgba>,
|
||||
}
|
||||
|
||||
impl JavaPasses {
|
||||
@@ -126,6 +148,7 @@ pub fn load(
|
||||
folder: name.to_owned(),
|
||||
targets: plan.targets,
|
||||
world_at: early.count(),
|
||||
world_target: plan.world_target,
|
||||
passes,
|
||||
flipped_after_prepare: plan.flipped_after_prepare,
|
||||
final_pass,
|
||||
@@ -136,6 +159,27 @@ pub fn load(
|
||||
if folders.is_empty() {
|
||||
return Err("no deferred, composite or final programs".into());
|
||||
}
|
||||
let mut custom_textures = BTreeMap::new();
|
||||
for tex in properties.custom_textures() {
|
||||
let key = match tex.stage {
|
||||
Some(stage) => format!("{stage}.{}", tex.sampler),
|
||||
None if tex.sampler == NOISE_KEY => NOISE_KEY.to_owned(),
|
||||
None => continue,
|
||||
};
|
||||
// Only PNGs for now: resource locations and raw `.dat` textures are
|
||||
// skipped and the sampler reads what it would without them.
|
||||
let image = pack
|
||||
.files
|
||||
.read_bytes(&tex.path)
|
||||
.ok_or_else(|| "not in the pack".to_owned())
|
||||
.and_then(|bytes| crate::shader_pack::decode_png(&bytes));
|
||||
match image {
|
||||
Ok(image) => {
|
||||
custom_textures.insert(key, image);
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: shader pack texture {}: {e}", tex.path),
|
||||
}
|
||||
}
|
||||
let uniforms = properties.custom_uniforms();
|
||||
for problem in &uniforms.problems {
|
||||
log::warn!("Horizon: shader pack custom uniform {problem}");
|
||||
@@ -144,6 +188,7 @@ pub fn load(
|
||||
folders,
|
||||
uniforms,
|
||||
sky: properties.sky(),
|
||||
custom_textures,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -151,6 +196,17 @@ pub fn load(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn custom_textures_belong_to_iris_s_stages() {
|
||||
assert_eq!(texture_key("world0/composite2", "gaux1"), "composite.gaux1");
|
||||
assert_eq!(texture_key("final", "colortex4"), "composite.colortex4");
|
||||
assert_eq!(
|
||||
texture_key("world-1/deferred10", "noisetex"),
|
||||
"deferred.noisetex"
|
||||
);
|
||||
assert_eq!(texture_key("prepare", "depthtex2"), "prepare.depthtex2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_the_full_screen_passes_of_a_java_pack() {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -12,8 +12,8 @@ use horizon_protocol::{
|
||||
item::Stack,
|
||||
packets::play::{
|
||||
self, AddEntity, AttributeSnapshot, EntityPositionSync, MoveEntity, MoveEntityPos,
|
||||
MoveEntityPosRot, MoveEntityRot, PositionPath, RotateHead, SetEntityData, SetEntityMotion,
|
||||
SetEquipment, SetPassengers, UpdateAttributes, VecDelta,
|
||||
MoveEntityPosRot, MoveEntityRot, PositionPath, RotateHead, SetEntityData, SetEntityLink,
|
||||
SetEntityMotion, SetEquipment, SetPassengers, UpdateAttributes, VecDelta,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -50,6 +50,10 @@ pub struct EntityState {
|
||||
pub data: i32,
|
||||
/// SynchedEntityData entries that differ from the defaults.
|
||||
pub meta: Vec<(u8, i32, Value)>,
|
||||
/// The defaults of entries `meta` leaves out while they are at them,
|
||||
/// sent when one goes back to its default (a calf grown up, a mob healed
|
||||
/// back to 1, wool grown back white) beyond Entity's own below.
|
||||
pub defaults: Vec<(u8, i32, Value)>,
|
||||
/// EquipmentSlot ordinals (0 main hand, 1 off hand, 2 feet, 3 legs,
|
||||
/// 4 chest, 5 head, 6 body, 7 saddle) and their stacks.
|
||||
pub equipment: Vec<(u8, Option<Stack>)>,
|
||||
@@ -59,6 +63,10 @@ pub struct EntityState {
|
||||
/// `Entity.getRequiresPrecisePosition` (primed TNT and falling blocks
|
||||
/// touching a block): moves are sent as full position syncs.
|
||||
pub precise: bool,
|
||||
/// `Entity.needsSync`: send its changes this tick, due or not.
|
||||
pub needs_sync: bool,
|
||||
/// `Leashable.getLeashHolder`: the entity holding its lead.
|
||||
pub leash: Option<i32>,
|
||||
}
|
||||
|
||||
impl EntityState {
|
||||
@@ -76,9 +84,12 @@ impl EntityState {
|
||||
self.velocity = [0.0; 3];
|
||||
self.data = 0;
|
||||
self.meta.clear();
|
||||
self.defaults.clear();
|
||||
self.equipment.clear();
|
||||
self.attributes.clear();
|
||||
self.precise = false;
|
||||
self.needs_sync = false;
|
||||
self.leash = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +222,8 @@ struct ServerEntity {
|
||||
/// and the first update after it dismounts is a full position sync.
|
||||
passengers: Vec<i32>,
|
||||
was_riding: bool,
|
||||
/// The lead's holder the clients were told of.
|
||||
leash: Option<i32>,
|
||||
/// The level tick it was last seen in, its place in that tick's list, and
|
||||
/// whether it was added in it (its first update is the next tick's).
|
||||
seen: u32,
|
||||
@@ -254,6 +267,7 @@ impl ServerEntity {
|
||||
teleport_delay: 0,
|
||||
passengers: riding.passengers(e.id).to_vec(),
|
||||
was_riding: false,
|
||||
leash: e.leash,
|
||||
seen: 0,
|
||||
index: 0,
|
||||
added: true,
|
||||
@@ -306,6 +320,13 @@ impl ServerEntity {
|
||||
if let Some(&vehicle) = riding.vehicle.get(&e.id) {
|
||||
out.push(riding.set_passengers(vehicle));
|
||||
}
|
||||
// A leashed entity: who holds its lead.
|
||||
if let Some(dest) = e.leash {
|
||||
out.push(play::Clientbound::SetEntityLink(SetEntityLink {
|
||||
source: e.id,
|
||||
dest,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes since the last update (ServerEntity.sendChanges): movement and
|
||||
@@ -319,10 +340,18 @@ impl ServerEntity {
|
||||
self.passengers.clear();
|
||||
self.passengers.extend_from_slice(passengers);
|
||||
}
|
||||
// Leashable.setLeashedTo / dropLeash: sendToTrackingPlayers at once.
|
||||
if e.leash != self.leash {
|
||||
out.push(play::Clientbound::SetEntityLink(SetEntityLink {
|
||||
source: e.id,
|
||||
dest: e.leash.unwrap_or(0),
|
||||
}));
|
||||
self.leash = e.leash;
|
||||
}
|
||||
// SynchedEntityData.isDirty: once for every client.
|
||||
let dirty = e.meta != self.meta;
|
||||
let due = self.interval != 0 && self.tick.is_multiple_of(self.interval);
|
||||
if due || dirty {
|
||||
if due || dirty || e.needs_sync {
|
||||
let rotation = (pack_degrees(e.pitch), pack_degrees(e.yaw));
|
||||
let turned = |a: i8, b: i8| (i32::from(a) - i32::from(b)).abs() >= 1;
|
||||
let rotate = self.arrow
|
||||
@@ -398,7 +427,7 @@ impl ServerEntity {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if self.track_deltas {
|
||||
if self.track_deltas || e.needs_sync {
|
||||
let diff: f64 = (0..3)
|
||||
.map(|k| (e.velocity[k] - self.velocity[k]).powi(2))
|
||||
.sum();
|
||||
@@ -466,7 +495,10 @@ impl ServerEntity {
|
||||
(0, serializer::BYTE) => Value::Byte(0),
|
||||
(6, serializer::POSE) => Value::Id(0),
|
||||
(14, serializer::OPTIONAL_BLOCK_POS) => Value::OptPos(None),
|
||||
_ => continue,
|
||||
_ => match e.defaults.iter().find(|d| (d.0, d.1) == (*index, *kind)) {
|
||||
Some(d) => d.2.clone(),
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
if !e.meta.iter().any(|m| m.0 == *index) {
|
||||
changed.push((*index, *kind, default));
|
||||
@@ -852,9 +884,12 @@ mod tests {
|
||||
velocity: [0.0; 3],
|
||||
data: 0,
|
||||
meta: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
equipment: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
precise: false,
|
||||
needs_sync: false,
|
||||
leash: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -918,6 +953,37 @@ mod tests {
|
||||
assert!(matches!(jump[0], play::Clientbound::EntityPositionSync(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_knocked_back_mob_is_sent_at_once() {
|
||||
let mut s = added(&state(0.0));
|
||||
changes(&mut s, &state(0.0));
|
||||
// Not due (tick 1 of 3), but LivingEntity.knockback set needsSync:
|
||||
// its move and its new motion go out now.
|
||||
let mut knocked = state(0.3);
|
||||
knocked.velocity = [0.2, 0.3, 0.0];
|
||||
knocked.needs_sync = true;
|
||||
let out = changes(&mut s, &knocked);
|
||||
assert!(
|
||||
matches!(
|
||||
out[..],
|
||||
[
|
||||
play::Clientbound::SetEntityMotion(_),
|
||||
play::Clientbound::MoveEntityPos(_)
|
||||
]
|
||||
),
|
||||
"{out:?}"
|
||||
);
|
||||
// Then every third tick again.
|
||||
assert!(changes(&mut s, &state(0.5)).is_empty());
|
||||
assert!(matches!(
|
||||
changes(&mut s, &state(0.6))[..],
|
||||
[
|
||||
play::Clientbound::SetEntityMotion(_),
|
||||
play::Clientbound::MoveEntityPos(_)
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fireballs_bundle_their_motion_with_their_power() {
|
||||
let mut e = state(0.0);
|
||||
@@ -1040,6 +1106,29 @@ mod tests {
|
||||
assert!(changes(&mut s, &e).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_back_at_a_listed_default_is_sent() {
|
||||
let mut e = state(0.0);
|
||||
e.defaults = vec![(16, serializer::BOOLEAN, Value::Bool(false))];
|
||||
// A calf: paired with its baby flag, then grown up.
|
||||
e.meta = vec![(16, serializer::BOOLEAN, Value::Bool(true))];
|
||||
let mut s = added(&e);
|
||||
changes(&mut s, &e);
|
||||
e.meta.clear();
|
||||
let data = changes(&mut s, &e)
|
||||
.into_iter()
|
||||
.find_map(|p| match p {
|
||||
play::Clientbound::SetEntityData(d) => Some(d),
|
||||
_ => None,
|
||||
})
|
||||
.expect("growing up is sent");
|
||||
assert_eq!(
|
||||
data.values,
|
||||
vec![(16, serializer::BOOLEAN, Value::Bool(false))]
|
||||
);
|
||||
assert!(changes(&mut s, &e).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_and_equipment_send_only_the_changes() {
|
||||
let mut e = state(0.0);
|
||||
@@ -1065,6 +1154,24 @@ mod tests {
|
||||
assert_eq!(eq.slots, vec![(0, None)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lead_tied_and_untied_reaches_the_clients() {
|
||||
let link = |out: &[play::Clientbound]| {
|
||||
out.iter().find_map(|p| match p {
|
||||
play::Clientbound::SetEntityLink(l) => Some((l.source, l.dest)),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let mut e = state(0.0);
|
||||
let mut s = added(&e);
|
||||
changes(&mut s, &e);
|
||||
e.leash = Some(7);
|
||||
assert_eq!(link(&changes(&mut s, &e)), Some((e.id, 7)));
|
||||
assert_eq!(link(&changes(&mut s, &e)), None);
|
||||
e.leash = None;
|
||||
assert_eq!(link(&changes(&mut s, &e)), Some((e.id, 0)));
|
||||
}
|
||||
|
||||
/// Two clients at the origin; the chunks they hold.
|
||||
struct Clients {
|
||||
chunks: crate::fast_hash::FxHashSet<(i32, i32)>,
|
||||
|
||||
@@ -18,6 +18,31 @@ pub const CREATIVE: i32 = 1;
|
||||
pub const ADVENTURE: i32 = 2;
|
||||
pub const SPECTATOR: i32 = 3;
|
||||
|
||||
/// The body state a player's file keeps that lives on `PlayerMotion`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SavedBody {
|
||||
pub fire_ticks: i32,
|
||||
pub air: i32,
|
||||
pub fall_distance: f64,
|
||||
}
|
||||
|
||||
impl SavedBody {
|
||||
/// The body state of `motion`, to save.
|
||||
pub fn of(motion: &PlayerMotion) -> Self {
|
||||
Self {
|
||||
fire_ticks: motion.fire_ticks,
|
||||
air: motion.air,
|
||||
fall_distance: motion.fall_distance,
|
||||
}
|
||||
}
|
||||
/// Put it on a new body.
|
||||
pub fn apply(self, motion: &mut PlayerMotion) {
|
||||
motion.fire_ticks = self.fire_ticks;
|
||||
motion.air = self.air;
|
||||
motion.fall_distance = self.fall_distance;
|
||||
}
|
||||
}
|
||||
|
||||
/// An effect as last sent in ClientboundUpdateMobEffectPacket.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SentEffect {
|
||||
@@ -93,6 +118,10 @@ pub struct Guest {
|
||||
/// Where the player respawns (a bed's spot; `ServerPlayer.respawnConfig`),
|
||||
/// None for the world spawn.
|
||||
pub spawn: Option<[f64; 3]>,
|
||||
/// A respawn point outside the Overworld (a respawn anchor, or one
|
||||
/// `spawnpoint` set there): its dimension, block, yaw and whether it is
|
||||
/// forced, as the player's save keeps it (`respawn`).
|
||||
pub respawn_elsewhere: Option<(String, [i32; 3], f32, bool)>,
|
||||
/// Pending ClientboundPlayerPositionPacket id; movement is ignored until
|
||||
/// the client accepts it (ServerGamePacketListenerImpl.awaitingPositionFromClient).
|
||||
pub awaiting_teleport: Option<(i32, [f64; 3])>,
|
||||
@@ -129,6 +158,9 @@ pub struct Guest {
|
||||
pub tracker: Tracker,
|
||||
// Player state the gameplay code reads and writes.
|
||||
pub motion: Option<Box<PlayerMotion>>,
|
||||
/// What the player's file says of the body (`Entity.load`: the fire
|
||||
/// and air ticks and the fall distance), for the first body it gets.
|
||||
pub loaded_body: Option<SavedBody>,
|
||||
/// The hurt and attack logs of the body it had before it respawned,
|
||||
/// until the new body takes them over (the control socket's `hurtlog`
|
||||
/// reads across a death).
|
||||
@@ -149,6 +181,9 @@ pub struct Guest {
|
||||
/// The ender chest inventory (`PlayerEnderChestContainer`, 27 slots).
|
||||
pub ender_items: Vec<Option<ItemStack>>,
|
||||
pub health: f32,
|
||||
/// The saved `AbsorptionAmount`, until the player's first body on the
|
||||
/// server takes it (the body holds the live amount).
|
||||
pub saved_absorption: Option<f32>,
|
||||
pub food: i32,
|
||||
pub saturation: f32,
|
||||
pub xp_level: i32,
|
||||
@@ -168,6 +203,10 @@ pub struct Guest {
|
||||
/// `lastSentHealth`, `lastSentFood` and `lastFoodSaturationZero`.
|
||||
pub sent_health: Option<(f32, i32, bool)>,
|
||||
pub sent_xp: Option<(f32, i32, i32)>,
|
||||
/// The air supply and absorption the player's client was last sent
|
||||
/// (Entity.DATA_AIR_SUPPLY_ID, Player.DATA_PLAYER_ABSORPTION_ID); their
|
||||
/// defaults until either changes.
|
||||
pub sent_air: (i32, f32),
|
||||
/// Where the player last died (Player.lastDeathLocation), sent with the respawn.
|
||||
pub last_death: Option<[i32; 3]>,
|
||||
/// The last action sequence to acknowledge at the next tick (-1: none).
|
||||
@@ -252,6 +291,7 @@ impl Guest {
|
||||
sleep_timer: 0,
|
||||
sent_bed: None,
|
||||
spawn: None,
|
||||
respawn_elsewhere: None,
|
||||
awaiting_teleport: None,
|
||||
teleport_counter: 0,
|
||||
teleport_tick: 0,
|
||||
@@ -270,6 +310,7 @@ impl Guest {
|
||||
batch_quota: 0.0,
|
||||
tracker: Tracker::default(),
|
||||
motion: None,
|
||||
loaded_body: None,
|
||||
logs_kept: None,
|
||||
inventory: vec![None; 36],
|
||||
armor: [None, None, None, None],
|
||||
@@ -280,6 +321,7 @@ impl Guest {
|
||||
craft_result: None,
|
||||
ender_items: vec![None; 27],
|
||||
health: 20.0,
|
||||
saved_absorption: None,
|
||||
food: 20,
|
||||
saturation: 5.0,
|
||||
xp_level: 0,
|
||||
@@ -292,6 +334,7 @@ impl Guest {
|
||||
menu_state: 0,
|
||||
sent_health: None,
|
||||
sent_xp: None,
|
||||
sent_air: (300, 0.0),
|
||||
last_death: None,
|
||||
ack_up_to: std::cell::Cell::new(-1),
|
||||
sent_selected: 0,
|
||||
|
||||
@@ -842,6 +842,20 @@ impl Hub {
|
||||
|
||||
/// ServerGamePacketListenerImpl.teleport: move the client and wait for its confirmation.
|
||||
pub fn teleport(&mut self, i: usize, position: Option<[f64; 3]>) {
|
||||
self.teleport_relative(i, position, 0);
|
||||
}
|
||||
|
||||
/// A teleport to `position` that keeps the player's motion and look
|
||||
/// (`Relative.DELTA` and `Relative.ROTATION`: an End gateway's).
|
||||
pub fn teleport_keeping_motion(&mut self, i: usize, position: [f64; 3]) {
|
||||
use play::relative::{DELTA_X, DELTA_Y, DELTA_Z, ROTATE_DELTA, X_ROT, Y_ROT};
|
||||
let relatives = Y_ROT | X_ROT | DELTA_X | DELTA_Y | DELTA_Z | ROTATE_DELTA;
|
||||
self.teleport_relative(i, Some(position), relatives);
|
||||
}
|
||||
|
||||
/// ServerGamePacketListenerImpl.teleport with `relatives`: the rotation
|
||||
/// and motion they name are sent as zero changes.
|
||||
fn teleport_relative(&mut self, i: usize, position: Option<[f64; 3]>, relatives: i32) {
|
||||
let ticks = self.ticks;
|
||||
let g = &mut self.guests[i];
|
||||
g.teleport_tick = ticks;
|
||||
@@ -852,13 +866,14 @@ impl Hub {
|
||||
g.teleport_counter = (g.teleport_counter + 1) & 0x7FFF_FFFF;
|
||||
let id = g.teleport_counter;
|
||||
g.awaiting_teleport = Some((id, g.position));
|
||||
let turn = relatives & (play::relative::Y_ROT | play::relative::X_ROT) != 0;
|
||||
g.send(&C::PlayerPosition(play::PlayerPosition {
|
||||
teleport: id,
|
||||
position: g.position,
|
||||
delta: [0.0; 3],
|
||||
yaw: g.yaw,
|
||||
pitch: g.pitch,
|
||||
relatives: 0,
|
||||
yaw: if turn { 0.0 } else { g.yaw },
|
||||
pitch: if turn { 0.0 } else { g.pitch },
|
||||
relatives,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1544,9 +1559,12 @@ impl Hub {
|
||||
velocity: [0.0; 3],
|
||||
data: 0,
|
||||
meta,
|
||||
defaults: Vec::new(),
|
||||
equipment,
|
||||
attributes,
|
||||
precise: false,
|
||||
needs_sync: false,
|
||||
leash: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1755,6 +1773,27 @@ impl Hub {
|
||||
}));
|
||||
}
|
||||
}
|
||||
// ServerEntity.sendDirtyEntityData: the air supply and the absorption
|
||||
// amount the server's body changed go to the player, whose HUD draws
|
||||
// them (bubbles, golden hearts).
|
||||
let g = &mut self.guests[i];
|
||||
if let Some(m) = g.motion.as_ref() {
|
||||
let now = (m.air, m.health.absorption());
|
||||
let mut values = Vec::new();
|
||||
if now.0 != g.sent_air.0 {
|
||||
values.push((1, serializer::INT, Value::Int(now.0)));
|
||||
}
|
||||
if now.1.to_bits() != g.sent_air.1.to_bits() {
|
||||
values.push((17, serializer::FLOAT, Value::Float(now.1)));
|
||||
}
|
||||
if !values.is_empty() {
|
||||
g.sent_air = now;
|
||||
g.send(&C::SetEntityData(play::SetEntityData {
|
||||
id: g.entity_id,
|
||||
values,
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Sent when the health or the food level changes, or the saturation
|
||||
// reaches or leaves zero; a dead player's health of 0 too.
|
||||
let g = &mut self.guests[i];
|
||||
@@ -2465,9 +2504,11 @@ impl Hub {
|
||||
global: false,
|
||||
}));
|
||||
}
|
||||
// lastSentExp, lastSentHealth and lastSentFood.
|
||||
// lastSentExp, lastSentHealth and lastSentFood; the client's new
|
||||
// player starts with the default air and no absorption.
|
||||
g.sent_health = None;
|
||||
g.sent_xp = None;
|
||||
g.sent_air = (300, 0.0);
|
||||
let (cx, cz) = g.chunk();
|
||||
g.send(&C::SetChunkCacheCenter(play::SetChunkCacheCenter {
|
||||
x: cx,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Guests' saved state: `players/data/<uuid>.dat` in the vanilla player
|
||||
//! compound, through the same code that saves the host.
|
||||
use super::guest::Guest;
|
||||
use super::guest::{Guest, SavedBody};
|
||||
use crate::{
|
||||
items::ItemStack,
|
||||
save::{Level, read_dat, write_dat},
|
||||
@@ -61,6 +61,7 @@ pub fn apply(guest: &mut Guest, level: &Level) {
|
||||
guest.head_yaw = guest.yaw;
|
||||
guest.on_ground = level.on_ground;
|
||||
guest.health = level.health;
|
||||
guest.saved_absorption = Some(level.absorption);
|
||||
guest.food = level.food;
|
||||
guest.saturation = level.saturation;
|
||||
guest.xp_level = level.xp_level;
|
||||
@@ -71,8 +72,25 @@ pub fn apply(guest: &mut Guest, level: &Level) {
|
||||
guest.game_mode = level.mode();
|
||||
guest.flying = level.flying;
|
||||
guest.selected = (level.selected_slot.max(0) as usize).min(8);
|
||||
guest.spawn = level.spawn.map(|s| [s[0] + 0.5, s[1], s[2] + 0.5]);
|
||||
// A point that is the world spawn is none of the player's own: it
|
||||
// respawns wherever the world spawn is then (`setworldspawn`).
|
||||
guest.spawn = level
|
||||
.spawn
|
||||
.filter(|s| Some(s.map(|v| v.floor() as i32)) != level.world_spawn)
|
||||
.map(|s| [s[0] + 0.5, s[1], s[2] + 0.5]);
|
||||
guest.respawn_elsewhere = level.respawn_elsewhere.clone();
|
||||
guest.saved = level.player_extra.clone();
|
||||
let extra = level.player_extra.as_ref();
|
||||
let short = |key: &str, default: i64| {
|
||||
extra
|
||||
.and_then(|t| crate::save::number(t.get(key)))
|
||||
.unwrap_or(default) as i32
|
||||
};
|
||||
guest.loaded_body = Some(SavedBody {
|
||||
fire_ticks: short("Fire", -20),
|
||||
air: short("Air", 300),
|
||||
fall_distance: level.fall_distance,
|
||||
});
|
||||
guest.recipe_book = level.recipe_book.clone();
|
||||
guest.dimension = match level.dimension.as_str() {
|
||||
d @ ("minecraft:the_nether" | "minecraft:the_end") => d.to_owned(),
|
||||
@@ -123,6 +141,12 @@ pub fn store(guest: &Guest, level: &mut Level) {
|
||||
level.dimension = guest.dimension.clone();
|
||||
level.rotation = [guest.yaw, guest.pitch];
|
||||
level.health = guest.health;
|
||||
level.absorption = guest
|
||||
.motion
|
||||
.as_ref()
|
||||
.map_or(guest.saved_absorption.unwrap_or(0.0), |m| {
|
||||
m.health.absorption()
|
||||
});
|
||||
level.food = guest.food;
|
||||
level.saturation = guest.saturation;
|
||||
level.xp_level = guest.xp_level;
|
||||
@@ -135,8 +159,27 @@ pub fn store(guest: &Guest, level: &mut Level) {
|
||||
level.flying = guest.flying;
|
||||
level.selected_slot = guest.selected as i32;
|
||||
level.spawn = guest.spawn.map(|s| [s[0] - 0.5, s[1], s[2] - 0.5]);
|
||||
level.respawn_elsewhere = guest.respawn_elsewhere.clone();
|
||||
level.on_ground = guest.on_ground;
|
||||
level.player_extra = guest.saved.clone();
|
||||
// The live body's fire, air and fall distance (Entity.saveWithoutId),
|
||||
// or what the file had while the player has no body yet.
|
||||
let body = guest
|
||||
.motion
|
||||
.as_deref()
|
||||
.map(SavedBody::of)
|
||||
.or(guest.loaded_body);
|
||||
if let Some(body) = body {
|
||||
level.fall_distance = body.fall_distance;
|
||||
let extra = level
|
||||
.player_extra
|
||||
.get_or_insert_with(|| horizon_world::nbt::Tag::Compound(Default::default()));
|
||||
if let horizon_world::nbt::Tag::Compound(map) = extra {
|
||||
let short = |v: i32| horizon_world::nbt::Tag::Short(v.clamp(-32768, 32767) as i16);
|
||||
map.insert("Fire".into(), short(body.fire_ticks));
|
||||
map.insert("Air".into(), short(body.air));
|
||||
}
|
||||
}
|
||||
level.recipe_book = guest.recipe_book.clone();
|
||||
level.ender_items = guest.ender_items.clone();
|
||||
level.inventory.clear();
|
||||
@@ -160,3 +203,52 @@ pub fn store(guest: &Guest, level: &mut Level) {
|
||||
put(100, &guest.armor[3]);
|
||||
put(150, &guest.offhand);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::lan::{Joined, PlayerSender};
|
||||
use horizon_protocol::packets::configuration::ClientInformation;
|
||||
use std::sync::{Arc, atomic::AtomicI32, mpsc};
|
||||
|
||||
fn guest() -> Guest {
|
||||
let (tx, _rx) = mpsc::channel();
|
||||
Guest::new(
|
||||
Joined {
|
||||
conn: 2,
|
||||
uuid: 2,
|
||||
name: "Alex".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(8),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
},
|
||||
2,
|
||||
[0.0, 64.0, 0.0],
|
||||
8,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_burn_and_fall_come_back() {
|
||||
// A player saved burning in mid-fall (#1323, #1327): Entity.saveWithoutId's
|
||||
// Fire, Air and fall_distance, read back for the next body.
|
||||
let mut g = guest();
|
||||
g.loaded_body = Some(SavedBody {
|
||||
fire_ticks: 96,
|
||||
air: 250,
|
||||
fall_distance: 9.5,
|
||||
});
|
||||
let mut level = Level::default();
|
||||
store(&g, &mut level);
|
||||
let tag = level.player_tag();
|
||||
let mut read = Level::default();
|
||||
read.read_player(&tag);
|
||||
read.player_extra = Some(tag);
|
||||
let mut back = guest();
|
||||
apply(&mut back, &read);
|
||||
assert_eq!(back.loaded_body, g.loaded_body);
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,7 @@ pub mod landing;
|
||||
pub mod lang;
|
||||
pub mod level_loading;
|
||||
pub mod light;
|
||||
pub mod locator_bar;
|
||||
pub mod lod;
|
||||
pub mod logging;
|
||||
pub mod menu;
|
||||
@@ -180,12 +181,14 @@ pub mod ticket_levels;
|
||||
pub mod title;
|
||||
pub mod title_edition;
|
||||
pub mod toasts;
|
||||
pub mod trims;
|
||||
pub mod ui;
|
||||
pub mod ui_theme;
|
||||
pub mod vanillagen;
|
||||
pub mod vault;
|
||||
pub mod weather;
|
||||
pub mod widgets;
|
||||
pub mod win_screen;
|
||||
pub mod window;
|
||||
pub mod world;
|
||||
pub mod world_spawn;
|
||||
|
||||
@@ -0,0 +1,742 @@
|
||||
//! The locator bar: `ClientWaypointManager` (the waypoints the server
|
||||
//! tracks for this player, from `ClientboundTrackedWaypointPacket`),
|
||||
//! `WaypointStyleManager` (`waypoint_style/*.json`: near and far distances
|
||||
//! and the dot sprites between them) and `LocatorBar`, the contextual bar
|
||||
//! that draws a dot per waypoint within 60 degrees of the view, with an
|
||||
//! arrow when it is above or below the screen.
|
||||
use crate::hud_layout::{Blend, GuiDraw};
|
||||
use horizon_assets::animation::{AnimationClip, AnimationMetadata};
|
||||
use horizon_core::geometry::trig;
|
||||
use horizon_protocol::packets::play::{
|
||||
TrackedWaypoint, WaypointId, WaypointTarget, waypoint_operation,
|
||||
};
|
||||
use std::{borrow::Cow, collections::HashMap, fs, path::Path};
|
||||
|
||||
/// `WaypointStyle`: the sprites (under `hud/locator_bar_dot/`) from near to far.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct WaypointStyle {
|
||||
pub near_distance: i32,
|
||||
pub far_distance: i32,
|
||||
pub sprites: Vec<String>,
|
||||
}
|
||||
impl WaypointStyle {
|
||||
/// `WaypointStyleManager.MISSING`.
|
||||
fn missing() -> Self {
|
||||
Self {
|
||||
near_distance: 0,
|
||||
far_distance: 1,
|
||||
sprites: vec!["hud/locator_bar_dot/missingno".into()],
|
||||
}
|
||||
}
|
||||
/// `WaypointStyle.CODEC` and `validate`: None when the file is invalid.
|
||||
pub fn parse(json: &str) -> Option<Self> {
|
||||
let v: serde_json::Value = serde_json::from_str(json).ok()?;
|
||||
let distance = |key: &str, default: i32| match v.get(key) {
|
||||
None => Some(default),
|
||||
Some(d) => d
|
||||
.as_i64()
|
||||
.filter(|d| (0..=60_000_000).contains(d))
|
||||
.map(|d| d as i32),
|
||||
};
|
||||
let near_distance = distance("near_distance", 128)?;
|
||||
let far_distance = distance("far_distance", 332)?;
|
||||
let sprites = v
|
||||
.get("sprites")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let id = s.as_str()?;
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
// Sprites of the gui atlas are named by path (minecraft only).
|
||||
(namespace == "minecraft").then(|| format!("hud/locator_bar_dot/{path}"))
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
(!sprites.is_empty() && near_distance > 0 && near_distance < far_distance).then_some(Self {
|
||||
near_distance,
|
||||
far_distance,
|
||||
sprites,
|
||||
})
|
||||
}
|
||||
/// `WaypointStyle.sprite`.
|
||||
pub fn sprite(&self, distance: f32) -> &str {
|
||||
let (first, last) = (&self.sprites[0], &self.sprites[self.sprites.len() - 1]);
|
||||
if distance < self.near_distance as f32 {
|
||||
return first;
|
||||
}
|
||||
if distance >= self.far_distance as f32 {
|
||||
return last;
|
||||
}
|
||||
match self.sprites.len() {
|
||||
1 => first,
|
||||
3 => &self.sprites[1],
|
||||
n => {
|
||||
let t = (distance - self.near_distance as f32)
|
||||
/ (self.far_distance - self.near_distance) as f32;
|
||||
// Mth.lerpInt(t, 1, n - 1).
|
||||
&self.sprites[(1 + (t * (n as f32 - 2.0)).floor() as i32) as usize]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An animated GUI sprite (`.png.mcmeta` animation, ticked with the GUI
|
||||
/// atlas): its image and frame sizes and the frame sequence.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SpriteFrames {
|
||||
image: [i32; 2],
|
||||
frame: [i32; 2],
|
||||
clip: AnimationClip,
|
||||
}
|
||||
impl SpriteFrames {
|
||||
/// None for a still sprite or invalid metadata.
|
||||
fn load(png: &Path) -> Option<Self> {
|
||||
let text = fs::read_to_string(png.with_extension("png.mcmeta")).ok()?;
|
||||
let meta: HashMap<String, Box<serde_json::value::RawValue>> =
|
||||
serde_json::from_str(&text).ok()?;
|
||||
let animation = AnimationMetadata::from_json(meta.get("animation")?.get()).ok()?;
|
||||
// The PNG header: width and height at bytes 16..24 of IHDR.
|
||||
let mut header = [0u8; 24];
|
||||
std::io::Read::read_exact(&mut fs::File::open(png).ok()?, &mut header).ok()?;
|
||||
let dim = |at: usize| i32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
|
||||
let image = [dim(16), dim(20)];
|
||||
let frame = animation.frame_size(image[0], image[1]);
|
||||
if frame[0] <= 0 || frame[1] <= 0 || image[0] % frame[0] != 0 || image[1] % frame[1] != 0 {
|
||||
return None;
|
||||
}
|
||||
let count = (image[0] / frame[0]) * (image[1] / frame[1]);
|
||||
let clip = animation.clip(count as u32).ok()??;
|
||||
Some(Self { image, frame, clip })
|
||||
}
|
||||
/// The `region` (image width, height, u, v) of the frame shown at `tick`.
|
||||
fn region(&self, tick: u64) -> [i32; 4] {
|
||||
let index = self.clip.sample(tick).frame as i32;
|
||||
let columns = self.image[0] / self.frame[0];
|
||||
[
|
||||
self.image[0],
|
||||
self.image[1],
|
||||
index % columns * self.frame[0],
|
||||
index / columns * self.frame[1],
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// `WaypointStyleManager`: every `assets/<namespace>/waypoint_style/*.json`,
|
||||
/// plus the animations of the bar's own sprites (the arrows blink).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct WaypointStyles(
|
||||
HashMap<String, WaypointStyle>,
|
||||
HashMap<String, SpriteFrames>,
|
||||
);
|
||||
impl WaypointStyles {
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let mut frames = HashMap::new();
|
||||
let hud = root.join("assets/minecraft/textures/gui/sprites/hud");
|
||||
let dots = fs::read_dir(hud.join("locator_bar_dot"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path());
|
||||
let arrows = ["locator_bar_arrow_up", "locator_bar_arrow_down"]
|
||||
.map(|a| hud.join(format!("{a}.png")));
|
||||
for png in arrows.into_iter().chain(dots) {
|
||||
if png.extension().is_none_or(|e| e != "png") {
|
||||
continue;
|
||||
}
|
||||
let Some(f) = SpriteFrames::load(&png) else {
|
||||
continue;
|
||||
};
|
||||
let name = png
|
||||
.strip_prefix(hud.parent().unwrap_or(&hud))
|
||||
.map(|r| r.with_extension("").to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
frames.insert(name, f);
|
||||
}
|
||||
let mut out = HashMap::new();
|
||||
for ns in fs::read_dir(root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
{
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
for f in fs::read_dir(ns.path().join("waypoint_style"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
{
|
||||
let path = f.path();
|
||||
if path.extension().is_none_or(|e| e != "json") {
|
||||
continue;
|
||||
}
|
||||
let name = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
if let Some(style) = fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|t| WaypointStyle::parse(&t))
|
||||
{
|
||||
out.insert(format!("{namespace}:{name}"), style);
|
||||
} else {
|
||||
log::warn!("Horizon: invalid waypoint style {}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
Self(out, frames)
|
||||
}
|
||||
/// The sprite at `tick`, with its animation frame when it has one.
|
||||
fn sprite(&self, name: &str, x: i32, y: i32, w: i32, h: i32, color: u32, tick: u64) -> GuiDraw {
|
||||
let mut d = sprite(name, x, y, w, h, color);
|
||||
if let (Some(f), GuiDraw::Sprite { region, .. }) = (self.1.get(name), &mut d) {
|
||||
*region = Some(f.region(tick));
|
||||
}
|
||||
d
|
||||
}
|
||||
/// `get`: the style, or the missing one.
|
||||
pub fn get(&self, id: &str) -> Cow<'_, WaypointStyle> {
|
||||
let id = if id.contains(':') {
|
||||
Cow::Borrowed(id)
|
||||
} else {
|
||||
Cow::Owned(format!("minecraft:{id}"))
|
||||
};
|
||||
self.0
|
||||
.get(&*id)
|
||||
.map_or_else(|| Cow::Owned(WaypointStyle::missing()), Cow::Borrowed)
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientWaypointManager`: the tracked waypoints by identifier.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ClientWaypoints {
|
||||
waypoints: Vec<TrackedWaypoint>,
|
||||
}
|
||||
impl ClientWaypoints {
|
||||
/// `ClientboundTrackedWaypointPacket.apply`.
|
||||
pub fn apply(&mut self, packet: &TrackedWaypoint) {
|
||||
let at = self.waypoints.iter().position(|w| w.id == packet.id);
|
||||
match packet.operation {
|
||||
waypoint_operation::TRACK => match at {
|
||||
Some(i) => self.waypoints[i] = packet.clone(),
|
||||
None => self.waypoints.push(packet.clone()),
|
||||
},
|
||||
waypoint_operation::UNTRACK => {
|
||||
if let Some(i) = at {
|
||||
self.waypoints.remove(i);
|
||||
}
|
||||
}
|
||||
// `TrackedWaypoint.update`: only the same kind of waypoint moves.
|
||||
_ => {
|
||||
if let Some(w) = at.map(|i| &mut self.waypoints[i]) {
|
||||
use WaypointTarget as T;
|
||||
if matches!(
|
||||
(w.target, packet.target),
|
||||
(T::Block(_), T::Block(_))
|
||||
| (T::Chunk(_), T::Chunk(_))
|
||||
| (T::Azimuth(_), T::Azimuth(_))
|
||||
) {
|
||||
w.target = packet.target;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn has_waypoints(&self) -> bool {
|
||||
!self.waypoints.is_empty()
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.waypoints.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// What the locator bar needs of the camera and the world this frame.
|
||||
pub struct View<'a> {
|
||||
pub gui_width: i32,
|
||||
pub gui_height: i32,
|
||||
/// The camera entity's UUID, its block position and its position.
|
||||
pub camera_uuid: u128,
|
||||
pub camera_entity_pos: [f64; 3],
|
||||
/// `Camera.position()` and `yRot()`, `xRot()`, `getFov()` in degrees.
|
||||
pub camera_pos: [f64; 3],
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
pub fov: f32,
|
||||
/// `Camera.getViewRotationProjectionMatrix`, applied to a point
|
||||
/// relative to the camera: normalised device coordinates.
|
||||
pub project: &'a dyn Fn([f64; 3]) -> [f64; 3],
|
||||
/// An entity with this UUID in the level: (block position, eye position).
|
||||
pub entity: &'a dyn Fn(u128) -> Option<([i32; 3], [f64; 3])>,
|
||||
pub styles: &'a WaypointStyles,
|
||||
/// Client ticks, for animated sprites.
|
||||
pub tick: u64,
|
||||
}
|
||||
|
||||
/// `TrackedWaypoint.PitchDirection`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Pitch {
|
||||
None,
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
/// `UUID.hashCode`.
|
||||
fn uuid_hash(uuid: u128) -> i32 {
|
||||
let hilo = (uuid >> 64) as u64 ^ uuid as u64;
|
||||
((hilo >> 32) as i32) ^ (hilo as i32)
|
||||
}
|
||||
/// `String.hashCode`.
|
||||
fn string_hash(s: &str) -> i32 {
|
||||
s.encode_utf16()
|
||||
.fold(0i32, |h, c| h.wrapping_mul(31).wrapping_add(i32::from(c)))
|
||||
}
|
||||
|
||||
/// `ARGB.setBrightness`.
|
||||
pub fn set_brightness(color: u32, brightness: f32) -> u32 {
|
||||
let [alpha, r, g, b] = color.to_be_bytes().map(i32::from);
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let range = (max - min) as f32;
|
||||
let saturation = if max != 0 { range / max as f32 } else { 0.0 };
|
||||
let argb =
|
||||
|r: i32, g: i32, b: i32| u32::from_be_bytes([alpha as u8, r as u8, g as u8, b as u8]);
|
||||
let round = |v: f32| (v * 255.0).round() as i32;
|
||||
if saturation == 0.0 {
|
||||
let v = round(brightness);
|
||||
return argb(v, v, v);
|
||||
}
|
||||
let (cr, cg, cb) = (
|
||||
(max - r) as f32 / range,
|
||||
(max - g) as f32 / range,
|
||||
(max - b) as f32 / range,
|
||||
);
|
||||
let mut hue = if r == max {
|
||||
cb - cg
|
||||
} else if g == max {
|
||||
2.0 + cr - cb
|
||||
} else {
|
||||
4.0 + cg - cr
|
||||
} / 6.0;
|
||||
if hue < 0.0 {
|
||||
hue += 1.0;
|
||||
}
|
||||
let segment = (hue - hue.floor()) * 6.0;
|
||||
let offset = segment - segment.floor();
|
||||
let primary = brightness * (1.0 - saturation);
|
||||
let secondary = brightness * (1.0 - saturation * offset);
|
||||
let tertiary = brightness * (1.0 - saturation * (1.0 - offset));
|
||||
let full = round(brightness);
|
||||
match segment as i32 {
|
||||
0 => argb(full, round(tertiary), round(primary)),
|
||||
1 => argb(round(secondary), full, round(primary)),
|
||||
2 => argb(round(primary), full, round(tertiary)),
|
||||
3 => argb(round(primary), round(secondary), full),
|
||||
4 => argb(round(tertiary), round(primary), full),
|
||||
5 => argb(full, round(primary), round(secondary)),
|
||||
_ => argb(r, g, b),
|
||||
}
|
||||
}
|
||||
|
||||
/// `Mth.wrapDegrees`.
|
||||
fn wrap_degrees(angle: f32) -> f32 {
|
||||
let mut a = angle % 360.0;
|
||||
if a >= 180.0 {
|
||||
a -= 360.0;
|
||||
}
|
||||
if a < -180.0 {
|
||||
a += 360.0;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
fn centre(v: [i32; 3]) -> [f64; 3] {
|
||||
v.map(|c| f64::from(c) + 0.5)
|
||||
}
|
||||
|
||||
impl View<'_> {
|
||||
/// `Vec3iWaypoint.position`: the tracked entity's eyes when it is within
|
||||
/// 3 blocks (Manhattan) of the waypoint, else the block's centre.
|
||||
fn block_position(&self, w: &TrackedWaypoint, v: [i32; 3]) -> [f64; 3] {
|
||||
if let WaypointId::Uuid(uuid) = w.id
|
||||
&& let Some((block, eyes)) = (self.entity)(uuid)
|
||||
{
|
||||
let manhattan: i32 = (0..3).map(|i| (block[i] - v[i]).abs()).sum();
|
||||
if manhattan <= 3 {
|
||||
return eyes;
|
||||
}
|
||||
}
|
||||
centre(v)
|
||||
}
|
||||
/// `yawAngleToCamera` of a point: `degreesDifference(camera yaw, the
|
||||
/// angle of camera - point turned clockwise by 90 degrees)`.
|
||||
fn yaw_to(&self, p: [f64; 3]) -> f64 {
|
||||
let c = self.camera_pos;
|
||||
// Vec3.rotateClockwise90: (-z, y, x).
|
||||
let (x, z) = (-(c[2] - p[2]), c[0] - p[0]);
|
||||
let angle = trig::atan2(z, x) as f32 * (180.0 / std::f32::consts::PI);
|
||||
f64::from(wrap_degrees(angle - self.yaw))
|
||||
}
|
||||
/// `ChunkPos.getMiddleBlockPosition(y)`'s centre.
|
||||
fn chunk_middle(chunk: [i32; 2], y: i32) -> [f64; 3] {
|
||||
centre([chunk[0] * 16 + 8, y, chunk[1] * 16 + 8])
|
||||
}
|
||||
fn yaw_angle(&self, w: &TrackedWaypoint) -> f64 {
|
||||
match w.target {
|
||||
WaypointTarget::Empty => f64::NAN,
|
||||
WaypointTarget::Block(v) => self.yaw_to(self.block_position(w, v)),
|
||||
WaypointTarget::Chunk(c) => {
|
||||
self.yaw_to(Self::chunk_middle(c, self.camera_pos[1] as i32))
|
||||
}
|
||||
WaypointTarget::Azimuth(a) => {
|
||||
f64::from(wrap_degrees(a * (180.0 / std::f32::consts::PI) - self.yaw))
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `GameRenderer.projectHorizonToScreen`.
|
||||
fn horizon(&self) -> f64 {
|
||||
if self.pitch <= -90.0 {
|
||||
return f64::NEG_INFINITY;
|
||||
}
|
||||
if self.pitch >= 90.0 {
|
||||
return f64::INFINITY;
|
||||
}
|
||||
// Math.tan of the float products, in double.
|
||||
let rad = std::f32::consts::PI / 180.0;
|
||||
f64::from(self.pitch * rad).tan() / f64::from(self.fov / 2.0 * rad).tan()
|
||||
}
|
||||
fn pitch_direction(&self, w: &TrackedWaypoint) -> Pitch {
|
||||
let by = |y: f64| {
|
||||
if y < -1.0 {
|
||||
Pitch::Down
|
||||
} else if y > 1.0 {
|
||||
Pitch::Up
|
||||
} else {
|
||||
Pitch::None
|
||||
}
|
||||
};
|
||||
match w.target {
|
||||
WaypointTarget::Empty => Pitch::None,
|
||||
WaypointTarget::Chunk(_) | WaypointTarget::Azimuth(_) => by(self.horizon()),
|
||||
WaypointTarget::Block(v) => {
|
||||
let p = self.block_position(w, v);
|
||||
let c = self.camera_pos;
|
||||
let s = (self.project)([p[0] - c[0], p[1] - c[1], p[2] - c[2]]);
|
||||
let behind = s[2] > 1.0;
|
||||
match by(if behind { -s[1] } else { s[1] }) {
|
||||
Pitch::None if behind && s[1] > 0.0 => Pitch::Up,
|
||||
Pitch::None if behind && s[1] < 0.0 => Pitch::Down,
|
||||
p => p,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `distanceSquared` from the camera entity.
|
||||
fn distance_sq(&self, w: &TrackedWaypoint) -> f64 {
|
||||
let e = self.camera_entity_pos;
|
||||
let d = |p: [f64; 3]| (0..3).map(|i| (p[i] - e[i]).powi(2)).sum::<f64>();
|
||||
match w.target {
|
||||
WaypointTarget::Block(v) => d(centre(v)),
|
||||
WaypointTarget::Chunk(c) => d(Self::chunk_middle(c, e[1].floor() as i32)),
|
||||
WaypointTarget::Empty | WaypointTarget::Azimuth(_) => f64::INFINITY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `LocatorBar.extractBackground`: the nine-sliced sprite and its rectangle.
|
||||
pub fn background(gui_width: i32, gui_height: i32) -> (&'static str, [i32; 4]) {
|
||||
(
|
||||
"gui/sprites/hud/locator_bar_background",
|
||||
[gui_width / 2 - 91, gui_height - 32 + 3, 182, 5],
|
||||
)
|
||||
}
|
||||
|
||||
fn sprite(name: &str, x: i32, y: i32, w: i32, h: i32, color: u32) -> GuiDraw {
|
||||
GuiDraw::Sprite {
|
||||
sprite: Cow::Owned(name.to_owned()),
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
region: None,
|
||||
color,
|
||||
blend: Blend::Textured,
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientWaypoints {
|
||||
/// `LocatorBar.extractRenderState`: the farthest waypoint first.
|
||||
pub fn draw(&self, view: &View) -> Vec<GuiDraw> {
|
||||
let top = view.gui_height - 32 + 3;
|
||||
let mut sorted: Vec<(f64, &TrackedWaypoint)> = self
|
||||
.waypoints
|
||||
.iter()
|
||||
.map(|w| (view.distance_sq(w), w))
|
||||
.collect();
|
||||
sorted.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
let mut out = Vec::new();
|
||||
for (distance_sq, w) in sorted {
|
||||
if w.id == WaypointId::Uuid(view.camera_uuid) {
|
||||
continue;
|
||||
}
|
||||
let angle = view.yaw_angle(w);
|
||||
// `!(angle <= -60) && !(angle > 60)`: NaN draws, at the centre.
|
||||
if angle <= -60.0 || angle > 60.0 {
|
||||
continue;
|
||||
}
|
||||
let middle = ((view.gui_width - 9) as f32 / 2.0).ceil() as i32;
|
||||
let style = view.styles.get(&w.style);
|
||||
let distance = (distance_sq as f32).sqrt();
|
||||
let color = w.color.map_or_else(
|
||||
|| {
|
||||
let hash = match &w.id {
|
||||
WaypointId::Uuid(u) => uuid_hash(*u),
|
||||
WaypointId::Name(n) => string_hash(n),
|
||||
};
|
||||
set_brightness(0xFF00_0000 | hash as u32, 0.9)
|
||||
},
|
||||
|c| c as u32,
|
||||
);
|
||||
let dot = (angle * 173.0 / 2.0 / 60.0).floor() as i32;
|
||||
out.push(view.styles.sprite(
|
||||
style.sprite(distance),
|
||||
middle + dot,
|
||||
top - 2,
|
||||
9,
|
||||
9,
|
||||
color,
|
||||
view.tick,
|
||||
));
|
||||
let arrow = match view.pitch_direction(w) {
|
||||
Pitch::None => continue,
|
||||
Pitch::Down => (6, "hud/locator_bar_arrow_down"),
|
||||
Pitch::Up => (-6, "hud/locator_bar_arrow_up"),
|
||||
};
|
||||
out.push(view.styles.sprite(
|
||||
arrow.1,
|
||||
middle + dot + 1,
|
||||
top + arrow.0,
|
||||
7,
|
||||
5,
|
||||
u32::MAX,
|
||||
view.tick,
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// `Hud.ContextualInfo`: which bar sits above the hotbar.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ContextualBar {
|
||||
Empty,
|
||||
Experience,
|
||||
Locator,
|
||||
JumpableVehicle,
|
||||
}
|
||||
impl ContextualBar {
|
||||
/// `Hud.nextContextualInfoState`: `recent_experience` is
|
||||
/// `willPrioritizeExperienceInfo` (experience changed in the last 100
|
||||
/// ticks), `jumping` is `willPrioritizeJumpInfo`.
|
||||
pub fn next(
|
||||
waypoints: bool,
|
||||
jumpable_vehicle: bool,
|
||||
jumping: bool,
|
||||
has_experience: bool,
|
||||
recent_experience: bool,
|
||||
) -> Self {
|
||||
if waypoints {
|
||||
if jumpable_vehicle && jumping {
|
||||
Self::JumpableVehicle
|
||||
} else if has_experience && recent_experience {
|
||||
Self::Experience
|
||||
} else {
|
||||
Self::Locator
|
||||
}
|
||||
} else if jumpable_vehicle {
|
||||
Self::JumpableVehicle
|
||||
} else if has_experience {
|
||||
Self::Experience
|
||||
} else {
|
||||
Self::Empty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tracked(operation: i32, id: u128, target: WaypointTarget) -> TrackedWaypoint {
|
||||
TrackedWaypoint {
|
||||
operation,
|
||||
id: WaypointId::Uuid(id),
|
||||
style: "minecraft:default".into(),
|
||||
color: None,
|
||||
target,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_pick_sprites_by_distance() {
|
||||
let default = WaypointStyle::parse(
|
||||
r#"{"sprites":["minecraft:default_0","minecraft:default_1","minecraft:default_2","minecraft:default_3"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((default.near_distance, default.far_distance), (128, 332));
|
||||
assert_eq!(default.sprite(10.0), "hud/locator_bar_dot/default_0");
|
||||
assert_eq!(default.sprite(128.0), "hud/locator_bar_dot/default_1");
|
||||
// lerpInt((d - 128) / 204, 1, 3): halfway is the third sprite.
|
||||
assert_eq!(default.sprite(229.0), "hud/locator_bar_dot/default_1");
|
||||
assert_eq!(default.sprite(230.0), "hud/locator_bar_dot/default_2");
|
||||
assert_eq!(default.sprite(332.0), "hud/locator_bar_dot/default_3");
|
||||
let bowtie = WaypointStyle::parse(
|
||||
r#"{"near_distance":64,"sprites":["minecraft:bowtie","minecraft:default_0","minecraft:default_1","minecraft:default_2","minecraft:default_3"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(bowtie.sprite(63.0), "hud/locator_bar_dot/bowtie");
|
||||
// lerpInt((d - 64) / 268, 1, 4).
|
||||
assert_eq!(bowtie.sprite(64.0), "hud/locator_bar_dot/default_0");
|
||||
assert_eq!(bowtie.sprite(200.0), "hud/locator_bar_dot/default_1");
|
||||
assert_eq!(bowtie.sprite(300.0), "hud/locator_bar_dot/default_2");
|
||||
// Three sprites: the middle one between near and far.
|
||||
let three = WaypointStyle::parse(r#"{"sprites":["a","b","c"]}"#).unwrap();
|
||||
assert_eq!(three.sprite(200.0), "hud/locator_bar_dot/b");
|
||||
// validate: sprites, near > 0, far > near.
|
||||
assert!(WaypointStyle::parse(r#"{"sprites":[]}"#).is_none());
|
||||
assert!(WaypointStyle::parse(r#"{"near_distance":0,"sprites":["a"]}"#).is_none());
|
||||
assert!(WaypointStyle::parse(r#"{"near_distance":400,"sprites":["a"]}"#).is_none());
|
||||
assert_eq!(
|
||||
WaypointStyles::default().get("minecraft:none").sprite(5.0),
|
||||
"hud/locator_bar_dot/missingno"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packets_track_update_and_untrack() {
|
||||
let mut w = ClientWaypoints::default();
|
||||
w.apply(&tracked(
|
||||
waypoint_operation::TRACK,
|
||||
1,
|
||||
WaypointTarget::Block([1, 2, 3]),
|
||||
));
|
||||
assert!(w.has_waypoints());
|
||||
w.apply(&tracked(
|
||||
waypoint_operation::UPDATE,
|
||||
1,
|
||||
WaypointTarget::Block([4, 5, 6]),
|
||||
));
|
||||
assert_eq!(w.waypoints[0].target, WaypointTarget::Block([4, 5, 6]));
|
||||
// Another kind of waypoint doesn't update it.
|
||||
w.apply(&tracked(
|
||||
waypoint_operation::UPDATE,
|
||||
1,
|
||||
WaypointTarget::Azimuth(1.0),
|
||||
));
|
||||
assert_eq!(w.waypoints[0].target, WaypointTarget::Block([4, 5, 6]));
|
||||
w.apply(&tracked(
|
||||
waypoint_operation::UNTRACK,
|
||||
1,
|
||||
WaypointTarget::Empty,
|
||||
));
|
||||
assert!(!w.has_waypoints());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brightness_like_argb() {
|
||||
// Pure red at 0.9: Math.round(0.9 * 255) = 230.
|
||||
assert_eq!(set_brightness(0xFFFF_0000, 0.9), 0xFFE6_0000);
|
||||
assert_eq!(set_brightness(0xFF80_8080, 0.9), 0xFFE6_E6E6);
|
||||
assert_eq!(uuid_hash(0x0000_0001_0000_0000_0000_0000_0000_0002), 3);
|
||||
assert_eq!(string_hash("ab"), 97 * 31 + 98);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_blink_through_their_frames() {
|
||||
// locator_bar_arrow_up.png.mcmeta: 7x10, two 5-pixel frames.
|
||||
let meta = r#"{"frames":[{"index":0,"time":10},{"index":1,"time":4}],"height":5}"#;
|
||||
let animation = AnimationMetadata::from_json(meta).unwrap();
|
||||
let frames = SpriteFrames {
|
||||
image: [7, 10],
|
||||
frame: animation.frame_size(7, 10),
|
||||
clip: animation.clip(2).unwrap().unwrap(),
|
||||
};
|
||||
assert_eq!(frames.region(0), [7, 10, 0, 0]);
|
||||
assert_eq!(frames.region(9), [7, 10, 0, 0]);
|
||||
assert_eq!(frames.region(10), [7, 10, 0, 5]);
|
||||
assert_eq!(frames.region(14), [7, 10, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dots_sit_on_the_bar_by_angle() {
|
||||
let styles = WaypointStyles(
|
||||
[(
|
||||
"minecraft:default".to_owned(),
|
||||
WaypointStyle {
|
||||
near_distance: 128,
|
||||
far_distance: 332,
|
||||
sprites: vec!["hud/locator_bar_dot/default_0".into()],
|
||||
},
|
||||
)]
|
||||
.into(),
|
||||
HashMap::new(),
|
||||
);
|
||||
let project = |p: [f64; 3]| [p[0], p[1], 0.5];
|
||||
let entity = |_: u128| None;
|
||||
let view = View {
|
||||
gui_width: 427,
|
||||
gui_height: 240,
|
||||
camera_uuid: 9,
|
||||
camera_entity_pos: [0.5, 64.0, 0.5],
|
||||
camera_pos: [0.5, 65.62, 0.5],
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
fov: 70.0,
|
||||
project: &project,
|
||||
entity: &entity,
|
||||
styles: &styles,
|
||||
tick: 0,
|
||||
};
|
||||
let mut w = ClientWaypoints::default();
|
||||
// Yaw 0 looks towards +z: a waypoint straight ahead sits in the middle.
|
||||
w.apply(&tracked(
|
||||
waypoint_operation::TRACK,
|
||||
1,
|
||||
WaypointTarget::Block([0, 65, 40]),
|
||||
));
|
||||
// One behind the camera and the camera's own waypoint are not drawn.
|
||||
w.apply(&tracked(
|
||||
waypoint_operation::TRACK,
|
||||
2,
|
||||
WaypointTarget::Block([0, 65, -40]),
|
||||
));
|
||||
w.apply(&tracked(
|
||||
waypoint_operation::TRACK,
|
||||
9,
|
||||
WaypointTarget::Block([0, 65, 40]),
|
||||
));
|
||||
let draws = w.draw(&view);
|
||||
assert_eq!(draws.len(), 1);
|
||||
let GuiDraw::Sprite { x, y, .. } = draws[0] else {
|
||||
panic!("{draws:?}");
|
||||
};
|
||||
assert_eq!((x, y), (209, 209));
|
||||
// 30 degrees to the right: floor(30 * 173 / 120) = 43 pixels.
|
||||
let east = ClientWaypoints {
|
||||
waypoints: vec![tracked(
|
||||
waypoint_operation::TRACK,
|
||||
3,
|
||||
WaypointTarget::Azimuth(30f32.to_radians()),
|
||||
)],
|
||||
};
|
||||
let GuiDraw::Sprite { x, .. } = east.draw(&view)[0] else {
|
||||
panic!();
|
||||
};
|
||||
assert_eq!(x, 209 + 43);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_bar_priorities() {
|
||||
use ContextualBar as B;
|
||||
assert_eq!(B::next(true, false, false, true, false), B::Locator);
|
||||
assert_eq!(B::next(true, false, false, true, true), B::Experience);
|
||||
assert_eq!(B::next(true, true, true, true, true), B::JumpableVehicle);
|
||||
assert_eq!(B::next(false, false, false, true, true), B::Experience);
|
||||
assert_eq!(B::next(false, false, false, false, false), B::Empty);
|
||||
// Creative players see the bar too.
|
||||
assert_eq!(B::next(true, false, false, false, false), B::Locator);
|
||||
}
|
||||
}
|
||||
@@ -326,14 +326,17 @@ struct Plan {
|
||||
impl Plan {
|
||||
#[cfg(test)]
|
||||
fn new(camera: [i32; 2], detail: i32, distance: u32) -> Self {
|
||||
Self::with_previous(camera, detail, distance, &Self::default(), 0)
|
||||
Self::with_previous(camera, detail, distance, &Self::default(), 0, false)
|
||||
}
|
||||
/// `reduced`: Reduced far detail (`graphics::FarDetail`), each level from
|
||||
/// half the distance.
|
||||
fn with_previous(
|
||||
camera: [i32; 2],
|
||||
detail: i32,
|
||||
distance: u32,
|
||||
previous: &Self,
|
||||
elevation: i32,
|
||||
reduced: bool,
|
||||
) -> Self {
|
||||
let range = distance.min(1024) as i32 * 16;
|
||||
let mut level = 0;
|
||||
@@ -349,25 +352,35 @@ impl Plan {
|
||||
for x in (camera[0] - range).div_euclid(size)..=(camera[0] + range).div_euclid(size) {
|
||||
let key = TileKey { x, z, level };
|
||||
result.roots.push(key);
|
||||
result.visit(key, camera, detail, range, previous);
|
||||
result.visit(key, camera, detail, range, previous, reduced);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
fn visit(&mut self, key: TileKey, camera: [i32; 2], detail: i32, range: i32, previous: &Self) {
|
||||
fn visit(
|
||||
&mut self,
|
||||
key: TileKey,
|
||||
camera: [i32; 2],
|
||||
detail: i32,
|
||||
range: i32,
|
||||
previous: &Self,
|
||||
reduced: bool,
|
||||
) {
|
||||
if key.distance(camera) > range {
|
||||
return;
|
||||
}
|
||||
self.nodes.insert(key);
|
||||
// Keep distant cells small on screen; finest tiles retain individual blocks.
|
||||
let threshold = key.size() * if previous.split.contains(&key) { 10 } else { 8 };
|
||||
// Keep distant cells small on screen; finest tiles retain individual
|
||||
// blocks. Reduced keeps them twice as large (a quarter of the cells).
|
||||
let split = if previous.split.contains(&key) { 10 } else { 8 };
|
||||
let threshold = key.size() * if reduced { split / 2 } else { split };
|
||||
if key.level > 0
|
||||
&& (key.distance(camera) < detail * 16 + 16
|
||||
|| key.distance(camera).max(self.elevation) < threshold)
|
||||
{
|
||||
self.split.insert(key);
|
||||
for child in key.children() {
|
||||
self.visit(child, camera, detail, range, previous);
|
||||
self.visit(child, camera, detail, range, previous, reduced);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -517,8 +530,10 @@ pub struct Lod {
|
||||
/// The plan or the covered columns changed: covered leaves need stand-ins.
|
||||
replanned: bool,
|
||||
invalidated: FxHashSet<(i32, i32)>,
|
||||
area: Option<([i32; 2], i32, u32, i32)>,
|
||||
area: Option<([i32; 2], i32, u32, i32, bool)>,
|
||||
plan: Plan,
|
||||
/// Reduced far detail (`set_reduced_detail`).
|
||||
reduced_detail: bool,
|
||||
pending: FxHashSet<TileKey>,
|
||||
sample_nearby: bool,
|
||||
detail_masks: FxHashMap<TileKey, [u64; 4]>,
|
||||
@@ -714,6 +729,7 @@ impl Lod {
|
||||
replanned: true,
|
||||
invalidated: FxHashSet::default(),
|
||||
area: None,
|
||||
reduced_detail: false,
|
||||
plan: Plan::default(),
|
||||
pending: FxHashSet::default(),
|
||||
sample_nearby: true,
|
||||
@@ -930,6 +946,11 @@ impl Lod {
|
||||
pub fn set_near_loading(&mut self, loading: bool) {
|
||||
self.near_loading = loading;
|
||||
}
|
||||
/// Reduced far detail (`graphics::FarDetail::Reduced`): the next update
|
||||
/// replans when it changes.
|
||||
pub fn set_reduced_detail(&mut self, reduced: bool) {
|
||||
self.reduced_detail = reduced;
|
||||
}
|
||||
fn held(&self) -> bool {
|
||||
self.small && self.near_loading
|
||||
}
|
||||
@@ -1021,11 +1042,19 @@ impl Lod {
|
||||
detail,
|
||||
distance,
|
||||
elevation,
|
||||
self.reduced_detail,
|
||||
);
|
||||
if self.area != Some(area) {
|
||||
crate::spans::time("lod_plan", || {
|
||||
self.area = Some(area);
|
||||
self.plan = Plan::with_previous(camera, detail, distance, &self.plan, elevation);
|
||||
self.plan = Plan::with_previous(
|
||||
camera,
|
||||
detail,
|
||||
distance,
|
||||
&self.plan,
|
||||
elevation,
|
||||
self.reduced_detail,
|
||||
);
|
||||
self.ready.retain(|key, _| self.plan.nodes.contains(key));
|
||||
self.detail_masks
|
||||
.retain(|key, _| self.plan.nodes.contains(key));
|
||||
@@ -1421,6 +1450,35 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::mesher::TerrainVertex;
|
||||
#[test]
|
||||
fn reduced_far_detail_only_merges_tiles_away_from_the_near_terrain() {
|
||||
let leaves = |plan: &Plan| {
|
||||
plan.nodes
|
||||
.iter()
|
||||
.filter(|k| !plan.split.contains(k))
|
||||
.count()
|
||||
};
|
||||
for camera in [[0, 0], [100, -40]] {
|
||||
for distance in [64, 128, 512, 1024] {
|
||||
let full = Plan::new(camera, 12, distance);
|
||||
let reduced = Plan::with_previous(camera, 12, distance, &Plan::default(), 0, true);
|
||||
// Never finer than Full anywhere, and as fine next to the
|
||||
// full-detail area, whose edge the finest tiles match.
|
||||
assert!(reduced.split.is_subset(&full.split));
|
||||
assert_eq!(reduced.roots, full.roots);
|
||||
for key in reduced.nodes.iter().filter(|k| !reduced.split.contains(k)) {
|
||||
if key.distance(camera) < 12 * 16 + 16 {
|
||||
assert_eq!(key.level, 0, "{key:?}");
|
||||
}
|
||||
}
|
||||
let (full, reduced) = (leaves(&full), leaves(&reduced));
|
||||
assert!(
|
||||
reduced * 2 < full,
|
||||
"{reduced} of {full} tiles at {distance}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn far_plan_is_bounded_and_near_tiles_align_with_chunks() {
|
||||
for camera in [[0, 0], [-1, -17], [1023, -4097]] {
|
||||
for distance in [64, 256, 1024] {
|
||||
@@ -1453,7 +1511,7 @@ mod tests {
|
||||
#[test]
|
||||
fn aerial_distance_avoids_unseen_full_detail_outside_the_near_area() {
|
||||
let ground = Plan::new([0, 0], 12, 1024);
|
||||
let high = Plan::with_previous([0, 0], 12, 1024, &Plan::default(), 1024);
|
||||
let high = Plan::with_previous([0, 0], 12, 1024, &Plan::default(), 1024, false);
|
||||
assert!(high.nodes.len() < ground.nodes.len());
|
||||
assert!(
|
||||
high.nodes.contains(&TileKey {
|
||||
@@ -1505,7 +1563,7 @@ mod tests {
|
||||
#[test]
|
||||
fn detail_survives_movement_across_a_split_threshold() {
|
||||
let before = Plan::new([0, 0], 2, 256);
|
||||
let after = Plan::with_previous([16, 0], 2, 256, &before, 0);
|
||||
let after = Plan::with_previous([16, 0], 2, 256, &before, 0, false);
|
||||
let mut retained = 0;
|
||||
for key in &before.split {
|
||||
if key.distance([16, 0]) < key.size() * 10 {
|
||||
@@ -1804,7 +1862,7 @@ mod tests {
|
||||
// Exercise the actual worker/update seam: a parent stays displayed
|
||||
// until all four child meshes can replace it in the same update.
|
||||
let camera = [-256.0, 100.0, -256.0];
|
||||
lod.area = Some(([-16, -16], 2, 64, 0));
|
||||
lod.area = Some(([-16, -16], 2, 64, 0, false));
|
||||
lod.plan.roots = vec![key];
|
||||
lod.plan.nodes.insert(key);
|
||||
// As a replan does (`update` only selects after a change).
|
||||
|
||||
@@ -792,6 +792,9 @@ pub struct Mob {
|
||||
pub on_ground: bool,
|
||||
pub horizontal_collision: bool,
|
||||
pub vertical_collision_below: bool,
|
||||
/// `Entity.needsSync`: knocked back, pushed or jumped since its server
|
||||
/// last sent it, so the next update goes out at once.
|
||||
pub needs_sync: bool,
|
||||
pub health: f32,
|
||||
pub hurt_time: i32,
|
||||
pub invulnerable: i32,
|
||||
@@ -1361,8 +1364,10 @@ impl PendingPushes {
|
||||
}
|
||||
/// Adds the pending pushes aimed at `m` to its velocity, in push order.
|
||||
pub fn apply_mob_pushes(m: &mut Mob, pending: &mut PendingPushes) {
|
||||
// Entity.push(x, y, z) sets needsSync.
|
||||
let mut add = |v: Vec3| {
|
||||
m.velocity = Vec3::new(m.velocity.x + v.x, m.velocity.y + v.y, m.velocity.z + v.z);
|
||||
m.needs_sync = true;
|
||||
};
|
||||
if !pending.list.is_empty() {
|
||||
pending.list.retain(|(id, v)| {
|
||||
@@ -1529,6 +1534,7 @@ impl Mob {
|
||||
on_ground: false,
|
||||
horizontal_collision: false,
|
||||
vertical_collision_below: false,
|
||||
needs_sync: false,
|
||||
hurt_time: 0,
|
||||
invulnerable: 0,
|
||||
death_time: 0,
|
||||
@@ -1769,6 +1775,7 @@ impl Mob {
|
||||
if power <= 0.0 {
|
||||
return;
|
||||
}
|
||||
self.needs_sync = true;
|
||||
while xd * xd + zd * zd < f64::from(1.0e-5_f32) {
|
||||
xd = (self.random.next_double() - self.random.next_double()) * 0.01;
|
||||
zd = (self.random.next_double() - self.random.next_double()) * 0.01;
|
||||
|
||||
@@ -832,6 +832,9 @@ impl Mob {
|
||||
};
|
||||
}
|
||||
pub fn jump_from_ground(&mut self, w: &MobWorld) {
|
||||
// LivingEntity.jumpFromGround (and the rabbit's and cubes' own,
|
||||
// which call it or do the same): needsSync.
|
||||
self.needs_sync = true;
|
||||
if self.species() == super::Species::Rabbit {
|
||||
super::rabbit::jump(self, w);
|
||||
return;
|
||||
|
||||
@@ -310,6 +310,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn love_ticks_survive_a_save() {
|
||||
// Animal.addAdditionalSaveData / readAdditionalSaveData: "InLove" (#1396).
|
||||
let mut m = rabbit();
|
||||
m.extra.in_love = 570;
|
||||
let tag = crate::entity_nbt::mob_to_nbt(&m, [0, 0, 0, 1], None);
|
||||
assert_eq!(tag.get("InLove"), Some(&horizon_world::nbt::Tag::Int(570)));
|
||||
let types = HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
|
||||
let back = crate::entity_nbt::mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
assert_eq!(back.extra.in_love, 570);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn landing_waits_then_real_hop_clock_moves_the_model() {
|
||||
let mut m = rabbit();
|
||||
|
||||
@@ -351,6 +351,9 @@ fn spawns_far_from_player(s: Species, category: Category) -> bool {
|
||||
}
|
||||
|
||||
/// What natural spawning reads from a level beyond the spawn rules.
|
||||
/// An entity type's species, width, height and category.
|
||||
pub type MobType = (Species, f32, f32, Category);
|
||||
|
||||
pub trait NaturalLevel: SpawnLevel {
|
||||
fn min_y(&self) -> i32;
|
||||
/// `getMaxY()`: the highest buildable block.
|
||||
@@ -359,7 +362,7 @@ pub trait NaturalLevel: SpawnLevel {
|
||||
fn ambient_light(&self) -> f32;
|
||||
fn has_ceiling(&self) -> bool;
|
||||
/// The entity type's species, width, height and category.
|
||||
fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)>;
|
||||
fn mob_type(&self, entity: &str) -> Option<MobType>;
|
||||
/// The `natural_mob_spawns` lists by category at a position.
|
||||
fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap<String, Vec<SpawnEntry>>>;
|
||||
/// The `natural_mob_spawns` spawn cost of a type at a position.
|
||||
@@ -376,9 +379,9 @@ pub trait NaturalLevel: SpawnLevel {
|
||||
wanted: &dyn Fn(&str) -> bool,
|
||||
) -> Vec<StructureAt>;
|
||||
fn structure_spawns(&self) -> &StructureSpawns;
|
||||
/// `getNearestPlayer(x, y, z, -1, false)`: the squared distance to the
|
||||
/// nearest player that is not a spectator.
|
||||
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64>;
|
||||
/// Where the players `getNearestPlayer(x, y, z, -1, false)` measures to
|
||||
/// are: every one that is not a spectator.
|
||||
fn nearest_player_positions(&self) -> &[[f64; 3]];
|
||||
/// The world spawn point, when it is in this dimension.
|
||||
fn respawn(&self) -> Option<[i32; 3]>;
|
||||
/// `ServerLevel.canSpawnEntitiesInChunk`.
|
||||
@@ -870,9 +873,13 @@ impl SpawnState {
|
||||
/// `ChunkMap.getPlayersCloseForSpawning`: none past the natural spawn
|
||||
/// chunk tracker's range of 8 chunks from every player, else the
|
||||
/// non-spectators within 128 blocks of the chunk's centre.
|
||||
fn players_near(&mut self, cx: i32, cz: i32) -> &[usize] {
|
||||
let players = &self.players;
|
||||
self.players_near.entry((cx, cz)).or_insert_with(|| {
|
||||
fn players_near<'a>(
|
||||
near: &'a mut FxHashMap<(i32, i32), Vec<usize>>,
|
||||
players: &[SpawnPlayer],
|
||||
cx: i32,
|
||||
cz: i32,
|
||||
) -> &'a [usize] {
|
||||
near.entry((cx, cz)).or_insert_with(|| {
|
||||
let tracked = players.iter().any(|p| {
|
||||
let (px, pz) = (
|
||||
(p.position[0].floor() as i32) >> 4,
|
||||
@@ -896,8 +903,7 @@ impl SpawnState {
|
||||
})
|
||||
}
|
||||
fn add_local(&mut self, cx: i32, cz: i32, category: Category) {
|
||||
let near = self.players_near(cx, cz).to_vec();
|
||||
for p in near {
|
||||
for &p in Self::players_near(&mut self.players_near, &self.players, cx, cz) {
|
||||
self.player_counts.entry(p).or_insert([0; 8])[category.index()] += 1;
|
||||
}
|
||||
}
|
||||
@@ -908,10 +914,10 @@ impl SpawnState {
|
||||
}
|
||||
/// `canSpawnForCategoryLocal` (`LocalMobCapCalculator.canSpawn`).
|
||||
pub fn can_spawn_local(&mut self, category: Category, cx: i32, cz: i32) -> bool {
|
||||
let near = self.players_near(cx, cz).to_vec();
|
||||
near.into_iter().any(|p| {
|
||||
let near = Self::players_near(&mut self.players_near, &self.players, cx, cz);
|
||||
near.iter().any(|p| {
|
||||
self.player_counts
|
||||
.get(&p)
|
||||
.get(p)
|
||||
.is_none_or(|c| c[category.index()] < category.max())
|
||||
})
|
||||
}
|
||||
@@ -1080,9 +1086,11 @@ pub fn spawn_category_for_position<L: NaturalLevel, R: RandomSource>(
|
||||
}
|
||||
let mut spawned: Vec<Aabb> = Vec::new();
|
||||
let mut cluster = 0u32;
|
||||
let mut nearest = Nearest::new(start);
|
||||
for _ in 0..3 {
|
||||
let (mut x, mut z) = (x0, z0);
|
||||
let mut current: Option<SpawnEntry> = None;
|
||||
// The group's entry, its type and where it was picked.
|
||||
let mut current: Option<(SpawnEntry, Option<MobType>, [i32; 3])> = None;
|
||||
let mut group_index = 0u32;
|
||||
let mut max = (random.next_float() * 4.0).ceil() as i32;
|
||||
let mut ll = 0;
|
||||
@@ -1092,16 +1100,15 @@ pub fn spawn_category_for_position<L: NaturalLevel, R: RandomSource>(
|
||||
z += random.next_int_bound(6).unwrap_or(0) - random.next_int_bound(6).unwrap_or(0);
|
||||
let (xx, zz) = (f64::from(x) + 0.5, f64::from(z) + 0.5);
|
||||
trace(Event::Try(x, z));
|
||||
let Some(distance) = l.nearest_player_distance_sqr(xx, f64::from(y), zz) else {
|
||||
let Some(distance) = nearest.distance_sqr(l, xx, f64::from(y), zz) else {
|
||||
continue;
|
||||
};
|
||||
if !right_distance(l, chunk, [x, y, z], distance) {
|
||||
continue;
|
||||
}
|
||||
if current.is_none() {
|
||||
let reduced = l.biome_tag("minecraft:reduce_water_ambient_spawns", x, y, z);
|
||||
let list = if category == Category::WaterAmbient
|
||||
&& reduced
|
||||
&& l.biome_tag("minecraft:reduce_water_ambient_spawns", x, y, z)
|
||||
&& random.next_float() < 0.98
|
||||
{
|
||||
None
|
||||
@@ -1112,21 +1119,32 @@ pub fn spawn_category_for_position<L: NaturalLevel, R: RandomSource>(
|
||||
break;
|
||||
};
|
||||
max = sample_count(&entry, random) as i32;
|
||||
current = Some(entry);
|
||||
let kind = l.mob_type(&entry.entity);
|
||||
current = Some((entry, kind, [x, y, z]));
|
||||
}
|
||||
let entry = current.as_ref().unwrap_or_else(|| unreachable!());
|
||||
if !valid_position_for_type(l, random, category, entry, [x, y, z], distance) {
|
||||
let (entry, kind, picked_at) = current.as_ref().unwrap_or_else(|| unreachable!());
|
||||
// The list the entry was picked from holds it where it was picked.
|
||||
let listed = *picked_at == [x, y, z];
|
||||
let valid = valid_position_for_type(
|
||||
l,
|
||||
random,
|
||||
category,
|
||||
entry,
|
||||
*kind,
|
||||
listed,
|
||||
[x, y, z],
|
||||
distance,
|
||||
);
|
||||
let Some((species, width, height, type_category)) = (*kind).filter(|_| valid) else {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let passed = state.can_spawn(l, &entry.entity, [x, y, z]);
|
||||
trace(Event::Test(entry.entity.clone(), [x, y, z], passed));
|
||||
if !passed {
|
||||
continue;
|
||||
}
|
||||
// getMobForSpawn: a type the game cannot create ends the pass.
|
||||
let Some((species, width, height, type_category)) = l.mob_type(&entry.entity) else {
|
||||
return;
|
||||
};
|
||||
// getMobForSpawn (a type the game cannot create, which would end
|
||||
// the pass, never passes isValidSpawnPostitionForType).
|
||||
let yaw = random.next_float() * 360.0;
|
||||
let bounds = spawn_box(xx, f64::from(y), zz, width, height);
|
||||
let despawn = f64::from(type_category.despawn_distance().pow(2));
|
||||
@@ -1167,6 +1185,52 @@ pub fn spawn_category_for_position<L: NaturalLevel, R: RandomSource>(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `getNearestPlayer(x, y, z, -1, false)`'s squared distance for the
|
||||
/// attempts of one start. The spawn checks only compare it with 24² and the
|
||||
/// despawn distances (128² at most), so it is measured only to the players
|
||||
/// that can be within 128 blocks of an attempt near the start: when none is,
|
||||
/// the nearest reads as infinitely far, which every check answers as it
|
||||
/// answers the real distance. An attempt that wanders farther than
|
||||
/// `MARGIN` from the start measures every player.
|
||||
struct Nearest {
|
||||
/// The players that can be within 128 blocks, once an attempt asked.
|
||||
nearby: Option<Vec<[f64; 3]>>,
|
||||
start: (f64, f64),
|
||||
}
|
||||
impl Nearest {
|
||||
const REACH: f64 = 128.0;
|
||||
const MARGIN: f64 = 64.0;
|
||||
fn new(start: [i32; 3]) -> Self {
|
||||
Self {
|
||||
nearby: None,
|
||||
start: (f64::from(start[0]) + 0.5, f64::from(start[2]) + 0.5),
|
||||
}
|
||||
}
|
||||
fn distance_sqr<L: NaturalLevel>(&mut self, l: &L, x: f64, y: f64, z: f64) -> Option<f64> {
|
||||
let all = l.nearest_player_positions();
|
||||
if all.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let distance = |p: &[f64; 3]| (p[0] - x).powi(2) + (p[1] - y).powi(2) + (p[2] - z).powi(2);
|
||||
let (sx, sz) = self.start;
|
||||
if (x - sx).abs() > Self::MARGIN || (z - sz).abs() > Self::MARGIN {
|
||||
return all.iter().map(distance).reduce(f64::min);
|
||||
}
|
||||
// The others are farther than REACH, across, from every point within
|
||||
// MARGIN of the start.
|
||||
let nearby = self.nearby.get_or_insert_with(|| {
|
||||
all.iter()
|
||||
.filter(|p| {
|
||||
let dx = ((p[0] - sx).abs() - Self::MARGIN).max(0.0);
|
||||
let dz = ((p[2] - sz).abs() - Self::MARGIN).max(0.0);
|
||||
dx * dx + dz * dz <= Self::REACH * Self::REACH
|
||||
})
|
||||
.copied()
|
||||
.collect()
|
||||
});
|
||||
Some(nearby.iter().map(distance).fold(f64::INFINITY, f64::min))
|
||||
}
|
||||
}
|
||||
/// `isRightDistanceToPlayerAndSpawnPoint`.
|
||||
fn right_distance<L: NaturalLevel>(l: &L, chunk: (i32, i32), pos: [i32; 3], distance: f64) -> bool {
|
||||
if distance <= 576.0 {
|
||||
@@ -1185,16 +1249,20 @@ fn right_distance<L: NaturalLevel>(l: &L, chunk: (i32, i32), pos: [i32; 3], dist
|
||||
let (cx, cz) = (pos[0] >> 4, pos[2] >> 4);
|
||||
(cx, cz) == chunk || l.can_spawn_in_chunk(cx, cz)
|
||||
}
|
||||
/// `isValidSpawnPostitionForType`.
|
||||
/// `isValidSpawnPostitionForType`, for the entry's type (`mob_type`).
|
||||
/// `listed`: the position is where the entry was picked from `mobsAt`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn valid_position_for_type<L: NaturalLevel, R: RandomSource>(
|
||||
l: &L,
|
||||
random: &mut R,
|
||||
category: Category,
|
||||
entry: &SpawnEntry,
|
||||
kind: Option<MobType>,
|
||||
listed: bool,
|
||||
pos: [i32; 3],
|
||||
distance: f64,
|
||||
) -> bool {
|
||||
let Some((species, width, height, type_category)) = l.mob_type(&entry.entity) else {
|
||||
let Some((species, width, height, type_category)) = kind else {
|
||||
return false;
|
||||
};
|
||||
if type_category == Category::Misc {
|
||||
@@ -1205,9 +1273,10 @@ fn valid_position_for_type<L: NaturalLevel, R: RandomSource>(
|
||||
return false;
|
||||
}
|
||||
let [x, y, z] = pos;
|
||||
if !mobs_at(l, category, x, y, z)
|
||||
.iter()
|
||||
.any(|e| same_spawner(e, entry))
|
||||
if !listed
|
||||
&& !mobs_at(l, category, x, y, z)
|
||||
.iter()
|
||||
.any(|e| same_spawner(e, entry))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -337,7 +337,10 @@ impl PlayerMotion {
|
||||
self.air = extra
|
||||
.and_then(|t| crate::save::number(t.get("Air")))
|
||||
.unwrap_or(300) as i32;
|
||||
self.fall_distance = level.fall_distance;
|
||||
self.set_health(level.health, false)?;
|
||||
// LivingEntity.readAdditionalSaveData: internalSetAbsorptionAmount.
|
||||
self.internal_set_absorption(level.absorption)?;
|
||||
self.health.food.food = level.food;
|
||||
self.health.food.saturation = level.saturation;
|
||||
self.flying = level.flying;
|
||||
@@ -1029,9 +1032,22 @@ impl MotionWorld<'_, '_, '_> {
|
||||
}
|
||||
/// `Entity.applyEffectsFromBlocks` for the blocks Horizon's player reacts
|
||||
/// to inside its movement path: powder snow (`PowderSnowBlock.entityInside`
|
||||
/// and the FREEZE / EXTINGUISH effects), cobwebs and sweet berry bushes. Pressure plates, tripwires and
|
||||
/// rails are the world tick's scan.
|
||||
/// and the FREEZE / EXTINGUISH effects), cobwebs, sweet berry bushes,
|
||||
/// cactus and lit campfires, and the magma block it stands on
|
||||
/// (`stepOn`). Pressure plates, tripwires and rails are the world tick's
|
||||
/// scan.
|
||||
pub fn apply_effects_from_blocks(&mut self) -> Result<()> {
|
||||
self.effects_from_blocks(false)
|
||||
}
|
||||
/// What `applyEffectsFromBlocks` does to a server's player along the
|
||||
/// moves its client sent since `from` (in its history): the damage of
|
||||
/// magma, cactus, lit campfires and grown sweet berry bushes. Its client
|
||||
/// moves it, so the slowing and the rest are the client's.
|
||||
pub fn server_block_damage(&mut self, from: Vec3) -> Result<()> {
|
||||
self.player.old_position = from;
|
||||
self.effects_from_blocks(true)
|
||||
}
|
||||
fn effects_from_blocks(&mut self, damage_only: bool) -> Result<()> {
|
||||
use horizon_world::entity_move::MovementRecord;
|
||||
let mut movements: Vec<MovementRecord> =
|
||||
self.player.history.records().iter().copied().collect();
|
||||
@@ -1056,6 +1072,19 @@ impl MotionWorld<'_, '_, '_> {
|
||||
if self.player.spectator {
|
||||
return Ok(());
|
||||
}
|
||||
// Block.stepOn for the block under an entity on the ground:
|
||||
// MagmaBlock's hot floor hurts unless it steps carefully (sneaks).
|
||||
if self.player.ground.on_ground {
|
||||
let on = self.legacy_on_position()?;
|
||||
let magma = self
|
||||
.assets
|
||||
.registry
|
||||
.state(self.block(on))
|
||||
.is_some_and(|s| s.name == "minecraft:magma_block");
|
||||
if magma && !self.player.shift_key_down {
|
||||
self.environment_hurt("minecraft:hot_floor", 1.0)?;
|
||||
}
|
||||
}
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
let mut entered = Vec::new();
|
||||
for movement in &movements {
|
||||
@@ -1081,6 +1110,7 @@ impl MotionWorld<'_, '_, '_> {
|
||||
&mut visited,
|
||||
&mut entered,
|
||||
budget,
|
||||
damage_only,
|
||||
)?;
|
||||
from = to;
|
||||
}
|
||||
@@ -1093,11 +1123,19 @@ impl MotionWorld<'_, '_, '_> {
|
||||
&mut visited,
|
||||
&mut entered,
|
||||
16,
|
||||
damage_only,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if budget <= 0 {
|
||||
self.check_inside_blocks(movement.to, movement.to, &mut visited, &mut entered, 1)?;
|
||||
self.check_inside_blocks(
|
||||
movement.to,
|
||||
movement.to,
|
||||
&mut visited,
|
||||
&mut entered,
|
||||
1,
|
||||
damage_only,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// InsideBlockEffectApplier.StepBasedCollector: the blocks entered
|
||||
@@ -1137,6 +1175,7 @@ impl MotionWorld<'_, '_, '_> {
|
||||
visited: &mut std::collections::HashSet<i64>,
|
||||
entered: &mut Vec<(i32, BlockPos)>,
|
||||
max_iterations: i32,
|
||||
damage_only: bool,
|
||||
) -> Result<i32> {
|
||||
let deflated = self.player.dimensions.bounds(to).deflate(Vec3::new(
|
||||
f64::from(1.0e-5_f32),
|
||||
@@ -1213,6 +1252,33 @@ impl MotionWorld<'_, '_, '_> {
|
||||
}
|
||||
};
|
||||
if inside && visited.insert(p.pack()) {
|
||||
// CactusBlock and CampfireBlock.entityInside (`entity.hurt`).
|
||||
match name {
|
||||
"minecraft:cactus" => self.environment_hurt("minecraft:cactus", 1.0)?,
|
||||
"minecraft:campfire" | "minecraft:soul_campfire" => {
|
||||
let lit = self
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("lit"))
|
||||
.is_some_and(|v| v == "true");
|
||||
let damage = if name == "minecraft:campfire" {
|
||||
1.0
|
||||
} else {
|
||||
2.0
|
||||
};
|
||||
if lit {
|
||||
self.environment_hurt("minecraft:campfire", damage)?;
|
||||
}
|
||||
}
|
||||
"minecraft:sweet_berry_bush" if damage_only => {
|
||||
self.sweet_berry_bush_damage(state)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if damage_only {
|
||||
continue;
|
||||
}
|
||||
match name {
|
||||
"minecraft:powder_snow" => {
|
||||
self.powder_snow_inside(p)?;
|
||||
@@ -1261,6 +1327,10 @@ impl MotionWorld<'_, '_, '_> {
|
||||
/// is at least 0.003 on X or Z.
|
||||
fn sweet_berry_bush_inside(&mut self, state: BlockStateId) -> Result<()> {
|
||||
self.make_stuck_in_block(Vec3::new(f64::from(0.8_f32), 0.75, f64::from(0.8_f32)));
|
||||
self.sweet_berry_bush_damage(state)
|
||||
}
|
||||
/// The server's half of `SweetBerryBushBlock.entityInside`: the prick.
|
||||
fn sweet_berry_bush_damage(&mut self, state: BlockStateId) -> Result<()> {
|
||||
let age = self
|
||||
.assets
|
||||
.registry
|
||||
|
||||
@@ -6,7 +6,7 @@ use horizon_world::{
|
||||
living_fluid::{LivingFluidWorld, TravelFluidTag},
|
||||
};
|
||||
impl MotionWorld<'_, '_, '_> {
|
||||
fn environment_hurt(&mut self, source: &str, damage: f32) -> Result<()> {
|
||||
pub(super) fn environment_hurt(&mut self, source: &str, damage: f32) -> Result<()> {
|
||||
if self.player.invulnerable || self.player.spectator {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::{
|
||||
keybinds::{self, CATEGORIES, KeyBinds, MAPPINGS},
|
||||
lang::{self, LanguageInfo},
|
||||
settings::{SOUND_SOURCES, Settings},
|
||||
shader_pack::PackConfig,
|
||||
text::{self, Style, Text},
|
||||
ui,
|
||||
};
|
||||
@@ -864,6 +865,8 @@ pub enum Page {
|
||||
Mods,
|
||||
/// The shader packs in `shaderpacks/`: pick one, open the folder.
|
||||
ShaderPacks,
|
||||
/// Interactive options and profiles for the active shader pack.
|
||||
ShaderPackOptions,
|
||||
/// Horizon's graphics settings (`graphics_ui`).
|
||||
Graphics,
|
||||
GraphicsAdvanced,
|
||||
@@ -871,9 +874,13 @@ pub enum Page {
|
||||
Controller,
|
||||
PadBinds,
|
||||
Extras,
|
||||
/// CreditsAndAttributionScreen.
|
||||
Credits,
|
||||
/// Horizon Settings: every page Horizon adds, as a mod's settings
|
||||
/// screen hangs off vanilla's Options.
|
||||
Horizon,
|
||||
/// AI Agent: agent control on or off, whether an agent is connected, the connection details.
|
||||
Agent,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Act {
|
||||
@@ -894,6 +901,14 @@ enum Act {
|
||||
/// Pick shader pack `i` of `OptionsUi::shader_packs` (again: off).
|
||||
ToggleShaderPack(usize),
|
||||
OpenShaderPackFolder,
|
||||
/// Credits & Attribution's Credits button (WinScreen without the poem).
|
||||
Credits,
|
||||
CopyAgentDetails,
|
||||
ShaderPackProfile,
|
||||
ShaderPackSubscreen(String),
|
||||
ShaderPackBackScreen,
|
||||
ShaderPackOption(usize),
|
||||
ResetShaderPackOptions,
|
||||
}
|
||||
enum WKind {
|
||||
Button {
|
||||
@@ -906,6 +921,11 @@ enum WKind {
|
||||
value: f32,
|
||||
opt: Opt,
|
||||
},
|
||||
ShaderOptionSlider {
|
||||
label: Text,
|
||||
value: f32,
|
||||
index: usize,
|
||||
},
|
||||
Text {
|
||||
text: Text,
|
||||
color: u32,
|
||||
@@ -943,6 +963,13 @@ pub enum Event {
|
||||
OpenModFolder,
|
||||
/// Create `shaderpacks/` if needed and open it.
|
||||
OpenShaderPackFolder,
|
||||
/// Roll the credits (`CreditsAndAttributionScreen.openCreditsScreen`).
|
||||
Credits,
|
||||
/// Copy how an agent connects (the AI Agent page).
|
||||
CopyAgentDetails,
|
||||
/// The shader pack settings page closed with changes: they are saved,
|
||||
/// the pack is built again with them.
|
||||
ReloadShaderPack,
|
||||
}
|
||||
/// `SliderableValueSet.applyValueImmediately`: these apply 600 ms after
|
||||
/// the last change (`OptionInstanceSliderButton.delayedApplyAt`).
|
||||
@@ -998,6 +1025,15 @@ pub struct OptionsUi {
|
||||
pub shader_packs: crate::shader_pack::Folder,
|
||||
/// The pack that failed to load last and why (set by the game).
|
||||
pub shader_pack_error: Option<(String, String)>,
|
||||
/// The AI Agent page's status lines (set by the game while it shows).
|
||||
pub agent_status: Vec<String>,
|
||||
/// Loaded interactive configuration for the currently selected shader pack.
|
||||
pub shader_pack_config: Option<PackConfig>,
|
||||
/// The shader pack option slider being dragged.
|
||||
pub(crate) dragging_shader_option: Option<usize>,
|
||||
/// `shader_pack_config` changed since the settings page opened: leaving
|
||||
/// it saves the values and reloads the pack.
|
||||
shader_pack_edited: bool,
|
||||
}
|
||||
impl Default for OptionsUi {
|
||||
fn default() -> Self {
|
||||
@@ -1028,6 +1064,10 @@ impl Default for OptionsUi {
|
||||
graphics: Default::default(),
|
||||
shader_packs: Default::default(),
|
||||
shader_pack_error: None,
|
||||
agent_status: Vec::new(),
|
||||
shader_pack_config: None,
|
||||
dragging_shader_option: None,
|
||||
shader_pack_edited: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1086,6 +1126,7 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Page::Extras => {
|
||||
"Optional additions, all off by default: the game stays vanilla until you turn one on."
|
||||
}
|
||||
Page::Agent => "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
|
||||
Page::Mods => {
|
||||
"Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on."
|
||||
}
|
||||
@@ -1107,6 +1148,11 @@ fn pairs(opts: &[Opt]) -> Vec<Row> {
|
||||
.collect()
|
||||
}
|
||||
impl OptionsUi {
|
||||
/// A switch of `horizon-extras.txt` as the Extras page draws it: "Caption: On".
|
||||
fn extra_button(&self, s: &Settings, item: crate::extras::Item) -> Widget {
|
||||
let label = format!("{}: {}", item.caption(), item.value(&s.extras));
|
||||
button(0.0, 0.0, 150.0, Text::literal(label), Act::Extra(item))
|
||||
}
|
||||
fn settings_search_active(&self) -> bool {
|
||||
self.settings_search.focused
|
||||
&& matches!(self.page(), Page::Video | Page::Controls | Page::KeyBinds)
|
||||
@@ -1152,7 +1198,7 @@ impl OptionsUi {
|
||||
}
|
||||
/// Pick shader pack `i` of the folder, or turn it off when it is the
|
||||
/// one running.
|
||||
pub(crate) fn toggle_shader_pack(&self, s: &mut Settings, i: usize) {
|
||||
pub(crate) fn toggle_shader_pack(&mut self, s: &mut Settings, i: usize) {
|
||||
let Some((id, Ok(_))) = self.shader_packs.packs.get(i) else {
|
||||
return;
|
||||
};
|
||||
@@ -1162,6 +1208,83 @@ impl OptionsUi {
|
||||
} else {
|
||||
id.clone()
|
||||
};
|
||||
self.sync_shader_pack_config(s);
|
||||
}
|
||||
/// Ensure `shader_pack_config` matches `s.horizon.shader_pack`.
|
||||
pub fn sync_shader_pack_config(&mut self, s: &Settings) {
|
||||
let id = s.horizon.shader_pack.as_str();
|
||||
if id.is_empty() {
|
||||
self.shader_pack_config = None;
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.shader_pack_config
|
||||
.as_ref()
|
||||
.is_some_and(|cfg| cfg.id == id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// A pack whose options can't be read has none, rather than being
|
||||
// read again every frame.
|
||||
let config = PackConfig::load(&self.shader_pack_dir(), id).unwrap_or_else(|e| {
|
||||
log::warn!("Horizon: shader pack {id} options: {e}");
|
||||
PackConfig {
|
||||
id: id.to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
self.shader_pack_config = Some(config);
|
||||
self.shader_pack_edited = false;
|
||||
}
|
||||
/// The picked pack has options or profiles to show.
|
||||
fn shader_pack_has_settings(&self, s: &Settings) -> bool {
|
||||
self.shader_pack_config.as_ref().is_some_and(|c| {
|
||||
!s.horizon.shader_pack.is_empty()
|
||||
&& c.id == s.horizon.shader_pack
|
||||
&& (!c.options.is_empty() || !c.profiles.is_empty())
|
||||
})
|
||||
}
|
||||
fn shader_pack_dir(&self) -> std::path::PathBuf {
|
||||
if self.shader_packs.dir.as_os_str().is_empty() {
|
||||
crate::shader_pack::folder()
|
||||
} else {
|
||||
self.shader_packs.dir.clone()
|
||||
}
|
||||
}
|
||||
/// A file or folder dropped on the window: on the Shader Packs page (as
|
||||
/// Iris's `ShaderPackScreen.onFilesDrop`), a pack copied into
|
||||
/// `shaderpacks/` and picked. `None` elsewhere; the caller saves the
|
||||
/// settings and loads the pack on `Some(Ok)`.
|
||||
pub fn drop_shader_pack(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
path: &std::path::Path,
|
||||
) -> Option<Result<(), String>> {
|
||||
if self.page() != Page::ShaderPacks {
|
||||
return None;
|
||||
}
|
||||
let dir = self.shader_pack_dir();
|
||||
Some(crate::shader_pack::import_pack(path, &dir).map(|id| {
|
||||
self.shader_packs = Default::default();
|
||||
self.shader_packs.poll(dir);
|
||||
s.horizon.shader_pack = id;
|
||||
self.sync_shader_pack_config(s);
|
||||
}))
|
||||
}
|
||||
/// Change the shader pack's options, applied when its page closes.
|
||||
fn edit_shader_pack(&mut self, edit: impl FnOnce(&mut PackConfig)) {
|
||||
if let Some(cfg) = &mut self.shader_pack_config {
|
||||
edit(cfg);
|
||||
self.shader_pack_edited = true;
|
||||
}
|
||||
}
|
||||
/// Save the active shader pack options to `shaderpacks/<id>.txt`.
|
||||
fn save_shader_pack_config(&self) {
|
||||
if let Some(cfg) = &self.shader_pack_config
|
||||
&& let Err(e) = cfg.save(&self.shader_pack_dir())
|
||||
{
|
||||
log::warn!("Horizon: shader pack {} options: {e}", cfg.id);
|
||||
}
|
||||
}
|
||||
pub(crate) fn shader_pack_failed(&self, id: &str) -> bool {
|
||||
self.shader_pack_error
|
||||
@@ -1195,11 +1318,14 @@ impl OptionsUi {
|
||||
Page::Packs => "resourcePack.title",
|
||||
Page::Mods => "Mods",
|
||||
Page::ShaderPacks => "Shader Packs",
|
||||
Page::ShaderPackOptions => "Shader Pack Settings",
|
||||
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
|
||||
Page::Controller => "Controller Settings",
|
||||
Page::PadBinds => "Controller Binds",
|
||||
Page::Extras => "Horizon Extras",
|
||||
Page::Credits => "credits_and_attribution.screen.title",
|
||||
Page::Horizon => "Horizon Settings",
|
||||
Page::Agent => "AI Agent",
|
||||
}
|
||||
}
|
||||
fn scrollbar_x(&self, width: f32) -> f32 {
|
||||
@@ -1316,8 +1442,31 @@ impl OptionsUi {
|
||||
open("Extras...", Page::Extras),
|
||||
Some(open("Mods...", Page::Mods)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
if s.extras.no_ai {
|
||||
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
|
||||
} else {
|
||||
Row::Buttons(
|
||||
open("AI Agent...", Page::Agent),
|
||||
Some(self.extra_button(s, crate::extras::Item::NoAi)),
|
||||
)
|
||||
},
|
||||
]
|
||||
}
|
||||
Page::Agent => {
|
||||
let mut rows = vec![Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::AgentControl),
|
||||
Some(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Copy Connection Details"),
|
||||
Act::CopyAgentDetails,
|
||||
)),
|
||||
)];
|
||||
rows.extend(self.agent_status.iter().cloned().map(Row::Note));
|
||||
rows
|
||||
}
|
||||
Page::Video => {
|
||||
// Horizon's graphics screens, Simple and Advanced.
|
||||
let mut rows = vec![Row::Buttons(
|
||||
@@ -1544,6 +1693,87 @@ impl OptionsUi {
|
||||
}
|
||||
rows
|
||||
}
|
||||
Page::ShaderPackOptions => {
|
||||
let Some(cfg) = &self.shader_pack_config else {
|
||||
return vec![Row::Header("No configurable options for this shader pack")];
|
||||
};
|
||||
let screen = cfg.current_screen();
|
||||
let mut rows = vec![Row::Note(format!("{} — {}", cfg.id, screen.label))];
|
||||
if cfg.active_screen.is_some() {
|
||||
rows.push(Row::Buttons(
|
||||
button(
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal("Back to Main Screen"),
|
||||
Act::ShaderPackBackScreen,
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
let mut widgets = Vec::new();
|
||||
for item in &screen.items {
|
||||
match item {
|
||||
crate::shader_pack::ScreenItem::Empty => {}
|
||||
crate::shader_pack::ScreenItem::Profile => {
|
||||
let label =
|
||||
Text::literal(format!("Profile: {}", cfg.active_profile_label()));
|
||||
widgets.push(button(0.0, 0.0, 150.0, label, Act::ShaderPackProfile));
|
||||
}
|
||||
crate::shader_pack::ScreenItem::Subscreen(sub, title) => {
|
||||
let label = Text::literal(format!("{title}..."));
|
||||
widgets.push(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
label,
|
||||
Act::ShaderPackSubscreen(sub.clone()),
|
||||
));
|
||||
}
|
||||
crate::shader_pack::ScreenItem::Option(idx) => {
|
||||
if let Some(opt) = cfg.options.get(*idx) {
|
||||
let label = Text::literal(format!(
|
||||
"{}: {}",
|
||||
opt.label,
|
||||
opt.display_value()
|
||||
));
|
||||
if let Some(value) = opt.slider_pos() {
|
||||
widgets.push(Widget {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
w: 150.0,
|
||||
h: 20.0,
|
||||
kind: WKind::ShaderOptionSlider {
|
||||
label,
|
||||
value,
|
||||
index: *idx,
|
||||
},
|
||||
list: false,
|
||||
});
|
||||
} else {
|
||||
widgets.push(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
label,
|
||||
Act::ShaderPackOption(*idx),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut iter = widgets.into_iter();
|
||||
while let Some(mut first) = iter.next() {
|
||||
if let Some(second) = iter.next() {
|
||||
rows.push(Row::Buttons(first, Some(second)));
|
||||
} else {
|
||||
first.w = 310.0;
|
||||
rows.push(Row::Buttons(first, None));
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
_ => {
|
||||
let _ = s;
|
||||
Vec::new()
|
||||
@@ -1554,6 +1784,9 @@ impl OptionsUi {
|
||||
fn layout(&mut self, s: &Settings, w: f32, h: f32) -> (Vec<Widget>, f32, f32, f32) {
|
||||
let mut out = Vec::new();
|
||||
let page = self.page();
|
||||
if matches!(page, Page::ShaderPacks | Page::ShaderPackOptions) {
|
||||
self.sync_shader_pack_config(s);
|
||||
}
|
||||
let cx = (w / 2.0).floor();
|
||||
if matches!(page, Page::Video | Page::Controls | Page::KeyBinds) {
|
||||
self.settings_search.x = cx - 100.0;
|
||||
@@ -1595,10 +1828,13 @@ impl OptionsUi {
|
||||
("options.accessibility", Some(Page::Accessibility)),
|
||||
// Vanilla's Telemetry Data and Credits slots: Horizon's
|
||||
// settings and its mods, as a mod adds its button here.
|
||||
// Credits & Attribution (WinScreen's credits) follows them.
|
||||
("Horizon Settings...", Some(Page::Horizon)),
|
||||
("Mods...", Some(Page::Mods)),
|
||||
("options.credits_and_attribution", Some(Page::Credits)),
|
||||
];
|
||||
let gy = (61.0 + 30.0f32).min(h - 33.0 - 120.0);
|
||||
let rows = entries.len().div_ceil(2) as f32;
|
||||
let gy = (61.0 + 30.0f32).min(h - 33.0 - rows * 24.0);
|
||||
for (i, (key, page)) in entries.iter().enumerate() {
|
||||
let x = cx - 158.0 + 4.0 + (i % 2) as f32 * 158.0;
|
||||
let y = gy + (i / 2) as f32 * 24.0;
|
||||
@@ -1609,6 +1845,46 @@ impl OptionsUi {
|
||||
top = 0.0;
|
||||
bottom = 0.0;
|
||||
}
|
||||
Page::Credits => {
|
||||
// CreditsAndAttributionScreen: the title header, three 210-wide
|
||||
// buttons 8 apart (HeaderAndFooterLayout: 30 below the header
|
||||
// unless the footer is closer), Done.
|
||||
out.push(Widget {
|
||||
x: 0.0,
|
||||
y: 12.0,
|
||||
w,
|
||||
h: 9.0,
|
||||
kind: WKind::Text {
|
||||
text: tr(Self::title(page)),
|
||||
color: u32::MAX,
|
||||
centered: true,
|
||||
},
|
||||
list: false,
|
||||
});
|
||||
let y = (33.0 + 30.0f32).min(h - 33.0 - 76.0);
|
||||
let x = cx - 105.0;
|
||||
out.push(button(
|
||||
x,
|
||||
y,
|
||||
210.0,
|
||||
tr("credits_and_attribution.button.credits"),
|
||||
Act::Credits,
|
||||
));
|
||||
// ConfirmLinkScreen to minecraft.net: Horizon opens no links.
|
||||
for (i, key) in [
|
||||
"credits_and_attribution.button.attribution",
|
||||
"credits_and_attribution.button.licenses",
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let y = y + 28.0 * (i + 1) as f32;
|
||||
out.push(inactive(button(x, y, 210.0, tr(key), Act::None)));
|
||||
}
|
||||
out.push(done(h - 27.0));
|
||||
top = 0.0;
|
||||
bottom = 0.0;
|
||||
}
|
||||
Page::KeyBinds => {
|
||||
top = 38.0;
|
||||
out.push(Widget {
|
||||
@@ -1900,6 +2176,10 @@ impl OptionsUi {
|
||||
if matches!(page, Page::Video | Page::Controls) {
|
||||
top = 38.0;
|
||||
}
|
||||
if page == Page::ShaderPacks {
|
||||
// Two footer rows, as Iris's ShaderPackScreen.
|
||||
bottom = h - 57.0;
|
||||
}
|
||||
out.push(Widget {
|
||||
x: 0.0,
|
||||
y: if matches!(page, Page::Video | Page::Controls) {
|
||||
@@ -1988,12 +2268,34 @@ impl OptionsUi {
|
||||
out[list_start..].iter_mut().for_each(|w| w.list = true);
|
||||
let folder = match page {
|
||||
Page::Mods => Some(("Open Mod Folder", Act::OpenModFolder)),
|
||||
Page::ShaderPacks => {
|
||||
Some(("Open Shader Pack Folder", Act::OpenShaderPackFolder))
|
||||
Page::ShaderPackOptions => {
|
||||
Some(("Reset to Defaults", Act::ResetShaderPackOptions))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some((label, act)) = folder {
|
||||
if page == Page::ShaderPacks {
|
||||
// Iris: the folder and the pack's settings above Done.
|
||||
out.push(button(
|
||||
cx - 154.0,
|
||||
h - 51.0,
|
||||
150.0,
|
||||
Text::literal("Open Shader Pack Folder"),
|
||||
Act::OpenShaderPackFolder,
|
||||
));
|
||||
let settings = button(
|
||||
cx + 4.0,
|
||||
h - 51.0,
|
||||
150.0,
|
||||
Text::literal("Shader Pack Settings..."),
|
||||
Act::Push(Page::ShaderPackOptions),
|
||||
);
|
||||
out.push(if self.shader_pack_has_settings(s) {
|
||||
settings
|
||||
} else {
|
||||
inactive(settings)
|
||||
});
|
||||
out.push(done(h - 27.0));
|
||||
} else if let Some((label, act)) = folder {
|
||||
out.push(button(
|
||||
cx - 154.0,
|
||||
h - 27.0,
|
||||
@@ -2160,6 +2462,16 @@ impl OptionsUi {
|
||||
170.0,
|
||||
));
|
||||
}
|
||||
if let WKind::Button {
|
||||
act: Act::ShaderPackOption(i),
|
||||
..
|
||||
} = &wd.kind
|
||||
&& let Some(cfg) = &self.shader_pack_config
|
||||
&& let Some(opt) = cfg.options.get(*i)
|
||||
&& !opt.description.is_empty()
|
||||
{
|
||||
tooltip = Some(atlas.font.split(&Text::literal(&opt.description), 170.0));
|
||||
}
|
||||
}
|
||||
match &wd.kind {
|
||||
WKind::Button { label, active, act } => {
|
||||
@@ -2200,6 +2512,31 @@ impl OptionsUi {
|
||||
true,
|
||||
);
|
||||
}
|
||||
WKind::ShaderOptionSlider {
|
||||
label,
|
||||
value,
|
||||
index,
|
||||
} => {
|
||||
let dragging = self.dragging_shader_option == Some(*index);
|
||||
if hover
|
||||
&& let Some(cfg) = &self.shader_pack_config
|
||||
&& let Some(opt) = cfg.options.get(*index)
|
||||
&& !opt.description.is_empty()
|
||||
{
|
||||
tooltip = Some(atlas.font.split(&Text::literal(&opt.description), 170.0));
|
||||
}
|
||||
ui::slider(
|
||||
batch,
|
||||
atlas,
|
||||
wd.x,
|
||||
wd.y,
|
||||
wd.w,
|
||||
label,
|
||||
*value,
|
||||
hover || dragging,
|
||||
true,
|
||||
);
|
||||
}
|
||||
WKind::Text {
|
||||
text,
|
||||
color,
|
||||
@@ -2414,6 +2751,19 @@ impl OptionsUi {
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
if let Some(idx) = self.dragging_shader_option
|
||||
&& let Some(wd) = widgets.iter().find(|wd| {
|
||||
matches!(&wd.kind, WKind::ShaderOptionSlider { index, .. } if *index == idx)
|
||||
})
|
||||
{
|
||||
let v = ui::slider_value(wd.x, wd.w, x);
|
||||
self.edit_shader_pack(|cfg| {
|
||||
if let Some(opt) = cfg.options.get_mut(idx) {
|
||||
opt.set_slider_pos(v);
|
||||
}
|
||||
});
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
// Controller Binds: a click cancels the wait.
|
||||
@@ -2499,6 +2849,36 @@ impl OptionsUi {
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::OpenShaderPackFolder => events.push(Event::OpenShaderPackFolder),
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
Act::ShaderPackProfile => {
|
||||
self.edit_shader_pack(|cfg| cfg.cycle_profile(false));
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::ShaderPackSubscreen(sub) => {
|
||||
if let Some(cfg) = &mut self.shader_pack_config {
|
||||
cfg.active_screen = Some(sub);
|
||||
}
|
||||
self.scroll = 0.0;
|
||||
}
|
||||
Act::ShaderPackBackScreen => {
|
||||
if let Some(cfg) = &mut self.shader_pack_config {
|
||||
cfg.active_screen = None;
|
||||
}
|
||||
self.scroll = 0.0;
|
||||
}
|
||||
Act::ShaderPackOption(i) => {
|
||||
self.edit_shader_pack(|cfg| {
|
||||
if let Some(opt) = cfg.options.get_mut(i) {
|
||||
opt.cycle(false);
|
||||
}
|
||||
});
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::ResetShaderPackOptions => {
|
||||
self.edit_shader_pack(PackConfig::reset_all);
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::None => {}
|
||||
}
|
||||
}
|
||||
@@ -2509,6 +2889,16 @@ impl OptionsUi {
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
WKind::ShaderOptionSlider { index, .. } => {
|
||||
self.dragging_shader_option = Some(index);
|
||||
let v = ui::slider_value(wd.x, wd.w, x);
|
||||
self.edit_shader_pack(|cfg| {
|
||||
if let Some(opt) = cfg.options.get_mut(index) {
|
||||
opt.set_slider_pos(v);
|
||||
}
|
||||
});
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
WKind::LanguageRow { code, .. } => {
|
||||
if self.language_choice.as_deref() == Some(code.as_str()) {
|
||||
events.extend(self.back(s));
|
||||
@@ -2537,7 +2927,7 @@ impl OptionsUi {
|
||||
}
|
||||
// AbstractSliderButton suppresses the press sound and plays once on
|
||||
// release, even if the pointer has left the widget or its value stayed.
|
||||
let slider = self.dragging.take().is_some();
|
||||
let slider = self.dragging.take().is_some() || self.dragging_shader_option.take().is_some();
|
||||
let graphics_slider = self.graphics.release();
|
||||
if slider || graphics_slider {
|
||||
vec![Event::ButtonClick]
|
||||
@@ -2593,6 +2983,14 @@ impl OptionsUi {
|
||||
/// Escape or Done: leave the current page, applying page results.
|
||||
pub fn back(&mut self, s: &mut Settings) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
if self.page() == Page::ShaderPackOptions
|
||||
&& let Some(cfg) = &mut self.shader_pack_config
|
||||
&& cfg.active_screen.is_some()
|
||||
{
|
||||
cfg.active_screen = None;
|
||||
self.scroll = 0.0;
|
||||
return events;
|
||||
}
|
||||
let page = self.stack.pop().unwrap_or(Page::Main);
|
||||
self.scroll = 0.0;
|
||||
self.scrollbar_drag_y = None;
|
||||
@@ -2609,6 +3007,12 @@ impl OptionsUi {
|
||||
events.push(Event::Language(code));
|
||||
}
|
||||
}
|
||||
// Iris's ShaderPackScreen applies its edits on Done, so the
|
||||
// pack is rebuilt once, not on every click or slider step.
|
||||
Page::ShaderPackOptions if std::mem::take(&mut self.shader_pack_edited) => {
|
||||
self.save_shader_pack_config();
|
||||
events.push(Event::ReloadShaderPack);
|
||||
}
|
||||
Page::Packs => {
|
||||
// PackSelectionScreen.onClose: PackSelectionModel.commit.
|
||||
if let Some(p) = self.pack_screen.take()
|
||||
@@ -3036,6 +3440,74 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_pack_is_picked_and_its_settings_apply_on_done() {
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
let size = (480.0, 240.0);
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-shaderpack-drop-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
ui.shader_packs.poll(dir.clone());
|
||||
let sample = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../examples/shaderpacks/horizon-sample");
|
||||
assert_eq!(
|
||||
ui.drop_shader_pack(&mut s, &sample),
|
||||
None,
|
||||
"only on its page"
|
||||
);
|
||||
ui.show(Page::ShaderPacks);
|
||||
assert_eq!(ui.drop_shader_pack(&mut s, &sample), Some(Ok(())));
|
||||
assert_eq!(s.horizon.shader_pack, "horizon-sample");
|
||||
assert!(dir.join("horizon-sample/pack.toml").is_file());
|
||||
let again = ui.drop_shader_pack(&mut s, &sample).unwrap().unwrap_err();
|
||||
assert!(again.contains("already"), "{again}");
|
||||
|
||||
let click = |ui: &mut OptionsUi, s: &mut Settings, act: &dyn Fn(&Act) -> bool| {
|
||||
let w = ui
|
||||
.layout(s, size.0, size.1)
|
||||
.0
|
||||
.into_iter()
|
||||
.find(|w| matches!(&w.kind, WKind::Button { act: a, active: true, .. } if act(a)))
|
||||
.unwrap();
|
||||
ui.click(
|
||||
s,
|
||||
&atlas,
|
||||
w.x + 1.0,
|
||||
w.y + 1.0,
|
||||
size,
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
)
|
||||
};
|
||||
click(&mut ui, &mut s, &|a| {
|
||||
*a == Act::Push(Page::ShaderPackOptions)
|
||||
});
|
||||
assert_eq!(ui.page(), Page::ShaderPackOptions);
|
||||
for _ in 0..3 {
|
||||
let events = click(&mut ui, &mut s, &|a| matches!(a, Act::ShaderPackOption(_)));
|
||||
assert!(!events.contains(&Event::ReloadShaderPack), "{events:?}");
|
||||
}
|
||||
let txt = dir.join("horizon-sample.txt");
|
||||
assert!(!txt.exists(), "nothing is saved before Done");
|
||||
let events = click(&mut ui, &mut s, &|a| *a == Act::Done);
|
||||
assert!(events.contains(&Event::ReloadShaderPack), "{events:?}");
|
||||
assert_eq!(ui.page(), Page::ShaderPacks);
|
||||
assert!(txt.is_file(), "Done saves the values");
|
||||
click(&mut ui, &mut s, &|a| {
|
||||
*a == Act::Push(Page::ShaderPackOptions)
|
||||
});
|
||||
let events = click(&mut ui, &mut s, &|a| *a == Act::Done);
|
||||
assert!(
|
||||
!events.contains(&Event::ReloadShaderPack),
|
||||
"no change: no reload"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_mods_page_toggles_mods_and_keeps_dev_and_invalid_entries_inactive() {
|
||||
let atlas = atlas();
|
||||
@@ -4133,9 +4605,13 @@ mod tests {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// Vanilla's ten Options buttons, the last two Horizon's.
|
||||
// Vanilla's Options buttons, Horizon's two in the Telemetry and Credits slots, then
|
||||
// Credits & Attribution (WinScreen's credits).
|
||||
let main = pushes(&mut ui);
|
||||
assert_eq!(&main[main.len() - 2..], [Page::Horizon, Page::Mods]);
|
||||
assert_eq!(
|
||||
&main[main.len() - 3..],
|
||||
[Page::Horizon, Page::Mods, Page::Credits]
|
||||
);
|
||||
ui.push(Page::Horizon);
|
||||
assert_eq!(
|
||||
pushes(&mut ui),
|
||||
@@ -4146,8 +4622,33 @@ mod tests {
|
||||
Page::Controller,
|
||||
Page::Extras,
|
||||
Page::Mods,
|
||||
Page::Agent,
|
||||
]
|
||||
);
|
||||
// No AI hides the AI Agent page.
|
||||
let mut no_ai = Settings::default();
|
||||
no_ai.extras.no_ai = true;
|
||||
let (widgets, _, _, _) = ui.layout(&no_ai, 427.0, 240.0);
|
||||
assert!(!widgets.iter().any(|w| matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::Push(Page::Agent),
|
||||
..
|
||||
}
|
||||
)));
|
||||
// The AI Agent page: the switch, Copy and the status the game sets.
|
||||
ui.push(Page::Agent);
|
||||
ui.agent_status = vec!["Waiting for an agent.".into()];
|
||||
let (widgets, _, _, _) = ui.layout(&s, 427.0, 240.0);
|
||||
let acts: Vec<&Act> = widgets
|
||||
.iter()
|
||||
.filter_map(|w| match &w.kind {
|
||||
WKind::Button { act, .. } => Some(act),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(acts.contains(&&Act::Extra(crate::extras::Item::AgentControl)));
|
||||
assert!(acts.contains(&&Act::CopyAgentDetails));
|
||||
// Vanilla's own pages keep the Horizon pages they had.
|
||||
ui.stack = vec![Page::Main, Page::Video];
|
||||
let video = pushes(&mut ui);
|
||||
|
||||
@@ -87,6 +87,7 @@ fn style_of(o: &serde_json::Map<String, serde_json::Value>) -> Style {
|
||||
underlined: flag("underlined"),
|
||||
strikethrough: flag("strikethrough"),
|
||||
obfuscated: flag("obfuscated"),
|
||||
font: o.get("font").and_then(crate::text::font_id),
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Memoized palette eligibility for the game thread's random-tick snapshot.
|
||||
//! Which sections of the game thread's chunks random-tick
|
||||
//! (`LevelChunkSection.isRandomlyTicking`), memoized per section.
|
||||
use crate::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, SECTIONS},
|
||||
chunk::{Chunk, MIN_Y, SECTIONS, Section},
|
||||
fast_hash::FxHashMap,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
@@ -10,6 +11,13 @@ use std::sync::{Arc, Weak};
|
||||
/// A value snapshot, in ascending section order, with no per-chunk allocation.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Sections(u32);
|
||||
impl Sections {
|
||||
/// The minimum Y of the lowest section in the set from block `min_y` up.
|
||||
pub fn from(self, min_y: i32) -> Option<i32> {
|
||||
let index = ((min_y - MIN_Y) >> 4).max(0) as u32;
|
||||
Sections(self.0 & u32::MAX.checked_shl(index).unwrap_or(0)).next()
|
||||
}
|
||||
}
|
||||
impl Iterator for Sections {
|
||||
type Item = i32;
|
||||
fn next(&mut self) -> Option<i32> {
|
||||
@@ -29,12 +37,20 @@ impl ExactSizeIterator for Sections {}
|
||||
|
||||
struct Cached {
|
||||
chunk: Weak<Chunk>,
|
||||
/// The section each bit of `sections` was read from.
|
||||
read: [Weak<Section>; SECTIONS],
|
||||
sections: Sections,
|
||||
}
|
||||
/// Resident chunks are immutable behind `Arc`; edits use `Arc::make_mut`.
|
||||
/// A weak reference detects both edits and replacement, without retaining block
|
||||
/// data or forcing a deep copy on the next edit. It also prevents address reuse.
|
||||
/// Clear this cache whenever the gameplay facts change (including resource reload).
|
||||
/// Vanilla keeps a count of randomly ticking blocks and fluids per section,
|
||||
/// updated by every `setBlockState`, and ticks the sections where it is above
|
||||
/// zero. Here a section's answer is read from its blocks after any edit:
|
||||
/// resident chunks and sections are immutable behind `Arc` and edited through
|
||||
/// `Arc::make_mut`, so a weak reference tells an edited or replaced one from
|
||||
/// the one read, without retaining block data or forcing a deep copy on the
|
||||
/// next edit, and prevents address reuse. An unchanged chunk costs one
|
||||
/// pointer comparison; a changed one, a comparison per section and a read of
|
||||
/// the sections that changed. Clear this cache whenever the gameplay facts
|
||||
/// change (including resource reload).
|
||||
#[derive(Default)]
|
||||
pub struct RandomTickSections {
|
||||
chunks: FxHashMap<(i32, i32), Cached>,
|
||||
@@ -46,6 +62,7 @@ impl RandomTickSections {
|
||||
pub fn remove(&mut self, key: &(i32, i32)) {
|
||||
self.chunks.remove(key);
|
||||
}
|
||||
/// The sections of `chunk` holding a randomly ticking block or fluid.
|
||||
pub fn get(&mut self, key: (i32, i32), chunk: &Arc<Chunk>, facts: &BlockFacts) -> Sections {
|
||||
self.get_with(key, chunk, |id| facts.get(BlockStateId(id)).random_ticks)
|
||||
}
|
||||
@@ -60,17 +77,23 @@ impl RandomTickSections {
|
||||
}
|
||||
let cached = self.chunks.entry(key).or_insert_with(|| Cached {
|
||||
chunk: Weak::new(),
|
||||
read: std::array::from_fn(|_| Weak::new()),
|
||||
sections: Sections::default(),
|
||||
});
|
||||
if cached.chunk.as_ptr() != Arc::as_ptr(chunk) {
|
||||
let mut mask = 0;
|
||||
for (i, section) in chunk.sections.iter().enumerate() {
|
||||
// Preserve maybe_has, including unused entries and global palettes.
|
||||
if section.blocks.maybe_has(&mut random) {
|
||||
mask |= 1 << i;
|
||||
for (i, (section, read)) in chunk.sections.iter().zip(&mut cached.read).enumerate() {
|
||||
if read.as_ptr() == Arc::as_ptr(section) {
|
||||
continue;
|
||||
}
|
||||
// `isRandomlyTicking`: the blocks themselves, not the palette,
|
||||
// which keeps the states no block uses any more.
|
||||
if section.blocks.has(&mut random) {
|
||||
cached.sections.0 |= 1 << i;
|
||||
} else {
|
||||
cached.sections.0 &= !(1 << i);
|
||||
}
|
||||
*read = Arc::downgrade(section);
|
||||
}
|
||||
cached.sections = Sections(mask);
|
||||
cached.chunk = Arc::downgrade(chunk);
|
||||
}
|
||||
cached.sections
|
||||
@@ -97,10 +120,24 @@ mod tests {
|
||||
[MIN_Y]
|
||||
);
|
||||
assert_eq!(worker.get(0, MIN_Y, 0), 0);
|
||||
// Even after the last ticking block is removed, its unused palette entry counts.
|
||||
// Once the last ticking block is gone the section stops ticking, as
|
||||
// vanilla's count drops to zero, though its palette still lists it.
|
||||
Arc::make_mut(&mut chunk).set(0, MIN_Y, 0, 0);
|
||||
assert_eq!(cache.get_with((0, 0), &chunk, |id| id == 1).len(), 1);
|
||||
Arc::make_mut(&mut Arc::make_mut(&mut chunk).sections[0]).compact();
|
||||
assert!(chunk.sections[0].blocks.maybe_has(|id| id == 1));
|
||||
assert_eq!(cache.get_with((0, 0), &chunk, |id| id == 1).len(), 0);
|
||||
// Only the edited section is read again (its palette: air, then 1).
|
||||
Arc::make_mut(&mut chunk).set(0, MIN_Y + 16, 0, 1);
|
||||
let mut asked = Vec::new();
|
||||
let sections = cache.get_with((0, 0), &chunk, |id| {
|
||||
asked.push(id);
|
||||
id == 1
|
||||
});
|
||||
assert_eq!(
|
||||
(sections.collect::<Vec<_>>(), asked),
|
||||
(vec![MIN_Y + 16], vec![0, 1])
|
||||
);
|
||||
Arc::make_mut(&mut chunk).set(0, MIN_Y + 16, 0, 0);
|
||||
Arc::make_mut(&mut Arc::make_mut(&mut chunk).sections[1]).compact();
|
||||
assert_eq!(cache.get_with((0, 0), &chunk, |id| id == 1).len(), 0);
|
||||
Arc::make_mut(&mut chunk).sections[SECTIONS - 1] = Arc::new(Section::default());
|
||||
cache.clear(); // resource reload, with changed facts for the same state IDs
|
||||
@@ -138,13 +175,36 @@ mod tests {
|
||||
}
|
||||
let chunk = Arc::new(chunk);
|
||||
let mut cache = RandomTickSections::default();
|
||||
// Global maybe_has considers the entire ID range, including absent states.
|
||||
// Global palettes: the states the blocks hold, not the whole ID range.
|
||||
assert_eq!(cache.get_with((0, 0), &chunk, |id| id == 40000).len(), 0);
|
||||
cache.clear();
|
||||
assert_eq!(
|
||||
cache
|
||||
.get_with((0, 0), &chunk, |id| id == 40000)
|
||||
.get_with((0, 0), &chunk, |id| id == 299)
|
||||
.collect::<Vec<_>>(),
|
||||
[MIN_Y, MIN_Y + 9 * 16, MIN_Y + (SECTIONS as i32 - 1) * 16]
|
||||
);
|
||||
let sections = cache.get_with((0, 0), &chunk, |id| id == 299);
|
||||
let top = MIN_Y + (SECTIONS as i32 - 1) * 16;
|
||||
let from = |y| sections.from(y);
|
||||
assert_eq!(
|
||||
[
|
||||
from(MIN_Y - 16),
|
||||
from(MIN_Y),
|
||||
from(MIN_Y + 16),
|
||||
from(MIN_Y + 9 * 16)
|
||||
],
|
||||
[
|
||||
Some(MIN_Y),
|
||||
Some(MIN_Y),
|
||||
Some(MIN_Y + 9 * 16),
|
||||
Some(MIN_Y + 9 * 16)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
[from(top), from(top + 16), from(top + 1000)],
|
||||
[Some(top), None, None]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +261,7 @@ mod tick_parity {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_random_ticks_match_original_scan() {
|
||||
fn actual_random_ticks_match_a_block_scan() {
|
||||
let root = std::env::var_os("HORIZON_RESOURCES")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
@@ -228,6 +288,7 @@ mod tick_parity {
|
||||
let registry = data::load_registry(&root).unwrap();
|
||||
let (mut facts, behaviours) = BlockFacts::load_pair(&root, ®istry).unwrap();
|
||||
let grass = registry.default_state("minecraft:grass_block").unwrap();
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
let chunk = |x| {
|
||||
let mut c = Chunk::empty(x, 0);
|
||||
c.lit = true;
|
||||
@@ -280,6 +341,15 @@ mod tick_parity {
|
||||
40 | 41 => {
|
||||
Arc::make_mut(level.chunks.get_mut(&(0, 0)).unwrap()).lit = step == 41;
|
||||
}
|
||||
70 => {
|
||||
// Every block of a ticking section replaced: its
|
||||
// palette still lists grass, but nothing ticks there.
|
||||
let c = Arc::make_mut(level.chunks.get_mut(&(-1, 0)).unwrap());
|
||||
for i in 0..4096 {
|
||||
c.set(i & 15, MIN_Y + 4 * 16 + (i >> 8), i >> 4 & 15, stone.0);
|
||||
}
|
||||
assert!(c.sections[4].blocks.maybe_has(|id| id == grass.0));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -290,47 +360,43 @@ mod tick_parity {
|
||||
facts.states[grass.0 as usize].random_ticks = step == 60;
|
||||
cache.clear();
|
||||
}
|
||||
let old: Vec<_> = original
|
||||
.chunks
|
||||
.iter()
|
||||
.filter(|(_, c)| c.lit)
|
||||
.map(|(&key, c)| {
|
||||
let sections: Vec<_> = c
|
||||
.sections
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, s)| {
|
||||
s.blocks
|
||||
.maybe_has(|id| facts.get(BlockStateId(id)).random_ticks)
|
||||
})
|
||||
.map(|(i, _)| MIN_Y + i as i32 * 16)
|
||||
.collect();
|
||||
(key, sections)
|
||||
})
|
||||
.collect();
|
||||
let new: Vec<_> = cached
|
||||
.chunks
|
||||
.iter()
|
||||
.filter(|(_, c)| c.lit)
|
||||
.map(|(&key, c)| (key, cache.get(key, c, &facts)))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
old,
|
||||
new.iter()
|
||||
.map(|(k, s)| (*k, s.collect::<Vec<_>>()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
// Stand in for an inside-block/scheduled write AFTER eligibility capture.
|
||||
// A write between ticks.
|
||||
if step == 20 {
|
||||
for level in [&mut original, &mut cached] {
|
||||
level.set_raw(BlockPos::new(0, MIN_Y + 16, 0), grass);
|
||||
}
|
||||
}
|
||||
for ((x, z), sections) in old {
|
||||
behaviours.tick_chunk_random(&mut original, &mut a, ChunkPos::new(x, z), sections);
|
||||
let lit = |level: &Level| {
|
||||
let mut keys: Vec<_> = level
|
||||
.chunks
|
||||
.iter()
|
||||
.filter(|(_, c)| c.lit)
|
||||
.map(|(&key, _)| key)
|
||||
.collect();
|
||||
keys.sort();
|
||||
keys
|
||||
};
|
||||
let random = |id| facts.get(BlockStateId(id)).random_ticks;
|
||||
for (x, z) in lit(&original) {
|
||||
// Vanilla's rule, asked as each section is reached: any of its
|
||||
// 4,096 blocks ticks randomly.
|
||||
let reference = |level: &mut Level, from: i32| {
|
||||
let chunk = &level.chunks[&(x, z)];
|
||||
(from..MIN_Y + SECTIONS as i32 * 16)
|
||||
.step_by(16)
|
||||
.find(|&min_y| {
|
||||
(0..16).any(|y| {
|
||||
(0..16).any(|z| (0..16).any(|x| random(chunk.get(x, min_y + y, z))))
|
||||
})
|
||||
})
|
||||
};
|
||||
behaviours.tick_chunk_random(&mut original, &mut a, ChunkPos::new(x, z), reference);
|
||||
}
|
||||
for ((x, z), sections) in new {
|
||||
behaviours.tick_chunk_random(&mut cached, &mut b, ChunkPos::new(x, z), sections);
|
||||
for (x, z) in lit(&cached) {
|
||||
let memoized = |level: &mut Level, from| {
|
||||
cache.get((x, z), &level.chunks[&(x, z)], &facts).from(from)
|
||||
};
|
||||
behaviours.tick_chunk_random(&mut cached, &mut b, ChunkPos::new(x, z), memoized);
|
||||
}
|
||||
assert_eq!(original.reads, cached.reads, "ordered reads at tick {step}");
|
||||
assert_eq!(original.events, cached.events, "events at tick {step}");
|
||||
|
||||
@@ -2258,14 +2258,18 @@ pub struct Level {
|
||||
pub game_mode: i32,
|
||||
pub time: i64,
|
||||
pub day_time: i64,
|
||||
/// The player's respawn point (bed, or the world spawn).
|
||||
/// Where the player respawns when it has no bed or anchor: its forced
|
||||
/// Overworld point (`spawnpoint`), or the world spawn.
|
||||
pub spawn: Option<[f64; 3]>,
|
||||
/// A respawn point in another dimension than the Overworld (a respawn
|
||||
/// anchor): dimension, block, yaw and `forced`.
|
||||
/// A respawn point resolved through its block when the player respawns
|
||||
/// (a bed, a respawn anchor, a forced point outside the Overworld):
|
||||
/// dimension, block, yaw and `forced`.
|
||||
pub respawn_elsewhere: Option<(String, [i32; 3], f32, bool)>,
|
||||
pub position: Option<[f64; 3]>,
|
||||
pub rotation: [f32; 2],
|
||||
pub health: f32,
|
||||
/// LivingEntity `AbsorptionAmount`: the golden hearts left.
|
||||
pub absorption: f32,
|
||||
pub food: i32,
|
||||
pub saturation: f32,
|
||||
/// (slot, item, count, damage): 0..36 inventory, 100..103 armor (feet..head), 150 offhand.
|
||||
@@ -2274,6 +2278,8 @@ pub struct Level {
|
||||
pub inventory_components: HashMap<u8, Box<crate::item_data::StackData>>,
|
||||
/// Player.XpSeed: the enchanting table seed.
|
||||
pub xp_seed: i32,
|
||||
/// ServerPlayer.seenCredits: the end poem has been shown.
|
||||
pub seen_credits: bool,
|
||||
/// Player.active_effects: (id, duration, amplifier, ambient, show_icon).
|
||||
pub active_effects: Vec<(String, i32, i32, bool, bool)>,
|
||||
/// Player.EnderItems: the ender chest inventory (27 slots).
|
||||
@@ -2288,6 +2294,8 @@ pub struct Level {
|
||||
pub dimension: String,
|
||||
/// World spawn (`level.dat` `spawn`), distinct from a bed respawn point.
|
||||
pub world_spawn: Option<[i32; 3]>,
|
||||
/// The world spawn's yaw and pitch (`setworldspawn`'s rotation).
|
||||
pub world_spawn_angle: [f32; 2],
|
||||
pub difficulty: String,
|
||||
pub hardcore: bool,
|
||||
/// `difficulty_settings.locked` (LevelData.isDifficultyLocked).
|
||||
@@ -2365,6 +2373,11 @@ impl Level {
|
||||
]
|
||||
}),
|
||||
};
|
||||
let angle = |k: &str| match data.get("spawn").and_then(|s| s.get(k)) {
|
||||
Some(Tag::Float(b)) => f32::from_bits(*b),
|
||||
_ => 0.0,
|
||||
};
|
||||
level.world_spawn_angle = [angle("yaw"), angle("pitch")];
|
||||
if let Some(d) = data.get("difficulty_settings") {
|
||||
level.difficulty = text(d.get("difficulty")).unwrap_or_else(|| "normal".into());
|
||||
level.hardcore = n(d.get("hardcore")) != 0;
|
||||
@@ -2455,6 +2468,7 @@ impl Level {
|
||||
}
|
||||
self.motion = read_doubles::<3>(p.get("Motion")).unwrap_or_default();
|
||||
self.health = f("Health").map_or(20.0, |v| v as f32);
|
||||
self.absorption = f("AbsorptionAmount").map_or(0.0, |v| v as f32);
|
||||
self.food = n("foodLevel").map_or(20, |v| v as i32);
|
||||
self.saturation = f("foodSaturationLevel").map_or(5.0, |v| v as f32);
|
||||
self.food_exhaustion = f("foodExhaustionLevel").unwrap_or(0.0) as f32;
|
||||
@@ -2465,6 +2479,7 @@ impl Level {
|
||||
self.selected_slot = n("SelectedItemSlot").unwrap_or(0) as i32;
|
||||
self.fall_distance = f("fall_distance").unwrap_or(0.0);
|
||||
self.on_ground = n("OnGround").unwrap_or(0) != 0;
|
||||
self.seen_credits = n("seenCredits").unwrap_or(0) != 0;
|
||||
if let Some(mode) = n("playerGameType") {
|
||||
self.creative = mode == 1;
|
||||
self.game_mode = mode as i32;
|
||||
@@ -2482,14 +2497,17 @@ impl Level {
|
||||
{
|
||||
let dimension =
|
||||
text(respawn.get("dimension")).unwrap_or_else(|| "minecraft:overworld".into());
|
||||
if dimension == "minecraft:overworld" {
|
||||
let forced = number(respawn.get("forced")).is_some_and(|v| v != 0);
|
||||
// A forced point in the Overworld (`spawnpoint`) is where the
|
||||
// player respawns; anything else (a bed, an anchor) is resolved
|
||||
// through its block then.
|
||||
if dimension == "minecraft:overworld" && forced {
|
||||
self.spawn = Some([f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])]);
|
||||
} else {
|
||||
let yaw = match respawn.get("yaw") {
|
||||
Some(Tag::Float(b)) => f32::from_bits(*b),
|
||||
_ => 0.0,
|
||||
};
|
||||
let forced = number(respawn.get("forced")).is_some_and(|v| v != 0);
|
||||
self.respawn_elsewhere = Some((dimension, [pos[0], pos[1], pos[2]], yaw, forced));
|
||||
}
|
||||
}
|
||||
@@ -2626,6 +2644,7 @@ impl Level {
|
||||
("OnGround", Tag::Byte(i8::from(self.on_ground))),
|
||||
("fall_distance", Tag::Double(self.fall_distance.to_bits())),
|
||||
("Health", Tag::Float(self.health.to_bits())),
|
||||
("AbsorptionAmount", Tag::Float(self.absorption.to_bits())),
|
||||
("foodLevel", Tag::Int(self.food)),
|
||||
("foodSaturationLevel", Tag::Float(self.saturation.to_bits())),
|
||||
(
|
||||
@@ -2686,6 +2705,7 @@ impl Level {
|
||||
if let Some(pos) = self.position {
|
||||
entries.push(("Pos", doubles(&pos)));
|
||||
}
|
||||
entries.push(("seenCredits", Tag::Byte(i8::from(self.seen_credits))));
|
||||
// A respawn point other than the world spawn: an anchor elsewhere, or a bed.
|
||||
if let Some((dimension, block, yaw, forced)) = &self.respawn_elsewhere {
|
||||
let mut respawn = vec![
|
||||
@@ -2704,6 +2724,8 @@ impl Level {
|
||||
s[1].floor() as i32,
|
||||
s[2].floor() as i32,
|
||||
];
|
||||
// A point of the player's own in the Overworld other than a bed
|
||||
// (`respawn_elsewhere`): `spawnpoint`'s, forced.
|
||||
if Some(block) != self.world_spawn {
|
||||
entries.push((
|
||||
"respawn",
|
||||
@@ -2712,6 +2734,7 @@ impl Level {
|
||||
("pos", Tag::IntArray(block.to_vec())),
|
||||
("yaw", Tag::Float(0f32.to_bits())),
|
||||
("pitch", Tag::Float(0f32.to_bits())),
|
||||
("forced", Tag::Byte(1)),
|
||||
]),
|
||||
));
|
||||
}
|
||||
@@ -2731,7 +2754,6 @@ impl Level {
|
||||
("seenCredits", Tag::Byte(0)),
|
||||
("HurtTime", Tag::Short(0)),
|
||||
("DeathTime", Tag::Short(0)),
|
||||
("AbsorptionAmount", Tag::Float(0)),
|
||||
("FallFlying", Tag::Byte(0)),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
@@ -2857,8 +2879,8 @@ impl Level {
|
||||
Tag::compound([
|
||||
("pos", Tag::IntArray(spawn.to_vec())),
|
||||
("dimension", string("minecraft:overworld")),
|
||||
("yaw", Tag::Float(0f32.to_bits())),
|
||||
("pitch", Tag::Float(0f32.to_bits())),
|
||||
("yaw", Tag::Float(self.world_spawn_angle[0].to_bits())),
|
||||
("pitch", Tag::Float(self.world_spawn_angle[1].to_bits())),
|
||||
]),
|
||||
),
|
||||
("difficulty_settings", difficulty),
|
||||
@@ -4114,6 +4136,7 @@ mod tests {
|
||||
position: Some([1.5, 70.0, -3.25]),
|
||||
rotation: [90.0, 10.0],
|
||||
health: 17.0,
|
||||
absorption: 5.0,
|
||||
food: 15,
|
||||
saturation: 2.5,
|
||||
inventory: vec![
|
||||
@@ -4126,6 +4149,7 @@ mod tests {
|
||||
xp_total: 30,
|
||||
dimension: "minecraft:the_nether".into(),
|
||||
world_spawn: Some([8, 64, 8]),
|
||||
world_spawn_angle: [45.0, -10.0],
|
||||
spawn: Some([20.0, 70.0, 20.0]),
|
||||
difficulty_locked: true,
|
||||
flying: true,
|
||||
@@ -4144,6 +4168,7 @@ mod tests {
|
||||
assert_eq!(back.position, Some([1.5, 70.0, -3.25]));
|
||||
assert_eq!(back.rotation, [90.0, 10.0]);
|
||||
assert_eq!((back.health, back.food, back.saturation), (17.0, 15, 2.5));
|
||||
assert_eq!(back.absorption, 5.0);
|
||||
let mut inv = back.inventory.clone();
|
||||
inv.sort();
|
||||
assert_eq!(
|
||||
@@ -4160,6 +4185,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(back.dimension, "minecraft:the_nether");
|
||||
assert_eq!(back.world_spawn, Some([8, 64, 8]));
|
||||
assert_eq!(back.world_spawn_angle, [45.0, -10.0]);
|
||||
assert_eq!(back.spawn, Some([20.0, 70.0, 20.0]));
|
||||
assert!(back.difficulty_locked);
|
||||
assert!(back.weather.raining);
|
||||
@@ -4178,10 +4204,13 @@ mod tests {
|
||||
let mut again = Level {
|
||||
player_extra: Some(extra),
|
||||
ender_items: ender,
|
||||
// The live amount, not the one the loaded compound held (#1307).
|
||||
absorption: 2.0,
|
||||
..back
|
||||
};
|
||||
again.write(&dir).unwrap();
|
||||
let third = Level::read(&dir).unwrap();
|
||||
assert_eq!(third.absorption, 2.0);
|
||||
assert!(matches!(
|
||||
third
|
||||
.player_extra
|
||||
|
||||
@@ -118,6 +118,7 @@ pub fn style_from_json(v: &Value) -> Style {
|
||||
s.underlined = flag("underlined");
|
||||
s.strikethrough = flag("strikethrough");
|
||||
s.obfuscated = flag("obfuscated");
|
||||
s.font = o.get("font").and_then(text::font_id);
|
||||
s
|
||||
}
|
||||
impl NumberFormat {
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
// Full-screen camera effects (screen_effects.rs): clip-space quads sampling
|
||||
// the shared 2D atlas with repeat inside each sprite rectangle.
|
||||
// Full-screen camera effects (screen_effects.rs): clip-space quads reading
|
||||
// one sprite rectangle of the shared 2D atlas as if it were its own texture,
|
||||
// with the sampler vanilla gives it (ReloadableTexture.setSampler: linear
|
||||
// when `blur`, clamp-to-edge when `clamp`, else nearest and repeat).
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@group(0) @binding(2) var atlas_sampler: sampler;
|
||||
// The block sprites' sampler (nearest), and the entity group's lightmap
|
||||
// sampler (linear) for `blur`. A blurred sprite has a one-texel border in the
|
||||
// atlas, wrapped as repeat reads it (hud.rs), so the hardware's bilinear
|
||||
// sample is GL_LINEAR with repeat; clamp-to-edge keeps the sample point half
|
||||
// a texel inside. Each sampling has its own pipeline (gfx.rs), so a pixel
|
||||
// costs what it did with nearest and repeat only: one sample.
|
||||
@group(0) @binding(2) var nearest_sampler: sampler;
|
||||
@group(0) @binding(4) var linear_sampler: sampler;
|
||||
override BLUR: bool = false;
|
||||
override CLAMP: bool = false;
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) @interpolate(flat) rect: vec4<f32>,
|
||||
@location(2) color: vec4<f32>,
|
||||
// With clamp-to-edge, the sprite's first and last texel centres.
|
||||
@location(3) @interpolate(flat) edges: vec4<f32>,
|
||||
}
|
||||
@vertex fn vs_screen(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) rect: vec4<f32>, @location(3) color: vec4<f32>) -> Out {
|
||||
@@ -15,12 +28,24 @@ struct Out {
|
||||
out.uv = uv;
|
||||
out.rect = rect;
|
||||
out.color = color;
|
||||
let half = 0.5 / vec2<f32>(textureDimensions(atlas));
|
||||
out.edges = vec4(rect.xy + half, rect.zw - half);
|
||||
return out;
|
||||
}
|
||||
fn sample(in: Out) -> vec4<f32> {
|
||||
let f = fract(in.uv);
|
||||
let uv = in.rect.xy + f * (in.rect.zw - in.rect.xy);
|
||||
return textureSampleLevel(atlas, atlas_sampler, uv, 0.0) * in.color;
|
||||
var f = in.uv;
|
||||
if !CLAMP {
|
||||
// Repeat: one period of the sprite.
|
||||
f = fract(f);
|
||||
}
|
||||
var uv = in.rect.xy + f * (in.rect.zw - in.rect.xy);
|
||||
if CLAMP {
|
||||
uv = clamp(uv, in.edges.xy, in.edges.zw);
|
||||
}
|
||||
if BLUR {
|
||||
return textureSampleLevel(atlas, linear_sampler, uv, 0.0) * in.color;
|
||||
}
|
||||
return textureSampleLevel(atlas, nearest_sampler, uv, 0.0) * in.color;
|
||||
}
|
||||
@fragment fn fs_screen(in: Out) -> @location(0) vec4<f32> {
|
||||
let c = sample(in);
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
//! `hud_overlays::camera_overlays` (vignette, pumpkin blur, powder-snow
|
||||
//! outline, nether portal swirl). Positions are clip-space quads; textures
|
||||
//! come from the shared 2D atlas and may repeat.
|
||||
use crate::{hud::Rect, hud_layout::GuiDraw};
|
||||
use crate::{
|
||||
hud::{Rect, TextureFlags},
|
||||
hud_layout::GuiDraw,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -25,15 +28,28 @@ pub struct ScreenVertex {
|
||||
pub rect: [f32; 4],
|
||||
pub color: [f32; 4],
|
||||
}
|
||||
/// Linear filtering (`TextureMetadataSection.blur`), else nearest.
|
||||
pub const SAMPLE_BLUR: u32 = 1;
|
||||
/// Clamp-to-edge addressing (`TextureMetadataSection.clamp`), else repeat.
|
||||
pub const SAMPLE_CLAMP: u32 = 2;
|
||||
/// The sampling bits of a standalone texture's flags.
|
||||
pub fn sampling(flags: TextureFlags) -> u32 {
|
||||
let bit = |on: bool, bit: u32| if on { bit } else { 0 };
|
||||
bit(flags.blur, SAMPLE_BLUR) | bit(flags.clamp, SAMPLE_CLAMP)
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ScreenQuad {
|
||||
pub vertices: [ScreenVertex; 4],
|
||||
pub blend: Blend,
|
||||
/// How the sprite is sampled: [`SAMPLE_BLUR`] and [`SAMPLE_CLAMP`].
|
||||
pub sampling: u32,
|
||||
}
|
||||
|
||||
/// What the camera sees this frame.
|
||||
pub struct ScreenInputs<'a> {
|
||||
pub sprites: &'a HashMap<String, Rect>,
|
||||
/// `.png.mcmeta` sampling of the standalone textures among `sprites`.
|
||||
pub flags: &'a HashMap<String, TextureFlags>,
|
||||
pub aspect: f32,
|
||||
pub partial: f32,
|
||||
/// Particle sprite of the view-blocking block around the eye.
|
||||
@@ -92,12 +108,17 @@ impl ScreenEffects {
|
||||
pub fn build(&self, input: &ScreenInputs) -> Vec<ScreenQuad> {
|
||||
let mut out = Vec::new();
|
||||
let sprite = |name: &str| input.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
// Standalone textures (`ReloadableTexture.setSampler`) follow their
|
||||
// metadata; atlas sprites (block overlay, fire, portal) sample
|
||||
// nearest with repeat.
|
||||
let texture_sampling = |name: &str| input.flags.get(name).copied().map_or(0, sampling);
|
||||
let scale = hand_scale(input.aspect);
|
||||
// Screen-effect quads live at view depth 0.5 in the hand projection.
|
||||
let hand_quad = |corners: [[f32; 2]; 4],
|
||||
uv: [[f32; 2]; 4],
|
||||
rect: [f32; 4],
|
||||
color: [f32; 4]| ScreenQuad {
|
||||
color: [f32; 4],
|
||||
sampling: u32| ScreenQuad {
|
||||
vertices: std::array::from_fn(|i| ScreenVertex {
|
||||
pos: [corners[i][0] * scale[0], corners[i][1] * scale[1]],
|
||||
uv: uv[i],
|
||||
@@ -105,6 +126,7 @@ impl ScreenEffects {
|
||||
color,
|
||||
}),
|
||||
blend: Blend::Alpha,
|
||||
sampling,
|
||||
};
|
||||
let full = [[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]];
|
||||
// ScreenEffectRenderer.submitBlockSprite: u1/v1 at (-1, -1), colour 0xFF191919.
|
||||
@@ -117,6 +139,7 @@ impl ScreenEffects {
|
||||
[[1.0, 1.0], [0.0, 1.0], [0.0, 0.0], [1.0, 0.0]],
|
||||
rect,
|
||||
[c, c, c, 1.0],
|
||||
0,
|
||||
));
|
||||
}
|
||||
if let Some((brightness, yaw, pitch)) = input.water
|
||||
@@ -129,6 +152,7 @@ impl ScreenEffects {
|
||||
uv,
|
||||
rect,
|
||||
[brightness, brightness, brightness, 0.1],
|
||||
texture_sampling("misc/underwater"),
|
||||
));
|
||||
}
|
||||
if input.on_fire
|
||||
@@ -150,11 +174,12 @@ impl ScreenEffects {
|
||||
[[1.0, 1.0], [0.0, 1.0], [0.0, 0.0], [1.0, 0.0]],
|
||||
rect,
|
||||
[1.0, 1.0, 1.0, alpha],
|
||||
0,
|
||||
));
|
||||
}
|
||||
}
|
||||
// Hud camera overlays: GUI-space full-screen blits (v grows downwards).
|
||||
let gui = |rect: [f32; 4], color: [f32; 4], blend: Blend| ScreenQuad {
|
||||
let gui = |rect: [f32; 4], color: [f32; 4], blend: Blend, sampling: u32| ScreenQuad {
|
||||
vertices: std::array::from_fn(|i| {
|
||||
let p = [[-1.0f32, 1.0], [1.0, 1.0], [1.0, -1.0], [-1.0, -1.0]][i];
|
||||
ScreenVertex {
|
||||
@@ -165,6 +190,7 @@ impl ScreenEffects {
|
||||
}
|
||||
}),
|
||||
blend,
|
||||
sampling,
|
||||
};
|
||||
let rgba = |c: u32| {
|
||||
let [a, r, g, b] = c.to_be_bytes();
|
||||
@@ -187,8 +213,14 @@ impl ScreenEffects {
|
||||
} else {
|
||||
Blend::Alpha
|
||||
};
|
||||
// The vignette darkens by texture × colour: black (in
|
||||
// daylight its brightness fades to 0 within half a
|
||||
// minute) leaves every pixel as it was.
|
||||
if blend == Blend::Vignette && color & 0x00ff_ffff == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(rect) = sprite(texture) {
|
||||
out.push(gui(rect, rgba(*color), blend));
|
||||
out.push(gui(rect, rgba(*color), blend, texture_sampling(texture)));
|
||||
}
|
||||
}
|
||||
GuiDraw::AtlasSprite {
|
||||
@@ -197,7 +229,7 @@ impl ScreenEffects {
|
||||
..
|
||||
} => {
|
||||
if let Some(rect) = sprite(name) {
|
||||
out.push(gui(rect, rgba(*color), Blend::Alpha));
|
||||
out.push(gui(rect, rgba(*color), Blend::Alpha, 0));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -243,8 +275,10 @@ mod tests {
|
||||
},
|
||||
);
|
||||
let e = ScreenEffects::default();
|
||||
let flags = HashMap::new();
|
||||
let input = ScreenInputs {
|
||||
sprites: &sprites,
|
||||
flags: &flags,
|
||||
aspect: 1.6,
|
||||
partial: 0.0,
|
||||
block_overlay: None,
|
||||
@@ -255,5 +289,80 @@ mod tests {
|
||||
let quads = e.build(&input);
|
||||
assert_eq!(quads.len(), 1);
|
||||
assert_eq!(quads[0].blend, Blend::Vignette);
|
||||
assert_eq!(quads[0].sampling, 0);
|
||||
// A black vignette (faded out in daylight) changes no pixel: no quad.
|
||||
// Below 1/255 the colour's channels are 0; a border warning tints it.
|
||||
for (brightness, warning, drawn) in [(0.003, 0.0, 0), (0.004, 0.0, 1), (0.0, 0.5, 1)] {
|
||||
let overlays = [crate::hud_overlays::vignette(320, 240, brightness, warning)];
|
||||
let input = ScreenInputs {
|
||||
block_overlay: None,
|
||||
gui_overlays: &overlays,
|
||||
..input
|
||||
};
|
||||
assert_eq!(e.build(&input).len(), drawn, "{brightness} {warning}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn standalone_textures_sample_by_their_metadata() {
|
||||
let rect = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
let sprites: HashMap<_, _> = ["misc/vignette", "misc/underwater", "block/stone"]
|
||||
.map(|name| (name.to_owned(), rect))
|
||||
.into();
|
||||
let blur = TextureFlags {
|
||||
blur: true,
|
||||
clamp: false,
|
||||
};
|
||||
let clamp = TextureFlags {
|
||||
blur: false,
|
||||
clamp: true,
|
||||
};
|
||||
let flags: HashMap<_, _> = [
|
||||
("misc/vignette".to_owned(), blur),
|
||||
("misc/underwater".to_owned(), clamp),
|
||||
// Atlas sprites ignore the texture section.
|
||||
("block/stone".to_owned(), blur),
|
||||
]
|
||||
.into();
|
||||
let quads = ScreenEffects::default().build(&ScreenInputs {
|
||||
sprites: &sprites,
|
||||
flags: &flags,
|
||||
aspect: 1.6,
|
||||
partial: 0.0,
|
||||
block_overlay: Some("block/stone".to_owned()),
|
||||
water: Some((1.0, 0.0, 0.0)),
|
||||
on_fire: false,
|
||||
gui_overlays: &[crate::hud_overlays::vignette(320, 240, 1.0, 0.0)],
|
||||
});
|
||||
let sampling: Vec<_> = quads.iter().map(|q| q.sampling).collect();
|
||||
assert_eq!(sampling, [0, SAMPLE_CLAMP, SAMPLE_BLUR]);
|
||||
}
|
||||
#[test]
|
||||
fn mcmeta_texture_section() {
|
||||
let parse = TextureFlags::from_mcmeta;
|
||||
assert_eq!(
|
||||
parse(r#"{"texture":{"blur":true}}"#),
|
||||
Some(TextureFlags {
|
||||
blur: true,
|
||||
clamp: false
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse(r#"{"texture":{"clamp":true,"mipmap_strategy":"mean"}}"#),
|
||||
Some(TextureFlags {
|
||||
blur: false,
|
||||
clamp: true
|
||||
})
|
||||
);
|
||||
// Defaults (false, false) or no section: the default sampler.
|
||||
assert_eq!(parse(r#"{"texture":{"blur":false}}"#), None);
|
||||
assert_eq!(parse(r#"{"animation":{}}"#), None);
|
||||
assert_eq!(parse("not json"), None);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,108 @@
|
||||
// Horizon shader pack prelude, api 2 additions (docs/shader-packs.md): placed
|
||||
// after the api 1 prelude before the stages of an `api = 2` pack, so api 1
|
||||
// packs keep every name they may have declared themselves.
|
||||
struct FrameExt {
|
||||
previous_view: mat4x4<f32>,
|
||||
previous_projection: mat4x4<f32>,
|
||||
shadow_view: mat4x4<f32>,
|
||||
shadow_projection: mat4x4<f32>,
|
||||
shadow_inverse_view: mat4x4<f32>,
|
||||
shadow_inverse_projection: mat4x4<f32>,
|
||||
// xyz: previous camera world position, w: frame delta time (seconds)
|
||||
previous_camera: vec4<f32>,
|
||||
// x: shadow map resolution, y: shadow distance (blocks), z: 0,
|
||||
// w: 1 while the shadow map is drawn this frame, else 0
|
||||
shadow_params: vec4<f32>,
|
||||
// x: eye block light (0..240), y: eye sky light (0..240), z: smoothed
|
||||
// rain (0..1), w: 0
|
||||
environment: vec4<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(6) var<uniform> frame_ext: FrameExt;
|
||||
@group(0) @binding(7) var scene_normal: texture_2d<f32>;
|
||||
@group(0) @binding(8) var scene_material: texture_2d<f32>;
|
||||
@group(0) @binding(9) var opaque_depth: texture_depth_2d;
|
||||
@group(0) @binding(10) var shadow_depth: texture_depth_2d;
|
||||
@group(0) @binding(11) var shadow_sampler: sampler_comparison;
|
||||
@group(0) @binding(12) var noise_texture: texture_2d<f32>;
|
||||
@group(0) @binding(13) var repeat_sampler: sampler;
|
||||
|
||||
// The opaque world's depth at `uv` (before translucent water and stained glass).
|
||||
fn opaque_depth_at(uv: vec2<f32>) -> f32 {
|
||||
return textureSampleLevel(opaque_depth, nearest_sampler, uv, 0);
|
||||
}
|
||||
|
||||
// World-space surface normal at `uv` from the G-buffer (or reconstructed from depth).
|
||||
fn world_normal_at(uv: vec2<f32>) -> vec3<f32> {
|
||||
let encoded = textureSampleLevel(scene_normal, nearest_sampler, uv, 0.0);
|
||||
if encoded.w > 0.001 {
|
||||
return normalize(encoded.xyz * 2.0 - 1.0);
|
||||
}
|
||||
let step_uv = frame.resolution.zw;
|
||||
let d0 = depth_at(uv);
|
||||
let p0 = world_offset(uv, d0);
|
||||
let px = world_offset(uv + vec2(step_uv.x, 0.0), depth_at(uv + vec2(step_uv.x, 0.0))) - p0;
|
||||
let py = world_offset(uv + vec2(0.0, step_uv.y), depth_at(uv + vec2(0.0, step_uv.y))) - p0;
|
||||
let n = cross(py, px);
|
||||
let len = length(n);
|
||||
if len > 1e-6 {
|
||||
return n / len;
|
||||
}
|
||||
return vec3(0.0, 1.0, 0.0);
|
||||
}
|
||||
// G-buffer material data at `uv`: r = block light (0..1), g = sky light (0..1),
|
||||
// b = water/translucent mask (0..1), a = roughness (0..1).
|
||||
fn material_at(uv: vec2<f32>) -> vec4<f32> {
|
||||
return textureSampleLevel(scene_material, nearest_sampler, uv, 0.0);
|
||||
}
|
||||
// Reproject a camera-relative world position into previous-frame UV and clip depth.
|
||||
fn reproject_uv(world_rel: vec3<f32>) -> vec3<f32> {
|
||||
let camera_delta = frame.camera_position.xyz - frame_ext.previous_camera.xyz;
|
||||
let prev_rel = world_rel + camera_delta;
|
||||
let clip = frame_ext.previous_projection * (frame_ext.previous_view * vec4(prev_rel, 1.0));
|
||||
if clip.w <= 1e-5 {
|
||||
return vec3(-1.0);
|
||||
}
|
||||
let ndc = clip.xyz / clip.w;
|
||||
return vec3(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5, ndc.z);
|
||||
}
|
||||
// Sample the directional sun/moon shadow map with 5-tap PCF and normal offset bias.
|
||||
// Returns 1.0 in full light and 0.0 in full shadow.
|
||||
fn sample_shadow(world_rel: vec3<f32>, world_normal: vec3<f32>) -> f32 {
|
||||
if frame_ext.shadow_params.w <= 0.001 {
|
||||
return 1.0;
|
||||
}
|
||||
let dist = length(world_rel);
|
||||
let max_dist = max(frame_ext.shadow_params.y, 16.0);
|
||||
if dist >= max_dist {
|
||||
return 1.0;
|
||||
}
|
||||
let light_dir = normalize(select(frame.moon.xyz, frame.sun.xyz, frame.sun.y >= -0.05));
|
||||
let ndotl = max(dot(world_normal, light_dir), 0.0);
|
||||
let bias_pos = world_rel + world_normal * (0.06 + 0.12 * (1.0 - ndotl));
|
||||
let light_clip = frame_ext.shadow_projection * (frame_ext.shadow_view * vec4(bias_pos, 1.0));
|
||||
let ndc = light_clip.xyz / max(light_clip.w, 1e-5);
|
||||
let shadow_uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
|
||||
if any(shadow_uv < vec2(0.002)) || any(shadow_uv > vec2(0.998)) || ndc.z <= 0.0 || ndc.z >= 1.0 {
|
||||
return 1.0;
|
||||
}
|
||||
let texel = 1.0 / max(frame_ext.shadow_params.x, 512.0);
|
||||
let ref_z = clamp(ndc.z + 0.0008, 0.0, 1.0);
|
||||
var sum = textureSampleCompareLevel(shadow_depth, shadow_sampler, shadow_uv, ref_z);
|
||||
sum += textureSampleCompareLevel(shadow_depth, shadow_sampler, shadow_uv + vec2(texel, 0.0), ref_z);
|
||||
sum += textureSampleCompareLevel(shadow_depth, shadow_sampler, shadow_uv - vec2(texel, 0.0), ref_z);
|
||||
sum += textureSampleCompareLevel(shadow_depth, shadow_sampler, shadow_uv + vec2(0.0, texel), ref_z);
|
||||
sum += textureSampleCompareLevel(shadow_depth, shadow_sampler, shadow_uv - vec2(0.0, texel), ref_z);
|
||||
let fade = smoothstep(max_dist, max_dist * 0.82, dist);
|
||||
return mix(1.0, sum * 0.2, fade * frame_ext.shadow_params.w);
|
||||
}
|
||||
|
||||
// ACES fitted filmic tonemapping curve (linear HDR -> linear 0..1).
|
||||
fn aces_tonemap(x: vec3<f32>) -> vec3<f32> {
|
||||
let a = 2.51;
|
||||
let b = 0.03;
|
||||
let c = 2.43;
|
||||
let d = 0.59;
|
||||
let e = 0.14;
|
||||
return clamp((x * (a * x + b)) / (x * (c * x + d) + e), vec3(0.0), vec3(1.0));
|
||||
}
|
||||
@@ -63,6 +63,9 @@ impl SpriteAnimation {
|
||||
pub fn pixels(&self, frame: usize) -> &[u8] {
|
||||
&self.frames[frame]
|
||||
}
|
||||
pub fn frame_count(&self) -> usize {
|
||||
self.frames.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -93,7 +96,7 @@ pub const SPRITE_SIZE: u32 = 16;
|
||||
/// with bigger textures are sampled down to fit.
|
||||
const MAX_ARRAY_BYTES: u64 = 256 << 20;
|
||||
/// Nearest-resample a square RGBA frame to another square size.
|
||||
fn resample(frame: &[u8], from: u32, to: u32) -> Vec<u8> {
|
||||
pub(crate) fn resample(frame: &[u8], from: u32, to: u32) -> Vec<u8> {
|
||||
if from == to {
|
||||
return frame.to_vec();
|
||||
}
|
||||
@@ -327,11 +330,15 @@ pub(crate) fn test_clip(size: u32, layer: u32, metadata: &str) -> SpriteAnimatio
|
||||
.flat_map(|i| [(i % size) as u8, (i / size) as u8, blue, 255])
|
||||
.collect()
|
||||
};
|
||||
test_frames(layer, vec![frame(0), frame(99)], metadata)
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_frames(layer: u32, frames: Vec<Vec<u8>>, metadata: &str) -> SpriteAnimation {
|
||||
let metadata = AnimationMetadata::from_json(metadata).unwrap();
|
||||
SpriteAnimation {
|
||||
layer,
|
||||
frames: Arc::new(vec![frame(0), frame(99)]),
|
||||
clip: Arc::new(metadata.clip(2).unwrap().unwrap()),
|
||||
clip: Arc::new(metadata.clip(frames.len() as u32).unwrap().unwrap()),
|
||||
frames: Arc::new(frames),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,13 @@ pub struct Style {
|
||||
pub click: Option<ClickEvent>,
|
||||
/// Hover text.
|
||||
pub hover: Option<Box<Text>>,
|
||||
/// The font (`FontDescription.Resource`), a full id like
|
||||
/// `minecraft:alt`; the default font when unset.
|
||||
pub font: Option<String>,
|
||||
}
|
||||
/// A style's `font` field: an identifier (`alt` is `minecraft:alt`).
|
||||
pub fn font_id(v: &Value) -> Option<String> {
|
||||
crate::command::args::parse_identifier(v.as_str()?)
|
||||
}
|
||||
impl Style {
|
||||
pub fn color(c: u32) -> Self {
|
||||
@@ -83,6 +90,9 @@ impl Style {
|
||||
if self.hover.is_none() {
|
||||
self.hover.clone_from(&base.hover);
|
||||
}
|
||||
if self.font.is_none() {
|
||||
self.font.clone_from(&base.font);
|
||||
}
|
||||
}
|
||||
pub fn is_bold(&self) -> bool {
|
||||
self.bold == Some(true)
|
||||
@@ -451,7 +461,7 @@ impl Text {
|
||||
}
|
||||
|
||||
/// The style fields of a component's JSON.
|
||||
const STYLE_KEYS: [&str; 8] = [
|
||||
const STYLE_KEYS: [&str; 9] = [
|
||||
"color",
|
||||
"bold",
|
||||
"italic",
|
||||
@@ -460,6 +470,7 @@ const STYLE_KEYS: [&str; 8] = [
|
||||
"obfuscated",
|
||||
"click_event",
|
||||
"hover_event",
|
||||
"font",
|
||||
];
|
||||
/// Style.Serializer: the fields a style sets.
|
||||
fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
@@ -500,6 +511,9 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
json!({ "action": "show_text", "value": hover.component() }),
|
||||
));
|
||||
}
|
||||
if let Some(font) = &style.font {
|
||||
out.push(("font".to_owned(), font.clone().into()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Armour trims on worn armour: `EquipmentLayerRenderer`'s trim layer. A
|
||||
//! trim's texture is `textures/trims/entity/<layer>/<pattern>.png` recoloured
|
||||
//! by `PalettedTextureManager`: every pixel whose colour is in the texture's
|
||||
//! base palette (its `palette` metadata section, `trim_base`) takes the colour
|
||||
//! at the same index of the material's palette (`textures/palettes/trim/*`),
|
||||
//! unless the equipment asset's `trim_overrides` pick another texture or
|
||||
//! palette (iron armour with an iron trim uses `trim/iron_darker`).
|
||||
use horizon_assets::palette::apply_palette;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
|
||||
/// An RGBA image: width, height, pixels.
|
||||
pub type Image = (u32, u32, Vec<u8>);
|
||||
|
||||
/// `minecraft:` added to an identifier without a namespace.
|
||||
fn qualified(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
|
||||
/// `assets/<namespace>/<folder>/<path><suffix>` of an identifier.
|
||||
fn asset_path(root: &Path, folder: &str, id: &str, suffix: &str) -> std::path::PathBuf {
|
||||
let id = qualified(id);
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", &id));
|
||||
root.join("assets")
|
||||
.join(namespace)
|
||||
.join(folder)
|
||||
.join(format!("{path}{suffix}"))
|
||||
}
|
||||
|
||||
/// `EquipmentClientInfo.TrimOverride`: when the trim matches (`when`'s
|
||||
/// material and pattern, each optional), use `texture` instead of the
|
||||
/// pattern's asset id and `palette` (none when absent) as the palette.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct TrimOverride {
|
||||
pub material: Option<String>,
|
||||
pub pattern: Option<String>,
|
||||
pub texture: Option<String>,
|
||||
pub palette: Option<String>,
|
||||
}
|
||||
impl TrimOverride {
|
||||
/// An equipment asset's `trim_overrides` list.
|
||||
pub fn list(equipment: &Value) -> Vec<Self> {
|
||||
let Some(list) = equipment.get("trim_overrides").and_then(Value::as_array) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let id = |v: &Value, key: &str| v.get(key).and_then(Value::as_str).map(qualified);
|
||||
list.iter()
|
||||
.filter_map(|o| {
|
||||
let when = o.get("when")?;
|
||||
Some(Self {
|
||||
material: id(when, "material"),
|
||||
pattern: id(when, "pattern"),
|
||||
texture: id(o, "texture"),
|
||||
palette: id(o, "palette"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `TrimPredicate.matches`.
|
||||
fn matches(&self, material: &str, pattern: &str) -> bool {
|
||||
self.material.as_deref().is_none_or(|m| m == material)
|
||||
&& self.pattern.as_deref().is_none_or(|p| p == pattern)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `trim_pattern` (asset id) and `trim_material` (palette id) registries.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TrimRegistry {
|
||||
patterns: HashMap<String, String>,
|
||||
materials: HashMap<String, String>,
|
||||
}
|
||||
impl TrimRegistry {
|
||||
/// Every `data/<namespace>/trim_pattern` and `trim_material` entry.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let mut out = Self::default();
|
||||
let Ok(namespaces) = fs::read_dir(root.join("data")) else {
|
||||
return out;
|
||||
};
|
||||
for ns in namespaces.flatten() {
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
for (folder, key, map) in [
|
||||
("trim_pattern", "asset_id", &mut out.patterns),
|
||||
("trim_material", "palette_id", &mut out.materials),
|
||||
] {
|
||||
let Ok(files) = fs::read_dir(ns.path().join(folder)) else {
|
||||
continue;
|
||||
};
|
||||
for f in files.flatten() {
|
||||
let path = f.path();
|
||||
if path.extension().is_none_or(|e| e != "json") {
|
||||
continue;
|
||||
}
|
||||
let Some(value) = fs::read(&path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(v) = value.get(key).and_then(Value::as_str) {
|
||||
let name = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
map.insert(format!("{namespace}:{name}"), qualified(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn of(patterns: &[(&str, &str)], materials: &[(&str, &str)]) -> Self {
|
||||
let map = |l: &[(&str, &str)]| {
|
||||
l.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
};
|
||||
Self {
|
||||
patterns: map(patterns),
|
||||
materials: map(materials),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The texture and palette of one trim layer.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct TrimTexture {
|
||||
/// `<namespace>:trims/entity/<layer>/<path>`, under `textures/`.
|
||||
pub texture: String,
|
||||
/// The target palette under `textures/palettes/`; none draws the
|
||||
/// texture's own colours.
|
||||
pub palette: Option<String>,
|
||||
}
|
||||
impl TrimTexture {
|
||||
/// `TrimTextureKey.getOrPrepareTexture`: the trim (`material`,
|
||||
/// `pattern` registry ids) on the `layer` (`humanoid`,
|
||||
/// `humanoid_leggings`...) of an equipment asset with `overrides`.
|
||||
pub fn resolve(
|
||||
registry: &TrimRegistry,
|
||||
overrides: &[TrimOverride],
|
||||
material: &str,
|
||||
pattern: &str,
|
||||
layer: &str,
|
||||
) -> Option<Self> {
|
||||
let (material, pattern) = (qualified(material), qualified(pattern));
|
||||
let mut texture = registry.patterns.get(&pattern)?.clone();
|
||||
let mut palette = Some(registry.materials.get(&material)?.clone());
|
||||
if let Some(o) = overrides.iter().find(|o| o.matches(&material, &pattern)) {
|
||||
if let Some(t) = &o.texture {
|
||||
texture.clone_from(t);
|
||||
}
|
||||
palette.clone_from(&o.palette);
|
||||
}
|
||||
let (namespace, path) = texture.split_once(':')?;
|
||||
Some(Self {
|
||||
texture: format!("{namespace}:trims/entity/{layer}/{path}"),
|
||||
palette,
|
||||
})
|
||||
}
|
||||
/// The atlas sprite name the generated texture is stored under.
|
||||
pub fn sprite(&self) -> String {
|
||||
format!(
|
||||
"horizon/trim/{}/{}",
|
||||
self.texture,
|
||||
self.palette.as_deref().unwrap_or("-")
|
||||
)
|
||||
}
|
||||
/// `PalettedTextureManager.prepareSlot`: the texture with its base
|
||||
/// palette's colours swapped for the target palette's (`PaletteMapping`;
|
||||
/// unchanged when a palette is missing or the sizes differ). None when
|
||||
/// the texture itself is missing.
|
||||
pub fn generate(&self, root: &Path) -> Option<Image> {
|
||||
let path = asset_path(root, "textures", &self.texture, ".png");
|
||||
let (w, h, pixels) = crate::hud::decode(&path).ok()?;
|
||||
let Some(palette) = &self.palette else {
|
||||
return Some((w, h, pixels));
|
||||
};
|
||||
let base = fs::read(path.with_extension("png.mcmeta"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
|
||||
.and_then(|m| {
|
||||
m.get("palette")?
|
||||
.get("base_palette")?
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
});
|
||||
let load = |id: &str| {
|
||||
crate::hud::decode(&asset_path(root, "textures/palettes", id, ".png"))
|
||||
.ok()
|
||||
.map(|(_, _, p)| argb(&p))
|
||||
};
|
||||
let mut mapped = argb(&pixels);
|
||||
if let (Some(key), Some(target)) = (base.as_deref().and_then(load), load(palette)) {
|
||||
// Different sizes: PaletteMapping.NONE, the texture as it is.
|
||||
let _ = apply_palette(&mut mapped, &key, &target);
|
||||
}
|
||||
let out = mapped
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
let [a, r, g, b] = p.to_be_bytes();
|
||||
[r, g, b, a]
|
||||
})
|
||||
.collect();
|
||||
Some((w, h, out))
|
||||
}
|
||||
}
|
||||
|
||||
fn argb(rgba: &[u8]) -> Vec<u32> {
|
||||
rgba.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| u32::from_be_bytes([p[3], p[0], p[1], p[2]]))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The largest trim texture of the resources (the size of the atlas's trim
|
||||
/// slots), at least vanilla's 64x32.
|
||||
pub fn slot_size(root: &Path) -> (u32, u32) {
|
||||
let mut size = (64, 32);
|
||||
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
|
||||
return size;
|
||||
};
|
||||
for ns in namespaces.flatten() {
|
||||
let Ok(layers) = fs::read_dir(ns.path().join("textures/trims/entity")) else {
|
||||
continue;
|
||||
};
|
||||
for layer in layers.flatten() {
|
||||
for f in fs::read_dir(layer.path()).into_iter().flatten().flatten() {
|
||||
let path = f.path();
|
||||
if path.extension().is_some_and(|e| e == "png")
|
||||
&& let Some((w, h)) = png_size(&path)
|
||||
{
|
||||
size = (size.0.max(w.min(512)), size.1.max(h.min(512)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
size
|
||||
}
|
||||
|
||||
/// The width and height in a PNG's IHDR chunk.
|
||||
fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
use std::io::Read;
|
||||
let mut head = [0u8; 24];
|
||||
fs::File::open(path).ok()?.read_exact(&mut head).ok()?;
|
||||
let be = |i: usize| u32::from_be_bytes(head[i..i + 4].try_into().unwrap());
|
||||
(&head[12..16] == b"IHDR").then(|| (be(16), be(20)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn registry() -> TrimRegistry {
|
||||
TrimRegistry::of(
|
||||
&[("minecraft:coast", "minecraft:coast")],
|
||||
&[
|
||||
("minecraft:iron", "minecraft:trim/iron"),
|
||||
("minecraft:gold", "minecraft:trim/gold"),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn material_palette_and_layer_folder() {
|
||||
let t = TrimTexture::resolve(
|
||||
®istry(),
|
||||
&[],
|
||||
"minecraft:gold",
|
||||
"minecraft:coast",
|
||||
"humanoid_leggings",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(t.texture, "minecraft:trims/entity/humanoid_leggings/coast");
|
||||
assert_eq!(t.palette.as_deref(), Some("minecraft:trim/gold"));
|
||||
// Unknown registry entries draw nothing.
|
||||
assert!(
|
||||
TrimTexture::resolve(
|
||||
®istry(),
|
||||
&[],
|
||||
"minecraft:wood",
|
||||
"minecraft:coast",
|
||||
"humanoid"
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equipment_overrides_pick_the_darker_palette() {
|
||||
let iron = serde_json::json!({"trim_overrides": [
|
||||
{"palette": "minecraft:trim/iron_darker", "when": {"material": "minecraft:iron"}},
|
||||
]});
|
||||
let overrides = TrimOverride::list(&iron);
|
||||
let resolve =
|
||||
|m| TrimTexture::resolve(®istry(), &overrides, m, "coast", "humanoid").unwrap();
|
||||
assert_eq!(
|
||||
resolve("iron").palette.as_deref(),
|
||||
Some("minecraft:trim/iron_darker")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve("gold").palette.as_deref(),
|
||||
Some("minecraft:trim/gold")
|
||||
);
|
||||
// An override without a palette leaves the texture's own colours.
|
||||
let plain = TrimOverride::list(&serde_json::json!({"trim_overrides": [
|
||||
{"texture": "minecraft:other", "when": {"pattern": "minecraft:coast"}},
|
||||
]}));
|
||||
let t = TrimTexture::resolve(®istry(), &plain, "gold", "coast", "humanoid").unwrap();
|
||||
assert_eq!(t.texture, "minecraft:trims/entity/humanoid/other");
|
||||
assert_eq!(t.palette, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_trims_are_recoloured() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
if !root.join("assets/minecraft/textures/trims").is_dir() {
|
||||
return;
|
||||
}
|
||||
let registry = TrimRegistry::load(&root);
|
||||
let t = TrimTexture::resolve(
|
||||
®istry,
|
||||
&[],
|
||||
"minecraft:gold",
|
||||
"minecraft:coast",
|
||||
"humanoid",
|
||||
)
|
||||
.unwrap();
|
||||
let (w, h, gold) = t.generate(&root).unwrap();
|
||||
assert_eq!((w, h), (64, 32));
|
||||
let plain = TrimTexture {
|
||||
palette: None,
|
||||
..t.clone()
|
||||
};
|
||||
let (_, _, grey) = plain.generate(&root).unwrap();
|
||||
// Every opaque pixel of the grey pattern takes a gold palette colour.
|
||||
let gold_palette = argb(
|
||||
&crate::hud::decode(&root.join("assets/minecraft/textures/palettes/trim/gold.png"))
|
||||
.unwrap()
|
||||
.2,
|
||||
);
|
||||
let mut opaque = 0;
|
||||
for (g, c) in grey.chunks(4).zip(gold.chunks(4)) {
|
||||
if g[3] != 0 {
|
||||
opaque += 1;
|
||||
let c = u32::from_be_bytes([0xFF, c[0], c[1], c[2]]);
|
||||
assert!(gold_palette.contains(&c), "{c:08x}");
|
||||
}
|
||||
}
|
||||
assert!(opaque > 0);
|
||||
assert_eq!(slot_size(&root), (64, 32));
|
||||
}
|
||||
}
|
||||
+257
-72
@@ -7,9 +7,40 @@ use crate::{
|
||||
};
|
||||
use std::{collections::HashMap, path::Path, sync::RwLock, time::Instant};
|
||||
|
||||
/// Nine-slice borders (left, top, right, bottom) from `.png.mcmeta` files.
|
||||
static NINE_SLICE: RwLock<Option<HashMap<String, [u32; 4]>>> = RwLock::new(None);
|
||||
/// `GuiSpriteScaling.NineSlice`: the sprite's size in GUI pixels, whatever
|
||||
/// its texture's resolution, its borders (left, top, right, bottom) in the
|
||||
/// same units, and whether the edges and centre stretch rather than tile.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct NineSlice {
|
||||
width: u32,
|
||||
height: u32,
|
||||
border: [u32; 4],
|
||||
stretch_inner: bool,
|
||||
}
|
||||
/// Nine-slice scaling from `.png.mcmeta` files.
|
||||
static NINE_SLICE: RwLock<Option<HashMap<String, NineSlice>>> = RwLock::new(None);
|
||||
|
||||
/// A `gui.scaling` section of type `nine_slice`.
|
||||
fn parse_nine_slice(v: &serde_json::Value) -> Option<NineSlice> {
|
||||
let s = &v["gui"]["scaling"];
|
||||
if s["type"] != "nine_slice" {
|
||||
return None;
|
||||
}
|
||||
let size = |k: &str| s[k].as_u64().map(|n| n as u32);
|
||||
let border = &s["border"];
|
||||
let border = if let Some(n) = border.as_u64() {
|
||||
[n as u32; 4]
|
||||
} else {
|
||||
let g = |k: &str| border[k].as_u64().unwrap_or(0) as u32;
|
||||
[g("left"), g("top"), g("right"), g("bottom")]
|
||||
};
|
||||
Some(NineSlice {
|
||||
width: size("width")?,
|
||||
height: size("height")?,
|
||||
border,
|
||||
stretch_inner: s["stretch_inner"].as_bool().unwrap_or(false),
|
||||
})
|
||||
}
|
||||
/// Read `gui.scaling` metadata for every GUI sprite under `root`.
|
||||
pub fn load_metadata(root: &Path) {
|
||||
let mut map = HashMap::new();
|
||||
@@ -32,47 +63,48 @@ pub fn load_metadata(root: &Path) {
|
||||
let Ok(text) = std::fs::read_to_string(&p) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
|
||||
let Some(nine_slice) = serde_json::from_str(&text)
|
||||
.ok()
|
||||
.as_ref()
|
||||
.and_then(parse_nine_slice)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let s = &v["gui"]["scaling"];
|
||||
if s["type"] != "nine_slice" {
|
||||
continue;
|
||||
}
|
||||
let border = &s["border"];
|
||||
let b = if let Some(n) = border.as_u64() {
|
||||
[n as u32; 4]
|
||||
} else {
|
||||
let g = |k: &str| border[k].as_u64().unwrap_or(0) as u32;
|
||||
[g("left"), g("top"), g("right"), g("bottom")]
|
||||
};
|
||||
let key = Path::new(stripped)
|
||||
.strip_prefix(&base)
|
||||
.map(|r| r.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
map.insert(key, b);
|
||||
map.insert(key, nine_slice);
|
||||
}
|
||||
}
|
||||
*NINE_SLICE.write().unwrap_or_else(|e| e.into_inner()) = Some(map);
|
||||
}
|
||||
fn border(sprite: &str) -> [u32; 4] {
|
||||
/// The sprite's nine-slice metadata; without any (no resources loaded), its
|
||||
/// GUI size and the widgets' usual borders.
|
||||
fn scaling(sprite: &str, r: &Rect) -> NineSlice {
|
||||
NINE_SLICE
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(sprite).copied())
|
||||
.unwrap_or(match sprite {
|
||||
"gui/sprites/widget/button"
|
||||
| "gui/sprites/widget/button_highlighted"
|
||||
| "gui/sprites/widget/button_disabled" => [3; 4],
|
||||
"gui/sprites/widget/slider_handle" | "gui/sprites/widget/slider_handle_highlighted" => {
|
||||
[2, 2, 2, 3]
|
||||
}
|
||||
_ => [1; 4],
|
||||
.unwrap_or(NineSlice {
|
||||
width: r.w,
|
||||
height: r.h,
|
||||
border: match sprite {
|
||||
"gui/sprites/widget/button"
|
||||
| "gui/sprites/widget/button_highlighted"
|
||||
| "gui/sprites/widget/button_disabled" => [3; 4],
|
||||
"gui/sprites/widget/slider_handle"
|
||||
| "gui/sprites/widget/slider_handle_highlighted" => [2, 2, 2, 3],
|
||||
_ => [1; 4],
|
||||
},
|
||||
stretch_inner: false,
|
||||
})
|
||||
}
|
||||
fn sub(r: Rect, x: f32, y: f32, w: f32, h: f32) -> Rect {
|
||||
let (du, dv) = ((r.u1 - r.u0) / r.w as f32, (r.v1 - r.v0) / r.h as f32);
|
||||
/// The region `[x, y, w, h]` of a sprite `size` GUI pixels large, in its
|
||||
/// own units (`TextureAtlasSprite.getU(x / width)`).
|
||||
fn sub(r: Rect, size: [f32; 2], [x, y, w, h]: [f32; 4]) -> Rect {
|
||||
let (du, dv) = ((r.u1 - r.u0) / size[0], (r.v1 - r.v0) / size[1]);
|
||||
Rect {
|
||||
u0: r.u0 + x * du,
|
||||
v0: r.v0 + y * dv,
|
||||
@@ -82,8 +114,9 @@ fn sub(r: Rect, x: f32, y: f32, w: f32, h: f32) -> Rect {
|
||||
h: h as u32,
|
||||
}
|
||||
}
|
||||
/// Tile `src` (a sub-rectangle of a sprite, in its pixels) over a target area.
|
||||
fn tile(batch: &mut HudBatch, r: Rect, src: [f32; 4], dst: [f32; 4], color: u32) {
|
||||
/// `blitTiledSprite`: `src` (a region of a sprite `size` large, in its
|
||||
/// units) repeated over `dst` from its top left, the last row and column cut.
|
||||
fn tile(batch: &mut HudBatch, r: Rect, size: [f32; 2], src: [f32; 4], dst: [f32; 4], color: u32) {
|
||||
let [sx, sy, sw, sh] = src;
|
||||
let [dx, dy, dw, dh] = dst;
|
||||
if sw <= 0.0 || sh <= 0.0 || dw <= 0.0 || dh <= 0.0 {
|
||||
@@ -95,13 +128,16 @@ fn tile(batch: &mut HudBatch, r: Rect, src: [f32; 4], dst: [f32; 4], color: u32)
|
||||
let mut x = 0.0;
|
||||
while x < dw {
|
||||
let w = sw.min(dw - x);
|
||||
batch.rect_uv(dx + x, dy + y, w, h, sub(r, sx, sy, w, h), color);
|
||||
batch.rect_uv(dx + x, dy + y, w, h, sub(r, size, [sx, sy, w, h]), color);
|
||||
x += sw;
|
||||
}
|
||||
y += sh;
|
||||
}
|
||||
}
|
||||
/// GuiGraphics.blitSprite with nine-slice scaling (inner parts tiled).
|
||||
/// `GuiGraphicsExtractor.blitNineSlicedSprite`: the corners at their size,
|
||||
/// the edges and centre tiled (or stretched with `stretch_inner`), all in
|
||||
/// the sprite's `gui.scaling` units, so a high-resolution pack's sprite draws
|
||||
/// as the default one does.
|
||||
pub fn nine_slice(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
@@ -112,47 +148,73 @@ pub fn nine_slice(
|
||||
h: f32,
|
||||
color: u32,
|
||||
) {
|
||||
let Some(r) = atlas.sprites.get(sprite).copied() else {
|
||||
return;
|
||||
if let Some(r) = atlas.sprites.get(sprite) {
|
||||
draw_nine_slice(batch, *r, scaling(sprite, r), [x, y, w, h], color);
|
||||
}
|
||||
}
|
||||
fn draw_nine_slice(
|
||||
batch: &mut HudBatch,
|
||||
r: Rect,
|
||||
n: NineSlice,
|
||||
[x, y, w, h]: [f32; 4],
|
||||
color: u32,
|
||||
) {
|
||||
let size = [n.width as f32, n.height as f32];
|
||||
let [sw, sh] = size;
|
||||
let [l, t, rr, b] = n.border.map(|v| v as f32);
|
||||
let (half_w, half_h) = ((w / 2.0).floor(), (h / 2.0).floor());
|
||||
let (l, rr, t, b) = (l.min(half_w), rr.min(half_w), t.min(half_h), b.min(half_h));
|
||||
// blitSprite(sprite, width, height, textureX, textureY, x, y, w, h).
|
||||
let part = |batch: &mut HudBatch, tx: f32, ty: f32, dst: [f32; 4]| {
|
||||
if dst[2] != 0.0 && dst[3] != 0.0 {
|
||||
batch.rect_uv(
|
||||
dst[0],
|
||||
dst[1],
|
||||
dst[2],
|
||||
dst[3],
|
||||
sub(r, size, [tx, ty, dst[2], dst[3]]),
|
||||
color,
|
||||
);
|
||||
}
|
||||
};
|
||||
let [l, t, rr, b] = border(sprite).map(|v| v as f32);
|
||||
let (sw, sh) = (r.w as f32, r.h as f32);
|
||||
let l = l.min(w / 2.0);
|
||||
let rr = rr.min(w / 2.0);
|
||||
let t = t.min(h / 2.0);
|
||||
let b = b.min(h / 2.0);
|
||||
let (cw, ch) = (w - l - rr, h - t - b);
|
||||
let (scw, sch) = (sw - l - rr, sh - t - b);
|
||||
// Corners.
|
||||
batch.rect_uv(x, y, l, t, sub(r, 0.0, 0.0, l, t), color);
|
||||
batch.rect_uv(x + w - rr, y, rr, t, sub(r, sw - rr, 0.0, rr, t), color);
|
||||
batch.rect_uv(x, y + h - b, l, b, sub(r, 0.0, sh - b, l, b), color);
|
||||
batch.rect_uv(
|
||||
x + w - rr,
|
||||
y + h - b,
|
||||
rr,
|
||||
b,
|
||||
sub(r, sw - rr, sh - b, rr, b),
|
||||
color,
|
||||
);
|
||||
// Edges and centre.
|
||||
tile(batch, r, [l, 0.0, scw, t], [x + l, y, cw, t], color);
|
||||
tile(
|
||||
batch,
|
||||
r,
|
||||
[l, sh - b, scw, b],
|
||||
[x + l, y + h - b, cw, b],
|
||||
color,
|
||||
);
|
||||
tile(batch, r, [0.0, t, l, sch], [x, y + t, l, ch], color);
|
||||
tile(
|
||||
batch,
|
||||
r,
|
||||
[sw - rr, t, rr, sch],
|
||||
[x + w - rr, y + t, rr, ch],
|
||||
color,
|
||||
);
|
||||
tile(batch, r, [l, t, scw, sch], [x + l, y + t, cw, ch], color);
|
||||
// blitNineSliceInnerSegment.
|
||||
let inner = |batch: &mut HudBatch, dst: [f32; 4], src: [f32; 4]| {
|
||||
if dst[2] <= 0.0 || dst[3] <= 0.0 {
|
||||
return;
|
||||
}
|
||||
if n.stretch_inner {
|
||||
batch.rect_uv(dst[0], dst[1], dst[2], dst[3], sub(r, size, src), color);
|
||||
} else {
|
||||
tile(batch, r, size, src, dst, color);
|
||||
}
|
||||
};
|
||||
if w == sw && h == sh {
|
||||
part(batch, 0.0, 0.0, [x, y, w, h]);
|
||||
return;
|
||||
}
|
||||
if h == sh {
|
||||
part(batch, 0.0, 0.0, [x, y, l, h]);
|
||||
inner(batch, [x + l, y, w - rr - l, h], [l, 0.0, sw - rr - l, sh]);
|
||||
part(batch, sw - rr, 0.0, [x + w - rr, y, rr, h]);
|
||||
return;
|
||||
}
|
||||
if w == sw {
|
||||
part(batch, 0.0, 0.0, [x, y, w, t]);
|
||||
inner(batch, [x, y + t, w, h - b - t], [0.0, t, sw, sh - b - t]);
|
||||
part(batch, 0.0, sh - b, [x, y + h - b, w, b]);
|
||||
return;
|
||||
}
|
||||
let (cw, ch) = (w - rr - l, h - b - t);
|
||||
let (scw, sch) = (sw - rr - l, sh - b - t);
|
||||
part(batch, 0.0, 0.0, [x, y, l, t]);
|
||||
inner(batch, [x + l, y, cw, t], [l, 0.0, scw, t]);
|
||||
part(batch, sw - rr, 0.0, [x + w - rr, y, rr, t]);
|
||||
part(batch, 0.0, sh - b, [x, y + h - b, l, b]);
|
||||
inner(batch, [x + l, y + h - b, cw, b], [l, sh - b, scw, b]);
|
||||
part(batch, sw - rr, sh - b, [x + w - rr, y + h - b, rr, b]);
|
||||
inner(batch, [x, y + t, l, ch], [0.0, t, l, sch]);
|
||||
inner(batch, [x + l, y + t, cw, ch], [l, t, scw, sch]);
|
||||
inner(batch, [x + w - rr, y + t, rr, ch], [sw - rr, t, rr, sch]);
|
||||
}
|
||||
/// Repeat a whole texture with each copy drawn `size` GUI pixels square.
|
||||
pub fn tiled(
|
||||
@@ -169,7 +231,6 @@ pub fn tiled(
|
||||
let Some(r) = atlas.sprites.get(sprite).copied() else {
|
||||
return;
|
||||
};
|
||||
let scale = r.w as f32 / size;
|
||||
let mut yy = 0.0;
|
||||
while yy < h {
|
||||
let th = size.min(h - yy);
|
||||
@@ -181,7 +242,7 @@ pub fn tiled(
|
||||
y + yy,
|
||||
tw,
|
||||
th,
|
||||
sub(r, 0.0, 0.0, tw * scale, th * scale),
|
||||
sub(r, [size, size], [0.0, 0.0, tw, th]),
|
||||
color,
|
||||
);
|
||||
xx += size;
|
||||
@@ -1128,6 +1189,130 @@ impl EditBox {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The quads' destination rectangles and their UVs.
|
||||
fn quads(batch: &HudBatch) -> Vec<([f32; 4], [f32; 4])> {
|
||||
batch
|
||||
.vertices
|
||||
.chunks(6)
|
||||
.map(|q| {
|
||||
let (a, c) = (q[0], q[2]);
|
||||
(
|
||||
[a.pos[0], a.pos[1], c.pos[0], c.pos[1]],
|
||||
[a.uv[0], a.uv[1], c.uv[0], c.uv[1]],
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Destination and UV rectangles equal to within f32 rounding.
|
||||
fn assert_quad(actual: ([f32; 4], [f32; 4]), expected: ([f32; 4], [f32; 4])) {
|
||||
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-5);
|
||||
assert!(
|
||||
close(actual.0, expected.0) && close(actual.1, expected.1),
|
||||
"{actual:?} != {expected:?}"
|
||||
);
|
||||
}
|
||||
/// A sprite filling the atlas, `w x h` texels as a 32x pack draws it.
|
||||
fn sprite(w: u32, h: u32) -> Rect {
|
||||
Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w,
|
||||
h,
|
||||
}
|
||||
}
|
||||
fn faithful(json: &str) -> NineSlice {
|
||||
parse_nine_slice(&serde_json::from_str(json).unwrap()).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn a_high_resolution_button_slices_in_its_gui_units() {
|
||||
// Faithful 32x's widget/button: 400x40 texels, gui.scaling 200x20.
|
||||
let n = faithful(
|
||||
r#"{"gui":{"scaling":{"type":"nine_slice","width":200,"height":20,
|
||||
"border":{"left":20,"top":4,"right":20,"bottom":4}}}}"#,
|
||||
);
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
draw_nine_slice(
|
||||
&mut batch,
|
||||
sprite(400, 40),
|
||||
n,
|
||||
[10.0, 5.0, 150.0, 20.0],
|
||||
u32::MAX,
|
||||
);
|
||||
let q = quads(&batch);
|
||||
// Full height: left border, the centre tiled, right border
|
||||
// (blitNineSlicedSprite's height == nineSlice.height case).
|
||||
assert_quad(q[0], ([10.0, 5.0, 30.0, 25.0], [0.0, 0.0, 0.1, 1.0]));
|
||||
assert_quad(q[1], ([30.0, 5.0, 140.0, 25.0], [0.1, 0.0, 0.65, 1.0]));
|
||||
assert_quad(q[2], ([140.0, 5.0, 160.0, 25.0], [0.9, 0.0, 1.0, 1.0]));
|
||||
assert_eq!(q.len(), 3);
|
||||
}
|
||||
#[test]
|
||||
fn nine_slice_corners_and_a_stretched_centre() {
|
||||
// Faithful 32x's tooltip/frame: 200x200 texels, 100x100, border 12, stretched.
|
||||
let n = faithful(
|
||||
r#"{"gui":{"scaling":{"type":"nine_slice","width":100,"height":100,"border":12,"stretch_inner":true}}}"#,
|
||||
);
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
draw_nine_slice(
|
||||
&mut batch,
|
||||
sprite(200, 200),
|
||||
n,
|
||||
[0.0, 0.0, 300.0, 40.0],
|
||||
u32::MAX,
|
||||
);
|
||||
let q = quads(&batch);
|
||||
assert_eq!(q.len(), 9);
|
||||
// Top left corner: 12 GUI pixels, 12/100 of the sprite.
|
||||
assert_quad(q[0], ([0.0, 0.0, 12.0, 12.0], [0.0, 0.0, 0.12, 0.12]));
|
||||
// The centre is one stretched quad (blitNineSliceInnerSegment, stretch_inner).
|
||||
assert_quad(q[7], ([12.0, 12.0, 288.0, 28.0], [0.12, 0.12, 0.88, 0.88]));
|
||||
// Bottom right corner.
|
||||
assert_quad(q[5], ([288.0, 28.0, 300.0, 40.0], [0.88, 0.88, 1.0, 1.0]));
|
||||
}
|
||||
#[test]
|
||||
fn a_sprite_drawn_at_its_own_size_is_one_quad() {
|
||||
let n = NineSlice {
|
||||
width: 50,
|
||||
height: 50,
|
||||
border: [10; 4],
|
||||
stretch_inner: false,
|
||||
};
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
draw_nine_slice(
|
||||
&mut batch,
|
||||
sprite(100, 100),
|
||||
n,
|
||||
[4.0, 4.0, 50.0, 50.0],
|
||||
u32::MAX,
|
||||
);
|
||||
let q = quads(&batch);
|
||||
assert_eq!(q.len(), 1);
|
||||
assert_quad(q[0], ([4.0, 4.0, 54.0, 54.0], [0.0, 0.0, 1.0, 1.0]));
|
||||
}
|
||||
#[test]
|
||||
fn borders_shrink_to_half_the_drawn_size() {
|
||||
let n = NineSlice {
|
||||
width: 20,
|
||||
height: 20,
|
||||
border: [8; 4],
|
||||
stretch_inner: true,
|
||||
};
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
// 9 wide: borders of floor(9 / 2) = 4, as vanilla's integer division.
|
||||
draw_nine_slice(
|
||||
&mut batch,
|
||||
sprite(20, 20),
|
||||
n,
|
||||
[0.0, 0.0, 9.0, 30.0],
|
||||
u32::MAX,
|
||||
);
|
||||
let q = quads(&batch);
|
||||
assert_quad(q[0], ([0.0, 0.0, 4.0, 8.0], [0.0, 0.0, 0.2, 0.4]));
|
||||
assert_quad(q[2], ([5.0, 0.0, 9.0, 8.0], [0.8, 0.0, 1.0, 0.4]));
|
||||
}
|
||||
#[test]
|
||||
fn append_fields_highlight_the_whole_value_and_blink_their_cursor() {
|
||||
use crate::hud::Blend;
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
//! `WinScreen`: the end poem, the credits and the post-credits text rolling up
|
||||
//! under the logo, after the player leaves the End for the first time (with
|
||||
//! the poem) or from Credits & Attribution (the credits alone). The texts come
|
||||
//! from the resource root, so resource packs can replace them.
|
||||
use crate::{
|
||||
font::Font,
|
||||
hud::{Blend, EndPortalFill, HudAtlas, HudBatch},
|
||||
text::{self, Style, Text},
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use std::{collections::HashSet, path::Path};
|
||||
|
||||
/// `WinScreen.OBFUSCATE_TOKEN`: `§f§k§a§b` marks where the poem hides a name.
|
||||
const OBFUSCATE_TOKEN: &str = "\u{a7}f\u{a7}k\u{a7}a\u{a7}b";
|
||||
/// `WinScreen.NAME_PREFIX`.
|
||||
const NAME_PREFIX: &str = " ";
|
||||
/// `WinScreen.SECTION_HEADING`.
|
||||
const SECTION_HEADING: &str = "============";
|
||||
/// A key the screen reacts to (`keyPressed` / `keyReleased`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Key {
|
||||
/// `KeyEvent.isUp`: the up arrow scrolls back.
|
||||
Up,
|
||||
/// Space speeds the scroll up.
|
||||
Space,
|
||||
/// Each held Control key speeds it up more.
|
||||
LeftControl,
|
||||
RightControl,
|
||||
}
|
||||
impl Key {
|
||||
pub fn from_code(code: winit::keyboard::KeyCode) -> Option<Self> {
|
||||
use winit::keyboard::KeyCode;
|
||||
match code {
|
||||
KeyCode::ArrowUp => Some(Self::Up),
|
||||
KeyCode::Space => Some(Self::Space),
|
||||
KeyCode::ControlLeft => Some(Self::LeftControl),
|
||||
KeyCode::ControlRight => Some(Self::RightControl),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub struct WinScreen {
|
||||
pub poem: bool,
|
||||
lines: Vec<Text>,
|
||||
centered: HashSet<usize>,
|
||||
total_scroll_length: i32,
|
||||
scroll: f32,
|
||||
speedup_active: bool,
|
||||
speedup_modifiers: HashSet<Key>,
|
||||
unmodified_scroll_speed: f32,
|
||||
scroll_speed: f32,
|
||||
direction: i32,
|
||||
}
|
||||
impl WinScreen {
|
||||
/// `WinScreen.<init>` and `init`: the poem (`texts/end.txt`) when `poem`,
|
||||
/// the credits (`texts/credits.json`), then `texts/postcredits.txt`.
|
||||
pub fn new(poem: bool, root: &Path, player: &str, font: &Font) -> Self {
|
||||
let speed = if poem { 0.5 } else { 0.75 };
|
||||
let mut screen = Self {
|
||||
poem,
|
||||
lines: Vec::new(),
|
||||
centered: HashSet::new(),
|
||||
total_scroll_length: 0,
|
||||
scroll: 0.0,
|
||||
speedup_active: false,
|
||||
speedup_modifiers: HashSet::new(),
|
||||
unmodified_scroll_speed: speed,
|
||||
scroll_speed: speed,
|
||||
direction: 1,
|
||||
};
|
||||
let texts = root.join("assets/minecraft/texts");
|
||||
let read = |name: &str| match std::fs::read_to_string(texts.join(name)) {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) => {
|
||||
log::error!("Couldn't load credits from file minecraft:texts/{name}: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
if poem && let Some(s) = read("end.txt") {
|
||||
screen.add_poem_file(&s, player, font);
|
||||
}
|
||||
if let Some(s) = read("credits.json") {
|
||||
match serde_json::from_str(&s) {
|
||||
Ok(json) => screen.add_credits_file(&json),
|
||||
Err(e) => {
|
||||
log::error!("Couldn't load credits from file minecraft:texts/credits.json: {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
if poem && let Some(s) = read("postcredits.txt") {
|
||||
screen.add_poem_file(&s, player, font);
|
||||
}
|
||||
screen.total_scroll_length = screen.lines.len() as i32 * 12;
|
||||
screen
|
||||
}
|
||||
/// `addPoemFile`: each line with the player's name in, its obfuscated
|
||||
/// spans turned into 3 to 6 scrambled characters, wrapped at 256 pixels
|
||||
/// and followed by an empty line; 8 more empty lines at the end.
|
||||
fn add_poem_file(&mut self, file: &str, player: &str, font: &Font) {
|
||||
let mut random = LegacyRandom::new(8124371);
|
||||
// BufferedReader.readLine: \n, \r or \r\n end a line; no empty last line.
|
||||
let mut lines: Vec<&str> = file
|
||||
.split('\n')
|
||||
.map(|l| l.strip_suffix('\r').unwrap_or(l))
|
||||
.collect();
|
||||
if file.ends_with('\n') {
|
||||
lines.pop();
|
||||
}
|
||||
for line in lines.iter().flat_map(|l| l.split('\r')) {
|
||||
let mut line = line.replace("PLAYERNAME", player);
|
||||
while let Some(pos) = line.find(OBFUSCATE_TOKEN) {
|
||||
let n = random.next_int_bound(4).unwrap_or(0) as usize + 3;
|
||||
line = format!(
|
||||
"{}\u{a7}f\u{a7}k{}{}",
|
||||
&line[..pos],
|
||||
&"XXXXXXXX"[..n],
|
||||
&line[pos + OBFUSCATE_TOKEN.len()..]
|
||||
);
|
||||
}
|
||||
let text = Text::legacy(&line);
|
||||
self.lines
|
||||
.extend(font.split_lines(&text, 256.0).into_iter().map(|(t, _)| t));
|
||||
self.add_empty_line();
|
||||
}
|
||||
for _ in 0..8 {
|
||||
self.add_empty_line();
|
||||
}
|
||||
}
|
||||
/// `addCreditsFile`: each section's heading, then each discipline's name
|
||||
/// and its titles with the names under them.
|
||||
fn add_credits_file(&mut self, json: &serde_json::Value) {
|
||||
let colored = |s: &str, color: u32| Text::styled(s, Style::color(color));
|
||||
let heading = || colored(SECTION_HEADING, text::WHITE);
|
||||
for section in json.as_array().into_iter().flatten() {
|
||||
let name = section["section"].as_str().unwrap_or_default();
|
||||
self.add_credits_line(heading(), true);
|
||||
self.add_credits_line(colored(name, text::YELLOW), true);
|
||||
self.add_credits_line(heading(), true);
|
||||
self.add_empty_line();
|
||||
self.add_empty_line();
|
||||
for discipline in section["disciplines"].as_array().into_iter().flatten() {
|
||||
let name = discipline["discipline"].as_str().unwrap_or_default();
|
||||
if !name.is_empty() {
|
||||
self.add_credits_line(colored(name, text::YELLOW), true);
|
||||
self.add_empty_line();
|
||||
self.add_empty_line();
|
||||
}
|
||||
for title in discipline["titles"].as_array().into_iter().flatten() {
|
||||
let name = title["title"].as_str().unwrap_or_default();
|
||||
self.add_credits_line(colored(name, text::GRAY), false);
|
||||
for name in title["names"].as_array().into_iter().flatten() {
|
||||
let line = format!("{NAME_PREFIX}{}", name.as_str().unwrap_or_default());
|
||||
self.add_credits_line(colored(&line, text::WHITE), false);
|
||||
}
|
||||
self.add_empty_line();
|
||||
self.add_empty_line();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn add_empty_line(&mut self) {
|
||||
self.lines.push(Text::empty());
|
||||
}
|
||||
fn add_credits_line(&mut self, line: Text, centered: bool) {
|
||||
if centered {
|
||||
self.centered.insert(self.lines.len());
|
||||
}
|
||||
self.lines.push(line);
|
||||
}
|
||||
/// The lines in order and whether each is centred.
|
||||
pub fn lines(&self) -> impl Iterator<Item = (&Text, bool)> {
|
||||
self.lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| (l, self.centered.contains(&i)))
|
||||
}
|
||||
fn calculate_scroll_speed(&self) -> f32 {
|
||||
let speed = if self.speedup_active {
|
||||
self.unmodified_scroll_speed * (5.0 + self.speedup_modifiers.len() as f32 * 15.0)
|
||||
} else {
|
||||
self.unmodified_scroll_speed
|
||||
};
|
||||
speed * self.direction as f32
|
||||
}
|
||||
/// `keyPressed` / `keyReleased` (Escape closes the screen: `onClose`).
|
||||
pub fn key(&mut self, key: Key, pressed: bool) {
|
||||
match (key, pressed) {
|
||||
(Key::Up, _) => self.direction = if pressed { -1 } else { 1 },
|
||||
(Key::Space, _) => self.speedup_active = pressed,
|
||||
(_, true) => {
|
||||
self.speedup_modifiers.insert(key);
|
||||
}
|
||||
(_, false) => {
|
||||
self.speedup_modifiers.remove(&key);
|
||||
}
|
||||
}
|
||||
self.scroll_speed = self.calculate_scroll_speed();
|
||||
}
|
||||
/// `tick`: once the text has rolled past the top, the screen is done
|
||||
/// (`respawn`, or back to the screen that opened it).
|
||||
pub fn finished(&self, height: f32) -> bool {
|
||||
let height = height as i32;
|
||||
self.scroll > (self.total_scroll_length + height + height + 24) as f32
|
||||
}
|
||||
pub fn scroll(&self) -> f32 {
|
||||
self.scroll
|
||||
}
|
||||
/// Stay `scroll` pixels in (until a key changes the speed), for
|
||||
/// screenshots.
|
||||
pub fn hold(&mut self, scroll: f32) {
|
||||
self.scroll = scroll;
|
||||
self.scroll_speed = 0.0;
|
||||
}
|
||||
/// `extractRenderState` after a frame of `delta_ticks` game ticks:
|
||||
/// `extractBackground`, the vignette, then the logo and the lines
|
||||
/// scrolled up. The poem's End portal background samples `end_portal`;
|
||||
/// the credits draw the scrolling menu background (over the blurred
|
||||
/// panorama on the title screen).
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
delta_ticks: f32,
|
||||
end_portal: EndPortalFill,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
if self.poem {
|
||||
let start = batch.vertices.len();
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, u32::MAX);
|
||||
batch.blend_since(start, Blend::EndPortal);
|
||||
batch.end_portal = end_portal;
|
||||
} else {
|
||||
self.menu_background(batch, atlas, w, h);
|
||||
}
|
||||
// extractVignette: credits_vignette.png stretched over the screen.
|
||||
if let Some(r) = atlas.sprites.get("misc/credits_vignette").copied() {
|
||||
let start = batch.vertices.len();
|
||||
batch.rect_uv(0.0, 0.0, w, h, r, u32::MAX);
|
||||
batch.blend_since(start, Blend::Vignette);
|
||||
}
|
||||
self.scroll = (self.scroll + delta_ticks * self.scroll_speed).max(0.0);
|
||||
let logo_x = (w / 2.0).floor() - 128.0;
|
||||
let logo_y = h + 50.0;
|
||||
// Everything below is drawn translated by `y_offs`.
|
||||
let y_offs = -self.scroll;
|
||||
// LogoRenderer(false) at full alpha.
|
||||
let edition_x = (w / 2.0).floor() - 64.0;
|
||||
batch.sprite_region(
|
||||
atlas,
|
||||
"gui/title/minecraft",
|
||||
[logo_x, logo_y + y_offs, 256.0, 44.0],
|
||||
256.0,
|
||||
64.0,
|
||||
);
|
||||
batch.sprite_region(
|
||||
atlas,
|
||||
crate::title_edition::SPRITE,
|
||||
[edition_x, logo_y + 44.0 - 7.0 + y_offs, 128.0, 14.0],
|
||||
128.0,
|
||||
16.0,
|
||||
);
|
||||
let mut y_pos = logo_y + 100.0;
|
||||
let last = self.lines.len().saturating_sub(1);
|
||||
for (i, line) in self.lines.iter().enumerate() {
|
||||
let mut shift = y_offs;
|
||||
if i == last {
|
||||
// The last line stops in the middle of the screen.
|
||||
let diff = y_pos + y_offs - ((h as i32 / 2) as f32 - 6.0);
|
||||
if diff < 0.0 {
|
||||
shift -= diff;
|
||||
}
|
||||
}
|
||||
if y_pos + y_offs + 12.0 + 8.0 > 0.0 && y_pos + y_offs < h {
|
||||
let x = if self.centered.contains(&i) {
|
||||
// GuiGraphicsExtractor.centeredText: x - width / 2.
|
||||
logo_x + 128.0 - (atlas.font.width_text(line) as i32 / 2) as f32
|
||||
} else {
|
||||
logo_x
|
||||
};
|
||||
batch.text_styled(atlas, line, x, y_pos + shift, u32::MAX, true);
|
||||
}
|
||||
y_pos += 12.0;
|
||||
}
|
||||
}
|
||||
/// `extractMenuBackground`: `menu_background.png` tiled at 32 pixels,
|
||||
/// scrolled by half the text's scroll.
|
||||
fn menu_background(&self, batch: &mut HudBatch, atlas: &HudAtlas, w: f32, h: f32) {
|
||||
let v = (self.scroll * 0.5).rem_euclid(32.0);
|
||||
let start = batch.vertices.len();
|
||||
crate::ui::tiled(
|
||||
batch,
|
||||
atlas,
|
||||
"gui/menu_background",
|
||||
0.0,
|
||||
-v,
|
||||
w,
|
||||
h + 32.0,
|
||||
32.0,
|
||||
u32::MAX,
|
||||
);
|
||||
batch.clip_since(start, 0.0, 0.0, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A resource root with only the credits texts: every glyph is the
|
||||
/// missing one, 6 pixels wide.
|
||||
fn screen(poem: bool, end: &str, credits: &str, post: &str) -> WinScreen {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-win-screen-{}-{poem}-{}",
|
||||
std::process::id(),
|
||||
end.len()
|
||||
));
|
||||
let texts = root.join("assets/minecraft/texts");
|
||||
std::fs::create_dir_all(&texts).unwrap();
|
||||
std::fs::write(texts.join("end.txt"), end).unwrap();
|
||||
std::fs::write(texts.join("credits.json"), credits).unwrap();
|
||||
std::fs::write(texts.join("postcredits.txt"), post).unwrap();
|
||||
let atlas = HudAtlas::fonts(&root).unwrap();
|
||||
let screen = WinScreen::new(poem, &root, "Steve", &atlas.font);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
screen
|
||||
}
|
||||
const CREDITS: &str = r#"[{"section":"Studio","disciplines":[
|
||||
{"discipline":"","titles":[{"title":"Head","names":["Ada","Bo"]}]},
|
||||
{"discipline":"Art","titles":[{"title":"Artist","names":[]}]}]}]"#;
|
||||
|
||||
#[test]
|
||||
fn the_poem_names_the_player_and_scrambles_its_hidden_names() {
|
||||
let s = screen(
|
||||
true,
|
||||
"\u{a7}3Hello PLAYERNAME.\r\n\n\u{a7}2\u{a7}f\u{a7}k\u{a7}a\u{a7}bkey\n",
|
||||
"[]",
|
||||
"End.",
|
||||
);
|
||||
let lines: Vec<(String, bool)> = s.lines().map(|(t, c)| (t.plain(), c)).collect();
|
||||
// Each line then an empty one; the empty line adds no text line; 8
|
||||
// empty lines after each file.
|
||||
let mut want = vec!["Hello Steve.", ""];
|
||||
want.push("");
|
||||
// LegacyRandom(8124371).nextInt(4) + 3 scrambled characters.
|
||||
let n = LegacyRandom::new(8124371).next_int_bound(4).unwrap() as usize + 3;
|
||||
let hidden = format!("{}key", &"XXXXXXXX"[..n]);
|
||||
want.extend([hidden.as_str(), ""]);
|
||||
want.extend([""; 8]);
|
||||
want.extend(["End.", ""]);
|
||||
want.extend([""; 8]);
|
||||
assert_eq!(
|
||||
lines,
|
||||
want.iter()
|
||||
.map(|l| (l.to_string(), false))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
let hidden_line = s.lines().nth(3).unwrap().0;
|
||||
let styles: Vec<_> = hidden_line.chars().map(|(_, s)| s.obfuscated).collect();
|
||||
assert!(styles[..n].iter().all(|o| *o == Some(true)));
|
||||
assert_eq!(s.total_scroll_length, lines.len() as i32 * 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_poem_lines_wrap_at_256_pixels() {
|
||||
// 6-pixel glyphs: 42 fit a line, and the space at each break goes.
|
||||
let line = vec!["a"; 50].join(" ");
|
||||
let s = screen(true, &line, "[]", "");
|
||||
let lines: Vec<String> = s.lines().take(4).map(|(t, _)| t.plain()).collect();
|
||||
assert_eq!(
|
||||
lines.iter().map(|l| l.chars().count()).collect::<Vec<_>>(),
|
||||
[41, 41, 15, 0]
|
||||
);
|
||||
assert!(lines.iter().all(|l| !l.starts_with(' ')));
|
||||
}
|
||||
#[test]
|
||||
fn credits_centre_the_headings_and_list_the_names() {
|
||||
let s = screen(false, "ignored", CREDITS, "ignored");
|
||||
let lines: Vec<(String, bool)> = s.lines().map(|(t, c)| (t.plain(), c)).collect();
|
||||
let l = |s: &str, c| (s.to_string(), c);
|
||||
let names = |n: &str| l(&format!("{NAME_PREFIX}{n}"), false);
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
l(SECTION_HEADING, true),
|
||||
l("Studio", true),
|
||||
l(SECTION_HEADING, true),
|
||||
l("", false),
|
||||
l("", false),
|
||||
l("Head", false),
|
||||
names("Ada"),
|
||||
names("Bo"),
|
||||
l("", false),
|
||||
l("", false),
|
||||
l("Art", true),
|
||||
l("", false),
|
||||
l("", false),
|
||||
l("Artist", false),
|
||||
l("", false),
|
||||
l("", false),
|
||||
]
|
||||
);
|
||||
let color = |i: usize| s.lines().nth(i).unwrap().0.chars().next().unwrap().1.color;
|
||||
assert_eq!(
|
||||
[color(0), color(1), color(5), color(6), color(10)],
|
||||
[
|
||||
Some(text::WHITE),
|
||||
Some(text::YELLOW),
|
||||
Some(text::GRAY),
|
||||
Some(text::WHITE),
|
||||
Some(text::YELLOW)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_set_the_scroll_speed_and_direction() {
|
||||
let mut s = screen(false, "", CREDITS, "");
|
||||
assert_eq!(s.scroll_speed, 0.75);
|
||||
s.key(Key::Space, true);
|
||||
assert_eq!(s.scroll_speed, 0.75 * 5.0);
|
||||
s.key(Key::LeftControl, true);
|
||||
s.key(Key::RightControl, true);
|
||||
assert_eq!(s.scroll_speed, 0.75 * 35.0);
|
||||
s.key(Key::Up, true);
|
||||
assert_eq!(s.scroll_speed, -0.75 * 35.0);
|
||||
s.key(Key::Space, false);
|
||||
assert_eq!(s.scroll_speed, -0.75);
|
||||
s.key(Key::Up, false);
|
||||
s.key(Key::LeftControl, false);
|
||||
assert_eq!(s.scroll_speed, 0.75);
|
||||
// The poem rolls at 0.5 pixels a tick.
|
||||
assert_eq!(screen(true, "", CREDITS, "").scroll_speed, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_screen_ends_once_everything_rolled_past() {
|
||||
let mut s = screen(false, "", CREDITS, "");
|
||||
let atlas = HudAtlas::fonts(Path::new("/nonexistent")).unwrap();
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
let h = batch.height;
|
||||
let end = (s.total_scroll_length as f32 + 2.0 * h + 24.0) / 0.75;
|
||||
s.draw(&mut batch, &atlas, end - 1.0, EndPortalFill::default());
|
||||
assert!(!s.finished(h));
|
||||
// Scrolling back never goes above the start.
|
||||
s.key(Key::Up, true);
|
||||
s.draw(&mut batch, &atlas, end * 2.0, EndPortalFill::default());
|
||||
assert_eq!(s.scroll(), 0.0);
|
||||
s.key(Key::Up, false);
|
||||
s.draw(&mut batch, &atlas, end + 1.0, EndPortalFill::default());
|
||||
assert!(s.finished(h));
|
||||
}
|
||||
}
|
||||
@@ -430,9 +430,12 @@ fn guests_see_each_other_and_the_world_entities() {
|
||||
velocity: [0.0; 3],
|
||||
data: 0,
|
||||
meta: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
equipment: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
precise: false,
|
||||
needs_sync: false,
|
||||
leash: None,
|
||||
};
|
||||
let first = thread::spawn(move || {
|
||||
let mut bot = connect(port, "Alex", false)?;
|
||||
|
||||
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user