mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
230
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9755416a02 | ||
|
|
a5fcb49900 | ||
|
|
0ca3a5be98 | ||
|
|
8993772de0 | ||
|
|
6a261a00a5 | ||
|
|
f8b83eea51 | ||
|
|
88398e8bda | ||
|
|
6d3489d30b | ||
|
|
7a84c93843 | ||
|
|
cba52f4e5d | ||
|
|
9f1fa0a0a2 | ||
|
|
fe84cc07a1 | ||
|
|
d4e42cf726 | ||
|
|
466512c90e | ||
|
|
4db071e281 | ||
|
|
2649c5e506 | ||
|
|
505de825db | ||
|
|
1f6511d0f3 | ||
|
|
1523c118d8 | ||
|
|
9239089b76 | ||
|
|
281ab9759c | ||
|
|
febad05c78 | ||
|
|
519edfac19 | ||
|
|
6561df77f6 | ||
|
|
cd7f177104 | ||
|
|
07ab7ccfc1 | ||
|
|
727f6c932a | ||
|
|
cd1f58ef72 | ||
|
|
291aae00e8 | ||
|
|
bec8cce82d | ||
|
|
0ee2425d40 | ||
|
|
7f3bd901bc | ||
|
|
d308df6167 | ||
|
|
5ded675a7d | ||
|
|
564162a0b0 | ||
|
|
8b5a27ed3f | ||
|
|
c0505424bd | ||
|
|
137045e321 | ||
|
|
28e3286e40 | ||
|
|
aef4186de4 | ||
|
|
b6c936f82b | ||
|
|
6960b65c62 | ||
|
|
5b13af2f75 | ||
|
|
557a827383 | ||
|
|
56f968e11c | ||
|
|
ae4cea18ef | ||
|
|
296802a618 | ||
|
|
a5d3ad165a | ||
|
|
2120ffedb4 | ||
|
|
04e6e21813 | ||
|
|
acee253e1c | ||
|
|
555dce8bb3 | ||
|
|
067163ded8 | ||
|
|
704cf97780 | ||
|
|
007d38fe49 | ||
|
|
43e2ad34c7 | ||
|
|
a6dbc6651f | ||
|
|
4bb3de29e7 | ||
|
|
786f67f77b | ||
|
|
797852c62b | ||
|
|
1f8f1055d5 | ||
|
|
0121466ed2 | ||
|
|
55b0545c11 | ||
|
|
5e14e309bc | ||
|
|
9e236c7914 | ||
|
|
2d7fc7f000 | ||
|
|
644a399389 | ||
|
|
9e31ce2f3d | ||
|
|
66d03b4d1c | ||
|
|
84d5574887 | ||
|
|
4118e026c3 | ||
|
|
51b374121b | ||
|
|
9042746704 | ||
|
|
795490271d | ||
|
|
9fe3ddfec3 | ||
|
|
7a62c6f4a7 | ||
|
|
e0bb0881e8 | ||
|
|
4b4950d7c4 | ||
|
|
e902b6fc18 | ||
|
|
5096b41100 | ||
|
|
46c84a8b24 | ||
|
|
ae82c1b693 | ||
|
|
501e543105 | ||
|
|
466fef4486 | ||
|
|
e34f01715c | ||
|
|
ad61c30f46 | ||
|
|
f35ee16412 | ||
|
|
5216403562 | ||
|
|
2aea6138b7 | ||
|
|
5b41d9ebf3 | ||
|
|
52f3dcc5ef | ||
|
|
7d99d23aef | ||
|
|
1feb28c045 | ||
|
|
7f6e8b634e | ||
|
|
2e69ac9886 | ||
|
|
c309d331ad | ||
|
|
7acfc655e8 | ||
|
|
d485c688d6 | ||
|
|
93d77962ee | ||
|
|
47ca0312df | ||
|
|
c9fa1c8115 | ||
|
|
d2b0347777 | ||
|
|
78c028321c | ||
|
|
64c174118a | ||
|
|
f21e09510e | ||
|
|
127434c46c | ||
|
|
7b40465f85 | ||
|
|
27c3559421 | ||
|
|
a91711afaf | ||
|
|
4df2461b65 | ||
|
|
35540bda73 | ||
|
|
f66e6a944e | ||
|
|
a34a41e1b5 | ||
|
|
9861a1ba04 | ||
|
|
26547a5f29 | ||
|
|
d9ab241406 | ||
|
|
41c2c2205c | ||
|
|
43134f4e41 | ||
|
|
85c8276e06 | ||
|
|
95bf2811fc | ||
|
|
d87b081c34 | ||
|
|
cf62fe270b | ||
|
|
b784b15b8a | ||
|
|
38b508e5a7 | ||
|
|
de24a16ef4 | ||
|
|
9b53749263 | ||
|
|
cdccd03c7d | ||
|
|
27943eb6c7 | ||
|
|
28fbc7ff47 | ||
|
|
33ced209a0 | ||
|
|
8155baa6f3 | ||
|
|
423a87cf68 | ||
|
|
ff8a13c99a | ||
|
|
0e1fcaa3cc | ||
|
|
1b45824beb | ||
|
|
a5053a898c | ||
|
|
610c2f3c10 | ||
|
|
7afb41a128 | ||
|
|
ce64c617e9 | ||
|
|
9b6faeb8cf | ||
|
|
1cfb4def89 | ||
|
|
b2fd94f49f | ||
|
|
b8eac21b6f | ||
|
|
fad711f55f | ||
|
|
4bc514829c | ||
|
|
b8ae7233e0 | ||
|
|
5001e3f57d | ||
|
|
f731ca1737 | ||
|
|
b124beafeb | ||
|
|
c7f448c523 | ||
|
|
c7937c5c20 | ||
|
|
ed652da572 | ||
|
|
b28797c534 | ||
|
|
e36cd17dd1 | ||
|
|
cf2e9d1bc0 | ||
|
|
e6c1a91a65 | ||
|
|
8cfc13fe2c | ||
|
|
a7e3bcf79f | ||
|
|
62bb83857c | ||
|
|
8d2b298253 | ||
|
|
e776eb1716 | ||
|
|
78e635fe3e | ||
|
|
4414c3c1af | ||
|
|
6cf5b046fa | ||
|
|
0f8f8fce11 | ||
|
|
708db4627e | ||
|
|
9d9389310d | ||
|
|
0762f49f20 | ||
|
|
bbd5c1c833 | ||
|
|
de286f236f | ||
|
|
91d401aa8a | ||
|
|
a7d0586d95 | ||
|
|
21ac444f28 | ||
|
|
1e7e2ffb27 | ||
|
|
c3fb1656e6 | ||
|
|
b98633b677 | ||
|
|
3c7942d2bd | ||
|
|
d5aa3f6e42 | ||
|
|
0914229800 | ||
|
|
de89e758cc | ||
|
|
5f5a43fd54 | ||
|
|
c0bf77f3c3 | ||
|
|
86474679db | ||
|
|
c4a39757a1 | ||
|
|
7690ad27c2 | ||
|
|
74da2fe6b7 | ||
|
|
6df1dfd4da | ||
|
|
560aa61e91 | ||
|
|
af548f5ae8 | ||
|
|
8fad68ab3e | ||
|
|
a0b4721c5b | ||
|
|
d136f966f8 | ||
|
|
0d554de016 | ||
|
|
62c5a6d27b | ||
|
|
9f0c201851 | ||
|
|
1db0eda4ae | ||
|
|
e314c2fa81 | ||
|
|
0f565f4e4c | ||
|
|
d8cc59dd55 | ||
|
|
8648470c47 | ||
|
|
eb664d50c1 | ||
|
|
e42ec2360c | ||
|
|
beac63849c | ||
|
|
97d119b4e8 | ||
|
|
cece5aca30 | ||
|
|
713de31f64 | ||
|
|
9142f57bc8 | ||
|
|
eeceb9c2de | ||
|
|
2a1132387f | ||
|
|
03cc30ae45 | ||
|
|
80e28dfe63 | ||
|
|
27f73e1db3 | ||
|
|
75bcbce441 | ||
|
|
2f489f54c5 | ||
|
|
5f73d0d02d | ||
|
|
b0ce58adea | ||
|
|
6145077d7e | ||
|
|
2a297a43eb | ||
|
|
de808e3612 | ||
|
|
23019a7bf9 | ||
|
|
50fdcad10f | ||
|
|
fc8e505c95 | ||
|
|
a07c6d57dc | ||
|
|
3c94b5441e | ||
|
|
5b89508fe9 | ||
|
|
d18197a176 | ||
|
|
775e5b19cb | ||
|
|
f56bf1ba38 | ||
|
|
ca119bb069 | ||
|
|
27d750f1c9 |
No files matched your search
@@ -1,7 +0,0 @@
|
||||
# Trusted reviewers: they pick the pull requests the Nightly holds with `/nightly 2640 2642` in any issue or pull
|
||||
# request (docs/release-flow.md "Nightly channel", tools/ci/nightly_set.py). Ludovic is always allowed and isn't
|
||||
# listed. One GitHub login per line, with who added it and when. Removing a line revokes that login at once.
|
||||
# A line with only `paused` stops every reviewer's `/nightly` (Ludovic's still work).
|
||||
kacpherek # added by Ludovic, 2026-10-27
|
||||
rdbtCVS # added by Ludovic, 2026-10-27
|
||||
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
|
||||
@@ -0,0 +1,9 @@
|
||||
# Trusted reviewers: one list for two things (docs/release-flow.md "Trusted reviewers").
|
||||
# - `/nightly 2640 2642` picks the pull requests the Nightly holds (tools/ci/nightly_set.py);
|
||||
# - `/close`, `/reopen`, `/label`, `/unlabel` triage issues and pull requests (tools/ci/triage_command.py).
|
||||
# Ludovic is always allowed and isn't listed. One GitHub login per line, with who added it and when. Removing a
|
||||
# line revokes that login at once. A line with only `paused` stops both for every reviewer (Ludovic's still work).
|
||||
kacpherek # added by Ludovic, 2026-10-27
|
||||
rdbtCVS # added by Ludovic, 2026-10-27
|
||||
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
|
||||
levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
|
||||
@@ -25,6 +25,9 @@ run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.req
|
||||
# names the channel ("horizon release nightly"), so a nightly's signature can't pass for a release's.
|
||||
# - `publish` verifies the signature with main's horizon-release-sign, checks the smoke test, and only then moves
|
||||
# the tag. Like sign, it never executes what build produced.
|
||||
# - The source ships with the build: `pick` writes horizon-source.tar.gz with `git archive` (git objects again, nothing
|
||||
# run), `sign` signs it with the builds, and `publish` attaches it and tags the commit nightly-source/<date>-<short>
|
||||
# for good (the dated release and the build branch go after 7 days; native mods pin the commit: docs/native-mods.md).
|
||||
# gh workflow run "nightly build" # now (skips when the pick is the same commit as last night's)
|
||||
# gh workflow run "nightly build" -f force=true # build and publish even if unchanged
|
||||
# gh workflow run "nightly build" -f dry_run=true # build and sign with a throwaway key, publish nothing
|
||||
@@ -76,6 +79,7 @@ jobs:
|
||||
date: ${{ steps.pick.outputs.date }}
|
||||
version: ${{ steps.pick.outputs.version }}
|
||||
nightly: ${{ steps.pick.outputs.nightly }}
|
||||
source_tag: ${{ steps.pick.outputs.source_tag }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -103,6 +107,18 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
{ echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
- name: The source of the build (horizon-source.tar.gz)
|
||||
if: steps.pick.outputs.build == 'true'
|
||||
env:
|
||||
SHA: ${{ steps.pick.outputs.sha }}
|
||||
run: git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: steps.pick.outputs.build == 'true'
|
||||
with:
|
||||
name: horizon-source
|
||||
path: horizon-source.tar.gz
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nightly-notes
|
||||
@@ -144,7 +160,7 @@ jobs:
|
||||
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
bash tools/ci/sign_release.sh nightly 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/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
@@ -187,6 +203,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SHA: ${{ needs.pick.outputs.sha }}
|
||||
DATE: ${{ needs.pick.outputs.date }}
|
||||
SOURCE_TAG: ${{ needs.pick.outputs.source_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 tools/ci/nightly.py rotate
|
||||
@@ -198,7 +215,9 @@ jobs:
|
||||
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
python3 tools/ci/nightly.py keep-source "$SHA" "$DATE"
|
||||
python3 tools/ci/nightly.py prune --days 7
|
||||
- name: Dry run, the nightly that would be published
|
||||
if: needs.pick.outputs.dry_run == 'true'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: nightly command
|
||||
# `/nightly …` comments: the trusted reviewers in .github/nightly-reviewers.txt (and Ludovic) pick the pull requests
|
||||
# `/nightly …` comments: the trusted reviewers in .github/trusted-reviewers.txt (and Ludovic) pick the pull requests
|
||||
# the Nightly holds (docs/release-flow.md "Nightly channel", tools/ci/nightly_set.py). This job only checks who asked,
|
||||
# saves the set on the nightly-state branch, starts `nightly build` and answers in the thread.
|
||||
#
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github/CODEOWNERS
|
||||
.github/nightly-reviewers.txt
|
||||
.github/trusted-reviewers.txt
|
||||
tools/ci/nightly_set.py
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Run the command
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
name: triage command
|
||||
# `/close`, `/reopen`, `/label`, `/unlabel` comments: the trusted reviewers in .github/trusted-reviewers.txt (and
|
||||
# Ludovic) triage issues and pull requests without any repository role (docs/release-flow.md "Trusted reviewers",
|
||||
# tools/ci/triage_command.py).
|
||||
#
|
||||
# Why no pull request's code runs here, though the token can write:
|
||||
# - `issue_comment` always runs this file from main, whoever comments and wherever.
|
||||
# - It checks out main's script and list only, never a pull request; it reads threads through the API.
|
||||
# - The comment's text and author reach the script as env vars, never in a shell line. The token stays in the step.
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
command:
|
||||
# Every other comment skips here, before a runner starts.
|
||||
if: >-
|
||||
github.event.comment.user.type != 'Bot' && (
|
||||
startsWith(github.event.comment.body, '/close') ||
|
||||
startsWith(github.event.comment.body, '/reopen') ||
|
||||
startsWith(github.event.comment.body, '/label') ||
|
||||
startsWith(github.event.comment.body, '/unlabel'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github/trusted-reviewers.txt
|
||||
tools/ci/nightly_set.py
|
||||
tools/ci/triage_command.py
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Run the command
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BODY: ${{ github.event.comment.body }}
|
||||
AUTHOR: ${{ github.event.comment.user.login }}
|
||||
ISSUE: ${{ github.event.issue.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
COMMENT_URL: ${{ github.event.comment.html_url }}
|
||||
run: python3 tools/ci/triage_command.py command
|
||||
@@ -35,9 +35,11 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
|
||||
| Crate | Owns |
|
||||
|---|---|
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~180 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~170 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
|
||||
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
|
||||
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
|
||||
| `horizon-client-input` | Key bindings and key names, toggle keys, mouse options, controller settings and glyphs, the clipboard, window options (`keybinds.rs`, `gamepad.rs`, `mouse.rs`, `window.rs`...). Re-exported under the old paths |
|
||||
| `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` |
|
||||
@@ -57,7 +59,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
|
||||
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),
|
||||
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private ludovic111/horizon-site: its build
|
||||
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private horizon-rust/horizon-site: its build
|
||||
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
|
||||
|
||||
## Build and test fast
|
||||
@@ -113,14 +115,14 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
|
||||
| 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` |
|
||||
| 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`, `spans.rs`; `horizon-client-input/src/window.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` |
|
||||
| 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`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.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: crate `horizon-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`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.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` |
|
||||
@@ -129,7 +131,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
| 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`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.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` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.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`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `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)) |
|
||||
|
||||
+4
-1
@@ -86,6 +86,9 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
|
||||
release notes), `skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
|
||||
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
|
||||
|
||||
Who reviews and merges: the Horizon Team, on CI for ordinary pull requests; risky ones (saves, network, world
|
||||
generation, the launcher's updates) wait for a local proof first ([merging](docs/release-flow.md#merging)).
|
||||
|
||||
Every merge to main is released automatically ([release flow](docs/release-flow.md)), so main always stays playable.
|
||||
How agents, checks, releases and signed updates keep this safe: [security](docs/security.md).
|
||||
|
||||
@@ -98,5 +101,5 @@ new pushes, we add it again.
|
||||
|
||||
## Talk to us
|
||||
|
||||
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/ludovic111/horizon/issues)
|
||||
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
|
||||
for bugs. If you want to take something on, say so in its issue so nobody else starts the same work.
|
||||
Generated
+30
-6
@@ -1370,7 +1370,6 @@ name = "horizon-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"arboard",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"cpal",
|
||||
@@ -1380,11 +1379,13 @@ dependencies = [
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-dualsense",
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-mods",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
@@ -1421,6 +1422,18 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-client-input"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"horizon-client-core",
|
||||
"horizon-dualsense",
|
||||
"horizon-theme",
|
||||
"png",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-command"
|
||||
version = "0.1.0"
|
||||
@@ -1469,6 +1482,17 @@ dependencies = [
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-items"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-client-core",
|
||||
"horizon-core",
|
||||
"horizon-world",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-sdk"
|
||||
version = "0.1.0"
|
||||
@@ -3182,9 +3206,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
version = "0.103.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -4362,14 +4386,14 @@ version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
"webpki-roots 1.0.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
version = "1.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
@@ -8,7 +8,9 @@ members = [
|
||||
"crates/horizon-world",
|
||||
"crates/horizon-client",
|
||||
"crates/horizon-client-core",
|
||||
"crates/horizon-client-input",
|
||||
"crates/horizon-command",
|
||||
"crates/horizon-items",
|
||||
"crates/horizon-server-files",
|
||||
"crates/horizon-server",
|
||||
"crates/horizon-bots",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ludovic
|
||||
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/ludovic111/horizon/actions/workflows/ci.yml"><img src="https://github.com/ludovic111/horizon/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
|
||||
<a href="https://github.com/horizon-rust/horizon/actions/workflows/ci.yml"><img src="https://github.com/horizon-rust/horizon/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
|
||||
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
|
||||
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
|
||||
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
|
||||
@@ -15,7 +15,7 @@
|
||||
<a href="https://playhorizon.dev"><b>Website</b></a> ·
|
||||
<a href="#download"><b>Download</b></a> ·
|
||||
<a href="https://playhorizon.dev/docs/"><b>Documentation</b></a> ·
|
||||
<a href="https://github.com/ludovic111/horizon/releases"><b>Release notes</b></a> ·
|
||||
<a href="https://github.com/horizon-rust/horizon/releases"><b>Release notes</b></a> ·
|
||||
<a href="ROADMAP.md"><b>Roadmap</b></a> ·
|
||||
<a href="#project-status"><b>Project status</b></a> ·
|
||||
<a href="#contributing"><b>Contributing</b></a>
|
||||
@@ -29,9 +29,9 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
|
||||
|
||||
## Download
|
||||
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
|
||||
|
||||
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)); Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -47,7 +47,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
|
||||
@@ -99,7 +99,7 @@ Horizon contains no game files and never downloads them. It builds its resources
|
||||
2. **Server jar:** the 26.3 server jar, from the `downloads.server.url` in your launcher's `versions/26.3/26.3.json` (or [minecraft.net/download/server](https://www.minecraft.net/en-us/download/server) while 26.3 is the current release). Pass it with `--server-jar` or set `HORIZON_SERVER_JAR`. It is only needed the first time.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/ludovic111/horizon.git
|
||||
git clone https://github.com/horizon-rust/horizon.git
|
||||
cd horizon
|
||||
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
|
||||
cargo run --release -p horizon-prepare -- sounds # optional: sounds from your launcher install
|
||||
@@ -158,7 +158,7 @@ them too (Rust uses Java's own float maths on each platform).
|
||||
|
||||
### Known issues
|
||||
|
||||
Horizon can't sign in to a Microsoft account yet, so it can't join online-mode servers (it joins offline-mode ones); skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
|
||||
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
+66
-66
@@ -32,7 +32,7 @@ Making Horizon faster than vanilla on every computer, with smooth camera movemen
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Frame pacing at render distance 32 ([#678](https://github.com/ludovic111/horizon/issues/678)): long frames come from work competing with the render thread. In review: world generation workers build free structure starts instead of queueing them ([#1760](https://github.com/ludovic111/horizon/issues/1760)), the worker pool leaves one core as vanilla does ([#1778](https://github.com/ludovic111/horizon/issues/1778)), and the terrain vertex arena is read as storage where flat draws run ([#1742](https://github.com/ludovic111/horizon/issues/1742)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/ludovic111/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/ludovic111/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
|
||||
Frame pacing at render distance 32 ([#678](https://github.com/horizon-rust/horizon/issues/678)): long frames come from work competing with the render thread. In review: world generation workers build free structure starts instead of queueing them ([#1760](https://github.com/horizon-rust/horizon/issues/1760)), the worker pool leaves one core as vanilla does ([#1778](https://github.com/horizon-rust/horizon/issues/1778)), and the terrain vertex arena is read as storage where flat draws run ([#1742](https://github.com/horizon-rust/horizon/issues/1742)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/horizon-rust/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/horizon-rust/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -42,7 +42,7 @@ Speeding up the code that reads and writes the blocks of the world, which almost
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/ludovic111/horizon/issues/1790), [#1794](https://github.com/ludovic111/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/ludovic111/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
|
||||
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/horizon-rust/horizon/issues/1790), [#1794](https://github.com/horizon-rust/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/horizon-rust/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -54,7 +54,7 @@ Making the world-generation mods of popular modpacks (new structures, YUNG's dun
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-port-pack` carries a pack's data-driven content over; structure mods that need code get a native port: YUNG's worldgen as written ([#1746](https://github.com/ludovic111/horizon/issues/1746)), Lithostitched's limited and guaranteed elements with pool and processor modifiers ([#1777](https://github.com/ludovic111/horizon/issues/1777)), its processors as the game's own types ([#1765](https://github.com/ludovic111/horizon/issues/1765)). Proved by the structure blocks and layout harnesses ([#1753](https://github.com/ludovic111/horizon/issues/1753), [#1757](https://github.com/ludovic111/horizon/issues/1757)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
|
||||
`horizon-port-pack` carries a pack's data-driven content over; structure mods that need code get a native port: YUNG's worldgen as written ([#1746](https://github.com/horizon-rust/horizon/issues/1746)), Lithostitched's limited and guaranteed elements with pool and processor modifiers ([#1777](https://github.com/horizon-rust/horizon/issues/1777)), its processors as the game's own types ([#1765](https://github.com/horizon-rust/horizon/issues/1765)). Proved by the structure blocks and layout harnesses ([#1753](https://github.com/horizon-rust/horizon/issues/1753), [#1757](https://github.com/horizon-rust/horizon/issues/1757)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -66,7 +66,7 @@ When you play on a server, other players, mobs, items and menus look and behave
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/ludovic111/horizon/issues/1802), [#1798](https://github.com/ludovic111/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/ludovic111/horizon/issues/1795)), skins ([#1652](https://github.com/ludovic111/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/ludovic111/horizon/issues/1650)), firework boosts ([#1671](https://github.com/ludovic111/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/ludovic111/horizon/issues/1731)), styled item names ([#1702](https://github.com/ludovic111/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/ludovic111/horizon/issues/1710)).
|
||||
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/horizon-rust/horizon/issues/1802), [#1798](https://github.com/horizon-rust/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/horizon-rust/horizon/issues/1795)), skins ([#1652](https://github.com/horizon-rust/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/horizon-rust/horizon/issues/1650)), firework boosts ([#1671](https://github.com/horizon-rust/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/horizon-rust/horizon/issues/1731)), styled item names ([#1702](https://github.com/horizon-rust/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/horizon-rust/horizon/issues/1710)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -76,7 +76,7 @@ Servers that show dialogs, such as the register and login screens of AuthMe, wil
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
26.3's dialog packets and model ([#1720](https://github.com/ludovic111/horizon/issues/1720)), the dialog screen with its buttons and actions ([#1734](https://github.com/ludovic111/horizon/issues/1734)), then text and boolean inputs ([#1735](https://github.com/ludovic111/horizon/issues/1735)).
|
||||
26.3's dialog packets and model ([#1720](https://github.com/horizon-rust/horizon/issues/1720)), the dialog screen with its buttons and actions ([#1734](https://github.com/horizon-rust/horizon/issues/1734)), then text and boolean inputs ([#1735](https://github.com/horizon-rust/horizon/issues/1735)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -86,7 +86,7 @@ A /pregen command that builds an area of the world ahead of time, so players don
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Generates, lights and saves an area before anyone walks there ([#1805](https://github.com/ludovic111/horizon/issues/1805), a contributor's pull request, in review). Useful on servers, and it moves the generation cost away from the first visit; the generation speed-ups are under 'Steady frames at long render distances'.
|
||||
Generates, lights and saves an area before anyone walks there ([#1805](https://github.com/horizon-rust/horizon/issues/1805), a contributor's pull request, in review). Useful on servers, and it moves the generation cost away from the first visit; the generation speed-ups are under 'Steady frames at long render distances'.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -98,7 +98,7 @@ One place for mods, modpacks and resource packs made for Horizon: browse them on
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Merged: the registry index (schema 1, `.hpack`, checks before install, [#1634](https://github.com/ludovic111/horizon/issues/1634)), `cargo horizon-mod check` ([#1642](https://github.com/ludovic111/horizon/issues/1642)) and the submission service (`crates/horizon-mod-registry`, [#1659](https://github.com/ludovic111/horizon/issues/1659)). In review: the website's /mods ([#1596](https://github.com/ludovic111/horizon/issues/1596)) and /mods/submit ([#1635](https://github.com/ludovic111/horizon/issues/1635)), and in the launcher the registry as a source ([#1636](https://github.com/ludovic111/horizon/issues/1636)), install, enable and takedowns ([#1637](https://github.com/ludovic111/horizon/issues/1637)), the Mods page ([#1638](https://github.com/ludovic111/horizon/issues/1638)) and modpacks as their own profiles ([#1640](https://github.com/ludovic111/horizon/issues/1640)).
|
||||
Merged: the registry index (schema 1, `.hpack`, checks before install, [#1634](https://github.com/horizon-rust/horizon/issues/1634)), `cargo horizon-mod check` ([#1642](https://github.com/horizon-rust/horizon/issues/1642)) and the submission service (`crates/horizon-mod-registry`, [#1659](https://github.com/horizon-rust/horizon/issues/1659)). In review: the website's /mods ([#1596](https://github.com/horizon-rust/horizon/issues/1596)) and /mods/submit ([#1635](https://github.com/horizon-rust/horizon/issues/1635)), and in the launcher the registry as a source ([#1636](https://github.com/horizon-rust/horizon/issues/1636)), install, enable and takedowns ([#1637](https://github.com/horizon-rust/horizon/issues/1637)), the Mods page ([#1638](https://github.com/horizon-rust/horizon/issues/1638)) and modpacks as their own profiles ([#1640](https://github.com/horizon-rust/horizon/issues/1640)).
|
||||
|
||||
More: [docs/mods.md](docs/mods.md)
|
||||
|
||||
@@ -110,7 +110,7 @@ Mods that run on the server side of a world, so a dedicated server or a LAN worl
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
API 0.3, experimental first ([#1833](https://github.com/ludovic111/horizon/issues/1833), in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). [docs/api/mods.md](docs/api/mods.md), [docs/mods.md](docs/mods.md).
|
||||
API 0.3, experimental first ([#1833](https://github.com/horizon-rust/horizon/issues/1833), in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). [docs/api/mods.md](docs/api/mods.md), [docs/mods.md](docs/mods.md).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -120,7 +120,7 @@ Mods will be able to open their own screens and settings pages and react to keys
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Capability `ui` is in review ([#1864](https://github.com/ludovic111/horizon/issues/1864)): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').
|
||||
Capability `ui` is in review ([#1864](https://github.com/horizon-rust/horizon/issues/1864)): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').
|
||||
|
||||
More: [docs/mods.md](docs/mods.md)
|
||||
|
||||
@@ -132,7 +132,7 @@ Better tools for mod makers: a test runner that needs no game window, templates
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
|
||||
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/horizon-rust/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/horizon-rust/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/horizon-rust/horizon/issues/1859), [#1810](https://github.com/horizon-rust/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
|
||||
|
||||
More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
|
||||
|
||||
@@ -146,7 +146,7 @@ Single player now runs a small server in the background, as vanilla does. We are
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Since [#1514](https://github.com/ludovic111/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: a tool that changes a block ends the click ([#1797](https://github.com/ludovic111/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/ludovic111/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/ludovic111/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
|
||||
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: a tool that changes a block ends the click ([#1797](https://github.com/horizon-rust/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/horizon-rust/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/horizon-rust/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -156,7 +156,7 @@ Item names, container titles and server messages follow the language you pick, a
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/ludovic111/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/ludovic111/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/ludovic111/horizon/issues/1672)) and the main menu with zh_cn ([#1774](https://github.com/ludovic111/horizon/issues/1774)).
|
||||
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/horizon-rust/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/horizon-rust/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/horizon-rust/horizon/issues/1672)) and the main menu with zh_cn ([#1774](https://github.com/horizon-rust/horizon/issues/1774)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -166,9 +166,9 @@ After you throw an ender pearl, its slot should show the cooldown sweeping away,
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
|
||||
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/horizon-rust/horizon/issues/1763).
|
||||
|
||||
More: [#1933](https://github.com/ludovic111/horizon/issues/1933)
|
||||
More: [#1933](https://github.com/horizon-rust/horizon/issues/1933)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -178,9 +178,9 @@ The Hidden and Commands Only chat settings should hide chat, and team prefixes s
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
|
||||
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/horizon-rust/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/horizon-rust/horizon/issues/1378)). Both issues have exact steps to reproduce.
|
||||
|
||||
More: [#1932](https://github.com/ludovic111/horizon/issues/1932) · [#1841](https://github.com/ludovic111/horizon/issues/1841)
|
||||
More: [#1932](https://github.com/horizon-rust/horizon/issues/1932) · [#1841](https://github.com/horizon-rust/horizon/issues/1841)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -190,9 +190,9 @@ The mouse pointer should turn into a pointing hand over menu buttons, as in vani
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
|
||||
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/horizon-rust/horizon/issues/1347).
|
||||
|
||||
More: [#1897](https://github.com/ludovic111/horizon/issues/1897)
|
||||
More: [#1897](https://github.com/horizon-rust/horizon/issues/1897)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -202,9 +202,9 @@ Ctrl+Backspace should delete a whole word in the creative search, and every list
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
|
||||
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/horizon-rust/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/horizon-rust/horizon/issues/1782), after [#978](https://github.com/horizon-rust/horizon/issues/978)).
|
||||
|
||||
More: [#1884](https://github.com/ludovic111/horizon/issues/1884) · [#1931](https://github.com/ludovic111/horizon/issues/1931)
|
||||
More: [#1884](https://github.com/horizon-rust/horizon/issues/1884) · [#1931](https://github.com/horizon-rust/horizon/issues/1931)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -214,9 +214,9 @@ Chickens should flap their wings while they fall, not stand still in the air.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
|
||||
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/horizon-rust/horizon/issues/1706).
|
||||
|
||||
More: [#1892](https://github.com/ludovic111/horizon/issues/1892)
|
||||
More: [#1892](https://github.com/horizon-rust/horizon/issues/1892)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -228,9 +228,9 @@ Shader packs were removed and are being rebuilt from scratch. They come back, of
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/ludovic111/horizon/issues/1584), [#1585](https://github.com/ludovic111/horizon/issues/1585), [#1586](https://github.com/ludovic111/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/ludovic111/horizon/issues/1698) to [#1801](https://github.com/ludovic111/horizon/issues/1801)).
|
||||
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/horizon-rust/horizon/issues/1584), [#1585](https://github.com/horizon-rust/horizon/issues/1585), [#1586](https://github.com/horizon-rust/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/horizon-rust/horizon/issues/1698) to [#1801](https://github.com/horizon-rust/horizon/issues/1801)).
|
||||
|
||||
More: [#1561](https://github.com/ludovic111/horizon/issues/1561) · [#1771](https://github.com/ludovic111/horizon/issues/1771) · [#1786](https://github.com/ludovic111/horizon/issues/1786)
|
||||
More: [#1561](https://github.com/horizon-rust/horizon/issues/1561) · [#1771](https://github.com/horizon-rust/horizon/issues/1771) · [#1786](https://github.com/horizon-rust/horizon/issues/1786)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -240,7 +240,7 @@ Horizon's own way to make shader packs, with tools to write and test them. Every
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the example pack Golden Hour. Merged: the check every pack passes ([#1820](https://github.com/ludovic111/horizon/issues/1820)) and the tool, templates and guide ([#1822](https://github.com/ludovic111/horizon/issues/1822)). In review: the post stage running in the game with hot reload ([#1861](https://github.com/ludovic111/horizon/issues/1861)), the sky stage ([#1863](https://github.com/ludovic111/horizon/issues/1863)) and registry checks that use the game's own check ([#1855](https://github.com/ludovic111/horizon/issues/1855)). Left: the material, lighting and shadow stages, a last-good fallback on reload, a GPU-time overlay per stage and budgets ([docs/shaders.md](docs/shaders.md), [docs/api/shaders.md](docs/api/shaders.md)). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from scratch'.
|
||||
`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the example pack Golden Hour. Merged: the check every pack passes ([#1820](https://github.com/horizon-rust/horizon/issues/1820)) and the tool, templates and guide ([#1822](https://github.com/horizon-rust/horizon/issues/1822)). In review: the post stage running in the game with hot reload ([#1861](https://github.com/horizon-rust/horizon/issues/1861)), the sky stage ([#1863](https://github.com/horizon-rust/horizon/issues/1863)) and registry checks that use the game's own check ([#1855](https://github.com/horizon-rust/horizon/issues/1855)). Left: the material, lighting and shadow stages, a last-good fallback on reload, a GPU-time overlay per stage and budgets ([docs/shaders.md](docs/shaders.md), [docs/api/shaders.md](docs/api/shaders.md)). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from scratch'.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -252,7 +252,7 @@ Your Discord profile can show that you are playing Horizon, with a switch to tur
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A Discord IPC client on its own thread ([#1787](https://github.com/ludovic111/horizon/issues/1787)) and the presence with its setting ([#1788](https://github.com/ludovic111/horizon/issues/1788)). Merged: it ships with the next release.
|
||||
A Discord IPC client on its own thread ([#1787](https://github.com/horizon-rust/horizon/issues/1787)) and the presence with its setting ([#1788](https://github.com/horizon-rust/horizon/issues/1788)). Merged: it ships with the next release.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -264,7 +264,7 @@ Cleaning up Horizon's code: removing what nothing uses and making it quicker to
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/ludovic111/horizon/issues/1432), [#1658](https://github.com/ludovic111/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/ludovic111/horizon/issues/1453), [#1454](https://github.com/ludovic111/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/ludovic111/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/ludovic111/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
|
||||
A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/horizon-rust/horizon/issues/1432), [#1658](https://github.com/horizon-rust/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/horizon-rust/horizon/issues/1453), [#1454](https://github.com/horizon-rust/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/horizon-rust/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/horizon-rust/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -278,7 +278,7 @@ New public numbers comparing Horizon with vanilla on many kinds of computers, me
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Redo the comparison after the current performance work ([#1073](https://github.com/ludovic111/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/ludovic111/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/ludovic111/horizon/issues/1749)). Next: a performance history across releases (an automatic comparison of each release with the last, on a timeline), more report fields (server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
|
||||
Redo the comparison after the current performance work ([#1073](https://github.com/horizon-rust/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/horizon-rust/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/horizon-rust/horizon/issues/1749)). Next: a performance history across releases (an automatic comparison of each release with the last, on a timeline), more report fields (server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -288,7 +288,7 @@ Creating a world and joining it should be quick even on a four-thread laptop, wh
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on structure starts, and leave one core free for the rest of the game as vanilla does ([#1760](https://github.com/ludovic111/horizon/issues/1760) and [#1778](https://github.com/ludovic111/horizon/issues/1778), in review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. Measured with `tools/perf_bench.py` ([docs/benchmark.md](docs/benchmark.md), [docs/profiling.md](docs/profiling.md)).
|
||||
Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on structure starts, and leave one core free for the rest of the game as vanilla does ([#1760](https://github.com/horizon-rust/horizon/issues/1760) and [#1778](https://github.com/horizon-rust/horizon/issues/1778), in review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. Measured with `tools/perf_bench.py` ([docs/benchmark.md](docs/benchmark.md), [docs/profiling.md](docs/profiling.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -320,7 +320,7 @@ Draw the game at a lower resolution and sharpen it to your screen's size, in the
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue [#1403](https://github.com/ludovic111/horizon/issues/1403).
|
||||
A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue [#1403](https://github.com/horizon-rust/horizon/issues/1403).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -330,7 +330,7 @@ Several smaller speed-ups, each measured: idle block entities that sleep, cheape
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/ludovic111/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
|
||||
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/horizon-rust/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
|
||||
|
||||
More: [docs/profiling.md](docs/profiling.md)
|
||||
|
||||
@@ -364,7 +364,7 @@ Making mods that add machines, magic and mobs work in Horizon, in steps, startin
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/ludovic111/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/ludovic111/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
|
||||
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -386,7 +386,7 @@ Set up Horizon, a server first, without Java on your computer: published tables
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/ludovic111/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
|
||||
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/horizon-rust/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -406,7 +406,7 @@ Smoother hosting: a real Windows service, a backup before every update, backups
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config for view distance and the message of the day (needs engine support); backups that keep world and player state ([#1808](https://github.com/ludovic111/horizon/issues/1808), a contributor's draft, in review). [docs/hosting.md](docs/hosting.md), docs/server.md.
|
||||
A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config for view distance and the message of the day (needs engine support); backups that keep world and player state ([#1808](https://github.com/horizon-rust/horizon/issues/1808), a contributor's draft, in review). [docs/hosting.md](docs/hosting.md), docs/server.md.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -464,7 +464,7 @@ Mods that add machines: blocks that tick, hold items and have their own menus. T
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed client and server channel and typed config beside them. A draft ([#1189](https://github.com/ludovic111/horizon/issues/1189)) proves a furnace ported tick for tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges ([docs/modpacks.md](docs/modpacks.md), docs/api/stability.md).
|
||||
Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed client and server channel and typed config beside them. A draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189)) proves a furnace ported tick for tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges ([docs/modpacks.md](docs/modpacks.md), docs/api/stability.md).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -486,7 +486,7 @@ Pistons and slime blocks will push players, mobs and items as in vanilla. Today
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/ludovic111/horizon/issues/1496), [#1609](https://github.com/ludovic111/horizon/issues/1609), [#1608](https://github.com/ludovic111/horizon/issues/1608), [#1704](https://github.com/ludovic111/horizon/issues/1704), [#1758](https://github.com/ludovic111/horizon/issues/1758).
|
||||
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/horizon-rust/horizon/issues/1496), [#1609](https://github.com/horizon-rust/horizon/issues/1609), [#1608](https://github.com/horizon-rust/horizon/issues/1608), [#1704](https://github.com/horizon-rust/horizon/issues/1704), [#1758](https://github.com/horizon-rust/horizon/issues/1758).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -506,7 +506,7 @@ Villages and fights behaving as in vanilla: iron golems defending villages, vill
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/ludovic111/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
|
||||
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/horizon-rust/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -516,7 +516,7 @@ Pick the speakers or headphones Horizon plays through, in Music & Sounds.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/ludovic111/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
|
||||
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/horizon-rust/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -544,7 +544,7 @@ Coming soon: sign in with your Microsoft account to play on online-mode servers.
|
||||
|
||||
Device-code sign-in in the launcher and online-mode login in the client ([docs/microsoft-account.md](docs/microsoft-account.md)). It is turned on by setting the app's client id once Minecraft API access is approved; until then the launcher shows "Coming soon".
|
||||
|
||||
More: [#1270](https://github.com/ludovic111/horizon/issues/1270)
|
||||
More: [#1270](https://github.com/horizon-rust/horizon/issues/1270)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -566,7 +566,7 @@ Builds of the game and the launcher for Macs with an Intel processor.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/ludovic111/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/ludovic111/horizon/issues/1161), [#1792](https://github.com/ludovic111/horizon/issues/1792).
|
||||
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/horizon-rust/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/horizon-rust/horizon/issues/1161), [#1792](https://github.com/horizon-rust/horizon/issues/1792).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -576,7 +576,7 @@ Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboar
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/ludovic111/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/ludovic111/horizon/issues/1007), closed). Checked on a real Wayland session.
|
||||
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/horizon-rust/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/horizon-rust/horizon/issues/1007), closed). Checked on a real Wayland session.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -626,7 +626,7 @@ Safer releases: changes to the release machinery need the owner's review, and AI
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/ludovic111/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
|
||||
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/horizon-rust/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -636,7 +636,7 @@ Every download Horizon makes is checked against the project's signature, includi
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/ludovic111/horizon/issues/1011), [#1014](https://github.com/ludovic111/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
|
||||
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/horizon-rust/horizon/issues/1011), [#1014](https://github.com/horizon-rust/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -650,7 +650,7 @@ Far terrain drawn from the world you really built and explored, caves and builds
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/ludovic111/horizon/issues/718).
|
||||
Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/horizon-rust/horizon/issues/718).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -692,7 +692,7 @@ Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a c
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Earlier research ([#527](https://github.com/ludovic111/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
|
||||
Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -704,7 +704,7 @@ Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1270](https://github.com/ludovic111/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
|
||||
Issue [#1270](https://github.com/horizon-rust/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -748,7 +748,7 @@ Contraptions and physics like the Create and Aeronautics mods, as a mod for Hori
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#805](https://github.com/ludovic111/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
|
||||
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -790,7 +790,7 @@ Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captur
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Today F3 shows position, chunk, light, biome and FPS ([docs/native-client.md](docs/native-client.md)). Vanilla also has F3+F6 debug options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump ([#562](https://github.com/ludovic111/horizon/issues/562) asked for the F3 editor).
|
||||
Today F3 shows position, chunk, light, biome and FPS ([docs/native-client.md](docs/native-client.md)). Vanilla also has F3+F6 debug options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump ([#562](https://github.com/horizon-rust/horizon/issues/562) asked for the F3 editor).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -814,7 +814,7 @@ An optional ray-tracing mode for graphics cards that support it (RTX and recent
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https://github.com/ludovic111/horizon/issues/1180)). Reflections, global illumination, Apple M3 and newer if it is cheap, and a performance report per card come after.
|
||||
A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https://github.com/horizon-rust/horizon/issues/1180)). Reflections, global illumination, Apple M3 and newer if it is cheap, and a performance report per card come after.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -824,7 +824,7 @@ Output for HDR monitors: brighter highlights and richer colour where your screen
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1441](https://github.com/ludovic111/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
|
||||
Issue [#1441](https://github.com/horizon-rust/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -858,7 +858,7 @@ Game and launcher builds for 64-bit ARM Linux computers.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1709](https://github.com/ludovic111/horizon/issues/1709): aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM machine with its Vulkan driver. The server build already drops audio and controllers (`--no-default-features`).
|
||||
Issue [#1709](https://github.com/horizon-rust/horizon/issues/1709): aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM machine with its Vulkan driver. The server build already drops audio and controllers (`--no-default-features`).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -868,7 +868,7 @@ Versions of Horizon for Android, iOS and web browsers. Nothing official yet; con
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/ludovic111/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/ludovic111/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/ludovic111/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
|
||||
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -912,7 +912,7 @@ An optional AI teammate you can talk to by voice. It plays beside you, helps or
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Draft pull requests: the companion ([#1068](https://github.com/ludovic111/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/ludovic111/horizon/issues/1069)) and a route that finishes the game ([#1070](https://github.com/ludovic111/horizon/issues/1070)). In the drafts: a companion that follows, fights, mines, crafts, sleeps and respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably yet, voices for more languages, and the safety evaluation, which must pass before it ships.
|
||||
Draft pull requests: the companion ([#1068](https://github.com/horizon-rust/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/horizon-rust/horizon/issues/1069)) and a route that finishes the game ([#1070](https://github.com/horizon-rust/horizon/issues/1070)). In the drafts: a companion that follows, fights, mines, crafts, sleeps and respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably yet, voices for more languages, and the safety evaluation, which must pass before it ships.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -938,7 +938,7 @@ Run Horizon's built-in benchmark and share the report: every new computer makes
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
|
||||
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/horizon-rust/horizon/issues/1711).
|
||||
|
||||
More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
|
||||
|
||||
@@ -952,7 +952,7 @@ Banners in End cities show as plain magenta instead of with their pattern.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/ludovic111/horizon/issues/1438); possibly related: [#526](https://github.com/ludovic111/horizon/issues/526).
|
||||
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -962,19 +962,19 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/ludovic111/horizon/issues/1437), [#1660](https://github.com/ludovic111/horizon/issues/1660).
|
||||
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660).
|
||||
|
||||
</details>
|
||||
|
||||
## Recently shipped
|
||||
|
||||
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/ludovic111/horizon/issues/1917) · [#1920](https://github.com/ludovic111/horizon/issues/1920) · [#1936](https://github.com/ludovic111/horizon/issues/1936)
|
||||
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/ludovic111/horizon/issues/1562) · [#1569](https://github.com/ludovic111/horizon/issues/1569) · [#1570](https://github.com/ludovic111/horizon/issues/1570)
|
||||
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/ludovic111/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
|
||||
- **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/ludovic111/horizon/issues/1434)
|
||||
- **Animated textures on the GPU** (v0.2.5): Water, lava and other animated blocks are now animated by the graphics card: 1.7 to 2.4 times better 1% lows on Intel graphics. [#1239](https://github.com/ludovic111/horizon/issues/1239)
|
||||
- **Far Detail for integrated graphics** (v0.2.5): An optional Reduced setting for far terrain, suggested on integrated graphics, that saves 2 to 4 ms of GPU time per frame. [#1199](https://github.com/ludovic111/horizon/issues/1199) · [#1029](https://github.com/ludovic111/horizon/issues/1029)
|
||||
- **The launcher's mod browser** (launcher@0.2.0): Search Modrinth for mods, modpacks and resource packs from the launcher; it says before installing what works in Horizon. [#1154](https://github.com/ludovic111/horizon/issues/1154) · [#1242](https://github.com/ludovic111/horizon/issues/1242) · [#1243](https://github.com/ludovic111/horizon/issues/1243) · [#1246](https://github.com/ludovic111/horizon/issues/1246) · [docs/mod-browser.md](docs/mod-browser.md)
|
||||
- **A blocky, Minecraft-like launcher** (launcher@0.2.0): The launcher got a new blocky look in Horizon's purple, in dark and light. [#1269](https://github.com/ludovic111/horizon/issues/1269) · [docs/design-system.md](docs/design-system.md)
|
||||
- **Dynamic lights** (v2026.10.22): An optional extra: torches you hold, drop or set on fire light up what is around them. [#1162](https://github.com/ludovic111/horizon/issues/1162)
|
||||
- **Host a server from the launcher** (launcher-v0.15.0): The launcher's Servers page creates, starts, backs up and updates a Horizon server with buttons, and invites friends. [#1026](https://github.com/ludovic111/horizon/issues/1026) · [docs/hosting.md](docs/hosting.md)
|
||||
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
|
||||
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
|
||||
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/horizon-rust/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
|
||||
- **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/horizon-rust/horizon/issues/1434)
|
||||
- **Animated textures on the GPU** (v0.2.5): Water, lava and other animated blocks are now animated by the graphics card: 1.7 to 2.4 times better 1% lows on Intel graphics. [#1239](https://github.com/horizon-rust/horizon/issues/1239)
|
||||
- **Far Detail for integrated graphics** (v0.2.5): An optional Reduced setting for far terrain, suggested on integrated graphics, that saves 2 to 4 ms of GPU time per frame. [#1199](https://github.com/horizon-rust/horizon/issues/1199) · [#1029](https://github.com/horizon-rust/horizon/issues/1029)
|
||||
- **The launcher's mod browser** (launcher@0.2.0): Search Modrinth for mods, modpacks and resource packs from the launcher; it says before installing what works in Horizon. [#1154](https://github.com/horizon-rust/horizon/issues/1154) · [#1242](https://github.com/horizon-rust/horizon/issues/1242) · [#1243](https://github.com/horizon-rust/horizon/issues/1243) · [#1246](https://github.com/horizon-rust/horizon/issues/1246) · [docs/mod-browser.md](docs/mod-browser.md)
|
||||
- **A blocky, Minecraft-like launcher** (launcher@0.2.0): The launcher got a new blocky look in Horizon's purple, in dark and light. [#1269](https://github.com/horizon-rust/horizon/issues/1269) · [docs/design-system.md](docs/design-system.md)
|
||||
- **Dynamic lights** (v2026.10.22): An optional extra: torches you hold, drop or set on fire light up what is around them. [#1162](https://github.com/horizon-rust/horizon/issues/1162)
|
||||
- **Host a server from the launcher** (launcher-v0.15.0): The launcher's Servers page creates, starts, backs up and updates a Horizon server with buttons, and invites friends. [#1026](https://github.com/horizon-rust/horizon/issues/1026) · [docs/hosting.md](docs/hosting.md)
|
||||
@@ -119,7 +119,7 @@ machines during the runs.
|
||||
|
||||
## Reproduce
|
||||
|
||||
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
|
||||
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
|
||||
cargo build --release -p horizon-bots # on the machine that runs the bots
|
||||
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
|
||||
|
||||
@@ -148,7 +148,7 @@ early, wrote no report. Its lines come from its output.
|
||||
|
||||
## Reproduce
|
||||
|
||||
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
|
||||
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
|
||||
cargo build --release -p horizon-bots # on the machine that runs the bots
|
||||
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
|
||||
|
||||
@@ -103,7 +103,7 @@ the Nix store, so the run set `LD_LIBRARY_PATH` to them (see #898).
|
||||
|
||||
## Reproduce
|
||||
|
||||
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
|
||||
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
|
||||
cargo build --release -p horizon-bots # on the machine that runs the bots
|
||||
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
|
||||
|
||||
@@ -97,6 +97,9 @@ impl Report {
|
||||
for r in &f.requires {
|
||||
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
|
||||
}
|
||||
if self.kind == Kind::NativeMod {
|
||||
out += " native source: unsandboxed; not executed by the registry check. Compile and test against the exact pinned Horizon and Mixins revisions before review.\n";
|
||||
}
|
||||
if self.kind == Kind::Mod && !self.code {
|
||||
out += " no code: content only, nothing to run\n";
|
||||
} else if self.kind == Kind::Mod {
|
||||
@@ -118,6 +121,7 @@ impl Report {
|
||||
pub fn kind_of(path: &Path) -> Option<Kind> {
|
||||
match path.extension()?.to_str()? {
|
||||
"hmod" => Some(Kind::Mod),
|
||||
"hnmod" => Some(Kind::NativeMod),
|
||||
"hpack" => Some(Kind::Modpack),
|
||||
"zip" => Some(Kind::ResourcePack),
|
||||
"hshader" => Some(Kind::Shader),
|
||||
|
||||
@@ -25,7 +25,7 @@ use std::{
|
||||
};
|
||||
|
||||
const TARGET: &str = "wasm32-unknown-unknown";
|
||||
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
|
||||
const SDK_GIT: &str = "{ git = \"https://github.com/horizon-rust/horizon\" }";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! `cargo horizon-mod registry build|review|check|list`: the registry repository's CI
|
||||
//! (`ludovic111/horizon-mods`, docs/api/mod-index.md). Network-free: the
|
||||
//! (`horizon-rust/horizon-mods`, docs/api/mod-index.md). Network-free: the
|
||||
//! workflow downloads the files and publishes what this writes.
|
||||
use horizon_mods::index::{
|
||||
FILE_HOST,
|
||||
@@ -8,7 +8,7 @@ use horizon_mods::index::{
|
||||
use std::path::Path;
|
||||
|
||||
/// Where the registry's site is published (its icons).
|
||||
const SITE: &str = "https://ludovic111.github.io/horizon-mods/";
|
||||
const SITE: &str = "https://horizon-rust.github.io/horizon-mods/";
|
||||
|
||||
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
|
||||
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Text components from commands: `ComponentArgument` reads an SNBT (or JSON)
|
||||
//! value, `ComponentSerialization` turns it into styled text.
|
||||
use crate::text::{Style, Text, named_color};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// `SnbtGrammar`, enough for components: compounds, lists, quoted and
|
||||
@@ -299,6 +300,15 @@ pub fn to_text_with(
|
||||
}
|
||||
}
|
||||
|
||||
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
|
||||
/// else is a component (`ComponentSerialization`).
|
||||
pub fn name_text(tag: &Tag) -> Text {
|
||||
match tag {
|
||||
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
|
||||
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -16,3 +16,20 @@ pub fn parse_identifier(raw: &str) -> Option<String> {
|
||||
});
|
||||
(ns_ok && path_ok).then(|| format!("{ns}:{path}"))
|
||||
}
|
||||
|
||||
/// `Identifier.CODEC` parsing an entity id: lowercase namespace and path
|
||||
/// characters, `minecraft` when the namespace is left out.
|
||||
pub fn normalize_id(s: &str) -> Option<String> {
|
||||
let (ns, path) = match s.split_once(':') {
|
||||
Some((ns, path)) => (if ns.is_empty() { "minecraft" } else { ns }, path),
|
||||
None => ("minecraft", s),
|
||||
};
|
||||
let ok = |c: char, slash: bool| {
|
||||
c.is_ascii_lowercase()
|
||||
|| c.is_ascii_digit()
|
||||
|| matches!(c, '_' | '-' | '.')
|
||||
|| slash && c == '/'
|
||||
};
|
||||
(ns.chars().all(|c| ok(c, false)) && path.chars().all(|c| ok(c, true)))
|
||||
.then(|| format!("{ns}:{path}"))
|
||||
}
|
||||
@@ -14,6 +14,7 @@ pub mod identifier;
|
||||
pub mod key_display;
|
||||
pub mod lang;
|
||||
pub mod nbt_path;
|
||||
pub mod nbt_value;
|
||||
pub mod particle_type;
|
||||
pub mod pattern;
|
||||
pub mod potions;
|
||||
@@ -23,4 +24,5 @@ pub mod snbt;
|
||||
pub mod sound_source;
|
||||
pub mod text;
|
||||
pub mod uuid;
|
||||
pub mod who;
|
||||
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Reading and writing single NBT values the way vanilla's codecs accept them.
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
pub fn string(s: &str) -> Tag {
|
||||
Tag::String(s.into())
|
||||
}
|
||||
pub fn text(tag: Option<&Tag>) -> Option<String> {
|
||||
match tag {
|
||||
Some(Tag::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Any numeric tag as i64 (vanilla's `getIntOr` etc. accept every number type).
|
||||
pub fn number(tag: Option<&Tag>) -> Option<i64> {
|
||||
Some(match tag? {
|
||||
Tag::Byte(v) => i64::from(*v),
|
||||
Tag::Short(v) => i64::from(*v),
|
||||
Tag::Int(v) => i64::from(*v),
|
||||
Tag::Long(v) => *v,
|
||||
Tag::Float(b) => f32::from_bits(*b) as i64,
|
||||
Tag::Double(b) => f64::from_bits(*b) as i64,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// Any numeric tag as f64, keeping a float's or double's fraction.
|
||||
pub fn float(tag: Option<&Tag>) -> Option<f64> {
|
||||
Some(match tag? {
|
||||
Tag::Float(b) => f64::from(f32::from_bits(*b)),
|
||||
Tag::Double(b) => f64::from_bits(*b),
|
||||
other => number(Some(other))? as f64,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Who an action came from or is aimed at: the player or a mob by id.
|
||||
|
||||
/// Who caused damage or is being targeted.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Who {
|
||||
Player,
|
||||
Mob(u32),
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "horizon-client-input"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
description = "The client's input layer: key bindings and their names, mouse, controllers, clipboard and window options"
|
||||
|
||||
[dependencies]
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
winit = "0.30.13"
|
||||
# The window icon.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
png = "0.18"
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
# The DualSense's trigger effects (the `controllers` feature).
|
||||
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
|
||||
|
||||
[features]
|
||||
controllers = ["dep:horizon-dualsense"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -145,6 +145,22 @@ const KEYS: &[(&str, KeyCode)] = &[
|
||||
("key.keyboard.period", KeyCode::Period),
|
||||
("key.keyboard.slash", KeyCode::Slash),
|
||||
("key.keyboard.caps.lock", KeyCode::CapsLock),
|
||||
("key.keyboard.world.1", KeyCode::IntlBackslash),
|
||||
("key.keyboard.international1", KeyCode::IntlRo),
|
||||
("key.keyboard.international3", KeyCode::IntlYen),
|
||||
("key.keyboard.lang1", KeyCode::Lang1),
|
||||
("key.keyboard.lang2", KeyCode::Lang2),
|
||||
("key.keyboard.lang3", KeyCode::Lang3),
|
||||
("key.keyboard.lang4", KeyCode::Lang4),
|
||||
("key.keyboard.lang5", KeyCode::Lang5),
|
||||
("key.keyboard.help", KeyCode::Help),
|
||||
("key.keyboard.select", KeyCode::Select),
|
||||
("key.keyboard.again", KeyCode::Again),
|
||||
("key.keyboard.undo", KeyCode::Undo),
|
||||
("key.keyboard.cut", KeyCode::Cut),
|
||||
("key.keyboard.copy", KeyCode::Copy),
|
||||
("key.keyboard.paste", KeyCode::Paste),
|
||||
("key.keyboard.find", KeyCode::Find),
|
||||
("key.keyboard.f1", KeyCode::F1),
|
||||
("key.keyboard.f2", KeyCode::F2),
|
||||
("key.keyboard.f3", KeyCode::F3),
|
||||
@@ -157,6 +173,18 @@ const KEYS: &[(&str, KeyCode)] = &[
|
||||
("key.keyboard.f10", KeyCode::F10),
|
||||
("key.keyboard.f11", KeyCode::F11),
|
||||
("key.keyboard.f12", KeyCode::F12),
|
||||
("key.keyboard.f13", KeyCode::F13),
|
||||
("key.keyboard.f14", KeyCode::F14),
|
||||
("key.keyboard.f15", KeyCode::F15),
|
||||
("key.keyboard.f16", KeyCode::F16),
|
||||
("key.keyboard.f17", KeyCode::F17),
|
||||
("key.keyboard.f18", KeyCode::F18),
|
||||
("key.keyboard.f19", KeyCode::F19),
|
||||
("key.keyboard.f20", KeyCode::F20),
|
||||
("key.keyboard.f21", KeyCode::F21),
|
||||
("key.keyboard.f22", KeyCode::F22),
|
||||
("key.keyboard.f23", KeyCode::F23),
|
||||
("key.keyboard.f24", KeyCode::F24),
|
||||
("key.keyboard.print.screen", KeyCode::PrintScreen),
|
||||
("key.keyboard.scroll.lock", KeyCode::ScrollLock),
|
||||
("key.keyboard.pause", KeyCode::Pause),
|
||||
@@ -188,6 +216,36 @@ const KEYS: &[(&str, KeyCode)] = &[
|
||||
("key.keyboard.keypad.0", KeyCode::Numpad0),
|
||||
("key.keyboard.keypad.period", KeyCode::NumpadDecimal),
|
||||
("key.keyboard.keypad.equal", KeyCode::NumpadEqual),
|
||||
("key.keyboard.keypad.comma", KeyCode::NumpadComma),
|
||||
("key.keyboard.keypad.backspace", KeyCode::NumpadBackspace),
|
||||
(
|
||||
"key.keyboard.keypad.left.parenthesis",
|
||||
KeyCode::NumpadParenLeft,
|
||||
),
|
||||
(
|
||||
"key.keyboard.keypad.right.parenthesis",
|
||||
KeyCode::NumpadParenRight,
|
||||
),
|
||||
("key.keyboard.keypad.hash", KeyCode::NumpadHash),
|
||||
(
|
||||
"key.keyboard.keypad.memory.store",
|
||||
KeyCode::NumpadMemoryStore,
|
||||
),
|
||||
(
|
||||
"key.keyboard.keypad.memory.recall",
|
||||
KeyCode::NumpadMemoryRecall,
|
||||
),
|
||||
(
|
||||
"key.keyboard.keypad.memory.clear",
|
||||
KeyCode::NumpadMemoryClear,
|
||||
),
|
||||
("key.keyboard.keypad.memory.add", KeyCode::NumpadMemoryAdd),
|
||||
(
|
||||
"key.keyboard.keypad.memory.subtract",
|
||||
KeyCode::NumpadMemorySubtract,
|
||||
),
|
||||
("key.keyboard.keypad.clear", KeyCode::NumpadClear),
|
||||
("key.keyboard.keypad.clear.entry", KeyCode::NumpadClearEntry),
|
||||
("key.keyboard.menu", KeyCode::ContextMenu),
|
||||
("key.keyboard.left.control", KeyCode::ControlLeft),
|
||||
("key.keyboard.left.shift", KeyCode::ShiftLeft),
|
||||
File renamed without changes.
@@ -0,0 +1,14 @@
|
||||
//! The client's input layer, below gameplay and screens: key bindings and their
|
||||
//! names, toggle keys, mouse settings, controllers, the clipboard and window
|
||||
//! options. horizon-client re-exports each module under its old path
|
||||
//! (`horizon_client::keybinds`, ...).
|
||||
#![forbid(unsafe_code)]
|
||||
pub mod clipboard;
|
||||
pub mod gamepad;
|
||||
pub mod input;
|
||||
pub mod interaction_input;
|
||||
pub mod key_names;
|
||||
pub mod keybinds;
|
||||
pub mod keymapping;
|
||||
pub mod mouse;
|
||||
pub mod window;
|
||||
File renamed without changes.
File renamed without changes.
@@ -10,7 +10,9 @@ description = "Horizon native development client"
|
||||
[dependencies]
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-client-input = { path = "../horizon-client-input" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
@@ -54,7 +56,6 @@ horizon-theme = { path = "../horizon-theme" }
|
||||
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
gilrs = { version = "0.11", optional = true }
|
||||
@@ -89,7 +90,7 @@ default = ["audio", "controllers"]
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["dep:cpal"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense"]
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Runs code mods (wasmtime and cranelift: a quarter of a clean build). On in every release build
|
||||
# (release.yml); the tests and probes don't need it. Content mods load without it.
|
||||
mods = ["horizon-mods/runtime"]
|
||||
@@ -105,5 +106,6 @@ path = "examples/probe/main.rs"
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
|
||||
@@ -2,7 +2,7 @@
|
||||
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
|
||||
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
|
||||
#[allow(dead_code)]
|
||||
#[path = "../src/blocks/decode.rs"]
|
||||
#[path = "../../horizon-items/src/blocks/decode.rs"]
|
||||
mod decode;
|
||||
|
||||
use std::{hint::black_box, path::Path, time::Instant};
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use horizon_client::{
|
||||
containers::{Furnace, FurnaceKind},
|
||||
crafting::Recipes,
|
||||
furnace::FurnaceCooking,
|
||||
item_data::{canon_stack, parse_stack},
|
||||
items::Items,
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
|
||||
@@ -1077,6 +1077,8 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
uuid: PLAYER_UUID,
|
||||
allies: Default::default(),
|
||||
remote: false,
|
||||
sleeping: false,
|
||||
sleep_timer: 0,
|
||||
passenger: false,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -218,6 +218,8 @@ impl RenderAssets {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
sprites.finish();
|
||||
// `finish` grows the buffer by doubling: keep only the bytes in use.
|
||||
sprites.pixels.shrink_to_fit();
|
||||
Ok(Self {
|
||||
sprite_size: sprites.size,
|
||||
sprite_minimum_size: sprites.minimum_size,
|
||||
@@ -234,3 +236,81 @@ impl RenderAssets {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::items::ConsumeEffect;
|
||||
|
||||
#[test]
|
||||
fn always_edible_and_consume_effects_loaded() {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
|
||||
|
||||
let apple = items.get("minecraft:golden_apple").expect("golden apple");
|
||||
assert!(apple.can_always_eat);
|
||||
assert!(apple.food.is_some());
|
||||
assert!(!apple.consumable.as_ref().unwrap().effects.is_empty());
|
||||
|
||||
let enchanted = items
|
||||
.get("minecraft:enchanted_golden_apple")
|
||||
.expect("enchanted golden apple");
|
||||
assert!(enchanted.can_always_eat);
|
||||
assert!(enchanted.food.is_some());
|
||||
assert!(!enchanted.consumable.as_ref().unwrap().effects.is_empty());
|
||||
|
||||
let beef = items.get("minecraft:cooked_beef").expect("cooked beef");
|
||||
assert!(!beef.can_always_eat);
|
||||
assert!(beef.food.is_some());
|
||||
|
||||
// Consumables without food, their style and their remainders.
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
let milk = items.get("minecraft:milk_bucket").expect("milk bucket");
|
||||
let drink = milk.consumable.as_ref().unwrap();
|
||||
assert!(milk.food.is_none());
|
||||
assert_eq!(drink.effects, [ConsumeEffect::ClearAllEffects]);
|
||||
assert_eq!((drink.ticks(), drink.animation), (32, UseAnimation::Drink));
|
||||
assert_eq!(drink.sound, "minecraft:entity.generic.drink");
|
||||
assert!(!drink.particles);
|
||||
assert_eq!(milk.use_remainder.as_deref(), Some("minecraft:bucket"));
|
||||
let honey = items.get("minecraft:honey_bottle").expect("honey bottle");
|
||||
let sip = honey.consumable.as_ref().unwrap();
|
||||
assert_eq!(sip.ticks(), 40);
|
||||
assert_eq!(sip.sound, "minecraft:item.honey_bottle.drink");
|
||||
assert_eq!(
|
||||
sip.effects,
|
||||
[ConsumeEffect::RemoveEffects(vec![
|
||||
"minecraft:poison".into()
|
||||
])]
|
||||
);
|
||||
assert_eq!(
|
||||
honey.use_remainder.as_deref(),
|
||||
Some("minecraft:glass_bottle")
|
||||
);
|
||||
let kelp = items.get("minecraft:dried_kelp").unwrap();
|
||||
let bite = kelp.consumable.as_ref().unwrap();
|
||||
assert_eq!((bite.ticks(), bite.animation), (16, UseAnimation::Eat));
|
||||
assert_eq!(bite.sound, "minecraft:entity.generic.eat");
|
||||
assert!(bite.particles && kelp.use_remainder.is_none());
|
||||
let chorus = items.get("minecraft:chorus_fruit").unwrap();
|
||||
assert_eq!(
|
||||
chorus.consumable.as_ref().unwrap().effects,
|
||||
[ConsumeEffect::TeleportRandomly(
|
||||
horizon_world::teleport_consume::TeleportConsume {
|
||||
diameter: 16.0,
|
||||
directional_particles: true,
|
||||
}
|
||||
)]
|
||||
);
|
||||
let chicken = items.get("minecraft:chicken").unwrap();
|
||||
assert!(matches!(
|
||||
chicken.consumable.as_ref().unwrap().effects[..],
|
||||
[ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,10 @@
|
||||
//! banners, and the loom rules of `LoomMenu` (selectable patterns, the
|
||||
//! six-layer limit and the result stack).
|
||||
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};
|
||||
|
||||
/// `BannerPatternLayers.Layer`: a pattern id and the dye name it is painted in.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct BannerLayer {
|
||||
pub pattern: Arc<str>,
|
||||
pub color: Arc<str>,
|
||||
}
|
||||
impl BannerLayer {
|
||||
pub fn new(pattern: &str, color: &str) -> Self {
|
||||
Self {
|
||||
pattern: Arc::from(pattern),
|
||||
color: Arc::from(color),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use horizon_items::banner_layer::{BannerLayer, layers_from_nbt, layers_to_nbt};
|
||||
|
||||
/// Layers a loom may stack on a banner (`LoomMenu`'s `hasMaxPatterns`).
|
||||
pub const MAX_LAYERS: usize = 6;
|
||||
@@ -224,44 +210,6 @@ pub fn has_max_layers(banner: &ItemStack) -> bool {
|
||||
layers(banner).len() >= MAX_LAYERS
|
||||
}
|
||||
|
||||
/// The `banner_patterns` NBT list of layers.
|
||||
pub fn layers_to_nbt(layers: &[BannerLayer]) -> Tag {
|
||||
Tag::List(
|
||||
layers
|
||||
.iter()
|
||||
.map(|l| {
|
||||
Tag::compound([
|
||||
("pattern", Tag::String((&*l.pattern).into())),
|
||||
("color", Tag::String((&*l.color).into())),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
pub fn layers_from_nbt(tag: Option<&Tag>) -> Vec<BannerLayer> {
|
||||
let Some(Tag::List(list)) = tag else {
|
||||
return Vec::new();
|
||||
};
|
||||
list.iter()
|
||||
.filter_map(|e| {
|
||||
let text = |k: &str| match e.get(k) {
|
||||
Some(Tag::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
let pattern = text("pattern")?;
|
||||
let color = text("color")?;
|
||||
Some(BannerLayer::new(
|
||||
&if pattern.contains(':') {
|
||||
pattern
|
||||
} else {
|
||||
format!("minecraft:{pattern}")
|
||||
},
|
||||
&color,
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! the rain, nor through a blocked front, unless the hive is emptied in an
|
||||
//! emergency), honey for every bee coming home with nectar. The host (the game
|
||||
//! or the mob AI probe) loads and adds the bees through [`HiveHost`].
|
||||
pub use crate::chunk::block_entity::{Beehive, Occupant};
|
||||
use crate::mobs::{Mob, Species, bee};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
@@ -38,20 +39,6 @@ const IGNORED_BEE_TAGS: [&str; 25] = [
|
||||
"UUID",
|
||||
];
|
||||
|
||||
/// `BeehiveBlockEntity`: the bees inside and the flower one of them knew.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Beehive {
|
||||
pub bees: Vec<Occupant>,
|
||||
pub flower: Option<[i32; 3]>,
|
||||
}
|
||||
/// `BeehiveBlockEntity.Occupant`: a bee's tag (with its `id`), its time in
|
||||
/// the hive and the time it stays at least.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Occupant {
|
||||
pub entity_data: Tag,
|
||||
pub ticks_in_hive: i32,
|
||||
pub min_ticks_in_hive: i32,
|
||||
}
|
||||
/// `BeehiveBlockEntity.BeeReleaseStatus`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Release {
|
||||
|
||||
@@ -20,12 +20,68 @@ pub struct SpawnEntry {
|
||||
/// A `ConstantInt` count (sampling draws nothing).
|
||||
pub constant: bool,
|
||||
}
|
||||
/// A `natural_mob_spawns` / `spawn_overrides` entry list.
|
||||
pub fn parse_entries(list: &Value) -> Vec<SpawnEntry> {
|
||||
list.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let count = &e["count"];
|
||||
let (min, max, constant) = match count {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64().unwrap_or(1) as u32;
|
||||
(n, n, true)
|
||||
}
|
||||
c if c["type"].as_str() == Some("minecraft:constant") => {
|
||||
let n = c["value"].as_u64().unwrap_or(1) as u32;
|
||||
(n, n, true)
|
||||
}
|
||||
c => (
|
||||
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
false,
|
||||
),
|
||||
};
|
||||
Some(SpawnEntry {
|
||||
entity: e["type"].as_str()?.to_owned(),
|
||||
weight: e["weight"].as_u64().unwrap_or(1) as u32,
|
||||
min,
|
||||
max,
|
||||
constant,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `MobSpawnSettings.MobSpawnCost`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SpawnCost {
|
||||
pub energy_budget: f64,
|
||||
pub charge: f64,
|
||||
}
|
||||
/// The `spawn_costs` of a `natural_mob_spawns` value.
|
||||
pub fn parse_costs(costs: &Value) -> HashMap<String, SpawnCost> {
|
||||
costs
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(k, c)| {
|
||||
(
|
||||
k.clone(),
|
||||
SpawnCost {
|
||||
energy_budget: c["energy_budget"].as_f64().unwrap_or(0.0),
|
||||
charge: c["charge"].as_f64().unwrap_or(0.0),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BiomeInfo {
|
||||
/// `natural_mob_spawns` by category (creature, monster, ...).
|
||||
pub spawns: HashMap<String, Vec<SpawnEntry>>,
|
||||
/// `natural_mob_spawns` spawn costs by entity type.
|
||||
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
|
||||
pub spawn_costs: HashMap<String, SpawnCost>,
|
||||
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
|
||||
pub creature_probability: f32,
|
||||
/// The `gameplay/increased_fire_burnout` attribute (jungles, swamps, peaks,
|
||||
@@ -150,12 +206,12 @@ impl Biomes {
|
||||
};
|
||||
let categories = &natural["spawns_by_category"];
|
||||
for (category, entries) in categories.as_object().into_iter().flatten() {
|
||||
let list = crate::mobs::spawn_selection::parse_entries(entries);
|
||||
let list = parse_entries(entries);
|
||||
spawns.insert(category.clone(), list);
|
||||
}
|
||||
list.push(BiomeInfo {
|
||||
spawns,
|
||||
spawn_costs: crate::mobs::spawn_selection::parse_costs(&natural["spawn_costs"]),
|
||||
spawn_costs: parse_costs(&natural["spawn_costs"]),
|
||||
creature_probability:
|
||||
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
|
||||
.as_f64()
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
//! Which states tick is a table by state id, built by the game from the block
|
||||
//! registry; whether a slot is made is decided when the slot is promoted at
|
||||
//! the start of the next tick, from the block state then.
|
||||
use crate::containers::BlockEntity;
|
||||
use crate::chunk::block_entity::BlockEntity;
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
||||
@@ -35,6 +35,25 @@ pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
data(stack).is_some_and(|(id, _)| OP_ONLY.contains(&id.as_str()))
|
||||
}
|
||||
|
||||
/// `SpawnEggItem.shouldPrintOpWarning` for a player with game master
|
||||
/// permission: a spawn egg carries data of an op-only entity type
|
||||
/// (`EntityTypes.OP_ONLY_CUSTOM_DATA`).
|
||||
pub fn spawn_egg_op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
stack.item.ends_with("_spawn_egg")
|
||||
&& matches!(
|
||||
stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:entity_data"))
|
||||
.and_then(|data| data.get("id")),
|
||||
Some(Tag::String(id)) if matches!(
|
||||
id.to_string_lossy().as_str(),
|
||||
"minecraft:falling_block"
|
||||
| "minecraft:command_block_minecart"
|
||||
| "minecraft:spawner_minecart"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// `Spawner.getSpawnEntityDisplayName` (`SpawnData.entity.id` of the item's
|
||||
/// data, which `ItemStack.addDetailsToTooltip` reads for spawners and trial
|
||||
/// spawners alike): the entity type a spawner item spawns.
|
||||
|
||||
@@ -97,6 +97,16 @@ pub fn diffuse(normal: Vec3, lights: (Vec3, Vec3)) -> f32 {
|
||||
pub fn vertex_light(packed: u8) -> u32 {
|
||||
(u32::from(packed >> 4) * 16) | ((u32::from(packed & 15) * 16) << 8)
|
||||
}
|
||||
/// `ChestRenderer.xmasTextures`: `SpecialDates.isExtendedChristmas` (24 to 26
|
||||
/// December in the local time zone), read once when the renderer is created.
|
||||
fn extended_christmas() -> bool {
|
||||
use chrono::Datelike;
|
||||
static XMAS: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*XMAS.get_or_init(|| {
|
||||
let now = chrono::Local::now();
|
||||
now.month() == 12 && (24..=26).contains(&now.day())
|
||||
})
|
||||
}
|
||||
fn pack_color(rgb: u32, alpha: u32, brightness: f32) -> u32 {
|
||||
let c = |shift: u32| (((rgb >> shift) & 255) as f32 * brightness) as u32;
|
||||
alpha << 24 | c(0) << 16 | c(8) << 8 | c(16)
|
||||
@@ -1173,11 +1183,25 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
}
|
||||
Kind::Chest {
|
||||
block,
|
||||
texture,
|
||||
part,
|
||||
y_rot,
|
||||
..
|
||||
} => {
|
||||
// ChestRenderer.getChestMaterial: normal and trapped chests use the christmas
|
||||
// sprites on 24-26 December. Copper and ender chests keep theirs.
|
||||
let texture = if matches!(block.as_str(), "chest" | "trapped_chest")
|
||||
&& extended_christmas()
|
||||
{
|
||||
match part {
|
||||
ChestPart::Single => "entity/chest/christmas",
|
||||
ChestPart::Left => "entity/chest/christmas_left",
|
||||
ChestPart::Right => "entity/chest/christmas_right",
|
||||
}
|
||||
} else {
|
||||
*texture
|
||||
};
|
||||
let model = match part {
|
||||
ChestPart::Single => "chest",
|
||||
ChestPart::Left => "double_chest_left",
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
//! in vanilla, ported as tables), data-driven `minecraft:brewing` recipes from
|
||||
//! `data/minecraft/recipe/brewing`, potion colours and tooltips, and
|
||||
//! `BrewingStandBlockEntity.serverTick`.
|
||||
pub use crate::chunk::block_entity::BrewingStand;
|
||||
use crate::{
|
||||
containers::Providers,
|
||||
items::{ItemStack, Items},
|
||||
providers::Providers,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashSet, fs, path::Path, sync::Arc};
|
||||
use std::{collections::HashSet, fs, path::Path};
|
||||
|
||||
pub use horizon_client_core::potions::{
|
||||
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
|
||||
@@ -190,18 +191,6 @@ impl Brewing {
|
||||
}
|
||||
}
|
||||
|
||||
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BrewingStand {
|
||||
pub items: [Option<ItemStack>; 5],
|
||||
pub brew_time: i32,
|
||||
pub total_brew_time: i32,
|
||||
pub fuel: i32,
|
||||
pub total_fuel: i32,
|
||||
pub speed: f32,
|
||||
pub ingredient: Option<Arc<str>>,
|
||||
pub last_bottles: Option<[bool; 3]>,
|
||||
}
|
||||
/// What a brewing tick asks the world to do.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct BrewTick {
|
||||
|
||||
@@ -772,6 +772,27 @@ pub fn bob_matrix(state: &BobState, damage_tilt_strength: f64, bob_view_option:
|
||||
pose
|
||||
}
|
||||
|
||||
/// The spinning effect `GameRenderer.renderLevel` multiplies into the level's
|
||||
/// projection after the bob: a skew about a tilted axis, from a portal's or
|
||||
/// Nausea's intensity (already scaled by Distortion Effects) and the angle in
|
||||
/// degrees. The first-person hand keeps its own projection without it.
|
||||
pub fn spinning_effect_matrix(intensity: f32, angle_degrees: f32) -> glam::Mat4 {
|
||||
if intensity <= 0.0 {
|
||||
return glam::Mat4::IDENTITY;
|
||||
}
|
||||
let mut skew = 5.0 / (intensity * intensity + 5.0) - intensity * 0.04;
|
||||
skew *= skew;
|
||||
let axis = glam::Vec3::new(
|
||||
0.0,
|
||||
std::f32::consts::SQRT_2 / 2.0,
|
||||
std::f32::consts::SQRT_2 / 2.0,
|
||||
);
|
||||
let angle = angle_degrees * DEG_TO_RAD;
|
||||
glam::Mat4::from_axis_angle(axis, angle)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(1.0 / skew, 1.0, 1.0))
|
||||
* glam::Mat4::from_axis_angle(axis, -angle)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
||||
@@ -6,6 +6,8 @@ use horizon_world::nbt::{Compound, Tag};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub mod block_entity;
|
||||
|
||||
pub const MIN_Y: i32 = -64;
|
||||
pub const HEIGHT: i32 = 384;
|
||||
pub const SECTIONS: usize = (HEIGHT / 16) as usize;
|
||||
@@ -149,7 +151,7 @@ pub struct Chunk {
|
||||
/// Newly generated (not loaded from a save): eligible for generation-time spawns.
|
||||
pub fresh: bool,
|
||||
/// Block entities read from a save, moved into the world on arrival.
|
||||
pub block_entities: Vec<((i32, i32, i32), crate::containers::BlockEntity)>,
|
||||
pub block_entities: Vec<((i32, i32, i32), block_entity::BlockEntity)>,
|
||||
/// `CustomName` of the container block entities (shown as the screen title).
|
||||
pub block_names: Vec<((i32, i32, i32), String)>,
|
||||
/// Generation postprocessing marks (ProtoChunk.postProcessing) as world
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
//! Block entity data: what a chunk stores for its block entities (`BlockEntity`
|
||||
//! and its payloads). Plain data, as vanilla's block entities are world data;
|
||||
//! their behaviour lives with the features that use them (`containers`,
|
||||
//! `brewing`, `signs`, ...), which re-export these types under their old paths.
|
||||
use horizon_client_core::{text::Text, who::Who};
|
||||
use horizon_items::{banner_layer::BannerLayer, items::ItemStack};
|
||||
use horizon_world::{
|
||||
level::sculk::SculkSpreader,
|
||||
nbt::{Compound, Tag},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// An entity's UUID as its four ints (`UUIDUtil.CODEC`).
|
||||
pub type Uuid = [i32; 4];
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FurnaceKind {
|
||||
Furnace,
|
||||
BlastFurnace,
|
||||
Smoker,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Furnace {
|
||||
pub kind: FurnaceKind,
|
||||
/// input, fuel, result
|
||||
pub items: [Option<ItemStack>; 3],
|
||||
pub lit_remaining: i32,
|
||||
pub lit_total: i32,
|
||||
pub cooking_timer: i32,
|
||||
pub cooking_total: i32,
|
||||
pub speed: f32,
|
||||
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
|
||||
/// made since a player last took from its output. The experience is owed per recipe
|
||||
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
|
||||
pub recipes_used: HashMap<String, u32>,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum BlockEntity {
|
||||
Chest(Vec<Option<ItemStack>>),
|
||||
Furnace(Furnace),
|
||||
Brewing(Box<BrewingStand>),
|
||||
/// Signs and hanging signs (`SignBlockEntity`).
|
||||
Sign(SignData),
|
||||
/// `JukeboxBlockEntity`: the record and its `JukeboxSongPlayer`.
|
||||
Jukebox(Jukebox),
|
||||
/// `BeaconBlockEntity`.
|
||||
Beacon(Box<Beacon>),
|
||||
/// `LecternBlockEntity`: the book and the open page.
|
||||
Lectern(Lectern),
|
||||
/// Sculk sensors, shriekers and catalysts (`crate::sculk`).
|
||||
Sculk(Box<SculkEntity>),
|
||||
/// `CreakingHeartBlockEntity`: its protector and the hurt emitter.
|
||||
CreakingHeart(Box<CreakingHeart>),
|
||||
/// `BannerBlockEntity`: the pattern layers.
|
||||
Banner(Banner),
|
||||
/// `CrafterBlockEntity`.
|
||||
Crafter(Crafter),
|
||||
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
|
||||
Spawner(Box<Spawner>),
|
||||
/// `TrialSpawnerBlockEntity` (`crate::mobs::trial_spawner`).
|
||||
TrialSpawner(Box<TrialSpawner>),
|
||||
/// `VaultBlockEntity` (`crate::vault`).
|
||||
Vault(Box<Vault>),
|
||||
/// `BeehiveBlockEntity` of beehives and bee nests (`crate::beehive`).
|
||||
Beehive(Box<Beehive>),
|
||||
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
|
||||
CommandBlock(Box<CommandBlock>),
|
||||
/// `CampfireBlockEntity` of campfires and soul campfires.
|
||||
Campfire(Box<Campfire>),
|
||||
/// `SkullBlockEntity` of every skull and head block.
|
||||
Skull(Skull),
|
||||
/// `TheEndGatewayBlockEntity`: its age and configured destination.
|
||||
EndGateway(EndGateway),
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
|
||||
ChiseledBookshelf(Box<ChiseledBookshelf>),
|
||||
}
|
||||
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ChiseledBookshelf {
|
||||
pub items: [Option<ItemStack>; 6],
|
||||
pub last_slot: i32,
|
||||
}
|
||||
impl Default for ChiseledBookshelf {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
items: Default::default(),
|
||||
last_slot: -1,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `TheEndGatewayBlockEntity`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EndGateway {
|
||||
pub age: i64,
|
||||
pub exit_portal: Option<[i32; 3]>,
|
||||
pub exact_teleport: bool,
|
||||
}
|
||||
/// `CampfireBlockEntity`: four cooking slots, each with its
|
||||
/// `cookingProgress` and `cookingTime` (the recipe's).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Campfire {
|
||||
pub items: [Option<ItemStack>; 4],
|
||||
pub progress: [i32; 4],
|
||||
pub total: [i32; 4],
|
||||
}
|
||||
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
|
||||
/// a compound, or a string when it names a player only), the note block
|
||||
/// sound and `custom_name`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Skull {
|
||||
pub profile: Option<horizon_world::nbt::Tag>,
|
||||
pub note_block_sound: Option<String>,
|
||||
pub custom_name: Option<horizon_world::nbt::Tag>,
|
||||
/// What `profile` draws, read from it once rather than every frame.
|
||||
pub skin: Option<SkullSkin>,
|
||||
}
|
||||
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
|
||||
/// with the `skins/<sha1>` sprite it downloads to, and the
|
||||
/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
|
||||
/// profile with no id).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SkullSkin {
|
||||
pub download: Option<(String, String)>,
|
||||
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
|
||||
/// an id only): the key `skins::Resolver` resolves it by.
|
||||
pub dynamic: Option<NameOrId>,
|
||||
pub fallback: &'static str,
|
||||
}
|
||||
/// `Either<String, UUID>`: the key a `Dynamic` `ResolvableProfile` resolves
|
||||
/// by (`ResolvableProfile.nameOrId`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum NameOrId {
|
||||
Name(String),
|
||||
Id(u128),
|
||||
}
|
||||
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
|
||||
/// and the ticks left in the crafting animation.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Crafter {
|
||||
pub items: [Option<ItemStack>; 9],
|
||||
pub disabled: [bool; 9],
|
||||
pub triggered: bool,
|
||||
pub crafting_ticks: i32,
|
||||
/// The block state's `triggered` when last ticked (not saved): the block
|
||||
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
|
||||
pub state_triggered: Option<bool>,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Banner {
|
||||
pub layers: Vec<BannerLayer>,
|
||||
pub name: Option<String>,
|
||||
/// Components not consumed by BannerBlockEntity's implicit fields.
|
||||
pub components: horizon_world::nbt::Compound,
|
||||
}
|
||||
/// `CreakingHeartBlockEntity` (the logic is in `game/creaking.rs`).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CreakingHeart {
|
||||
/// The protector: its entity id once resolved, and its UUID (the saved form).
|
||||
pub creaking: Option<(Option<u32>, [i32; 4])>,
|
||||
pub ticks_existed: i64,
|
||||
pub ticker: i32,
|
||||
/// Ticks left of the hurt effect (`emitter`) and where it heads.
|
||||
pub emitter: i32,
|
||||
pub emitter_target: Option<[f64; 3]>,
|
||||
pub output_signal: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Lectern {
|
||||
pub book: Option<ItemStack>,
|
||||
pub page: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Beacon {
|
||||
pub levels: i32,
|
||||
/// Effect ids (`minecraft:speed`).
|
||||
pub primary: Option<String>,
|
||||
pub secondary: Option<String>,
|
||||
/// Beam sections (RGB colour, height) and the ones being rebuilt.
|
||||
pub sections: Vec<(u32, i32)>,
|
||||
pub checking: Vec<(u32, i32)>,
|
||||
pub last_check_y: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Jukebox {
|
||||
pub record: Option<ItemStack>,
|
||||
/// `ticks_since_song_started`, while a song plays.
|
||||
pub ticks: i64,
|
||||
pub playing: bool,
|
||||
}
|
||||
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BrewingStand {
|
||||
pub items: [Option<ItemStack>; 5],
|
||||
pub brew_time: i32,
|
||||
pub total_brew_time: i32,
|
||||
pub fuel: i32,
|
||||
pub total_fuel: i32,
|
||||
pub speed: f32,
|
||||
pub ingredient: Option<Arc<str>>,
|
||||
pub last_bottles: Option<[bool; 3]>,
|
||||
}
|
||||
/// One sign side (`SignText`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SignText {
|
||||
pub lines: [String; 4],
|
||||
/// `DyeColor` id.
|
||||
pub color: u8,
|
||||
pub glowing: bool,
|
||||
}
|
||||
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct SignData {
|
||||
pub hanging: bool,
|
||||
pub front: SignText,
|
||||
pub back: SignText,
|
||||
pub waxed: bool,
|
||||
}
|
||||
/// `VibrationInfo`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Info {
|
||||
pub event: &'static str,
|
||||
pub distance: f32,
|
||||
pub pos: [f64; 3],
|
||||
pub source: Option<Who>,
|
||||
pub owner: Option<Who>,
|
||||
}
|
||||
/// `VibrationSelector`: the closest, then highest-frequency candidate of a tick.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Selector {
|
||||
pub(crate) current: Option<(Info, i64)>,
|
||||
}
|
||||
/// `VibrationSystem.Data`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Data {
|
||||
pub current: Option<Info>,
|
||||
pub travel: i32,
|
||||
pub selector: Selector,
|
||||
}
|
||||
/// Which sculk block entity this is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SculkKind {
|
||||
Sensor,
|
||||
Calibrated,
|
||||
Shrieker,
|
||||
Catalyst,
|
||||
}
|
||||
/// `SculkSensorBlockEntity`, `CalibratedSculkSensorBlockEntity`,
|
||||
/// `SculkShriekerBlockEntity` and `SculkCatalystBlockEntity`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SculkEntity {
|
||||
pub kind: SculkKind,
|
||||
pub vibration: Data,
|
||||
/// `last_vibration_frequency` (sensors).
|
||||
pub last_frequency: i32,
|
||||
/// `warning_level` (shriekers).
|
||||
pub warning_level: i32,
|
||||
/// The catalyst's charge cursors.
|
||||
pub spreader: SculkSpreader,
|
||||
}
|
||||
/// `SpawnData.CustomSpawnRules`: the block and sky light the spawn block
|
||||
/// must have (inclusive ranges within 0..=15).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct CustomSpawnRules {
|
||||
pub block: (i32, i32),
|
||||
pub sky: (i32, i32),
|
||||
}
|
||||
/// `SpawnData`: the entity to spawn, optional light rules and an equipment
|
||||
/// table (kept as loaded).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct SpawnData {
|
||||
pub entity: Compound,
|
||||
pub custom_spawn_rules: Option<CustomSpawnRules>,
|
||||
pub equipment: Option<Tag>,
|
||||
}
|
||||
/// Which `SpawnData` is next: one of the potentials (the same object in
|
||||
/// vanilla, so changing it changes the potential) or its own.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum Next {
|
||||
Potential(usize),
|
||||
Own(SpawnData),
|
||||
}
|
||||
/// `BaseSpawner`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Spawner {
|
||||
pub spawn_delay: i32,
|
||||
/// `SpawnPotentials`: data and weight.
|
||||
pub potentials: Vec<(SpawnData, i32)>,
|
||||
pub(crate) next: Option<Next>,
|
||||
pub min_spawn_delay: i32,
|
||||
pub max_spawn_delay: i32,
|
||||
pub spawn_count: i32,
|
||||
pub max_nearby_entities: i32,
|
||||
pub required_player_range: i32,
|
||||
pub spawn_range: i32,
|
||||
/// The client copy's delay (`spawnDelay` on the client level), which
|
||||
/// speeds the spin up as the next spawn nears.
|
||||
pub client_delay: i32,
|
||||
pub spin: f64,
|
||||
pub o_spin: f64,
|
||||
}
|
||||
/// `TrialSpawnerConfig`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TrialConfig {
|
||||
pub spawn_range: i32,
|
||||
pub total_mobs: f32,
|
||||
pub simultaneous_mobs: f32,
|
||||
pub total_mobs_added_per_player: f32,
|
||||
pub simultaneous_mobs_added_per_player: f32,
|
||||
pub ticks_between_spawn: i32,
|
||||
pub spawn_potentials: Vec<(SpawnData, i32)>,
|
||||
pub loot_tables_to_eject: Vec<(String, i32)>,
|
||||
pub items_to_drop_when_ominous: String,
|
||||
}
|
||||
/// A `Holder<TrialSpawnerConfig>` as saved: a registry id
|
||||
/// (`minecraft:trial_chamber/breeze/normal`) or an inline config.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ConfigSlot {
|
||||
/// The value as loaded, written back as is; None for the default.
|
||||
pub saved: Option<Tag>,
|
||||
pub config: TrialConfig,
|
||||
}
|
||||
/// `TrialSpawner` with its `TrialSpawnerStateData`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TrialSpawner {
|
||||
pub normal: ConfigSlot,
|
||||
pub ominous: ConfigSlot,
|
||||
pub target_cooldown_length: i32,
|
||||
pub required_player_range: i32,
|
||||
pub detected_players: Vec<Uuid>,
|
||||
pub current_mobs: Vec<Uuid>,
|
||||
pub cooldown_ends_at: i64,
|
||||
pub next_mob_spawns_at: i64,
|
||||
pub total_mobs_spawned: i32,
|
||||
pub next_spawn_data: Option<SpawnData>,
|
||||
pub ejecting_loot_table: Option<String>,
|
||||
/// `isOminous`, set from the block on every server tick.
|
||||
pub is_ominous: bool,
|
||||
/// The server's display entity exists (`getOrCreateDisplayEntity`): it is
|
||||
/// made once per loaded block entity and kept.
|
||||
pub has_display: bool,
|
||||
pub spin: f64,
|
||||
pub o_spin: f64,
|
||||
/// `getDispensingItems`, kept once rolled (not saved): the ominous items
|
||||
/// and their weights (the rolled counts).
|
||||
pub dispensing: Option<Vec<ItemStack>>,
|
||||
}
|
||||
/// `VaultConfig`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct VaultConfig {
|
||||
pub loot_table: String,
|
||||
pub activation_range: f64,
|
||||
pub deactivation_range: f64,
|
||||
/// None when the config has no key (`ItemStack.EMPTY`): the vault never opens.
|
||||
pub key_item: Option<ItemStack>,
|
||||
pub override_loot_table_to_display: Option<String>,
|
||||
}
|
||||
/// `VaultBlockEntity`: its config, server data and shared data.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Vault {
|
||||
pub config: VaultConfig,
|
||||
/// `VaultServerData`, oldest rewarded player first.
|
||||
pub rewarded_players: Vec<Uuid>,
|
||||
pub state_updating_resumes_at: i64,
|
||||
/// Popped from the end.
|
||||
pub items_to_eject: Vec<ItemStack>,
|
||||
pub total_ejections_needed: i32,
|
||||
pub last_insert_fail_timestamp: i64,
|
||||
/// `VaultSharedData`.
|
||||
pub display_item: Option<ItemStack>,
|
||||
pub connected_players: Vec<Uuid>,
|
||||
pub connected_particles_range: f64,
|
||||
/// The shared data changed since the last tick sent it.
|
||||
pub shared_dirty: bool,
|
||||
/// `VaultClientData`: the display item's spin and the previous tick's, in degrees.
|
||||
pub spin: f32,
|
||||
pub o_spin: f32,
|
||||
}
|
||||
/// `BeehiveBlockEntity`: the bees inside and the flower one of them knew.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Beehive {
|
||||
pub bees: Vec<Occupant>,
|
||||
pub flower: Option<[i32; 3]>,
|
||||
}
|
||||
/// `BeehiveBlockEntity.Occupant`: a bee's tag (with its `id`), its time in
|
||||
/// the hive and the time it stays at least.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Occupant {
|
||||
pub entity_data: Tag,
|
||||
pub ticks_in_hive: i32,
|
||||
pub min_ticks_in_hive: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CommandBlock {
|
||||
pub command: String,
|
||||
pub success_count: i32,
|
||||
/// `CustomName` as saved (a text component).
|
||||
pub custom_name: Option<Tag>,
|
||||
pub track_output: bool,
|
||||
pub last_output: Option<Text>,
|
||||
pub update_last_execution: bool,
|
||||
/// The game time of the last run, -1 for none.
|
||||
pub last_execution: i64,
|
||||
pub powered: bool,
|
||||
pub auto: bool,
|
||||
pub condition_met: bool,
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
//! `CommandBlockEntity` with its `BaseCommandBlock`: the command, the success
|
||||
//! count comparators read, the output it keeps, and the powered, automatic
|
||||
//! and condition flags `CommandBlock.tick` runs on.
|
||||
use crate::{
|
||||
save::{number, text},
|
||||
pub use crate::chunk::block_entity::CommandBlock;
|
||||
use horizon_client_core::{
|
||||
nbt_value::{number, text},
|
||||
scoreboard,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
@@ -76,22 +78,6 @@ pub fn automatic_block(name: &str) -> bool {
|
||||
name == "minecraft:chain_command_block"
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CommandBlock {
|
||||
pub command: String,
|
||||
pub success_count: i32,
|
||||
/// `CustomName` as saved (a text component).
|
||||
pub custom_name: Option<Tag>,
|
||||
pub track_output: bool,
|
||||
pub last_output: Option<Text>,
|
||||
pub update_last_execution: bool,
|
||||
/// The game time of the last run, -1 for none.
|
||||
pub last_execution: i64,
|
||||
pub powered: bool,
|
||||
pub auto: bool,
|
||||
pub condition_met: bool,
|
||||
}
|
||||
|
||||
impl Default for CommandBlock {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -123,7 +109,7 @@ impl CommandBlock {
|
||||
last_output: track_output
|
||||
.then(|| tag.get("LastOutput"))
|
||||
.flatten()
|
||||
.map(|t| crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t))),
|
||||
.map(|t| scoreboard::comp_text(&scoreboard::tag_to_json(t))),
|
||||
update_last_execution,
|
||||
last_execution: if update_last_execution {
|
||||
number(tag.get("LastExecution")).unwrap_or(-1)
|
||||
@@ -179,7 +165,7 @@ impl CommandBlock {
|
||||
pub fn name(&self) -> Text {
|
||||
self.custom_name.as_ref().map_or_else(
|
||||
|| Text::literal("@"),
|
||||
|t| crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
|
||||
|t| scoreboard::comp_text(&scoreboard::tag_to_json(t)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
//! Container block entities: chests/barrels (27 slots) and the three furnace
|
||||
//! kinds, ticking as `AbstractFurnaceBlockEntity.serverTick` with fuel times
|
||||
//! from the data-driven `context_int_provider`/`context_float_provider` files.
|
||||
use crate::{
|
||||
crafting::Recipes,
|
||||
items::{ItemStack, Items},
|
||||
pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
|
||||
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path, sync::Arc};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FurnaceKind {
|
||||
Furnace,
|
||||
BlastFurnace,
|
||||
Smoker,
|
||||
}
|
||||
impl FurnaceKind {
|
||||
pub fn recipe_type(self) -> &'static str {
|
||||
match self {
|
||||
@@ -34,21 +28,6 @@ impl FurnaceKind {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Furnace {
|
||||
pub kind: FurnaceKind,
|
||||
/// input, fuel, result
|
||||
pub items: [Option<ItemStack>; 3],
|
||||
pub lit_remaining: i32,
|
||||
pub lit_total: i32,
|
||||
pub cooking_timer: i32,
|
||||
pub cooking_total: i32,
|
||||
pub speed: f32,
|
||||
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
|
||||
/// made since a player last took from its output. The experience is owed per recipe
|
||||
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
|
||||
pub recipes_used: HashMap<String, u32>,
|
||||
}
|
||||
/// The key `recipes_used` keeps a recipe under: its full id, as vanilla's `ResourceKey<Recipe>`
|
||||
/// writes it in `RecipesUsed`. Horizon's own recipe ids are file names without the namespace, so
|
||||
/// keeping both forms would write a key twice, and vanilla refuses such a furnace
|
||||
@@ -63,78 +42,6 @@ pub fn recipe_used_key(id: &str) -> String {
|
||||
/// A hopper's slots: the only plain container with five.
|
||||
pub const HOPPER_SLOTS: usize = 5;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum BlockEntity {
|
||||
Chest(Vec<Option<ItemStack>>),
|
||||
Furnace(Furnace),
|
||||
Brewing(Box<crate::brewing::BrewingStand>),
|
||||
/// Signs and hanging signs (`SignBlockEntity`).
|
||||
Sign(crate::signs::SignData),
|
||||
/// `JukeboxBlockEntity`: the record and its `JukeboxSongPlayer`.
|
||||
Jukebox(Jukebox),
|
||||
/// `BeaconBlockEntity`.
|
||||
Beacon(Box<Beacon>),
|
||||
/// `LecternBlockEntity`: the book and the open page.
|
||||
Lectern(Lectern),
|
||||
/// Sculk sensors, shriekers and catalysts (`crate::sculk`).
|
||||
Sculk(Box<crate::sculk::SculkEntity>),
|
||||
/// `CreakingHeartBlockEntity`: its protector and the hurt emitter.
|
||||
CreakingHeart(Box<CreakingHeart>),
|
||||
/// `BannerBlockEntity`: the pattern layers.
|
||||
Banner(Banner),
|
||||
/// `CrafterBlockEntity`.
|
||||
Crafter(Crafter),
|
||||
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
|
||||
Spawner(Box<crate::mobs::spawner::Spawner>),
|
||||
/// `TrialSpawnerBlockEntity` (`crate::mobs::trial_spawner`).
|
||||
TrialSpawner(Box<crate::mobs::trial_spawner::TrialSpawner>),
|
||||
/// `VaultBlockEntity` (`crate::vault`).
|
||||
Vault(Box<crate::vault::Vault>),
|
||||
/// `BeehiveBlockEntity` of beehives and bee nests (`crate::beehive`).
|
||||
Beehive(Box<crate::beehive::Beehive>),
|
||||
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
|
||||
CommandBlock(Box<crate::command_block::CommandBlock>),
|
||||
/// `CampfireBlockEntity` of campfires and soul campfires.
|
||||
Campfire(Box<Campfire>),
|
||||
/// `SkullBlockEntity` of every skull and head block.
|
||||
Skull(Skull),
|
||||
/// `TheEndGatewayBlockEntity`: its age and configured destination.
|
||||
EndGateway(EndGateway),
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
|
||||
ChiseledBookshelf(Box<ChiseledBookshelf>),
|
||||
}
|
||||
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ChiseledBookshelf {
|
||||
pub items: [Option<ItemStack>; 6],
|
||||
pub last_slot: i32,
|
||||
}
|
||||
impl Default for ChiseledBookshelf {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
items: Default::default(),
|
||||
last_slot: -1,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `TheEndGatewayBlockEntity`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EndGateway {
|
||||
pub age: i64,
|
||||
pub exit_portal: Option<[i32; 3]>,
|
||||
pub exact_teleport: bool,
|
||||
}
|
||||
/// `CampfireBlockEntity`: four cooking slots, each with its
|
||||
/// `cookingProgress` and `cookingTime` (the recipe's).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Campfire {
|
||||
pub items: [Option<ItemStack>; 4],
|
||||
pub progress: [i32; 4],
|
||||
pub total: [i32; 4],
|
||||
}
|
||||
impl Campfire {
|
||||
/// `placeFood`'s slot: the first empty one, given the item's
|
||||
/// `campfire_cooking` time; it takes one item of `held`.
|
||||
@@ -182,17 +89,6 @@ impl Campfire {
|
||||
changed
|
||||
}
|
||||
}
|
||||
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
|
||||
/// a compound, or a string when it names a player only), the note block
|
||||
/// sound and `custom_name`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Skull {
|
||||
pub profile: Option<horizon_world::nbt::Tag>,
|
||||
pub note_block_sound: Option<String>,
|
||||
pub custom_name: Option<horizon_world::nbt::Tag>,
|
||||
/// What `profile` draws, read from it once rather than every frame.
|
||||
pub skin: Option<SkullSkin>,
|
||||
}
|
||||
impl Skull {
|
||||
pub fn new(
|
||||
profile: Option<horizon_world::nbt::Tag>,
|
||||
@@ -208,18 +104,6 @@ impl Skull {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
|
||||
/// with the `skins/<sha1>` sprite it downloads to, and the
|
||||
/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
|
||||
/// profile with no id).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SkullSkin {
|
||||
pub download: Option<(String, String)>,
|
||||
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
|
||||
/// an id only): the key `skins::Resolver` resolves it by.
|
||||
pub dynamic: Option<crate::skins::NameOrId>,
|
||||
pub fallback: &'static str,
|
||||
}
|
||||
impl SkullSkin {
|
||||
pub fn of(profile: &horizon_world::nbt::Tag) -> Self {
|
||||
let download = crate::skins::profile_skin_url_nbt(profile).and_then(|url| {
|
||||
@@ -248,18 +132,6 @@ impl SkullSkin {
|
||||
fn is_skull(name: &str) -> bool {
|
||||
name.ends_with("_skull") || (name.ends_with("_head") && name != "minecraft:piston_head")
|
||||
}
|
||||
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
|
||||
/// and the ticks left in the crafting animation.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Crafter {
|
||||
pub items: [Option<ItemStack>; 9],
|
||||
pub disabled: [bool; 9],
|
||||
pub triggered: bool,
|
||||
pub crafting_ticks: i32,
|
||||
/// The block state's `triggered` when last ticked (not saved): the block
|
||||
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
|
||||
pub state_triggered: Option<bool>,
|
||||
}
|
||||
impl Crafter {
|
||||
/// `slotCanBeDisabled`: only empty slots.
|
||||
pub fn slot_can_be_disabled(&self, slot: usize) -> bool {
|
||||
@@ -299,48 +171,6 @@ impl Crafter {
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Banner {
|
||||
pub layers: Vec<crate::banner::BannerLayer>,
|
||||
pub name: Option<String>,
|
||||
/// Components not consumed by BannerBlockEntity's implicit fields.
|
||||
pub components: horizon_world::nbt::Compound,
|
||||
}
|
||||
/// `CreakingHeartBlockEntity` (the logic is in `game/creaking.rs`).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CreakingHeart {
|
||||
/// The protector: its entity id once resolved, and its UUID (the saved form).
|
||||
pub creaking: Option<(Option<u32>, [i32; 4])>,
|
||||
pub ticks_existed: i64,
|
||||
pub ticker: i32,
|
||||
/// Ticks left of the hurt effect (`emitter`) and where it heads.
|
||||
pub emitter: i32,
|
||||
pub emitter_target: Option<[f64; 3]>,
|
||||
pub output_signal: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Lectern {
|
||||
pub book: Option<ItemStack>,
|
||||
pub page: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Beacon {
|
||||
pub levels: i32,
|
||||
/// Effect ids (`minecraft:speed`).
|
||||
pub primary: Option<String>,
|
||||
pub secondary: Option<String>,
|
||||
/// Beam sections (RGB colour, height) and the ones being rebuilt.
|
||||
pub sections: Vec<(u32, i32)>,
|
||||
pub checking: Vec<(u32, i32)>,
|
||||
pub last_check_y: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Jukebox {
|
||||
pub record: Option<ItemStack>,
|
||||
/// `ticks_since_song_started`, while a song plays.
|
||||
pub ticks: i64,
|
||||
pub playing: bool,
|
||||
}
|
||||
impl BlockEntity {
|
||||
pub fn for_block(name: &str) -> Option<Self> {
|
||||
if let Some(hanging) = crate::signs::sign_kind(name) {
|
||||
@@ -546,283 +376,7 @@ impl BlockEntity {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Evaluator for `context_int_provider` / `context_float_provider` values.
|
||||
pub struct Providers {
|
||||
ints: HashMap<String, Value>,
|
||||
floats: HashMap<String, Value>,
|
||||
}
|
||||
fn load_dir(dir: &Path, prefix: &str, out: &mut HashMap<String, Value>) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
let name = p
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
if p.is_dir() {
|
||||
load_dir(&p, &format!("{prefix}{name}/"), out);
|
||||
} else if let Ok(bytes) = fs::read(&p)
|
||||
&& let Ok(v) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
out.insert(format!("minecraft:{prefix}{name}"), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Providers {
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let mut ints = HashMap::new();
|
||||
let mut floats = HashMap::new();
|
||||
load_dir(
|
||||
&root.join("data/minecraft/context_int_provider"),
|
||||
"",
|
||||
&mut ints,
|
||||
);
|
||||
load_dir(
|
||||
&root.join("data/minecraft/context_float_provider"),
|
||||
"",
|
||||
&mut floats,
|
||||
);
|
||||
Self { ints, floats }
|
||||
}
|
||||
fn eval(v: &Value, fast: bool, table: &HashMap<String, Value>, depth: u32) -> f64 {
|
||||
if depth > 16 {
|
||||
return 0.0;
|
||||
}
|
||||
match v {
|
||||
Value::Number(n) => n.as_f64().unwrap_or(0.0),
|
||||
Value::String(name) => table
|
||||
.get(name)
|
||||
.map_or(0.0, |v| Self::eval(v, fast, table, depth + 1)),
|
||||
Value::Object(o) => {
|
||||
let f =
|
||||
|k: &str| Self::eval(o.get(k).unwrap_or(&Value::Null), fast, table, depth + 1);
|
||||
match o.get("type").and_then(Value::as_str).unwrap_or("") {
|
||||
"minecraft:div" => {
|
||||
let r = f("right");
|
||||
if r == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(f("left") / r).trunc()
|
||||
}
|
||||
}
|
||||
"minecraft:mul" => f("left") * f("right"),
|
||||
"minecraft:add" | "minecraft:sum" => f("left") + f("right"),
|
||||
"minecraft:sub" => f("left") - f("right"),
|
||||
"minecraft:constant" => f("value"),
|
||||
"minecraft:conditional" => {
|
||||
// The only condition in the pinned data: block/fast_cooking.
|
||||
let condition = o.get("condition").and_then(Value::as_str)
|
||||
== Some("minecraft:block/fast_cooking");
|
||||
if condition && fast {
|
||||
f("on_true")
|
||||
} else {
|
||||
f("on_false")
|
||||
}
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
/// A plain `context_int_provider` value or reference (no context conditions).
|
||||
pub fn int_value(&self, v: &Value) -> i32 {
|
||||
Self::eval(v, false, &self.ints, 0) as i32
|
||||
}
|
||||
/// A plain `context_float_provider` value or reference; `default` when absent.
|
||||
pub fn float_value(&self, v: &Value, default: f32) -> f32 {
|
||||
if v.is_null() {
|
||||
default
|
||||
} else {
|
||||
Self::eval(v, false, &self.floats, 0) as f32
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
ints: HashMap::new(),
|
||||
floats: HashMap::new(),
|
||||
}
|
||||
}
|
||||
pub fn burn_time(&self, items: &Items, stack: &ItemStack, fast: bool) -> i32 {
|
||||
items
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.fuel.as_ref())
|
||||
.map_or(0, |(time, _)| Self::eval(time, fast, &self.ints, 0) as i32)
|
||||
}
|
||||
pub fn speed(&self, items: &Items, stack: &ItemStack, fast: bool) -> f32 {
|
||||
items
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.fuel.as_ref())
|
||||
.map_or(1.0, |(_, speed)| {
|
||||
Self::eval(speed, fast, &self.floats, 0) as f32
|
||||
})
|
||||
}
|
||||
}
|
||||
fn remainder(item: &str) -> Option<&'static str> {
|
||||
match item {
|
||||
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
|
||||
Some("minecraft:bucket")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
impl Furnace {
|
||||
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
|
||||
/// the same item with the same components restarts cooking, with the
|
||||
/// total time of the new input's recipe (200 ticks without one).
|
||||
pub fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
|
||||
let same = match (&self.items[0], &stack) {
|
||||
(Some(old), Some(new)) => old.same_item_same_components(new),
|
||||
_ => false,
|
||||
};
|
||||
self.items[0] = stack;
|
||||
if !same {
|
||||
self.cooking_total = self.items[0]
|
||||
.as_ref()
|
||||
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
|
||||
.map_or(200, |recipe| total_time(recipe.time, self.speed));
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
|
||||
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
|
||||
pub fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
|
||||
self.recipes_used
|
||||
.iter()
|
||||
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
|
||||
.collect()
|
||||
}
|
||||
/// A menu click wrote its slots into `items`, and `before` was the input
|
||||
/// slot until then: the menu called `setItem` on it only if it changed
|
||||
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
|
||||
pub fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
|
||||
if self.items[0] != before {
|
||||
let after = std::mem::replace(&mut self.items[0], before);
|
||||
self.set_input(recipes, after);
|
||||
}
|
||||
}
|
||||
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
recipes: &Recipes,
|
||||
items: &Items,
|
||||
providers: &Providers,
|
||||
) -> Option<bool> {
|
||||
let was_lit = self.lit_remaining > 0;
|
||||
let mut lit = false;
|
||||
if self.lit_remaining > 0 {
|
||||
self.lit_remaining -= 1;
|
||||
lit = self.lit_remaining > 0;
|
||||
}
|
||||
let fast = self.kind.fast();
|
||||
let has_ingredient = self.items[0].is_some();
|
||||
let has_fuel = self.items[1].is_some();
|
||||
if lit || has_fuel && has_ingredient {
|
||||
if let Some(ingredient) = self.items[0].clone() {
|
||||
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
|
||||
if self.cooking_timer == 0 {
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
}
|
||||
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
|
||||
let can_burn = match &self.items[2] {
|
||||
None => true,
|
||||
Some(out) => {
|
||||
out.item == recipe.result.item && out.count + recipe.result.count <= max
|
||||
}
|
||||
};
|
||||
if can_burn {
|
||||
if !lit {
|
||||
let fuel = self.items[1].clone();
|
||||
let time = fuel
|
||||
.as_ref()
|
||||
.map_or(0, |f| providers.burn_time(items, f, fast));
|
||||
let speed = fuel
|
||||
.as_ref()
|
||||
.map_or(1.0, |f| providers.speed(items, f, fast));
|
||||
self.lit_remaining = time;
|
||||
self.lit_total = time;
|
||||
self.speed = speed;
|
||||
if self.cooking_total > 0 && self.cooking_timer < self.cooking_total {
|
||||
let ratio = self.cooking_timer as f32 / self.cooking_total as f32;
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
self.cooking_timer =
|
||||
(ratio * self.cooking_total as f32).ceil() as i32;
|
||||
}
|
||||
if time > 0 {
|
||||
if let Some(f) = self.items[1].as_mut() {
|
||||
let item = f.item.clone();
|
||||
f.count -= 1;
|
||||
if f.count == 0 {
|
||||
self.items[1] = remainder(&item).map(|r| ItemStack {
|
||||
item: Arc::from(r),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
lit = true;
|
||||
}
|
||||
}
|
||||
if lit {
|
||||
self.cooking_timer += 1;
|
||||
if self.cooking_timer >= self.cooking_total {
|
||||
self.cooking_timer = 0;
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
match &mut self.items[2] {
|
||||
Some(out) => out.count += recipe.result.count,
|
||||
None => self.items[2] = Some(recipe.result.clone()),
|
||||
}
|
||||
*self
|
||||
.recipes_used
|
||||
.entry(recipe_used_key(&recipe.id))
|
||||
.or_insert(0) += 1;
|
||||
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
|
||||
// the bucket in the fuel slot.
|
||||
if self.items[0]
|
||||
.as_ref()
|
||||
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
|
||||
&& self.items[1]
|
||||
.as_ref()
|
||||
.is_some_and(|f| &*f.item == "minecraft:bucket")
|
||||
{
|
||||
self.items[1] =
|
||||
Some(ItemStack::new("minecraft:water_bucket", 1));
|
||||
}
|
||||
if let Some(i) = self.items[0].as_mut() {
|
||||
i.count -= 1;
|
||||
if i.count == 0 {
|
||||
self.items[0] = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else if self.cooking_timer > 0 {
|
||||
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
|
||||
}
|
||||
(was_lit != lit).then_some(lit)
|
||||
}
|
||||
}
|
||||
fn total_time(time: u32, speed: f32) -> i32 {
|
||||
if speed > 0.0 {
|
||||
(time as f32 / speed).ceil() as i32
|
||||
} else {
|
||||
time as i32
|
||||
}
|
||||
}
|
||||
pub use crate::providers::Providers;
|
||||
|
||||
/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
|
||||
/// more with the chance of what is left over (a level random draw only when there is some).
|
||||
|
||||
@@ -355,6 +355,19 @@ impl Creative {
|
||||
pub fn search_items(&self) -> &[ItemStack] {
|
||||
&self.search_items
|
||||
}
|
||||
/// Opening the screen: vanilla rebuilds CreativeModeInventoryScreen, whose
|
||||
/// init() makes a fresh search box and a zeroed scrollbar; only the tab
|
||||
/// persists (its static selectedTab). selectTab on the reopened screen
|
||||
/// takes the oldTab != tab branch, so it empties and refocuses the box.
|
||||
pub fn reopen(&mut self) {
|
||||
self.search.set_value("");
|
||||
self.release();
|
||||
self.scroll = 0.0;
|
||||
self.search.focused = self.tab().search;
|
||||
if self.search.focused {
|
||||
self.search.reset_blink();
|
||||
}
|
||||
}
|
||||
/// Indices into `search_items` that CreativeModeInventoryScreen.refreshSearchResults
|
||||
/// shows for `term`: the whole tab when empty, the tag tree after `#`, else
|
||||
/// the name tree, both over vanilla's suffix arrays (substring matches,
|
||||
@@ -575,6 +588,8 @@ pub enum DisplayClick {
|
||||
Nothing,
|
||||
/// SWAP: the hotbar slot gets a full stack of the item.
|
||||
Hotbar(usize, ItemStack),
|
||||
/// SWAP with button 40 (Key F): the off-hand gets a full stack of the item.
|
||||
Offhand(ItemStack),
|
||||
/// THROW: a copy of the item is dropped.
|
||||
Drop(ItemStack),
|
||||
}
|
||||
@@ -596,8 +611,11 @@ pub fn click_display(
|
||||
match input {
|
||||
Input::Swap => {
|
||||
return match shown {
|
||||
Some(s) => DisplayClick::Hotbar(button as usize, copy(s, max(s))),
|
||||
None => DisplayClick::Nothing,
|
||||
Some(s) if button == 40 => DisplayClick::Offhand(copy(s, max(s))),
|
||||
Some(s) if (0..9).contains(&button) => {
|
||||
DisplayClick::Hotbar(button as usize, copy(s, max(s)))
|
||||
}
|
||||
Some(_) | None => DisplayClick::Nothing,
|
||||
};
|
||||
}
|
||||
Input::Clone => {
|
||||
@@ -720,11 +738,15 @@ mod tests {
|
||||
display(Some(stack("ender_pearl", 9)), 1, Pickup).0,
|
||||
Some(stack("ender_pearl", 8))
|
||||
);
|
||||
// Number keys fill a hotbar slot, Q drops copies, pick block clones.
|
||||
// Number keys fill a hotbar slot, F fills off-hand, Q drops copies, pick block clones.
|
||||
assert_eq!(
|
||||
display(None, 3, Swap).1,
|
||||
DisplayClick::Hotbar(3, stack("stone", 64))
|
||||
);
|
||||
assert_eq!(
|
||||
display(None, 40, Swap).1,
|
||||
DisplayClick::Offhand(stack("stone", 64))
|
||||
);
|
||||
assert_eq!(
|
||||
display(None, 0, Throw).1,
|
||||
DisplayClick::Drop(stack("stone", 1))
|
||||
|
||||
@@ -29,6 +29,10 @@ impl EffectInstance {
|
||||
fn infinite(&self) -> bool {
|
||||
self.duration == -1
|
||||
}
|
||||
/// `MobEffectInstance.endsWithin`.
|
||||
pub fn ends_within(&self, ticks: i32) -> bool {
|
||||
!self.infinite() && self.duration <= ticks
|
||||
}
|
||||
fn shorter_than(&self, other: &Self) -> bool {
|
||||
!self.infinite() && (self.duration < other.duration || other.infinite())
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ use crate::{
|
||||
doubles, float, floats, item_from_nbt, item_to_nbt, number, put, read_doubles, text,
|
||||
uuid_from_tag,
|
||||
},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
@@ -874,14 +873,7 @@ fn custom_name(tag: &Tag) -> Option<String> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
|
||||
/// else is a component (`ComponentSerialization`).
|
||||
pub fn name_text(tag: &Tag) -> Text {
|
||||
match tag {
|
||||
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
|
||||
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
|
||||
}
|
||||
}
|
||||
pub use horizon_client_core::component::name_text;
|
||||
/// Mob.finalizeSpawn's follow range bonus in the saved attributes.
|
||||
pub(crate) fn spawn_bonus(tag: &Tag) -> Option<f64> {
|
||||
let Some(Tag::List(attrs)) = tag.get("attributes") else {
|
||||
|
||||
@@ -443,18 +443,7 @@ impl UseResult {
|
||||
matches!(self, Self::Success { swing: true }) && before == after
|
||||
}
|
||||
}
|
||||
pub fn consumable_animation(v: &serde_json::Value) -> horizon_world::consumable::UseAnimation {
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
match v
|
||||
.get("animation")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("eat")
|
||||
{
|
||||
"eat" => UseAnimation::Eat,
|
||||
"drink" => UseAnimation::Drink,
|
||||
_ => UseAnimation::None,
|
||||
}
|
||||
}
|
||||
pub use horizon_items::use_animation::consumable_animation;
|
||||
/// What the click handling reads and does in the world.
|
||||
pub trait HandWorld {
|
||||
fn hit(&mut self) -> Hit;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! `AbstractFurnaceBlockEntity`'s cooking over the furnace data the chunk
|
||||
//! holds (`containers::Furnace`): it needs the recipes, so it lives above them.
|
||||
use crate::{
|
||||
containers::{Furnace, recipe_used_key},
|
||||
crafting::Recipes,
|
||||
items::{ItemStack, Items},
|
||||
providers::Providers,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The furnace methods that read the recipes.
|
||||
pub trait FurnaceCooking {
|
||||
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
|
||||
/// the same item with the same components restarts cooking, with the
|
||||
/// total time of the new input's recipe (200 ticks without one).
|
||||
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>);
|
||||
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
|
||||
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
|
||||
fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)>;
|
||||
/// A menu click wrote its slots into `items`, and `before` was the input
|
||||
/// slot until then: the menu called `setItem` on it only if it changed
|
||||
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
|
||||
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>);
|
||||
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
|
||||
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool>;
|
||||
}
|
||||
fn remainder(item: &str) -> Option<&'static str> {
|
||||
match item {
|
||||
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
|
||||
Some("minecraft:bucket")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
impl FurnaceCooking for Furnace {
|
||||
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
|
||||
let same = match (&self.items[0], &stack) {
|
||||
(Some(old), Some(new)) => old.same_item_same_components(new),
|
||||
_ => false,
|
||||
};
|
||||
self.items[0] = stack;
|
||||
if !same {
|
||||
self.cooking_total = self.items[0]
|
||||
.as_ref()
|
||||
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
|
||||
.map_or(200, |recipe| total_time(recipe.time, self.speed));
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
|
||||
self.recipes_used
|
||||
.iter()
|
||||
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
|
||||
.collect()
|
||||
}
|
||||
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
|
||||
if self.items[0] != before {
|
||||
let after = std::mem::replace(&mut self.items[0], before);
|
||||
self.set_input(recipes, after);
|
||||
}
|
||||
}
|
||||
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool> {
|
||||
let was_lit = self.lit_remaining > 0;
|
||||
let mut lit = false;
|
||||
if self.lit_remaining > 0 {
|
||||
self.lit_remaining -= 1;
|
||||
lit = self.lit_remaining > 0;
|
||||
}
|
||||
let fast = self.kind.fast();
|
||||
let has_ingredient = self.items[0].is_some();
|
||||
let has_fuel = self.items[1].is_some();
|
||||
if lit || has_fuel && has_ingredient {
|
||||
if let Some(ingredient) = self.items[0].clone() {
|
||||
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
|
||||
if self.cooking_timer == 0 {
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
}
|
||||
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
|
||||
let can_burn = match &self.items[2] {
|
||||
None => true,
|
||||
Some(out) => {
|
||||
out.item == recipe.result.item && out.count + recipe.result.count <= max
|
||||
}
|
||||
};
|
||||
if can_burn {
|
||||
if !lit {
|
||||
let fuel = self.items[1].clone();
|
||||
let time = fuel
|
||||
.as_ref()
|
||||
.map_or(0, |f| providers.burn_time(items, f, fast));
|
||||
let speed = fuel
|
||||
.as_ref()
|
||||
.map_or(1.0, |f| providers.speed(items, f, fast));
|
||||
self.lit_remaining = time;
|
||||
self.lit_total = time;
|
||||
self.speed = speed;
|
||||
if self.cooking_total > 0 && self.cooking_timer < self.cooking_total {
|
||||
let ratio = self.cooking_timer as f32 / self.cooking_total as f32;
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
self.cooking_timer =
|
||||
(ratio * self.cooking_total as f32).ceil() as i32;
|
||||
}
|
||||
if time > 0 {
|
||||
if let Some(f) = self.items[1].as_mut() {
|
||||
let item = f.item.clone();
|
||||
f.count -= 1;
|
||||
if f.count == 0 {
|
||||
self.items[1] = remainder(&item).map(|r| ItemStack {
|
||||
item: Arc::from(r),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
lit = true;
|
||||
}
|
||||
}
|
||||
if lit {
|
||||
self.cooking_timer += 1;
|
||||
if self.cooking_timer >= self.cooking_total {
|
||||
self.cooking_timer = 0;
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
match &mut self.items[2] {
|
||||
Some(out) => out.count += recipe.result.count,
|
||||
None => self.items[2] = Some(recipe.result.clone()),
|
||||
}
|
||||
*self
|
||||
.recipes_used
|
||||
.entry(recipe_used_key(&recipe.id))
|
||||
.or_insert(0) += 1;
|
||||
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
|
||||
// the bucket in the fuel slot.
|
||||
if self.items[0]
|
||||
.as_ref()
|
||||
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
|
||||
&& self.items[1]
|
||||
.as_ref()
|
||||
.is_some_and(|f| &*f.item == "minecraft:bucket")
|
||||
{
|
||||
self.items[1] =
|
||||
Some(ItemStack::new("minecraft:water_bucket", 1));
|
||||
}
|
||||
if let Some(i) = self.items[0].as_mut() {
|
||||
i.count -= 1;
|
||||
if i.count == 0 {
|
||||
self.items[0] = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else if self.cooking_timer > 0 {
|
||||
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
|
||||
}
|
||||
(was_lit != lit).then_some(lit)
|
||||
}
|
||||
}
|
||||
fn total_time(time: u32, speed: f32) -> i32 {
|
||||
if speed > 0.0 {
|
||||
(time as f32 / speed).ceil() as i32
|
||||
} else {
|
||||
time as i32
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,7 @@ mod maps;
|
||||
mod minecarts;
|
||||
mod mob_blocks;
|
||||
mod mob_gear;
|
||||
mod mob_sounds;
|
||||
mod mod_browser;
|
||||
#[cfg(feature = "mods")]
|
||||
mod mod_browser_recipes;
|
||||
@@ -176,6 +177,7 @@ mod sign_edit;
|
||||
mod sniffers;
|
||||
mod spawner;
|
||||
mod spear;
|
||||
mod spectator;
|
||||
mod spill;
|
||||
mod startup_menu;
|
||||
mod station_control;
|
||||
@@ -220,6 +222,7 @@ use horizon_client::{
|
||||
entities::{Experience, ExperienceOrb, ItemEntity},
|
||||
environment::Environment,
|
||||
first_person::{Hand, SwingAnimation},
|
||||
furnace::FurnaceCooking,
|
||||
gfx::{Frame, Renderer},
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
hud_layout, hud_overlays,
|
||||
@@ -583,8 +586,8 @@ struct GameLevel<'w> {
|
||||
cart_boxes: Vec<([f64; 3], [f64; 3])>,
|
||||
/// The item frames a comparator reads through a conductor.
|
||||
item_frames: &'w [horizon_client::entities::ItemFrame],
|
||||
/// Each minecart's kind and comparator signal, in `cart_boxes` order.
|
||||
cart_signals: Vec<(minecarts::CartKind, i32)>,
|
||||
/// The minecarts, in `cart_boxes` order, for a detector rail's comparator.
|
||||
carts: &'w [minecarts::Minecart],
|
||||
/// Arrow and thrown trident boxes (wooden buttons).
|
||||
arrow_boxes: Vec<([f64; 3], [f64; 3])>,
|
||||
/// The other entities that keep blocks from being placed into them.
|
||||
@@ -668,15 +671,22 @@ impl EntityObstructionWorld for BuildingObstruction {
|
||||
}
|
||||
}
|
||||
impl GameLevel<'_> {
|
||||
/// The mobs that trigger plates and tripwires: living entities that don't
|
||||
/// ignore block triggers (`Entity.isIgnoringBlockTriggers`: bats; armor
|
||||
/// stands are not in `mobs`).
|
||||
/// The living entities that trigger plates and tripwires and don't ignore
|
||||
/// block triggers (`Entity.isIgnoringBlockTriggers`: bats and marker armor
|
||||
/// stands). Armor stands live apart from `mobs`.
|
||||
fn mob_trigger_boxes(&self) -> impl Iterator<Item = Aabb> + '_ {
|
||||
self.blockers
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| !m.removed && m.species() != horizon_client::mobs::Species::Bat)
|
||||
.map(Mob::bounds)
|
||||
.chain(
|
||||
self.blockers
|
||||
.armor_stands
|
||||
.iter()
|
||||
.filter(|s| !s.marker)
|
||||
.map(|s| s.bounds()),
|
||||
)
|
||||
}
|
||||
/// The non-living entities that trigger plates and tripwires (boats,
|
||||
/// crystals, primed TNT, falling blocks, item entities, experience orbs and
|
||||
@@ -728,24 +738,36 @@ impl GameLevel<'_> {
|
||||
Vec3::new(x + 0.8, y + 0.8, z + 0.8),
|
||||
);
|
||||
let mut container = None;
|
||||
for ((lo, hi), &(kind, signal)) in self.cart_boxes.iter().zip(&self.cart_signals) {
|
||||
let cart = Aabb::new(
|
||||
for ((lo, hi), cart) in self.cart_boxes.iter().zip(self.carts) {
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(lo[0], lo[1], lo[2]),
|
||||
Vec3::new(hi[0], hi[1], hi[2]),
|
||||
);
|
||||
if !area.intersects(cart) {
|
||||
if !area.intersects(bounds) {
|
||||
continue;
|
||||
}
|
||||
match kind {
|
||||
match cart.kind {
|
||||
minecarts::CartKind::CommandBlock => return 0,
|
||||
minecarts::CartKind::Chest | minecarts::CartKind::Hopper => {
|
||||
container = container.or(Some(signal));
|
||||
// Counted here, for the cart a comparator reads, not for
|
||||
// every cart on every block tick.
|
||||
container = container.or_else(|| Some(self.cart_signal(cart)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
container.unwrap_or(0)
|
||||
}
|
||||
/// A container minecart's comparator signal: its open menu's slots while
|
||||
/// someone has it open, else its own.
|
||||
fn cart_signal(&self, cart: &minecarts::Minecart) -> i32 {
|
||||
let key = (i32::MAX - 15, minecarts::CART_KEY_Y, cart.id as i32);
|
||||
let slots = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::Chest(open)) => open,
|
||||
_ => &cart.items,
|
||||
};
|
||||
minecarts::Minecart::container_signal(slots, self.items)
|
||||
}
|
||||
/// Every entity box that blocks building: the players who aren't
|
||||
/// spectators, the minecarts and the `Blockers` (a removed mob or a marker
|
||||
/// armor stand, whose box is empty, doesn't).
|
||||
@@ -1175,6 +1197,8 @@ struct WorldRest {
|
||||
end_environment: Environment,
|
||||
behaviours: Behaviours,
|
||||
player_arm: Option<horizon_client::mobs::Part>,
|
||||
/// The slim model's right arm (3 pixels wide).
|
||||
player_arm_slim: Option<horizon_client::mobs::Part>,
|
||||
player_type: Option<Arc<horizon_client::mobs::MobType>>,
|
||||
/// `SpinAttackEffectModel` (Riptide's spinning boxes).
|
||||
spin_attack: Option<horizon_client::mobs::Part>,
|
||||
@@ -1247,8 +1271,12 @@ impl Resources {
|
||||
fn threads(&self) -> usize {
|
||||
self.core.threads
|
||||
}
|
||||
fn player_arm(&self) -> &Option<horizon_client::mobs::Part> {
|
||||
&self.rest().player_arm
|
||||
fn player_arm(&self, slim: bool) -> &Option<horizon_client::mobs::Part> {
|
||||
if slim {
|
||||
&self.rest().player_arm_slim
|
||||
} else {
|
||||
&self.rest().player_arm
|
||||
}
|
||||
}
|
||||
fn player_type(&self) -> &Option<Arc<horizon_client::mobs::MobType>> {
|
||||
&self.rest().player_type
|
||||
@@ -1490,6 +1518,8 @@ struct Game<'a> {
|
||||
/// The shader pack's entity and block entity ids, while one draws entities.
|
||||
pack_ids: Option<shader_packs::PackIds>,
|
||||
started: Instant,
|
||||
/// `SpectatorGui`: the menu a spectator opens with the hotbar keys.
|
||||
spectator_gui: horizon_client::spectator_menu::SpectatorGui,
|
||||
previous: Instant,
|
||||
/// FramerateLimitTracker.latestInputTime.
|
||||
last_input: Instant,
|
||||
@@ -1525,6 +1555,10 @@ struct Game<'a> {
|
||||
cart_sounds: HashSet<u32>,
|
||||
/// The minecart the riding sounds were started for (`LocalPlayer.startRiding`).
|
||||
riding_cart_sounds: Option<u32>,
|
||||
/// Bees whose buzz is playing, and whether it is the aggressive one.
|
||||
bee_sounds: HashMap<u32, bool>,
|
||||
/// Guardians whose attack hum is playing.
|
||||
guardian_sounds: HashSet<u32>,
|
||||
dropped: Vec<ItemEntity>,
|
||||
next_entity: u32,
|
||||
particles: horizon_client::particles::ParticleEngine,
|
||||
@@ -2833,7 +2867,6 @@ impl Game<'_> {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let cart_signals = self.cart_signals();
|
||||
let arrow_boxes = self
|
||||
.creatures
|
||||
.projectiles
|
||||
@@ -2877,7 +2910,7 @@ impl Game<'_> {
|
||||
let mut level = GameLevel {
|
||||
cart_boxes,
|
||||
item_frames: &self.item_frames,
|
||||
cart_signals,
|
||||
carts: &self.carts,
|
||||
arrow_boxes,
|
||||
blockers,
|
||||
dropped: &self.dropped,
|
||||
@@ -2904,7 +2937,13 @@ impl Game<'_> {
|
||||
behaviours: self.res.behaviours(),
|
||||
items: self.res.items(),
|
||||
recipes: self.res.recipes(),
|
||||
loot: horizon_client::structure_blocks::loot_data(&self.options.resources),
|
||||
// A client rolls no loot (LootTable.getRandomItems needs a
|
||||
// ServerLevel): it doesn't load the tables on its game thread.
|
||||
loot: if self.remote.is_some() {
|
||||
None
|
||||
} else {
|
||||
horizon_client::structure_blocks::loot_data(&self.options.resources)
|
||||
},
|
||||
registry: self.pack.assets.registry,
|
||||
rules,
|
||||
difficulty: i32::from(self.ui.difficulty),
|
||||
@@ -4331,6 +4370,14 @@ impl Game<'_> {
|
||||
self.use_spyglass();
|
||||
return Ok(true);
|
||||
}
|
||||
// BundleItem.use: startUsingItem, then onUseTick empties it.
|
||||
if !on_block && info.bundle {
|
||||
if self.using.is_none() {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
}
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if !on_block && &*stack.item == "minecraft:bow" {
|
||||
if self.using.is_none()
|
||||
&& (self.creative || self.projectile_slot("minecraft:bow").is_some())
|
||||
@@ -4615,6 +4662,10 @@ impl Game<'_> {
|
||||
self.tick_brush(slot, ticks);
|
||||
return;
|
||||
}
|
||||
if info.bundle {
|
||||
self.tick_bundle(slot, ticks);
|
||||
return;
|
||||
}
|
||||
let Some(consumable) = info.consumable.as_ref() else {
|
||||
self.using = None;
|
||||
return;
|
||||
@@ -4637,6 +4688,53 @@ impl Game<'_> {
|
||||
self.using = Some((slot, ticks));
|
||||
}
|
||||
}
|
||||
/// `BundleItem.onUseTick` and `getUseDuration` (200): the first tick and
|
||||
/// then every second tick from the 11th on drop one stack out of the
|
||||
/// bundle in `slot`; the use ends after 200 ticks.
|
||||
fn tick_bundle(&mut self, slot: usize, ticks: u32) {
|
||||
const USE_DURATION: u32 = 200;
|
||||
let remaining = USE_DURATION - ticks.min(USE_DURATION);
|
||||
if ticks == 0 || (remaining < USE_DURATION - 10 && remaining.is_multiple_of(2)) {
|
||||
self.drop_bundle_content(slot);
|
||||
}
|
||||
self.using = (ticks + 1 < USE_DURATION).then_some((slot, ticks + 1));
|
||||
}
|
||||
/// `BundleItem.dropContent`: `removeOneItemFromBundle` and `Player.drop`,
|
||||
/// then the drop sound and the used statistic.
|
||||
fn drop_bundle_content(&mut self, slot: usize) {
|
||||
let items = self.res.items();
|
||||
let Some(mut bundle) = self.stack_in(slot).cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(mut contents) = horizon_client::bundle::Contents::of(items, &bundle) else {
|
||||
return;
|
||||
};
|
||||
let Some(removed) = contents.remove_one(items) else {
|
||||
return;
|
||||
};
|
||||
contents.store(&mut bundle);
|
||||
let item = Arc::clone(&bundle.item);
|
||||
*self.stack_in_mut(slot) = Some(bundle);
|
||||
let Some(position) = self.player.as_ref().map(|p| p.position) else {
|
||||
return;
|
||||
};
|
||||
let at = [position.x, position.y, position.z];
|
||||
// playRemoveOneSound, then Player.drop and playDropContentsSound at
|
||||
// the player's block.
|
||||
let pitch = 0.8 + self.world.random.next_float() * 0.4;
|
||||
self.audio
|
||||
.play(horizon_client::bundle::REMOVE_ONE, Some(at), 0.8, pitch);
|
||||
self.player_drop(removed);
|
||||
let block = BlockPos::containing(position);
|
||||
let pitch = 0.8 + self.world.random.next_float() * 0.4;
|
||||
self.audio.play(
|
||||
horizon_client::bundle::DROP_CONTENTS,
|
||||
Some([block.x, block.y, block.z].map(|v| f64::from(v) + 0.5)),
|
||||
0.8,
|
||||
pitch,
|
||||
);
|
||||
self.stats.add("used", &item, 1);
|
||||
}
|
||||
/// `LivingEntity.releaseUsingItem` (`Item.releaseUsing`) for `stack`,
|
||||
/// used for `ticks`, its hand acting: a bow or a trident lets go (not
|
||||
/// when its hand changed), a spyglass stops scoping.
|
||||
@@ -5192,8 +5290,13 @@ impl Game<'_> {
|
||||
MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity),
|
||||
MotionEvent::EnterBlock { state } => self.trigger_enter_block(state),
|
||||
MotionEvent::HoneySlide {
|
||||
sound, particles, ..
|
||||
pos,
|
||||
sound,
|
||||
particles,
|
||||
} => {
|
||||
if self.world.game_time % 20 == 0 {
|
||||
self.trigger_honey_slide([pos.x, pos.y, pos.z]);
|
||||
}
|
||||
if sound {
|
||||
self.audio
|
||||
.play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0);
|
||||
@@ -5557,8 +5660,10 @@ impl Game<'_> {
|
||||
if self.lan_acting.is_some() {
|
||||
return;
|
||||
}
|
||||
// MouseHandler.grabMouse never captures an inactive window.
|
||||
if capture && !self.focused {
|
||||
// MouseHandler.grabMouse never captures an inactive window, and vanilla
|
||||
// grabs only on `setScreen(null)`: the pack prompt and server dialogs
|
||||
// are screens over the world that keep the pointer.
|
||||
if capture && (!self.focused || self.server_packs.prompt.is_some() || self.dialog_open()) {
|
||||
return;
|
||||
}
|
||||
// The container screen's press state belongs to the screen: a screen
|
||||
@@ -5627,17 +5732,22 @@ impl Game<'_> {
|
||||
fn apply_cursor_icon(&mut self) {
|
||||
use horizon_client::widgets::Cursor;
|
||||
let wanted = horizon_client::widgets::take_cursor();
|
||||
if wanted == self.cursor_icon {
|
||||
let effective = if self.settings.allow_cursor_changes {
|
||||
wanted
|
||||
} else {
|
||||
Cursor::Arrow
|
||||
};
|
||||
if effective == self.cursor_icon {
|
||||
return;
|
||||
}
|
||||
if let Some(window) = &self.window {
|
||||
window.set_cursor(match wanted {
|
||||
window.set_cursor(match effective {
|
||||
Cursor::Arrow => winit::window::CursorIcon::Default,
|
||||
Cursor::PointingHand => winit::window::CursorIcon::Pointer,
|
||||
Cursor::NotAllowed => winit::window::CursorIcon::NotAllowed,
|
||||
});
|
||||
}
|
||||
self.cursor_icon = wanted;
|
||||
self.cursor_icon = effective;
|
||||
}
|
||||
/// LevelLoadingScreen.onClose -> Gui.setScreen(null) -> grabMouse.
|
||||
fn capture_loaded_world(&mut self) {
|
||||
@@ -5860,7 +5970,9 @@ impl Game<'_> {
|
||||
let draws = hud_layout::crosshair(wi, hi, attack, full);
|
||||
hud_layout::paint(&mut batch, &self.gui.atlas, &draws);
|
||||
}
|
||||
if !spectator {
|
||||
if spectator {
|
||||
self.draw_spectator_hotbar(&mut batch);
|
||||
} else {
|
||||
let partial = self.clock.fraction() as f32;
|
||||
let inputs = hud_layout::HotbarInputs {
|
||||
selected: self.selected as i32,
|
||||
@@ -6032,7 +6144,14 @@ impl Game<'_> {
|
||||
// Screen.extractBlurredBackground blurs the world and HUD, while the
|
||||
// screen's background, widgets, toasts and debug overlay stay sharp.
|
||||
batch.blur_at = batch.vertices.len() as u32;
|
||||
match self.screen {
|
||||
// PackConfirmScreen takes the open screen's place (it comes back once
|
||||
// the prompt is answered): the screen under the prompt isn't drawn.
|
||||
let screen = if self.server_packs.prompt.is_some() {
|
||||
Screen::Playing
|
||||
} else {
|
||||
self.screen
|
||||
};
|
||||
match screen {
|
||||
Screen::Paused if self.pause_menu => self.draw_pause_menu(&mut batch),
|
||||
// PauseScreen(false): only its title, at the top.
|
||||
Screen::Paused => {
|
||||
@@ -6072,9 +6191,11 @@ impl Game<'_> {
|
||||
// ToastManager.extractRenderState: F1 hides the toasts too.
|
||||
self.draw_toasts(&mut batch, !show_hud);
|
||||
self.draw_pack_toasts(&mut batch, !show_hud);
|
||||
self.remote_hud(&mut batch);
|
||||
self.draw_sign_editor(&mut batch);
|
||||
self.draw_book(&mut batch);
|
||||
if self.server_packs.prompt.is_none() {
|
||||
self.remote_hud(&mut batch);
|
||||
self.draw_sign_editor(&mut batch);
|
||||
self.draw_book(&mut batch);
|
||||
}
|
||||
batch
|
||||
}
|
||||
fn creative_inventory_open(&self) -> bool {
|
||||
@@ -6542,9 +6663,12 @@ impl Game<'_> {
|
||||
let catalog = self.res.items();
|
||||
let max = |s: &ItemStack| catalog.get(&s.item).map_or(64, |i| i.max_stack);
|
||||
match click_display(&mut self.carried, shown, max, button, input) {
|
||||
DisplayClick::Hotbar(i, stack) => self.inventory[i] = Some(stack),
|
||||
DisplayClick::Hotbar(i, stack) if i < self.inventory.len() => {
|
||||
self.inventory[i] = Some(stack);
|
||||
}
|
||||
DisplayClick::Offhand(stack) => self.offhand = Some(stack),
|
||||
DisplayClick::Drop(stack) => self.player_drop(stack),
|
||||
DisplayClick::Nothing => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// Several clicks on one menu instance (a quick craft drag is three).
|
||||
@@ -7774,7 +7898,9 @@ impl Game<'_> {
|
||||
self.yaw
|
||||
};
|
||||
for m in &self.mobs {
|
||||
if m.fire_ticks > 0 && !m.removed {
|
||||
// Blaze.isOnFire: a charged blaze burns.
|
||||
let blazing = m.species() == horizon_client::mobs::Species::Blaze && m.extra.flag;
|
||||
if (m.fire_ticks > 0 || blazing) && !m.removed {
|
||||
let at = m.previous.lerp(m.position, alpha);
|
||||
horizon_client::fire_render::flames(
|
||||
[at.x, at.y, at.z],
|
||||
@@ -10385,14 +10511,18 @@ impl Game<'_> {
|
||||
if hand == Hand::Off {
|
||||
continue;
|
||||
}
|
||||
let Some(arm) = self.res.player_arm().clone() else {
|
||||
let slim = self.player_model.as_ref().is_some_and(|m| m.slim);
|
||||
let Some(arm) = self.res.player_arm(slim).clone() else {
|
||||
continue;
|
||||
};
|
||||
// The player's own skin, as the inventory's model shows it.
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
let Some(r) = self
|
||||
.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.get("entity/player/wide/steve")
|
||||
.player_model
|
||||
.as_ref()
|
||||
.and_then(|m| m.skin.as_deref())
|
||||
.and_then(|skin| sprites.get(skin))
|
||||
.or_else(|| sprites.get("entity/player/wide/steve"))
|
||||
.copied()
|
||||
else {
|
||||
continue;
|
||||
@@ -11588,6 +11718,11 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.bob_matrix(camera_alpha as f32)
|
||||
};
|
||||
let spin = if title {
|
||||
glam::Mat4::IDENTITY
|
||||
} else {
|
||||
self.spinning_matrix(camera_alpha as f32)
|
||||
};
|
||||
let terrain_distance = self.terrain_distance();
|
||||
let distance = (terrain_distance * 16) as f32;
|
||||
let depth_far =
|
||||
@@ -11595,7 +11730,7 @@ impl Game<'_> {
|
||||
self.view_frustum = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| r.camera_frustum(fov_degrees, yaw, pitch, bob, depth_far));
|
||||
.map(|r| r.camera_frustum(fov_degrees, yaw, pitch, bob * spin, depth_far));
|
||||
// Keep the buffer local while builders need mutable access to the game.
|
||||
let mut entities = std::mem::take(&mut self.entity_vertices);
|
||||
entities.clear();
|
||||
@@ -11678,12 +11813,24 @@ impl Game<'_> {
|
||||
pitch,
|
||||
fov_degrees,
|
||||
bob,
|
||||
spin,
|
||||
sky,
|
||||
distance,
|
||||
detail_distance: (self.streaming.radius * 16) as f32,
|
||||
depth_far,
|
||||
underwater,
|
||||
title,
|
||||
sky_blocked: self.potions.effects.get("blindness").is_some()
|
||||
|| self.potions.effects.get("darkness").is_some()
|
||||
|| matches!(
|
||||
horizon_client::fog::fog_type(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
[camera.x, camera.y, camera.z],
|
||||
self.forward(),
|
||||
),
|
||||
horizon_client::fog::FogType::Lava | horizon_client::fog::FogType::PowderSnow
|
||||
),
|
||||
skybox: self.dim == Dim::Overworld && self.settings.horizon.sky,
|
||||
end_sky: self.dim == Dim::End && self.settings.horizon.sky,
|
||||
end_sky_sprite: self
|
||||
@@ -11996,11 +12143,12 @@ impl Game<'_> {
|
||||
Screen::Playing => {
|
||||
// key.inventory while riding a tamed mount opens its inventory.
|
||||
if !self.open_ridden_mount_menu() {
|
||||
self.screen = if self.creative {
|
||||
Screen::Creative
|
||||
if self.creative {
|
||||
self.screen = Screen::Creative;
|
||||
self.creative_screen.reopen();
|
||||
} else {
|
||||
Screen::Inventory
|
||||
};
|
||||
self.screen = Screen::Inventory;
|
||||
}
|
||||
self.capture(false);
|
||||
}
|
||||
}
|
||||
@@ -12062,11 +12210,16 @@ impl Game<'_> {
|
||||
KeyCode::Digit8 => 7,
|
||||
_ => 8,
|
||||
};
|
||||
// Minecraft.handleKeybinds: a creative player holding the load
|
||||
// or save toolbar key loads or saves a toolbar instead (load
|
||||
// first when both are held).
|
||||
// Minecraft.handleKeybinds: a spectator's number keys drive
|
||||
// the spectator menu, not the hotbar; a creative player
|
||||
// holding the load or save toolbar key loads or saves a
|
||||
// toolbar instead (load first when both are held).
|
||||
let load = self.mapped_key_down("key.loadToolbarActivator");
|
||||
if self.creative && (load || self.mapped_key_down("key.saveToolbarActivator")) {
|
||||
if self.spectator() {
|
||||
self.spectator_hotbar_key(slot as i32);
|
||||
} else if self.creative
|
||||
&& (load || self.mapped_key_down("key.saveToolbarActivator"))
|
||||
{
|
||||
self.hotbar_load_or_save(slot, load);
|
||||
} else {
|
||||
self.selected = slot;
|
||||
@@ -12085,6 +12238,14 @@ impl Game<'_> {
|
||||
if self.sleeping.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
// key.spectatorHotbar (the middle button) opens the spectator menu
|
||||
// and uses its selected item.
|
||||
if button == MouseButton::Middle && self.spectator() {
|
||||
if pressed {
|
||||
self.spectator_action_key();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
return self.remote_mouse(button, pressed);
|
||||
}
|
||||
@@ -12567,13 +12728,16 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
self.capture(false);
|
||||
// Screens (books, signs) stay open instead of pausing.
|
||||
// Gui.setPauseScreen: an open screen (books, signs, the
|
||||
// pack prompt, a server dialog) stays instead of pausing.
|
||||
if self.screen == Screen::Playing
|
||||
&& self.player.is_some()
|
||||
&& !self.options.no_pause
|
||||
&& self.settings.file.bool("pauseOnLostFocus")
|
||||
&& self.book.is_none()
|
||||
&& self.sign_edit.is_none()
|
||||
&& self.server_packs.prompt.is_none()
|
||||
&& !self.dialog_open()
|
||||
{
|
||||
self.screen = Screen::Paused;
|
||||
}
|
||||
@@ -12649,7 +12813,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.mouse
|
||||
.on_scroll(raw_x, raw_y, discrete, wheel_sensitivity)
|
||||
{
|
||||
if self.spectator() {
|
||||
if self.spectator() && self.spectator_gui.is_menu_active() {
|
||||
self.spectator_scroll(-wheel);
|
||||
} else if self.spectator() {
|
||||
if let Some(player) = &mut self.player {
|
||||
player.flying_speed = horizon_client::mouse::spectator_flying_speed(
|
||||
player.flying_speed,
|
||||
@@ -13566,6 +13732,7 @@ impl<'a> Game<'a> {
|
||||
pack_ids: None,
|
||||
health_hud: hud_layout::HealthHud::default(),
|
||||
started: now,
|
||||
spectator_gui: Default::default(),
|
||||
previous: now,
|
||||
last_input: now,
|
||||
pad: Default::default(),
|
||||
@@ -13594,6 +13761,8 @@ impl<'a> Game<'a> {
|
||||
sound_paused: false,
|
||||
cart_sounds: HashSet::new(),
|
||||
riding_cart_sounds: None,
|
||||
bee_sounds: HashMap::new(),
|
||||
guardian_sounds: HashSet::new(),
|
||||
dropped: Vec::new(),
|
||||
next_entity: 0,
|
||||
particles,
|
||||
@@ -14422,6 +14591,8 @@ fn load_world_rest(
|
||||
player_type: perspective::player_type(root),
|
||||
player_arm: horizon_client::mobs::load_model(root, "minecraft:player#main")
|
||||
.and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")),
|
||||
player_arm_slim: horizon_client::mobs::load_model(root, "minecraft:player_slim#main")
|
||||
.and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")),
|
||||
spin_attack: horizon_client::mobs::load_model(root, "minecraft:spin_attack#main"),
|
||||
})
|
||||
}
|
||||
@@ -14559,6 +14730,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
pitch: 10.0,
|
||||
fov_degrees: 85.0,
|
||||
bob: glam::Mat4::IDENTITY,
|
||||
spin: glam::Mat4::IDENTITY,
|
||||
sky: horizon_client::environment::Sky::default(),
|
||||
distance: 12.0,
|
||||
detail_distance: 12.0,
|
||||
@@ -14566,6 +14738,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
underwater: false,
|
||||
cave_culling: false,
|
||||
title: true,
|
||||
sky_blocked: false,
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
|
||||
@@ -993,6 +993,42 @@ impl Game<'_> {
|
||||
block_ok && state_ok && adv::conditions(&c["player"], &pctx, w)
|
||||
});
|
||||
}
|
||||
/// `SlideDownBlockTrigger`: match the actual block and its state.
|
||||
pub(super) fn trigger_honey_slide(&mut self, pos: [i32; 3]) {
|
||||
let me = self.player_entity();
|
||||
self.trigger("minecraft:slide_down_block", move |c, w| {
|
||||
let ctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &ctx, w) && adv::block(c, pos, w)
|
||||
});
|
||||
}
|
||||
|
||||
/// `TargetBlockTrigger`: signal and projectile conditions are evaluated in
|
||||
/// the shooter's context, including distance from the shooter.
|
||||
pub(super) fn trigger_target_hit(&mut self, projectile: &EntityView, strength: i32) {
|
||||
let me = self.player_entity();
|
||||
let projectile = projectile.clone();
|
||||
self.trigger("minecraft:target_hit", move |c, w| {
|
||||
let player = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let target = adv::Entities {
|
||||
this: Some(&projectile),
|
||||
..player
|
||||
};
|
||||
adv::conditions(&c["player"], &player, w)
|
||||
&& adv::range_i(&c["signal_strength"], i64::from(strength))
|
||||
&& adv::conditions(&c["projectile"], &target, w)
|
||||
});
|
||||
}
|
||||
|
||||
/// `ItemUsedOnLocationTrigger` (placed_block, item_used_on_block).
|
||||
pub(super) fn trigger_block(&mut self, name: &str, pos: [i32; 3], stack: &ItemStack) {
|
||||
let me = self.player_entity();
|
||||
|
||||
@@ -563,13 +563,21 @@ impl Game<'_> {
|
||||
};
|
||||
let time = (self.world.game_time.rem_euclid(40)) as f32 + alpha as f32;
|
||||
let scoping = self.scoping();
|
||||
let view = f64::from(self.settings.render_distance * 16);
|
||||
for (k, e) in &self.world.block_entities {
|
||||
let BlockEntity::Beacon(b) = e else { continue };
|
||||
// `getBeamSections` is empty while Levels is 0.
|
||||
if b.levels == 0 || b.sections.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let base = glam::DVec3::new(f64::from(k.0) + 0.5, f64::from(k.1), f64::from(k.2) + 0.5);
|
||||
let distance = (base - camera).truncate().length() as f32;
|
||||
// `BeaconRenderer.shouldRender`: horizontal distance under render distance × 16.
|
||||
let offset = base - camera;
|
||||
let horizontal = glam::DVec2::new(offset.x, offset.z).length();
|
||||
if horizontal >= view {
|
||||
continue;
|
||||
}
|
||||
let distance = horizontal as f32;
|
||||
let scale = if scoping {
|
||||
1.0
|
||||
} else {
|
||||
|
||||
@@ -9,6 +9,7 @@ use horizon_client::{
|
||||
Result,
|
||||
block_entities::{Pos, Ticking},
|
||||
containers::{BlockEntity, FurnaceKind, HOPPER_SLOTS},
|
||||
furnace::FurnaceCooking,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
@@ -76,9 +76,11 @@ fn view_pages(data: Option<&StackData>) -> Vec<Text> {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, page)| match tagged.then(|| &w.page_tags[i]) {
|
||||
Some(tag @ horizon_world::nbt::Tag::Compound(_)) => {
|
||||
horizon_client::entity_nbt::name_text(tag)
|
||||
}
|
||||
// A component object, or a component list (`ComponentSerialization`
|
||||
// takes both; servers behind ViaVersion send the list form).
|
||||
Some(
|
||||
tag @ (horizon_world::nbt::Tag::Compound(_) | horizon_world::nbt::Tag::List(_)),
|
||||
) => horizon_client::entity_nbt::name_text(tag),
|
||||
_ => Text::literal(page),
|
||||
})
|
||||
.collect()
|
||||
@@ -137,6 +139,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
ClickEvent::OpenUrl(url) => self.click_url(url),
|
||||
ClickEvent::RunCommand(command) => self.click_command(&command),
|
||||
ClickEvent::SuggestCommand(command) => {
|
||||
// defaultHandleClickEvent: the chat screen takes it over.
|
||||
if self.player.is_some() {
|
||||
self.close_book();
|
||||
self.open_chat(&command);
|
||||
}
|
||||
}
|
||||
ClickEvent::CopyToClipboard(value) => {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&value);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,60 @@
|
||||
//! shovels) put on a lit campfire puts it out.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, animate_tick::make_campfire_smoke, items::ItemStack};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::chunk::BlockState;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The lit campfire at `pos` that `held` puts out, when the item is in
|
||||
/// `#minecraft:douses_campfires` (`CampfireBlock.useItemOn`).
|
||||
fn campfire_to_douse(&self, pos: [i32; 3], held: Option<&ItemStack>) -> Option<BlockState> {
|
||||
held.filter(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:douses_campfires")
|
||||
.is_some_and(|members| members.contains(&*s.item))
|
||||
})?;
|
||||
self.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
|
||||
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// `CampfireBlock.useItemOn` as this client runs it before the server's
|
||||
/// answer (`MultiPlayerGameMode.useItemOn`): the twenty smoke puffs of
|
||||
/// `CampfireBlock.douse` and the campfire gone out, kept as predicted
|
||||
/// under `seq`. The level event's sound comes from the server. True when
|
||||
/// it doused (SUCCESS), which ends the click for both hands.
|
||||
pub(super) fn predict_campfire_douse(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
held: Option<&ItemStack>,
|
||||
seq: i32,
|
||||
) -> bool {
|
||||
// Player.isSecondaryUseActive: a held item means both hands count.
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) {
|
||||
return false;
|
||||
}
|
||||
let Some(mut state) = self.campfire_to_douse(pos, held) else {
|
||||
return false;
|
||||
};
|
||||
let signal = state.properties.get("signal_fire").map(String::as_str) == Some("true");
|
||||
for _ in 0..20 {
|
||||
make_campfire_smoke(&mut self.particles, pos, signal, true);
|
||||
}
|
||||
state.properties.insert("lit".into(), "false".into());
|
||||
if let Ok(unlit) = self.pack.assets.registry.resolve(&state) {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.predict_blocks(&vec![(at, unlit)], seq);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `CampfireBlock.useItemOn` with `held` in the hand that acts, the block
|
||||
/// at `pos` clicked: true when it doused a lit campfire (SUCCESS).
|
||||
pub(super) fn use_campfire_douse(
|
||||
@@ -11,24 +63,11 @@ impl Game<'_> {
|
||||
pos: [i32; 3],
|
||||
held: Option<&ItemStack>,
|
||||
) -> Result<bool> {
|
||||
let Some(tool) = held.filter(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:douses_campfires")
|
||||
.is_some_and(|members| members.contains(&*s.item))
|
||||
}) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some(mut state) = registry
|
||||
.state(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
|
||||
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
|
||||
.cloned()
|
||||
let (Some(used), Some(mut state)) = (held.cloned(), self.campfire_to_douse(pos, held))
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
let registry = self.pack.assets.registry;
|
||||
// Level event 1009 (SOUND_EXTINGUISH_FIRE) goes to every player.
|
||||
self.level_event_at(1009, pos, 0);
|
||||
// CampfireBlock.douse: the acting client's own smoke (twenty puffs),
|
||||
@@ -44,7 +83,6 @@ impl Game<'_> {
|
||||
if let Ok(unlit) = registry.resolve(&state) {
|
||||
self.level_set(pos, unlit)?;
|
||||
}
|
||||
let used = tool.clone();
|
||||
self.damage_held(1);
|
||||
// ServerPlayerGameMode.useItemOn: the action was consumed.
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, &used);
|
||||
|
||||
@@ -859,7 +859,7 @@ impl Game<'_> {
|
||||
/// first, as `ClientPacketListener.sendUnattendedCommand` lets client
|
||||
/// commands take it. (Vanilla asks first when the command fails to
|
||||
/// parse; here the server answers it.)
|
||||
fn click_command(&mut self, command: &str) {
|
||||
pub(super) fn click_command(&mut self, command: &str) {
|
||||
let command = command.strip_prefix('/').unwrap_or(command);
|
||||
if self.mod_command(command) {
|
||||
return;
|
||||
|
||||
@@ -227,7 +227,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// Whether the level in place has a running task, which the server pumps
|
||||
/// between its ticks (`pump_pregen`).
|
||||
/// between its ticks (`pump_chunks`).
|
||||
pub(in crate::game) fn pregen_runs_here(&self) -> bool {
|
||||
self.cmds
|
||||
.pregen
|
||||
@@ -243,11 +243,19 @@ impl Game<'_> {
|
||||
/// Keep the workers fed until `until`: collect what they finished and
|
||||
/// hand them the next jobs as soon as each one is done, rather than once
|
||||
/// a tick.
|
||||
pub(in crate::game) fn pump_pregen(&mut self, until: Instant) {
|
||||
pub(in crate::game) fn pump_chunks(&mut self, until: Instant, paused: bool) {
|
||||
// Force-loaded chunks too: decorating in vanilla's order, a chunk
|
||||
// waits for its neighbours far more often than once a tick allows.
|
||||
// And the players' chunks while they load, as vanilla's server runs
|
||||
// its chunk tasks while it waits for the next tick
|
||||
// (`MinecraftServer.waitUntilNextTick`): once a tick, a few jobs per
|
||||
// worker (`schedule_generation`) capped joining a world at about 120
|
||||
// columns a second with two workers, the workers idle most of the
|
||||
// time. Not while a respawn search waits for a chunk: its area is
|
||||
// only asked for by the tick's own `update_world`.
|
||||
let players = !paused && self.spawn_wait.is_empty();
|
||||
while self.pregen_runs_here()
|
||||
|| (!self.streaming.forced.is_empty() && self.streaming.busy())
|
||||
|| ((players || !self.streaming.forced.is_empty()) && self.streaming.busy())
|
||||
{
|
||||
let now = Instant::now();
|
||||
if now >= until {
|
||||
|
||||
@@ -46,6 +46,10 @@ pub(super) struct Creatures {
|
||||
pub projectiles: Vec<Projectile>,
|
||||
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
|
||||
pub wind_bursts: Vec<super::wind::Burst>,
|
||||
/// The players a fishing hook can catch, taken as the hooks tick, and
|
||||
/// the pulls on them waiting for the hub (game/fishing.rs).
|
||||
pub hook_targets: Vec<super::fishing::HookTarget>,
|
||||
pub hook_pulls: Vec<(u32, horizon_core::geometry::Vec3)>,
|
||||
/// Mace smash knockback on the other players from a guest's turn, waiting
|
||||
/// for it to end (game/mace.rs).
|
||||
pub smash_pushes: Vec<super::mace::SmashPush>,
|
||||
@@ -226,6 +230,8 @@ impl Game<'_> {
|
||||
uuid: self.level.player_uuid.unwrap_or_default(),
|
||||
allies: std::sync::Arc::new(self.player_allies()),
|
||||
remote: false,
|
||||
sleeping: self.sleeping.is_some(),
|
||||
sleep_timer: self.sleep_timer,
|
||||
passenger: self.riding.is_some(),
|
||||
})
|
||||
}
|
||||
@@ -1356,6 +1362,22 @@ impl Game<'_> {
|
||||
[0.0, 0.2, 0.0],
|
||||
10,
|
||||
),
|
||||
MobEvent::DropGift { table, at } => {
|
||||
let stacks =
|
||||
self.res
|
||||
.items()
|
||||
.loot_table(table, false, None, &mut self.world.random);
|
||||
for stack in stacks {
|
||||
// The ItemEntity constructor's random motion.
|
||||
let r = &mut self.world.random;
|
||||
let v = [
|
||||
r.next_double() * 0.2 - 0.1,
|
||||
0.2,
|
||||
r.next_double() * 0.2 - 0.1,
|
||||
];
|
||||
self.spawn_item(stack, at, v, 10);
|
||||
}
|
||||
}
|
||||
// `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop
|
||||
// above trampled farmland breaks and drops.
|
||||
MobEvent::SetBlock { pos, state } => self.level_set(pos, state)?,
|
||||
@@ -2644,10 +2666,25 @@ impl Game<'_> {
|
||||
let hit = self.with_level(|b, level, ticks| {
|
||||
b.target_projectile_hit(level, ticks, block, location, face, arrow)
|
||||
});
|
||||
if hit.is_some() {
|
||||
if let Some(strength) = hit {
|
||||
let _ = self.process_level_events();
|
||||
if p.owner == Some(Who::Player) {
|
||||
if p.owner == Some(Who::Player) && (p.guest.is_none() || p.guest == self.lan_acting) {
|
||||
self.stats.custom("target_hit", 1);
|
||||
let kind = if p
|
||||
.arrow
|
||||
.as_ref()
|
||||
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow")
|
||||
{
|
||||
"minecraft:spectral_arrow"
|
||||
} else {
|
||||
p.kind.id()
|
||||
};
|
||||
let projectile = horizon_client::advancements::EntityView {
|
||||
kind: kind.to_owned(),
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
..Default::default()
|
||||
};
|
||||
self.trigger_target_hit(&projectile, strength);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3356,6 +3393,7 @@ impl Game<'_> {
|
||||
.item_tags
|
||||
.get("minecraft:parrot_poisonous_food")
|
||||
.is_some_and(|t| t.contains(&*item));
|
||||
let player_at = self.eye_position();
|
||||
let acting = self.acting_player_uuid();
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft)
|
||||
|| self.keys.contains(&winit::keyboard::KeyCode::ShiftRight);
|
||||
@@ -3635,7 +3673,12 @@ impl Game<'_> {
|
||||
}
|
||||
// Parrot.mobInteract: the owner makes a tame parrot on the ground
|
||||
// sit or stand (poisonous food is handled apart).
|
||||
Species::Parrot if m.extra.tame && m.on_ground && !parrot_poisonous => {
|
||||
Species::Parrot
|
||||
if m.extra.tame
|
||||
&& m.extra.owner == Some(acting)
|
||||
&& m.on_ground
|
||||
&& !parrot_poisonous =>
|
||||
{
|
||||
m.extra.sitting = !m.extra.sitting;
|
||||
self.world.modified.insert((
|
||||
m.position.x.floor() as i32 >> 4,
|
||||
@@ -3688,6 +3731,29 @@ impl Game<'_> {
|
||||
consume = true;
|
||||
}
|
||||
}
|
||||
// Parrot.mobInteract: #parrot_poisonous_food (a cookie) poisons the
|
||||
// parrot for 45 s and then hurts it with a player attack of
|
||||
// Float.MAX_VALUE, which an invulnerable one shrugs off.
|
||||
Species::Parrot
|
||||
if self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:parrot_poisonous_food")
|
||||
.is_some_and(|t| t.contains(&*item)) =>
|
||||
{
|
||||
consume = true;
|
||||
m.add_effect("minecraft:poison", 900, 0);
|
||||
let id = m.id;
|
||||
let hurt = m.damage(
|
||||
f32::MAX,
|
||||
horizon_client::mobs::Damage::player(player_at.unwrap_or(center)),
|
||||
);
|
||||
// The feeder gets the kill, as for a normal player attack.
|
||||
if hurt {
|
||||
self.last_hurt_by_player.insert(id, self.lan_acting);
|
||||
}
|
||||
}
|
||||
// Parrot.mobInteract: parrot food may tame a wild one (one time in
|
||||
// ten); a parrot never takes it as Animal food (isFood is false).
|
||||
Species::Parrot if !m.extra.tame && is_food => {
|
||||
|
||||
@@ -215,6 +215,106 @@ impl Game<'_> {
|
||||
}
|
||||
self.post_attack_bursts(&held, &kind);
|
||||
}
|
||||
/// `EnchantmentHelper.doPostPiercingAttackEffects`: the
|
||||
/// `minecraft:post_piercing_attack` effects of the weapon in hand after a
|
||||
/// jab (Lunge's dash).
|
||||
pub(super) fn post_piercing_attack(&mut self) {
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return;
|
||||
};
|
||||
let mut effects = Vec::new();
|
||||
for (id, level) in held.enchantments() {
|
||||
let Some(def) = self.res.stations().enchantments.get(id) else {
|
||||
continue;
|
||||
};
|
||||
for entry in def.effects["minecraft:post_piercing_attack"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
effects.push((*level, entry["effect"].clone()));
|
||||
}
|
||||
}
|
||||
// Lunge's requirements: not riding, gliding or in water, and a
|
||||
// player outside creative with at least 7 food.
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if effects.is_empty()
|
||||
|| self.riding.is_some()
|
||||
|| p.fall_flying
|
||||
|| p.fluids.was_touching_water
|
||||
|| !(self.creative || p.health.food.food >= 7)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (level, effect) in effects {
|
||||
self.apply_piercing_effect(&effect, level);
|
||||
}
|
||||
}
|
||||
/// One `post_piercing_attack` effect on the player: `AllOf`,
|
||||
/// `ChangeItemDamage`, `ApplyExhaustion`, `ApplyEntityImpulse` and
|
||||
/// `PlaySoundEffect`.
|
||||
fn apply_piercing_effect(&mut self, effect: &serde_json::Value, level: u32) {
|
||||
let amount = || level_value(&effect["amount"], level);
|
||||
match effect["type"].as_str() {
|
||||
Some("minecraft:all_of") => {
|
||||
for e in effect["effects"].as_array().into_iter().flatten() {
|
||||
self.apply_piercing_effect(e, level);
|
||||
}
|
||||
}
|
||||
Some("minecraft:change_item_damage") => self.damage_held(amount() as u32),
|
||||
Some("minecraft:apply_exhaustion") => {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.cause_food_exhaustion(amount());
|
||||
}
|
||||
}
|
||||
// The direction turned by the look (`Entity.getLookQuaternion`:
|
||||
// pitch about x, then yaw about y), scaled by coordinate_scale
|
||||
// and the magnitude, added to the velocity (the guest's client
|
||||
// is told of it by `guest_hit_by`).
|
||||
Some("minecraft:apply_impulse") => {
|
||||
let vec3 = |key: &str| [0, 1, 2].map(|i| effect[key][i].as_f64().unwrap_or(0.0));
|
||||
let (d, scale) = (vec3("direction"), vec3("coordinate_scale"));
|
||||
let magnitude = f64::from(level_value(&effect["magnitude"], level));
|
||||
let (yaw, pitch) = (
|
||||
f64::from(self.yaw).to_radians(),
|
||||
f64::from(self.pitch).to_radians(),
|
||||
);
|
||||
let (y1, z1) = (
|
||||
d[1] * pitch.cos() - d[2] * pitch.sin(),
|
||||
d[1] * pitch.sin() + d[2] * pitch.cos(),
|
||||
);
|
||||
let impulse = horizon_core::geometry::Vec3::new(
|
||||
(d[0] * yaw.cos() - z1 * yaw.sin()) * scale[0] * magnitude,
|
||||
y1 * scale[1] * magnitude,
|
||||
(d[0] * yaw.sin() + z1 * yaw.cos()) * scale[2] * magnitude,
|
||||
);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.velocity = p.velocity.add_vector(impulse);
|
||||
}
|
||||
}
|
||||
// The sound for the level (a list picks by level), heard by all.
|
||||
Some("minecraft:play_sound") => {
|
||||
let sounds: Vec<&str> = match &effect["sound"] {
|
||||
serde_json::Value::Array(list) => {
|
||||
list.iter().filter_map(|s| s.as_str()).collect()
|
||||
}
|
||||
one => one.as_str().into_iter().collect(),
|
||||
};
|
||||
let index = (level as usize).saturating_sub(1);
|
||||
if let Some(sound) = sounds.get(index.min(sounds.len().saturating_sub(1))) {
|
||||
let float = |key: &str| effect[key].as_f64().unwrap_or(1.0) as f32;
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
self.audio.play(sound, at, float("volume"), float("pitch"));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `ApplyMobEffect.apply`: one of `to_apply` (a single id or a list) for a
|
||||
/// random duration and amplifier between the level-based bounds.
|
||||
fn apply_mob_effect(&mut self, mob: usize, effect: &serde_json::Value, level: u32) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Fishing: `FishingRodItem.use` (cast / reel in), the `FishingHook` (flight,
|
||||
//! bobbing, lure and bite timers, open water, hooking mobs, retrieving the
|
||||
//! `gameplay/fishing` loot) and `FishingHookRenderer` (bobber and line).
|
||||
use super::{Game, view_vector};
|
||||
use super::{Game, lan::LanHost, lan_mobs::guest_bounds, view_vector};
|
||||
use horizon_client::advancements::EntityView;
|
||||
use horizon_client::lan::{entity::States, hub::Hub};
|
||||
use horizon_client::{
|
||||
@@ -47,6 +47,13 @@ pub(super) struct Hook {
|
||||
luck: i32,
|
||||
lure: i32,
|
||||
}
|
||||
/// A player a hook can catch (`canHitEntity`): their entity id, which
|
||||
/// guest they are (none: the host) and their box.
|
||||
pub(super) struct HookTarget {
|
||||
id: u32,
|
||||
conn: Option<u32>,
|
||||
bounds: Aabb,
|
||||
}
|
||||
const SIZE: f64 = 0.25;
|
||||
fn bounds(p: Vec3) -> Aabb {
|
||||
let h = SIZE / 2.0;
|
||||
@@ -168,6 +175,11 @@ impl Game<'_> {
|
||||
} else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) {
|
||||
item.velocity = pulled(item.velocity);
|
||||
hooked_item = true;
|
||||
} else if self.creatures.hook_targets.iter().any(|t| t.id == id) {
|
||||
// A player: the hub moves them when the tick ends.
|
||||
self.creatures
|
||||
.hook_pulls
|
||||
.push((id, Vec3::new(d.x * 0.1, d.y * 0.1, d.z * 0.1)));
|
||||
}
|
||||
// CriteriaTriggers.FISHING_ROD_HOOKED with no items.
|
||||
let hooked = self
|
||||
@@ -476,6 +488,15 @@ impl Game<'_> {
|
||||
if !self.holds_rod() || d2 > 1024.0 {
|
||||
return;
|
||||
}
|
||||
// The players it can catch, as they stand now (the host's own hook;
|
||||
// guests' hooks use the ones taken as their turns start).
|
||||
if self.lan_acting.is_none()
|
||||
&& matches!(h.state, State::Flying | State::Hooked(_))
|
||||
&& let Some(host) = self.lan.take()
|
||||
{
|
||||
self.refresh_hook_targets(&host);
|
||||
self.lan = Some(host);
|
||||
}
|
||||
h.previous = h.position;
|
||||
if h.on_ground {
|
||||
h.life += 1;
|
||||
@@ -501,8 +522,8 @@ impl Game<'_> {
|
||||
self.hook = Some(h);
|
||||
return;
|
||||
}
|
||||
// checkCollision: hook the first living mob it touches, or (via
|
||||
// `canHitEntity`) a dropped item.
|
||||
// checkCollision: hook the first living mob or other player it
|
||||
// touches, or (via `canHitEntity`) a dropped item.
|
||||
let bb = bounds(h.position);
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
let touches = |b: Aabb| {
|
||||
@@ -514,6 +535,14 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|m| m.alive() && touches(m.bounds()))
|
||||
.map(|m| m.id)
|
||||
.or_else(|| {
|
||||
let owner = self.lan_acting;
|
||||
self.creatures
|
||||
.hook_targets
|
||||
.iter()
|
||||
.find(|t| t.conn != owner && touches(t.bounds))
|
||||
.map(|t| t.id)
|
||||
})
|
||||
.or_else(|| {
|
||||
self.dropped
|
||||
.iter()
|
||||
@@ -528,12 +557,25 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
State::Hooked(id) => {
|
||||
// updateHookedEntity: ride the hooked mob or item at 0.8 of its height.
|
||||
// updateHookedEntity: ride the hooked mob, player or item at 0.8 of its height.
|
||||
let hooked = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && m.alive())
|
||||
.map(|m| (m.position, f64::from(m.height())))
|
||||
.or_else(|| {
|
||||
self.creatures
|
||||
.hook_targets
|
||||
.iter()
|
||||
.find(|t| t.id == id)
|
||||
.map(|t| {
|
||||
let (lo, hi) = (t.bounds.min_position(), t.bounds.max_position());
|
||||
(
|
||||
Vec3::new((lo.x + hi.x) / 2.0, lo.y, (lo.z + hi.z) / 2.0),
|
||||
hi.y - lo.y,
|
||||
)
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
self.dropped
|
||||
.iter()
|
||||
@@ -602,6 +644,59 @@ impl Game<'_> {
|
||||
);
|
||||
self.hook = Some(h);
|
||||
}
|
||||
/// The players a hook can catch: everyone but spectators and the dead
|
||||
/// (`Projectile.canHitEntity`), the host and the guests in the level.
|
||||
pub(super) fn refresh_hook_targets(&mut self, host: &LanHost) {
|
||||
let mut targets = Vec::new();
|
||||
if self.options.dedicated.is_none()
|
||||
&& !self.spectator()
|
||||
&& let Some(p) = self.player.as_ref()
|
||||
&& !p.health.dead_or_dying()
|
||||
{
|
||||
targets.push(HookTarget {
|
||||
id: host.host_entity as u32,
|
||||
conn: None,
|
||||
bounds: p.bounds(),
|
||||
});
|
||||
}
|
||||
for g in &host.hub.guests {
|
||||
if host.hub.here(g) && g.loaded && !g.dead && !g.spectator() {
|
||||
targets.push(HookTarget {
|
||||
id: g.entity_id as u32,
|
||||
conn: Some(g.conn),
|
||||
bounds: guest_bounds(g),
|
||||
});
|
||||
}
|
||||
}
|
||||
self.creatures.hook_targets = targets;
|
||||
}
|
||||
/// `FishingHook.pullEntity` on the players the hooks reeled in this tick
|
||||
/// pulled: their velocity moves, and a guest's reaches their client.
|
||||
pub(super) fn flush_hook_pulls(&mut self) {
|
||||
let pulls = std::mem::take(&mut self.creatures.hook_pulls);
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
for (id, delta) in pulls {
|
||||
let push = |g: &mut Self| {
|
||||
if let Some(p) = g.player.as_mut() {
|
||||
p.velocity = p.velocity.add_vector(delta);
|
||||
}
|
||||
};
|
||||
if id == host.host_entity as u32 {
|
||||
push(self);
|
||||
} else if let Some(conn) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.entity_id as u32 == id)
|
||||
.map(|g| g.conn)
|
||||
{
|
||||
self.guest_hit_by(&mut host, conn, push);
|
||||
}
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
/// `FishingRodCast`: the stack in the arm holding the rod
|
||||
/// (`FishingHookRenderer.getHoldingArm`) while the hook is out.
|
||||
pub(super) fn cast_rod(&self) -> Option<&ItemStack> {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! Field of view (`Camera.tickFov`, `calculateFov`,
|
||||
//! `AbstractClientPlayer.getFieldOfViewModifier`), view and hurt bobbing
|
||||
//! (`GameRenderer.bobHurt`, `bobView`) and the spyglass (`SpyglassItem`,
|
||||
//! (`GameRenderer.bobHurt`, `bobView`), the portal and Nausea spin
|
||||
//! (`GameRenderer.renderLevel`) and the spyglass (`SpyglassItem`,
|
||||
//! `Hud.extractSpyglassOverlay`). The arithmetic is `horizon_client::camera`.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
camera::{
|
||||
BobState, CameraFluid, FovPlayer, HurtBob, UseItem, bob_matrix, field_of_view_modifier,
|
||||
spinning_effect_matrix,
|
||||
},
|
||||
fog::{FogType, fog_type},
|
||||
hud::HudBatch,
|
||||
@@ -26,6 +28,28 @@ impl Game<'_> {
|
||||
pub(super) fn fov_effect_scale(&self) -> f32 {
|
||||
self.settings.file.double("fovEffectScale") as f32
|
||||
}
|
||||
/// `options.screenEffectScale` (Distortion Effects).
|
||||
pub(super) fn screen_effect_scale(&self) -> f32 {
|
||||
self.settings.file.double("screenEffectScale") as f32
|
||||
}
|
||||
/// `LocalPlayer.getEffectBlendFactor(NAUSEA, partial)`: 0 without the effect.
|
||||
pub(super) fn nausea_blend(&self, partial: f32) -> f32 {
|
||||
if self.potions.effects.get("nausea").is_none() {
|
||||
return 0.0;
|
||||
}
|
||||
let fx = &self.screen_fx;
|
||||
fx.o_nausea + partial * (fx.nausea - fx.o_nausea)
|
||||
}
|
||||
/// The spin `GameRenderer.renderLevel` multiplies into the level's
|
||||
/// projection: the larger of the portal and Nausea intensities, scaled by
|
||||
/// Distortion Effects squared, turning by `LocalPlayer.getSpinningEffectAngle`.
|
||||
pub(super) fn spinning_matrix(&self, partial: f32) -> glam::Mat4 {
|
||||
let fx = &self.screen_fx;
|
||||
let portal = fx.o_portal + partial * (fx.portal - fx.o_portal);
|
||||
let scale = self.screen_effect_scale();
|
||||
let intensity = portal.max(self.nausea_blend(partial)) * (scale * scale);
|
||||
spinning_effect_matrix(intensity, fx.spinning_time + partial * fx.spinning_speed)
|
||||
}
|
||||
/// What `AbstractClientPlayer.getFieldOfViewModifier` reads.
|
||||
fn fov_player(&self) -> Option<FovPlayer> {
|
||||
let p = self.player.as_ref()?;
|
||||
|
||||
@@ -429,7 +429,9 @@ impl Game<'_> {
|
||||
if let Some(id) = map {
|
||||
// ItemFrameRenderer: the map fills the face, in 90-degree steps.
|
||||
let depth = if f.invisible { 0.5 } else { 0.4375 };
|
||||
let item_light = if f.glow { 240 | 240 << 8 } else { packed };
|
||||
// A glow frame's map is 15728850 = 0xF000D2 (sky coordinate 240,
|
||||
// block coordinate 210), not full bright.
|
||||
let item_light = if f.glow { 240 | 210 << 8 } else { packed };
|
||||
let m = pose
|
||||
* glam::DMat4::from_translation(glam::DVec3::new(0.0, 0.0, depth))
|
||||
* glam::DMat4::from_rotation_z(
|
||||
|
||||
@@ -403,6 +403,12 @@ struct GameHands<'g, 'a> {
|
||||
error: Option<Box<dyn std::error::Error>>,
|
||||
}
|
||||
impl GameHands<'_, '_> {
|
||||
/// The main-hand item's `kinetic_weapon` and `piercing_weapon`, a spear's.
|
||||
fn attack_kinetic(&self) -> Option<horizon_client::spear::Kinetic> {
|
||||
self.game
|
||||
.held()
|
||||
.and_then(|s| horizon_client::spear::Kinetic::of(&s.item))
|
||||
}
|
||||
fn keep(&mut self, r: Result<()>) {
|
||||
if let Err(e) = r
|
||||
&& self.error.is_none()
|
||||
@@ -470,18 +476,27 @@ impl HandWorld for GameHands<'_, '_> {
|
||||
self.game.creative
|
||||
}
|
||||
fn cannot_attack(&mut self) -> bool {
|
||||
false
|
||||
let ticker = self.game.hand_strength.ticker;
|
||||
self.game.cannot_attack_with_item(ticker, 0)
|
||||
}
|
||||
fn piercing_weapon(&mut self) -> bool {
|
||||
false
|
||||
self.attack_kinetic().is_some()
|
||||
}
|
||||
fn attack_animation(&mut self) -> SwingAnimation {
|
||||
SwingAnimation::DEFAULT
|
||||
self.attack_kinetic()
|
||||
.map_or(SwingAnimation::DEFAULT, |k| k.attack_animation())
|
||||
}
|
||||
fn interact_animation(&mut self, _: Hand) -> SwingAnimation {
|
||||
SwingAnimation::DEFAULT
|
||||
}
|
||||
fn piercing_attack(&mut self, _: SwingAnimation) {}
|
||||
fn piercing_attack(&mut self, _: SwingAnimation) {
|
||||
// The server's part (`PiercingWeapon.attack`), then the client's:
|
||||
// the sound `MultiPlayerGameMode.piercingAttack` plays.
|
||||
if let Some(sound) = self.game.jab() {
|
||||
self.game.play_at_feet(sound);
|
||||
}
|
||||
self.game.hand_strength.reset_attack();
|
||||
}
|
||||
fn attack_entity(&mut self) -> bool {
|
||||
self.game.attack()
|
||||
}
|
||||
|
||||
@@ -82,11 +82,11 @@ fn civil(days: i64) -> (i64, u32, u32) {
|
||||
(yoe + era * 400 + i64::from(m <= 2), m, d)
|
||||
}
|
||||
/// `DateTimeFormatter.ofPattern(pattern).format(now)` for the month, day,
|
||||
/// year and time letters item models use (UTC: no time zone database).
|
||||
/// year and time letters item models use, in the system time zone
|
||||
/// (`LocalTime` without a `time_zone`).
|
||||
pub(super) fn local_time(pattern: &str) -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as i64);
|
||||
let now = chrono::Local::now();
|
||||
let secs = now.timestamp() + i64::from(now.offset().local_minus_utc());
|
||||
let (y, mo, d) = civil(secs.div_euclid(86_400));
|
||||
let s = secs.rem_euclid(86_400);
|
||||
let (h, mi, se) = (s / 3600, s / 60 % 60, s % 60);
|
||||
@@ -165,13 +165,16 @@ pub(super) fn owner_at(position: [f64; 3], yaw: f32, entity_type: &str) -> Owner
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
/// `UseDuration` of a stack: 72000 for drawn items, the consume time for food.
|
||||
/// `UseDuration` of a stack (`Item.getUseDuration`): 72000 for drawn items
|
||||
/// and for `blocks_attacks` items (shields), the consume time for food.
|
||||
fn use_duration(components: &Components) -> i32 {
|
||||
if let Some(c) = components.get("minecraft:consumable") {
|
||||
let seconds = c["consume_seconds"].as_f64().unwrap_or(1.6);
|
||||
return (seconds * 20.0) as i32;
|
||||
}
|
||||
if components.get("minecraft:kinetic_weapon").is_some() {
|
||||
if components.get("minecraft:blocks_attacks").is_some()
|
||||
|| components.get("minecraft:kinetic_weapon").is_some()
|
||||
{
|
||||
return 72_000;
|
||||
}
|
||||
let item_model = components
|
||||
|
||||
@@ -14,7 +14,7 @@ use horizon_client::{
|
||||
hub::{BodyFlags, HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
playerdata,
|
||||
},
|
||||
mobs::{Mob, Who, leash::Holder},
|
||||
mobs::{Mob, Species, Who, leash::Holder},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
@@ -168,6 +168,7 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
|
||||
| "minecraft:zombie_villager"
|
||||
| "minecraft:zombified_piglin"
|
||||
| "minecraft:hoglin"
|
||||
| "minecraft:zoglin"
|
||||
| "minecraft:goat"
|
||||
| "minecraft:ocelot"
|
||||
| "minecraft:turtle"
|
||||
@@ -324,9 +325,13 @@ fn mob_meta(
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
let kind = m.kind.id.as_str();
|
||||
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, glowing 0x40
|
||||
// (LivingEntity.updateGlowingStatus).
|
||||
let shared = i8::from(m.fire_ticks > 0) | i8::from(m.is_currently_glowing()) << 6;
|
||||
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, swimming 0x10 (a drowned
|
||||
// under water, `Drowned.setSwimming`), invisible 0x20 (the effect's flag)
|
||||
// and glowing 0x40 (LivingEntity.updateGlowingStatus).
|
||||
let shared = i8::from(m.fire_ticks > 0)
|
||||
| i8::from(m.species() == Species::Drowned && m.extra.flag) << 4
|
||||
| i8::from(m.invisible || horizon_client::mobs::trader::is_invisible(m)) << 5
|
||||
| i8::from(m.is_currently_glowing()) << 6;
|
||||
if shared != 0 {
|
||||
meta.push((0, serializer::BYTE, Value::Byte(shared)));
|
||||
}
|
||||
@@ -336,6 +341,12 @@ fn mob_meta(
|
||||
if pose != 0 {
|
||||
meta.push((6, serializer::POSE, Value::Id(i32::from(pose))));
|
||||
}
|
||||
// LivingEntity.DATA_SLEEPING_POS_ID: the bed a sleeping villager lies in.
|
||||
if horizon_client::mobs::villager::sleeping(m)
|
||||
&& let Some(bed) = m.extra.bed
|
||||
{
|
||||
meta.push((14, serializer::OPTIONAL_BLOCK_POS, Value::OptPos(Some(bed))));
|
||||
}
|
||||
if let Some(name) = &m.custom_name {
|
||||
meta.push((
|
||||
2,
|
||||
@@ -430,8 +441,14 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((17, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
// Piglin.DATA_IS_DANCING (after the baby flag and the crossbow charge).
|
||||
// AbstractPiglin.DATA_IMMUNE_TO_ZOMBIFICATION (16: with the dimension it
|
||||
// makes it shake) and Piglin.DATA_IS_DANCING (after the baby flag and
|
||||
// the crossbow charge).
|
||||
"minecraft:piglin" => {
|
||||
if e.flag2 {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
if e.scratch[0] > 0 {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
@@ -485,6 +502,15 @@ fn mob_meta(
|
||||
if let Some(v) = id(registry, name) {
|
||||
meta.push((index, serializer, Value::Id(v)));
|
||||
}
|
||||
// Cat.IS_LYING and RELAX_STATE_ONE.
|
||||
if kind == "minecraft:cat" {
|
||||
for (index, on) in [(21, e.flag), (22, e.relax_one)] {
|
||||
if on {
|
||||
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((index, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
@@ -509,10 +535,16 @@ fn mob_meta(
|
||||
| "minecraft:skeleton_horse"
|
||||
| "minecraft:zombie_horse"
|
||||
| "minecraft:camel" => {
|
||||
// AbstractHorse.DATA_ID_FLAGS: tame 2.
|
||||
if e.tame {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(2)));
|
||||
// AbstractHorse.DATA_ID_FLAGS: tame 2, eating 16 (grazing), standing
|
||||
// 32 (rearing).
|
||||
let grazes = horizon_client::mobs::equine::grazes(m.species());
|
||||
let flags = if e.tame { 2 } else { 0 }
|
||||
| if grazes && e.flag { 16 } else { 0 }
|
||||
| if grazes && e.counter > 0 { 32 } else { 0 };
|
||||
if flags != 0 {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
defaults.push((18, serializer::BYTE, Value::Byte(0)));
|
||||
if kind == "minecraft:horse" {
|
||||
let v = i32::from(e.variant) | i32::from(e.variant2) << 8;
|
||||
if v != 0 {
|
||||
@@ -545,6 +577,15 @@ fn mob_meta(
|
||||
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after Zombie's three).
|
||||
"minecraft:villager" | "minecraft:zombie_villager" => {
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
if kind == "minecraft:villager" {
|
||||
unhappy_counter(m, meta, defaults);
|
||||
} else {
|
||||
// ZombieVillager.DATA_CONVERTING_ID: the cure's shaking.
|
||||
if horizon_client::mobs::zombie_villager::is_converting(m) {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
let villager_type = VILLAGER_TYPES[e.variant as usize % VILLAGER_TYPES.len()];
|
||||
let profession = PROFESSIONS[e.variant2 as usize % PROFESSIONS.len()];
|
||||
if let (Some(t), Some(p)) = (
|
||||
@@ -752,6 +793,60 @@ fn mob_meta(
|
||||
meta.push((17, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
|
||||
defaults.push((16, serializer::OPTIONAL_BLOCK_STATE, Value::Id(0)));
|
||||
}
|
||||
// Zombie.DATA_DROWNED_CONVERSION_ID (after DATA_BABY_ID and
|
||||
// DATA_SPECIAL_TYPE_ID): the shaking of the conversion under water.
|
||||
"minecraft:zombie" | "minecraft:husk" => {
|
||||
if e.counter2 > 0 {
|
||||
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two).
|
||||
"minecraft:wandering_trader" => unhappy_counter(m, meta, defaults),
|
||||
// Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and
|
||||
// AbstractPiglin's, the first entry of its subclasses (the piglin's is
|
||||
// in its own arm): with the dimension it makes them shake.
|
||||
"minecraft:hoglin" | "minecraft:piglin_brute" => {
|
||||
let index = if kind == "minecraft:hoglin" { 18 } else { 16 };
|
||||
if e.flag2 {
|
||||
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((index, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// Blaze.DATA_FLAGS_ID: charged 1, which makes it burn.
|
||||
"minecraft:blaze" => {
|
||||
if e.flag {
|
||||
meta.push((16, serializer::BYTE, Value::Byte(1)));
|
||||
}
|
||||
defaults.push((16, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// Turtle.HAS_EGG and LAYING_EGG (after AgeableMob's two).
|
||||
"minecraft:turtle" => {
|
||||
if e.flag {
|
||||
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
if e.flag2 {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
|
||||
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// WitherBoss.DATA_TARGET_B and DATA_TARGET_C (after DATA_TARGET_A, the
|
||||
// main target): the entities the side heads look at, by id.
|
||||
"minecraft:wither" => {
|
||||
for (k, index) in [(0, 17), (1, 18)] {
|
||||
let target = match e.ints[k] {
|
||||
0 => None,
|
||||
-1 => player_entity,
|
||||
id => Some(id),
|
||||
};
|
||||
if let Some(target) = target {
|
||||
meta.push((index, serializer::INT, Value::Int(target)));
|
||||
}
|
||||
defaults.push((index, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -769,6 +864,19 @@ fn mob_meta(
|
||||
}
|
||||
}
|
||||
|
||||
/// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two): the head
|
||||
/// shake of a villager saying no counts it down from 40.
|
||||
fn unhappy_counter(
|
||||
m: &Mob,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
if m.extra.ints[0] > 0 {
|
||||
meta.push((18, serializer::INT, Value::Int(m.extra.ints[0])));
|
||||
}
|
||||
defaults.push((18, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
|
||||
/// What a client shakes a mob by: Entity.DATA_TICKS_FROZEN (7), whose full
|
||||
/// count is `isFullyFrozen`, and Strider.DATA_SUFFOCATING (19, after
|
||||
/// AgeableMob's two and DATA_BOOST_TIME).
|
||||
@@ -1603,6 +1711,8 @@ impl Game<'_> {
|
||||
let _ = self.flush_wind_bursts();
|
||||
// A sweep attack's reach to the other players from a guest's attack.
|
||||
self.flush_sweeps();
|
||||
// A reeled-in hook pulling a player.
|
||||
self.flush_hook_pulls();
|
||||
// Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes).
|
||||
self.flush_lan_particles();
|
||||
}
|
||||
@@ -1743,6 +1853,11 @@ impl Game<'_> {
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_entity_events.push((id, event, at));
|
||||
}
|
||||
// Event 21: a window that simulates the world itself is its own
|
||||
// client, and hears the guardian's hum too.
|
||||
if event == 21 && self.options.execution_role.is_client() {
|
||||
self.start_guardian_sound(id as u32);
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.crit` (or `magicCrit` when `magic`): this window's
|
||||
/// emitter, and an animation for the attacker's client and observers once
|
||||
@@ -2019,6 +2134,8 @@ impl Game<'_> {
|
||||
.get(g.selected)
|
||||
.and_then(|s| s.as_ref())
|
||||
.is_some_and(|s| s.data().is_some_and(|d| !d.charged.is_empty()));
|
||||
// ClientInformation.mainHand (HumanoidArm: 0 left, 1 right).
|
||||
m.extra.main_hand_left = g.info.main_hand == 0;
|
||||
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
|
||||
let visible = || g.effects.list.iter().filter(|e| e.visible);
|
||||
m.extra.effect_particles = visible()
|
||||
@@ -2043,6 +2160,18 @@ impl Game<'_> {
|
||||
let using = host.play.get(&g.conn).and_then(|p| p.using());
|
||||
m.extra.using_item = using.map_or(0, |(_, ticks)| ticks as i32 + 1);
|
||||
m.extra.using_off_hand = using.is_some_and(|(slot, _)| slot == OFFHAND_SLOT);
|
||||
// AvatarRenderer.getArmPose: the used item's BLOCK animation (an
|
||||
// item with `blocks_attacks`) raises the arm.
|
||||
m.extra.blocking = using
|
||||
.and_then(|(slot, _)| {
|
||||
if slot == OFFHAND_SLOT {
|
||||
m.extra.off_hand.as_ref()
|
||||
} else {
|
||||
m.extra.main_hand.as_ref()
|
||||
}
|
||||
})
|
||||
.and_then(|i| self.res.items().get(i))
|
||||
.is_some_and(|i| i.blocks_attacks.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
//! 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, respawn_anchor::RespawnConfig};
|
||||
use super::{
|
||||
Dim, Game, GuestUse, Hit, Mining, Screen, lan::LanHost, respawn_anchor::RespawnConfig,
|
||||
};
|
||||
use horizon_client::{
|
||||
command::vanilla,
|
||||
containers::BlockEntity,
|
||||
@@ -1133,6 +1135,8 @@ impl Game<'_> {
|
||||
g.player_on_equip("mainhand", offhand.as_ref(), main.as_ref());
|
||||
});
|
||||
}
|
||||
// STAB: `PiercingWeapon.attack` of a left click with a spear.
|
||||
8 if !spectator => self.guest_jab(host, conn),
|
||||
_ => {}
|
||||
},
|
||||
S::SetCarriedItem(p) => {
|
||||
@@ -1437,6 +1441,9 @@ impl Game<'_> {
|
||||
}
|
||||
self.sync_rules();
|
||||
}
|
||||
// handleTeleportToEntityPacket: a spectator goes to the player,
|
||||
// in whichever level they are.
|
||||
S::TeleportToEntity(p) if spectator => self.guest_spectate_teleport(host, gi, p.uuid),
|
||||
S::ChangeGameMode(p) if host.hub.guests[gi].permission >= 2 => {
|
||||
if let Some(mode) = usize::try_from(p.mode)
|
||||
.ok()
|
||||
@@ -1564,6 +1571,51 @@ impl Game<'_> {
|
||||
/// and `Player.remove`'s `inventoryMenu.removed` drops what the cursor
|
||||
/// held (`dropOrPlaceInInventory` for a removed player). Respawning
|
||||
/// sooner removes it too.
|
||||
/// `ServerGamePacketListenerImpl.handleTeleportToEntityPacket` for a
|
||||
/// player: the host's own, or a guest in the world.
|
||||
fn guest_spectate_teleport(&mut self, host: &mut LanHost, gi: usize, uuid: u128) {
|
||||
let own = host.hub.guests[gi].conn;
|
||||
let host_player = self
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|_| self.options.dedicated.is_none() && uuid == host.host_uuid)
|
||||
.map(|p| {
|
||||
(
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
self.dim,
|
||||
)
|
||||
});
|
||||
let target = host_player.or_else(|| {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.placed && g.conn != own && g.uuid == uuid)
|
||||
.map(|g| (g.position, g.yaw, g.pitch, Dim::from_id(&g.dimension)))
|
||||
});
|
||||
let Some((at, yaw, pitch, dim)) = target else {
|
||||
return;
|
||||
};
|
||||
if Dim::from_id(&host.hub.guests[gi].dimension) != dim {
|
||||
// Only a dedicated server keeps its players in different levels.
|
||||
if self.options.dedicated.is_some() {
|
||||
self.level_teleports
|
||||
.push(super::server_travel::LevelTeleport {
|
||||
conn: own,
|
||||
dim,
|
||||
at,
|
||||
rotation: Some([yaw, pitch]),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
(g.yaw, g.pitch) = (yaw, pitch);
|
||||
// A new body there: no fall distance or motion carried over.
|
||||
g.motion = None;
|
||||
host.hub.teleport(gi, Some(at));
|
||||
}
|
||||
fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.death_time += 1;
|
||||
@@ -1676,6 +1728,37 @@ impl Game<'_> {
|
||||
Some((centre, eye))
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.handlePlayerAction` for STAB: the spear in
|
||||
/// the guest's main hand jabs once its charge allows it (with five ticks
|
||||
/// of tolerance). The guest's client is sent the dash Lunge gave them.
|
||||
fn guest_jab(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let at = g.position;
|
||||
// ServerPlayer.getKnownMovement: the move their client just sent.
|
||||
let movement = g
|
||||
.moves
|
||||
.last()
|
||||
.map_or(Vec3::ZERO, |m| Vec3::new(m.0[0], m.0[1], m.0[2]));
|
||||
let sound = self
|
||||
.guest_hit_by(host, conn, |g| {
|
||||
g.known_movement = movement;
|
||||
if g.cannot_attack_with_item(g.attack_ticks as i32, 5) {
|
||||
None
|
||||
} else {
|
||||
g.jab()
|
||||
}
|
||||
})
|
||||
.flatten();
|
||||
// PiercingWeapon.makeSound: `playSound(causer, ...)`, so the guest's
|
||||
// own client plays it.
|
||||
if let Some(name) = sound {
|
||||
let source = horizon_client::settings::sound_source(name) as i32;
|
||||
host.hub.sound(name, at, 1.0, 1.0, source, Some(conn));
|
||||
}
|
||||
}
|
||||
|
||||
fn guest_attack(&mut self, host: &mut LanHost, conn: u32, id: i32) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
@@ -2081,6 +2164,10 @@ impl Game<'_> {
|
||||
.filter(|g| host.hub.here(g) && g.loaded)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
// The players the guests' hooks can catch.
|
||||
if host.play.values().any(|p| p.hook.is_some()) {
|
||||
self.refresh_hook_targets(host);
|
||||
}
|
||||
for conn in conns {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
continue;
|
||||
@@ -2520,6 +2607,9 @@ impl Game<'_> {
|
||||
let _ = w.tick_environment_damage();
|
||||
let _ = w.apply_fire_contact();
|
||||
let _ = w.server_hazards_tick(in_wall);
|
||||
// LivingEntity.aiStep: a Riptide spin counts down on the server's
|
||||
// copy too, or it would stay in the spin pose (eye 0.4) for good.
|
||||
w.player.count_down_server_spin();
|
||||
// Player.tick ends with updatePlayerPose: a swimmer, or a player
|
||||
// crawling under a block, takes the short box its moves are checked with.
|
||||
let _ = w.update_player_pose();
|
||||
@@ -2619,8 +2709,15 @@ impl Game<'_> {
|
||||
// others (Player.playSound excludes the player, whose client
|
||||
// plays it), the entity event 53 everyone around.
|
||||
MotionEvent::HoneySlide {
|
||||
sound, particles, ..
|
||||
pos,
|
||||
sound,
|
||||
particles,
|
||||
} => {
|
||||
if self.world.game_time % 20 == 0 {
|
||||
self.as_guest(host, conn, |game| {
|
||||
game.trigger_honey_slide([pos.x, pos.y, pos.z])
|
||||
});
|
||||
}
|
||||
let Some((id, at)) = host.hub.guest(conn).map(|g| (g.entity_id, g.position))
|
||||
else {
|
||||
continue;
|
||||
@@ -3160,7 +3257,8 @@ impl Game<'_> {
|
||||
let packet = swing_packet(id, c.hand, c.animation);
|
||||
host.hub.broadcast_near(at, 128.0, &packet, Some(conn));
|
||||
if let Some(m) = host.models.get_mut(&conn) {
|
||||
m.swing();
|
||||
// The duration already counts the guest's haste or fatigue.
|
||||
m.swing_hand(c.hand == Hand::Off, c.duration_ticks);
|
||||
}
|
||||
}
|
||||
play.swing.tick();
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
furnace::FurnaceCooking,
|
||||
items::ItemStack,
|
||||
lan::{guest::Guest, menu::Slots},
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
|
||||
@@ -26,14 +26,28 @@ pub(super) fn player_for(
|
||||
guests: &GuestViews,
|
||||
bound: Option<u32>,
|
||||
) -> Option<u32> {
|
||||
// A tame cat reads its owner wherever they are while they sleep, or just
|
||||
// woke up (`CatRelaxOnOwnerGoal` uses `getOwner`, not the nearest player).
|
||||
if m.species() == Species::Cat
|
||||
&& m.extra.tame
|
||||
&& let Some(owner) = m.extra.owner
|
||||
{
|
||||
let asleep = |v: &PlayerView| v.uuid == owner && (v.sleeping || v.sleep_timer > 0);
|
||||
if host.is_some_and(asleep) {
|
||||
return None;
|
||||
}
|
||||
if let Some((conn, _)) = guests.iter().find(|(_, v)| asleep(v)) {
|
||||
return Some(*conn);
|
||||
}
|
||||
}
|
||||
let targeting = m.ai.target == Some(Who::Player)
|
||||
|| m.ai.last_hurt_by == Some(Who::Player) && m.age as i32 - m.ai.last_hurt_by_time <= 100;
|
||||
// A tame wolf or cat sees its owner (`TamableAnimal.getOwner`), while they
|
||||
// A tame wolf, cat or parrot sees its owner (`TamableAnimal.getOwner`), while they
|
||||
// are in play, whoever is nearer. Not while it targets or was just hurt by
|
||||
// a player: then `Who::Player` is that attacker, whoever it is, and the
|
||||
// owner goals ignore it until the wolf lets go.
|
||||
if !targeting
|
||||
&& matches!(m.species(), Species::Wolf | Species::Cat)
|
||||
&& matches!(m.species(), Species::Wolf | Species::Cat | Species::Parrot)
|
||||
&& m.extra.tame
|
||||
&& let Some(owner) = m.extra.owner
|
||||
{
|
||||
@@ -399,6 +413,8 @@ mod tests {
|
||||
uuid: [0; 4],
|
||||
allies: Default::default(),
|
||||
remote: false,
|
||||
sleeping: false,
|
||||
sleep_timer: 0,
|
||||
passenger: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,9 +408,9 @@ impl Game<'_> {
|
||||
}
|
||||
*next += self.clock.tick_duration();
|
||||
if *next > now {
|
||||
// A `/pregen` task or force-loaded chunks keep the workers fed
|
||||
// until the next tick.
|
||||
self.pump_pregen(*next);
|
||||
// The chunks loading (the players', force-loaded ones, a
|
||||
// `/pregen` task) keep the workers fed until the next tick.
|
||||
self.pump_chunks(*next, paused);
|
||||
let now = Instant::now();
|
||||
if *next > now {
|
||||
std::thread::sleep(*next - now);
|
||||
|
||||
@@ -202,7 +202,7 @@ impl Minecart {
|
||||
self.velocity.x.hypot(self.velocity.z)
|
||||
}
|
||||
/// `AbstractContainerMenu.getRedstoneSignalFromContainer` of `slots`.
|
||||
fn container_signal(slots: &[Option<ItemStack>], items: &Items) -> i32 {
|
||||
pub(super) fn container_signal(slots: &[Option<ItemStack>], items: &Items) -> i32 {
|
||||
let mut total = 0.0f32;
|
||||
for s in slots.iter().flatten() {
|
||||
let max = items.get(&s.item).map_or(64, |i| i.max_stack).min(64);
|
||||
@@ -1029,22 +1029,6 @@ impl Game<'_> {
|
||||
}
|
||||
best
|
||||
}
|
||||
/// Each cart's kind and comparator signal, for the detector rail
|
||||
/// (`DetectorRailBlock.getAnalogOutputSignal`). A cart whose menu is open
|
||||
/// counts the items in that menu.
|
||||
pub(super) fn cart_signals(&self) -> Vec<(CartKind, i32)> {
|
||||
self.carts
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
|
||||
let slots = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::Chest(open)) => open,
|
||||
_ => &c.items,
|
||||
};
|
||||
(c.kind, Minecart::container_signal(slots, self.res.items()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `interact`: ride, open, fuel or ignite the cart under the crosshair.
|
||||
pub(super) fn interact_cart(&mut self) -> bool {
|
||||
let Some((i, d)) = self.cart_hit() else {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! The looping sound instances of mobs: a bee's buzz
|
||||
//! (`ClientPacketListener.postAddEntitySoundInstance`) and a guardian's
|
||||
//! attack hum (entity event 21). The sound engine ticks them with what
|
||||
//! `start_mob_sounds` reads off the mobs.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
mobs::{Species, bee, guardian},
|
||||
sound_engine::{MobSound, SoundEngine, SoundInstance, Sounds},
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Start the buzz of each bee that has none (or whose anger changed, which
|
||||
/// swaps the flying loop for the aggressive one and back), forget the mobs
|
||||
/// that are gone, and read what the instances follow.
|
||||
pub(super) fn start_mob_sounds(&mut self) -> Vec<MobSound> {
|
||||
let game_time = self.world.game_time;
|
||||
let mobs: Vec<MobSound> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
!m.removed
|
||||
&& matches!(
|
||||
m.species(),
|
||||
Species::Bee | Species::Guardian | Species::ElderGuardian
|
||||
)
|
||||
})
|
||||
.map(|m| MobSound {
|
||||
id: m.id,
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
// Vec3.horizontalDistance.
|
||||
speed: m.velocity.x.hypot(m.velocity.z),
|
||||
baby: m.is_baby(),
|
||||
// NeutralMob.isAngry: the anger end time is ahead.
|
||||
angry: m.species() == Species::Bee
|
||||
&& bee::get(m).is_some_and(|b| b.anger_end > 0 && b.anger_end - game_time > 0),
|
||||
silent: m.silent,
|
||||
attack_scale: guardian::attack_scale(m),
|
||||
})
|
||||
.collect();
|
||||
self.guardian_sounds
|
||||
.retain(|id| mobs.iter().any(|m| m.id == *id));
|
||||
self.bee_sounds
|
||||
.retain(|id, _| mobs.iter().any(|m| m.id == *id));
|
||||
for sound in &mobs {
|
||||
let is_bee = self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.id == sound.id && m.species() == Species::Bee);
|
||||
if is_bee && self.bee_sounds.get(&sound.id) != Some(&sound.angry) {
|
||||
self.bee_sounds.insert(sound.id, sound.angry);
|
||||
let instance = SoundInstance::bee(sound, sound.angry, SoundEngine::unseeded());
|
||||
self.audio.play_instance(instance, false);
|
||||
}
|
||||
}
|
||||
mobs
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleEntityEvent` 21: a guardian's attack
|
||||
/// hum starts (one loop per guardian; the loops of its earlier beams are
|
||||
/// the same loop, as each one follows the guardian's charge).
|
||||
pub(super) fn start_guardian_sound(&mut self, id: u32) {
|
||||
let Some(m) = self.mobs.iter().find(|m| {
|
||||
m.id == id && matches!(m.species(), Species::Guardian | Species::ElderGuardian)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
if !self.guardian_sounds.insert(id) {
|
||||
return;
|
||||
}
|
||||
let sound = MobSound {
|
||||
id,
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
speed: 0.0,
|
||||
baby: false,
|
||||
angry: false,
|
||||
silent: m.silent,
|
||||
attack_scale: guardian::attack_scale(m),
|
||||
};
|
||||
let instance = SoundInstance::guardian_attack(&sound, SoundEngine::unseeded());
|
||||
self.audio.play_instance(instance, false);
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,9 @@ impl Game<'_> {
|
||||
/// Capability `recipes`'s tables and known displays for this tick's
|
||||
/// snapshot; nothing while no running mod reads them.
|
||||
pub(super) fn mod_recipes(&mut self) -> (Option<Arc<Recipes>>, Arc<[i32]>) {
|
||||
if !self.mods.mods.wants_recipes() {
|
||||
// The tables come from the world resources, which may still be
|
||||
// loading behind the title on a slow start.
|
||||
if !self.mods.mods.wants_recipes() || self.res.rest.get().is_none() {
|
||||
return (None, Arc::from([]));
|
||||
}
|
||||
if self.mods.recipes.is_none() {
|
||||
|
||||
@@ -34,11 +34,13 @@ impl Game<'_> {
|
||||
self.sound_paused = self.pause;
|
||||
}
|
||||
let (vehicle, carts) = self.start_cart_sounds();
|
||||
let mobs = self.start_mob_sounds();
|
||||
let ctx = TickContext {
|
||||
player_underwater: self.ambience.was_underwater(),
|
||||
player_removed: self.player.is_none(),
|
||||
vehicle,
|
||||
carts,
|
||||
mobs,
|
||||
frozen: !self.clock.runs_normally(),
|
||||
};
|
||||
self.audio.tick(self.pause, &ctx);
|
||||
|
||||
@@ -158,7 +158,10 @@ pub(super) fn player_type(root: &Path) -> Option<Arc<MobType>> {
|
||||
ambient_interval: 80,
|
||||
flags: Default::default(),
|
||||
model: Arc::new(model),
|
||||
layers: Default::default(),
|
||||
// PlayerModel's slim arms, for slim skins.
|
||||
layers: horizon_client::mobs::load_model(root, "minecraft:player_slim#main")
|
||||
.map(|slim| [("player_slim#main".to_owned(), Arc::new(slim))].into())
|
||||
.unwrap_or_default(),
|
||||
texture: "entity/player/wide/steve".into(),
|
||||
brain: Brain::Passive,
|
||||
anim: Anim::Humanoid { zombie_arms: false },
|
||||
|
||||
@@ -213,7 +213,8 @@ pub(super) struct Remote {
|
||||
/// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`.
|
||||
show_death_screen: bool,
|
||||
player_type: Option<Arc<MobType>>,
|
||||
/// Inventory as last synchronised, for creative-mode slot updates.
|
||||
/// Inventory (then the off hand) as last synchronised, for creative-mode
|
||||
/// slot updates.
|
||||
synced: Vec<Option<ItemStack>>,
|
||||
decode_errors: u32,
|
||||
clock: Option<i32>,
|
||||
@@ -510,7 +511,7 @@ impl Game<'_> {
|
||||
death_message: None,
|
||||
show_death_screen: true,
|
||||
player_type,
|
||||
synced: vec![None; 36],
|
||||
synced: vec![None; 37],
|
||||
decode_errors: 0,
|
||||
clock: None,
|
||||
clock_rate: 1.0,
|
||||
@@ -699,7 +700,6 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let text = content.plain();
|
||||
log::info!("[chat] {text}");
|
||||
// addServerSystemMessage and addClientSystemMessage both carry
|
||||
// GuiMessageTag.systemSinglePlayer: the grey bar at the line's left.
|
||||
let indicator = (source != horizon_client::chat::ChatSource::Player)
|
||||
@@ -2097,6 +2097,13 @@ impl Game<'_> {
|
||||
m.extra.ints[1] = code;
|
||||
}
|
||||
}
|
||||
// Avatar.DATA_PLAYER_MAIN_HAND (HumanoidArm: 0 left, 1
|
||||
// right): the arm a swing of the main hand moves.
|
||||
if let (15, Value::Id(arm)) = (index, value)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
{
|
||||
m.extra.main_hand_left = *arm == 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): this
|
||||
@@ -2321,6 +2328,10 @@ impl Game<'_> {
|
||||
if p.event == 69 {
|
||||
self.remote_ravager_roar(p.id);
|
||||
}
|
||||
// ClientPacketListener.handleEntityEvent 21: a guardian's hum.
|
||||
if p.event == 21 {
|
||||
self.start_guardian_sound(p.id as u32);
|
||||
}
|
||||
if p.event == 1
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& m.species() == horizon_client::mobs::Species::Rabbit
|
||||
@@ -2421,7 +2432,7 @@ impl Game<'_> {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
self.swing_hand(hand, animation);
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.swing();
|
||||
m.swing_hand_modified(hand == Hand::Off, animation.duration);
|
||||
}
|
||||
}
|
||||
// The player's own instances: its maximum health (the hearts the
|
||||
@@ -3075,6 +3086,20 @@ impl Game<'_> {
|
||||
.collect();
|
||||
Some((players, r.online_mode, upside_down))
|
||||
}
|
||||
/// The signed-in account's `textures`, once the services answered.
|
||||
fn own_textures(&self) -> Option<horizon_client::skins::Textures> {
|
||||
use horizon_client::skins::{self, NameOrId, Resolve};
|
||||
let auth = horizon_client::auth::current()?;
|
||||
let state = self
|
||||
.skin_profiles
|
||||
.borrow_mut()
|
||||
.state(&NameOrId::Id(auth.uuid));
|
||||
let Resolve::Done(Some(profile)) = state else {
|
||||
return None;
|
||||
};
|
||||
let (value, signature) = profile.textures()?;
|
||||
Some(skins::unpack(&value, signature, &skins::TEXTURE_URIS))
|
||||
}
|
||||
/// `PlayerInfo.getSkin` for every player entity, including NPCs that are
|
||||
/// never in the tab list: resolve each profile's textures and give the
|
||||
/// level's player models their skin.
|
||||
@@ -3083,12 +3108,23 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let local = r.profile;
|
||||
let entries: Vec<(u128, horizon_client::skins::Textures)> = r
|
||||
let mut entries: Vec<(u128, horizon_client::skins::Textures)> = r
|
||||
.skin_textures
|
||||
.iter()
|
||||
.map(|(uuid, textures)| (*uuid, textures.clone()))
|
||||
.collect();
|
||||
// Minecraft.profileFuture: the signed-in account's own profile, fetched from the
|
||||
// services, gives the local player its skin where the server sends none (single
|
||||
// player's offline-mode server).
|
||||
if !entries.iter().any(|(u, t)| *u == local && t.skin.is_some())
|
||||
&& let Some(own) = self.own_textures()
|
||||
{
|
||||
entries.retain(|(u, _)| *u != local);
|
||||
entries.push((local, own));
|
||||
}
|
||||
let mut resolved: Vec<(u128, String)> = Vec::new();
|
||||
// PlayerSkin.model of each resolved skin: the textures' `slim`.
|
||||
let mut slim = std::collections::HashSet::new();
|
||||
for (uuid, textures) in entries {
|
||||
// PlayerInfo.createSkinLookup: only the local player's own skin
|
||||
// may be unsigned.
|
||||
@@ -3099,10 +3135,22 @@ impl Game<'_> {
|
||||
})
|
||||
};
|
||||
if let Some(skin) = skin {
|
||||
if textures.slim {
|
||||
slim.insert(uuid);
|
||||
}
|
||||
resolved.push((uuid, skin));
|
||||
}
|
||||
}
|
||||
resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.again_skin(skin));
|
||||
// PlayerInfo.getSkin: the downloaded skin and its model, else the
|
||||
// profile's default skin (DefaultPlayerSkin.get), slim or wide.
|
||||
let skin_of = |skins: &HashMap<u128, String>, uuid: u128| match skins.get(&uuid) {
|
||||
Some(skin) => (skin.clone(), slim.contains(&uuid)),
|
||||
None => {
|
||||
let skin = horizon_client::tab_list::default_skin(uuid);
|
||||
(skin.to_owned(), skin.contains("/slim/"))
|
||||
}
|
||||
};
|
||||
let Some((level, skins, own)) = self.remote.as_mut().map(|r| {
|
||||
r.skins = resolved.into_iter().collect();
|
||||
(
|
||||
@@ -3111,27 +3159,26 @@ impl Game<'_> {
|
||||
.map(|(id, uuid)| (*id as u32, *uuid))
|
||||
.collect::<Vec<_>>(),
|
||||
r.skins.clone(),
|
||||
r.skins.get(&r.profile).cloned(),
|
||||
r.profile,
|
||||
)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
for (id, uuid) in level {
|
||||
let Some(skin) = skins.get(&uuid) else {
|
||||
continue;
|
||||
};
|
||||
let (skin, is_slim) = skin_of(&skins, uuid);
|
||||
if let Some(mob) = self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id && &*m.kind.id == "minecraft:player")
|
||||
{
|
||||
mob.skin = Some(skin.clone());
|
||||
mob.skin = Some(skin);
|
||||
mob.slim = is_slim;
|
||||
}
|
||||
}
|
||||
if let Some(skin) = own
|
||||
&& let Some(mob) = self.player_model.as_mut()
|
||||
{
|
||||
if let Some(mob) = self.player_model.as_mut() {
|
||||
let (skin, is_slim) = skin_of(&skins, own);
|
||||
mob.skin = Some(skin);
|
||||
mob.slim = is_slim;
|
||||
}
|
||||
// ClientMannequin.updateSkin (PlayerSkinRenderCache.lookup): a
|
||||
// mannequin's DATA_PROFILE `textures` property resolves like a
|
||||
@@ -3181,7 +3228,7 @@ impl Game<'_> {
|
||||
(m.id, textures)
|
||||
})
|
||||
.collect();
|
||||
let mut resolved: Vec<(u32, Option<String>)> = Vec::new();
|
||||
let mut resolved: Vec<(u32, Option<String>, bool)> = Vec::new();
|
||||
for (id, textures) in mannequins {
|
||||
let skin = {
|
||||
let mut downloads = self.skin_downloads.borrow_mut();
|
||||
@@ -3189,15 +3236,17 @@ impl Game<'_> {
|
||||
downloads.state(kind, url)
|
||||
})
|
||||
};
|
||||
resolved.push((id, skin));
|
||||
let slim = skin.is_some() && textures.slim;
|
||||
resolved.push((id, skin, slim));
|
||||
}
|
||||
for (id, skin) in resolved {
|
||||
for (id, skin, slim) in resolved {
|
||||
let skin = skin.filter(|s| !s.starts_with("skins/") || self.again_skin(s));
|
||||
if let Some(mob) = self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id && &*m.kind.id == "minecraft:mannequin")
|
||||
{
|
||||
mob.slim = slim && skin.is_some();
|
||||
mob.skin = skin;
|
||||
}
|
||||
}
|
||||
@@ -3581,6 +3630,7 @@ impl Game<'_> {
|
||||
self.sleep_timer = super::tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
|
||||
// Gui.tick: the titles, the action bar and the held item's name.
|
||||
self.tick_hud();
|
||||
self.tick_subtitles();
|
||||
let entity_collisions = self.player_entity_collisions();
|
||||
let pushed = self.remote_pushes();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
@@ -3928,6 +3978,34 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let game_time = self.world.game_time;
|
||||
// `AbstractPiglin.isConverting`: the dimension is not piglin safe.
|
||||
let nether = self.dim == super::Dim::Nether;
|
||||
// What a wither's side heads can look at, by network entity id: the
|
||||
// mobs and the player (position and eye height).
|
||||
let wither_targets: Vec<(i32, [f64; 3], f64)> = if self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.species() == horizon_client::mobs::Species::Wither)
|
||||
{
|
||||
let own = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.zip(self.eye_position())
|
||||
.map(|(r, eye)| (r.entity_id, eye, eye[1]));
|
||||
self.mobs
|
||||
.iter()
|
||||
.map(|m| {
|
||||
(
|
||||
m.id as i32,
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
m.eye_y(),
|
||||
)
|
||||
})
|
||||
.chain(own)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -4014,6 +4092,11 @@ impl Game<'_> {
|
||||
if m.species() == horizon_client::mobs::Species::Fox {
|
||||
horizon_client::mobs::fox::client_tick(m);
|
||||
}
|
||||
// Cat.tick: the lying and relaxing amounts follow the synched flags.
|
||||
if m.species() == horizon_client::mobs::Species::Cat {
|
||||
m.anim.vo = m.anim.v;
|
||||
horizon_client::mobs::tame::cat_amounts(m);
|
||||
}
|
||||
// Chicken.aiStep: the wings follow the server's on-ground flag.
|
||||
if m.species() == horizon_client::mobs::Species::Chicken {
|
||||
horizon_client::mobs::farm::chicken_flap(m);
|
||||
@@ -4021,6 +4104,20 @@ impl Game<'_> {
|
||||
// LivingEntity.updatingUsingItem: the ticks the item has been
|
||||
// used before this one. Another player's use particles; a mob's
|
||||
// drawn bow only needs the count (its pull follows it).
|
||||
if player {
|
||||
// AvatarRenderer.getArmPose: the used item's BLOCK animation
|
||||
// (an item with `blocks_attacks`) raises the arm.
|
||||
let used = if m.extra.using_off_hand {
|
||||
&m.extra.off_hand
|
||||
} else {
|
||||
&m.extra.main_hand
|
||||
};
|
||||
m.extra.blocking = m.extra.using_item > 0
|
||||
&& used
|
||||
.as_ref()
|
||||
.and_then(|i| self.res.items().get(i))
|
||||
.is_some_and(|i| i.blocks_attacks.is_some());
|
||||
}
|
||||
if m.extra.using_item > 0 {
|
||||
if player {
|
||||
let item = if m.extra.using_off_hand {
|
||||
@@ -4067,8 +4164,26 @@ impl Game<'_> {
|
||||
}
|
||||
// The animation states a server computes in its own `ai_step`.
|
||||
horizon_client::mobs::sync::client_tick(m, game_time);
|
||||
if m.species() == horizon_client::mobs::Species::Panda {
|
||||
horizon_client::mobs::panda::client_tick(m, thundering);
|
||||
match m.species() {
|
||||
// WitherBoss.aiStep: the side heads turn toward their targets.
|
||||
horizon_client::mobs::Species::Wither => {
|
||||
horizon_client::mobs::wither::client_tick(m, |id| {
|
||||
wither_targets
|
||||
.iter()
|
||||
.find(|t| t.0 == id)
|
||||
.map(|t| (t.1, t.2))
|
||||
});
|
||||
}
|
||||
// Hoglin and piglin `isConverting` outside the Nether.
|
||||
horizon_client::mobs::Species::Hoglin
|
||||
| horizon_client::mobs::Species::Piglin
|
||||
| horizon_client::mobs::Species::PiglinBrute => {
|
||||
horizon_client::mobs::piglin::client_converting(m, nether);
|
||||
}
|
||||
horizon_client::mobs::Species::Panda => {
|
||||
horizon_client::mobs::panda::client_tick(m, thundering);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for o in self.orbs.iter_mut().filter(|_| running) {
|
||||
@@ -4231,12 +4346,14 @@ impl Game<'_> {
|
||||
{
|
||||
out.push(packet);
|
||||
}
|
||||
// Creative inventory edits are sent slot by slot (SetCreativeModeSlot).
|
||||
// Creative inventory edits are sent slot by slot (SetCreativeModeSlot);
|
||||
// `synced[36]` is the off hand.
|
||||
let mut creative_slots = Vec::new();
|
||||
if self.creative {
|
||||
for i in 0..36 {
|
||||
if self.inventory[i] != r.synced[i] {
|
||||
r.synced[i] = self.inventory[i].clone();
|
||||
for i in 0..37 {
|
||||
let stack = self.inventory.get(i).unwrap_or(&self.offhand);
|
||||
if *stack != r.synced[i] {
|
||||
r.synced[i] = stack.clone();
|
||||
creative_slots.push(i);
|
||||
}
|
||||
}
|
||||
@@ -4245,8 +4362,13 @@ impl Game<'_> {
|
||||
self.send(packet);
|
||||
}
|
||||
for i in creative_slots {
|
||||
let slot = if i < 9 { 36 + i } else { i } as i16;
|
||||
let item = self.to_network(&self.inventory[i]);
|
||||
// InventoryMenu's slots: the hotbar is 36..=44, the off hand 45.
|
||||
let slot = match i {
|
||||
0..9 => 36 + i,
|
||||
36 => 45,
|
||||
_ => i,
|
||||
} as i16;
|
||||
let item = self.to_network(self.inventory.get(i).unwrap_or(&self.offhand));
|
||||
self.send(S::SetCreativeModeSlot(play::SetCreativeModeSlot {
|
||||
slot,
|
||||
item,
|
||||
@@ -4255,7 +4377,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// Inventory as the server last described it.
|
||||
fn remote_synced(&mut self) {
|
||||
let inventory = self.inventory.clone();
|
||||
let mut inventory = self.inventory.clone();
|
||||
inventory.push(self.offhand.clone());
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.synced = inventory;
|
||||
}
|
||||
@@ -4541,6 +4664,11 @@ impl Game<'_> {
|
||||
if reacts {
|
||||
self.predict_use(&changes, seq);
|
||||
self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
|
||||
} else if self.predict_campfire_douse(hit.pos, held.as_ref(), seq) {
|
||||
// CampfireBlock.useItemOn succeeded: this hand swings and the
|
||||
// click ends, so a shield in the other hand is not raised.
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
return true;
|
||||
} else if held.as_ref().is_some_and(|s| &*s.item == "minecraft:brush") {
|
||||
// BrushItem.useOn: brushing starts here (CONSUME, no swing); the
|
||||
// server brushes and the dust is this client's own.
|
||||
@@ -4727,7 +4855,7 @@ impl Game<'_> {
|
||||
/// predicted under the use's sequence until the server acknowledges it
|
||||
/// (`BlockStatePredictionHandler.retainKnownServerState`): a use the
|
||||
/// server refused goes back.
|
||||
fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) {
|
||||
pub(super) fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) {
|
||||
for &(p, state) in changes {
|
||||
let pos = [p.x, p.y, p.z];
|
||||
let before = self.world.block(p.x, p.y, p.z);
|
||||
@@ -4829,7 +4957,10 @@ impl Game<'_> {
|
||||
if self.using.is_some() {
|
||||
return;
|
||||
}
|
||||
let bundle = self.res.items().get(&held.item).is_some_and(|i| i.bundle);
|
||||
let start = match &*held.item {
|
||||
// BundleItem.use: always starts; the server drops the contents.
|
||||
_ if bundle => true,
|
||||
// BowItem.use: with an arrow, or in creative.
|
||||
"minecraft:bow" => self.creative || self.projectile_slot("minecraft:bow").is_some(),
|
||||
// CrossbowItem.use: charging when not charged and with ammunition;
|
||||
@@ -4932,6 +5063,14 @@ impl Game<'_> {
|
||||
self.using = hit.and(Some((slot, elapsed))).filter(|_| elapsed < 200);
|
||||
return;
|
||||
}
|
||||
// BundleItem.getUseDuration: 200 ticks, the server drops the contents.
|
||||
if stack
|
||||
.as_ref()
|
||||
.is_some_and(|s| self.res.items().get(&s.item).is_some_and(|i| i.bundle))
|
||||
{
|
||||
self.using = (in_hand && ticks + 1 < 200).then_some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
// A drawn bow, a charging crossbow, a raised shield...: held until
|
||||
// the button lets go.
|
||||
if stack.as_ref().is_some_and(|s| self.held_until_released(s)) {
|
||||
@@ -4963,8 +5102,11 @@ impl Game<'_> {
|
||||
// countdown goes on past zero (LivingEntity.updateUsingItem completes
|
||||
// only on the server) until the server's USE_ITEM_COMPLETE.
|
||||
let remaining = consumable.ticks() - ticks as i32;
|
||||
if consumable.particles && consumable.should_emit(remaining) {
|
||||
self.eat_particles(&stack, 5);
|
||||
if consumable.should_emit(remaining) {
|
||||
// `emit_consume` plays the eat sound and spawns the bites (when the
|
||||
// item has consume particles), as `tick_used_item` does on the
|
||||
// player's own server.
|
||||
self.emit_consume(&consumable, &stack, 5);
|
||||
}
|
||||
self.using = Some((slot, ticks + 1));
|
||||
}
|
||||
@@ -5104,6 +5246,28 @@ impl Game<'_> {
|
||||
if self.spectator() {
|
||||
return true;
|
||||
}
|
||||
// A spear jabs (`MultiPlayerGameMode.piercingAttack`): the server
|
||||
// stabs, this client swings, plays the sound and starts its charge
|
||||
// again.
|
||||
if let Some(kinetic) = self
|
||||
.held()
|
||||
.and_then(|s| horizon_client::spear::Kinetic::of(&s.item))
|
||||
{
|
||||
if self.cannot_attack_with_item(self.hand_strength.ticker, 0) {
|
||||
return false;
|
||||
}
|
||||
// ServerboundPlayerActionPacket.Action.STAB.
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
action: 8,
|
||||
pos: [0; 3],
|
||||
direction: 0,
|
||||
sequence: 0,
|
||||
}));
|
||||
self.hand_strength.reset_attack();
|
||||
self.swing_hand(Hand::Main, kinetic.attack_animation());
|
||||
self.play_at_feet(kinetic.attack_sound);
|
||||
return true;
|
||||
}
|
||||
let mut instant = false;
|
||||
// A ghast's fireball or a wind charge nearer than any mob: the
|
||||
// server turns it (game/deflect.rs).
|
||||
@@ -5154,7 +5318,13 @@ impl Game<'_> {
|
||||
if !down {
|
||||
self.clicks.miss_time = 0;
|
||||
}
|
||||
if self.clicks.miss_time > 0 || self.using.is_some() {
|
||||
// A piercing weapon neither breaks blocks nor swings on its own.
|
||||
if self.clicks.miss_time > 0
|
||||
|| self.using.is_some()
|
||||
|| self
|
||||
.held()
|
||||
.is_some_and(|s| horizon_client::spear::Kinetic::of(&s.item).is_some())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let entity = self.entity_targeted();
|
||||
@@ -5217,11 +5387,12 @@ impl Game<'_> {
|
||||
KeyCode::KeyE => {
|
||||
// The inventory opens without telling the server.
|
||||
self.recipe_ui.reset_screen();
|
||||
self.screen = if self.creative {
|
||||
Screen::Creative
|
||||
if self.creative {
|
||||
self.screen = Screen::Creative;
|
||||
self.creative_screen.reopen();
|
||||
} else {
|
||||
Screen::Inventory
|
||||
};
|
||||
self.screen = Screen::Inventory;
|
||||
}
|
||||
self.craft_size = 2;
|
||||
self.capture(false);
|
||||
Ok(true)
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
use super::Game;
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
use horizon_client::mobs::{
|
||||
Mob, Species, Who, armadillo, bat, bee, camel, creaking, finalize, panda, sniffer, sync, tame,
|
||||
Mob, Species, Who, armadillo, bat, bee, camel, creaking, equine, finalize, illusioner, panda,
|
||||
sniffer, sync, tame, zombie_villager,
|
||||
};
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_protocol::entity_data::{Particle, ParticleData, Value};
|
||||
use horizon_protocol::registry::{Builtin, Dynamic};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
|
||||
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
|
||||
@@ -31,7 +33,21 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
// 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),
|
||||
(0, Value::Byte(flags)) => {
|
||||
m.fire_ticks = i32::from(flags & 1);
|
||||
// Drowned.setSwimming: the shared flag of swimming.
|
||||
if species == Species::Drowned {
|
||||
m.extra.flag = flags & 16 != 0;
|
||||
}
|
||||
// The shared flag of invisibility: Entity.isInvisible hides
|
||||
// the model (an illusioner shows its mirror images).
|
||||
let invisible = flags & 32 != 0;
|
||||
if species == Species::Illusioner {
|
||||
illusioner::set_invisible(m, invisible);
|
||||
} else if &*m.kind.id != "minecraft:player" {
|
||||
m.invisible = invisible;
|
||||
}
|
||||
}
|
||||
// Entity.DATA_POSE: the pose the species animates by.
|
||||
(6, Value::Id(pose)) => sync::set_pose(m, (*pose).clamp(0, 17) as u8),
|
||||
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the
|
||||
@@ -112,6 +128,9 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
m.extra.sitting = flags & 1 != 0;
|
||||
m.extra.tame = flags & 4 != 0;
|
||||
}
|
||||
// Cat.IS_LYING and RELAX_STATE_ONE.
|
||||
(Species::Cat, 21, Value::Bool(lying)) => m.extra.flag = *lying,
|
||||
(Species::Cat, 22, Value::Bool(relaxing)) => m.extra.relax_one = *relaxing,
|
||||
// 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;
|
||||
@@ -215,6 +234,42 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
(Species::CopperGolem, 16, Value::Id(state)) => {
|
||||
m.extra.variant = (*state).clamp(0, 3) as u8;
|
||||
}
|
||||
// AbstractHorse.DATA_ID_FLAGS: eating 16 and standing 32 (the
|
||||
// graze and the rear), which `sync::client_tick` animates.
|
||||
(s, 18, Value::Byte(flags)) if equine::grazes(s) => equine::set_flags(m, *flags as u8),
|
||||
// Enderman.DATA_CARRY_STATE (0 for none): the block it holds.
|
||||
(Species::Enderman, 16, Value::Id(state)) => {
|
||||
m.extra.carried = (*state > 0).then_some(BlockStateId(*state as u32));
|
||||
}
|
||||
// Zombie.DATA_DROWNED_CONVERSION_ID (after DATA_BABY_ID and
|
||||
// DATA_SPECIAL_TYPE_ID): the conversion's shaking.
|
||||
(Species::Zombie | Species::Husk, 18, Value::Bool(converting)) => {
|
||||
m.extra.counter2 = i32::from(*converting);
|
||||
}
|
||||
// ZombieVillager.DATA_CONVERTING_ID (before its villager data).
|
||||
(Species::ZombieVillager, 19, Value::Bool(converting)) => {
|
||||
zombie_villager::set_converting(m, *converting);
|
||||
}
|
||||
// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two):
|
||||
// the head shake of `no`, which the server counts down.
|
||||
(Species::Villager | Species::WanderingTrader, 18, Value::Int(ticks)) => {
|
||||
m.extra.ints[0] = *ticks;
|
||||
}
|
||||
// Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and
|
||||
// AbstractPiglin's: `sync`'s conversion shake reads it.
|
||||
(Species::Hoglin, 18, Value::Bool(immune))
|
||||
| (Species::Piglin | Species::PiglinBrute, 16, Value::Bool(immune)) => {
|
||||
m.extra.flag2 = *immune;
|
||||
}
|
||||
// Blaze.DATA_FLAGS_ID: charged 1, which makes it burn.
|
||||
(Species::Blaze, 16, Value::Byte(flags)) => m.extra.flag = flags & 1 != 0,
|
||||
// Turtle.HAS_EGG and LAYING_EGG (after AgeableMob's two).
|
||||
(Species::Turtle, 18, Value::Bool(has_egg)) => m.extra.flag = *has_egg,
|
||||
(Species::Turtle, 19, Value::Bool(laying)) => m.extra.flag2 = *laying,
|
||||
// WitherBoss.DATA_TARGET_B and _C (after DATA_TARGET_A): the
|
||||
// entities the side heads turn toward in `wither::client_tick`.
|
||||
(Species::Wither, 17, Value::Int(id)) => m.extra.ints[0] = *id,
|
||||
(Species::Wither, 18, Value::Int(id)) => m.extra.ints[1] = *id,
|
||||
// Pillager.IS_CHARGING_CROSSBOW: the charge pose (Raider's
|
||||
// IS_CELEBRATING takes 16).
|
||||
(Species::Pillager, 17, Value::Bool(charging)) => m.extra.flag = *charging,
|
||||
@@ -357,9 +412,20 @@ impl Game<'_> {
|
||||
let Some(r) = &self.remote else {
|
||||
return;
|
||||
};
|
||||
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): a villager lies
|
||||
// in its bed the way the bed faces, or gets up.
|
||||
let bed = values.iter().find_map(|v| match v {
|
||||
(14, _, Value::OptPos(bed)) => Some(bed.map_or(0, |b| self.bed_facing(b))),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
apply(m, values, &r.builtin, r.entity_id);
|
||||
apply_registry_data(m, values, &r.registries, self.world.game_time);
|
||||
if let Some(code) = bed
|
||||
&& m.species() == Species::Villager
|
||||
{
|
||||
m.extra.ints[1] = code;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,11 @@ impl Game<'_> {
|
||||
rain,
|
||||
thunder,
|
||||
skybox: self.dim == Dim::Overworld,
|
||||
blindness: None,
|
||||
blindness: self
|
||||
.potions
|
||||
.effects
|
||||
.get("blindness")
|
||||
.map(|effect| (effect.duration, effect.duration == -1)),
|
||||
darkness: self.darkness_fog(),
|
||||
darken_world: self
|
||||
.boss_overlay
|
||||
@@ -140,7 +144,8 @@ impl Game<'_> {
|
||||
} else {
|
||||
63.0
|
||||
};
|
||||
camera.y < horizon
|
||||
self.dim == Dim::Overworld
|
||||
&& camera.y < horizon
|
||||
&& fog_type(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
@@ -209,8 +214,8 @@ impl Game<'_> {
|
||||
percent_frozen: p.percent_frozen(),
|
||||
partial,
|
||||
portal: [fx.o_portal, fx.portal],
|
||||
nausea: 0.0,
|
||||
screen_effect_scale: 1.0,
|
||||
nausea: self.nausea_blend(partial),
|
||||
screen_effect_scale: self.screen_effect_scale(),
|
||||
};
|
||||
let mut scope = self.scope_scale;
|
||||
let gui_overlays =
|
||||
@@ -247,6 +252,12 @@ impl Game<'_> {
|
||||
let brightness = self.eye_brightness(e);
|
||||
let inside_portal = self.client_portal_effect();
|
||||
self.screen_fx.tick(brightness, inside_portal);
|
||||
let nausea = self
|
||||
.potions
|
||||
.effects
|
||||
.get("nausea")
|
||||
.map(|e| !e.ends_within(60));
|
||||
self.screen_fx.tick_spinning(nausea);
|
||||
// LocalPlayer water vision and AtmosphericFogEnvironment rain fog.
|
||||
let in_water =
|
||||
fog_type(&self.world, self.res.facts(), e, self.forward()) == FogType::Water;
|
||||
|
||||
@@ -269,6 +269,8 @@ impl Game<'_> {
|
||||
open.requests.push((p.id, p.url, p.hash));
|
||||
open.required = p.required;
|
||||
open.prompt = text;
|
||||
// Gui.setScreen: KeyMapping.releaseAll, so no walking behind the prompt.
|
||||
self.keys.clear();
|
||||
self.capture(false);
|
||||
}
|
||||
/// `handleResourcePackPop`: one pack, or all of them.
|
||||
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
Mob, Who,
|
||||
spawner::{Spawner, SpawnerAdded, SpawnerEvent, SpawnerSpawn},
|
||||
spawning,
|
||||
trial_spawner::TrialState,
|
||||
},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
@@ -373,12 +374,11 @@ impl Game<'_> {
|
||||
/// clicked block has no spawner.
|
||||
pub(super) fn use_spawn_egg_on_spawner(&mut self, pos: [i32; 3], kind: &str) -> Option<bool> {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
if !matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
Some(BlockEntity::Spawner(_))
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let trial = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::Spawner(_)) => false,
|
||||
Some(BlockEntity::TrialSpawner(_)) => true,
|
||||
_ => return None,
|
||||
};
|
||||
if !self.ui.rules.bool("spawner_blocks_work") {
|
||||
self.system_message(horizon_client::text::Text::translate(
|
||||
"advMode.notEnabled.spawner",
|
||||
@@ -386,16 +386,26 @@ impl Game<'_> {
|
||||
));
|
||||
return Some(false);
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
let mut random = std::mem::replace(
|
||||
&mut self.world.random,
|
||||
horizon_core::random::LegacyRandom::new(0),
|
||||
);
|
||||
if let Some(BlockEntity::Spawner(s)) = self.world.block_entities.get_mut(&key) {
|
||||
s.set_entity_id(kind, &mut random, &mut events);
|
||||
if trial {
|
||||
// TrialSpawnerBlockEntity.setEntityId: overrideEntityToSpawn, which
|
||||
// ends in setState(INACTIVE).
|
||||
if let Some(BlockEntity::TrialSpawner(t)) = self.world.block_entities.get_mut(&key) {
|
||||
t.override_entity(kind);
|
||||
}
|
||||
let ominous = self.trial_spawner_block(key).is_some_and(|(_, o)| o);
|
||||
let _ = self.set_trial_spawner_state(key, TrialState::Inactive, ominous);
|
||||
} else {
|
||||
let mut events = Vec::new();
|
||||
let mut random = std::mem::replace(
|
||||
&mut self.world.random,
|
||||
horizon_core::random::LegacyRandom::new(0),
|
||||
);
|
||||
if let Some(BlockEntity::Spawner(s)) = self.world.block_entities.get_mut(&key) {
|
||||
s.set_entity_id(kind, &mut random, &mut events);
|
||||
}
|
||||
self.world.random = random;
|
||||
let _ = self.apply_spawner_events(key, events, &HashMap::new());
|
||||
}
|
||||
self.world.random = random;
|
||||
let _ = self.apply_spawner_events(key, events, &HashMap::new());
|
||||
// sendBlockUpdated(3) and the player's block_change game event.
|
||||
self.spawner_block_updated(key);
|
||||
self.post_block_game_event("minecraft:block_change", pos, Some(Who::Player), None);
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
//! attacker's own speed, and each condition runs out after its time
|
||||
//! (`Item.use`, `ItemStack.onUseTick`, `KineticWeapon.damageEntities`,
|
||||
//! `Player.stabAttack`). The hit's feedback (`onKineticHit`) reaches the
|
||||
//! players around as entity event 2.
|
||||
//! players around as entity event 2. A left click jabs instead
|
||||
//! (`PiercingWeapon.attack`), which Lunge turns into a dash.
|
||||
use super::{Game, view_vector};
|
||||
use horizon_client::{
|
||||
attack_strength::delay,
|
||||
collisions::SceneCollisions,
|
||||
first_person::Hand,
|
||||
items::ItemStack,
|
||||
mobs::Damage,
|
||||
spear::{HIT_FEEDBACK_TICKS, Kinetic, REACH},
|
||||
@@ -115,6 +118,28 @@ impl Game<'_> {
|
||||
hits
|
||||
}
|
||||
|
||||
/// `ProjectileUtil.getHitEntitiesAlong` with the spear's `AttackRange`: the
|
||||
/// mobs along the view from the minimum reach to the maximum plus the
|
||||
/// attacker's own movement, none when a block is closer than the minimum.
|
||||
fn stab_targets(&self, eye: Vec3, look: Vec3, movement: Vec3) -> Vec<usize> {
|
||||
// AttackRange.effectiveMinRange and effectiveMaxRange for a player.
|
||||
let (min, max) = if self.creative {
|
||||
(REACH.min_creative, REACH.max_creative)
|
||||
} else {
|
||||
(REACH.min, REACH.max)
|
||||
};
|
||||
let from = eye.add_vector(look.scale(f64::from(min)));
|
||||
let mut to = eye.add_vector(look.scale(f64::from(max) + movement.dot(look).max(0.0)));
|
||||
// A block closer than the minimum reach takes the whole stab.
|
||||
if let Some(block) = self.clip_collider(eye, to) {
|
||||
to = block;
|
||||
if eye.distance_squared(to) < eye.distance_squared(from) {
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
self.mobs_along(from, to, f64::from(REACH.hitbox_margin))
|
||||
}
|
||||
|
||||
/// `KineticWeapon.damageEntities`: one tick of the spear in use, `ticks`
|
||||
/// ticks into the use.
|
||||
pub(super) fn spear_tick(&mut self, stack: &ItemStack, ticks: u32) {
|
||||
@@ -136,25 +161,10 @@ impl Game<'_> {
|
||||
let look = Vec3::new(look[0], look[1], look[2]);
|
||||
let movement = self.known_player_movement();
|
||||
let attacker_speed = look.dot(movement.scale(20.0));
|
||||
// AttackRange.effectiveMinRange and effectiveMaxRange for a player.
|
||||
let (min, max) = if self.creative {
|
||||
(REACH.min_creative, REACH.max_creative)
|
||||
} else {
|
||||
(REACH.min, REACH.max)
|
||||
};
|
||||
let from = eye.add_vector(look.scale(f64::from(min)));
|
||||
let mut to = eye.add_vector(look.scale(f64::from(max) + movement.dot(look).max(0.0)));
|
||||
// A block closer than the minimum reach takes the whole stab.
|
||||
if let Some(block) = self.clip_collider(eye, to) {
|
||||
to = block;
|
||||
if eye.distance_squared(to) < eye.distance_squared(from) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let now = self.world.game_time;
|
||||
let actor = self.lan_acting;
|
||||
let mut affected = false;
|
||||
for i in self.mobs_along(from, to, f64::from(REACH.hitbox_margin)) {
|
||||
for i in self.stab_targets(eye, look, movement) {
|
||||
let key = (actor, self.mobs[i].id);
|
||||
let recent = self
|
||||
.stabbed
|
||||
@@ -204,9 +214,18 @@ impl Game<'_> {
|
||||
return false;
|
||||
};
|
||||
let from = [p.position.x, p.position.y, p.position.z];
|
||||
// The enchantments' share, with no attack strength scaling for a
|
||||
// weapon in use.
|
||||
// The enchantments' share, scaled by the attack strength unless the
|
||||
// weapon is in use.
|
||||
let magic_boost = self.enchant_damage_bonus(&kind);
|
||||
let (base_damage, magic_boost) = if self.using.is_none() {
|
||||
let scale = self.attack_strength_scale();
|
||||
(
|
||||
base_damage * (0.2 + scale * scale * 0.8),
|
||||
magic_boost * scale,
|
||||
)
|
||||
} else {
|
||||
(base_damage, magic_boost)
|
||||
};
|
||||
let total = if deals_damage {
|
||||
base_damage + magic_boost
|
||||
} else {
|
||||
@@ -275,6 +294,57 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
|
||||
/// `Player.cannotAttackWithItem` for the held item: a spear needs its
|
||||
/// `minimum_attack_charge` of 1, counting `tolerance` ticks more than the
|
||||
/// `ticker` ticks charged.
|
||||
pub(super) fn cannot_attack_with_item(&self, ticker: i32, tolerance: i32) -> bool {
|
||||
self.held().is_some_and(|s| Kinetic::of(&s.item).is_some())
|
||||
&& (ticker + tolerance) as f32 / delay(self.main_hand_attack_speed()) < 1.0
|
||||
}
|
||||
|
||||
/// `PiercingWeapon.attack` of the spear in the main hand, for a left
|
||||
/// click: every mob along the view is stabbed, then `postPiercingAttack`
|
||||
/// (Lunge), the hit sound and the swing. Gives the sound of the jab
|
||||
/// (`makeSound`), which everyone but the user hears from the server.
|
||||
pub(super) fn jab(&mut self) -> Option<&'static str> {
|
||||
let kinetic = self.held().and_then(|s| Kinetic::of(&s.item))?;
|
||||
let eye = self.eye_position()?;
|
||||
let eye = Vec3::new(eye[0], eye[1], eye[2]);
|
||||
let look = view_vector(self.yaw, self.pitch);
|
||||
let look = Vec3::new(look[0], look[1], look[2]);
|
||||
// The attack damage attribute.
|
||||
let damage = 1.0
|
||||
+ self.attack_item_info().map_or(0.0, |i| i.attack_damage)
|
||||
+ self.effect_attack_bonus();
|
||||
let movement = self.known_player_movement();
|
||||
let mut hit = false;
|
||||
for i in self.stab_targets(eye, look, movement) {
|
||||
hit |= self.stab_mob(i, damage, true, true, false);
|
||||
}
|
||||
// Player.onAttack, then postPiercingAttack.
|
||||
self.attack_ticks = 0;
|
||||
self.post_piercing_attack();
|
||||
if hit {
|
||||
self.play_at_feet(kinetic.hit_sound);
|
||||
}
|
||||
// swingAndResetAttackStrength (ServerPlayer): a swing that starts
|
||||
// resets the attack strength.
|
||||
let swings = self.swings_started;
|
||||
self.swing_hand(Hand::Main, kinetic.attack_animation());
|
||||
if self.swings_started != swings {
|
||||
self.reset_attack_strength();
|
||||
}
|
||||
Some(kinetic.attack_sound)
|
||||
}
|
||||
|
||||
/// A sound at the player's feet.
|
||||
pub(super) fn play_at_feet(&mut self, sound: &str) {
|
||||
if let Some(p) = &self.player {
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
self.audio.play(sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.onKineticHit` for `entity`: the hit feels (the spear
|
||||
/// moves back, `kinetic_hit_ticks`) and sounds, at most once every ten
|
||||
/// ticks.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! The spectator menu (`SpectatorGui`): its players and teams from the
|
||||
//! server's player list and teams, and the teleport request.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
hud::HudBatch,
|
||||
hud_layout, keybinds,
|
||||
scoreboard::{comp_text, team_color_rgb},
|
||||
spectator_menu::{Member, Outcome, SpectatorMenu, Team},
|
||||
tab_list::{SPECTATOR, TabPlayer},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Util.getMillis` for the menu's fade.
|
||||
fn spectator_now(&self) -> i64 {
|
||||
self.started.elapsed().as_millis() as i64
|
||||
}
|
||||
/// `new SpectatorMenu`: the listed players, and the teams with a player
|
||||
/// to go to (`TeleportToTeamMenuCategory.createTeamEntries`).
|
||||
fn new_spectator_menu(&mut self) -> SpectatorMenu {
|
||||
let tab: Vec<TabPlayer> = self
|
||||
.remote_tab_players()
|
||||
.map_or_else(Vec::new, |(players, _, _)| players);
|
||||
let member = |p: &TabPlayer| Member {
|
||||
uuid: p.uuid,
|
||||
name: p.name.clone(),
|
||||
skin: p.skin.clone(),
|
||||
enabled: p.game_mode != SPECTATOR,
|
||||
};
|
||||
let players: Vec<Member> = tab.iter().map(member).collect();
|
||||
let teams = self
|
||||
.score
|
||||
.board
|
||||
.teams
|
||||
.iter()
|
||||
.filter_map(|team| {
|
||||
let members: Vec<Member> = team
|
||||
.players
|
||||
.iter()
|
||||
.filter_map(|name| tab.iter().find(|p| &p.name == name))
|
||||
.filter(|p| p.game_mode != SPECTATOR)
|
||||
.map(member)
|
||||
.collect();
|
||||
if members.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// A random member's face is the team's icon.
|
||||
let pick = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.subsec_nanos() as usize)
|
||||
% members.len();
|
||||
Some(Team {
|
||||
name: comp_text(&team.display_name),
|
||||
color: team.color.as_deref().and_then(team_color_rgb),
|
||||
icon: members[pick].skin.clone(),
|
||||
players: members,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
SpectatorMenu::new(players, teams)
|
||||
}
|
||||
fn spectator_outcome(&mut self, outcome: Outcome) {
|
||||
if let Outcome::Teleport(uuid) = outcome {
|
||||
// PlayerMenuItem.selectItem.
|
||||
self.send(S::TeleportToEntity(play::TeleportToEntity { uuid }));
|
||||
}
|
||||
}
|
||||
/// A hotbar key as a spectator (`SpectatorGui.onHotbarSelected`).
|
||||
pub(super) fn spectator_hotbar_key(&mut self, slot: i32) {
|
||||
let now = self.spectator_now();
|
||||
let menu = self.spectator_menu_if_needed();
|
||||
let outcome = self.spectator_gui.on_hotbar_selected(slot, now, menu);
|
||||
self.spectator_outcome(outcome);
|
||||
}
|
||||
/// `key.spectatorHotbar`: opens the menu, or uses the selected item.
|
||||
pub(super) fn spectator_action_key(&mut self) {
|
||||
let now = self.spectator_now();
|
||||
let menu = self.spectator_menu_if_needed();
|
||||
let outcome = self.spectator_gui.on_action_key(now, menu);
|
||||
self.spectator_outcome(outcome);
|
||||
}
|
||||
/// The menu a press would open, built only when none is up.
|
||||
fn spectator_menu_if_needed(&mut self) -> Option<SpectatorMenu> {
|
||||
(!self.spectator_gui.is_menu_active()).then(|| self.new_spectator_menu())
|
||||
}
|
||||
/// `MouseHandler.onScroll` in spectator mode with the menu up.
|
||||
pub(super) fn spectator_scroll(&mut self, wheel: i32) {
|
||||
let now = self.spectator_now();
|
||||
self.spectator_gui.on_mouse_scrolled(wheel, now);
|
||||
}
|
||||
/// `Hud.extractHotbar` for a spectator, and `extractAction`'s prompt.
|
||||
pub(super) fn draw_spectator_hotbar(&mut self, batch: &mut HudBatch) {
|
||||
let (w, h) = (batch.width as i32, batch.height as i32);
|
||||
let now = self.spectator_now();
|
||||
let labels: Vec<Text> = (1..=9)
|
||||
.map(|n| keybinds::display_name(self.settings.keys.get(&format!("key.hotbar.{n}"))))
|
||||
.collect();
|
||||
let font = &self.gui.atlas.font;
|
||||
let mut draws = self
|
||||
.spectator_gui
|
||||
.hotbar(w, h, now, &|slot| labels[slot].clone(), font);
|
||||
draws.extend(self.spectator_gui.action(
|
||||
w,
|
||||
h,
|
||||
now,
|
||||
font,
|
||||
self.settings.text_background(0.0),
|
||||
));
|
||||
hud_layout::paint(batch, &self.gui.atlas, &draws);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use super::{Game, GameLevel, Screen, dispenser::new_item_motion, storage::is_shu
|
||||
use horizon_client::{
|
||||
block_entity_item, block_entity_nbt,
|
||||
containers::{Banner, BlockEntity},
|
||||
furnace::FurnaceCooking,
|
||||
item_data,
|
||||
items::ItemStack,
|
||||
save,
|
||||
|
||||
@@ -2059,9 +2059,16 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
// ItemStack.OP_NBT_WARNING, for a player with game master permission.
|
||||
if !nested && self.ui.registries.permission >= 2 && block_entity_item::op_warning(stack) {
|
||||
for (key, fallback) in [
|
||||
// ItemStack.OP_NBT_WARNING: `BlockItem` and `SpawnEggItem`
|
||||
// `.shouldPrintOpWarning` for a player with game master permission;
|
||||
// a nested projectile has no player. The first line is bold.
|
||||
if !nested
|
||||
&& self.ui.registries.permission >= 2
|
||||
&& (item_info.is_some_and(|i| i.block.is_some())
|
||||
&& block_entity_item::op_warning(stack)
|
||||
|| block_entity_item::spawn_egg_op_warning(stack))
|
||||
{
|
||||
for (i, (key, fallback)) in [
|
||||
("item.op_warning.line1", "Warning:"),
|
||||
(
|
||||
"item.op_warning.line2",
|
||||
@@ -2071,12 +2078,15 @@ impl Game<'_> {
|
||||
"item.op_warning.line3",
|
||||
"Do not use unless you know the exact contents!",
|
||||
),
|
||||
] {
|
||||
push(
|
||||
&mut lines,
|
||||
self.translate(key, fallback),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let text = horizon_client::text::Text::legacy(&self.translate(key, fallback));
|
||||
lines.push((
|
||||
if i == 0 { text.bold() } else { text },
|
||||
rgba(255, 85, 85, 255),
|
||||
);
|
||||
));
|
||||
}
|
||||
}
|
||||
lines
|
||||
|
||||
@@ -272,7 +272,7 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
/// `setState` (and `removeOminous`): the block's state and ominous flag.
|
||||
fn set_trial_spawner_state(
|
||||
pub(super) fn set_trial_spawner_state(
|
||||
&mut self,
|
||||
key: Pos,
|
||||
state: TrialState,
|
||||
|
||||
@@ -105,12 +105,9 @@ const COMPLETE_COLUMN: u32 = (1 << (crate::chunk::HEIGHT / 16)) - 1;
|
||||
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
|
||||
/// The world pass again after occlusion culling's test: it loads both
|
||||
/// targets (phase 2 draws over phase 1, reading the test's results).
|
||||
/// LevelRenderer: no sky inside lava or powder snow (sky fog ends within 2 blocks).
|
||||
/// `LevelRenderer.addSkyPass`: fluids and camera effects block the sky pass.
|
||||
fn sky_open(frame: &Frame) -> bool {
|
||||
match frame.fog {
|
||||
Some(f) => f.sky_end > 2.0,
|
||||
None => !frame.underwater,
|
||||
}
|
||||
!frame.sky_blocked
|
||||
}
|
||||
|
||||
/// The Overworld-style sky (disc, sunset, stars, void) draws this frame.
|
||||
@@ -222,6 +219,9 @@ pub struct Frame<'a> {
|
||||
/// The `GameRenderer.bobHurt`/`bobView` pose multiplied into the
|
||||
/// projection (`crate::camera::bob_matrix`).
|
||||
pub bob: Mat4,
|
||||
/// The spinning effect multiplied into the level's projection only
|
||||
/// (`crate::camera::spinning_effect_matrix`), not the hand's.
|
||||
pub spin: Mat4,
|
||||
pub sky: Sky,
|
||||
/// Render distance in blocks (fog end).
|
||||
pub distance: f32,
|
||||
@@ -235,6 +235,8 @@ pub struct Frame<'a> {
|
||||
pub cave_culling: bool,
|
||||
/// Title screen: only the dedicated panorama cube map and the HUD.
|
||||
pub title: bool,
|
||||
/// Lava, powder snow, blindness or darkness blocks the sky pass.
|
||||
pub sky_blocked: bool,
|
||||
/// Dimensions without a skybox (the Nether) clear to the fog colour.
|
||||
pub skybox: bool,
|
||||
/// The End: clear to black and draw the resident sky box behind the world.
|
||||
@@ -3418,7 +3420,7 @@ impl Renderer {
|
||||
frame.fov_degrees,
|
||||
frame.yaw,
|
||||
frame.pitch,
|
||||
frame.bob,
|
||||
frame.bob * frame.spin,
|
||||
frame.depth_far,
|
||||
)
|
||||
}
|
||||
@@ -4508,7 +4510,7 @@ impl Renderer {
|
||||
cull.bind_depth(&self.device, world_depth);
|
||||
}
|
||||
let cull = self.cull.as_ref().filter(|_| culling && solid_draws > 0);
|
||||
let clear = if frame.title || (frame.end_sky && !frame.underwater) {
|
||||
let clear = if frame.title || (frame.end_sky && !frame.underwater && sky_open(frame)) {
|
||||
wgpu::Color::BLACK
|
||||
} else {
|
||||
wgpu::Color {
|
||||
|
||||
@@ -361,8 +361,9 @@ impl NativePost {
|
||||
pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs {
|
||||
let aspect = size[0] as f32 / size[1].max(1) as f32;
|
||||
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
|
||||
let projection =
|
||||
super::world_projection(frame.fov_degrees, aspect, frame.depth_far) * frame.bob;
|
||||
let projection = super::world_projection(frame.fov_degrees, aspect, frame.depth_far)
|
||||
* frame.bob
|
||||
* frame.spin;
|
||||
// A celestial body's direction at `angle` degrees (SkyRenderer: Y -90°, then X by the
|
||||
// angle, of straight up): overhead at 0, rising in the east (+x).
|
||||
let body = |angle: f32| {
|
||||
|
||||
@@ -287,7 +287,8 @@ impl FrameUniforms {
|
||||
aspect,
|
||||
0.05,
|
||||
frame.depth_far,
|
||||
) * frame.bob;
|
||||
) * frame.bob
|
||||
* frame.spin;
|
||||
let camera = frame.camera.as_vec3();
|
||||
let (previous_camera, previous_view, previous_projection) =
|
||||
self.previous
|
||||
|
||||
@@ -22,18 +22,15 @@ pub mod block_entity_anim;
|
||||
pub mod block_entity_item;
|
||||
pub mod block_entity_render;
|
||||
pub mod block_transform;
|
||||
pub mod blocks;
|
||||
pub mod border_render;
|
||||
pub mod boss_overlay;
|
||||
pub mod breaking;
|
||||
pub mod brewing;
|
||||
pub mod bundle;
|
||||
pub mod camera;
|
||||
pub mod cauldron;
|
||||
pub mod chat;
|
||||
pub mod chunk;
|
||||
pub mod chunk_map;
|
||||
pub mod clipboard;
|
||||
pub mod clock;
|
||||
pub mod clouds;
|
||||
pub mod collisions;
|
||||
@@ -43,7 +40,6 @@ pub mod confirm_screen;
|
||||
pub mod container_screen;
|
||||
pub mod containers;
|
||||
pub mod control;
|
||||
pub mod cooldowns;
|
||||
pub mod crafting;
|
||||
pub mod crash;
|
||||
pub mod creative;
|
||||
@@ -58,7 +54,6 @@ pub mod dialog_screen;
|
||||
pub mod discord_presence;
|
||||
pub mod dynamic_light;
|
||||
pub mod effects;
|
||||
pub mod enchant;
|
||||
pub mod end_crystal;
|
||||
pub mod entities;
|
||||
pub mod entity_culling;
|
||||
@@ -74,9 +69,9 @@ pub mod flatgen;
|
||||
pub mod fog;
|
||||
pub mod font;
|
||||
pub mod friends_screen;
|
||||
pub mod furnace;
|
||||
pub mod game_mode_switcher;
|
||||
pub mod game_rules_screen;
|
||||
pub mod gamepad;
|
||||
pub mod gfx;
|
||||
pub mod gizmos;
|
||||
pub mod gossip;
|
||||
@@ -90,19 +85,12 @@ pub mod hud;
|
||||
pub mod hud_layout;
|
||||
pub mod hud_overlays;
|
||||
pub mod hunger;
|
||||
pub mod input;
|
||||
pub mod interaction_input;
|
||||
pub mod inv_sort;
|
||||
pub mod inventory_effects;
|
||||
pub mod item_data;
|
||||
pub mod item_render;
|
||||
pub mod item_sync;
|
||||
pub mod item_tooltip;
|
||||
pub mod items;
|
||||
pub mod java_text;
|
||||
pub mod key_names;
|
||||
pub mod keybinds;
|
||||
pub mod keymapping;
|
||||
pub mod lan;
|
||||
pub mod landing;
|
||||
pub mod level_loading;
|
||||
@@ -122,7 +110,6 @@ pub mod mod_list;
|
||||
mod model_library;
|
||||
pub mod models;
|
||||
pub mod motion;
|
||||
pub mod mouse;
|
||||
pub mod moved_extras;
|
||||
pub mod multiplayer;
|
||||
pub mod music;
|
||||
@@ -147,8 +134,8 @@ pub mod portal_sections;
|
||||
pub mod pose;
|
||||
pub mod pregen;
|
||||
pub mod projectiles;
|
||||
pub mod providers;
|
||||
pub mod raid;
|
||||
pub mod random_sequences;
|
||||
pub mod random_tick_sections;
|
||||
pub mod recipe_book;
|
||||
pub mod recipe_book_screen;
|
||||
@@ -170,6 +157,7 @@ pub mod sound_engine;
|
||||
pub mod sound_events;
|
||||
pub mod spans;
|
||||
pub mod spear;
|
||||
pub mod spectator_menu;
|
||||
pub mod sprint;
|
||||
pub mod sprites;
|
||||
pub mod stations;
|
||||
@@ -193,7 +181,6 @@ pub mod vault;
|
||||
pub mod weather;
|
||||
pub mod widgets;
|
||||
pub mod win_screen;
|
||||
pub mod window;
|
||||
pub mod world;
|
||||
pub mod world_spawn;
|
||||
pub mod worldborder;
|
||||
@@ -209,4 +196,20 @@ pub use horizon_client_core::nbt_path;
|
||||
pub use horizon_client_core::scoreboard;
|
||||
pub use horizon_client_core::snbt;
|
||||
pub use horizon_client_core::text;
|
||||
pub use horizon_client_input::clipboard;
|
||||
pub use horizon_client_input::gamepad;
|
||||
pub use horizon_client_input::input;
|
||||
pub use horizon_client_input::interaction_input;
|
||||
pub use horizon_client_input::key_names;
|
||||
pub use horizon_client_input::keybinds;
|
||||
pub use horizon_client_input::keymapping;
|
||||
pub use horizon_client_input::mouse;
|
||||
pub use horizon_client_input::window;
|
||||
pub use horizon_command as command;
|
||||
pub use horizon_items::blocks;
|
||||
pub use horizon_items::bundle;
|
||||
pub use horizon_items::cooldowns;
|
||||
pub use horizon_items::enchant;
|
||||
pub use horizon_items::item_data;
|
||||
pub use horizon_items::items;
|
||||
pub use horizon_items::random_sequences;
|
||||
@@ -504,12 +504,7 @@ impl MobTypes {
|
||||
Ok(Self { types: out })
|
||||
}
|
||||
}
|
||||
/// Who caused damage or is being targeted.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Who {
|
||||
Player,
|
||||
Mob(u32),
|
||||
}
|
||||
pub use horizon_client_core::who::Who;
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DamageKind {
|
||||
PlayerAttack,
|
||||
@@ -706,6 +701,8 @@ pub struct Extra {
|
||||
pub body_damage: i32,
|
||||
/// The components (dye, name...) of that armor stack.
|
||||
pub body_extra: Option<Box<crate::item_data::StackData>>,
|
||||
/// `Cat.RELAX_STATE_ONE`: watching a sleeping owner before lying down.
|
||||
pub relax_one: bool,
|
||||
/// The saddle's components (name, enchantments...) when it is not a bare
|
||||
/// one; `saddled` stays the gameplay flag.
|
||||
pub saddle_stack: Option<crate::items::ItemStack>,
|
||||
@@ -940,6 +937,8 @@ pub struct Mob {
|
||||
/// The skin texture of a player mob (`PlayerInfo.getSkin`), which
|
||||
/// replaces the type's default when its download is ready.
|
||||
pub skin: Option<String>,
|
||||
/// `PlayerSkin.model` is `SLIM`: the player model with 3-pixel arms.
|
||||
pub slim: bool,
|
||||
/// A mannequin's `DATA_PROFILE` (ResolvableProfile): the profile's
|
||||
/// `textures` property resolves to `skin` like a player's.
|
||||
pub profile: Option<horizon_protocol::entity_data::Profile>,
|
||||
@@ -963,7 +962,8 @@ pub enum MobEvent {
|
||||
mob: u32,
|
||||
at: [f64; 3],
|
||||
},
|
||||
/// `Level.broadcastEntityEvent` for a mob: guests run `sync::entity_event`.
|
||||
/// `Level.broadcastEntityEvent` for a mob: guests run its
|
||||
/// `handleEntityEvent`.
|
||||
EntityEvent {
|
||||
mob: u32,
|
||||
event: i8,
|
||||
@@ -1155,6 +1155,11 @@ pub enum MobEvent {
|
||||
count: u32,
|
||||
at: [f64; 3],
|
||||
},
|
||||
/// `Mob.dropFromGiftLootTable`: a gift loot table's items dropped at `at`.
|
||||
DropGift {
|
||||
table: &'static str,
|
||||
at: [f64; 3],
|
||||
},
|
||||
SetBlock {
|
||||
pos: [i32; 3],
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
@@ -1483,6 +1488,8 @@ pub struct Seen {
|
||||
pub sitting: bool,
|
||||
/// `Entity.isSilent`.
|
||||
pub silent: bool,
|
||||
/// `Cat.isLying` or `isRelaxStateOne`.
|
||||
pub lying: bool,
|
||||
/// `Entity.isVehicle` or `isPassenger`.
|
||||
pub ridden: bool,
|
||||
/// `Attributes.KNOCKBACK_RESISTANCE`.
|
||||
@@ -1578,6 +1585,7 @@ impl Mob {
|
||||
],
|
||||
sitting: m.extra.sitting,
|
||||
silent: m.silent,
|
||||
lying: m.species() == Species::Cat && (m.extra.flag || m.extra.relax_one),
|
||||
ridden: m.rider.is_some() || m.extra.rider.is_some() || m.extra.mount.is_some(),
|
||||
knockback_resistance: m.attrs.knockback_resistance,
|
||||
effects: m
|
||||
@@ -1748,6 +1756,10 @@ pub struct PlayerView {
|
||||
/// A LAN guest: its own client moves it, and touching mobs push it there
|
||||
/// (`EntitySelector.pushableBy` on the client).
|
||||
pub remote: bool,
|
||||
/// `isSleeping` and `getSleepTimer` (100 once slept through, then the
|
||||
/// ticks fading out after waking up).
|
||||
pub sleeping: bool,
|
||||
pub sleep_timer: u32,
|
||||
/// `isPassenger`.
|
||||
pub passenger: bool,
|
||||
}
|
||||
@@ -1898,6 +1910,7 @@ impl Mob {
|
||||
rider: None,
|
||||
outbox: Vec::new(),
|
||||
skin: None,
|
||||
slim: false,
|
||||
profile: None,
|
||||
attrs: Attrs {
|
||||
speed: kind.speed,
|
||||
|
||||
@@ -53,17 +53,62 @@ pub struct Ai {
|
||||
/// Swing animation progress (attackAnim) and ticks.
|
||||
pub swing_time: i32,
|
||||
pub swinging: bool,
|
||||
/// The current swing's `SwingDescription.durationTicks`; 0 is the default 6.
|
||||
pub swing_duration: i32,
|
||||
/// Walked distance and the next footstep.
|
||||
pub steps: super::living::Steps,
|
||||
}
|
||||
impl Mob {
|
||||
/// `LivingEntity.getCurrentSwingDuration`: 6 ticks unless a swing set its own.
|
||||
pub fn swing_duration(&self) -> i32 {
|
||||
if self.ai.swing_duration > 0 {
|
||||
self.ai.swing_duration
|
||||
} else {
|
||||
6
|
||||
}
|
||||
}
|
||||
/// `SwingState.startIfAble`: a swing past the first half of the current
|
||||
/// one restarts it.
|
||||
fn start_swing(&mut self, duration: i32) -> bool {
|
||||
let half = self.swing_duration() / 2;
|
||||
if self.ai.swinging && self.ai.swing_time >= 0 && self.ai.swing_time < half {
|
||||
return false;
|
||||
}
|
||||
self.ai.swing_time = -1;
|
||||
self.ai.swinging = true;
|
||||
self.ai.swing_duration = duration.max(1);
|
||||
true
|
||||
}
|
||||
/// LivingEntity.swing
|
||||
pub fn swing(&mut self) {
|
||||
if !self.ai.swinging || self.ai.swing_time >= 3 || self.ai.swing_time < 0 {
|
||||
self.ai.swing_time = -1;
|
||||
self.ai.swinging = true;
|
||||
if self.start_swing(6) {
|
||||
self.extra.swing_right_arm = None;
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.swing(hand, animation, false)` for a swing of `duration`
|
||||
/// ticks (`getModifiedSwingDuration`): the arm is `hand.asArm(mainArm)`.
|
||||
pub fn swing_hand(&mut self, off_hand: bool, duration: i32) {
|
||||
if self.start_swing(duration) {
|
||||
self.extra.swing_right_arm = Some(!off_hand != self.main_arm_left());
|
||||
}
|
||||
}
|
||||
/// `swing_hand` for a swing of `base` ticks, lengthened or shortened by
|
||||
/// this mob's haste, conduit power or mining fatigue
|
||||
/// (`LivingEntity.getModifiedSwingDuration`).
|
||||
pub fn swing_hand_modified(&mut self, off_hand: bool, base: i32) {
|
||||
let amplifier = |id: &str| self.extra.effects.iter().find(|e| e.0 == id).map(|e| e.2);
|
||||
let dig_speed = amplifier("minecraft:haste").max(amplifier("minecraft:conduit_power"));
|
||||
let fatigue = amplifier("minecraft:mining_fatigue");
|
||||
let duration = crate::first_person::swing_duration(
|
||||
crate::first_person::SwingAnimation {
|
||||
kind: 1,
|
||||
duration: base,
|
||||
},
|
||||
dig_speed,
|
||||
fatigue,
|
||||
);
|
||||
self.swing_hand(off_hand, duration);
|
||||
}
|
||||
}
|
||||
/// The module implementing a species' behaviour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -540,18 +585,21 @@ fn effects_from_blocks(m: &mut Mob, w: &MobWorld) {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// LivingEntity.updateSwingTime (6-tick swing duration).
|
||||
/// LivingEntity.updateSwingTime.
|
||||
pub fn tick_swing(m: &mut Mob) {
|
||||
let duration = m.swing_duration();
|
||||
if m.ai.swinging {
|
||||
m.ai.swing_time += 1;
|
||||
if m.ai.swing_time >= 6 {
|
||||
if m.ai.swing_time >= duration {
|
||||
m.ai.swing_time = 0;
|
||||
m.ai.swinging = false;
|
||||
m.ai.swing_duration = 0;
|
||||
m.extra.swing_right_arm = None;
|
||||
}
|
||||
} else {
|
||||
m.ai.swing_time = 0;
|
||||
}
|
||||
m.anim.attack_anim = m.ai.swing_time as f32 / 6.0;
|
||||
m.anim.attack_anim = m.ai.swing_time as f32 / duration as f32;
|
||||
}
|
||||
/// `Entity.isInRain`: rain at the block position, or at the top of the box.
|
||||
pub(super) fn in_rain(m: &Mob, w: &MobWorld) -> bool {
|
||||
@@ -587,6 +635,9 @@ fn axolotl_air_supply(m: &mut Mob, w: &MobWorld) {
|
||||
fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
m.was_eye_in_water = m.fluids.eye_in_water;
|
||||
m.update_fluids(w, events);
|
||||
if m.species() == Species::Drowned {
|
||||
super::undead::drowned_update_swimming(m, w);
|
||||
}
|
||||
let fire = crate::motion::environment::fire_contact(w.world, w.registry, m.bounds());
|
||||
if fire > 0.0 {
|
||||
m.ticks_frozen = 0;
|
||||
|
||||
@@ -540,6 +540,26 @@ fn rot_lerp_rad(a: f32, from: f32, to: f32) -> f32 {
|
||||
}
|
||||
from + a * diff
|
||||
}
|
||||
/// `DrownedModel.setupSwimAnimation`: the arms reach up and sway, the legs
|
||||
/// kick and the head lies straight.
|
||||
fn drowned_swim(p: &mut Poses, s: &State, amount: f32) {
|
||||
if amount <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let sway = amount * 0.35 * (0.1 * s.age).msin();
|
||||
let kick = amount * 0.55 * (0.1 * s.age).msin();
|
||||
let reach = -PI * 4.0 / 5.0;
|
||||
let (right_x, left_x) = (p.get("right_arm")[3], p.get("left_arm")[3]);
|
||||
let (right_z, left_z) = (p.get("right_arm")[5], p.get("left_arm")[5]);
|
||||
p.xrot("right_arm", rot_lerp_rad(amount, right_x, reach) + sway);
|
||||
p.xrot("left_arm", rot_lerp_rad(amount, left_x, reach) - sway);
|
||||
p.zrot("right_arm", rot_lerp_rad(amount, right_z, -0.15));
|
||||
p.zrot("left_arm", rot_lerp_rad(amount, left_z, 0.15));
|
||||
let (left_leg, right_leg) = (p.get("left_leg")[3], p.get("right_leg")[3]);
|
||||
p.xrot("left_leg", left_leg - kick);
|
||||
p.xrot("right_leg", right_leg + kick);
|
||||
p.xrot("head", 0.0);
|
||||
}
|
||||
/// `HumanoidModel.setupSwimAnimation`: the stroke of both arms over 26
|
||||
/// walk-animation units, the head down and a slow flutter kick. The left arm
|
||||
/// eases the short way round, the right one linearly, as in vanilla.
|
||||
@@ -697,6 +717,9 @@ pub fn setup(
|
||||
h.crouching = m.extra.crouching;
|
||||
h.passenger = m.extra.passenger;
|
||||
humanoid(p, s, &h);
|
||||
if m.kind.anim == Anim::Drowned {
|
||||
drowned_swim(p, s, m.extra.swim.amount(s.partial));
|
||||
}
|
||||
if m.kind.anim == Anim::ZombifiedPiglin {
|
||||
super::piglin::ears(p, s);
|
||||
}
|
||||
|
||||
@@ -405,3 +405,43 @@ impl Goal for Rolled {
|
||||
scared(m)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mobs::sensor_scan_tests::kind;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// An armadillo on the ground.
|
||||
fn armadillo() -> Mob {
|
||||
let mut kind = kind();
|
||||
let k = Arc::make_mut(&mut kind);
|
||||
k.id = "minecraft:armadillo".into();
|
||||
k.species = Species::Armadillo;
|
||||
let mut m = Mob::new(1, kind, Vec3::ZERO, 0.0);
|
||||
m.on_ground = true;
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hit_rolls_it_up_into_a_ball() {
|
||||
// Armadillo.actuallyHurt: a hit by a living thing curls it up and
|
||||
// keeps the danger in mind for 80 ticks, so the model shows the
|
||||
// shell instead of the armadillo (#2759).
|
||||
let mut m = armadillo();
|
||||
after_hurt(&mut m, &Damage::player([0.0, 0.0, 0.0]));
|
||||
assert_eq!(state(&m), ROLLING);
|
||||
// Armadillo.rollUp: the roll sound.
|
||||
assert!(
|
||||
m.outbox
|
||||
.iter()
|
||||
.any(|e| matches!(e, MobEvent::Sound(s, ..) if *s == "entity.armadillo.roll")),
|
||||
"the roll plays its sound"
|
||||
);
|
||||
// ArmadilloState.shouldHideInShell: the shell shows after five ticks.
|
||||
for _ in 0..6 {
|
||||
tick(&mut m);
|
||||
}
|
||||
assert!(hiding_in_shell(&m), "the model shows the shell");
|
||||
}
|
||||
}
|
||||
@@ -471,6 +471,11 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
if m.extra.counter > 0 {
|
||||
m.extra.counter -= 1;
|
||||
}
|
||||
animate(m);
|
||||
}
|
||||
/// `AbstractHorse.tick`'s eat, stand and mouth animations, which follow the
|
||||
/// eating and standing flags (`flag` and `counter > 0`).
|
||||
pub fn animate(m: &mut Mob) {
|
||||
let (eating, standing) = (m.extra.flag, m.extra.counter > 0);
|
||||
let v = &mut m.anim.v;
|
||||
if standing {
|
||||
@@ -488,6 +493,22 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
}
|
||||
v[MOUTH] = (v[MOUTH] - v[MOUTH] * 0.7 - 0.05).max(0.0);
|
||||
}
|
||||
/// `AbstractHorse.tick` on a client that is not the server: the animations
|
||||
/// follow the flags of the synched data (`set_flags`).
|
||||
pub fn client_tick(m: &mut Mob) {
|
||||
m.anim.vo = m.anim.v;
|
||||
animate(m);
|
||||
}
|
||||
/// `AbstractHorse.DATA_ID_FLAGS` on a client: eating 16 and standing 32.
|
||||
pub fn set_flags(m: &mut Mob, flags: u8) {
|
||||
m.extra.flag = flags & 16 != 0;
|
||||
m.extra.counter = i32::from(flags & 32 != 0);
|
||||
}
|
||||
/// Whether `ai_step` above runs this species' grazing and rearing, so that
|
||||
/// the server's flags 16 and 32 mean eating and standing.
|
||||
pub fn grazes(s: Species) -> bool {
|
||||
is_equine(s) && !super::camel::is_camel(s) && !super::llama::is_llama(s)
|
||||
}
|
||||
/// Body texture and the markings overlay.
|
||||
pub fn texture(m: &Mob) -> String {
|
||||
let baby = if m.is_baby() { "_baby" } else { "" };
|
||||
|
||||
Loaded 100 of 274 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user