mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
202
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).
|
||||
Generated
+1
@@ -1395,6 +1395,7 @@ dependencies = [
|
||||
name = "horizon-bench"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-telemetry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -8,3 +8,5 @@ description = "The benchmark report format (docs/benchmark-report.md), shared by
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# The hardware probes shared with telemetry (cores, clocks, drive, VRAM, power, form).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
@@ -16,8 +16,22 @@ pub struct Hardware {
|
||||
pub cpu: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_threads: Option<usize>,
|
||||
/// Physical cores.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_cores: Option<u16>,
|
||||
/// Base and top boost clock, where the OS gives them.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_base_mhz: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_boost_mhz: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ram_mb: Option<u64>,
|
||||
/// The drive under the game folder: `nvme`, `ssd` or `hdd`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub storage: Option<String>,
|
||||
/// `laptop` or `desktop`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub form: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gpu: Option<Gpu>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -33,6 +47,9 @@ pub struct Gpu {
|
||||
pub backend: String,
|
||||
/// `integrated`, `discrete`, `virtual`, `cpu`, `other`.
|
||||
pub kind: String,
|
||||
/// Its own memory in MiB (rounded to 256), for a discrete GPU whose driver says.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub vram_mb: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -47,17 +64,50 @@ pub struct Display {
|
||||
|
||||
impl Hardware {
|
||||
pub fn detect() -> Hardware {
|
||||
let probes = probes();
|
||||
Hardware {
|
||||
os: std::env::consts::OS.to_owned(),
|
||||
os_version: os_version(),
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
cpu: cpu(),
|
||||
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
cpu_cores: probes.cores,
|
||||
cpu_base_mhz: probes.base,
|
||||
cpu_boost_mhz: probes.boost,
|
||||
ram_mb: ram_bytes().map(|b| b >> 20),
|
||||
storage: None,
|
||||
form: probes.form,
|
||||
gpu: None,
|
||||
display: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hardware::detect`] with the kind of drive `game_dir` is on.
|
||||
pub fn detect_in(game_dir: &std::path::Path) -> Hardware {
|
||||
Hardware {
|
||||
storage: horizon_telemetry::system::storage(game_dir).map(str::to_owned),
|
||||
..Hardware::detect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the telemetry probes read (`horizon_telemetry::system`).
|
||||
struct Probes {
|
||||
cores: Option<u16>,
|
||||
base: Option<u32>,
|
||||
boost: Option<u32>,
|
||||
form: Option<String>,
|
||||
}
|
||||
|
||||
fn probes() -> Probes {
|
||||
let s = horizon_telemetry::payload::System::detect();
|
||||
let some = |n: u32| (n > 0).then_some(n);
|
||||
Probes {
|
||||
cores: (s.cores > 0).then_some(s.cores),
|
||||
base: some(s.cpu_base_mhz),
|
||||
boost: some(s.cpu_boost_mhz),
|
||||
form: (!s.form.is_empty()).then_some(s.form),
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-minute load average: above about the number of threads the machine was busy.
|
||||
@@ -82,25 +132,7 @@ pub fn load_average() -> Option<f64> {
|
||||
|
||||
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
|
||||
pub fn power_source() -> Option<&'static str> {
|
||||
if cfg!(target_os = "macos") {
|
||||
let batt = output("pmset", &["-g", "batt"])?;
|
||||
return Some(if batt.contains("'Battery Power'") {
|
||||
"battery"
|
||||
} else {
|
||||
"ac"
|
||||
});
|
||||
}
|
||||
let mut on_battery = None;
|
||||
for entry in std::fs::read_dir("/sys/class/power_supply").ok()?.flatten() {
|
||||
let read =
|
||||
|name: &str| std::fs::read_to_string(entry.path().join(name)).unwrap_or_default();
|
||||
match read("type").trim() {
|
||||
"Mains" if read("online").trim() == "1" => return Some("ac"),
|
||||
"Battery" => on_battery = Some("battery"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
on_battery
|
||||
horizon_telemetry::system::power()
|
||||
}
|
||||
|
||||
/// Resident memory of a process, in bytes (macOS, Linux).
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `banner_patterns` layers carried by banner items, shields and placed
|
||||
//! banners, and the loom rules of `LoomMenu` (selectable patterns, the
|
||||
//! six-layer limit and the result stack).
|
||||
use crate::{block_entity_render::DYES, item_data, items::ItemStack, items::Items};
|
||||
use crate::{block_entity_render::DYES, items::ItemStack, items::Items};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path, sync::Arc};
|
||||
@@ -261,12 +261,6 @@ pub fn layers_from_nbt(tag: Option<&Tag>) -> Vec<BannerLayer> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Keeps `item_data` linked: the `StackData` fields the banner code reads.
|
||||
pub fn set_layers(stack: &mut ItemStack, layers: Vec<BannerLayer>) {
|
||||
let data: &mut item_data::StackData = stack.data_mut();
|
||||
data.banner_patterns = layers;
|
||||
stack.tidy();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -140,7 +140,22 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
driver: text(&renderer["driver"]),
|
||||
backend: text(&renderer["backend"]).to_lowercase(),
|
||||
kind: text(&renderer["device_type"]),
|
||||
vram_mb: None,
|
||||
});
|
||||
if let Some(gpu) = report.hardware.gpu.as_mut() {
|
||||
let vendor = horizon_telemetry::system::gpu_vendor(&gpu.name, 0);
|
||||
let vendor = if gpu.vendor.is_empty() {
|
||||
vendor
|
||||
} else {
|
||||
gpu.vendor.as_str()
|
||||
}
|
||||
.to_owned();
|
||||
if gpu.kind.contains("iscrete") {
|
||||
gpu.vram_mb = horizon_telemetry::system::vram_mb(&vendor);
|
||||
}
|
||||
}
|
||||
report.hardware.storage =
|
||||
horizon_telemetry::system::storage(&setup.game_dir).map(str::to_owned);
|
||||
report.hardware.display = serde_json::from_value(title["display"].clone()).ok();
|
||||
|
||||
replace_world(&setup.game_dir)?;
|
||||
@@ -224,6 +239,10 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
|
||||
let status = control.status();
|
||||
let renderer = &status["renderer"];
|
||||
// On Wayland the window is on a screen only once it has been shown: ask again.
|
||||
if report.hardware.display.is_none() {
|
||||
report.hardware.display = serde_json::from_value(status["display"].clone()).ok();
|
||||
}
|
||||
let c = &mut report.conditions;
|
||||
c.scenario = Some(setup.scenario.clone());
|
||||
c.seed = Some(SEED.to_string());
|
||||
|
||||
@@ -32,16 +32,18 @@ pub enum Group {
|
||||
Move,
|
||||
Act,
|
||||
Screens,
|
||||
Multiplayer,
|
||||
World,
|
||||
Scripting,
|
||||
}
|
||||
impl Group {
|
||||
pub const ALL: [Group; 7] = [
|
||||
pub const ALL: [Group; 8] = [
|
||||
Group::Session,
|
||||
Group::Observe,
|
||||
Group::Move,
|
||||
Group::Act,
|
||||
Group::Screens,
|
||||
Group::Multiplayer,
|
||||
Group::World,
|
||||
Group::Scripting,
|
||||
];
|
||||
@@ -52,6 +54,7 @@ impl Group {
|
||||
Group::Move => "Move and look",
|
||||
Group::Act => "Act on the world",
|
||||
Group::Screens => "Screens, menus and chat",
|
||||
Group::Multiplayer => "Multiplayer, LAN and friends",
|
||||
Group::World => "World and cheats",
|
||||
Group::Scripting => "Scripting and benchmarks",
|
||||
}
|
||||
@@ -117,6 +120,50 @@ const OVER: Param = flag(
|
||||
"turn with mouse motion spread over this many seconds (eased), as a player would",
|
||||
);
|
||||
const FACE: Ty = Ty::Enum(&["down", "up", "north", "south", "west", "east"]);
|
||||
const BOX: [Param; 6] = [
|
||||
p("x1", Ty::Int, "corner X"),
|
||||
p("y1", Ty::Int, "corner Y"),
|
||||
p("z1", Ty::Int, "corner Z"),
|
||||
p("x2", Ty::Int, "opposite X"),
|
||||
p("y2", Ty::Int, "opposite Y"),
|
||||
p("z2", Ty::Int, "opposite Z"),
|
||||
];
|
||||
const PAGES: Ty = Ty::Enum(&[
|
||||
"main",
|
||||
"video",
|
||||
"sound",
|
||||
"controls",
|
||||
"keybinds",
|
||||
"controller",
|
||||
"controller-binds",
|
||||
"mouse",
|
||||
"chat",
|
||||
"accessibility",
|
||||
"language",
|
||||
"font",
|
||||
"packs",
|
||||
"mods",
|
||||
"graphics",
|
||||
"graphics-advanced",
|
||||
"extras",
|
||||
"horizon",
|
||||
"shader-packs",
|
||||
"agent",
|
||||
]);
|
||||
|
||||
/// `--confirm`, required by destructive commands: the player agreed.
|
||||
const CONFIRM: Param = Param {
|
||||
name: "confirm",
|
||||
ty: Ty::Bool,
|
||||
required: true,
|
||||
flag: true,
|
||||
help: "true once the player agreed: it cannot be undone",
|
||||
};
|
||||
|
||||
/// MCP tools that delete or overwrite the player's things (`destructiveHint`).
|
||||
pub const DESTRUCTIVE: &[&str] = &["world-delete", "fill", "setblock"];
|
||||
/// MCP tools that reach other players or the network (`openWorldHint`): only when the player asks.
|
||||
pub const OPEN_WORLD: &[&str] = &["connect", "lan", "friends", "chat", "command"];
|
||||
|
||||
macro_rules! cmd {
|
||||
($name:expr, $group:ident, $usage:expr, $summary:expr, $details:expr, [$($ex:expr),* $(,)?], $params:expr, mcp: $mcp:expr, local: $local:expr $(, wire: $wire:expr)?) => {
|
||||
@@ -164,7 +211,19 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[flag("lines", Ty::Int, "how many lines of latest.log (default 100)"), flag("crash", Ty::Bool, "the newest crash report instead")],
|
||||
mcp: true, local: true),
|
||||
cmd!("status", Session, "status", "game, renderer and streaming state (the scripting socket's original summary)",
|
||||
"", ["horizon-ctl status"], &[], mcp: false, local: false),
|
||||
"Also what F3 shows: the overlay flags, the renderer, chunk streaming and memory.", ["horizon-ctl status"], &[], mcp: true, local: false),
|
||||
cmd!("worlds", Session, "worlds", "the saved worlds, newest first: folder, name, mode, last played, version",
|
||||
"folder is what open, world-rename, world-backup and world-delete take.", ["horizon-ctl worlds"], &[], mcp: true, local: false),
|
||||
cmd!("world-rename", Session, "world-rename FOLDER NAME", "rename a world, as Edit World does (its folder stays)",
|
||||
"Needs the title screen (no world open).", ["horizon-ctl world-rename Test \"My Base\""],
|
||||
&[p("folder", Ty::Str, "world folder from worlds"), p("name", Ty::Rest, "the new name")], mcp: true, local: false),
|
||||
cmd!("world-backup", Session, "world-backup FOLDER", "zip a world into backups/, as Edit World's Make Backup does",
|
||||
"Needs the title screen. Replies once the zip is written, with its size.", ["horizon-ctl world-backup Test"],
|
||||
&[p("folder", Ty::Str, "world folder from worlds")], mcp: true, local: false),
|
||||
cmd!("world-delete", Session, "world-delete FOLDER --confirm", "delete a world for good, as Select World's Delete does",
|
||||
"Needs the title screen and --confirm: ask the player first, it cannot be undone (world-backup keeps a copy).",
|
||||
["horizon-ctl world-delete Test --confirm"],
|
||||
&[p("folder", Ty::Str, "world folder from worlds"), CONFIRM], mcp: true, local: false),
|
||||
cmd!("open", Session, "open WORLD [SEED] [creative]", "open or create a world from the title screen",
|
||||
"Opens saves/WORLD, creating it with SEED (and creative mode) when it doesn't exist.",
|
||||
["horizon-ctl open Test 12345", "horizon-ctl open Build 0 creative"],
|
||||
@@ -190,10 +249,10 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("light", Observe, "light X1 Y1 Z1 X2 Y2 Z2", "the stored sky and block light of a box",
|
||||
"At most 32768 blocks per request. One hex digit per block in `sky` and `block`, x fastest, then z, then y; `.` where no chunk is loaded.",
|
||||
["horizon-ctl light 0 64 0 15 70 15"],
|
||||
&[p("x1", Ty::Int, "corner X"), p("y1", Ty::Int, "corner Y"), p("z1", Ty::Int, "corner Z"), p("x2", Ty::Int, "opposite X"), p("y2", Ty::Int, "opposite Y"), p("z2", Ty::Int, "opposite Z")], mcp: true, local: false),
|
||||
&BOX, mcp: true, local: false),
|
||||
cmd!("blocks", Observe, "blocks X1 Y1 Z1 X2 Y2 Z2", "the non-air blocks of a box, with counts per block",
|
||||
"At most 32768 blocks per request.", ["horizon-ctl blocks 0 60 0 15 70 15"],
|
||||
&[p("x1", Ty::Int, "corner X"), p("y1", Ty::Int, "corner Y"), p("z1", Ty::Int, "corner Z"), p("x2", Ty::Int, "opposite X"), p("y2", Ty::Int, "opposite Y"), p("z2", Ty::Int, "opposite Z")], mcp: true, local: false),
|
||||
&BOX, mcp: true, local: false),
|
||||
cmd!("find-block", Observe, "find-block BLOCK [RADIUS]", "the nearest loaded blocks of a kind",
|
||||
"Searches a cube of RADIUS (default 32, at most 64) around the player; returns the count and the 16 nearest.",
|
||||
["horizon-ctl find-block oak_log", "horizon-ctl find-block diamond_ore 48"],
|
||||
@@ -264,12 +323,15 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
"down holds the key until key ... up, release or stop; wait does not release it. move, goto, jump, break and the other actions release the movement keys when they end.",
|
||||
["horizon-ctl key space tap"], &[p("key", Ty::Str, "key name"), p("action", Ty::Enum(&["down", "up", "tap", "repeat"]), "what to do")], mcp: true, local: false),
|
||||
cmd!("mouse", Act, "mouse left|right|middle down|up|click", "press or release a mouse button in the world", "",
|
||||
["horizon-ctl mouse right click"], &[p("button", Ty::Enum(&["left", "right", "middle"]), "button"), p("action", Ty::Enum(&["down", "up", "click"]), "what to do")], mcp: false, local: false),
|
||||
["horizon-ctl mouse right click"], &[p("button", Ty::Enum(&["left", "right", "middle"]), "button"), p("action", Ty::Enum(&["down", "up", "click"]), "what to do")], mcp: true, local: false),
|
||||
cmd!("interact-entity", Act, "interact-entity ID", "right-click an entity: trade with a villager, mount a horse, ride a boat, shear a sheep",
|
||||
"Faces the entity (from entities), right-clicks it with the held item, waits two ticks and replies with the screen.",
|
||||
["horizon-ctl interact-entity 42"], &[p("id", Ty::Int, "entity id from entities")], mcp: true, local: true),
|
||||
cmd!("pad", Act, "pad BUTTON down|up|tap | pad stick left|right X Y | pad trigger left|right V | pad release | pad status", "a virtual controller: buttons, sticks and triggers as a real pad would send them",
|
||||
"Buttons by position: south east west north lb rb lt rt select start ls rs up down left right (south is Xbox A / PlayStation cross). Sticks run -1..1 with up positive and stay tilted until centred; triggers 0..1. Merged with any real controller; status lists the connected ones and what is held.",
|
||||
["horizon-ctl pad south tap", "horizon-ctl pad stick left 0 1", "horizon-ctl pad trigger right 1", "horizon-ctl pad status"],
|
||||
&[p("what", Ty::Str, "a button name, stick, trigger, release or status"), opt("a", Ty::Str, "down|up|tap, or left|right"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: false, local: false),
|
||||
cmd!("release", Act, "release", "release every key and mouse button", "", ["horizon-ctl release"], &[], mcp: false, local: false),
|
||||
&[p("what", Ty::Str, "a button name, stick, trigger, release or status"), opt("a", Ty::Str, "down|up|tap, or left|right"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: true, local: false),
|
||||
cmd!("release", Act, "release", "release every key and mouse button", "", ["horizon-ctl release"], &[], mcp: true, local: false),
|
||||
cmd!("stop", Act, "stop", "cancel the running action (goto, break...) and release every key", "", ["horizon-ctl stop"], &[], mcp: true, local: false),
|
||||
// Screens
|
||||
cmd!("open-inventory", Screens, "open-inventory", "open the inventory screen (crafting grid 100-103, result 200)", "",
|
||||
@@ -317,7 +379,31 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("respawn", Screens, "respawn", "respawn from the death screen", "", ["horizon-ctl respawn"], &[], mcp: true, local: false),
|
||||
cmd!("pause", Screens, "pause", "open the pause menu", "", ["horizon-ctl pause"], &[], mcp: true, local: false),
|
||||
cmd!("resume", Screens, "resume", "leave the pause menu", "", ["horizon-ctl resume"], &[], mcp: true, local: false),
|
||||
cmd!("options", Screens, "options [PAGE]", "open the options screen, or one of its pages (Horizon's too)",
|
||||
"Then ui lists its widgets and click, set and type change them, as with the mouse. options-set changes an options.txt value without the screen.",
|
||||
["horizon-ctl options video", "horizon-ctl options horizon"], &[opt("page", PAGES, "the page (default: the main options)")], mcp: true, local: false),
|
||||
cmd!("book", Screens, "book [next|back|take|done]", "the open book or lectern: its page text, or press one of its buttons",
|
||||
"", ["horizon-ctl book", "horizon-ctl book next"], &[opt("action", Ty::Enum(&["next", "back", "take", "done"]), "a button to press")], mcp: true, local: false),
|
||||
cmd!("signedit", Screens, "signedit X Y Z [front|back]", "open the sign editor of the sign at X Y Z (then type and close)",
|
||||
"", ["horizon-ctl signedit 3 64 1"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("side", Ty::Enum(&["front", "back"]), "which side")], mcp: true, local: false),
|
||||
cmd!("mount-menu", Screens, "mount-menu", "open the inventory of the horse, donkey, mule or llama being ridden",
|
||||
"As the inventory key does while riding; also opens the nearest one's.", ["horizon-ctl mount-menu"], &[], mcp: true, local: false),
|
||||
cmd!("chat-log", Screens, "chat-log [N]", "the newest N chat messages", "", ["horizon-ctl chat-log 5"], &[], mcp: false, local: false),
|
||||
// Multiplayer
|
||||
cmd!("connect", Multiplayer, "connect ADDRESS [NAME]", "join a multiplayer server, or a friend's world by its invite code, from the title screen",
|
||||
"ADDRESS is HOST[:PORT], or friend:CODE for an invite code. Only when the player asks: it reaches the network.",
|
||||
["horizon-ctl connect play.example.net", "horizon-ctl connect localhost:25565", "horizon-ctl connect friend:0B33K-Q405Q-..."],
|
||||
&[p("address", Ty::Str, "HOST[:PORT] or friend:CODE"), opt("name", Ty::Str, "player name when offline")], mcp: true, local: false),
|
||||
cmd!("disconnect", Multiplayer, "disconnect", "leave the server, back to the server list", "", ["horizon-ctl disconnect"], &[], mcp: true, local: false),
|
||||
cmd!("servers", Multiplayer, "servers", "the Multiplayer screen's server list: name and address",
|
||||
"Add, edit and remove them on the Multiplayer screen (click-button Multiplayer, then ui).", ["horizon-ctl servers"], &[], mcp: true, local: false),
|
||||
cmd!("lan", Multiplayer, "lan open [PORT] [commands] [force] | lan close | lan status", "open the world to LAN (Open to LAN), stop, or see the guests",
|
||||
"PORT 0 or none picks one; commands lets guests use cheats; force forces the game mode. Only when the player asks.",
|
||||
["horizon-ctl lan open", "horizon-ctl lan status", "horizon-ctl lan close"],
|
||||
&[p("action", Ty::Enum(&["open", "close", "status"]), "what to do"), opt("port", Ty::Int, "port for open"), opt("commands", Ty::Bool, "allow cheats"), opt("force", Ty::Bool, "force the game mode")], mcp: true, local: false),
|
||||
cmd!("friends", Multiplayer, "friends host|stop|status", "Play with Friends: share the open world behind an invite code, stop, or see who joined",
|
||||
"status gives the code: share it only when the player asks. Friends join with connect friend:CODE.",
|
||||
["horizon-ctl friends host", "horizon-ctl friends status"], &[p("action", Ty::Enum(&["host", "stop", "status"]), "what to do")], mcp: true, local: false),
|
||||
// World
|
||||
cmd!("give", World, "give ITEM [COUNT]", "add items to the inventory", "", ["horizon-ctl give oak_log 16"],
|
||||
&[p("item", Ty::Str, "item id"), opt("count", Ty::Int, "how many (default 1)")], mcp: true, local: false),
|
||||
@@ -325,8 +411,20 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[p("ticks", Ty::Int, "day time")], mcp: true, local: false),
|
||||
cmd!("setblock", World, "setblock X Y Z BLOCK[props]", "set a block (with neighbour updates)", "", ["horizon-ctl setblock 0 64 0 stone"],
|
||||
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], p("block", Ty::Str, "block state such as oak_stairs[facing=east]")], mcp: true, local: false),
|
||||
cmd!("summon", World, "summon KIND [X Y Z] [baby]", "spawn a mob", "", ["horizon-ctl summon cow"], &[p("kind", Ty::Str, "mob type")], mcp: true, local: false),
|
||||
cmd!("effect", World, "effect ID SECONDS [AMPLIFIER] | effect clear", "give a status effect", "", ["horizon-ctl effect speed 30 1"], &[], mcp: false, local: false),
|
||||
cmd!("summon", World, "summon KIND [X Y Z] [baby]", "spawn a mob (in front of the player without a position)", "Replies with its id.", ["horizon-ctl summon cow", "horizon-ctl summon pig 10 64 3 baby"],
|
||||
&[p("kind", Ty::Str, "mob type"), opt("x", Ty::Num, "X"), opt("y", Ty::Num, "Y"), opt("z", Ty::Num, "Z"), opt("baby", Ty::Bool, "a baby")], mcp: true, local: false),
|
||||
cmd!("effect", World, "effect ID SECONDS [AMPLIFIER] | effect clear", "give a status effect, or clear them all", "", ["horizon-ctl effect speed 30 1", "horizon-ctl effect clear"],
|
||||
&[p("id", Ty::Str, "effect id such as speed, or clear"), opt("seconds", Ty::Num, "duration"), opt("amplifier", Ty::Int, "level - 1 (default 0)")], mcp: true, local: false),
|
||||
cmd!("effects", World, "effects", "the player's active status effects", "", ["horizon-ctl effects"], &[], mcp: true, local: false),
|
||||
cmd!("xp", World, "xp LEVELS", "add experience levels (negative removes)", "", ["horizon-ctl xp 30"], &[p("levels", Ty::Int, "levels to add")], mcp: true, local: false),
|
||||
cmd!("enchant", World, "enchant ID LEVEL", "enchant the held item (a book stores it)", "", ["horizon-ctl enchant sharpness 5"],
|
||||
&[p("id", Ty::Str, "enchantment id"), p("level", Ty::Int, "level")], mcp: true, local: false),
|
||||
cmd!("potion", World, "potion ITEM POTION", "give a potion item", "", ["horizon-ctl potion splash_potion healing"],
|
||||
&[p("item", Ty::Enum(&["potion", "splash_potion", "lingering_potion", "tipped_arrow"]), "which item"), p("potion", Ty::Str, "potion id")], mcp: true, local: false),
|
||||
cmd!("fill", World, "fill X1 Y1 Z1 X2 Y2 Z2 BLOCK", "set a box of loaded blocks", "", ["horizon-ctl fill 0 64 0 4 64 4 glass"],
|
||||
&[BOX[0], BOX[1], BOX[2], BOX[3], BOX[4], BOX[5], p("block", Ty::Str, "block state")], mcp: true, local: false),
|
||||
cmd!("container", World, "container X Y Z [ITEM COUNT]", "list a container's items, or add items to it", "", ["horizon-ctl container 3 64 1", "horizon-ctl container 3 64 1 diamond 3"],
|
||||
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("item", Ty::Str, "item to add"), opt("count", Ty::Int, "how many")], mcp: true, local: false),
|
||||
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
|
||||
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
|
||||
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
|
||||
@@ -340,8 +438,8 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("mcp", Scripting, "mcp", "serve the Model Context Protocol on stdin/stdout, one tool per command",
|
||||
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image.",
|
||||
["horizon-ctl mcp"], &[], mcp: false, local: true),
|
||||
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[], mcp: false, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[], mcp: false, local: false),
|
||||
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again")], mcp: true, local: false),
|
||||
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
|
||||
"The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). With raw, the reply also lists every frame time in milliseconds (frame_ms).",
|
||||
["horizon-ctl fly 0 100 0 500 100 0 20"], &[], mcp: false, local: false),
|
||||
@@ -366,8 +464,9 @@ pub fn overview() -> String {
|
||||
}
|
||||
out.push_str(
|
||||
"\nEvery reply is one JSON line. Errors are {\"error\": \"...\"}; the exit status is 0 on\n\
|
||||
success, 1 when the game replied with an error, 2 for a usage error and 3 when the\n\
|
||||
game cannot be reached or started. Actions (goto, break, place...) block until done.\n\
|
||||
success, 1 when the game replied with an error, 2 for a usage error, 3 when the\n\
|
||||
game cannot be reached or started and 4 while No AI is on (the launcher's settings).\n\
|
||||
Actions (goto, break, place...) block until done.\n\
|
||||
horizon-ctl help --game lists the running game's own commands, test hooks included.\n\
|
||||
The socket is $HORIZON_CONTROL, else $XDG_RUNTIME_DIR/horizon-control.sock (on Windows\n\
|
||||
%TEMP%\\horizon-control.json, a localhost TCP port with a token; any path ending in .json\n\
|
||||
@@ -402,6 +501,73 @@ pub fn detail(c: &Cmd) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The generated reference, docs/horizon-ctl-commands.md (`horizon-ctl help --markdown`).
|
||||
pub fn markdown() -> String {
|
||||
let cell = |s: &str| s.replace('|', "\\|");
|
||||
let mut out = String::from(
|
||||
"<!-- Generated by `horizon-ctl help --markdown` from the command table in \
|
||||
crates/horizon-client/src/bin/horizon-ctl/commands.rs: edit the table, then regenerate. -->\n\
|
||||
# horizon-ctl commands\n\n\
|
||||
Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launcher's \
|
||||
`horizon-launcher mcp` offers the same tools as `game_<tool>`, all but `launch`) and its \
|
||||
arguments. Positional arguments come first, in order; `--flags` after them. \
|
||||
`horizon-ctl help --game` lists the game's own commands, test hooks included. \
|
||||
Design rules and the full inventory: [agent-control.md](agent-control.md).\n",
|
||||
);
|
||||
for group in Group::ALL {
|
||||
out.push_str(&format!(
|
||||
"\n## {}\n\n| Command | MCP tool | What it does | Arguments |\n| --- | --- | --- | --- |\n",
|
||||
group.title()
|
||||
));
|
||||
for c in COMMANDS.iter().filter(|c| c.group == group) {
|
||||
let tool = if c.mcp {
|
||||
let mut t = format!("`{}`", tool_name(c));
|
||||
if DESTRUCTIVE.contains(&c.name) {
|
||||
t.push_str(" (destructive)");
|
||||
} else if OPEN_WORLD.contains(&c.name) {
|
||||
t.push_str(" (network)");
|
||||
}
|
||||
t
|
||||
} else {
|
||||
"CLI only".to_owned()
|
||||
};
|
||||
let args: Vec<String> = c
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let ty = match p.ty {
|
||||
Ty::Int => "integer".to_owned(),
|
||||
Ty::Num => "number".to_owned(),
|
||||
Ty::Str => "string".to_owned(),
|
||||
Ty::Rest => "text".to_owned(),
|
||||
Ty::Bool => "boolean".to_owned(),
|
||||
Ty::Enum(v) => v.join(", "),
|
||||
};
|
||||
let name = if p.flag {
|
||||
format!("--{}", p.name)
|
||||
} else {
|
||||
p.name.to_owned()
|
||||
};
|
||||
let need = if p.required { ", required" } else { "" };
|
||||
format!("`{name}` ({ty}{need}): {}", cell(p.help))
|
||||
})
|
||||
.collect();
|
||||
out.push_str(&format!(
|
||||
"| `{}` | {} | {} | {} |\n",
|
||||
cell(c.usage),
|
||||
tool,
|
||||
cell(&capitalize(c.summary)),
|
||||
if args.is_empty() {
|
||||
"none".to_owned()
|
||||
} else {
|
||||
args.join("<br>")
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
chars
|
||||
@@ -640,6 +806,43 @@ mod tests {
|
||||
assert_eq!(tool_name(find("look-at-entity").unwrap()), "look_at_entity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_generated_reference_is_up_to_date() {
|
||||
let path = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../docs/horizon-ctl-commands.md"
|
||||
);
|
||||
let on_disk = std::fs::read_to_string(path).unwrap_or_default();
|
||||
assert!(
|
||||
on_disk == markdown(),
|
||||
"docs/horizon-ctl-commands.md is out of date: run `horizon-ctl help --markdown > docs/horizon-ctl-commands.md`"
|
||||
);
|
||||
for c in COMMANDS {
|
||||
assert!(
|
||||
on_disk.contains(&format!("`{}", c.usage.replace('|', "\\|"))),
|
||||
"{} undocumented",
|
||||
c.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destructive_and_network_tools_exist() {
|
||||
for name in DESTRUCTIVE.iter().chain(OPEN_WORLD) {
|
||||
assert!(find(name).is_some_and(|c| c.mcp), "{name}");
|
||||
}
|
||||
let delete = find("world-delete").unwrap();
|
||||
assert_eq!(
|
||||
line_for(delete, &json!({ "folder": "A", "confirm": true })).unwrap(),
|
||||
["world-delete", "A", "--confirm"]
|
||||
);
|
||||
let lan = find("lan").unwrap();
|
||||
assert_eq!(
|
||||
line_for(lan, &json!({ "action": "open", "commands": true })).unwrap(),
|
||||
["lan", "open", "commands"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_mentions_every_command() {
|
||||
let text = overview();
|
||||
|
||||
@@ -20,6 +20,8 @@ use serde_json::{Value, json};
|
||||
const GAME_ERROR: u8 = 1;
|
||||
const USAGE: u8 = 2;
|
||||
const UNREACHABLE: u8 = 3;
|
||||
/// No AI is on (`horizon_core::no_ai`): agent control is off.
|
||||
const DENIED: u8 = 4;
|
||||
|
||||
/// A failure, with the exit status it maps to.
|
||||
#[derive(Debug)]
|
||||
@@ -34,6 +36,13 @@ impl Failure {
|
||||
status: USAGE,
|
||||
}
|
||||
}
|
||||
/// No AI is on in the game's home.
|
||||
fn denied() -> Self {
|
||||
Self {
|
||||
reply: json!({ "error": horizon_core::no_ai::REFUSAL, "kind": "denied" }),
|
||||
status: DENIED,
|
||||
}
|
||||
}
|
||||
fn unreachable(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
reply: json!({ "error": message.into(), "kind": "connection" }),
|
||||
@@ -98,6 +107,8 @@ impl Client {
|
||||
pub struct Session {
|
||||
pub socket: PathBuf,
|
||||
pub timeout: Option<Duration>,
|
||||
/// The game's home, where No AI is read (`horizon_core::no_ai`).
|
||||
pub home: PathBuf,
|
||||
client: Option<Client>,
|
||||
}
|
||||
|
||||
@@ -106,9 +117,14 @@ impl Session {
|
||||
Self {
|
||||
socket,
|
||||
timeout,
|
||||
home: horizon_client::logging::game_home(),
|
||||
client: None,
|
||||
}
|
||||
}
|
||||
/// A failure while No AI is on: every command refuses (help aside).
|
||||
pub fn allowed(&self) -> Result<(), Failure> {
|
||||
horizon_core::no_ai::check(&self.home).map_err(|_| Failure::denied())
|
||||
}
|
||||
fn client(&mut self) -> Result<&mut Client, Failure> {
|
||||
if self.client.is_none() {
|
||||
self.client = Some(Client::connect(&self.socket, self.timeout)?);
|
||||
@@ -137,6 +153,7 @@ impl Session {
|
||||
let Some(name) = words.first().map(String::as_str) else {
|
||||
return Err(Failure::usage("no command; try horizon-ctl help"));
|
||||
};
|
||||
self.allowed()?;
|
||||
if words.iter().skip(1).any(|w| w == "--help" || w == "-h") {
|
||||
return Err(Failure::usage(
|
||||
"use horizon-ctl COMMAND --help outside scripts",
|
||||
@@ -174,6 +191,18 @@ impl Session {
|
||||
ready["launch"] = reply;
|
||||
Ok(ready)
|
||||
}
|
||||
"interact-entity" => {
|
||||
let id = match words {
|
||||
[_, id] if id.parse::<i64>().is_ok() => id,
|
||||
_ => return Err(Failure::usage(format!("usage: {}", usage(name)))),
|
||||
};
|
||||
self.game(&format!("look-at entity {id}"))?;
|
||||
self.game("mouse right click")?;
|
||||
self.game("wait 2")?;
|
||||
let mut screen = self.game("screen")?;
|
||||
screen["ok"] = json!(true);
|
||||
Ok(screen)
|
||||
}
|
||||
"hold" => {
|
||||
let (key, seconds) = match words {
|
||||
[_, key, _] => (key.clone(), number(2, "SECONDS")?),
|
||||
@@ -183,7 +212,7 @@ impl Session {
|
||||
thread::sleep(Duration::from_secs_f64(seconds.max(0.0)));
|
||||
self.game(&format!("key {key} up"))
|
||||
}
|
||||
"logs" => logs(&horizon_client::logging::game_home(), &words[1..]),
|
||||
"logs" => logs(&self.home, &words[1..]),
|
||||
"wait-ready" => {
|
||||
let limit = if words.len() > 1 {
|
||||
number(1, "SECONDS")?
|
||||
@@ -417,6 +446,10 @@ fn main() -> ExitCode {
|
||||
print!("{}", commands::overview());
|
||||
0
|
||||
}
|
||||
Some("help") if args.get(1).is_some_and(|a| a == "--markdown") => {
|
||||
print!("{}", commands::markdown());
|
||||
0
|
||||
}
|
||||
Some("help") if args.get(1).is_none_or(|a| a != "--game") => match args.get(1) {
|
||||
None => {
|
||||
print!("{}", commands::overview());
|
||||
@@ -451,6 +484,10 @@ fn main() -> ExitCode {
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("mcp") if session.allowed().is_err() => {
|
||||
eprintln!("horizon-ctl mcp: {}", horizon_core::no_ai::REFUSAL);
|
||||
DENIED
|
||||
}
|
||||
Some("mcp") => match mcp::serve(&mut session) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
@@ -581,6 +618,27 @@ mod tests {
|
||||
assert_eq!(f.status, USAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_ai_refuses_every_command() {
|
||||
let home = std::env::temp_dir().join(format!("horizon-ctl-no-ai-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-nobody.sock"), None);
|
||||
s.home = home.clone();
|
||||
assert_eq!(
|
||||
s.run(&["state".to_owned()]).unwrap_err().status,
|
||||
UNREACHABLE
|
||||
);
|
||||
horizon_core::no_ai::set(&home, true).unwrap();
|
||||
for words in [vec!["state"], vec!["logs"], vec!["launch", "--world", "A"]] {
|
||||
let words: Vec<String> = words.into_iter().map(str::to_owned).collect();
|
||||
let f = s.run(&words).unwrap_err();
|
||||
assert_eq!(f.status, DENIED, "{words:?}");
|
||||
assert_eq!(f.reply["kind"], "denied");
|
||||
assert_eq!(f.reply["error"], horizon_core::no_ai::REFUSAL);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_paths_become_absolute() {
|
||||
let mut words = vec!["screenshot".to_owned(), "a.png".to_owned()];
|
||||
|
||||
@@ -23,7 +23,13 @@ fn tools() -> Vec<Value> {
|
||||
schema = json!({ "type": "object", "properties": {} });
|
||||
description = "Capture the game window and return it as a PNG image.".into();
|
||||
}
|
||||
json!({ "name": commands::tool_name(c), "description": description, "inputSchema": schema })
|
||||
let mut tool = json!({ "name": commands::tool_name(c), "description": description, "inputSchema": schema });
|
||||
if commands::DESTRUCTIVE.contains(&c.name) {
|
||||
tool["annotations"] = json!({ "destructiveHint": true });
|
||||
} else if commands::OPEN_WORLD.contains(&c.name) {
|
||||
tool["annotations"] = json!({ "openWorldHint": true });
|
||||
}
|
||||
tool
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -144,9 +144,6 @@ impl BlockEntities {
|
||||
pub fn set_states(&mut self, states: Vec<Ticking>) {
|
||||
self.states = states;
|
||||
}
|
||||
pub fn take_states(&mut self) -> Vec<Ticking> {
|
||||
std::mem::take(&mut self.states)
|
||||
}
|
||||
pub fn ticking(&self, state: u32) -> Ticking {
|
||||
self.states.get(state as usize).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -382,10 +382,6 @@ impl Shulker {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `getProgress(a)`: `Mth.lerp(a, progressOld, progress)`.
|
||||
pub fn get_progress(&self, a: f32) -> f32 {
|
||||
self.progress_old + a * (self.progress - self.progress_old)
|
||||
}
|
||||
/// `ShulkerBoxBlock.canOpen`: a closed box only opens when its lid has room
|
||||
/// (`level.noCollision` of the half-block lid box, deflated by 1e-6).
|
||||
pub fn lid_box(pos: [i32; 3], facing: u8) -> [f64; 6] {
|
||||
@@ -668,14 +664,6 @@ impl Skull {
|
||||
self.animating = false;
|
||||
}
|
||||
}
|
||||
/// `getAnimation(a)`.
|
||||
pub fn get_animation(&self, a: f32) -> f32 {
|
||||
if self.animating {
|
||||
self.tick_count as f32 + a
|
||||
} else {
|
||||
self.tick_count as f32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `DecoratedPotBlockEntity.WobbleStyle`.
|
||||
|
||||
@@ -875,13 +875,6 @@ impl BlockEntityRenderer {
|
||||
visibility: None,
|
||||
}
|
||||
}
|
||||
/// The standing banner's pole-and-bar and flag models (`BannerRenderer.submitSpecial`).
|
||||
pub fn banner_models(&self) -> Option<(&ModelPart, &ModelPart)> {
|
||||
Some((
|
||||
self.models.get("standing_banner")?,
|
||||
self.models.get("standing_banner_flag")?,
|
||||
))
|
||||
}
|
||||
fn kind(&self, state: u32) -> &Kind {
|
||||
self.kinds.get(state as usize).unwrap_or(&Kind::None)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ impl BossBar {
|
||||
style: "progress".into(),
|
||||
value: 0,
|
||||
max: 100,
|
||||
visible: false,
|
||||
visible: true,
|
||||
player: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,10 +169,6 @@ impl TickRateManager {
|
||||
self.frozen_ticks_to_run -= 1;
|
||||
}
|
||||
}
|
||||
/// `isEntityFrozen` for an entity that is not (and does not carry) a player.
|
||||
pub fn is_entity_frozen(&self, is_player_or_carries_player: bool) -> bool {
|
||||
!self.runs_normally() && !is_player_or_carries_player
|
||||
}
|
||||
}
|
||||
|
||||
/// What `/tick sprint` reports when it ends.
|
||||
|
||||
@@ -812,14 +812,6 @@ pub fn java_parse_int(s: &str) -> Option<i32> {
|
||||
}
|
||||
s.parse().ok()
|
||||
}
|
||||
/// Long.parseLong.
|
||||
pub fn java_parse_long(s: &str) -> Option<i64> {
|
||||
let digits = s.strip_prefix(['-', '+']).unwrap_or(s);
|
||||
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok()
|
||||
}
|
||||
/// Double.parseDouble over the characters StringReader reads as a number.
|
||||
pub fn java_parse_double(s: &str) -> Option<f64> {
|
||||
let t = s.trim_matches(|c: char| c <= ' ');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! ComponentSerialization.CODEC and Style.Serializer.MAP_CODEC decoding of
|
||||
//! SNBT text components, with the data-fixer-upper error messages
|
||||
//! (ComponentArgument, StyleArgument and component-valued item data).
|
||||
use super::codec::{get, no_key, not_list, not_map, snbt};
|
||||
use super::codec::{get, no_key, not_list, not_map};
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
|
||||
/// A DataResult error: its message and whether it carries a partial result.
|
||||
@@ -362,7 +362,3 @@ pub fn decode_style(t: &Tag) -> Result<(), String> {
|
||||
_ => Err(not_map(t)),
|
||||
}
|
||||
}
|
||||
#[allow(unused)]
|
||||
fn _keep(t: &Tag) -> String {
|
||||
snbt(t)
|
||||
}
|
||||
@@ -632,10 +632,6 @@ pub fn parse_predicate(reader: &mut Reader, reg: &Registries) -> CmdResult<ItemI
|
||||
pub fn suggest_predicate(builder: &mut SuggestionsBuilder, reg: &Registries) {
|
||||
PREDICATE.with(|g| g.parse_for_suggestions_with(builder, Some(reg)));
|
||||
}
|
||||
#[allow(unused)]
|
||||
fn _unused(t: &Tag) -> String {
|
||||
snbt(t)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -183,9 +183,6 @@ impl<'a> Reader<'a> {
|
||||
pub fn read_int(&mut self) -> CmdResult<i32> {
|
||||
self.number("int")
|
||||
}
|
||||
pub fn read_long(&mut self) -> CmdResult<i64> {
|
||||
self.number("long")
|
||||
}
|
||||
pub fn read_double(&mut self) -> CmdResult<f64> {
|
||||
self.number("double")
|
||||
}
|
||||
@@ -222,21 +219,6 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
Err(CommandError::tr_at("parsing.quote.expected.end", &[], self))
|
||||
}
|
||||
pub fn read_quoted_string(&mut self) -> CmdResult<String> {
|
||||
if !self.can_read() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let next = self.peek();
|
||||
if next != '"' && next != '\'' {
|
||||
return Err(CommandError::tr_at(
|
||||
"parsing.quote.expected.start",
|
||||
&[],
|
||||
self,
|
||||
));
|
||||
}
|
||||
self.skip();
|
||||
self.read_string_until(next)
|
||||
}
|
||||
pub fn read_string(&mut self) -> CmdResult<String> {
|
||||
if !self.can_read() {
|
||||
return Ok(String::new());
|
||||
@@ -539,18 +521,6 @@ impl<'a> SuggestionsBuilder<'a> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The same with a tooltip per candidate.
|
||||
pub fn suggest_matching_tooltip<'b>(
|
||||
&mut self,
|
||||
candidates: impl IntoIterator<Item = (&'b str, String)>,
|
||||
) {
|
||||
let lower = self.remaining_lower();
|
||||
for (c, tip) in candidates {
|
||||
if args::matches_sub_str(&lower, &c.to_lowercase()) {
|
||||
self.suggest_tooltip(c, tip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// String.compareToIgnoreCase.
|
||||
pub fn compare_ignore_case(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! SnbtGrammar and TagParser: vanilla's packrat SNBT grammar, with its
|
||||
//! error messages and completion suggestions, producing NBT tags.
|
||||
use super::packrat::{
|
||||
Grammar, Reason, Rule, State, Sugg, Term, Value, alt, ch, chs, look, opt, seq,
|
||||
};
|
||||
use super::packrat::{Grammar, Reason, State, Sugg, Term, Value, alt, ch, chs, look, opt, seq};
|
||||
use super::{CmdResult, CommandError, Reader, SuggestionsBuilder};
|
||||
use horizon_world::nbt::{Compound, JavaString, Tag};
|
||||
use std::rc::Rc;
|
||||
@@ -981,5 +979,3 @@ pub fn rule_in<V: super::packrat::Value>(
|
||||
}
|
||||
}
|
||||
}
|
||||
#[allow(unused)]
|
||||
fn _rule_kind(_: Rule<SV>) {}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! mode keywords are read back from the parsed literals.
|
||||
use super::{
|
||||
Dispatcher,
|
||||
args::{ArgType, GameMode, Resource, integer},
|
||||
args::{ArgType, Resource, integer},
|
||||
};
|
||||
use crate::gamerules::{RULES, Value};
|
||||
|
||||
@@ -509,10 +509,6 @@ fn clone_mode(d: &mut Dispatcher, node: usize) {
|
||||
d.executes(l, "clone");
|
||||
}
|
||||
}
|
||||
/// Game mode names for selector `gamemode=` and the argument.
|
||||
pub fn game_modes() -> [&'static str; 4] {
|
||||
GameMode::ALL.map(GameMode::name)
|
||||
}
|
||||
/// The shared vanilla tree.
|
||||
/// Reorder node children as vanilla registers them (Brigadier keeps
|
||||
/// children in registration order; smart usage lists them that way).
|
||||
@@ -559,5 +555,3 @@ pub fn release_dispatcher() -> &'static Dispatcher {
|
||||
static TREE: std::sync::OnceLock<Dispatcher> = std::sync::OnceLock::new();
|
||||
TREE.get_or_init(|| build_with(false))
|
||||
}
|
||||
#[allow(unused)]
|
||||
const _: (u8, u8) = (ALL, GAMEMASTERS);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -252,24 +252,6 @@ impl BlockEntity {
|
||||
};
|
||||
Some(Self::Chest(vec![None; size]))
|
||||
}
|
||||
/// The vanilla block entity type id of a container block.
|
||||
pub fn id_for_block(name: &str) -> Option<&'static str> {
|
||||
Some(match name {
|
||||
"minecraft:chest" => "minecraft:chest",
|
||||
n if n.ends_with("copper_chest") => "minecraft:chest",
|
||||
"minecraft:trapped_chest" => "minecraft:trapped_chest",
|
||||
"minecraft:barrel" => "minecraft:barrel",
|
||||
"minecraft:hopper" => "minecraft:hopper",
|
||||
"minecraft:dispenser" => "minecraft:dispenser",
|
||||
"minecraft:dropper" => "minecraft:dropper",
|
||||
"minecraft:furnace" => "minecraft:furnace",
|
||||
"minecraft:blast_furnace" => "minecraft:blast_furnace",
|
||||
"minecraft:smoker" => "minecraft:smoker",
|
||||
n if n.ends_with("shulker_box") => "minecraft:shulker_box",
|
||||
"minecraft:crafter" => "minecraft:crafter",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Chest(items) => match items.len() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@ use std::{
|
||||
pub const CAPACITY: usize = 240;
|
||||
/// `AbstractDebugChart.CHART_HEIGHT`.
|
||||
pub const CHART_HEIGHT: i32 = 60;
|
||||
/// `TpsDebugDimensions`: a tick sample's values.
|
||||
pub const FULL_TICK: usize = 0;
|
||||
pub const TICK_SERVER_METHOD: usize = 1;
|
||||
pub const SCHEDULED_TASKS: usize = 2;
|
||||
pub const IDLE: usize = 3;
|
||||
@@ -252,10 +250,6 @@ impl Chart {
|
||||
pub fn width(max_width: i32) -> i32 {
|
||||
(CAPACITY as i32 + 2).min(max_width)
|
||||
}
|
||||
/// `getFullHeight`: the box and the min/avg/max line above it.
|
||||
pub fn full_height() -> i32 {
|
||||
CHART_HEIGHT + 9
|
||||
}
|
||||
/// `AbstractDebugChart.extractRenderState`.
|
||||
pub fn draw(
|
||||
self,
|
||||
|
||||
@@ -359,10 +359,6 @@ impl Enchantments {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Whether a stack draws the enchantment glint.
|
||||
pub fn has_glint(&self, stack: &ItemStack) -> bool {
|
||||
stack.has_any_enchantments() || self.props(&stack.item).is_some_and(|p| p.glint)
|
||||
}
|
||||
|
||||
// ---- Enchanting table (EnchantmentHelper) ----
|
||||
|
||||
@@ -514,19 +510,6 @@ impl Enchantments {
|
||||
.is_some_and(|e| e.effects.get(component).is_some())
|
||||
})
|
||||
}
|
||||
/// The highest level among enchantments on `stack` that have `component`.
|
||||
pub fn component_level(&self, stack: &ItemStack, component: &str) -> u32 {
|
||||
stack
|
||||
.enchantments()
|
||||
.iter()
|
||||
.filter(|(id, _)| {
|
||||
self.get(id)
|
||||
.is_some_and(|e| e.effects.get(component).is_some())
|
||||
})
|
||||
.map(|(_, l)| *l)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
/// `minecraft:attributes` contributions of `stack`: (attribute, id, amount, operation).
|
||||
pub fn attributes(&self, stack: &ItemStack) -> Vec<(String, String, f64, String)> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,11 @@ pub struct Extras {
|
||||
pub sort: bool,
|
||||
/// A vanilla key name: sorts the part under the cursor.
|
||||
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 {
|
||||
@@ -135,6 +140,8 @@ impl Default for Extras {
|
||||
mouse_tweaks_wheel: true,
|
||||
sort: false,
|
||||
sort_key: "key.keyboard.unknown".into(),
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,6 +205,8 @@ impl Extras {
|
||||
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
|
||||
"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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -205,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",
|
||||
"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),
|
||||
@@ -234,6 +243,9 @@ impl Extras {
|
||||
bool_text(self.mouse_tweaks_wheel),
|
||||
bool_text(self.sort),
|
||||
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
|
||||
@@ -451,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>> {
|
||||
@@ -529,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.
|
||||
@@ -542,7 +559,7 @@ impl Item {
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort
|
||||
| AutoReconnect => {
|
||||
| AutoReconnect | NoAi | AgentControl => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -586,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.
|
||||
@@ -616,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,
|
||||
})
|
||||
}
|
||||
@@ -671,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 => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,15 +289,6 @@ impl Default for HandHeights {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What `FirstPersonHandsAndItems.tick` decides about a hand's visible item.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct HandChange {
|
||||
/// The visible item is not the hand's item after the instant replacement
|
||||
/// (`mainHandItem != nextMainHand`): the hand lowers.
|
||||
pub changed: bool,
|
||||
/// The visible item becomes the hand's item this tick.
|
||||
pub replace: bool,
|
||||
}
|
||||
impl HandHeights {
|
||||
/// `FirstPersonHandsAndItems.tick`'s heights. `changed` says per hand
|
||||
/// whether the visible item differs from the held one once
|
||||
|
||||
+824
-225
File diff suppressed because it is too large.
Load diff
+295
-119
@@ -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;
|
||||
@@ -126,6 +129,7 @@ mod remote_vehicles;
|
||||
mod render_control;
|
||||
mod render_tick;
|
||||
mod respawn_anchor;
|
||||
mod saves_control;
|
||||
mod saving;
|
||||
mod scoreboard;
|
||||
mod screens;
|
||||
@@ -159,6 +163,7 @@ mod ui_control;
|
||||
mod vault;
|
||||
mod villagers;
|
||||
mod wandering_trader;
|
||||
mod win;
|
||||
mod wind;
|
||||
mod wither;
|
||||
mod world_loading;
|
||||
@@ -263,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
|
||||
@@ -277,8 +285,9 @@ struct Options {
|
||||
dev_mods: Vec<PathBuf>,
|
||||
/// `--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 can board, ride and hit
|
||||
/// vehicles (#1222); `--no-server-thread` keeps it off.
|
||||
/// 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()),
|
||||
@@ -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 --server-thread Run single player's world on its server thread (no vehicles 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 — 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);
|
||||
@@ -2528,6 +2577,17 @@ impl Game<'_> {
|
||||
LevelEvent::LevelEvent { id: 2004, pos, .. } => {
|
||||
self.spawner_burst([pos.x, pos.y, pos.z])
|
||||
}
|
||||
// LevelEventHandler 1505: the growth particles, then the sound.
|
||||
LevelEvent::LevelEvent {
|
||||
id: 1505,
|
||||
pos,
|
||||
data,
|
||||
} => {
|
||||
self.growth_particles(pos, data);
|
||||
let at = [pos.x, pos.y, pos.z].map(|v| f64::from(v) + 0.5);
|
||||
self.audio
|
||||
.play("minecraft:item.bone_meal.use", Some(at), 1.0, 1.0);
|
||||
}
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2015,
|
||||
pos,
|
||||
@@ -2571,7 +2631,6 @@ impl Game<'_> {
|
||||
1030 => Some(("minecraft:block.anvil.use", 1.0)),
|
||||
1029 => Some(("minecraft:block.anvil.destroy", 1.0)),
|
||||
1031 => Some(("minecraft:block.anvil.land", 0.3)),
|
||||
1505 => Some(("minecraft:item.bone_meal.use", 1.0)),
|
||||
1042 => Some(("minecraft:block.grindstone.use", 1.0)),
|
||||
1044 => Some(("minecraft:block.smithing_table.use", 1.0)),
|
||||
// LevelEvent.SOUND_PAGE_TURN (a lectern's page).
|
||||
@@ -3555,8 +3614,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 +3829,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 +3959,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 +4318,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 +4522,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 +4738,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 +4822,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 +4868,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 +4918,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 +4976,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 +6563,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 +7049,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 +7567,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 +7667,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());
|
||||
@@ -7717,6 +7823,10 @@ impl Game<'_> {
|
||||
fullscreen: s.fullscreen,
|
||||
graphics: s.graphics_preset.clone(),
|
||||
};
|
||||
// Again now: on Wayland the window is on a screen only once it has been shown.
|
||||
if let Some(window) = &self.window {
|
||||
horizon_client::telemetry::note_display(window);
|
||||
}
|
||||
self.perf
|
||||
.finish(&horizon_client::logging::game_home(), settings);
|
||||
}
|
||||
@@ -7749,6 +7859,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<()> {
|
||||
@@ -8001,13 +8112,15 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// `BlockTransformer.transformBlock` for a held axe, hoe or shovel.
|
||||
fn use_block_transformer(
|
||||
&mut self,
|
||||
/// What `BlockTransformer.transformBlock` would do with a held axe, hoe
|
||||
/// or shovel on the block at `pos`, without doing it: the new state, its
|
||||
/// sound, loot table, the durability it costs and its particle.
|
||||
pub(super) fn block_transform(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
pos: [i32; 3],
|
||||
face: [i32; 3],
|
||||
) -> Result<bool> {
|
||||
) -> Option<(BlockStateId, String, Option<String>, u32, String)> {
|
||||
use horizon_client::block_transform::Around;
|
||||
struct Column<'w> {
|
||||
world: &'w World,
|
||||
@@ -8027,14 +8140,11 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
}
|
||||
let Some(id) = self
|
||||
let id = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.block_transformer.clone())
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
.and_then(|i| i.block_transformer.clone())?;
|
||||
// playerHasBlockingItemUseIntent: a shield in the off hand is raised
|
||||
// instead, unless sneaking.
|
||||
let shield = self
|
||||
@@ -8043,7 +8153,7 @@ impl Game<'_> {
|
||||
.and_then(|s| self.res.items().get(&s.item))
|
||||
.is_some_and(|i| i.blocks_attacks.is_some());
|
||||
if shield && !self.keys.contains(&KeyCode::ShiftLeft) {
|
||||
return Ok(false);
|
||||
return None;
|
||||
}
|
||||
let face_name = match face {
|
||||
[0, -1, 0] => "down",
|
||||
@@ -8054,38 +8164,47 @@ impl Game<'_> {
|
||||
_ => "east",
|
||||
};
|
||||
let registry = self.pack.assets.registry;
|
||||
let (state, sound, loot, damage, particle) = {
|
||||
let around = Column {
|
||||
world: &self.world,
|
||||
registry,
|
||||
pos,
|
||||
};
|
||||
let Some((_, t, state)) =
|
||||
self.res
|
||||
.transformers()
|
||||
.apply(self.res.items(), registry, &id, face_name, &around)
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
(
|
||||
state,
|
||||
t.sound.clone(),
|
||||
t.loot.clone(),
|
||||
t.damage,
|
||||
t.particle.clone(),
|
||||
)
|
||||
let around = Column {
|
||||
world: &self.world,
|
||||
registry,
|
||||
pos,
|
||||
};
|
||||
let Ok(new) = registry.resolve(&state) else {
|
||||
return Ok(false);
|
||||
};
|
||||
self.level_set(pos, new)?;
|
||||
// BlockTransformer.TransformParticle.send.
|
||||
match particle.as_str() {
|
||||
let (_, t, state) =
|
||||
self.res
|
||||
.transformers()
|
||||
.apply(self.res.items(), registry, &id, face_name, &around)?;
|
||||
let new = registry.resolve(&state).ok()?;
|
||||
Some((
|
||||
new,
|
||||
t.sound.clone(),
|
||||
t.loot.clone(),
|
||||
t.damage,
|
||||
t.particle.clone(),
|
||||
))
|
||||
}
|
||||
/// `BlockTransformer.TransformParticle.send`: its level event.
|
||||
pub(super) fn transform_particle(&mut self, particle: &str, pos: [i32; 3]) {
|
||||
match particle {
|
||||
"scrape" => self.level_event_at(3005, pos, 0),
|
||||
"wax_on" => self.level_event_at(3003, pos, 0),
|
||||
"wax_off" => self.level_event_at(3004, pos, 0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `BlockTransformer.transformBlock` for a held axe, hoe or shovel.
|
||||
fn use_block_transformer(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
pos: [i32; 3],
|
||||
face: [i32; 3],
|
||||
) -> Result<bool> {
|
||||
let Some((new, sound, loot, damage, particle)) = self.block_transform(stack, pos, face)
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
self.level_set(pos, new)?;
|
||||
// BlockTransformer.TransformParticle.send.
|
||||
self.transform_particle(&particle, pos);
|
||||
if loot.as_deref() == Some("minecraft:till/rooted_dirt") {
|
||||
self.pop_resource(
|
||||
[pos[0] + face[0], pos[1] + face[1], pos[2] + face[2]],
|
||||
@@ -9064,31 +9183,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;
|
||||
@@ -9440,9 +9569,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;
|
||||
@@ -9450,6 +9577,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()));
|
||||
@@ -9462,6 +9590,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;
|
||||
@@ -9767,6 +9896,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 {
|
||||
@@ -10030,12 +10161,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)
|
||||
}
|
||||
@@ -10119,6 +10251,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
|
||||
@@ -10450,6 +10583,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 = (
|
||||
@@ -10494,6 +10631,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")
|
||||
@@ -10505,7 +10643,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),
|
||||
@@ -10515,6 +10653,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
match self.new_renderer(window.clone()) {
|
||||
Ok(r) => {
|
||||
self.renderer = Some(r);
|
||||
@@ -10619,6 +10758,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 {
|
||||
@@ -10713,7 +10855,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()
|
||||
@@ -10771,6 +10915,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)?;
|
||||
@@ -11027,6 +11172,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)
|
||||
}
|
||||
@@ -11160,6 +11308,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());
|
||||
@@ -11587,6 +11736,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(),
|
||||
@@ -11604,6 +11754,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,
|
||||
@@ -11624,6 +11778,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],
|
||||
@@ -11645,6 +11800,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,
|
||||
@@ -11728,6 +11885,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>,
|
||||
@@ -11747,6 +11905,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"),
|
||||
@@ -11970,7 +12129,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!(
|
||||
@@ -11984,7 +12156,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))
|
||||
@@ -12011,7 +12182,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!(
|
||||
@@ -12422,6 +12593,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);
|
||||
}
|
||||
@@ -12439,12 +12614,7 @@ fn start_game<'a>(
|
||||
game.mods.dev = game.options.dev_mods.clone();
|
||||
game.start_mods();
|
||||
}
|
||||
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
|
||||
@@ -12585,6 +12755,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")
|
||||
@@ -12596,7 +12771,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),
|
||||
@@ -12606,6 +12781,7 @@ impl Loading {
|
||||
return;
|
||||
}
|
||||
};
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
|
||||
match loading_renderer(
|
||||
window.clone(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use horizon_client::{
|
||||
chat::{Chat, ChatAction, ChatScreen, Commands, SYSTEM_INDICATOR},
|
||||
command::Registries,
|
||||
gamerules::GameRules,
|
||||
hud::{HudBatch, rgba},
|
||||
hud::HudBatch,
|
||||
screen_draw::Measure,
|
||||
text::{ClickEvent, Style, Text},
|
||||
};
|
||||
@@ -883,10 +883,6 @@ impl Game<'_> {
|
||||
.chat
|
||||
.add(&self.gui.atlas, message, None, self.ticks, focused);
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
fn chat_debug_fill(&self, batch: &mut HudBatch) {
|
||||
batch.fill(&self.gui.atlas, 0.0, 0.0, 1.0, 1.0, rgba(0, 0, 0, 0));
|
||||
}
|
||||
}
|
||||
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, local time
|
||||
/// approximated as UTC).
|
||||
|
||||
@@ -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) {
|
||||
@@ -165,6 +172,9 @@ impl Game<'_> {
|
||||
if let Some(value) = self.events_command(&words, reply)? {
|
||||
return Ok(value);
|
||||
}
|
||||
if let Some(value) = self.saves_control(&words, line, reply)? {
|
||||
return Ok(value);
|
||||
}
|
||||
if let Some(value) = self.options_control(&words, line)? {
|
||||
return Ok(Some(value));
|
||||
}
|
||||
@@ -192,6 +202,7 @@ impl Game<'_> {
|
||||
.chain(super::ui_control::HELP)
|
||||
.chain(super::options::CONTROL_HELP)
|
||||
.chain(super::events::HELP)
|
||||
.chain(super::saves_control::HELP)
|
||||
.chain(super::pad::HELP)
|
||||
.collect::<Vec<_>>() }))),
|
||||
"setblock" | "fill" | "particle" | "signedit" => self.render_command(&words),
|
||||
@@ -564,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()),
|
||||
};
|
||||
@@ -641,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());
|
||||
@@ -723,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";
|
||||
}
|
||||
@@ -740,6 +775,7 @@ impl Game<'_> {
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,6 +834,12 @@ impl Game<'_> {
|
||||
"velocity": [p.velocity.x, p.velocity.y, p.velocity.z],
|
||||
"on_ground": p.ground.on_ground,
|
||||
"health": p.health.health(),
|
||||
// The maximum the HUD's hearts last read (its cached value).
|
||||
"max_health": p
|
||||
.attributes
|
||||
.lookup(p.attributes.key("minecraft:max_health").as_ref())
|
||||
.and_then(|h| p.attributes.instance(&h).ok())
|
||||
.map(|i| i.cached_value()),
|
||||
"food": p.health.food.food,
|
||||
})
|
||||
});
|
||||
@@ -850,6 +892,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()),
|
||||
});
|
||||
@@ -906,7 +950,7 @@ impl Game<'_> {
|
||||
"streaming": self.streaming.stages().into_iter().map(|(k, v)| (k.to_string(), v.into())).collect::<serde_json::Map<_, _>>(),
|
||||
"dirty_light": self.world.dirty_light.len(),
|
||||
"renderer": renderer,
|
||||
"display": self.window.as_ref().and_then(|w| w.current_monitor()).map(|m| json!({
|
||||
"display": self.window.as_deref().and_then(horizon_client::telemetry::monitor).map(|m| json!({
|
||||
"width": m.size().width,
|
||||
"height": m.size().height,
|
||||
"scale": m.scale_factor(),
|
||||
@@ -933,6 +977,10 @@ impl Game<'_> {
|
||||
"flashes": self.lightning_seen[1], "flash": self.flash});
|
||||
// Primed TNT, falling blocks, projectiles, tridents and end crystals.
|
||||
status["projectiles"] = json!(self.projectiles_status());
|
||||
// The particles made so far, by type.
|
||||
status["particles"] = json!(self.particles.made());
|
||||
// The scoreboard sidebar the HUD shows.
|
||||
status["sidebar"] = self.sidebar_status();
|
||||
status
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
@@ -97,8 +97,58 @@ impl EndState {
|
||||
}
|
||||
}
|
||||
|
||||
impl EndState {
|
||||
/// The eyes' entity ids, for a server to number them.
|
||||
pub(super) fn eye_ids(&mut self) -> impl Iterator<Item = &mut u32> {
|
||||
self.eyes.iter_mut().map(|e| &mut e.id)
|
||||
}
|
||||
/// The numbered eyes as a server's clients track them: (id, position, motion).
|
||||
pub(super) fn eyes_tracked(&self) -> impl Iterator<Item = (u32, [f64; 3], [f64; 3])> {
|
||||
self.eyes
|
||||
.iter()
|
||||
.filter(|e| e.id != 0)
|
||||
.map(|e| (e.id, e.position.to_array(), e.velocity.to_array()))
|
||||
}
|
||||
/// `handleAddEntity` for an eye of ender on a server's client: it flies
|
||||
/// where the server says.
|
||||
pub(super) fn add_remote_eye(&mut self, id: u32, at: [f64; 3], velocity: [f64; 3]) {
|
||||
self.eyes.retain(|e| e.id != id);
|
||||
let at = glam::DVec3::from_array(at);
|
||||
self.eyes.push(EyeOfEnder {
|
||||
id,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: glam::DVec3::from_array(velocity),
|
||||
target: at,
|
||||
life: 0,
|
||||
survive: false,
|
||||
});
|
||||
}
|
||||
/// One tick of a server's client: `step` moves each toward where the
|
||||
/// server put it.
|
||||
pub(super) fn step_remote_eyes(&mut self, mut step: impl FnMut(u32, [f64; 3]) -> [f64; 3]) {
|
||||
for e in &mut self.eyes {
|
||||
e.previous = e.position;
|
||||
e.life += 1;
|
||||
e.position = glam::DVec3::from_array(step(e.id, e.position.to_array()));
|
||||
}
|
||||
}
|
||||
pub(super) fn remove_remote_eye(&mut self, id: u32) {
|
||||
self.eyes.retain(|e| e.id != id);
|
||||
}
|
||||
pub(super) fn clear_remote_eyes(&mut self) {
|
||||
self.eyes.clear();
|
||||
}
|
||||
/// Where each eye is, for `status`.
|
||||
pub(super) fn eye_positions(&self) -> impl Iterator<Item = (u32, [f64; 3])> {
|
||||
self.eyes.iter().map(|e| (e.id, e.position.to_array()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The EyeOfEnder entity.
|
||||
struct EyeOfEnder {
|
||||
/// Its entity id once a server's clients track it (0 until then).
|
||||
id: u32,
|
||||
position: glam::DVec3,
|
||||
previous: glam::DVec3,
|
||||
velocity: glam::DVec3,
|
||||
@@ -342,7 +392,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 +466,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 +545,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 +770,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 +809,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 +846,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,
|
||||
@@ -995,6 +1073,7 @@ impl Game<'_> {
|
||||
};
|
||||
let survive = self.world.random.next_int_bound(5).unwrap_or(0) > 0;
|
||||
self.end.eyes.push(EyeOfEnder {
|
||||
id: 0,
|
||||
position: start,
|
||||
previous: start,
|
||||
velocity: glam::DVec3::ZERO,
|
||||
|
||||
@@ -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,10 +6,12 @@
|
||||
//! the world's options) with [`Integrated::call`], as vanilla's client calls
|
||||
//! into its `IntegratedServer`.
|
||||
//!
|
||||
//! Opt-in again (`--server-thread` or `HORIZON_SERVER_THREAD=1`): it was the
|
||||
//! default for one day, but a server's players can't board, ride or hit
|
||||
//! boats and minecarts yet (#1222), and single player's window is one of
|
||||
//! them here.
|
||||
//! 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 = {
|
||||
@@ -951,6 +997,7 @@ impl Game<'_> {
|
||||
self.lan_update_streaming(&host);
|
||||
self.lan_update_models(&mut host);
|
||||
self.lan_update_waypoints(&mut host, state.as_ref());
|
||||
self.lan_sync_scoreboard(&mut host);
|
||||
drop(holding);
|
||||
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
|
||||
self.lan = Some(host);
|
||||
@@ -1008,7 +1055,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
|
||||
@@ -1041,8 +1088,14 @@ impl Game<'_> {
|
||||
}
|
||||
for e in events {
|
||||
if let horizon_world::level::LevelEvent::LevelEvent { id, pos, data } = e {
|
||||
// The acting guest does not predict the egg's teleport particles.
|
||||
let except = if *id == 2015 { None } else { self.lan_acting };
|
||||
// The acting guest does not predict the egg's teleport
|
||||
// particles, nor bone meal's growth (BoneMealItem.useOn's
|
||||
// levelEvent names no player).
|
||||
let except = if matches!(*id, 2015 | 1505) {
|
||||
None
|
||||
} else {
|
||||
self.lan_acting
|
||||
};
|
||||
self.lan_events
|
||||
.push((*id, [pos.x, pos.y, pos.z], *data, except));
|
||||
}
|
||||
@@ -1243,7 +1296,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 +1311,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +277,8 @@ impl Game<'_> {
|
||||
self.rockets.iter_mut().for_each(|r| give(&mut r.id));
|
||||
self.crystals.iter_mut().for_each(|c| give(&mut c.id));
|
||||
self.bolts.iter_mut().for_each(|b| give(&mut b.id));
|
||||
self.potions.thrown_ids().for_each(&mut give);
|
||||
self.end.eye_ids().for_each(&mut give);
|
||||
}
|
||||
|
||||
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
|
||||
@@ -356,6 +361,26 @@ impl Game<'_> {
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
// Thrown potions and bottles: ThrowableItemProjectile.DATA_ITEM_STACK
|
||||
// (the potion's colour), which a plain bottle's type already has.
|
||||
for (id, kind, stack, position, velocity) in self.potions.thrown_tracked() {
|
||||
let mut e = state(id, kind, at(position), 0.0, false);
|
||||
e.velocity = at(velocity);
|
||||
if stack.extra.is_some() || stack.count != 1 {
|
||||
e.meta.push((
|
||||
8,
|
||||
serializer::ITEM_STACK,
|
||||
Value::Item(hub.stack(&Some(stack.clone()))),
|
||||
));
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
// EyeOfEnder: its item is the plain eye (DATA_ITEM_STACK's default).
|
||||
for (id, position, velocity) in self.end.eyes_tracked() {
|
||||
let mut e = state(id, "minecraft:eye_of_ender", position, 0.0, false);
|
||||
e.velocity = velocity;
|
||||
out.push(e);
|
||||
}
|
||||
for t in self.tridents.iter().filter(|t| t.id != 0) {
|
||||
let mut e = state(t.id, "minecraft:trident", at(t.position), t.yaw, false);
|
||||
e.pitch = t.pitch;
|
||||
|
||||
@@ -129,23 +129,6 @@ impl Game<'_> {
|
||||
}
|
||||
/// The screen title: the custom name, else the profession (the client
|
||||
/// adds the level itself).
|
||||
#[allow(dead_code)]
|
||||
fn merchant_title(&self, state: &MerchantState) -> String {
|
||||
if let Some(name) = &state.name {
|
||||
return name.clone();
|
||||
}
|
||||
let e = &self.res.stations().enchantments;
|
||||
let profession = state.session.profession.trim_start_matches("minecraft:");
|
||||
if state.session.villager {
|
||||
e.translate(&format!("entity.minecraft.villager.{profession}"))
|
||||
.unwrap_or(profession)
|
||||
.to_owned()
|
||||
} else {
|
||||
e.translate("entity.minecraft.wandering_trader")
|
||||
.unwrap_or("Wandering Trader")
|
||||
.to_owned()
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.sendMerchantOffers`, when the offers changed.
|
||||
fn lan_send_offers(
|
||||
&mut self,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Level events whose client handler (`LevelEventHandler`) spawns particles
|
||||
//! on a block: dragon egg teleportation (2015), a consumable's teleport
|
||||
//! (2017), electric sparks (3002), waxing (3003), wax off (3004) and
|
||||
//! scraping (3005).
|
||||
//! on a block: bone meal's growth (1505), dragon egg teleportation (2015), a
|
||||
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
|
||||
//! off (3004) and scraping (3005).
|
||||
use super::Game;
|
||||
use horizon_client::particles::{self, ParticleOptions, ParticleType};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
use horizon_world::level::LevelEvent;
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -42,6 +42,65 @@ impl Game<'_> {
|
||||
self.particles.add(&options, p, v);
|
||||
}
|
||||
}
|
||||
/// `BoneMealItem.addGrowthParticles` (event 1505, `count` the data):
|
||||
/// happy villager sparks over a spreading block's neighbourhood (grass,
|
||||
/// nylium, netherrack, moss) or water, or inside a growing block.
|
||||
pub(super) fn growth_particles(&mut self, pos: BlockPos, count: i32) {
|
||||
let p = [pos.x, pos.y, pos.z];
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
let facts = self.res.facts();
|
||||
// ParticleUtils.spawnParticles(level, pos, count, xzSpread, ySpread,
|
||||
// allowFloatingParticles, particle).
|
||||
let (at, count, xz, height, floating) = match self.res.behaviours().bone_meal_spreads(state)
|
||||
{
|
||||
Some(spreads) => {
|
||||
// BonemealableBlock.getParticlePos: below for rooted dirt
|
||||
// (its hanging roots) and mangrove leaves (their propagule).
|
||||
let at = match &*facts.get(state).name {
|
||||
"minecraft:rooted_dirt" | "minecraft:mangrove_leaves" => [p[0], p[1] - 1, p[2]],
|
||||
_ => p,
|
||||
};
|
||||
if spreads {
|
||||
(at, count * 3, 3.0, 1.0, false)
|
||||
} else {
|
||||
// ParticleUtils.spawnParticleInBlock: up to the
|
||||
// block's shape (a full block when air).
|
||||
let inside = self.world.block(at[0], at[1], at[2]);
|
||||
let height = if facts.is_air(inside) {
|
||||
1.0
|
||||
} else {
|
||||
facts
|
||||
.get(inside)
|
||||
.outline
|
||||
.iter()
|
||||
.map(|b| b[4])
|
||||
.fold(0.0, f64::max)
|
||||
};
|
||||
(at, count, 0.5, height, true)
|
||||
}
|
||||
}
|
||||
None if &*facts.get(state).name == "minecraft:water" => (p, count * 3, 3.0, 1.0, false),
|
||||
None => return,
|
||||
};
|
||||
let options = ParticleOptions::simple(ParticleType::HappyVillager);
|
||||
for _ in 0..count.max(0) {
|
||||
let r = self.particles.level_random();
|
||||
let v = [0.0; 3].map(|_: f64| r.next_gaussian() * 0.02);
|
||||
let start = 0.5 - xz;
|
||||
let x = f64::from(at[0]) + start + r.next_double() * xz * 2.0;
|
||||
let y = f64::from(at[1]) + r.next_double() * height;
|
||||
let z = f64::from(at[2]) + start + r.next_double() * xz * 2.0;
|
||||
let below = [x.floor() as i32, y.floor() as i32 - 1, z.floor() as i32];
|
||||
if floating
|
||||
|| !self
|
||||
.res
|
||||
.facts()
|
||||
.is_air(self.world.block(below[0], below[1], below[2]))
|
||||
{
|
||||
self.particles.add(&options, [x, y, z], v);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Queue and play level event `id` at `pos` (`Level.levelEvent`).
|
||||
pub(super) fn level_event_at(&mut self, id: i32, pos: [i32; 3], data: i32) {
|
||||
self.level_events.push(LevelEvent::LevelEvent {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -18,6 +18,8 @@ use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
/// A thrown splash or lingering potion in flight.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct ThrownPotion {
|
||||
/// Its entity id once a server's clients track it (0 until then).
|
||||
id: u32,
|
||||
stack: ItemStack,
|
||||
/// The position before the last tick, for drawing between ticks.
|
||||
previous: Vec3,
|
||||
@@ -47,6 +49,7 @@ impl Game<'_> {
|
||||
/// player's throws and dispensers both end up here).
|
||||
pub(super) fn launch_thrown(&mut self, stack: ItemStack, position: Vec3, velocity: Vec3) {
|
||||
self.potions.thrown.push(ThrownPotion {
|
||||
id: 0,
|
||||
stack,
|
||||
previous: position,
|
||||
position,
|
||||
@@ -90,19 +93,78 @@ impl PotionState {
|
||||
pub(super) fn thrown_entities(&self) -> Vec<(&'static str, Vec3, Vec3)> {
|
||||
self.thrown
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let kind = match &*t.stack.item {
|
||||
"minecraft:lingering_potion" => "minecraft:lingering_potion",
|
||||
"minecraft:experience_bottle" => "minecraft:experience_bottle",
|
||||
_ => "minecraft:splash_potion",
|
||||
};
|
||||
(kind, t.position, t.velocity)
|
||||
})
|
||||
.map(|t| (thrown_kind(&t.stack), t.position, t.velocity))
|
||||
.collect()
|
||||
}
|
||||
pub(super) fn clear_thrown(&mut self) {
|
||||
self.thrown.clear();
|
||||
}
|
||||
/// Their entity ids, for a server to number them.
|
||||
pub(super) fn thrown_ids(&mut self) -> impl Iterator<Item = &mut u32> {
|
||||
self.thrown.iter_mut().map(|t| &mut t.id)
|
||||
}
|
||||
/// The numbered ones as a server's clients track them: (id, entity
|
||||
/// type, item, position, motion).
|
||||
pub(super) fn thrown_tracked(
|
||||
&self,
|
||||
) -> impl Iterator<Item = (u32, &'static str, &ItemStack, Vec3, Vec3)> {
|
||||
self.thrown.iter().filter(|t| t.id != 0).map(|t| {
|
||||
(
|
||||
t.id,
|
||||
thrown_kind(&t.stack),
|
||||
&t.stack,
|
||||
t.position,
|
||||
t.velocity,
|
||||
)
|
||||
})
|
||||
}
|
||||
/// `handleAddEntity` for a thrown potion or bottle on a server's client:
|
||||
/// drawn where the server says (its item comes in its entity data).
|
||||
pub(super) fn add_remote_thrown(&mut self, id: u32, kind: &str, at: Vec3, velocity: Vec3) {
|
||||
self.thrown.retain(|t| t.id != id);
|
||||
let item = match kind {
|
||||
"minecraft:lingering_potion" | "minecraft:experience_bottle" => kind,
|
||||
_ => "minecraft:splash_potion",
|
||||
};
|
||||
self.thrown.push(ThrownPotion {
|
||||
id,
|
||||
stack: ItemStack::new(item, 1),
|
||||
previous: at,
|
||||
position: at,
|
||||
velocity,
|
||||
age: 0,
|
||||
});
|
||||
}
|
||||
/// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it shows.
|
||||
pub(super) fn remote_thrown_item(&mut self, id: u32, stack: ItemStack) {
|
||||
if let Some(t) = self.thrown.iter_mut().find(|t| t.id == id) {
|
||||
t.stack = stack;
|
||||
}
|
||||
}
|
||||
/// One tick of a server's client: `step` moves each toward where the
|
||||
/// server put it.
|
||||
pub(super) fn step_remote_thrown(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
|
||||
for t in &mut self.thrown {
|
||||
t.previous = t.position;
|
||||
t.age += 1;
|
||||
t.position = step(t.id, t.position);
|
||||
}
|
||||
}
|
||||
pub(super) fn remove_remote_thrown(&mut self, id: u32) {
|
||||
self.thrown.retain(|t| t.id != id);
|
||||
}
|
||||
pub(super) fn has_thrown(&self, id: u32) -> bool {
|
||||
self.thrown.iter().any(|t| t.id == id)
|
||||
}
|
||||
}
|
||||
|
||||
/// The entity type of a thrown potion or bottle.
|
||||
fn thrown_kind(stack: &ItemStack) -> &'static str {
|
||||
match &*stack.item {
|
||||
"minecraft:lingering_potion" => "minecraft:lingering_potion",
|
||||
"minecraft:experience_bottle" => "minecraft:experience_bottle",
|
||||
_ => "minecraft:splash_potion",
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -297,10 +359,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.
|
||||
@@ -149,6 +164,8 @@ pub(super) struct Remote {
|
||||
level_players: HashMap<i32, u128>,
|
||||
/// The local player's profile UUID (always in the level).
|
||||
profile: u128,
|
||||
/// The local player's profile name: its score holder and team member.
|
||||
pub(super) profile_name: String,
|
||||
destroying: Option<([i32; 3], u8, f32)>,
|
||||
destroy_delay: u32,
|
||||
input: horizon_client::interaction_input::InteractionInput,
|
||||
@@ -351,6 +368,7 @@ impl Game<'_> {
|
||||
r.clear_sections();
|
||||
}
|
||||
self.apply_settings();
|
||||
self.clear_waypoints();
|
||||
self.remote = Some(Remote {
|
||||
session,
|
||||
brand: "unknown".into(),
|
||||
@@ -379,6 +397,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),
|
||||
@@ -394,6 +413,7 @@ impl Game<'_> {
|
||||
online_mode: false,
|
||||
level_players: HashMap::new(),
|
||||
profile: 0,
|
||||
profile_name: String::new(),
|
||||
destroying: None,
|
||||
destroy_delay: 0,
|
||||
input: Default::default(),
|
||||
@@ -429,6 +449,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();
|
||||
}
|
||||
@@ -777,6 +798,7 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.joined = true;
|
||||
r.profile = uuid;
|
||||
r.profile_name = name.clone();
|
||||
r.status = format!("Joined as {name}; loading terrain...");
|
||||
}
|
||||
}
|
||||
@@ -929,6 +951,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 +1338,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 +1361,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 +1444,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 +1460,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);
|
||||
}
|
||||
@@ -1650,29 +1729,10 @@ impl Game<'_> {
|
||||
self.block_entity_event(p.pos, i32::from(p.b0), i32::from(p.b1));
|
||||
}
|
||||
}
|
||||
C::LevelEvent(p) if p.kind == 2015 => {
|
||||
self.dragon_egg_particles(p.pos, p.data);
|
||||
}
|
||||
C::LevelEvent(p) if p.kind == 2017 => {
|
||||
self.consume_teleport_particles(p.pos, p.data);
|
||||
}
|
||||
C::LevelEvent(p) if p.kind == 2001 => {
|
||||
// LevelEvent.PARTICLES_DESTROY_BLOCK: someone else broke a block.
|
||||
let state = BlockStateId(p.data.max(0) as u32);
|
||||
let s = self.res.facts().get(state).sounds.clone();
|
||||
let at = [
|
||||
f64::from(p.pos[0]) + 0.5,
|
||||
f64::from(p.pos[1]) + 0.5,
|
||||
f64::from(p.pos[2]) + 0.5,
|
||||
];
|
||||
self.audio.play(
|
||||
&s.break_sound,
|
||||
Some(at),
|
||||
(s.volume + 1.0) / 2.0,
|
||||
s.pitch * 0.8,
|
||||
);
|
||||
self.destroy_particles(p.pos, state);
|
||||
}
|
||||
// ClientLevel.levelEvent: LevelEventHandler, as this client's own
|
||||
// level plays them (particles and sounds: a spawner's burst, bone
|
||||
// meal, waxing, a block broken by someone else).
|
||||
C::LevelEvent(p) => self.level_event_at(p.kind, p.pos, p.data),
|
||||
C::Sound(p) => {
|
||||
let name = match &p.sound.1 {
|
||||
Some((location, _)) => Some(location.clone()),
|
||||
@@ -1741,17 +1801,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 +1850,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 +1908,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 +1962,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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2019,6 +2132,15 @@ impl Game<'_> {
|
||||
m.swing();
|
||||
}
|
||||
}
|
||||
// The player's own instances: its maximum health (the hearts the
|
||||
// HUD draws and the health it may hold), reach, speed and so on.
|
||||
// Then the received health again, which the old maximum clamped.
|
||||
C::UpdateAttributes(p) if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) => {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
horizon_client::lan::attributes::apply(&mut pl.attributes, &p.values);
|
||||
}
|
||||
self.apply_remote_health();
|
||||
}
|
||||
C::UpdateMobEffect(p) => self.remote_effect(&p),
|
||||
C::RemoveMobEffect(p) => {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
|
||||
@@ -2061,6 +2183,7 @@ impl Game<'_> {
|
||||
self.capture(false);
|
||||
}
|
||||
}
|
||||
C::TrackedWaypoint(p) => self.handle_waypoint(&p),
|
||||
C::SetExperience(p) => {
|
||||
self.experience = Experience {
|
||||
level: p.level,
|
||||
@@ -2114,6 +2237,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 +3011,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 +3121,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 +3280,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 +3311,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 +3591,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
|
||||
@@ -3698,6 +3815,8 @@ impl Game<'_> {
|
||||
}
|
||||
if reacts {
|
||||
self.predict_use(&changes, seq);
|
||||
} else if let Some(stack) = held.as_ref() {
|
||||
self.predict_item_on_block(stack, &hit);
|
||||
}
|
||||
if held_block {
|
||||
if reacts
|
||||
@@ -3716,6 +3835,38 @@ impl Game<'_> {
|
||||
false
|
||||
}
|
||||
|
||||
/// `Item.useOn` on this client's level for the items whose effects are
|
||||
/// the clicker's own (`Level.levelEvent(player, ...)` and
|
||||
/// `playSound(player, ...)`: the server sends them to the others only):
|
||||
/// honeycomb waxing copper, and an axe, hoe or shovel transforming a
|
||||
/// block (stripping, scraping, removing wax, tilling, making a path).
|
||||
fn predict_item_on_block(&mut self, stack: &ItemStack, hit: &super::Hit) {
|
||||
let at = [
|
||||
f64::from(hit.pos[0]) + 0.5,
|
||||
f64::from(hit.pos[1]) + 0.5,
|
||||
f64::from(hit.pos[2]) + 0.5,
|
||||
];
|
||||
if &*stack.item == "minecraft:honeycomb" {
|
||||
// HoneycombItem.useOn.
|
||||
let registry = self.pack.assets.registry;
|
||||
let clicked = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let waxes = registry
|
||||
.state(clicked)
|
||||
.and_then(|old| self.res.transformers().waxed(registry, old))
|
||||
.is_some();
|
||||
if waxes {
|
||||
self.level_event_at(3003, hit.pos, 0);
|
||||
self.audio
|
||||
.play("minecraft:item.honeycomb.wax_on", Some(at), 1.0, 1.0);
|
||||
}
|
||||
} else if let Some((_, sound, _, _, particle)) =
|
||||
self.block_transform(stack, hit.pos, hit.face)
|
||||
{
|
||||
self.transform_particle(&particle, hit.pos);
|
||||
self.audio.play(&sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// MultiPlayerGameMode.useItemOn inside startPrediction: the blocks the
|
||||
/// use changed on this client's level at once, each kept as predicted
|
||||
/// under the use's sequence until the server acknowledges it (a use the
|
||||
@@ -5023,6 +5174,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);
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,15 @@ impl Game<'_> {
|
||||
self.rockets
|
||||
.push(super::fireworks::Rocket::remote(id, at, velocity));
|
||||
}
|
||||
"minecraft:splash_potion"
|
||||
| "minecraft:lingering_potion"
|
||||
| "minecraft:experience_bottle" => {
|
||||
self.potions.add_remote_thrown(id, kind, at, velocity);
|
||||
}
|
||||
"minecraft:eye_of_ender" => {
|
||||
self.end
|
||||
.add_remote_eye(id, position, [velocity.x, velocity.y, velocity.z])
|
||||
}
|
||||
"minecraft:end_crystal" => self.crystals.push(super::dragon::Crystal {
|
||||
id,
|
||||
position: at,
|
||||
@@ -105,6 +114,17 @@ impl Game<'_> {
|
||||
/// `handleSetEntityData` for these types: what changes how they look.
|
||||
pub(super) fn remote_projectile_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
let id = id as u32;
|
||||
if self.potions.has_thrown(id) {
|
||||
// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it is.
|
||||
for (index, _, value) in values {
|
||||
if let (8, Value::Item(stack)) = (index, value)
|
||||
&& let Some(stack) = self.to_stack(stack)
|
||||
{
|
||||
self.potions.remote_thrown_item(id, stack);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (index, _, value) in values {
|
||||
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id) {
|
||||
// PrimedTnt.DATA_FUSE_ID.
|
||||
@@ -184,6 +204,11 @@ impl Game<'_> {
|
||||
c.time += 1;
|
||||
c.position = step(c.id, c.position);
|
||||
}
|
||||
self.potions.step_remote_thrown(&mut step);
|
||||
self.end.step_remote_eyes(|id, at| {
|
||||
let at = step(id, Vec3::new(at[0], at[1], at[2]));
|
||||
[at.x, at.y, at.z]
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_projectile(&mut self, id: u32) {
|
||||
@@ -193,6 +218,8 @@ impl Game<'_> {
|
||||
self.tridents.retain(|t| t.id != id);
|
||||
self.rockets.retain(|r| r.id != id);
|
||||
self.crystals.retain(|c| c.id != id);
|
||||
self.potions.remove_remote_thrown(id);
|
||||
self.end.remove_remote_eye(id);
|
||||
}
|
||||
|
||||
/// Every one a server sent (a new connection, level or dimension).
|
||||
@@ -203,6 +230,8 @@ impl Game<'_> {
|
||||
self.tridents.clear();
|
||||
self.rockets.clear();
|
||||
self.crystals.clear();
|
||||
self.potions.clear_thrown();
|
||||
self.end.clear_remote_eyes();
|
||||
}
|
||||
|
||||
/// Their kind and place, for `status`.
|
||||
@@ -225,6 +254,12 @@ impl Game<'_> {
|
||||
for p in &self.creatures.projectiles {
|
||||
out.push(json!({"id": p.id, "kind": format!("{:?}", p.kind).split([' ', '{']).next().unwrap_or(""), "position": at(p.position)}));
|
||||
}
|
||||
for (kind, p, _) in self.potions.thrown_entities() {
|
||||
out.push(json!({"kind": kind, "position": at(p)}));
|
||||
}
|
||||
for (id, p) in self.end.eye_positions() {
|
||||
out.push(json!({"id": id, "kind": "minecraft:eye_of_ender", "position": p}));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Worlds and the multiplayer server list from the control socket: what the Select World, Edit
|
||||
//! World and Multiplayer screens do, by world folder. Changing a world needs the title screen
|
||||
//! (no world open), as those screens do; deleting one needs `--confirm`.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, title};
|
||||
use serde_json::{Value, json};
|
||||
use std::{path::PathBuf, sync::mpsc::Sender, time::UNIX_EPOCH};
|
||||
|
||||
pub(super) const HELP: &[&str] = &[
|
||||
"worlds the saved worlds, newest first: folder, name, mode, last played, version",
|
||||
"world-rename FOLDER NAME rename a world (Edit World; its folder stays)",
|
||||
"world-backup FOLDER zip a world into backups/ (Edit World > Make Backup)",
|
||||
"world-delete FOLDER --confirm delete a world for good (Select World > Delete)",
|
||||
"servers the multiplayer server list (servers.dat): name and address",
|
||||
];
|
||||
|
||||
fn saves() -> Result<PathBuf> {
|
||||
Ok(std::env::current_dir()?.join("saves"))
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Some(reply) for these commands (Some(None): the reply comes later), None for others.
|
||||
pub(super) fn saves_control(
|
||||
&mut self,
|
||||
words: &[&str],
|
||||
line: &str,
|
||||
reply: &Sender<String>,
|
||||
) -> Result<Option<Option<Value>>> {
|
||||
let folder = || {
|
||||
words
|
||||
.get(1)
|
||||
.copied()
|
||||
.ok_or_else(|| format!("{} FOLDER: the folder from worlds", words[0]))
|
||||
};
|
||||
let on_title = || {
|
||||
if self.title.is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"{} needs the title screen: save and leave the world first (pause, then Save and Quit to Title)",
|
||||
words[0]
|
||||
))
|
||||
}
|
||||
};
|
||||
let value = match words[0] {
|
||||
"worlds" => {
|
||||
let worlds: Vec<Value> = title::list_worlds(&saves()?)
|
||||
.iter()
|
||||
.map(|w| {
|
||||
json!({
|
||||
"folder": w.folder,
|
||||
"name": w.name,
|
||||
"mode": if w.creative { "creative" } else { "survival" },
|
||||
"last_played": w.modified.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64),
|
||||
"commands": w.commands,
|
||||
"version": w.version,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({ "worlds": worlds })
|
||||
}
|
||||
"world-rename" => {
|
||||
on_title()?;
|
||||
let folder = folder()?;
|
||||
let name = line
|
||||
.trim_start()
|
||||
.strip_prefix("world-rename")
|
||||
.and_then(|rest| rest.trim_start().strip_prefix(folder))
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty())
|
||||
.ok_or("world-rename FOLDER NAME: missing the new name")?;
|
||||
title::world_management::rename(&saves()?, folder, name)?;
|
||||
if let Some(title) = self.title.as_mut() {
|
||||
title.worlds_changed();
|
||||
}
|
||||
json!({ "ok": true, "folder": folder, "name": name })
|
||||
}
|
||||
"world-delete" => {
|
||||
on_title()?;
|
||||
let folder = folder()?;
|
||||
if !words.contains(&"--confirm") {
|
||||
return Err(format!(
|
||||
"world-delete deletes {folder} for good: ask the player, then add --confirm"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
title::world_management::delete(&saves()?, folder)?;
|
||||
if let Some(title) = self.title.as_mut() {
|
||||
title.worlds_changed();
|
||||
}
|
||||
json!({ "ok": true, "deleted": folder })
|
||||
}
|
||||
"world-backup" => {
|
||||
on_title()?;
|
||||
let folder = folder()?.to_owned();
|
||||
let saves = saves()?;
|
||||
let backups = title::world_management::backups_path(&saves);
|
||||
// A big world takes a while to zip: off the game thread, as Make Backup does.
|
||||
let reply = reply.clone();
|
||||
std::thread::spawn(move || {
|
||||
let answer = match title::world_management::backup(&saves, &folder) {
|
||||
Ok(bytes) => {
|
||||
json!({ "ok": true, "folder": folder, "backups": backups, "bytes": bytes })
|
||||
}
|
||||
Err(e) => json!({ "error": format!("backup of {folder} failed: {e}") }),
|
||||
};
|
||||
let _ = reply.send(answer.to_string());
|
||||
});
|
||||
return Ok(Some(None));
|
||||
}
|
||||
"servers" => {
|
||||
let file = horizon_client::multiplayer::servers_file(&saves()?);
|
||||
let servers: Vec<Value> = horizon_protocol::servers::load(&file)
|
||||
.iter()
|
||||
.map(|s| json!({ "name": s.name, "address": s.ip }))
|
||||
.collect();
|
||||
json!({ "servers": servers })
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
Ok(Some(Some(value)))
|
||||
}
|
||||
}
|
||||
@@ -324,6 +324,22 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ServerScoreboard`'s packets: each placed player's client is sent
|
||||
/// what changed on the board since it was last sent it (all of it on
|
||||
/// joining), lan/scoreboard.rs.
|
||||
pub(super) fn lan_sync_scoreboard(&mut self, host: &mut super::lan::LanHost) {
|
||||
if host.hub.guests.iter().all(|g| !g.placed) {
|
||||
return;
|
||||
}
|
||||
self.load_scoreboard();
|
||||
let view = horizon_client::lan::scoreboard::View::of(&self.score.board);
|
||||
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
|
||||
if g.scoreboard != view {
|
||||
g.send_all(&g.scoreboard.changes(&view));
|
||||
g.scoreboard = view.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.doTick` score updates and the statistic criteria.
|
||||
pub(super) fn tick_scoreboard(&mut self) {
|
||||
self.load_scoreboard();
|
||||
@@ -563,19 +579,42 @@ impl Game<'_> {
|
||||
|
||||
// ---- the sidebar -----------------------------------------------------------
|
||||
|
||||
/// `Hud.extractScoreboardSidebar` / `displayScoreboardSidebar`.
|
||||
pub(super) fn draw_scoreboard(&self, batch: &mut HudBatch) {
|
||||
/// The objective in the player's team's sidebar slot, else the sidebar's.
|
||||
fn sidebar_objective(&self) -> Option<&sb::Objective> {
|
||||
let board = &self.score.board;
|
||||
if board.display.is_empty() {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
// A server's client is in its team by its profile name.
|
||||
let name = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map_or(PLAYER_NAME, |r| r.profile_name.as_str());
|
||||
let team_slot = board
|
||||
.players_team(PLAYER_NAME)
|
||||
.players_team(name)
|
||||
.and_then(|t| t.color.as_ref())
|
||||
.map(|c| format!("sidebar.team.{c}"))
|
||||
.and_then(|slot| board.display_objective(&slot));
|
||||
let Some(objective) = team_slot.or_else(|| board.display_objective(sb::SLOT_SIDEBAR))
|
||||
else {
|
||||
team_slot.or_else(|| board.display_objective(sb::SLOT_SIDEBAR))
|
||||
}
|
||||
/// The sidebar the HUD shows: its objective and its lines, for `status`.
|
||||
pub(super) fn sidebar_status(&self) -> serde_json::Value {
|
||||
let board = &self.score.board;
|
||||
let Some(objective) = self.sidebar_objective() else {
|
||||
return serde_json::Value::Null;
|
||||
};
|
||||
let scores: serde_json::Map<String, serde_json::Value> = board
|
||||
.scores
|
||||
.iter()
|
||||
.filter_map(|(holder, s)| Some((holder.clone(), s.get(&objective.name)?.value.into())))
|
||||
.collect();
|
||||
serde_json::json!({ "objective": objective.name, "title": objective.display_text().plain(), "scores": scores })
|
||||
}
|
||||
|
||||
/// `Hud.extractScoreboardSidebar` / `displayScoreboardSidebar`.
|
||||
pub(super) fn draw_scoreboard(&self, batch: &mut HudBatch) {
|
||||
let board = &self.score.board;
|
||||
let Some(objective) = self.sidebar_objective() else {
|
||||
return;
|
||||
};
|
||||
let draws = hud_overlays::sidebar(
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
+496
-463
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));
|
||||
|
||||
@@ -119,10 +119,6 @@ impl Tile {
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
pub fn resident_lods(&self) -> usize {
|
||||
self.lods.len()
|
||||
}
|
||||
|
||||
pub fn upload_lod(&mut self, key: TileKey, mesh: &SectionMesh) {
|
||||
self.remove_lod(key);
|
||||
if let Some(bounds) = Bounds::mesh(mesh)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,7 +7,6 @@ use std::collections::BTreeMap;
|
||||
|
||||
/// `ReputationEventType` amounts.
|
||||
pub const REPUTATION_CHANGE_PER_EVENT: i32 = 25;
|
||||
pub const REPUTATION_CHANGE_PER_EVERLASTING_MEMORY: i32 = 20;
|
||||
pub const REPUTATION_CHANGE_PER_TRADE: i32 = 2;
|
||||
/// `GossipContainer.DISCARD_THRESHOLD`: values below this are forgotten.
|
||||
const DISCARD_THRESHOLD: i32 = 2;
|
||||
|
||||
@@ -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,
|
||||
@@ -469,14 +486,6 @@ impl Item {
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// Caption and value text, split from the label ("Caption: Value").
|
||||
pub fn caption_value(self, s: &Settings) -> (String, String) {
|
||||
let label = self.label(s).plain();
|
||||
match label.split_once(": ") {
|
||||
Some((c, v)) => (c.to_owned(), v.to_owned()),
|
||||
None => (label, String::new()),
|
||||
}
|
||||
}
|
||||
pub fn slider(self, s: &Settings) -> Option<f32> {
|
||||
match self {
|
||||
Item::Vanilla(o) => o.slider_position(s),
|
||||
|
||||
Loaded 100 of 332 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user