Compare commits

...
Author SHA1 Message Date
Ludovic Marie a3acb5f0d2 Merge pull request #3082 from Domanffe/test/port-harness-symlink-skip
🤖 Agent: read the diff at f4d6f9c0e: test_port_harness skips the reports-link case where the host can't make symlinks. verify green. Queued for the merger.
2026-10-28 20:22:19 +01:00
Ludovic Marie 49b77529dd Merge pull request #2811 from ESHAYAT102/feat/server-delete-confirmation
🤖 Agent: read the diff at 84390f6dc: Delete server in its settings: refused unless the server is in the list, not installed as a service, and its supervisor lock (`.horizon/lock`) can be taken; the folder goes to the system trash, never `rm`. Unit tests cover a live lock, a non-server folder and a failing trash. verify and launcher green; no harness reaches it. Queued for the merger, which waits for this head's parity run.
2026-10-28 20:22:15 +01:00
Ludovic Marie dafa794cb3 Merge pull request #2762 from ESHAYAT102/fix/stopped-server-settings
🤖 Agent: read the diff at bcdbc252f: the sidebar's stopped-server entry now goes through `open_server`, which loads the server's settings instead of only setting `servers.open`. verify and launcher green; no harness reaches launcher code. Queued for the merger, which waits for this head's parity run.
2026-10-28 20:22:09 +01:00
Cosmic b7df7bd9e0 Merge pull request #3318 from degepie/fix/accept-26.3-republish-5
Prepare: accept Mojang's fifth 26.3 republish (5df1795e, index 917ff6d7)
2026-10-28 15:17:44 -04:00
Ludovic Marie a1e56a4d1b Merge pull request #3284 from horizon-rust/perf/sign-text-cache
🤖 Agent: verified. 3458 text signs in view, same scene/build profile, Mac mini: 124 → 222 fps (CPU 8.01 → 4.46 ms/frame), no-sign view unchanged (~1620 fps); look unchanged (pixel diff 6/268800, below run-to-run noise). New unit test checks the cached text against the uncached renderer across text/glow/state/font changes; entity batch equivalence test passes. Local: fmt, clippy -D warnings, horizon-client tests, Python tests and the 50 parity harnesses parity.py --changed picks all pass; CI verify green.
2026-10-28 20:03:48 +01:00
Ludovic Marie ab32a64d08 Merge pull request #2791 from levzzz5154/fix/spawn-egg-baby
🤖 Agent: read the diff at 70068076f: spawn-egg offspring rule split into `spawn_egg_is_for` / `spawn_egg_offspring` (`SpawnEggItem.spawnOffspringFromSpawnEgg`), behaviour unchanged, with a test. verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 20:03:44 +01:00
Ludovic Marie f843f28a27 Merge pull request #2748 from levzzz5154/fix/turtle-walk-animation-rate
🤖 Agent: read the diff at 186198afe: test only: the turtle's limb speed follows its walk (`LivingEntity.updateWalkAnimation`, `TurtleMoveControl`). verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 20:01:08 +01:00
Ludovic Marie abcc2e7c80 Merge pull request #3238 from masa-masa-0314/fix/show-dialog-click
🤖 Agent: read the diff at 4d477067c ("Chat and book links: a show_dialog click event opens its dialog") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 20:01:04 +01:00
Ludovic Marie ac78481794 Merge pull request #3210 from PH4N7OMx/codex/windows-default-browser
🤖 Agent: read the diff at 998c94bee ("fix(windows): open URLs with the registered protocol handler") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 20:00:59 +01:00
Ludovic Marie 650601848f Merge pull request #3127 from kacpherek/fix/shulker-bullet-model
🤖 Agent: read the diff at 4a18a4bde ("Shulker bullets are drawn as the spinning bullet model, not a flat spr") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 20:00:55 +01:00
Ludovic Marie f9ea299952 Merge pull request #3124 from kacpherek/fix/mob-crowd-push
🤖 Agent: read the diff at f6b467eba ("Crowded mobs take cramming damage, as vanilla") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 20:00:50 +01:00
Ludovic Marie b6eb028bb6 Merge pull request #3100 from Domanffe/perf/name-tags-in-range-first
🤖 Agent: read the diff at 5f8afb17f ("Name tags: copy a custom name only inside the tag's range") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:57:52 +01:00
Ludovic Marie 9dffa8a2a9 Merge pull request #3097 from Domanffe/perf/effect-panel-strings
🤖 Agent: read the diff at f7bb1ceb7 ("Effect panels: borrow the name and reuse the icon key") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:57:48 +01:00
Ludovic Marie cd89bc6dc1 Merge pull request #3095 from Domanffe/perf/server-row-sprite-path
🤖 Agent: read the diff at b9494af97 ("Server list: build each row's sprite key once") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:57:44 +01:00
Ludovic Marie 8a863ad378 Merge pull request #3092 from Domanffe/perf/creative-tab-by-reference
🤖 Agent: read the diff at d1ead6a5f ("Creative screen: draw the selected tab from a reference") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:57:40 +01:00
Ludovic Marie a27561da88 Merge pull request #3086 from Domanffe/perf/draw-slot-item-by-reference
🤖 Agent: read the diff at 3511d556e ("Inventory draw: a slot is drawn from a reference, not a copy") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:57:36 +01:00
Ludovic Marie ed7213df2e Merge pull request #3083 from Domanffe/perf/hud-paint-reuses-sprite-key
🤖 Agent: read the diff at 4bf633e8a ("HUD paint: build the sprite key in one reused buffer") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:54:50 +01:00
Ludovic Marie b1b27240d4 Merge pull request #3070 from iLostName/boat-view-clamp-every-turn
🤖 Agent: read the diff at a1b32304a ("boat: the view stops at 105 degrees at every turn, so it doesn't jitte") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:54:45 +01:00
Ludovic Marie 8a2a31e6a3 Merge pull request #3069 from iLostName/no-hitting-ridden-mob
🤖 Agent: read the diff at 7934a0b18 ("riding: the crosshair skips the mob being ridden, so it can't be hit") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:54:41 +01:00
Ludovic Marie 0264b39aa6 Merge pull request #3068 from iLostName/creeper-death-message
🤖 Agent: read the diff at 992c63eef ("death message: a creeper's blast names the creeper, not the last mob t") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:54:37 +01:00
Ludovic Marie 83935015cf Merge pull request #3067 from iLostName/pickup-fills-offhand
🤖 Agent: read the diff at a967ca434 ("item pickup: a stack in the off hand fills before the inventory, as ge") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:54:33 +01:00
Ludovic Marie 506a9c8f01 Merge pull request #3065 from iLostName/f1-only-without-screen
🤖 Agent: read the diff at c0b560527 ("F1 hides the HUD only with no screen open, as key.toggleGui does") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:50:56 +01:00
Ludovic Marie 475b22ff19 Merge pull request #3063 from iLostName/redstone-ore-lights-on-use
🤖 Agent: read the diff at e06134f71 ("redstone ore: a right-click lights it, as RedStoneOreBlock.useItemOn d") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:50:51 +01:00
Ludovic Marie 0d1f56e015 Merge pull request #3060 from kacpherek/fix/name-tags-face-front-camera
🤖 Agent: read the diff at c66554126 ("Name tags read the right way in the front third-person view") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:50:46 +01:00
Ludovic Marie 958c02593c Merge pull request #3056 from kacpherek/fix/spear-charge-while-riding
🤖 Agent: read the diff at 4a1c5fc54 ("Spears: charges hit while riding, and a stab stops the sprint") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:50:42 +01:00
Ludovic Marie a266eee6d2 Merge pull request #3102 from Domanffe/fix/random-sequences-write-failure
🤖 Agent: read the diff at 23d214606: the villagers' random_sequences write logs a warning on failure. verify green. Queued for the merger.
2026-10-28 19:50:37 +01:00
Ludovic Marie 62898b4cf6 Merge pull request #3053 from kacpherek/fix/entity-shadows-no-cutout
🤖 Agent: read the diff at 012a3cf90 ("Draw entity shadows in dim light like vanilla") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:47:45 +01:00
Ludovic Marie d08e05283a Merge pull request #3038 from hectoreccles/perf/spawn-candidate-buffers
🤖 Agent: read the diff at a48fcf0d1 ("Reuse natural spawn candidate buffers") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:47:41 +01:00
Ludovic Marie e7a07f88b3 Merge pull request #3035 from hectoreccles/perf/streaming-scan-interval
🤖 Agent: read the diff at 2eb3de274 ("Sweep far chunks every eighth tick") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:47:36 +01:00
Ludovic Marie 4e59dab892 Merge pull request #3033 from hectoreccles/perf/block-entity-ticker-list
🤖 Agent: read the diff at 67511d974 ("Reuse the block entity ticker list between ticks") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:47:32 +01:00
Ludovic Marie a1f4841526 Merge pull request #3032 from hectoreccles/perf/hopper-cooldown-first
🤖 Agent: read the diff at df90f62d3 ("Hopper ticks check their block only when they move") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:47:27 +01:00
Ludovic Marie 7dd4b772c9 Merge pull request #2997 from feeldev12/feat/link-prompt-copy
🤖 Agent: read the diff at 5c14d1814 ("fix(client): the link prompt has ConfirmLinkScreen's Copy to Clipboard") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:44:13 +01:00
Ludovic Marie 03dd78d67b Merge pull request #2994 from kacpherek/fix/particles-sounds-parity
🤖 Agent: read the diff at 0f8e3a9b0 ("Match vanilla mob and arrow sounds: horses, hostile fall and swim, hur") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:44:09 +01:00
Ludovic Marie 6267cd3e15 Merge pull request #2992 from kacpherek/fix/mob-ai-villager-parity
🤖 Agent: read the diff at efa8df83b ("Villagers hear bells, use bone meal, fetch dropped items and play tag ") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:44:04 +01:00
Ludovic Marie 265640ac18 Merge pull request #2991 from kacpherek/fix/block-behaviour-parity
🤖 Agent: read the diff at ffb659460 ("Match vanilla for eight block behaviours: ender chest, doors, hanging ") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:44:00 +01:00
Ludovic Marie f5a3088f0d Merge pull request #2985 from kacpherek/fix/hostile-mob-parity
🤖 Agent: read the diff at f69088012 ("Hostile mobs: hoglin timing, crossbow aim, witch resistance, wither sk") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:43:56 +01:00
Ludovic Marie 3ccbba76df Merge pull request #2984 from kacpherek/fix/passive-mob-parity
🤖 Agent: read the diff at 58422bcc1 ("Passive mobs: sound variants, donkey and mule stats, no mule breeding,") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:39:39 +01:00
Ludovic Marie c519ff6c56 Merge pull request #2977 from ASOwnerYT/fix/2832-return-to-sender
🤖 Agent: read the diff at c4348be8d ("Return to Sender needs the ghast's own fireball: check player_killed_e") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:39:34 +01:00
Ludovic Marie 400d15ab3f Merge pull request #2975 from kacpherek/fix/advancement-triggers-small
🤖 Agent: read the diff at c3e19973c ("Fire lightning, spear, nest, shear, allay and sculk advancement trigge") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:39:29 +01:00
Ludovic Marie d6971605d4 Merge pull request #2957 from kacpherek/fix/smooth-camera-mouse
🤖 Agent: read the diff at 7ab2b20bd ("Let a mouse button toggle the cinematic camera") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:39:24 +01:00
Ludovic Marie 7dc818eb5b Merge pull request #2948 from kacpherek/fix/hdr-crash-macos
🤖 Agent: read the diff at dddcc9972 ("Fix the crash on every start with HDR on in macOS, and start with HDR ") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:39:20 +01:00
Ludovic Marie 2772738d47 Merge pull request #2938 from ASOwnerYT/fix/2802-projectile-death-message
🤖 Agent: read the diff at 513aed64c ("Name the projectile in the death message when it has no owner") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:35:20 +01:00
Ludovic Marie 131625c414 Merge pull request #2906 from kacpherek/fix/2904-fullscreen-crash
🤖 Agent: read the diff at 22cab3eeb ("Open windowed after the window system kills the game in fullscreen") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:35:15 +01:00
Ludovic Marie 478ae35b53 Merge pull request #2880 from leifiyoo/dyed-leather-armour-colour
🤖 Agent: read the diff at 8057d2751 ("Worn dyed leather armour shows its dye colour") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:35:11 +01:00
Ludovic Marie 5f868011ee Merge pull request #2843 from levzzz5154/fix/breeze-wind-scroll
🤖 Agent: read the diff at f507a067c ("The breeze's wind and the wind charge scroll their textures") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:35:06 +01:00
Ludovic Marie 67712eeeef Merge pull request #2836 from levzzz5154/fix/spawn-egg-persistence
🤖 Agent: read the diff at 28f906c37 ("A summoned or egg-spawned mob is not persistent") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:35:02 +01:00
Ludovic Marie 5b7d19f02b Merge pull request #3080 from Domanffe/fix/port-regen-python-that-runs
🤖 Agent: read the diff at 93e61ef93: port_regen.python() probes python3/python/py and keeps the first that runs Python 3 (cached), with a test. verify green. Queued for the merger.
2026-10-28 19:30:16 +01:00
Ludovic Marie f66f66c3b4 Merge pull request #2815 from sirrayi/fix/zip-untrusted-sizes
🤖 Agent: read the diff at ce848af06: zip hardening: the raw buffer's capacity is capped at the archive length and inflate at 1 GiB with saturating maths, so a zip64 entry claiming u64::MAX/2 errs instead of aborting; real packs are unchanged (the limit stays size*2+1024 below 1 GiB). Test included. verify green; no harness reaches it. Queued for the merger, which waits for this head's parity run.
2026-10-28 18:39:20 +01:00
Ludovic Marie 7cbb8c4506 Merge pull request #2808 from levzzz5154/fix/shulker-body-yaw
🤖 Agent: read the diff at a8903cba0: test only: the shulker's body stays at 0 while its head turns (`ShulkerBodyRotationControl`, `ShulkerLookControl`). verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 18:35:28 +01:00
Ludovic Marie 6a05885ee3 Merge pull request #2797 from levzzz5154/fix/screen-cursor-centre
🤖 Agent: read the diff at 1d693cb3c: `MouseHandler.grabMouse`/`releaseMouse`: the cursor is centred only when the grab state changes; fixture test updated. verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 18:35:22 +01:00
Ludovic Marie c979ce46e9 Merge pull request #2771 from ASOwnerYT/claude/select-world-blank-deletion-332d14
🤖 Agent: read the diff at f10558380: `WorldSelectionList.handleNewLevels`: an emptied list opens Create New World, whose Cancel returns to the title. verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 18:35:13 +01:00
Ludovic Marie 0c5c38ac9e Merge pull request #2736 from levzzz5154/fix/2273-entity-view-scale
🤖 Agent: read the diff at fa940d5b6: `entity_view_scale` extracted from `Entity.setViewScale` (clamp(renderDistance / 8, 1, 2.5) * entityDistanceScaling) with tests for the 48-block chicken range. verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 18:35:09 +01:00
Ludovic Marie ae3b9e273f Merge pull request #2732 from levzzz5154/fix/mining-hit-sound
🤖 Agent: read the diff at fcc7a818b: the remote-mining fixture now records that the hit sound plays once at dig start (`ClientLevel.addBreakingBlockEffects`), plus the `dig_sounds` LAN check for #2277. verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 18:35:04 +01:00
Ludovic Marie d0b6e48921 Merge pull request #3281 from horizon-rust/perf/world-entry-decoration-cache
🤖 Agent: verify green; pr_ready on the Mac mini passes (94 affected harnesses, 93 run and pass incl. the worldgen ones; lan_integration by name only; new test decoration_is_shared_by_biome_source). Through the Singleplayer screen, 2 workers, medians of 5: client joined 0.45 → 0.31 s (create) and 0.46 → 0.31 s (reload); reload first controllable frame 0.60 → 0.42 s.
2026-10-28 18:20:09 +01:00
Ludovic Marie 07e4a86658 Merge pull request #2817 from sirrayi/fix/server-http-line-limit
🤖 Agent: read the diff at d3be8dbf0: horizon-server HTTP API: request and header lines over 8 KiB are a 400 instead of growing without bound; test with a 100 KB line. verify green; no harness reaches it. Queued for the merger, which waits for this head's parity run.
2026-10-28 17:58:46 +01:00
degepieandClaude Opus 5.5 3c98062662 Prepare: accept Mojang's fifth 26.3 republish (5df1795e, index 917ff6d7)
Mojang republished the 26.3 version JSON again. The live copy (5df1795e)
points at asset index 34 917ff6d7; neither was in research-lock.json, so
`horizon-prepare --prepare` from a fresh official-launcher install stops
with "is not a 26.3 version file Horizon knows", and the sounds step warns
about an unknown asset index. Jars, libraries and javaVersion are
unchanged; the index differs from 0c8f43b2 only in 142 minecraft/lang/*
objects and pack.mcmeta (uz_uz moved in the language list).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-28 21:51:58 +05:00
Ludovic Marie e1b2b0b2fd Merge pull request #2742 from levzzz5154/fix/fire-animated-textures
🤖 Agent: read the diff at a77639022: test only: every graphics preset keeps animated textures on (#2064 stays fixed). verify green; parity selects no harness for it. Queued for the merger, which waits for this head's parity run.
2026-10-28 17:47:18 +01:00
levzzzandCodex 9755416a02 Fix Windows path assertions in the launcher toolchain test (#3285)
Compare managed compiler and PATH entries as platform paths, including Windows executable suffixes. Fixes the Windows assertion that blocked the native-capable launcher release.

Validation: pr_ready passed (401 Python and 219 launcher tests), and the release dry run passed tests and packaged smoke checks on Linux, macOS and Windows.

Co-Authored-By: Codex <[email protected]>
2026-10-28 21:42:53 +05:00
Ludovic MarieandClaude Opus 5.5 0281365907 Signs: keep laid-out sign text between frames, batch entity quads in runs
A wall of 3458 text signs ran at 128 fps against 1642 without them: each
frame laid every sign's text out again glyph by glyph (Font::glyph_of was a
quarter of the main thread), then regrouped every entity quad by material
through a per-quad index list.

- block_entity_render: a sign's text vertices are kept relative to its
  block and moved to the camera each frame. They are laid out again when
  the sign data, block state, light, glowing outline distance or font
  (Font::epoch: a new font, a glyph cache reset, the Unicode and Japanese
  options) changes; signs out of view for 100 ticks are dropped.
- gfx/entity_batch: quads are grouped in runs of one material and whole
  runs copied (2-4x faster on model-like input, the same when every quad
  changes material).

Fixes #1461 (the cost per sign; the vanilla text distance of 64 blocks
and the look are unchanged).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:28:08 +01:00
rdbt a5fcb49900 Merge pull request #3262 from Domanffe/fix/chat-log-duplicate
Log a chat message once, as [CHAT]
2026-10-28 16:27:04 +00:00
Ludovic MarieandClaude Opus 5.5 6f6229c2d4 Worldgen: build a biome source's feature decoration once, ahead of the world
`Decoration::new` decodes the placed features of every biome a source can
place: about 140 ms of the ~185 ms `OverworldGenerator::for_world` took on
each world opening (Mac mini). It depends on the worldgen resources and the
biome source alone, not on the seed, so `GeneratorData` now keeps it by
biome source (`decoration_for`, like the ring cache) and every generator
built for that source shares it.

The world screens' preload (#2481) already reads the worldgen bundle while
the player picks or creates a world; it now also builds the Overworld's
decoration (`prepare_decoration`), so opening the world finds it ready.
Reopening a world or returning to a dimension in the same session builds
nothing again either.

Mac mini, --profile fast, offscreen, seed 1234, RD 12, 2 workers, server
thread, through the Singleplayer screen, medians of 5 alternated with main:
  server ready to join: create 0.45 -> 0.31 s, reload 0.46 -> 0.31 s
  first controllable frame: reload 0.60 -> 0.42 s, create 0.85 -> 0.79 s

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:22:05 +01:00
Ludovic Marie 0ca3a5be98 Merge pull request #3077 from Domanffe/test/parity-reach-posix-fixture
🤖 Agent: read the diff at aef4186de: test_parity_reach's fixture records dependencies with `as_posix()`, as the tool does. verify green. Queued for the merger.
2026-10-28 17:19:37 +01:00
Ludovic Marie 8993772de0 Merge pull request #3078 from Domanffe/fix/port-harness-windows-log-path
🤖 Agent: read the diff at 28e3286e4: port_harness also picks up Windows `C:...x.log` paths from javac's "see ..." lines. verify green. Queued for the merger.
2026-10-28 17:00:51 +01:00
Ludovic Marie 6a261a00a5 Merge pull request #2886 from socksprox/fix/launcher-forget-offline-name
🤖 Agent: read the diff at 5096b4110: launcher offline names: Forget also deletes the name's skin and cape files, async skin picks bail if the name was forgotten meanwhile, account rows load-mutate-save, and "New name" rolls a fresh PlayerNNNNN into every game home. Tests included. verify and launcher green; no harness reaches launcher code. Queued for the merger, which waits for this head's parity run.
2026-10-28 17:00:45 +01:00
Ludovic Marie f8b83eea51 Merge pull request #3250 from horizon-rust/perf/world-entry-pump-between-ticks
🤖 Agent: verify green; pr_ready on the Mac mini passes (34 affected harnesses, 33 run and pass, lan_integration by name: 7 lan_e2e checks incl. spawned/respawn/portals/forced-levels pass). World entry, 2 workers, RD 12, medians of 3: create 8.05 → 3.57 s, reload 3.72 → 1.42 s to all view columns loaded.
2026-10-28 16:54:43 +01:00
Alexander KrylovandHermes Agent 88398e8bda Log a chat message once
`chat_line` logged `[chat]` and then `Chat::add_message` logged `[CHAT]` for
the same message, so every line appeared twice, and the `[chat]` copy slipped
past the `[CHAT]` filter `log_for_report` uses to keep chat out of an attached
log.

Vanilla's `ChatComponent.logChatMessage` writes the one line `add_message`
already writes, so the `chat_line` call is the leftover.

Fixes #3261

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 18:36:21 +03:00
Ludovic Marie 6d3489d30b Merge pull request #3064 from iLostName/trial-spawner-spawn-egg
🤖 Agent: read the diff at 557a82738: a spawn egg on a trial spawner follows `TrialSpawner.overrideEntityToSpawn` -> `FullConfig.overrideEntity` in the 26.3 source: data reset, normal and ominous configs replaced by `Holder.direct(withSpawning(type))` (saved inline through the new `TrialConfig::encode`, `optionalFieldOf` defaults left out), then `setState(INACTIVE)`; plain spawners unchanged. Round-trip test included. verify and parity green on this head. Queued for the merger.
2026-10-28 16:35:54 +01:00
Ludovic MarieandClaude Opus 5.5 7a84c93843 Server: feed the chunk workers between ticks while players' chunks load
A server thread (single player's, a LAN host's, a dedicated server's) ran
`streaming.update` once a tick, and `schedule_generation` keeps at most
three load or generate jobs per worker in flight. Joining a world was
capped at about six columns a tick (120 a second with two workers) while
the workers idled most of each tick. Vanilla's server runs its chunk
tasks while it waits for the next tick (`MinecraftServer.waitUntilNextTick`).

`pump_pregen`, now `pump_chunks`, already did that for a /pregen task and
force-loaded chunks; it now also pumps while the players' chunks are in
flight, except while the server is paused or a respawn search waits for
a chunk (its area is only asked for by the tick's own update).

Mac mini, --profile fast, offscreen, seed 1234, RD 12, server thread,
medians of 3, all 637 view columns in and meshed once:
  2 workers: create 8.05 -> 3.57 s, reload 3.72 -> 1.42 s
  4 workers: create 4.97 -> 2.16 s, reload 2.23 -> 1.27 s

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 16:26:03 +01:00
Ludovic Marie cba52f4e5d Merge pull request #3218 from horizon-rust/fix-server-junction
🤖 Agent: verified: verify, launcher and parity-scope green; local fmt, clippy -D warnings and launcher tests pass. Fixes #3200. Queued for merge.
2026-10-28 16:04:17 +01:00
masa-masa-0314andZCode 4d477067c3 Chat and books: open a show_dialog click event's dialog
A server may put a show_dialog click event on a chat line or a book
page; ClickEvent.Action.SHOW_DIALOG allows it from a server, and vanilla
opens the dialog through Screen.defaultHandleGameClickEvent. Horizon
dropped the click event while parsing the component (no arm for it in
component.rs, no ClickEvent variant to carry it), so clicking the text
did nothing.

The dialog now survives as ClickEvent::ShowDialog (a registry id or the
inline dialog), parses from click_event, round-trips back out, and both
click handlers (chat and BookViewScreen) open it with show_dialog_ref,
the same path a server's show_dialog packet takes.

Co-Authored-By: ZCode <[email protected]>
2026-10-28 22:52:46 +08:00
Ludovic MarieandClaude Opus 5.5 9f1fa0a0a2 launcher: quote server junction paths and show why mklink failed
Creating a server on Windows failed with a bare "mklink /J failed" (#3200).
servers.rs passed the paths to cmd unquoted, so a user folder with `&`, `^`
or `(` broke cmd's parsing; sources.rs already quoted them. Both now use
one sources::junction, which also puts cmd's own message in the error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 15:15:13 +01:00
Ludovic Marie fe84cc07a1 Merge pull request #3208 from horizon-rust/launcher-sidebar-sponsor
🤖 Agent: verified: verify, launcher and parity-scope green; local cargo fmt, clippy -D warnings and launcher tests pass. Queued for merge.
2026-10-28 14:55:08 +01:00
Ludovic Marie d4e42cf726 Merge pull request #3195 from horizon-rust/feat/launcher-native-mods-window
🤖 Agent: proven with a real launcher GUI run in isolated dirs (Settings → Native mods row, red warning naming veinminer 0.3.0, trust → cached build reused, status line); launcher clippy and 265 tests green; #3194 merged and this now targets main with only its own 5 files. Verify green at 505de825.
2026-10-28 14:51:41 +01:00
PH4N7OMxandOpenAI Codex 998c94bee3 fix(windows): open URLs with the registered protocol handler
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-28 14:46:01 +01:00
Ludovic MarieandClaude Opus 5.5 466512c90e launcher: a Sponsor link in the sidebar footer
The GitHub Sponsors link was only on the patch notes page. Show it beside
Discord and Website in the sidebar footer (heart icon), from links.json's
sponsor entry, translated in fr and ru.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:44:51 +01:00
Ludovic Marie 4db071e281 Merge pull request #3194 from horizon-rust/feat/launcher-native-mods-page
🤖 Agent: proven with a real launcher run in isolated dirs: registry install of Veinminer's .hnmod into an instance, native-build of that instance's enabled native mods, instance pinned to the build; launcher clippy and tests green after merging main (#3140 and #3189 merged). Verify green at 1f6511d0.
2026-10-28 14:40:43 +01:00
Ludovic Marie 2649c5e506 Merge pull request #3202 from horizon-rust/ci/nightly-cap-12
Nightly: 12 builds a day instead of 6
2026-10-28 14:27:40 +01:00
Ludovic MarieandClaude Opus 5.5 505de825db Merge feat/launcher-native-mods-page (main merged in)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:27:32 +01:00
Ludovic MarieandClaude Opus 5.5 1f6511d0f3 Merge main into feat/launcher-native-mods-page (#3140 and #3189 merged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:26:02 +01:00
Ludovic MarieandClaude Opus 5.5 1523c118d8 Nightly: 12 builds a day instead of 6
The Team tests fixes on the Nightly and hit the cap of 6 builds a day
(Ludovic, 2026-10-28: raise it to 12).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:21:08 +01:00
Ludovic Marie 9239089b76 Merge pull request #3140 from levzzz5154/feat/native-mod-bundles
🤖 Agent: merging levzzz5154's native-mod registry kind; proven with the real Veinminer .hnmod through registry build, the launcher's install check and a native build that ran (see comment).
2026-10-28 14:20:09 +01:00
Ludovic Marie 281ab9759c Merge pull request #3180 from horizon-rust/refactor/furnace-cooking
🤖 Agent: verify passed at 8b5a27ed3. Move only (crate split B7): the furnace's recipe logic leaves containers as the FurnaceCooking extension trait. Full pr_ready green on this head (1261 tests, parity 69 affected / 0 failed); #2959, #2960 and #3131 still merge cleanly. Ready once parity is green.
2026-10-28 14:13:59 +01:00
Ludovic MarieandClaude Opus 5.5 febad05c78 launcher: French and Russian for the Native mods row
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:07:55 +01:00
Ludovic MarieandClaude Opus 5.5 519edfac19 docs: native mods in the launcher window
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:06:21 +01:00
Ludovic Marie 6561df77f6 Merge pull request #3189 from horizon-rust/feat/launcher-native-build
🤖 Agent: merging: native-build compiles horizon-mixins bundles on their pinned Nightly with the launcher's own Rust; proven by building levzzz's Veinminer .hnmod on 83f15fa (580 s cold, 2m46s warm) and /veinminer answering in the built game.
2026-10-28 14:05:32 +01:00
Ludovic MarieandClaude Opus 5.5 cd7f177104 Launcher: a Native mods switch and Build in Settings, with the warning
Settings → Game → Native mods (experimental), off by default. On, Build names the native mods
turned on in the instance Play starts, warns in red that they run with full access to the
computer, and compiles them only from "I trust these mods: build"; the row shows the step while
it runs, the error if it fails, and the build the instance plays. Ported from native-mixins-1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:04:35 +01:00
Ludovic MarieandClaude Opus 5.5 07ab7ccfc1 Merge the native build's measured numbers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:54:36 +01:00
Ludovic MarieandClaude Opus 5.5 727f6c932a docs/native-mods.md: the measured build times and sizes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:54:36 +01:00
Ludovic MarieandClaude Opus 5.5 cd1f58ef72 Launcher: native mods from the registry install and build like the others
- native-mod entries (levzzz5154's #3140) are listed as mods and install as
  horizon-native-mods/<id>.hnmod (the folder #3112's inventory reads), turned on in
  horizon-mods.txt, after the .hnmod is checked as the registry checks it.
- native-build with no folder builds the native mods turned on in the instance, and that
  instance then plays the build (the game's own home builds into the Native mods instance).
- Every registry project says who made it; a project not made by Horizon says it is not made
  or endorsed by the Horizon project, with the mod policy's link (playhorizon.dev/mods/policy).
  A native mod says it runs with full access to the computer and which Horizon it builds on.
- Pruning keeps every native build an instance plays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:54:35 +01:00
Ludovic Marie 291aae00e8 Merge pull request #3174 from horizon-rust/ci/nightly-source
🤖 Agent: merging: nightly publishes horizon-source.tar.gz (signed) and tags its commit nightly-source/<date>-<short>; proven by a full dry run of the workflow (run 40686585965) and tests/test_nightly.py.
2026-10-28 13:49:40 +01:00
Ludovic Marie bec8cce82d Merge pull request #3179 from horizon-rust/feat/launcher-rust-toolchain
🤖 Agent: merging: the launcher's portable Rust 1.99.1 (pinned SHA-256 components, own data dir); proven by a real isolated rust install + crate build on the Mac mini and the launcher tests.
2026-10-28 13:49:37 +01:00
Ludovic MarieandClaude Opus 5.5 0ee2425d40 Merge levzzz5154's #3140 (native-mod registry kind, .hnmod validator)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:44:24 +01:00
Ludovic MarieandClaude Opus 5.5 7f3bd901bc Launcher: build native mods on the Nightly they pin, with its own Rust
horizon-launcher native-build FOLDER --trust-native-code compiles mixin bundles (a folder with a
horizon-mixin.toml, or an unpacked .hnmod from the registry) into a game of their own:
- the Horizon commit the mods pin (all must agree; else the installed version's), its source from
  GitHub by commit (Nightlies keep theirs as nightly-source/ tags);
- horizon-mixins at the commit the mods were tested with, built once with the launcher's Rust;
- horizon-mixins apply, then the release's cargo build, with the portable toolchain;
- installed as native-<commit>-<key>, played by the Native mods instance.
Based on native-mixins-1's unmerged work; docs/native-mods.md describes it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:43:52 +01:00
Ludovic MarieandClaude Opus 5.5 d308df6167 Launcher: a portable Rust toolchain of its own, for native mods
Native mods compile the game on the player's computer (levzzz5154: many native mods per build). The
launcher now downloads the official rustc, cargo and rust-std components of Rust 1.99.1 (plus
rust-mingw on Windows, the windows-gnu host that needs no Visual Studio) from static.rust-lang.org,
each pinned by SHA-256, into <launcher data>/rust/, and runs them with its own CARGO_HOME. No rustup,
nothing installed on the system. `horizon-launcher rust status|install|remove`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:32:09 +01:00
Ludovic MarieandClaude Opus 5.5 5ded675a7d Nightly: publish the exact source, and tag its commit for good
The nightly release names its commit, and now also attaches horizon-source.tar.gz (git archive of that
commit, made from git objects in pick and signed with the builds) and tags the commit
nightly-source/<date>-<short>. The dated release and the nightly/build-* branch are pruned after 7 days;
the tag is not, so native mods pinned to a Nightly's commit keep building.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:16:48 +01:00
Ludovic Marie 564162a0b0 Merge pull request #2741 from horizon-rust/refactor/cut-biome-spawn-parsers
🤖 Agent: verify passed at 0f565f4e4. Move only: parse_entries/parse_costs/SpawnCost go from mobs/spawn_selection.rs to biome.rs (re-exported), cutting biome -> mobs (step A of the cycle plan). pr_ready --quick green (1315 tests). Ready to merge once parity is green.
2026-10-28 13:12:01 +01:00
Ludovic Marie 8b5a27ed3f Merge remote-tracking branch 'origin/main' into refactor/furnace-cooking 2026-10-28 13:11:00 +01:00
Hermes AgentandHermes Agent c0505424bd fix(launcher): keep native sources hidden until build integration lands
Co-Authored-By: Hermes Agent (OpenAI) <[email protected]>
2026-10-28 15:27:51 +05:00
Hermes AgentandHermes Agent 137045e321 feat(mods): validate source-only native mod bundles
Co-Authored-By: Hermes Agent (OpenAI) <[email protected]>
2026-10-28 15:20:36 +05:00
Kacpherek 4a18a4bde4 Draw the shulker bullet with its model instead of a sprite
The bullet fell through to the item billboard and showed the whole spark texture. Vanilla ShulkerBulletRenderer draws the tumbling bullet model plus a 1.5x translucent copy at full block light.
2026-10-28 10:47:31 +01:00
Kacpherek f6b467eba9 Hurt crammed mobs by 6 at the max entity cramming limit
Mobs only drew the cramming random and never took damage, so a crowd was never thinned. Vanilla's LivingEntity.pushEntities hurts a mob by 6 on a quarter of the ticks while max_entity_cramming or more non-riding entities are pushable around it.
2026-10-28 10:47:13 +01:00
AlexanderandHermes Agent 23d2146066 Villagers: report a failed random_sequences write
The merchant's random sequences (the salts that keep its offers stable) were
written with the result dropped, so a failed write rerolled every trade on
the next load with nothing in the log. Log it like the other save paths.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:38:23 +03:00
AlexanderandHermes Agent 5f8afb17fc Name tags: copy a custom name only inside the tag's range
name_tags cloned every named mob's custom name each frame and dropped the tag
afterwards when the mob was beyond 64 blocks. Test the distance first, so the
copy happens only for a mob the frame can show.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:26:00 +03:00
AlexanderandHermes Agent f7bb1ceb79 Effect panels: borrow the name and reuse the icon key
The panel list is painted every frame the inventory is open. An unclipped
name was cloned per panel and the icon key was a fresh format! per panel; the
name is borrowed and the key is built in one buffer now.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:16:09 +03:00
AlexanderandHermes Agent b9494af976 Server list: build each row's sprite key once
The row made the short sprite name (format! or to_owned) and then wrapped it
in format!("gui/sprites/{sprite}") for the atlas. Build the full key in the
match arm and hand it straight to sprite_scaled.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:14:14 +03:00
AlexanderandHermes Agent d1ead6a5fa Clippy: tab is already a reference
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:09:17 +03:00
AlexanderandHermes Agent 3511d556ef rustfmt
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:58:20 +03:00
AlexanderandHermes Agent 4ff7118db7 Creative screen: draw the selected tab from a reference
tab_headers() builds a header per tab without the item lists, and the caller
then cloned the selected one again: three Strings copied every frame the
screen is open. Borrow it instead.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:55:47 +03:00
AlexanderandHermes Agent 626068da3a Inventory draw: a slot is drawn from a reference, not a copy
draw_slot_item needs neither ownership nor &mut self: it reads the carried
stack only for a drag. Taking `&Option<ItemStack>` and &self drops the copy
every caller made, including the two the creative hotbar made per slot each
frame (and one clone per hovered slot becomes a clone only when hovered).

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:40:11 +03:00
AlexanderandHermes Agent 4bf633e8a9 HUD paint: build the sprite key in one reused buffer
paint() runs for every drawn HUD call each frame and made a fresh String per
sprite to look it up in the atlas: sprite.to_string() for a mob_effect and
format!("gui/sprites/{sprite}") otherwise. One buffer outside the loop keeps
the same keys without the allocation.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:33:46 +03:00
AlexanderandHermes Agent f4d6f9c0ed Tests: skip the reports-link case when the host cannot link
os.symlink needs Developer Mode or an elevated token on Windows, so the test
errored with WinError 1314 instead of exercising own_reports. Skip it there
and keep it on hosts that can link.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:30:03 +03:00
AlexanderandHermes Agent 93e61ef936 Port regen: hand the tools a Python that runs
python() returned shutil.which("python3"), which on Git for Windows is the
Microsoft Store stub (prints "Python", exits 49): every tool it launched
failed before its first line. Probe python3, python and py and keep the first
that is Python 3, once per process.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:27:21 +03:00
AlexanderandHermes Agent 28e3286e40 Port harness: read the log a Windows javac error names
named_logs only matched a Posix absolute path (`see /...log`), so on Windows
horizon-prepare's "see C:\...\export.log" matched nothing and its javac
errors were never shown. The pattern now takes either form.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:21:06 +03:00
AlexanderandHermes Agent aef4186de4 Tests: the parity-reach fixture names files the way the tool does
narrow() and its graph key files by Posix path (`crates/a/src/used.rs`), so
a fixture built from str(relative_to) on Windows produced backslashes and all
seven tests errored in setUp. as_posix() in the fixture matches the tool.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:16:41 +03:00
Kacpherek c66554126d Turn name tags to the camera in third-person front view
Name tags faced the player's own look, so in the front view (F5 twice) every name and hologram read mirrored. They now face the camera, turned round in that view, like Camera.rotation().
2026-10-28 06:58:59 +01:00
Kacpherek 4a1c5fc54c Stop sprinting after a spear stab with knockback
Player.causeExtraKnockback stops the attacker's sprint, as the melee path already does.
2026-10-28 06:57:48 +01:00
iLostName a1b32304a3 boat: the view stops at 105 degrees at every turn, so it doesn't jitter
Fixes #2983.
2026-10-28 06:52:29 +01:00
iLostName 7934a0b180 riding: the crosshair skips the mob being ridden, so it can't be hit
Fixes #2870.
2026-10-28 06:52:28 +01:00
iLostName 992c63eefb death message: a creeper's blast names the creeper, not the last mob that hit
The creeper is out of the mob list when its blast lands, so its name now comes with the explosion event. Fixes #2978.
2026-10-28 06:52:28 +01:00
iLostName a967ca4340 item pickup: a stack in the off hand fills before the inventory, as getSlotWithRemainingSpace does
Fixes #2865.
2026-10-28 06:52:28 +01:00
iLostName c0b560527f F1 hides the HUD only with no screen open, as key.toggleGui does
Fixes #2942.
2026-10-28 06:52:27 +01:00
iLostName 557a827383 trial spawner: a spawn egg sets its mob, as overrideEntityToSpawn does
Fixes #3041.
2026-10-28 06:52:26 +01:00
iLostName e06134f71a redstone ore: a right-click lights it, as RedStoneOreBlock.useItemOn does
Fixes #3044.
2026-10-28 06:52:15 +01:00
Kacpherek 330314327a Give a mounted spear charge its mount's speed
The charge read the rider's own last move, which a rider never sends, so on horseback it had no speed and hit nothing. ServerPlayer.getKnownSpeed gives a rider its vehicle's movement.
2026-10-28 06:30:14 +01:00
Kacpherek 012a3cf908 Use the End's ambient light for entity shadows
DimensionType.ambientLight is 0.25 in the End; shadows used 0, so they were weaker there.
2026-10-28 06:10:38 +01:00
Kacpherek 0e03cac368 Draw faint entity shadows instead of cutting them out
Shadows used the entity fragment shader, which discards alpha under 0.1, so they vanished at block light 7 and below and faded out a few blocks away. rendertype_entity_shadow.fsh has no cutout.
2026-10-28 06:10:38 +01:00
Hector EcclesandOpenCode a48fcf0d16 Reuse natural spawn candidate buffers
Every spawning pass built a new 289-per-player candidate Vec and a new
dedup set. Keep both in a scratch struct on the game and clear them between
passes, so their capacity survives. The first pass and the thunder pass use
the same buffers.

Co-Authored-By: OpenCode <[email protected]>
2026-10-27 21:55:47 -04:00
Hector EcclesandOpenCode 2eb3de2748 Sweep far chunks every eighth tick
Streaming walked every loaded column and every proto chunk on each update to
drop the ones that had fallen out of range. The sweep now runs at once when
the view moves and every eighth tick otherwise; unloads can wait 400 ms
without changing what is visible or generated.

Co-Authored-By: OpenCode <[email protected]>
2026-10-27 21:47:29 -04:00
Hector EcclesandOpenCode 67511d9749 Reuse the block entity ticker list between ticks
The list of ticking block entities was cloned into a new Vec on every tick,
even when no slot had been freed and nothing had been added. Keep it behind an
Arc and clone the Arc instead; promotions and sweeps use Arc::make_mut, so the
list is copied only when it actually changes.

Also skip the block lookup in tick_spawner: the caller only reaches it for a
BlockEntity::Spawner, and changing the block removes or replaces that entity.

Co-Authored-By: OpenCode <[email protected]>
2026-10-27 21:40:12 -04:00
Hector EcclesandOpenCode df90f62d35 Hopper ticks check their block only when they move
A hopper on its 8-tick cooldown looked up its block and compared the state
name every tick, then returned without moving. Advance the cooldown first and
only resolve the block when it is due to move. The sweep that drops hoppers
whose block entity is gone can run once a second; a hopper that ticks still
checks its own block.

Co-Authored-By: OpenCode <[email protected]>
2026-10-27 21:28:52 -04:00
Ludovic MarieandClaude Opus 5.5 efa8df83b3 villagers: a torchflower crop is never ripe
TorchflowerCropBlock.getMaxAge is 2 while its age property stops at 1, so
CropBlock.isMaxAge never holds for it: HarvestFarmland leaves it alone and
UseBonemeal keeps it as a target until it turns into a torchflower.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 23:06:19 +01:00
Ludovic MarieandClaude Opus 5.5 58422bcc19 Bred babies keep the classic sound set; allays walk back to within 4 blocks
- Animal.spawnChildFromBreeding creates the baby with getBreedOffspring and
  no finalizeSpawn, so its sound variant stays the default (classic); the
  spawn path here had just drawn a random one.
- StayCloseToTarget sets a walk target with closeEnough 4: once started at
  16 blocks, the allay walks until MoveToTargetSink.reachedTarget (Manhattan
  block distance 4), not only back inside 16.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:54:14 +01:00
feeldev 5c14d18141 fix(client): the link prompt has ConfirmLinkScreen's Copy to Clipboard button
ConfirmLinkScreen.addButtons: an untrusted link asks with three 100-wide
buttons, Yes, Copy to Clipboard and No; Copy puts the link on the
clipboard and answers no. confirm_screen::Choice lays out a row of any
number of buttons; Confirm keeps its two of 150 through it.
2026-10-27 16:52:15 -05:00
Kacpherek 0f8e3a9b09 Format the mob sound changes 2026-10-27 22:40:25 +01:00
Kacpherek 4ce1eb6b0a Play the crossbow hit sound for arrows shot from a crossbow
CrossbowItem.createProjectile sets the arrow's sound event to item.crossbow.hit; every arrow hit played entity.arrow.hit.
2026-10-27 22:40:16 +01:00
Kacpherek 47d249e7dd Pick hurt and death sounds by water, size, age and shell
Drowned and guardians use their water or land pair, tiny slimes and magma cubes the _small pair, a closed shulker hurt_closed and a baby turtle the _baby pair, as getHurtSound and getDeathSound do. Horizon always used the plain sound.
2026-10-27 22:40:10 +01:00
Kacpherek ea6ebae264 Fix horse standing, angry, ambient and baby horse voices
RandomStandGoal plays getAmbientStandSound (the ambient sound) for every horse, not entity.horse.angry for horses only; makeMad uses getAngrySound (zombie horse its own, skeleton horse none, baby horse baby_horse.angry); a baby horse and a skeleton horse in water pick their own ambient, hurt and death sounds.
2026-10-27 22:40:10 +01:00
Kacpherek d878f3bed2 Give donkeys, mules and baby horses their own jump and eating sounds
playJumpSound is entity.donkey.jump or entity.mule.jump (skeleton_horse.jump_water in water); Horse.getEatingSound is entity.baby_horse.eat for a baby. Horizon played the horse's for all.
2026-10-27 22:39:37 +01:00
Kacpherek ec5fc9d5e4 Play horse hoof, wood, gallop and breathing steps
AbstractHorse.playStepSound plays entity.horse.step (baby_horse.step), step_wood on wood or for the first five ridden steps, and gallops every third step with Horse.playGallopSound's breathing. Horizon played the block's step sound.
2026-10-27 22:39:37 +01:00
Kacpherek 99948fdf3f Play the hostile fall sounds when a monster takes fall damage
Monster.getFallSounds is entity.hostile.small_fall and big_fall; Horizon played the generic pair.
2026-10-27 22:39:37 +01:00
Kacpherek 73ad5f1dbb Play the hostile swim sound for swimming monsters and hoglins
Monster and Hoglin override getSwimSound with entity.hostile.swim; Horizon played the generic swim sound for them.
2026-10-27 22:39:37 +01:00
Kacpherek 6e26990ad3 Run cargo fmt on the villager changes
Formatting only.
2026-10-27 22:39:05 +01:00
Kacpherek ffb6594600 Format the block behaviour changes 2026-10-27 22:37:59 +01:00
Ludovic Marie 4b4950d7c4 Merge branch 'refactor/be-layer-deps' into refactor/furnace-cooking 2026-10-27 22:36:31 +01:00
Ludovic MarieandClaude Opus 5.5 1d693cb3c3 Read mouse.rs where the input crate keeps it now
#2750 moved it to crates/horizon-client-input.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:33:10 +01:00
Ludovic MarieandClaude Opus 5.5 23e3cf465c Merge main into screen-cursor-centre
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:33:03 +01:00
Kacpherek 0d3ca74bd0 Break turtle eggs by stepping on them
TurtleEggBlock.stepOn calls destroyEgg(100) for any step that is not careful; only the fall path (1 in 3) existed.
2026-10-27 22:33:01 +01:00
Kacpherek f69088012b Format the hostile mob fixes 2026-10-27 22:32:05 +01:00
Kacpherek 154aaa2051 Fix clippy and format the passive mob fixes 2026-10-27 22:32:05 +01:00
Kacpherek 1ab273641e Disarm a tripwire with shears before breaking it
TripWireBlock.playerWillDestroy sets DISARMED (flags 260) and fires the shear event when the main hand holds shears; the property was never set.
2026-10-27 22:31:36 +01:00
Kacpherek 785d579f47 Stop vines spreading when the spread_vines rule is off
VineBlock.randomTick only runs when GameRules.SPREAD_VINES is on; Horizon ignored the rule.
2026-10-27 22:31:17 +01:00
Kacpherek 3fe8aa76b2 Build a golem whenever a carved pumpkin is placed
CarvedPumpkinBlock.onPlace runs on every placement; /setblock, /fill, /clone and piston moves never completed a golem.
2026-10-27 22:30:05 +01:00
Kacpherek dc75e77eb1 Pick a parent's variant at random for pig, cow and chicken babies
Pig, Cow and Chicken.getBreedOffspring take either parent's variant with equal odds; Horizon always took the initiator's.
2026-10-27 22:29:41 +01:00
Kacpherek 94e8c85fd0 Roll a natural salmon's size
Salmon.finalizeSpawn picks small 30, medium 50 or large 15; natural spawns kept variant 0, so every salmon rendered small.
2026-10-27 22:29:29 +01:00
Kacpherek 28c30923f4 Play the sound variant of pigs, cows, chickens, wolves and cats
Vanilla plays the mob's sound variant set, the baby set for babies (Pig/Cow/Chicken/Wolf/Cat.getSoundSet); Horizon always played the classic adult sounds and never gave a natural spawn a variant. Tame cats also purreow one time in four (Cat.getAmbientSound).
2026-10-27 22:29:29 +01:00
Kacpherek 2b74cfae4a Drop a beehive with its bees when it is broken in creative
BeehiveBlock.playerWillDestroy drops the hive item with bees and honey_level when drops are prevented and block_drops is on; creative breaks dropped nothing.
2026-10-27 22:29:06 +01:00
Kacpherek 60a4edaef9 Chain a hanging sign under a ceiling hanging sign
CeilingHangingSignBlock.useItemOn passes for a hanging sign item on the DOWN face; Horizon opened the editor instead of placing the next sign.
2026-10-27 22:28:29 +01:00
Ludovic MarieandClaude Opus 5.5 5096b41100 Merge main into #2886: offline names need an owner since main
Keep main's owner check (play_as takes the store, Sign out updates
the owner state) with the branch's load-mutate-save and skin cleanup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:28:22 +01:00
Kacpherek e9eaa66542 Offer the iron golem's poppy for 400 game ticks, not 800
OfferFlowerGoal.start sets tick to adjustedTickDelay(400), which is 200 goal ticks. Horizon counted 400 goal ticks, so the offer lasted twice as long.
2026-10-27 22:28:16 +01:00
Kacpherek 2c2aa41619 Clear a door's lower half when the tool would not drop it
DoorBlock.playerWillDestroy removes the bottom half for creative or !hasCorrectToolForDrops (iron door by hand); only creative was handled.
2026-10-27 22:27:59 +01:00
Kacpherek f164427cba Use the full not_scary_for_pufferfish list for the puff trigger
Turtles, guardians, tadpoles, nautiluses and sulfur cubes no longer puff a pufferfish, and axolotls do, as in Pufferfish.PUFFER_SCARY_MOB and the tag.
2026-10-27 22:27:50 +01:00
Kacpherek b77301b6eb Let an allay wander up to 16 blocks from its player
StayCloseToTarget only pulls an allay back once its target is 16 blocks away (closerThan(tooFar)); Horizon pulled it back beyond 4.
2026-10-27 22:27:44 +01:00
Kacpherek e08adcd31c Anger nearby piglins when an ender chest is opened
EnderChestBlock.useWithoutItem calls PiglinAi.angerNearbyPiglins(true); the ender chest branch skipped the generic container path that does it.
2026-10-27 22:27:31 +01:00
Kacpherek 830a9d3d84 Give the skeleton horse vanilla's water drag
SkeletonHorse.getWaterSlowDown is 0.96; Horizon used the 0.8 default so it swam too slowly.
2026-10-27 22:27:21 +01:00
Kacpherek 6f0d56512d Stop mules from breeding
Mule inherits AbstractHorse.canMate, which is false; only horses and donkeys (Horse/Donkey.canMate) find a partner.
2026-10-27 22:27:21 +01:00
Kacpherek 482444afc4 Roll vanilla's donkey, mule and zombie horse attributes on spawn
A natural donkey or mule rolled a horse jump; vanilla rolls only health and leaves jump at 0.5. A zombie horse uses its own jump 0.5-0.7 and speed (9+3 rolls)/42.16 (AbstractChestedHorse, ZombieHorse).
2026-10-27 22:27:09 +01:00
Kacpherek 0d7f842b09 Hurt the dragon with a wind charge
AbstractWindCharge.onHitEntity hurts whatever it hits for 1, the dragon's parts included (crystals are not hit); Horizon only looked at ordinary mobs.
2026-10-27 22:26:23 +01:00
Kacpherek a1dfcbf2b3 Remove the dragon's flame cloud when it takes off
DragonSittingFlamingPhase.end discards the cloud it made; Horizon left it to last its full 200 ticks after the phase ended.
2026-10-27 22:26:23 +01:00
Kacpherek 5703fdfdc4 Let slimes, ghasts and calm piglins not block a bed
ServerPlayer.startSleepInBed looks for Monster entities that isPreventingPlayerRest; slimes, ghasts, phantoms and unangry zombified piglins are not. Horizon counted every hostile-category mob.
2026-10-27 22:26:23 +01:00
Kacpherek 57494e6871 Slow mobs in cobwebs again
WebBlock.entityInside makes a mob stuck at 0.25/0.05/0.25 (0.5/0.25/0.5 with Weaving) and ends its fall; spiders and withers ignore it. The block-effects pass replaced the earlier cobweb code and dropped it.
2026-10-27 22:26:23 +01:00
Kacpherek ad8ef0b7e8 Wither a skull's target by difficulty, and only on a hit
WitherSkull.onHitEntity adds Wither for 10 s on Normal, 40 s on Hard and nothing on Easy, only when the hit hurt; Horizon always gave 10 s.
2026-10-27 22:26:23 +01:00
Kacpherek f9fca5688d Give witches their resistance to magic
Witch.getDamageAfterMagicAbsorb takes 85% off #witch_resistant_to (magic, indirect_magic, sonic_boom, thorns) and all of the witch's own damage; Horizon had no such rule.
2026-10-27 22:26:23 +01:00
Kacpherek 4156a6ac93 Aim a mob's crossbow by the world difficulty
RangedAttackMob.rangedAttackUncertainty is 14 - 4 * difficulty; Horizon always used the Normal value 6.
2026-10-27 22:26:23 +01:00
Kacpherek 6c3defa821 Hit with a hoglin or zoglin every 40 ticks, 15 for a baby
HoglinAi and Zoglin use MeleeAttack.create(40) for adults and create(15) for babies; Horizon used the 20-tick default.
2026-10-27 22:26:23 +01:00
Ludovic MarieandClaude Opus 5.5 8057d27511 Worn armour: only a dyeable layer takes the stack's dye
getColorForLayer returns white for a layer without Dyeable, whatever
dyed_color the stack has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:12 +01:00
Kacpherek 4e814a152a Make every wolf beg for a bone or its food
BegGoal.playerHoldingInteresting is a bone or Wolf.isFood for any wolf. Horizon had untamed wolves beg only for bones and tamed wolves only for food.
2026-10-27 22:25:20 +01:00
Kacpherek 3f378dda96 Make a chased child villager run away in tag
PlayTagWithOtherKids always chased the first friend. Vanilla flees to a village spot (LandRandomPos 20, 8, speed 0.6) when a friend chases it, otherwise chases the kid with one to five chasers, else any friend.
2026-10-27 22:25:02 +01:00
Ludovic MarieandClaude Opus 5.5 70068076f2 Keep set_baby's doc comment on set_baby
The two new functions went in between it and its comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:24:39 +01:00
Kacpherek 59b67af6a8 Send villagers to the dropped items they want
NearestItemSensor was a no-op and GoToWantedItem never started for villagers. Vanilla scans 32 blocks every 20 ticks for the nearest item the villager wants and sees, and walks to it at 0.5 when it is within 4 blocks.
2026-10-27 22:23:43 +01:00
Kacpherek 1b469a542f Let villagers with bone meal grow crops
UseBonemeal never started: it computed the bone meal count and returned false. Vanilla picks a crop below max age around the villager every 10 ticks, holds bone meal and grows it, then picks again 40 ticks later, for up to 80 cycles.
2026-10-27 22:21:24 +01:00
Kacpherek 567e92e2a2 Let villagers harvest torchflower crops
TorchflowerCropBlock is a CropBlock with max age 1. It was missing from the villagers' crop lists, so farmers planted torchflower seeds but never harvested them.
2026-10-27 22:20:12 +01:00
Kacpherek 5388064d88 Ring the bell for real when a villager rings it
RingBell calls BellBlock.attemptToRing: the bell shakes, the sound plays and the block_change game event fires. Horizon only played the sound.
2026-10-27 22:19:17 +01:00
Kacpherek 20ffd8e538 Tell villagers within 32 blocks that a bell was rung
BellBlockEntity.updateEntities sets HEARD_BELL_TIME on living entities within 32 blocks. Nothing set it, so a rung bell never sent villagers into the hide activity.
2026-10-27 22:18:52 +01:00
Kacpherek c3e19973cf Fire item_used_on_block for a compass used on a lodestone
Vanilla fires ITEM_USED_ON_BLOCK when a compass locks onto a lodestone (ServerPlayerGameMode via useOn).
2026-10-27 22:08:13 +01:00
Kacpherek 7aaa4b433b Fire avoid_vibration when a sneaking step is ignored
Vanilla's VibrationSystem fires AVOID_VIBRATION for a sneaking player whose step a sculk sensor or a warden ignores; a shrieker cannot trigger it.
2026-10-27 22:08:13 +01:00
Kacpherek 3cf601f760 Fire kill_mob_near_sculk_catalyst for the player who hurt the mob
Vanilla's SculkCatalystBlockEntity fires it for the player who last hurt a mob whose experience a catalyst takes.
2026-10-27 22:08:13 +01:00
Kacpherek 39327fca33 Fire allay_drop_item_on_block when an allay drops at the liked block
Vanilla's AllayAi.throwItem fires ALLAY_DROP_ITEM_ON_BLOCK for the liked player with the block below the deposit.
2026-10-27 22:08:12 +01:00
Kacpherek 6489cdf829 Fire player_interacted_with_entity for leashing, feeding and repairing
Vanilla's PlayerInteractTrigger fires after a successful item use on an entity: leashing, feeding a baby sniffer, repairing wolf armour. Item predicates now check the damage component, and guests' interactions fire it too.
2026-10-27 22:08:12 +01:00
Kacpherek 1727f2acf1 Fire player_sheared_equipment when wolf armour is sheared off
Vanilla's Mob.shearItem fires PLAYER_SHEARED_EQUIPMENT with the armour and the mob. A wolf's armour now counts as its body slot, with its durability, for entity and item predicates.
2026-10-27 22:08:04 +01:00
Kacpherek 079963f27e Fire bee_nest_destroyed with the bees left inside
Vanilla's BeehiveBlock.playerDestroy fires BEE_NEST_DESTROYED after emptying the hive, with the tool and the bees still inside; a silk touch break keeps them in.
2026-10-27 22:07:25 +01:00
Kacpherek 3c72bf2d79 Fire spear_mobs when a spear stabs several mobs in one use
Vanilla's KineticWeapon fires SPEAR_MOBS with the number of living entities stabbed in the use, so stabbing five or more completes the spear advancement.
2026-10-27 22:07:25 +01:00
Kacpherek 16cda1637b Fire lightning_strike and channeled_lightning for the right players
Vanilla's LightningBolt fires LIGHTNING_STRIKE when it is discarded, for players within 256 blocks, counting the fires it set and the mobs it missed as bystanders, and CHANNELED_LIGHTNING for the player who channelled it. The lightning rod advancement no longer passes for every bolt.
2026-10-27 22:07:24 +01:00
asowneryt 513aed64c5 Merge upstream main into fix/2802-projectile-death-message 2026-10-28 10:02:41 +13:00
Kacpherek 7ab2b20bd5 Let mouse buttons toggle cinematic camera 2026-10-27 22:02:17 +01:00
Kacpherek dddcc99726 Start with HDR off after a start that died switching it on
HDR output now writes horizon-hdr-pending before it is switched on and removes it after the first presented HDR frame. A start that finds the file turns hdr off in horizon-graphics.txt and logs it, so a native crash in HDR output costs one start, not every start.
2026-10-27 22:02:16 +01:00
Kacpherek b8f0cde900 Fix the Metal HDR colour space lookup that crashed macOS at start
wgpu-hal's Metal backend read the CoreGraphics colour space names (kCGColorSpaceITUR_2100_PQ and others) as if dlsym returned the string, but it returns the address of the global that holds it. Configuring a PQ swapchain passed that address to CGColorSpaceCreateWithName, which faulted with no Rust panic. Read through the pointer.
2026-10-27 21:59:16 +01:00
asownerytandClaude Opus 5.5 c4348be8d2 Check the killing blow of player_killed_entity: Return to Sender needs the ghast's fireball
KilledTrigger.TriggerInstance.matches tests killing_blow, a
DamageSourcePredicate, against the damage that killed the entity.
Horizon ignored it, so any ghast kill awarded Return to Sender (and any
breeze kill Blowback, any skeleton kill from 50 blocks Sniper Duel).

Fixes #2832

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 09:56:46 +13:00
Ludovic MarieandClaude Opus 5.5 a34a41e1b5 refactor: move the furnace's recipe logic out of containers
Move-only step B7 of the horizon-client crate split. `impl Furnace`
needed `crafting::Recipes`, the one gameplay module containers still
reached. Its four methods (set_input, recipe_experience, input_clicked,
tick) move unchanged to a new `furnace` module, as the extension trait
`FurnaceCooking` implemented for the Furnace data, with the two helpers
only they use (remainder, total_time). Callers add one `use`.

containers now depends only on modules of the render/world layer
(skins and tab_list included), so it can move into that crate whole.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:59:55 +01:00
Kacpherek 22cab3eeb3 Open windowed after the window system kills the game in fullscreen
When winit's event loop ends with ExitFailure (on Linux, a compositor
dropping the connection after a protocol error) while fullscreen is on,
turn fullscreen off in options.txt before the error goes on, so a
fullscreen the system can't show doesn't crash every launch (#2904).
2026-10-27 19:02:20 +01:00
socksproxandDevin 9b6faeb8cf launcher: the game's made-up name can be rolled for a new one
The game's own name (horizon-player.txt) isn't a kept offline name, so
Forget never applied to it and there was no way to be rid of it. Settings
> Account shows it with "New name", which writes a fresh PlayerNNNNN to
every existing game home — the default and each instance's — so the
player stays the same (new) player in its worlds, as PLAYER_FILES' copies
do.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:13:55 +01:00
socksproxandDevin 8cfc13fe2c launcher: the account list follows changes made outside the window
accounts.json was read once at start-up, so an offline name added or
forgotten through the CLI or another window left the running window
without the name's row (and its Forget button) until relaunch. Page
switches and opening the account chip's menu read it again, as the
game's made-up name already was.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:49:50 +01:00
socksproxandDevin a7e3bcf79f launcher: a forgotten offline name is gone for good
Forget removed the name from accounts.json but left skins/<name>.png and
skins/<name>.cape.png behind, the skin panel's file pick could still
record a look for a name that no longer existed, and the account rows'
Play as / Play offline / Sign out saved the window's stale account list,
which could write a name forgotten elsewhere (CLI, another window) back.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:41:07 +01:00
leifiyooandClaude Haiku 5.5 e7f5201c1f Client: worn dyed leather armour shows its dye colour
EquipmentLayerRenderer.getColorForLayer takes a dyeable layer's colour from
the stack's minecraft:dyed_color and falls back to the asset's undyed colour.
The player's own armour and LAN guests' armour, plus armour stands, used only
the undyed colour, so dyed leather looked brown.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-10-27 16:30:53 +01:00
levzzzandminimax-m3.1-flash f507a067cd The breeze's wind and the wind charge scroll their textures
Vanilla draws the breeze's wind layer and the wind charge with
`RenderTypes.breezeWind`, whose texture scrolls with the entity's age: by
0.02 of the texture per tick for the breeze's wind layer and 0.03 for the
wind charge. Horizon drew both with a still texture, so the breeze's gust
and the wind charge did not move.

The draw now takes a scroll offset and wraps the vertex's U inside the
texture, as the texture matrix of the vanilla's render type does.

Fixes #2760

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 19:30:29 +05:00
levzzz 28f906c37d Merge remote-tracking branch 'upstream/main' into HEAD 2026-10-27 19:21:10 +05:00
levzzzandminimax-m3.1-flash f8f2f904bc A summoned or egg-spawned mob is not persistent
Vanilla never calls `Mob.setPersistenceRequired` for a mob it spawns: the
spawn egg's offspring, the spawn egg on a block and `/summon` all leave
the mob to the despawn rule, and only the NBT can make it persistent.
Horizon marked every one of them persistent, so they never despawn and
they are left out of the mob cap.

The three paths now spawn the mob like before, without the persistence
flag, and the command execution corpus checks it against vanilla: a
summoned zombie is not selected by `nbt={PersistenceRequired:1b}`.

Fixes #2787

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 19:21:08 +05:00
sirrayiandDevin ce848af067 Zip: don't trust an entry's declared sizes for allocation
A server resource pack whose zip64 sizes claim ~2^63 bytes aborted the client on Vec::with_capacity. Cap the capacity at the archive's length and the inflate limit at 1 GiB, with saturating arithmetic.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 13:36:51 +00:00
sirrayiandDevin d3be8dbf05 horizon-server: bound HTTP request and header lines
read_line had no limit, and the read timeout applies per read, so a client streaming bytes without a newline to the API or the web panel grew the supervisor's memory without bound. Lines longer than 8 KiB now get a 400.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 13:36:51 +00:00
levzzz 152334eee3 Merge remote-tracking branch 'upstream/main' into fix/screen-cursor-centre 2026-10-27 18:23:26 +05:00
levzzzandminimax-m3.1-flash 67e0d54062 tests: give the capture fixture the mouse position helper
The fixture compiles `Game::capture` on its own, so the helper it now
calls has to be part of it: the test extracts it from the client, like it
extracts the methods, and the call inside the fixture loses the crate
path.

The fixture also asserts the grab path now: grabbing the pointer puts
the mouse at the window's centre, as `MouseHandler.grabMouse` does.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:18:07 +05:00
asownerytandClaude Opus 5.5 53cb3a19d1 Name the projectile in the death message when it has no owner
AbstractArrow.onHitEntity makes an ownerless arrow (or trident) its own
causing entity, and fireballs, spit and shulker bullets are the
DamageSource's direct entity. Horizon recorded no entity for an ownerless
projectile's hit, so DamageSource.getLocalizedDeathMessage's one-argument
form showed the raw two-placeholder template (%1$s was shot by %2$s).

Fixes #2802

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 02:16:56 +13:00
Eshayat Al-WasiuandCodex 84390f6dc1 Match server deletion input to button row width
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:53 +06:00
Eshayat Al-WasiuandCodex 9d191c28f2 Narrow server deletion confirmation input
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:53 +06:00
Eshayat Al-WasiuandCodex 5d42108a98 Place deletion confirmation help below input
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:53 +06:00
Eshayat Al-WasiuandCodex 1972ef3bef Use shared settings row styling for server deletion
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:53 +06:00
Eshayat Al-WasiuandCodex e8145b07fa Remove redundant server deletion confirmation hint
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:53 +06:00
Eshayat Al-WasiuandCodex 5e7295a631 Align server deletion controls with the settings rows
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:52 +06:00
Eshayat Al-WasiuandCodex 761a770174 Emphasize the server name in the delete confirmation
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:52 +06:00
Eshayat Al-WasiuandCodex 7f0a61954b Add a confirmed server delete action to launcher settings
Move stopped servers to the system trash after name confirmation. Refuse active supervisor locks and background services, and refresh the sidebar after removal.

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:52 +06:00
levzzzandminimax-m3.1-flash a8903cba07 Merge upstream/main into fix/shulker-body-yaw
Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:14:07 +05:00
levzzzandminimax-m3.1-flash 00df6c646f The shulker's body stays square to its block while the head turns
Shulker.recreateFromPacket sets the body rotation to 0 and
Shulker.ShulkerBodyRotationControl.clientTick is empty, so a shulker's
body never turns: it stays square to the block it is fixed to and only
its head turns. The fix (#2311) is already on main, but nothing held it
in place: the entity model harness compares the model's parts and cannot
see the entity's own rotation. This test drives the body rotation
control and the look control and pins both halves of the vanilla's
behaviour - the body stays at 0 and the head still turns to its target
(#1661).

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:45:49 +05:00
levzzz 1f040d06eb Merge remote-tracking branch 'upstream/main' into fix/screen-cursor-centre 2026-10-27 17:45:16 +05:00
levzzzandminimax-m3.1-flash d13b478ca2 ui: the mouse position is the centre whenever the grab state changes
`MouseHandler.grabMouse` and `MouseHandler.releaseMouse` both put the
mouse at the window's centre, so a screen opened from gameplay starts
with the cursor in the middle. Horizon centred it when the pointer was
released, but not when it was grabbed again, so closing a screen left
the cursor where the screen had it.

The rule is one function now, `mouse_position_after_grab_change`, and the
game applies it on both sides of the grab state change.

Fixes #1925

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:44:55 +05:00
levzzz 91de744250 Merge remote-tracking branch 'upstream/main' into fix/spawn-egg-baby 2026-10-27 17:24:11 +05:00
levzzzandminimax-m3.1-flash 1654cb51db The spawn egg's offspring is a named, tested rule
The behaviour is in main (#2275): `Mob.checkAndHandleImportantInteractions`
uses a spawn egg of the mob's own kind on a mob, and
`SpawnEggItem.spawnOffspringFromSpawnEgg` spawns the offspring as a baby
of that kind at the parent's position, and one egg less in hand.

The rule now lives in the library, where it is named and tested:
`spawn_egg_is_for` (the egg is for the mob's own kind) and
`spawn_egg_offspring` (the baby, with the parent's variant and colour).

Fixes #2269

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:24:09 +05:00
asownerytandClaude Opus 5.5 f105583803 Select World: deleting the last world opens Create New World
WorldSelectionList.handleNewLevels opens a fresh CreateWorldScreen when
the singleplayer list comes back empty, with Cancel going to the title
screen. Horizon went back to an empty list instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 00:42:44 +13:00
levzzzandminimax-m3.1-flash fa940d5b6b Add a regression test for the entity draw distance
The view scale introduced by #2317 (Entity.setViewScale in
LevelExtractor.extractVisibleEntities) decides how far an entity is drawn:
Entity.shouldRenderAtSqrDistance multiplies the bounding box's mean edge by
64 and by that scale, so a chicken (0.4 by 0.7 by 0.4) is drawn up to 48
blocks away at the vanilla defaults. Without the scale it vanished at 32
blocks, the distance reported in #2273. The merged fix carried no test, so
the scale formula now lives in a free function and the draw distance of a
chicken, of a broken bounding box and of every render distance is pinned.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:15 +05:00
Eshayat Al-WasiuandCodex bcdbc252fe Load server details when opening from the launcher sidebar
Use the shared open_server path so stopped servers load settings immediately and switching servers clears stale detail state.

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 17:07:42 +06:00
levzzzandminimax-m3.1-flash 186198afe6 Pin the turtle's walk animation to vanilla's rate
The turtle's flailing feet (#1776) were the walk animation running at its
maximum: the generic MoveControl walked the turtle at its full movement
speed (0.25), so LivingEntity.updateWalkAnimation's min(distance * 4, 1)
saturated at 1.0 and TurtleModel swung the legs at the maximum rate and
amplitude. PR #2565 gave the turtle its own pace, but its tests only pin
the forward input the control sets, not the rate of the animation the
reporter saw. This test pins the symptom: the turtle covers 0.18447
blocks a tick and its walk animation runs at 0.73789, vanilla's
min(distance * 4, 1) for that pace.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 15:44:33 +05:00
levzzz a77639022c Merge remote-tracking branch 'upstream/main' into fix/fire-animated-textures 2026-10-27 15:40:21 +05:00
levzzz fcc7a818b4 Merge remote-tracking branch 'upstream/main' into fix/mining-hit-sound 2026-10-27 15:36:16 +05:00
levzzzandminimax-m3.1-flash a3e581b56f Keep the animated textures on in every graphics preset
The Performance preset used to turn the animated textures off, which froze
the fire, the water, the lava and the portals: players reported a fire with
no animation (#2064). The presets keep them on now, and a test keeps it
that way, so a preset that freezes the animation again fails the build
instead of the report.

Fixes #2064

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 15:35:06 +05:00
levzzzandminimax-m3.1-flash 9ebccd6a59 Cover the mining sound cadence in the LAN harness
The fix for #2277 landed in #2350: the server's destroy progress level
events (2019 and 2020) play the block's hit sound, so the digger and the
players around hear the same hits. Nothing checked that, though. Add a LAN
check that holds the attack on a stone block and compares the hit sounds
both sides count, and assert in the remote mining fixture that the client
plays the hit sound once, when the dig starts, and not in the loop. On the
tree before #2350 the check fails: the digger counted 20 hits against the 10
the player nearby heard.

Fixes #2277

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 15:14:04 +05:00
Ludovic MarieandClaude Opus 5.5 0f565f4e4c refactor: biome's spawn-list parsers live in biome.rs
parse_entries, parse_costs and SpawnCost read the biome JSON's
natural_mob_spawns and build biome::SpawnEntry; biome.rs reached into
mobs::spawn_selection for them, one of the edges that keep the client lib
in a single cycle. They move to biome.rs and spawn_selection re-exports
them. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:11:39 +01:00
155 changed files with 6198 additions and 867 deletions

No files matched your search

+20 -1
View File
@@ -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'
Generated
+37
View File
@@ -876,6 +876,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.19.0"
@@ -1398,6 +1404,7 @@ dependencies = [
"log",
"miniz_oxide 0.8.9",
"naga",
"open",
"png",
"pollster",
"rustix 1.1.5",
@@ -1877,6 +1884,25 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -2691,6 +2717,17 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95"
dependencies = [
"dunce",
"is-wsl",
"libc",
]
[[package]]
name = "orbclient"
version = "0.3.55"
+4 -2
View File
@@ -61,8 +61,10 @@ large_enum_variant = "allow"
# wgpu-hal 30.0.1 with gfx-rs/wgpu#10124: wgpu 30 creates a Vulkan descriptor
# pool with no pool sizes while it creates the device, which Vulkan allows only
# since 1.3.215, and Intel's Windows driver for 6th-10th gen iGPUs (HD 520/530,
# UHD 620/630) crashes on it with an access violation (#746). Drop this and the
# folder once a wgpu release has the fix.
# UHD 620/630) crashes on it with an access violation (#746). It also fixes the
# Metal backend's lookup of the CoreGraphics HDR colour space names, which handed
# CoreGraphics a symbol's address as the string and crashed HDR output on macOS.
# Drop this and the folder once a wgpu release has both fixes.
[patch.crates-io]
wgpu-hal = { path = "third_party/wgpu-hal" }
+4
View File
@@ -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),
@@ -195,6 +195,11 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
Some("copy_to_clipboard") => {
text("value").map(crate::text::ClickEvent::CopyToClipboard)
}
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
// the dialog itself; the server may send it (isAllowedFromServer).
Some("show_dialog") => e.get("dialog").map(|dialog| {
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
}),
_ => None,
};
}
+8
View File
@@ -50,6 +50,9 @@ pub enum ClickEvent {
/// `ClickEvent.RunCommand`: the command, with or without its `/`.
RunCommand(String),
CopyToClipboard(String),
/// `ClickEvent.ShowDialog`: a `minecraft:dialog` registry id, or the
/// dialog itself as the component carried it.
ShowDialog(horizon_world::nbt::Tag),
/// A local file to open with the system handler.
/// Only allow this in locally constructed messages, never server/LAN text.
OpenFile(String),
@@ -557,6 +560,11 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
ClickEvent::RunCommand(command) => {
json!({ "action": "run_command", "command": command })
}
// ClickEvent.ShowDialog.CODEC: the dialog the server sent it with.
ClickEvent::ShowDialog(dialog) => json!({
"action": "show_dialog",
"dialog": crate::scoreboard::tag_to_json(dialog),
}),
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
};
out.push(("click_event".to_owned(), event));
+41
View File
@@ -324,6 +324,24 @@ pub fn ease_in_out(t: f64) -> f64 {
0.5 - 0.5 * (std::f64::consts::PI * t).cos()
}
/// `MouseHandler.grabMouse` / `releaseMouse`: the mouse position is the
/// window's centre whenever the pointer's grab state changes, so a screen
/// opened from gameplay starts with the cursor in the middle. A release of
/// an already free pointer (or a grab of an already grabbed one) leaves the
/// position where it is, as vanilla does.
pub fn mouse_position_after_grab_change(
grab: bool,
was_grabbed: bool,
size: [u32; 2],
scale: f32,
) -> Option<(f32, f32)> {
if grab == was_grabbed {
None
} else {
Some((size[0] as f32 / 2.0 / scale, size[1] as f32 / 2.0 / scale))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -485,4 +503,27 @@ mod tests {
assert_eq!(look.on_scroll(0.0, 0.4, false, 1.0), None);
assert_eq!(look.on_scroll(0.0, 0.7, false, 1.0), Some((1, (0, 1))));
}
#[test]
fn the_mouse_position_is_the_centre_when_the_grab_changes() {
// A grab of a free pointer and a release of a grabbed one both put
// the position at the window's centre (`MouseHandler.grabMouse`,
// `MouseHandler.releaseMouse`).
assert_eq!(
mouse_position_after_grab_change(true, false, [1920, 1080], 1.0),
Some((960.0, 540.0))
);
assert_eq!(
mouse_position_after_grab_change(false, true, [1920, 1080], 2.0),
Some((480.0, 270.0))
);
// Without a change of the grab state the position stays where it is.
assert_eq!(
mouse_position_after_grab_change(false, false, [1920, 1080], 1.0),
None
);
assert_eq!(
mouse_position_after_grab_change(true, true, [1920, 1080], 1.0),
None
);
}
}
+2
View File
@@ -70,6 +70,8 @@ unicode-bidi = "0.3.18"
rustix = { version = "1", default-features = false, features = ["std", "time"] }
[target.'cfg(windows)'.dependencies]
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
# on the PATH.
@@ -161,6 +161,7 @@ pub fn main() -> Result<()> {
"sound {} {:08x} {:08x}",
match sound {
ArrowSound::HitGround => "hit",
ArrowSound::CrossbowHit => "crossbow_hit",
ArrowSound::Splash => "splash",
ArrowSound::Extinguish => "extinguish",
ArrowSound::TridentHitGround => "trident_hit_ground",
@@ -369,6 +369,7 @@ pub fn main() -> Result<()> {
banner: &|_| layers.clone(),
skull: &|_| None,
glyph: &|c| atlas.glyph_cell(c),
glyphs: atlas.font.epoch(),
};
let mut verts = Vec::new();
renderer.render_block(&scene, pos, state, Mat4::IDENTITY, packed, &mut verts);
@@ -87,7 +87,11 @@ pub fn main() {
}
"tick" => {
out.push_str("tick");
for (p, _) in entities.tickers(|p| states.get(&p).copied().unwrap_or(0)) {
for (p, _) in entities
.tickers(|p| states.get(&p).copied().unwrap_or(0))
.iter()
.copied()
{
out.push_str(&format!(" {},{},{}", p.0, p.1, p.2));
}
out.push('\n');
@@ -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},
@@ -1121,6 +1121,7 @@ pub fn main() -> horizon_client::Result<()> {
biomes: &biomes,
mob_griefing: true,
mob_drops: true,
max_entity_cramming: 24,
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
end: false,
world_seed: 0,
+54 -2
View File
@@ -444,6 +444,8 @@ pub struct EntityView {
pub gamemode: Option<String>,
/// Lightning: entities it struck.
pub struck: Vec<EntityView>,
/// Lightning: blocks it set on fire (`LightningBolt.blocksSetOnFire`).
pub blocks_set_on_fire: i32,
pub fishing_open_water: Option<bool>,
pub variant: Option<String>,
/// The data components the entity reports (`Entity.get`): id and value.
@@ -538,6 +540,11 @@ pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
{
return false;
}
if let Some(d) = comps["minecraft:damage"].as_u64()
&& u64::from(it.damage) != d
{
return false;
}
let preds = &v["predicates"];
if let Some(list) = preds["minecraft:enchantments"].as_array() {
for e in list {
@@ -734,7 +741,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
.is_some_and(|t| entity(&p["looking_at"], t, w, origin)))
}
"minecraft:type_specific/lightning" => {
range_i(&p["blocks_set_on_fire"], 0)
range_i(&p["blocks_set_on_fire"], i64::from(e.blocks_set_on_fire))
&& (p["entity_struck"].is_null()
|| e.struck
.iter()
@@ -818,7 +825,13 @@ pub fn damage(v: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> boo
{
return false;
}
let ty = &v["type"];
damage_source(&v["type"], d, w, origin)
}
/// `DamageSourcePredicate`: the damage type's tags and the entities behind it.
pub fn damage_source(ty: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> bool {
if ty.is_null() {
return true;
}
let tags_ok = ty["tags"].as_array().into_iter().flatten().all(|t| {
let tag = t["id"].as_str().unwrap_or("");
let tag = tag.strip_prefix('#').unwrap_or(tag);
@@ -1326,8 +1339,47 @@ mod tests {
fn in_tag(&self, _: &str, tag: &str, id: &str) -> bool {
(tag == "minecraft:stone_tool_materials" && id == "minecraft:cobblestone")
|| (tag == "minecraft:sulfur_cube_archetype/explosive" && id == "minecraft:tnt")
|| (tag == "minecraft:is_projectile"
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
}
}
/// Return to Sender's `killing_blow`: only the ghast's own fireball, as a
/// projectile, kills it for the advancement; a sword or an arrow does not.
#[test]
fn killing_blow_needs_the_fireball() {
let blow = serde_json::json!({
"direct_entity": {"minecraft:entity_type": "minecraft:fireball"},
"tags": [{"expected": true, "id": "#minecraft:is_projectile"}]
});
let entity = |kind: &str| EntityView {
kind: kind.into(),
..Default::default()
};
let player = entity("minecraft:player");
let fireball = DamageView {
kind: "minecraft:fireball".into(),
direct: Some(entity("minecraft:fireball")),
source: Some(player.clone()),
..Default::default()
};
assert!(damage_source(&blow, &fireball, &Flat, [0.0; 3]));
let sword = DamageView {
kind: "minecraft:player_attack".into(),
direct: Some(player.clone()),
source: Some(player.clone()),
is_direct: true,
..Default::default()
};
assert!(!damage_source(&blow, &sword, &Flat, [0.0; 3]));
let arrow = DamageView {
kind: "minecraft:arrow".into(),
direct: Some(entity("minecraft:arrow")),
source: Some(player),
..Default::default()
};
assert!(!damage_source(&blow, &arrow, &Flat, [0.0; 3]));
assert!(damage_source(&Value::Null, &sword, &Flat, [0.0; 3]));
}
#[test]
fn uh_oh_accepts_either_tnt_event_only_for_adult_sulfur_cubes() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(
+10
View File
@@ -130,6 +130,8 @@ pub enum ArrowSound {
Splash,
/// The arrow's `soundEvent` (`entity.arrow.hit`), for blocks and entities.
HitGround,
/// An arrow shot from a crossbow: `item.crossbow.hit`.
CrossbowHit,
/// `entity.generic.extinguish_fire`.
Extinguish,
/// A thrown trident's `getDefaultHitGroundSoundEvent` (`item.trident.hit_ground`).
@@ -782,8 +784,16 @@ impl Ticker<'_, '_, '_, '_> {
/// `playSound(soundEvent, 1, 1.2 / (random * 0.2 + 0.9))`.
fn hit_sound(&mut self) {
let pitch = 1.2 / (self.st.random.next_float() * 0.2 + 0.9);
// CrossbowItem.createProjectile: its arrows hit with the crossbow's sound.
let crossbow = self
.st
.fired_from_weapon
.as_ref()
.is_some_and(|s| &*s.item == "minecraft:crossbow");
let sound = if self.trident() {
ArrowSound::TridentHitGround
} else if crossbow {
ArrowSound::CrossbowHit
} else {
ArrowSound::HitGround
};
+59 -3
View File
@@ -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()
+10 -9
View File
@@ -26,6 +26,7 @@
use crate::chunk::block_entity::BlockEntity;
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
use std::sync::Arc;
pub type Pos = (i32, i32, i32);
@@ -46,7 +47,7 @@ pub enum Ticking {
pub struct BlockEntities {
map: HashMap<Pos, BlockEntity>,
/// `Level.blockEntityTickers`: positions and the slot each was given.
tickers: Vec<(Pos, u32)>,
tickers: Arc<Vec<(Pos, u32)>>,
/// `LevelChunk.tickersInLevel`: the live slot of each ticking position.
live: FxHashMap<Pos, u32>,
/// Positions that may need a slot, in the order vanilla would have made
@@ -233,7 +234,7 @@ impl BlockEntities {
}
pub fn clear(&mut self) {
self.map.clear();
self.tickers.clear();
self.tickers = Arc::default();
self.live.clear();
self.added.clear();
self.pending.clear();
@@ -274,7 +275,7 @@ impl BlockEntities {
/// and freed ones leave it. `state` reads the block state at a position.
/// Returns the slots to tick, in order; check each with `is_live` before
/// ticking it, as the ticks before it can free it.
pub fn tickers(&mut self, state: impl Fn(Pos) -> u32) -> Vec<(Pos, u32)> {
pub fn tickers(&mut self, state: impl Fn(Pos) -> u32) -> Arc<Vec<(Pos, u32)>> {
for (pos, token) in std::mem::take(&mut self.added) {
if self.pending.get(&pos) != Some(&token) {
continue;
@@ -288,21 +289,21 @@ impl BlockEntities {
}
let slot = self.next();
self.live.insert(pos, slot);
self.tickers.push((pos, slot));
Arc::make_mut(&mut self.tickers).push((pos, slot));
}
if !std::mem::take(&mut self.freed) && self.out.is_empty() {
return self.tickers.clone();
return Arc::clone(&self.tickers);
}
self.out.clear();
let (live, map) = (&mut self.live, &self.map);
self.tickers.retain(|(p, slot)| {
Arc::make_mut(&mut self.tickers).retain(|(p, slot)| {
let mine = live.get(p) == Some(slot);
if mine && !map.contains_key(p) {
live.remove(p);
}
mine && map.contains_key(p)
});
self.tickers.clone()
Arc::clone(&self.tickers)
}
/// Whether a slot from `tickers` still ticks.
pub fn is_live(&self, (pos, slot): (Pos, u32)) -> bool {
@@ -329,8 +330,8 @@ mod tests {
}
fn order(b: &mut BlockEntities, states: &HashMap<Pos, u32>) -> Vec<Pos> {
b.tickers(|p| states.get(&p).copied().unwrap_or(0))
.into_iter()
.map(|(p, _)| p)
.iter()
.map(|(p, _)| *p)
.collect()
}
@@ -435,7 +435,8 @@ pub struct BellTick {
pub ended: Option<(Vec<([f64; 3], i32)>, Vec<u64>)>,
}
fn closer_to_center_than(pos: [i32; 3], p: [f64; 3], distance: f64) -> bool {
/// `Vec3i.closerToCenterThan`: `p` is within `distance` of the block's centre.
pub fn closer_to_center_than(pos: [i32; 3], p: [f64; 3], distance: f64) -> bool {
let dx = f64::from(pos[0]) + 0.5 - p[0];
let dy = f64::from(pos[1]) + 0.5 - p[1];
let dz = f64::from(pos[2]) + 0.5 - p[2];
@@ -597,6 +597,18 @@ fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> Stri
line.to_owned()
}
/// `AbstractSignRenderer.isOutlineVisible`: the camera within 16 blocks of
/// the sign's centre.
fn sign_text_near(pos: [i32; 3], camera: DVec3) -> bool {
DVec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
)
.distance_squared(camera)
< 256.0
}
/// `AbstractSignRenderer.submitSignText`: four centred lines, glowing text
/// full-bright with an 8x outline in the dark colour.
fn sign_text_calls(
@@ -757,6 +769,9 @@ pub struct Scene<'a> {
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
/// Font glyph cell (none for blank glyphs such as the space) and advance.
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
/// text laid out under another value is laid out again.
pub glyphs: u64,
}
pub struct BlockEntityRenderer {
@@ -768,7 +783,30 @@ pub struct BlockEntityRenderer {
pot_patterns: HashMap<String, String>,
/// `Block.shouldRenderFace` for End gateway faces (its occlusion pair cache needs a lock).
visibility: Option<Mutex<FaceVisibility>>,
sign_texts: Mutex<SignTexts>,
}
/// Laid-out sign text by position. `AbstractSignRenderer` lays the text out
/// again each frame, glyph by glyph; its vertices only change with what is
/// in the key, so they are kept relative to the block and moved to the camera.
#[derive(Default)]
struct SignTexts {
signs: crate::fast_hash::FxHashMap<[i32; 3], SignTextEntry>,
/// Game time of the last sweep of signs out of view.
swept: i64,
}
struct SignTextEntry {
state: u32,
data: crate::signs::SignData,
packed: u8,
/// Within the glowing text outline's distance (only kept for glowing text).
near: bool,
glyphs: u64,
/// Game time it was last drawn.
used: i64,
vertices: Vec<Vertex>,
}
/// Game ticks a sign's text is kept after it was last drawn.
const SIGN_TEXT_KEEP: i64 = 100;
const MODELS: [(&str, &str); 27] = [
("chest", "minecraft:chest#main"),
("double_chest_left", "minecraft:double_chest_left#main"),
@@ -898,6 +936,7 @@ impl BlockEntityRenderer {
closed_chests,
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
}
}
fn kind(&self, state: u32) -> &Kind {
@@ -932,11 +971,81 @@ impl BlockEntityRenderer {
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
- scene.camera)
.as_vec3();
let base = Mat4::from_translation(origin);
let packed = scene.world.light(pos[0], pos[1], pos[2]);
if matches!(kind, Kind::Sign { .. }) && scene.texts.is_none() {
self.sign_text(scene, *pos, *state, origin, packed, out);
continue;
}
let base = Mat4::from_translation(origin);
self.render_one(scene, *pos, kind, base, packed, lights, false, true, out);
}
}
/// A sign's text from the cache, laid out again when its data, light,
/// state, outline or font changed.
fn sign_text(
&self,
scene: &Scene,
pos: [i32; 3],
state: u32,
origin: Vec3,
packed: u8,
out: &mut Vec<Vertex>,
) {
let Some(data) = (scene.sign)(pos) else {
return;
};
let near = (data.front.glowing || data.back.glowing) && sign_text_near(pos, scene.camera);
let Ok(mut cache) = self.sign_texts.lock() else {
return;
};
let now = scene.game_time;
if (now - cache.swept).abs() >= SIGN_TEXT_KEEP {
cache.swept = now;
cache
.signs
.retain(|_, e| (now - SIGN_TEXT_KEEP..=now).contains(&e.used));
}
let glyphs = scene.glyphs;
let signs = &mut cache.signs;
if !signs.get(&pos).is_some_and(|e| {
(e.state, e.packed, e.near, e.glyphs) == (state, packed, near, glyphs)
&& e.data == *data
}) {
let mut vertices = signs.remove(&pos).map_or_else(Vec::new, |e| e.vertices);
vertices.clear();
self.render_one(
scene,
pos,
self.kind(state),
Mat4::IDENTITY,
packed,
diffuse_lights(scene.nether),
false,
true,
&mut vertices,
);
signs.insert(
pos,
SignTextEntry {
state,
data: data.clone(),
packed,
near,
glyphs,
used: now,
vertices,
},
);
}
let Some(entry) = signs.get_mut(&pos) else {
return;
};
entry.used = now;
out.extend(entry.vertices.iter().map(|v| Vertex {
pos: (Vec3::from_array(v.pos) + origin).to_array(),
..*v
}));
}
/// One block entity at `pos` drawn under `base` with the given packed light
/// (the parity fixture's entry point).
pub fn render_block(
@@ -1168,13 +1277,7 @@ impl BlockEntityRenderer {
m *= Mat4::from_translation(Vec3::new(0.0, 1.0 / 3.0, 0.046_666_667))
* Mat4::from_scale(s * 0.010_416_667);
}
let near = DVec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
)
.distance_squared(scene.camera)
< 256.0;
let near = sign_text_near(pos, scene.camera);
let calls = sign_text_calls(scene, data, text, m, packed, near);
match scene.texts {
Some(sink) => sink.borrow_mut().extend(calls),
@@ -1954,6 +2057,7 @@ mod tests {
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyphs: 0,
};
// Compare the cached world path with the original model renderer,
// including atlas replacement, current light and animated fallback.
@@ -2154,6 +2258,128 @@ mod tests {
assert_eq!(out[2].uv, [0.5, 0.5]);
}
#[test]
fn cached_sign_text_matches_and_follows_changes() {
let renderer = BlockEntityRenderer {
models: HashMap::new(),
kinds: vec![
Kind::Sign {
hanging: false,
wall: false,
angle: 180.0,
},
Kind::Sign {
hanging: true,
wall: true,
angle: 90.0,
},
],
closed_chests: Default::default(),
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
};
let world = World::new(BlockStateId(0), 1);
let sprites = HashMap::new();
let text = |line: &str, glowing| crate::signs::SignText {
lines: [line.into(), "two".into(), String::new(), "four".into()],
color: 3,
glowing,
};
let first_text = crate::signs::SignData {
front: text("one", false),
..Default::default()
};
let changed = crate::signs::SignData {
front: text("changed", false),
..Default::default()
};
let glowing = crate::signs::SignData {
back: text("glow", true),
..changed.clone()
};
let wide = std::cell::Cell::new(6.0);
let glyph = |c: char| {
let u = f32::from(c as u8 % 16) / 16.0;
let rect = Rect {
u0: u,
v0: 0.0,
u1: u + 0.0625,
v1: 0.0625,
w: 8,
h: 8,
};
((c != ' ').then_some(rect), wide.get())
};
let (first_sign, changed_sign, glowing_sign) = (
|_| Some(&first_text),
|_| Some(&changed),
|_| Some(&glowing),
);
let mut scene = Scene {
camera: DVec3::new(3.25, 70.5, -4.75),
orientation: Quat::IDENTITY,
anim: &|_| BlockAnim::default(),
texts: None,
partial: 0.0,
game_time: 0,
max_y: 320,
world: &world,
sprites: &sprites,
nether: false,
lid: &|_| 0.0,
sign: &first_sign,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
};
let check = |scene: &Scene, pos: [i32; 3], state: u32| {
let mut cached = Vec::new();
renderer.render(scene, [(pos, state)].iter(), &mut cached);
let corner = DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
let mut original = Vec::new();
renderer.render_one(
scene,
pos,
renderer.kind(state),
Mat4::from_translation((corner - scene.camera).as_vec3()),
world.light(pos[0], pos[1], pos[2]),
diffuse_lights(false),
false,
true,
&mut original,
);
assert!(!cached.is_empty());
assert_eq!(cached.len(), original.len());
for (a, b) in cached.iter().zip(&original) {
assert!((Vec3::from_array(a.pos) - Vec3::from_array(b.pos)).length() < 1e-5);
assert_eq!((a.uv, a.color, a.light), (b.uv, b.color, b.light));
}
cached.len()
};
let (pos, other) = ([1, 70, 2], [-3, 64, 9]);
let first = check(&scene, pos, 0);
// The camera moves: the same text, moved.
scene.camera.x += 0.5;
check(&scene, pos, 0);
// New text, glowing text near and far, another state, other glyphs.
scene.sign = &changed_sign;
assert_ne!(check(&scene, pos, 0), first);
scene.sign = &glowing_sign;
check(&scene, pos, 0);
scene.camera.z -= 20.0;
check(&scene, pos, 0);
check(&scene, pos, 1);
check(&scene, other, 1);
wide.set(5.0);
scene.glyphs = 1;
check(&scene, pos, 1);
// Signs out of view for a while are dropped.
scene.game_time = 2 * SIGN_TEXT_KEEP;
check(&scene, pos, 0);
assert_eq!(renderer.sign_texts.lock().unwrap().signs.len(), 1);
}
#[test]
fn first_line_wraps_on_bytes_without_splitting_a_char() {
let advance = |c: char| if c == ' ' { 4.0 } else { 6.0 };
@@ -2214,6 +2440,7 @@ mod tests {
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyphs: 0,
};
let mut verts = Vec::new();
renderer.render_block(&scene, [0; 3], state.0, Mat4::IDENTITY, 0xff, &mut verts);
+119 -25
View File
@@ -15,33 +15,11 @@ pub struct Confirm {
pub buttons: [(f32, f32); 2],
}
impl Confirm {
pub fn new(font: &Font, (w, h): (f32, f32), title: &Text, message: &Text) -> Self {
let message: Vec<Text> = font
.split_lines(message, w - 50.0)
.into_iter()
.map(|(line, _)| line)
.take(15)
.collect();
let tw = font.width_text(title);
let mw = message
.iter()
.map(|l| font.width_text(l))
.fold(0.0, f32::max);
let mh = 9.0 * message.len() as f32;
let col = tw.max(mw).max(304.0);
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
let mid = cell(mw) + (mw / 2.0).trunc();
let mut lines = vec![(title.clone(), cell(tw), ly)];
for (i, line) in message.into_iter().enumerate() {
let x = mid - (font.width_text(&line) / 2.0).trunc();
lines.push((line, x, ly + 17.0 + 9.0 * i as f32));
}
let (bx, by) = (cell(304.0), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
pub fn new(font: &Font, size: (f32, f32), title: &Text, message: &Text) -> Self {
let Choice { lines, buttons, .. } = Choice::new(font, size, title, message, 2, 150.0);
Self {
lines,
buttons: [(bx, by), (bx + 154.0, by)],
buttons: [buttons[0], buttons[1]],
}
}
/// The answer a click at `cursor` gives: true for yes, false for no.
@@ -84,3 +62,119 @@ impl Confirm {
}
}
}
/// `ConfirmScreen` with a row of `count` buttons `width` wide, 4 apart: `ConfirmLinkScreen`'s
/// yes, copy and no are three of 100. `Confirm` is its two of 150.
pub struct Choice {
/// Each line and where it is drawn.
pub lines: Vec<(Text, f32, f32)>,
/// The buttons' corners, left to right.
pub buttons: Vec<(f32, f32)>,
pub width: f32,
}
impl Choice {
pub fn new(
font: &Font,
(w, h): (f32, f32),
title: &Text,
message: &Text,
count: usize,
width: f32,
) -> Self {
let message: Vec<Text> = font
.split_lines(message, w - 50.0)
.into_iter()
.map(|(line, _)| line)
.take(15)
.collect();
let tw = font.width_text(title);
let mw = message
.iter()
.map(|l| font.width_text(l))
.fold(0.0, f32::max);
let mh = 9.0 * message.len() as f32;
let row = width * count as f32 + 4.0 * count.saturating_sub(1) as f32;
let col = tw.max(mw).max(row);
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
let mid = cell(mw) + (mw / 2.0).trunc();
let mut lines = vec![(title.clone(), cell(tw), ly)];
for (i, line) in message.into_iter().enumerate() {
let x = mid - (font.width_text(&line) / 2.0).trunc();
lines.push((line, x, ly + 17.0 + 9.0 * i as f32));
}
let (bx, by) = (cell(row), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
Self {
lines,
buttons: (0..count)
.map(|i| (bx + (width + 4.0) * i as f32, by))
.collect(),
width,
}
}
/// The button a click at `cursor` hits, counted from the left.
pub fn pressed_at(&self, cursor: (f32, f32)) -> Option<usize> {
self.buttons
.iter()
.position(|&(x, y)| ui::hovered(cursor, x, y, self.width, 20.0))
}
/// The lines and the buttons, over whatever is behind.
pub fn draw(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
labels: &[Text],
) {
for (line, x, y) in &self.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
for (&(bx, by), label) in self.buttons.iter().zip(labels) {
let hover = ui::hovered(cursor, bx, by, self.width, 20.0);
ui::button(batch, atlas, bx, by, self.width, 20.0, label, true, hover);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hud::Rect;
use std::{collections::HashMap, path::Path};
fn font() -> Font {
// The missing-glyph metrics let these tests run without game assets.
let rect = Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
};
Font::load(
Path::new("/nonexistent-horizon-confirm-test-resources"),
&HashMap::new(),
&[],
(1, 1),
(0, 0),
rect,
)
}
#[test]
fn three_buttons_sit_four_apart_in_the_middle() {
let (title, message) = (Text::literal("Open?"), Text::literal("https://x"));
let c = Choice::new(&font(), (480.0, 270.0), &title, &message, 3, 100.0);
let xs: Vec<f32> = c.buttons.iter().map(|b| b.0).collect();
// 3 x 100 + 2 x 4 = 308 wide, centred on 480.
assert_eq!(xs, vec![86.0, 190.0, 294.0]);
assert_eq!(c.pressed_at((191.0, c.buttons[1].1 + 1.0)), Some(1));
assert_eq!(c.pressed_at((188.0, c.buttons[1].1 + 1.0)), None);
// Confirm keeps its two 150-wide buttons where they were.
let two = Confirm::new(&font(), (480.0, 270.0), &title, &message);
assert_eq!(two.buttons[0].0, 88.0);
assert_eq!(two.buttons[1].0, 242.0);
}
}
+2 -166
View File
@@ -5,11 +5,8 @@ pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
};
use crate::{
crafting::Recipes,
items::{ItemStack, Items},
};
use std::{collections::HashMap, sync::Arc};
use crate::items::ItemStack;
use std::collections::HashMap;
impl FurnaceKind {
pub fn recipe_type(self) -> &'static str {
@@ -380,167 +377,6 @@ impl BlockEntity {
}
}
pub use crate::providers::Providers;
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
}
}
/// `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).
+10
View File
@@ -263,6 +263,16 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
// rendertype_outline.fsh: the vertex colour (the team colour; outline vertices carry no
// normal, so no cardinal light) wherever the texture is not fully clear.
// rendertype_entity_shadow.fsh: the shadow texture times the vertex colour,
// then fog, with no alpha cutout, so a faint shadow in dim light still draws.
@fragment fn fs_entity_shadow(in: Out) -> @location(0) vec4<f32> {
var color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
color *= in.light_color;
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
@fragment fn fs_outline(in: Out) -> @location(0) vec4<f32> {
if textureSample(atlas, atlas_sampler, in.uv).a == 0.0 { discard; }
return vec4(in.color.rgb, 1.0);
+15
View File
@@ -130,6 +130,8 @@ pub struct Font {
atlas: (u32, u32),
region: (u32, u32),
cache: RefCell<Cache>,
/// Set anew for each font and each `reset_cache` (see `epoch`).
generation: std::cell::Cell<u64>,
/// "Force Unicode Font": the `uniform` filter option.
pub uniform: bool,
/// "Japanese Glyph Variants": the `jp` filter option.
@@ -559,6 +561,11 @@ impl Region {
Some((ax, ay))
}
}
/// A value no font has had yet (`Font::epoch`).
fn next_generation() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
impl Font {
/// Build from the atlas holding the bitmap font sheets (`sprites`, of
/// `atlas` size) with `region` the reserved rows.
@@ -603,6 +610,7 @@ impl Font {
.map_or(0, |d| d.as_nanos() as i64),
),
}),
generation: std::cell::Cell::new(next_generation()),
uniform: false,
japanese: false,
};
@@ -661,6 +669,13 @@ impl Font {
}
cache.region.cursor = (0, 0);
cache.region.row = 0;
self.generation.set(next_generation());
}
/// Changes whenever a glyph may come out different: another font, a
/// cache reset (glyphs move in the atlas) or other font options. Text laid
/// out under one epoch can be kept until it changes.
pub fn epoch(&self) -> u64 {
self.generation.get() << 2 | u64::from(self.uniform) << 1 | u64::from(self.japanese)
}
/// The glyph for a character in the default font (FontSet.getGlyph).
pub fn glyph(&self, c: char) -> Glyph {
+173
View File
@@ -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
}
}
+163 -31
View File
@@ -222,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,
@@ -273,6 +274,7 @@ use std::{
};
use winit::{
application::ApplicationHandler,
error::EventLoopError,
event::{DeviceEvent, DeviceId, ElementState, MouseButton, MouseScrollDelta, WindowEvent},
event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
keyboard::{KeyCode, PhysicalKey},
@@ -1791,6 +1793,8 @@ struct Game<'a> {
creaking: creaking::CreakingState,
/// Monster spawners' display mobs (`game/spawner.rs`).
spawners: spawner::SpawnerState,
/// Reused candidate buffers for natural spawning.
spawn_scratch: natural_spawning::SpawnScratch,
trident_model: Option<horizon_client::mobs::Part>,
arrow_model: Option<horizon_client::mobs::Part>,
tnts: Vec<tnt::PrimedTnt>,
@@ -2329,12 +2333,19 @@ impl Game<'_> {
.items()
.get(&stack.item)
.map_or(64, |i| i.max_stack);
// Prefer the selected slot, then the hotbar, then the main inventory.
// getSlotWithRemainingSpace: the selected slot, the off hand, then
// the hotbar and the main inventory.
let order: Vec<usize> = std::iter::once(self.selected)
.chain(std::iter::once(shield::OFFHAND_SLOT))
.chain((0..36).filter(|&i| i != self.selected))
.collect();
for &i in &order {
if let Some(slot) = &mut self.inventory[i]
let slot = if i == shield::OFFHAND_SLOT {
&mut self.offhand
} else {
&mut self.inventory[i]
};
if let Some(slot) = slot
&& slot.same_item_same_components(&stack)
&& slot.count < max
{
@@ -3552,6 +3563,9 @@ impl Game<'_> {
state,
kind: "dispense",
} => self.dispense_from(pos, state)?,
LevelEvent::BlockEvent {
pos, kind: "golem", ..
} => self.check_golem_spawn([pos.x, pos.y, pos.z])?,
LevelEvent::BlockEvent {
pos,
kind: "command_block",
@@ -3685,6 +3699,7 @@ impl Game<'_> {
self.open_ender_chest(key);
// EnderChestBlock.useWithoutItem
self.stats.custom("open_enderchest", 1);
self.anger_piglins(true);
if self.container_opener_event(key, true) {
self.container_edge_sound(key, true);
}
@@ -4012,12 +4027,36 @@ impl Game<'_> {
// library: the block entity's side effects (containers spill), neighbour
// and shape updates, and the destroy event (particles, sound).
let creative = self.creative;
// TripWireBlock.playerWillDestroy: shears disarm the string first (flags
// 260), so taking it down does not trip the hook.
if &*facts.name == "minecraft:tripwire"
&& self.held().is_some_and(|s| &*s.item == "minecraft:shears")
&& let Some(disarmed) = self.with_props(state, &[("disarmed", "true")])
{
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, disarmed, 260));
self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None);
}
// BeehiveBlock.playerWillDestroy: a creative break still drops the hive
// with its bees and honey level.
if creative
&& self.ui.rules.bool("block_drops")
&& let Some((h, (honey, _))) = &hive
&& (!h.bees.is_empty() || honey.is_some_and(|l| l > 0))
&& let Some(stack) = self.picked_stack(pos, true)
{
let at = pos.map(f64::from);
self.spawn_item(stack, at, dispenser::new_item_motion(), 10);
}
let correct_tool =
!facts.requires_tool || self.res.items().correct_for_drops(self.held(), &facts.name);
let removed = self.with_level(|b, level, ticks| {
b.player_destroy_block(
level,
ticks,
BlockPos::new(pos[0], pos[1], pos[2]),
creative,
correct_tool,
)
});
if removed {
@@ -4046,11 +4085,14 @@ impl Game<'_> {
self.pop_resource(pos, d);
}
}
// BeehiveBlock.playerDestroy: silk touch keeps the bees in.
if let Some((hive, (honey, facing))) = hive
&& !silk
{
self.hive_broken_by_player(pos, hive, honey, facing);
// BeehiveBlock.playerDestroy: silk touch keeps the bees in, so
// the trigger counts them; otherwise they are out already.
if let Some((hive, (honey, facing))) = hive {
let bees = if silk { hive.bees.len() as i64 } else { 0 };
if !silk {
self.hive_broken_by_player(pos, hive, honey, facing);
}
self.trigger_bee_nest_destroyed(broken, held.as_ref(), bees);
}
// InfestedBlock.spawnAfterBreak: its silverfish comes out unless the
// tool has silk touch (#prevents_infested_spawns) or blocks don't drop.
@@ -4244,6 +4286,10 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if self.use_redstone_ore(hit.pos, held.as_ref())? {
self.swing_arm();
return Ok(true);
}
match self.use_respawn_anchor(hit.pos, held.as_ref())? {
respawn_anchor::AnchorUse::Charged => {
self.swing_arm();
@@ -4253,9 +4299,30 @@ impl Game<'_> {
respawn_anchor::AnchorUse::Pass => anchor_passed = true,
respawn_anchor::AnchorUse::Other => {}
}
// CeilingHangingSignBlock.useItemOn: a hanging sign aimed at the
// underside passes, so the next sign chains below instead of
// opening the editor (signs here carry no click commands).
let chains_sign = hit.face == [0, -1, 0]
&& held
.as_ref()
.is_some_and(|s| s.item.ends_with("_hanging_sign"))
&& {
let name = &self
.res
.facts()
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
.name;
name.ends_with("_hanging_sign") && !name.contains("_wall_")
}
&& matches!(
self.world
.block_entities
.get(&(hit.pos[0], hit.pos[1], hit.pos[2])),
Some(BlockEntity::Sign(_))
);
// TRY_WITH_EMPTY_HAND: only the main hand's turn uses the block
// itself (useWithoutItem).
if !anchor_passed && self.hand == Hand::Main {
if !anchor_passed && !chains_sign && self.hand == Hand::Main {
let face = match hit.face {
[0, 1, 0] => Direction::Up,
[0, -1, 0] => Direction::Down,
@@ -5624,6 +5691,16 @@ impl Game<'_> {
rotation.turn(xo * zoom, yo * zoom);
self.yaw = rotation.y_rot;
self.pitch = rotation.x_rot;
// Entity.turn ends in AbstractBoat.onPassengerTurned: clampRotation
// keeps the view within 105 degrees of the boat at every turn, not
// only at the tick, so it never swings past and back.
if let Some(b) = self
.riding
.and_then(|id| self.boats.iter().find(|b| b.id == id))
{
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
self.yaw += delta.clamp(-105.0, 105.0) - delta;
}
}
}
/// `Minecraft.pause`: a single-player game with a pausing screen open.
@@ -5686,6 +5763,8 @@ impl Game<'_> {
let warp = |window: &Window| {
let _ = window.set_cursor_position(centre);
};
let scale = HudBatch::new(size).scale;
let was_grabbed = self.captured;
if capture {
self.mouse.set_ignore_first_move();
// macOS warps by re-associating the mouse with the pointer,
@@ -5716,13 +5795,23 @@ impl Game<'_> {
if self.focused {
warp(window);
}
let scale = HudBatch::new(size).scale;
self.cursor = (size[0] as f32 / 2.0 / scale, size[1] as f32 / 2.0 / scale);
} else {
let _ = window.set_cursor_grab(CursorGrabMode::None);
}
self.captured = false;
}
// MouseHandler.grabMouse and releaseMouse: the mouse position is
// the window's centre whenever the pointer's grab state changes, so
// a screen opened from gameplay starts with the cursor in the middle
// and the game starts with it away from the screen's last spot.
if let Some(pos) = horizon_client::mouse::mouse_position_after_grab_change(
capture,
was_grabbed,
size,
scale,
) {
self.cursor = pos;
}
window.set_cursor_visible(!self.captured);
}
/// `GuiGraphicsExtractor.applyCursor` -> `Window.selectCursor` at the end
@@ -5766,30 +5855,31 @@ impl Game<'_> {
/// highlight (its count yellow when the slot caps it), and hides while
/// it is the drag's only slot.
fn draw_slot_item(
&mut self,
&self,
batch: &mut HudBatch,
drag: Option<&DragView>,
slot: usize,
stack: Option<ItemStack>,
stack: &Option<ItemStack>,
x: f32,
y: f32,
) {
let carried;
let (stack, capped) = match drag.and_then(|d| d.slots.get(&slot)) {
Some(None) => return,
Some(Some((count, capped))) => {
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
let carried = self
carried = self
.carried
.clone()
.map(|c| ItemStack { count: *count, ..c });
(carried, *capped)
(carried.as_ref(), *capped)
}
None => (stack, false),
None => (stack.as_ref(), false),
};
if let Some(stack) = stack {
self.draw_item_model(batch, &stack, x, y);
self.draw_item_model(batch, stack, x, y);
let text = capped.then(|| format!("\u{a7}e{}", stack.count));
self.draw_item_decorations(batch, &stack, x, y, text);
self.draw_item_decorations(batch, stack, x, y, text);
}
}
/// `extractCarriedItem`: the carried stack at the cursor, showing what a
@@ -6407,7 +6497,7 @@ impl Game<'_> {
);
}
let stack = self.slot_view(i);
self.draw_slot_item(batch, drag.as_ref(), i, stack, ox + sx, oy + sy);
self.draw_slot_item(batch, drag.as_ref(), i, &stack, ox + sx, oy + sy);
}
}
fn draw_inventory(&mut self, batch: &mut HudBatch) {
@@ -8016,7 +8106,12 @@ impl Game<'_> {
.then(|| self.gui.atlas.sprites.get("misc/shadow").copied())
.flatten();
if let Some(sprite) = shadow_sprite {
let ambient = if self.dim == Dim::Nether { 0.1 } else { 0.0 };
// DimensionType.ambientLight: the_nether 0.1, the_end 0.25.
let ambient = match self.dim {
Dim::Nether => 0.1,
Dim::End => 0.25,
Dim::Overworld => 0.0,
};
let darken = self.sky_darken();
let a = alpha;
let lerp = |p: Vec3, q: Vec3| {
@@ -8080,6 +8175,7 @@ impl Game<'_> {
banner: &|p| self.banner_layers_at(p),
skull: &|p| self.skull_skin_at(p),
glyph: &|c| self.gui.atlas.glyph_cell(c),
glyphs: self.gui.atlas.font.epoch(),
};
// Block entities outside the view frustum are skipped (with a
// block of margin for models larger than their block).
@@ -8401,7 +8497,9 @@ impl Game<'_> {
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let mut best: Option<(usize, f64)> = None;
for (i, m) in self.mobs.iter().enumerate() {
if !m.alive() {
// ProjectileUtil.getEntityHitResult: the mob the player rides
// shares its root vehicle and is never the hit.
if !m.alive() || Some(m.id) == self.riding {
continue;
}
if let Some(t) = ray_box(eye, dir, reach, m.bounds())
@@ -10956,7 +11054,11 @@ impl Game<'_> {
// Creative players skip the monster check (`!this.isCreative()`).
let monsters = !self.creative
&& self.mobs.iter().any(|m| {
m.hostile()
// `Monster.class` entities that `isPreventingPlayerRest`: a
// zombified piglin only while it is angry.
horizon_client::mobs::ai::is_monster(m.species())
&& (m.species() != horizon_client::mobs::Species::ZombifiedPiglin
|| (m.extra.anger > 0 && m.ai.target.is_some()))
&& m.alive()
&& (m.position.x - position.x).abs() < 8.0
&& (m.position.y - position.y).abs() < 5.0
@@ -11066,7 +11168,7 @@ impl Game<'_> {
let selected = self.creative_screen.selected;
// Only what is drawn of the tabs: their stacks stay where they are.
let tabs = self.creative_screen.tab_headers();
let tab = tabs[selected.min(tabs.len() - 1)].clone();
let tab = &tabs[selected.min(tabs.len() - 1)];
let tab_sprite = |t: &horizon_client::creative::Tab, on: bool| {
format!(
"gui/sprites/container/creative_inventory/tab_{}_{}_{}",
@@ -11102,8 +11204,8 @@ impl Game<'_> {
r.v1 = r.v0 + (r.v1 - r.v0) * Creative::HEIGHT / r.h as f32;
batch.rect_uv(ox, oy, Creative::WIDTH, Creative::HEIGHT, r, u32::MAX);
}
let (tx, ty) = Creative::tab_position(&tab);
batch.sprite(&self.gui.atlas, &tab_sprite(&tab, true), ox + tx, oy + ty);
let (tx, ty) = Creative::tab_position(tab);
batch.sprite(&self.gui.atlas, &tab_sprite(tab, true), ox + tx, oy + ty);
for t in &tabs {
let (tx, ty) = Creative::tab_position(t);
let stack = ItemStack {
@@ -11121,7 +11223,7 @@ impl Game<'_> {
}
let atlas = &self.gui.atlas;
if !tab.inventory {
let title = self.creative_tab_title(&tab);
let title = self.creative_tab_title(tab);
batch.text(
atlas,
&title,
@@ -11200,15 +11302,14 @@ impl Game<'_> {
let drag = self.drag_view();
for c in 0..9 {
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0);
let stack = self.inventory[c].clone();
self.draw_slot_item(batch, drag.as_ref(), c, stack.clone(), x, y);
self.draw_slot_item(batch, drag.as_ref(), c, &self.inventory[c], x, y);
if self.cursor.0 >= x
&& self.cursor.0 < x + 16.0
&& self.cursor.1 >= y
&& self.cursor.1 < y + 16.0
{
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
hovered = stack;
hovered = self.inventory[c].clone();
}
}
let scrollable = self.creative_screen.rows(&self.pack.names) > 5;
@@ -12131,7 +12232,8 @@ impl Game<'_> {
KeyCode::KeyL if self.screen == Screen::Playing => self.open_advancements(false),
KeyCode::KeyL if self.screen == Screen::Advancements => self.close_advancements(),
KeyCode::Slash if self.screen == Screen::Playing => self.open_chat("/"),
KeyCode::F1 => self.toggle_hide_gui(),
// key.toggleGui is a KeyMapping: it clicks only with no screen open.
KeyCode::F1 if self.screen == Screen::Playing => self.toggle_hide_gui(),
KeyCode::F11 => self.toggle_fullscreen(),
KeyCode::F2 => self.take_screenshot(),
KeyCode::F5 if self.screen == Screen::Playing => self.cycle_camera(),
@@ -12995,6 +13097,12 @@ impl ApplicationHandler for Game<'_> {
}
self.extras_mouse(button, pressed);
self.mod_mouse_key(button, pressed);
if pressed
&& self.settings.keys.get("key.smoothCamera")
== horizon_client::keybinds::mouse_name(button)
{
self.smooth_camera = !self.smooth_camera;
}
for mapped in self.settings.keys.translate_mouse_all(button) {
match mapped {
Err(b) => self.play_mouse(b, pressed)?,
@@ -13906,6 +14014,7 @@ impl<'a> Game<'a> {
ominous_spawners: Vec::new(),
creaking: Default::default(),
spawners: Default::default(),
spawn_scratch: Default::default(),
trident_model: prepared.trident_model,
arrow_model: prepared.arrow_model,
tnts: Vec::new(),
@@ -14939,8 +15048,26 @@ fn write_report(game: &Game<'_>) -> Result<()> {
Ok(())
}
/// The window system ended the event loop with a failure (on Linux, a compositor dropping the
/// connection after a protocol error) while fullscreen was on: the next start opens windowed, so a
/// fullscreen the system can't show doesn't crash every launch (#2904).
fn leave_fullscreen_after(
result: &std::result::Result<(), EventLoopError>,
settings: &mut Settings,
) {
if matches!(result, Err(EventLoopError::ExitFailure(_))) && settings.fullscreen {
settings.fullscreen = false;
settings.save(&Game::options_path());
log::warn!(
"the window system closed the game in fullscreen; the next start opens windowed"
);
}
}
fn run_event_loop(mut game: Game<'_>) -> Result<()> {
EventLoop::new()?.run_app(&mut game)?;
let result = EventLoop::new()?.run_app(&mut game);
leave_fullscreen_after(&result, &mut game.settings);
result?;
horizon_client::discord_presence::stop();
write_report(&game)?;
if let Some(e) = game.error.take() {
@@ -15297,7 +15424,12 @@ fn run_loading(
error: None,
};
let mut app = App::Loading(Box::new(loading));
EventLoop::new()?.run_app(&mut app)?;
let result = EventLoop::new()?.run_app(&mut app);
match &mut app {
App::Ready(game) => leave_fullscreen_after(&result, &mut game.settings),
App::Loading(loading) => leave_fullscreen_after(&result, &mut loading.settings),
}
result?;
horizon_client::discord_presence::stop();
match app {
App::Ready(mut game) => {
+138 -3
View File
@@ -408,6 +408,14 @@ impl Game<'_> {
equipment[4] = slot(&m.extra.legs);
equipment[5] = slot(&m.extra.feet);
}
// A wolf's armor is its body slot, with its durability.
if m.species() == horizon_client::mobs::Species::Wolf {
equipment[6] = m.extra.head.as_ref().map(|id| {
let mut armor = ItemStack::new(id, 1);
armor.damage = m.extra.body_damage.max(0) as u32;
item_view(&armor)
});
}
EntityView {
kind: m.kind.id.clone(),
position: [m.position.x, m.position.y, m.position.z],
@@ -719,6 +727,32 @@ impl Game<'_> {
adv::conditions(&c["player"], &pctx, w) && adv::conditions(&c[&key], &ectx, w)
});
}
/// `KilledTrigger` (`player_killed_entity`): the entity killed and the
/// killing blow's damage source (none: any `killing_blow` fails).
pub(super) fn trigger_killed(&mut self, target: &EntityView, blow: Option<&adv::DamageView>) {
let me = self.player_entity();
let target = target.clone();
let blow = blow.cloned();
self.trigger("minecraft:player_killed_entity", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let ectx = adv::Entities {
this: Some(&target),
..pctx
};
let blow_ok = c["killing_blow"].is_null()
|| blow
.as_ref()
.is_some_and(|d| adv::damage_source(&c["killing_blow"], d, w, me.position));
adv::conditions(&c["player"], &pctx, w)
&& adv::conditions(&c["entity"], &ectx, w)
&& blow_ok
});
}
/// `KilledByArrowTrigger`: what the arrow killed and the weapon it came from.
pub(super) fn trigger_killed_by_arrow(
&mut self,
@@ -869,9 +903,6 @@ impl Game<'_> {
stack: &ItemStack,
target: &EntityView,
) {
if self.lan_acting.is_some() {
return;
}
let me = self.player_entity();
let it = item_view(stack);
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
@@ -1043,6 +1074,110 @@ impl Game<'_> {
adv::conditions(&c["location"], &ctx, w)
});
}
/// `LightningStrikeTrigger`: the bolt, and the mobs around it that it did
/// not hit (`bystander`), each tested from this player's position.
pub(super) fn trigger_lightning_strike(&mut self, bolt: &EntityView, viewers: &[EntityView]) {
let me = self.player_entity();
let (bolt, viewers) = (bolt.clone(), viewers.to_vec());
self.trigger("minecraft:lightning_strike", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let bctx = adv::Entities {
this: Some(&bolt),
..pctx
};
adv::conditions(&c["player"], &pctx, w)
&& adv::conditions(&c["lightning"], &bctx, w)
&& (c["bystander"].is_null()
|| viewers.iter().any(|v| {
adv::conditions(
&c["bystander"],
&adv::Entities {
this: Some(v),
..pctx
},
w,
)
}))
});
}
/// `ChanneledLightningTrigger`: each victim condition matched by some
/// mob the bolt hit, from the player who channelled it.
pub(super) fn trigger_channeled_lightning(&mut self, victims: &[EntityView]) {
let me = self.player_entity();
let victims = victims.to_vec();
self.trigger("minecraft:channeled_lightning", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& c["victims"].as_array().into_iter().flatten().all(|want| {
victims.iter().any(|v| {
adv::conditions(
want,
&adv::Entities {
this: Some(v),
..pctx
},
w,
)
})
})
});
}
/// `SpearMobsTrigger`: the living entities the spear stabbed in this use.
pub(super) fn trigger_spear_mobs(&mut self, stabbed: usize) {
let me = self.player_entity();
self.trigger("minecraft:spear_mobs", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& c["count"].as_u64().is_none_or(|n| stabbed as u64 >= n)
});
}
/// `BeeNestDestroyedTrigger`: the nest's block state, the tool that broke
/// it and the bees left inside.
pub(super) fn trigger_bee_nest_destroyed(
&mut self,
state: horizon_world::registry::BlockStateId,
tool: Option<&ItemStack>,
bees: i64,
) {
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
return;
};
let me = self.player_entity();
let tool = tool.map(item_view).unwrap_or_default();
self.trigger("minecraft:bee_nest_destroyed", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let state_ok = c["state"]
.as_object()
.into_iter()
.flatten()
.all(|(k, v)| definition.properties.get(k).map(String::as_str) == v.as_str());
adv::id_matches(&c["blocks"], "block", &definition.name, w)
&& state_ok
&& adv::item(&c["item"], &tool, w)
&& adv::range_i(&c["num_bees_inside"], bees)
&& adv::conditions(&c["player"], &pctx, w)
});
}
/// A trigger with only the player condition (slept_in_bed, started_riding).
pub(super) fn trigger_player(&mut self, name: &str) {
let me = self.player_entity();
+24 -7
View File
@@ -89,6 +89,16 @@ impl Game<'_> {
}
}
}
/// The UUID of the player an allay likes (`LIKED_PLAYER`).
fn allay_liked_uuid(&self, i: usize) -> [i32; 4] {
// An allay saved before its player was kept belongs to the host.
self.mobs[i]
.extra
.owner
.filter(|u| *u != [0, 0, 0, 1])
.or(self.level.player_uuid)
.unwrap_or([0, 0, 0, 1])
}
/// `AllayAi.getLikedPlayer`: the player the allay was given an item by,
/// found by UUID among the host and the guests of the level (the window
/// is a guest on the server thread), when they are not a spectator and
@@ -98,13 +108,7 @@ impl Game<'_> {
if !m.extra.tame {
return None;
}
// An allay saved before its player was kept belongs to the host.
let uuid = m
.extra
.owner
.filter(|u| *u != [0, 0, 0, 1])
.or(self.level.player_uuid)
.unwrap_or([0, 0, 0, 1]);
let uuid = self.allay_liked_uuid(i);
let host = self
.player_view()
.filter(|p| !p.spectator && self.level.player_uuid.unwrap_or([0, 0, 0, 1]) == uuid)
@@ -159,6 +163,19 @@ impl Game<'_> {
];
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt().max(1e-6);
let v = [d[0] / len * 0.2, d[1] / len * 0.3, d[2] / len * 0.2];
// AllayAi.throwItem: the liked player hears of the block below the deposit.
let below = [
target.x.floor() as i32,
(target.y - 1.0).floor() as i32,
target.z.floor() as i32,
];
let uuid = self.allay_liked_uuid(i);
let stack = ItemStack::new(&item, 1);
self.for_each_player(|g| {
if g.acting_uuid() == uuid && !g.spectator() {
g.trigger_block("minecraft:allay_drop_item_on_block", below, &stack);
}
});
let m = &mut self.mobs[i];
m.extra.ints[1] -= 1;
m.extra.ints[3] = 60;
+35 -6
View File
@@ -16,6 +16,21 @@ use horizon_core::{
};
use std::{collections::HashMap, path::PathBuf, sync::Arc};
/// `EquipmentLayerRenderer.getColorForLayer`: a dyeable layer (one with a
/// colour when undyed) takes the stack's `minecraft:dyed_color`, else that
/// colour; any other layer stays white, dyed or not. The result is in the
/// renderer's byte order (R and B swapped).
fn layer_color(undyed: Option<u32>, stack: Option<&ItemStack>) -> u32 {
let dyed = stack
.and_then(|s| s.data())
.and_then(|d| d.dyed_color)
.map(|c| c as u32 & 0xFF_FFFF);
undyed.map_or(0xFFFF_FFFF, |undyed| {
let c = dyed.unwrap_or(undyed);
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
})
}
/// Armor stand models and the humanoid layers of every equipment asset.
pub(super) struct ArmorStandAssets {
pub main: Option<Part>,
@@ -179,9 +194,7 @@ impl Game<'_> {
) else {
continue;
};
let color = undyed.map_or(0xFFFF_FFFF, |c| {
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
});
let color = layer_color(*undyed, stack);
out.push((part, [r.u0, r.v0, r.u1, r.v1], color));
// EquipmentLayerRenderer: the trim layer over the armour.
if let Some(r) = stack
@@ -695,9 +708,7 @@ impl Game<'_> {
else {
continue;
};
let color = undyed.map_or(0xFFFF_FFFF, |c| {
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
});
let color = layer_color(*undyed, Some(stack));
// ArmorStandRenderer's HumanoidArmorLayer: the armour glint
// when `stack.hasFoil()`, as on a living entity.
let item_glint = self
@@ -993,3 +1004,21 @@ mod burn_drop_tests {
assert_eq!(drops, vec![kept]);
}
}
#[cfg(test)]
mod layer_color_tests {
use super::*;
#[test]
fn a_dyed_stack_overrides_the_undyed_leather_colour() {
let mut dyed = ItemStack::new("minecraft:leather_helmet", 1);
dyed.data_mut().dyed_color = Some(0x1E_90FF);
// Dyed blue, R and B swapped into the renderer's order, opaque.
assert_eq!(layer_color(Some(0xA06540), Some(&dyed)), 0xFFFF_901E);
// An undyed stack keeps the asset's brown.
let plain = ItemStack::new("minecraft:leather_helmet", 1);
assert_eq!(layer_color(Some(0xA06540), Some(&plain)), 0xFF40_65A0);
// No dye and no undyed colour: white.
assert_eq!(layer_color(None, Some(&plain)), 0xFFFF_FFFF);
// A layer that isn't dyeable stays white even on a dyed stack.
assert_eq!(layer_color(None, Some(&dyed)), 0xFFFF_FFFF);
}
}
@@ -7,6 +7,7 @@ use super::Game;
use horizon_client::{
block_entity_anim::{
BellEntity, ConduitServer, ConduitTarget, EndGateway, Shulker, ShulkerStatus, Ticker,
closer_to_center_than,
},
block_entity_render::BlockAnim,
collisions::{SceneCollisions, no_collision_empty},
@@ -49,11 +50,16 @@ impl Game<'_> {
}
Some(Ticker::Bell) => {
let gathered = self.bell_gather(pos);
self.be_anims
.bells
.entry(pos)
.or_default()
.trigger_event(a, b, time, || gathered);
let rung =
self.be_anims
.bells
.entry(pos)
.or_default()
.trigger_event(a, b, time, || gathered);
// The server's copy tells the living entities within 32 blocks.
if rung && self.remote.is_none() {
self.bell_heard(pos, time);
}
}
Some(Ticker::Gateway) => {
let initial = self.gateway_loaded(pos);
@@ -200,6 +206,26 @@ impl Game<'_> {
}
out
}
/// `BellBlockEntity.updateEntities`: every gathered entity alive within 32
/// blocks of the bell's centre gets `HEARD_BELL_TIME` (only villagers
/// have a brain that reads it).
fn bell_heard(&mut self, pos: [i32; 3], now: i64) {
let nearby = self
.be_anims
.bells
.get(&pos)
.and_then(|b| b.nearby.clone())
.unwrap_or_default();
for m in &mut self.mobs {
if m.alive()
&& !m.removed
&& nearby.contains(&u64::from(m.id))
&& closer_to_center_than(pos, [m.position.x, m.position.y, m.position.z], 32.0)
{
horizon_client::mobs::villager_brain::heard_bell(m, now);
}
}
}
/// `ShulkerBoxBlock.canOpen`: a closed box opens only when its lid has
/// room to rise (`level.noCollision` of the half-open lid box).
pub(super) fn shulker_can_open(&self, pos: [i32; 3]) -> bool {
@@ -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;
@@ -23,12 +24,16 @@ impl Game<'_> {
let states = self.ticking_states();
self.world.block_entities.set_states(states);
}
self.forget_gone_hoppers();
// Only lingering cooldowns are dropped; a hopper that ticks checks
// its own block. Once a second is soon enough for the rest.
if self.world.game_time % 20 == 0 {
self.forget_gone_hoppers();
}
let ticking = self.block_ticking_area();
let mut spawners = Vec::new();
let mut trial_spawners = Vec::new();
self.item_columns = Some(Default::default());
for slot in self.world.block_entity_tickers() {
for slot in self.world.block_entity_tickers().iter().copied() {
let pos = slot.0;
// Ticks before this one can remove it (`TickingBlockEntity.isRemoved`).
if !self.world.block_entities.is_live(slot)
+3
View File
@@ -151,6 +151,9 @@ impl Game<'_> {
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&value);
}
// BookViewScreen.handleClickEvent: the rest is
// Screen.defaultHandleGameClickEvent's.
ClickEvent::ShowDialog(dialog) => self.show_dialog_ref(&dialog),
_ => {}
}
}
+28 -11
View File
@@ -786,6 +786,9 @@ impl Game<'_> {
}
}
Some(ClickEvent::RunCommand(command)) => self.click_command(&command),
// Screen.defaultHandleGameClickEvent: the dialog opens over the
// screen, and closes back to it.
Some(ClickEvent::ShowDialog(dialog)) => self.show_dialog_ref(&dialog),
// Screen.defaultHandleClickEvent: a page link means nothing in chat.
Some(ClickEvent::ChangePage(_)) | None => {}
}
@@ -808,16 +811,19 @@ impl Game<'_> {
&self,
url: &str,
size: (f32, f32),
) -> horizon_client::confirm_screen::Confirm {
) -> horizon_client::confirm_screen::Choice {
let warning = Text::translate("chat.link.warning", &[]).with_color(0xFF_CCCC);
let message = Text::literal(url)
.append(Text::literal("\n"))
.append(warning);
horizon_client::confirm_screen::Confirm::new(
// ConfirmLinkScreen.addButtons: yes, copy and no, 100 wide each.
horizon_client::confirm_screen::Choice::new(
&self.gui.atlas.font,
size,
&Text::translate("chat.link.confirm", &[]),
&message,
3,
100.0,
)
}
pub(super) fn draw_link_prompt(&self, batch: &mut HudBatch) {
@@ -827,12 +833,13 @@ impl Game<'_> {
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let (yes, no) = (
let labels = [
Text::translate("gui.yes", &[]),
Text::translate("chat.copy", &[]),
Text::translate("gui.no", &[]),
);
];
self.link_prompt_layout(url, (w, h))
.draw(batch, atlas, self.cursor, [&yes, &no]);
.draw(batch, atlas, self.cursor, &labels);
}
/// The prompt's answer: the link opens on yes; either way the chat is back.
pub(super) fn answer_link_prompt(&mut self, yes: bool) {
@@ -843,15 +850,20 @@ impl Game<'_> {
}
}
pub(super) fn link_prompt_click(&mut self) {
let Some(url) = &self.link_prompt else {
let Some(url) = self.link_prompt.clone() else {
return;
};
let answer = self
.link_prompt_layout(url, self.gui_size())
.answer_at(self.cursor);
if let Some(yes) = answer {
let pressed = self
.link_prompt_layout(&url, self.gui_size())
.pressed_at(self.cursor);
if let Some(button) = pressed {
self.audio.play_button_click();
self.answer_link_prompt(yes);
if button == LINK_COPY {
// ConfirmLinkScreen.copyToClipboard, then the callback's no.
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&url);
}
self.answer_link_prompt(button == LINK_YES);
}
}
/// `Screen.clickCommandAction`: run a clicked command as the player
@@ -992,6 +1004,7 @@ impl Game<'_> {
spawn_mobs: r.bool("spawn_mobs"),
spawn_monsters: r.bool("spawn_monsters"),
fire_spread_radius_around_player: r.int("fire_spread_radius_around_player"),
spread_vines: r.bool("spread_vines"),
}
}
/// Apply rules mirrored in other state (natural regeneration, difficulty).
@@ -1210,3 +1223,7 @@ pub fn screenshot_name(dir: &std::path::Path) -> String {
fn owner_permission(allow_commands: bool) -> u8 {
if allow_commands { 4 } else { 0 }
}
/// `ConfirmLinkScreen`'s buttons, left to right: yes, copy, no.
const LINK_YES: usize = 0;
const LINK_COPY: usize = 1;
+6
View File
@@ -39,6 +39,11 @@ pub(super) struct CombatTracker {
pub(super) last_hurt_mob: Option<(u32, i64)>,
/// The mob whose projectile is hitting the player now.
pub(super) projectile_owner: Option<u32>,
/// The display name of the projectile hitting the player now: the
/// `DamageSource.directEntity` the death message names when no owner is
/// known (`AbstractArrow.onHitEntity` makes an ownerless arrow its own
/// cause).
pub(super) projectile_direct: Option<Text>,
/// The projectile hitting the player now is another player's that
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
pub(super) refuse_player_hit: bool,
@@ -208,6 +213,7 @@ impl Game<'_> {
}
self.combat.pending.clear();
self.combat.pending_direct.clear();
self.combat.projectile_direct = None;
self.combat.fall_before = self.player.as_ref().map_or(0.0, |p| p.fall_distance);
}
/// `FallLocation.getCurrentFallLocation` and the fall distance so far.
+1 -1
View File
@@ -1851,7 +1851,7 @@ impl Game<'_> {
if self.ui.difficulty == 0 && t.category == "monster" {
return Err(err("commands.summon.failed.peaceful", &[]));
}
self.spawn_mob(&short, pos, true);
self.spawn_mob_spawned(&short, pos);
name = Text::translate(&format!("entity.minecraft.{short}"), &[]);
} else {
match short.as_str() {
@@ -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 {
+212 -17
View File
@@ -277,6 +277,19 @@ impl Game<'_> {
pub(super) fn spawn_mob(&mut self, kind: &str, at: [f64; 3], persistent: bool) -> Option<u32> {
self.spawn_mob_for(kind, at, persistent, false)
}
/// `SummonCommand` and `SpawnEggItem`: vanilla spawns the mob, but it
/// never calls `Mob.setPersistenceRequired` for it, so the mob is not
/// persistent: the despawn rule holds it like any other mob, and only
/// its NBT can make it persistent.
pub(super) fn spawn_mob_spawned(&mut self, kind: &str, at: [f64; 3]) -> Option<u32> {
// The spawn is not a natural one (no jockeys, the patrol leader roll
// stays), but the mob is not persistent.
let id = self.spawn_mob_for(kind, at, true, false)?;
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.persistent = false;
}
Some(id)
}
/// `spawn_mob` for a patrol or raid (`EntitySpawnReason.PATROL` / `EVENT`),
/// which never rolls a patrol leader.
pub(super) fn spawn_mob_for(
@@ -334,7 +347,7 @@ impl Game<'_> {
}
};
let id = self
.spawn_mob(kind, at, true)
.spawn_mob_spawned(kind, at)
.ok_or_else(|| format!("unknown or unsupported mob {kind}"))?;
if baby && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.extra.age = if m.species() == Species::Sniffer {
@@ -456,6 +469,7 @@ impl Game<'_> {
biomes: self.res.biomes(),
mob_griefing: self.ui.rules.bool("mob_griefing"),
mob_drops: self.ui.rules.bool("mob_drops"),
max_entity_cramming: self.ui.rules.int("max_entity_cramming"),
end: self.dim == Dim::End,
world_seed: self.level.seed,
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
@@ -732,6 +746,7 @@ impl Game<'_> {
self.guardian_bubbles();
self.villager_reputation_tick();
self.merchant_tick();
self.villager_item_sense();
self.villager_pickups();
for m in dead {
self.mob_died(&m);
@@ -789,14 +804,30 @@ impl Game<'_> {
// takes the experience (before the loot is dropped). It takes what
// would drop, whoever killed the mob.
self.sculk_mob_died(m);
let mut catalyst_xp = 0;
let consumed =
self.catalyst_takes_experience([m.position.x, m.position.y, m.position.z], |_| {
if horizon_client::mobs::drops::drops_experience(&dying) {
catalyst_xp = if horizon_client::mobs::drops::drops_experience(&dying) {
horizon_client::mobs::drops::base_experience(&mut dying).max(0) as u32
} else {
0
}
};
catalyst_xp
});
// CatalystListener: with experience to take, the player who last hurt
// the mob gets KILL_MOB_NEAR_SCULK_CATALYST.
if consumed && catalyst_xp > 0 {
let who = match killer {
Some(Some(conn)) => Some(super::commands::Target::Guest(conn)),
Some(None) if self.lan_acting.is_none() => Some(super::commands::Target::Player),
_ => None,
};
if let Some(who) = who {
self.for_player(who, |g| {
g.trigger_player("minecraft:kill_mob_near_sculk_catalyst")
});
}
}
if by_player {
// A guest's kill is theirs: their statistics, scores and
// advancements (LivingEntity.die credits lastHurtByPlayer).
@@ -856,7 +887,42 @@ impl Game<'_> {
self.stats.custom("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(m.id));
let view = self.mob_entity(m);
self.trigger_entity("minecraft:player_killed_entity", "entity", &view);
let blow = m.last_damage.map(|(d, _)| self.damage_view(d));
self.trigger_killed(&view, blow.as_ref());
}
/// A mob's damage source as advancement predicates see it: its type, the
/// causing entity, and the projectile when one dealt it.
fn damage_view(&self, d: Damage) -> horizon_client::advancements::DamageView {
let source = match d.attacker {
Some(Who::Player) => Some(self.player_entity()),
Some(Who::Mob(id)) => self
.mobs
.iter()
.find(|o| o.id == id)
.map(|o| self.mob_entity(o)),
None => None,
};
let projectile = match d.kind {
DamageKind::Arrow => Some("minecraft:arrow"),
DamageKind::Fireball => Some("minecraft:fireball"),
_ => None,
};
let direct = match projectile {
Some(kind) => Some(horizon_client::advancements::EntityView {
kind: kind.into(),
position: d.from.unwrap_or_default(),
..Default::default()
}),
None => source.clone(),
};
horizon_client::advancements::DamageView {
kind: damage_type(d.kind, matches!(d.attacker, Some(Who::Player))).to_owned(),
// DamageSource.isDirect: the causing entity is the direct one.
is_direct: projectile.is_none(),
direct,
source,
..Default::default()
}
}
/// `dropAllDeathLoot` through `mobs::drops`: the entity loot table from the
/// world's random sequence, equipment by drop chance, species extras and
@@ -1193,14 +1259,18 @@ impl Game<'_> {
owner,
} => self.add_fangs(at, yaw, delay, owner),
MobEvent::Shoot(..) => {}
MobEvent::ExplosionSource { mob, charged } => {
MobEvent::ExplosionSource { mob, charged, name } => {
self.combat.explosion_source = Some(mob);
// The creeper causes the blast's damage, but it is out of
// the mob list already: its name comes with the event.
self.note_named_attacker("minecraft:explosion", mob, name);
if charged {
self.combat.charged_exploders.push(mob);
}
}
MobEvent::Explode(at, radius, species) => {
// The exploding mob causes the damage (Creeper.explodeCreeper).
let noted = self.combat.explosion_source.is_some();
let source_mob = self.combat.explosion_source.or_else(|| {
self.mobs
.iter()
@@ -1211,7 +1281,7 @@ impl Game<'_> {
.map(|m| m.id)
.next()
});
if let Some(id) = source_mob {
if !noted && let Some(id) = source_mob {
self.note_attacker("minecraft:explosion", id);
}
self.combat.explosion_source = source_mob;
@@ -1279,8 +1349,10 @@ impl Game<'_> {
MobEvent::Hiss { mob } => {
if let Some(m) = self.mobs.iter().find(|m| m.id == mob) {
let at = m.center();
let sound = horizon_client::mobs::finalize::variant_sound(m, "hiss")
.unwrap_or("entity.cat.hiss");
self.audio
.play("minecraft:entity.cat.hiss", Some(at), 1.0, 1.0);
.play(&format!("minecraft:{sound}"), Some(at), 1.0, 1.0);
}
}
MobEvent::Tamed { .. } | MobEvent::Buck { .. } => self.mount_event(&e),
@@ -1296,6 +1368,26 @@ impl Game<'_> {
}
}
},
MobEvent::Bonemeal { pos } => {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
if self.with_level(|b, level, ticks| b.grow_crop(level, ticks, at)) {
self.level_events
.push(horizon_world::level::LevelEvent::LevelEvent {
id: 1505,
pos: at,
data: 15,
});
let _ = self.process_level_events();
}
}
MobEvent::RingBell { bell } => {
let pos = BlockPos::new(bell[0], bell[1], bell[2]);
let rang = self
.with_level(|b, level, ticks| b.bell_attempt_to_ring(level, ticks, pos));
if rang {
let _ = self.process_level_events();
}
}
MobEvent::ChestOpen { chest, open } => self.golem_chest_open(chest, open),
MobEvent::ChestSlot { chest, slot, stack } => {
self.golem_chest_slot(chest, slot, stack)
@@ -1857,6 +1949,9 @@ impl Game<'_> {
&& let Some(child) = self.mobs.iter_mut().find(|m| m.id == id)
{
child.extra.age = -24000;
// getBreedOffspring creates the baby without finalizeSpawn: it keeps
// the default (`classic`) sound variant.
child.extra.sound_variant = None;
// A llama's baby was drawn in the parent's tick, with the
// experience.
if let Some(o) = llama {
@@ -1952,8 +2047,12 @@ impl Game<'_> {
}
// Offspring inherit a parent's variant (pigs/cows/chickens) or mixed wool.
child.extra.variant = p.extra.variant;
// Fox.getBreedOffspring: either parent's variant.
if p.species() == Species::Fox && !coin {
// Fox, Pig, Cow and Chicken.getBreedOffspring: either parent's variant.
if matches!(
p.species(),
Species::Fox | Species::Pig | Species::Cow | Species::Chicken
) && !coin
{
child.extra.variant = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant);
}
if p.species() == Species::Sheep {
@@ -2083,6 +2182,10 @@ impl Game<'_> {
if let Some(a) = attacker.or(owner) {
self.note_attacker(damage_type, a);
}
// The projectile is the direct entity: named when it has no owner.
if let Some(name) = self.combat.projectile_direct.take() {
self.note_direct_entity(damage_type, name);
}
// DamageSource.scalesWithDifficulty: a mob (or its projectile's
// owner) caused the hit, not a player.
let living_cause = attacker.or(owner).is_some();
@@ -2559,6 +2662,7 @@ impl Game<'_> {
let name = match sound {
ArrowSound::Splash => "minecraft:entity.generic.splash",
ArrowSound::HitGround => "minecraft:entity.arrow.hit",
ArrowSound::CrossbowHit => "minecraft:item.crossbow.hit",
ArrowSound::Extinguish => "minecraft:entity.generic.extinguish_fire",
ArrowSound::TridentHitGround => "minecraft:item.trident.hit_ground",
ArrowSound::TridentHit => "minecraft:item.trident.hit",
@@ -2700,6 +2804,26 @@ impl Game<'_> {
Some(Who::Mob(id)) => Some(id),
_ => None,
};
self.combat.projectile_direct = match hit {
Hit::Entity {
who: Who::Player, ..
} => {
let spectral = p
.arrow
.as_ref()
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow");
let id = if spectral {
"minecraft:spectral_arrow"
} else {
p.kind.id()
};
Some(Text::translate(
&format!("entity.{}", id.replace(':', ".")),
&[],
))
}
_ => None,
};
let at = match hit {
Hit::Entity { at, .. } | Hit::Block { at, .. } => at,
Hit::None => return Ok(()),
@@ -3269,7 +3393,16 @@ impl Game<'_> {
}
{
let item = self.held().map(|h| h.item.to_string()).unwrap_or_default();
let used = self.held().cloned();
if self.interact_leash(index, &item) {
if let Some(stack) = &used {
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity(
"minecraft:player_interacted_with_entity",
stack,
&target,
);
}
self.swing_arm();
return Ok(true);
}
@@ -3843,6 +3976,11 @@ impl Game<'_> {
if !handled {
return Ok(false);
}
// PlayerInteractTrigger: the item used on the mob, once the interaction took.
if let Some(stack) = &held {
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
}
// Turtle.ageBoundaryReached: food that finishes a baby's growth.
if baby_turtle && !self.mobs[index].is_baby() {
self.handle_mob_events(vec![MobEvent::GiftLoot {
@@ -3886,6 +4024,9 @@ impl Game<'_> {
}
}
if let Some(stack) = armor_drop {
// Mob.shearItem: PLAYER_SHEARED_EQUIPMENT with the armor and the wolf.
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity("minecraft:player_sheared_equipment", &stack, &target);
self.spawn_item(stack, [center[0], center[1] + 0.5, center[2]], [0.0; 3], 10);
}
for (item, count) in drops {
@@ -4060,7 +4201,7 @@ impl Game<'_> {
};
let parent = &self.mobs[index];
if !parent.alive()
|| stack.item.strip_suffix("_spawn_egg") != Some(parent.kind.id.as_str())
|| !horizon_client::mobs::finalize::spawn_egg_is_for(&stack.item, &parent.kind.id)
|| !horizon_client::mobs::drops::can_be_baby(parent.species())
// WanderingTrader.getBreedOffspring returns null: no offspring, the
// click carries on to trading.
@@ -4075,15 +4216,11 @@ impl Game<'_> {
let (species, variant, color) =
(parent.species(), parent.extra.variant, parent.extra.color);
let target = self.mob_entity(&self.mobs[index]);
let Some(id) = self.spawn_mob(&kind, at, true) else {
let Some(id) = self.spawn_mob_spawned(&kind, at) else {
return false;
};
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::finalize::set_baby(child, true);
child.extra.variant = variant;
if species == Species::Sheep {
child.extra.color = color;
}
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
}
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
self.consume_held();
@@ -4121,7 +4258,7 @@ impl Game<'_> {
f64::from(p[1]),
f64::from(p[2]) + 0.5,
];
if self.spawn_mob(&kind, at, true).is_none() {
if self.spawn_mob_spawned(&kind, at).is_none() {
return Ok(false);
}
self.swing_arm();
@@ -4439,6 +4576,63 @@ impl Game<'_> {
self.wind_charge_geometry(a, c, packed, out);
continue;
}
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
// translucent copy of it (colour 0x26FFFFFF); always 15 block light.
if matches!(a.kind, ProjectileKind::ShulkerBullet { .. }) {
static BULLET: std::sync::OnceLock<Option<horizon_client::mobs::Part>> =
std::sync::OnceLock::new();
let model = BULLET.get_or_init(|| {
horizon_client::mobs::load_model(
&self.options.resources,
"minecraft:shulker_bullet#main",
)
});
if let (Some(model), Some(r)) =
(model, atlas.sprites.get("entity/shulker/spark").copied())
{
use horizon_core::geometry::trig;
let tc = a.age as f32 + alpha as f32;
let mut poses = horizon_client::mobs::anim::Poses::new(model);
poses.yrot("main", a.yaw.to_radians());
poses.xrot("main", a.pitch.to_radians());
let light = (packed & 0xFF) | ((15 * 16) << 8);
let base = glam::Mat4::from_translation(c)
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.15, 0.0))
* glam::Mat4::from_rotation_y(
(trig::sin(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_x(
(trig::cos(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_z(
(trig::sin(f64::from(tc * 0.15)) * 360.0).to_radians(),
)
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5));
let uv = [r.u0, r.v0, r.u1, r.v1];
horizon_client::mobs::render::draw(
model,
base,
&poses,
uv,
0xFFFF_FFFF,
light,
0,
out,
);
// The translucent shell blends over the opaque core.
horizon_client::mobs::render::draw(
model,
base * glam::Mat4::from_scale(glam::Vec3::splat(1.5)),
&poses,
uv,
0x26FF_FFFF,
light,
0,
out,
);
}
continue;
}
// WitherSkullRenderer: the skull model facing its flight.
if let Some(dangerous) = super::wither::skull_kind(a) {
static SKULL: std::sync::OnceLock<Option<horizon_client::mobs::Part>> =
@@ -4561,6 +4755,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::SonicBoom => "minecraft:sonic_boom",
K::Freeze => "minecraft:freeze",
K::OutOfWorld => "minecraft:out_of_world",
K::Cramming => "minecraft:cramming",
}
}
/// A Horizon item stack as a loot stack (id, count, damage, enchantments).
+1 -1
View File
@@ -163,7 +163,7 @@ impl Game<'_> {
}
/// A `show_dialog` click event's dialog: a registry id or the dialog itself.
fn show_dialog_ref(&mut self, dialog: &Tag) {
pub(super) fn show_dialog_ref(&mut self, dialog: &Tag) {
match dialog {
Tag::String(id) => {
let id = id.to_string_lossy();
+9 -1
View File
@@ -1083,7 +1083,7 @@ impl Game<'_> {
self.stats.add("killed", "minecraft:ender_dragon", 1);
self.stats.custom("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(id));
self.trigger_entity("minecraft:player_killed_entity", "entity", view);
self.trigger_killed(view, None);
}
/// Player melee on the dragon's parts or a crystal under the crosshair.
/// The dragon part under the crosshair within reach: its id and distance.
@@ -2230,12 +2230,20 @@ impl Game<'_> {
.filter(|p| p.alive && !p.creative && !p.spectator)
.collect();
let mut harmed: Vec<(FightWho, f32)> = Vec::new();
// DragonSittingFlamingPhase.end discards the flame cloud.
let flame_over = self
.dragon
.as_ref()
.is_some_and(|d| !matches!(d.phase, Phase::Flaming { .. }));
let mut clouds = std::mem::take(&mut self.breath_clouds);
clouds.retain_mut(|c| {
// Lingering potions' clouds tick with the potions.
if c.potion.is_some() {
return true;
}
if c.from_dragon && flame_over {
return false;
}
c.age += 1;
if c.age >= c.duration {
return false;
@@ -71,10 +71,18 @@ impl Game<'_> {
Some(r) => self.settings.render_distance.min(r.view_distance),
None => self.settings.render_distance,
};
(f64::from(distance) / 8.0).clamp(1.0, 2.5) * f64::from(self.settings.entity_distance)
entity_view_scale(distance, self.settings.entity_distance)
}
}
/// Entity.setViewScale: `Mth.clamp(renderDistance / 8, 1, 2.5) *
/// entityDistanceScaling`. Every entity's draw distance is scaled by it, so
/// a small mob is drawn nearer than a big one and a longer render distance
/// draws both further away.
pub(super) fn entity_view_scale(render_distance: i32, entity_distance: f32) -> f64 {
(f64::from(render_distance) / 8.0).clamp(1.0, 2.5) * f64::from(entity_distance)
}
/// Entity.shouldRenderAtSqrDistance's default range: 64 times the bounding
/// box's mean edge, scaled by the view scale.
pub(super) fn default_range(size: glam::DVec3, view_scale: f64) -> f64 {
@@ -135,3 +143,41 @@ impl Game<'_> {
self.entity_in_view(camera, position, range, lo, hi)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `Entity.setViewScale`: the entity distance option times the render
/// distance's boost — 1 at eight chunks, 1.5 at twelve, 2.5 at twenty.
#[test]
fn the_view_scale_follows_the_render_distance() {
assert_eq!(entity_view_scale(4, 1.0), 1.0);
assert_eq!(entity_view_scale(8, 1.0), 1.0);
assert_eq!(entity_view_scale(12, 1.0), 1.5);
assert_eq!(entity_view_scale(20, 1.0), 2.5);
assert_eq!(entity_view_scale(32, 1.0), 2.5);
// The entity distance option scales it down.
assert_eq!(entity_view_scale(12, 0.5), 0.75);
}
/// `Entity.shouldRenderAtSqrDistance`: a chicken's box (0.4 by 0.7 by
/// 0.4) is drawn up to 48 blocks away at the vanilla defaults, not the
/// 32 blocks a missing view scale leaves (#2273).
#[test]
fn a_chicken_is_drawn_forty_eight_blocks_away() {
let range = default_range(glam::DVec3::new(0.4, 0.7, 0.4), entity_view_scale(12, 1.0));
assert_eq!(range, 48.0);
}
/// `Entity.shouldRenderAtSqrDistance` on a broken box: the size falls
/// back to one block.
#[test]
fn a_broken_bounding_box_falls_back_to_one_block() {
let range = default_range(
glam::DVec3::new(f64::NAN, 0.7, 0.4),
entity_view_scale(12, 1.0),
);
assert_eq!(range, 96.0);
}
}
@@ -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},
+3 -3
View File
@@ -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);
+79 -11
View File
@@ -4,7 +4,8 @@
//! `thunderHit` (damage and burning, charged creepers, zombified piglins,
//! witches, mooshroom colours, turtles, burnt items), lightning rods, the sky
//! flash, the sounds and the branching additive bolt.
use super::Game;
use super::{Game, commands::Target};
use horizon_client::advancements::EntityView;
use horizon_client::mobs::{Damage, DamageKind, MobEvent, SpawnSetup, Species};
use horizon_core::{position::BlockPos, random::RandomSource};
@@ -18,6 +19,13 @@ pub(super) struct Bolt {
visual_only: bool,
/// Mooshrooms and copper golems change once per bolt.
converted: Vec<u32>,
/// The mobs it has struck (`hitEntities`).
hit: Vec<u32>,
/// Blocks it set on fire (`blocksSetOnFire`).
fires: i32,
/// The player who channelled it (`LightningBolt.cause`): `Some(None)` for
/// the host, `Some(Some(conn))` for a guest.
pub cause: Option<Option<u32>>,
}
impl Game<'_> {
/// `ServerChunkCache.tickSpawningChunk`: one `tickThunder` roll per
@@ -29,10 +37,13 @@ impl Game<'_> {
if rain <= 0.2 || thunder <= 0.9 || self.dim != super::Dim::Overworld {
return;
}
let (_, mut chunks) = self.spawning_chunks(&self.level_players());
let mut scratch = std::mem::take(&mut self.spawn_scratch);
self.spawning_chunks(&self.level_players(), &mut scratch);
let levels = self.tickets.levels();
chunks.retain(|&(x, z)| levels.random_ticking(x, z));
for (cx, cz) in chunks {
scratch
.candidates
.retain(|&(x, z)| levels.random_ticking(x, z));
for (cx, cz) in scratch.candidates.drain(..) {
if self.world.random.next_int_bound(100_000).unwrap_or(1) != 0 {
continue;
}
@@ -77,6 +88,7 @@ impl Game<'_> {
];
self.spawn_bolt(at, trap);
}
self.spawn_scratch = scratch;
}
/// `ServerLevel.findLightningRod`: `PoiManager.findClosest` over the
/// lightning rod points of interest within 128 blocks of `center` whose
@@ -206,6 +218,9 @@ impl Game<'_> {
seed,
visual_only,
converted: Vec::new(),
hit: Vec::new(),
fires: 0,
cause: None,
});
}
/// `ClientLevel.addEntity` for a server's lightning bolt: on a client it
@@ -224,11 +239,16 @@ impl Game<'_> {
seed,
visual_only: true,
converted: Vec::new(),
hit: Vec::new(),
fires: 0,
cause: None,
});
}
/// `spawnFire`: fire at the strike point and `extra` random neighbours,
/// wherever `BaseFireBlock.getState` fire can survive.
fn bolt_fire(&mut self, at: [f64; 3], extra: i32) {
/// wherever `BaseFireBlock.getState` fire can survive. Returns how many
/// blocks it set on fire.
fn bolt_fire(&mut self, at: [f64; 3], extra: i32) -> i32 {
let mut set = 0;
let base = [
at[0].floor() as i32,
at[1].floor() as i32,
@@ -253,8 +273,10 @@ impl Game<'_> {
});
if let Some(fire) = fire {
let _ = self.level_set(p, fire);
set += 1;
}
}
set
}
/// `LightningBolt.tick` for every bolt.
pub(super) fn tick_bolts(&mut self) {
@@ -278,7 +300,7 @@ impl Game<'_> {
if !b.visual_only {
// Only Normal and Hard start fires with the strike itself.
if self.ui.difficulty >= 2 {
self.bolt_fire(b.at, 4);
b.fires += self.bolt_fire(b.at, 4);
}
self.strike_rod(b.at);
}
@@ -293,6 +315,7 @@ impl Game<'_> {
b.life -= 1;
if b.life < 0 {
if b.flashes == 0 {
self.lightning_strike_triggers(b);
return false;
}
if b.life < -self.world.random.next_int_bound(10).unwrap_or(0) {
@@ -300,7 +323,7 @@ impl Game<'_> {
b.life = 1;
b.seed = self.world.random.next_long();
if !b.visual_only {
self.bolt_fire(b.at, 0);
b.fires += self.bolt_fire(b.at, 0);
}
}
}
@@ -310,7 +333,11 @@ impl Game<'_> {
}
self.flash = 2;
if !b.visual_only {
self.thunder_hit(b);
let struck = self.thunder_hit(b);
if let Some(cause) = b.cause {
let who = cause.map_or(Target::Player, Target::Guest);
self.for_player(who, |g| g.trigger_channeled_lightning(&struck));
}
}
}
true
@@ -355,8 +382,41 @@ impl Game<'_> {
self.with_level(|b, level, ticks| b.lightning_clear_copper(level, ticks, p));
let _ = self.process_level_events();
}
/// `thunderHit` for entities within 3 blocks (up to 9 above).
fn thunder_hit(&mut self, b: &mut Bolt) {
/// `LightningStrikeTrigger` at the bolt's discard: every player within 256
/// blocks, with the mobs in its 30-block box that it did not hit.
fn lightning_strike_triggers(&mut self, b: &Bolt) {
let at = b.at;
let bolt = EntityView {
kind: "minecraft:lightning_bolt".into(),
position: at,
blocks_set_on_fire: b.fires,
..Default::default()
};
let viewers: Vec<EntityView> = self
.mobs
.iter()
.filter(|m| m.alive() && !b.hit.contains(&m.id))
.filter(|m| {
(m.position.x - at[0]).abs() <= 15.0
&& (m.position.z - at[2]).abs() <= 15.0
&& m.position.y >= at[1] - 15.0
&& m.position.y <= at[1] + 21.0
})
.map(|m| self.mob_entity(m))
.collect();
self.for_each_player(|g| {
let Some(p) = g.player.as_ref().map(|p| p.position) else {
return;
};
let d2 = (p.x - at[0]).powi(2) + (p.y - at[1]).powi(2) + (p.z - at[2]).powi(2);
if d2 < 256.0 * 256.0 {
g.trigger_lightning_strike(&bolt, &viewers);
}
});
}
/// `thunderHit` for entities within 3 blocks (up to 9 above). Returns the
/// mobs in that box before the hit, as `CHANNELED_LIGHTNING` counts them.
fn thunder_hit(&mut self, b: &mut Bolt) -> Vec<EntityView> {
let at = b.at;
let inside = move |p: [f64; 3]| {
(p[0] - at[0]).abs() <= 3.0
@@ -364,6 +424,13 @@ impl Game<'_> {
&& p[1] >= at[1] - 3.0
&& p[1] <= at[1] + 9.0
};
let struck: Vec<(u32, EntityView)> = self
.mobs
.iter()
.filter(|m| m.alive() && inside([m.position.x, m.position.y, m.position.z]))
.map(|m| (m.id, self.mob_entity(m)))
.collect();
b.hit.extend(struck.iter().map(|(id, _)| *id));
let damage = Damage {
kind: DamageKind::Generic,
attacker: None,
@@ -461,6 +528,7 @@ impl Game<'_> {
!inside([e.position.x, e.position.y, e.position.z])
|| e.stack.item.contains("netherite")
});
struck.into_iter().map(|(_, view)| view).collect()
}
/// Simulation-order render inputs; seeded geometry lives in the renderer.
pub(super) fn bolt_instances(&self) -> Vec<horizon_client::gfx::lightning::Bolt> {
+14 -6
View File
@@ -595,6 +595,18 @@ impl Game<'_> {
/// `HopperBlockEntity.pushItemsTick` of the hopper at `pos`, if its block
/// is still a hopper (`BlockEntityType.isValid`).
pub(super) fn tick_hopper(&mut self, pos: Pos) {
let time = self.world.game_time;
let waiting = {
let state = self.hoppers.entry(pos).or_default();
state.cooldown -= 1;
state.ticked = time;
state.cooldown > 0
};
if waiting {
return;
}
// Most ticks only advance the cooldown: the block is checked when the
// hopper is about to move, not on every tick it waits.
let hopper = self
.res
.facts()
@@ -606,14 +618,10 @@ impl Game<'_> {
self.hoppers.remove(&pos);
return;
}
let time = self.world.game_time;
let state = self.hoppers.entry(pos).or_default();
state.cooldown -= 1;
state.ticked = time;
if state.cooldown <= 0 {
if let Some(state) = self.hoppers.get_mut(&pos) {
state.cooldown = 0;
self.hopper_try_move_items(pos);
}
self.hopper_try_move_items(pos);
}
/// Drops the cooldowns of hoppers whose block entity is gone.
pub(super) fn forget_gone_hoppers(&mut self) {
+1
View File
@@ -739,6 +739,7 @@ impl Game<'_> {
return false;
}
let at = [x, y, z].map(|v| f64::from(v) + 0.5);
self.trigger_block("minecraft:item_used_on_block", hit.pos, stack);
self.audio
.play("minecraft:item.lodestone_compass.lock", Some(at), 1.0, 1.0);
let target = Tag::compound([(
@@ -1459,6 +1459,7 @@ impl Game<'_> {
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|c| self.gui.atlas.glyph_cell(c),
glyphs: self.gui.atlas.font.epoch(),
};
self.res
.block_entity_render()
+11 -1
View File
@@ -259,6 +259,7 @@ impl Game<'_> {
Species::CamelHusk => "minecraft:entity.camel_husk.eat",
Species::Donkey => "minecraft:entity.donkey.eat",
Species::Mule => "minecraft:entity.mule.eat",
Species::Horse if m.is_baby() => "minecraft:entity.baby_horse.eat",
_ => "minecraft:entity.horse.eat",
};
let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
@@ -337,7 +338,16 @@ impl Game<'_> {
}
// The nautilus plays its dash sound itself.
if equine::is_equine(m.species()) {
let sound = "minecraft:entity.horse.jump";
// playJumpSound: Donkey and Mule have their own; a skeleton
// horse in water splashes.
let sound = match m.species() {
Species::Donkey => "minecraft:entity.donkey.jump",
Species::Mule => "minecraft:entity.mule.jump",
Species::SkeletonHorse if m.fluids.in_water => {
"minecraft:entity.skeleton_horse.jump_water"
}
_ => "minecraft:entity.horse.jump",
};
let at = m.center();
self.audio.play(sound, Some(at), 0.4, 1.0);
}
+16 -5
View File
@@ -81,10 +81,13 @@ impl Game<'_> {
let viewer = self.player_team();
let mut tags = Vec::new();
let players = self.other_players();
let named = self
.mobs
.iter()
.filter_map(|m| Some((m, m.custom_name.clone()?, false)));
let named = self.mobs.iter().filter_map(|m| {
let name = m.custom_name.as_ref()?;
// The tag's 64 block distance is measured to the entity: copy the
// name only for a mob the frame can show it on.
let at = glam::DVec3::new(m.position.x, m.position.y, m.position.z);
(at.distance_squared(camera) < 64.0 * 64.0).then(|| (m, name.clone(), false))
});
let players = players.into_iter().map(|(m, name)| (m, name, true));
// Who is riding whom: asked once per tag below, and rescanning every mob
// for each tag made this quadratic in the number of loaded mobs.
@@ -328,7 +331,15 @@ impl Game<'_> {
) {
// Options.getBackgroundOpacity(0.25) black, as ARGB.color.
const BACKGROUND: u32 = 0x3F00_0000;
let forward = glam::DVec3::from_array(view_vector(self.yaw, self.pitch));
// The camera's facing (Camera.rotation()), turned round in third-person
// front view, so the text reads the right way from where it is seen.
let (view_yaw, view_pitch) =
if self.camera_type == super::perspective::CameraType::ThirdPersonFront {
(self.yaw + 180.0, -self.pitch)
} else {
(self.yaw, self.pitch)
};
let forward = glam::DVec3::from_array(view_vector(view_yaw, view_pitch));
let right = forward.cross(glam::DVec3::Y).normalize_or_zero();
let up = right.cross(forward).normalize_or_zero();
let white = self.gui.atlas.font.white();
@@ -24,6 +24,15 @@ use horizon_core::{
use horizon_world::registry::BlockStateId;
use std::collections::HashMap;
/// Reused buffers for one pass over the spawning chunks: the candidate chunks
/// and the set that keeps each one once. The buffers keep their capacity
/// between ticks.
#[derive(Default)]
pub(super) struct SpawnScratch {
pub(super) candidates: Vec<(i32, i32)>,
pub(super) seen: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
}
/// A player in the level in place, as spawning and despawning see it.
pub(super) struct LevelPlayer {
pub(super) position: Vec3,
@@ -445,9 +454,17 @@ impl<'a> Game<'a> {
/// chunks (within 8 chunks of a player, `getSpawnCandidateChunks`) that
/// are ticking and within 128 blocks of a non-spectator. Also returns how
/// many chunks the tracker holds (`getNaturalSpawnChunkCount`).
pub(super) fn spawning_chunks(&self, players: &[LevelPlayer]) -> (i32, Vec<(i32, i32)>) {
let mut candidates: Vec<(i32, i32)> = Vec::with_capacity(289 * players.len());
let mut seen = horizon_client::fast_hash::FxHashSet::default();
pub(super) fn spawning_chunks(
&self,
players: &[LevelPlayer],
scratch: &mut SpawnScratch,
) -> i32 {
let candidates = &mut scratch.candidates;
candidates.clear();
candidates.reserve(289 * players.len());
let seen = &mut scratch.seen;
seen.clear();
seen.reserve(289 * players.len());
for p in players {
let (pcx, pcz) = (
p.position.x.floor() as i32 >> 4,
@@ -471,7 +488,7 @@ impl<'a> Game<'a> {
.iter()
.any(|p| close_enough_for_spawning(p, (cx, cz)))
});
(count, candidates)
count
}
/// `ServerChunkCache.tickChunks`: the spawn state over the loaded mobs,
/// the categories under their global caps (creatures every 400 ticks),
@@ -482,8 +499,9 @@ impl<'a> Game<'a> {
if players.is_empty() {
return;
}
let (spawnable, mut chunks) = self.spawning_chunks(&players);
for &key in &chunks {
let mut scratch = std::mem::take(&mut self.spawn_scratch);
let spawnable = self.spawning_chunks(&players, &mut scratch);
for &key in &scratch.candidates {
*self.world.inhabited.entry(key).or_insert(0) += 1;
}
let spawn_mobs = self.ui.rules.bool("spawn_mobs");
@@ -524,12 +542,12 @@ impl<'a> Game<'a> {
Vec::new()
};
// Util.shuffle.
for i in (1..chunks.len()).rev() {
for i in (1..scratch.candidates.len()).rev() {
let j = random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
chunks.swap(i, j);
scratch.candidates.swap(i, j);
}
if !categories.is_empty() {
for chunk in chunks {
for chunk in scratch.candidates.drain(..) {
let mut spawn = |l: &GameLevel, r: &SpawnRequest| {
let spawned = l.game.finalize_spawned(r, &mut pending);
if let Some(s) = &spawned {
@@ -551,6 +569,7 @@ impl<'a> Game<'a> {
}
self.world.random = random;
self.add_spawned(pending, false);
self.spawn_scratch = scratch;
}
/// `NoiseBasedChunkGenerator.spawnOriginalMobs`: creature packs for a
/// freshly generated chunk, from the biome at its corner column's top,
@@ -273,6 +273,7 @@ impl Game<'_> {
s.horizon.hdr,
s.horizon.hdr_paper_white as f32,
s.horizon.hdr_peak as f32,
&Self::options_path().with_file_name(horizon_client::graphics::HDR_MARKER),
);
}
// Window.updateFullscreenIfChanged: only a changed option asks the
+8
View File
@@ -45,6 +45,14 @@ pub fn open_folder(dir: &Path) {
}
/// `Util.getPlatform().openUri`: the system browser on the link.
pub fn open_uri(uri: &str) {
// URLs need Windows' registered protocol handler, not Explorer's path
// handling. ShellExecute also preserves the URL as one Unicode value,
// without interpolating its query string into a command interpreter.
#[cfg(windows)]
if let Err(error) = open::that_detached(uri) {
log::warn!("Horizon: could not open URI {uri}: {error}");
}
#[cfg(not(windows))]
open_folder(Path::new(uri));
}
/// The resource root to load from, given the vanilla root, the selection and
+4 -6
View File
@@ -960,7 +960,8 @@ impl Game<'_> {
let amount =
|base: i32| (0.5 * f64::from(base << amp) + 0.5) as i32 as f32;
if (*id == "instant_damage") != inverted {
m.hurt(amount(6), Some(at), &mut self.world.random);
let harm = m.potion_harm(amount(6));
m.hurt(harm, Some(at), &mut self.world.random);
} else {
m.health = (m.health + amount(4)).min(m.kind.max_health);
}
@@ -1228,11 +1229,8 @@ impl Game<'_> {
};
let m = &mut self.mobs[i];
if harm {
m.hurt(
(intensity * f64::from(6 << amp) + 0.5) as i32 as f32,
Some([at.x, at.y, at.z]),
&mut self.world.random,
);
let harm = m.potion_harm((intensity * f64::from(6 << amp) + 0.5) as i32 as f32);
m.hurt(harm, Some([at.x, at.y, at.z]), &mut self.world.random);
} else {
m.health = (m.health + (intensity * f64::from(4 << amp) + 0.5) as i32 as f32)
.min(m.kind.max_health);
-1
View File
@@ -700,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)
+40 -1
View File
@@ -623,6 +623,18 @@ impl Game<'_> {
SculkKind::Shrieker => sculk::shrieker_can_listen(event),
_ => sculk::is_vibration(event),
};
// VibrationSystem.User.isValidVibration: a player sneaking past a
// sensor, whose step it ignores, is AVOID_VIBRATION (sensors only;
// a shrieker cannot trigger it).
if listens
&& matches!(kind, SculkKind::Sensor | SculkKind::Calibrated)
&& ctx.source == Some(Who::Player)
&& !ctx.spectator
&& ctx.stepping_carefully
&& sculk::ignored_when_sneaking(event)
{
self.trigger_player("minecraft:avoid_vibration");
}
if !sculk::valid_vibration(listens, event, ctx) {
return;
}
@@ -693,12 +705,15 @@ impl Game<'_> {
fn warden_listen(&mut self, index: usize, event: &'static str, pos: [f64; 3], ctx: &Context) {
let now = self.world.game_time;
let (occludes, world) = (&self.sculk.tables.occludes, &self.world);
warden::listen(&mut self.mobs[index], now, event, pos, ctx, |a, b| {
let avoided = warden::listen(&mut self.mobs[index], now, event, pos, ctx, |a, b| {
sculk::is_occluded(a, b, |p| {
let state = world.block(p.x, p.y, p.z);
occludes.get(state.0 as usize).copied().unwrap_or(false)
})
});
if avoided {
self.trigger_player("minecraft:avoid_vibration");
}
}
// ---- ticking ----
@@ -1577,6 +1592,30 @@ impl Game<'_> {
}
}
/// `RedStoneOreBlock.useItemOn`: a right-click lights the ore whatever the
/// hand holds. False (PASS) when the hand holds a block item, which goes on
/// to be placed; true (SUCCESS) otherwise.
pub(super) fn use_redstone_ore(
&mut self,
pos: [i32; 3],
held: Option<&horizon_client::items::ItemStack>,
) -> Result<bool> {
if !matches!(
&*self.block_name(pos),
"minecraft:redstone_ore" | "minecraft:deepslate_redstone_ore"
) {
return Ok(false);
}
self.attack_block(pos)?;
let block_item = held.is_some_and(|s| {
self.res
.items()
.get(&s.item)
.is_some_and(|i| i.block.is_some())
});
Ok(!block_item)
}
fn tick_mob_sculk_events(&mut self) {
let snapshot: Vec<(u32, [f64; 3], bool, bool, Species, Option<i64>)> = self
.mobs
+31 -22
View File
@@ -10,6 +10,7 @@ use horizon_client::{
Mob, Who,
spawner::{Spawner, SpawnerAdded, SpawnerEvent, SpawnerSpawn},
spawning,
trial_spawner::TrialState,
},
particles::{ParticleOptions, ParticleType},
};
@@ -64,14 +65,13 @@ impl Game<'_> {
.unwrap_or_default();
self.spawners.entity_types = Some(types);
}
/// The server tick of the spawner at `key`, if its block is still a
/// spawner. Its client tick comes after every block entity's
/// (`client_tick_spawners`).
/// The server tick of the spawner at `key`. Its client tick comes after
/// every block entity's (`client_tick_spawners`).
///
/// The caller only reaches this for a `BlockEntity::Spawner`, and changing
/// the block removes or replaces that entity, so the block is not read
/// again here.
pub(super) fn tick_spawner(&mut self, key: Pos) -> Result<()> {
let state = self.world.block(key.0, key.1, key.2);
if &*self.res.facts().get(state).name != "minecraft:spawner" {
return Ok(());
}
let Some(BlockEntity::Spawner(mut spawner)) = self.world.block_entities.take(&key) else {
return Ok(());
};
@@ -373,12 +373,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 +385,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);
+14 -1
View File
@@ -44,8 +44,16 @@ impl Game<'_> {
}
/// `ServerPlayer.getKnownMovement` of the acting player: what they moved
/// in the last tick. A guest's comes with their moves.
/// in the last tick. A guest's comes with their moves. A rider's is its
/// mount's move (`getKnownSpeed`: the vehicle's, or the move the driver's
/// client sent for it), so a charge on horseback carries the horse's speed.
fn known_player_movement(&self) -> Vec3 {
if let Some(i) = self
.riding
.and_then(|v| self.mobs.iter().position(|m| m.id == v))
{
return self.mob_speed(i).scale(1.0 / 20.0);
}
if self.lan_acting.is_some() {
return self.known_movement;
}
@@ -193,6 +201,9 @@ impl Game<'_> {
}
let who = actor.map_or(OWN, |conn| conn as i32);
self.kinetic_hit_feedback(who, &kinetic, [feet.x, feet.y, feet.z]);
// SPEAR_MOBS_TRIGGER: the living entities stabbed in this use.
let stabbed = self.stabbed.keys().filter(|(who, _)| *who == actor).count();
self.trigger_spear_mobs(stabbed);
}
}
@@ -239,6 +250,8 @@ impl Game<'_> {
self.mobs[index].knockback(0.4, xd, zd);
let (strength, _) = self.extra_knockback(&kind, 0.0);
self.mobs[index].knockback(f64::from(strength), xd, zd);
// ...and the attacker stops sprinting.
self.stop_sprint_after_extra_knockback();
}
let dismounted = dismounts && self.mobs[index].extra.mount.is_some();
if dismounted {
+1
View File
@@ -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,
+1 -1
View File
@@ -1643,7 +1643,7 @@ impl Game<'_> {
continue;
};
let stack = self.slot_mut(i).clone();
self.draw_slot_item(batch, drag.as_ref(), i, stack, ox + sx, oy + sy);
self.draw_slot_item(batch, drag.as_ref(), i, &stack, ox + sx, oy + sy);
}
if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) {
let (sx, sy) = self.slot_position(slot);
+4 -2
View File
@@ -137,7 +137,9 @@ impl Game<'_> {
horizon_world::nbt::Tag::Int(horizon_world::DATA_VERSION),
),
]);
let _ = horizon_client::save::write_dat(&self.random_sequences_path(), tag);
if let Err(e) = horizon_client::save::write_dat(&self.random_sequences_path(), tag) {
log::warn!("the villager random sequences were not written: {e}");
}
}
/// `AbstractVillager.addOffersFromTradeSet` with the world's random
/// sequence of the set, at the merchant's position.
@@ -783,7 +785,7 @@ impl Game<'_> {
for i in slots {
let (sx, sy) = self.slot_position(i);
let stack = self.slot_mut(i).clone();
self.draw_slot_item(batch, drag.as_ref(), i, stack, ox + sx, oy + sy);
self.draw_slot_item(batch, drag.as_ref(), i, &stack, ox + sx, oy + sy);
}
if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) {
let (sx, sy) = self.slot_position(slot);
@@ -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,
+16 -9
View File
@@ -225,7 +225,7 @@ impl Game<'_> {
/// The `summon_entity` and `play_sound` effects of `channeling.json`: in a
/// thunderstorm (`weather_check`) a spot that sees the sky (`can_see_sky`)
/// gets a lightning bolt and the thunder sound.
fn channel_lightning(&mut self, at: [f64; 3]) {
fn channel_lightning(&mut self, at: [f64; 3], guest: Option<u32>) {
// Level.isThundering: the thunder level, in dimensions with weather.
if self.dim != super::Dim::Overworld || self.world.weather.levels(1.0).1 <= 0.9 {
return;
@@ -240,13 +240,17 @@ impl Game<'_> {
return;
}
self.spawn_bolt(at, false);
// The summon effect's owner is the thrower: LightningBolt.setCause.
if let Some(bolt) = self.bolts.last_mut() {
bolt.cause = Some(guest);
}
self.audio
.play("minecraft:item.trident.thunder", Some(at), 5.0, 1.0);
}
/// `minecraft:hit_block` of Channeling (`AbstractArrow.hitBlockEnchantmentEffects`):
/// the block the trident's path first hits, if it is a lightning rod, with
/// the hit location clamped into the block (`BlockPos.clampLocationWithin`).
fn channel_rod_hit(&mut self, from: Vec3, velocity: Vec3) {
fn channel_rod_hit(&mut self, from: Vec3, velocity: Vec3, guest: Option<u32>) {
let to = Vec3::new(
from.x + velocity.x,
from.y + velocity.y,
@@ -279,11 +283,14 @@ impl Game<'_> {
let edge = f64::from(1.0e-5f32);
let clamp =
|v: f64, origin: i32| v.clamp(f64::from(origin) + edge, f64::from(origin) + 1.0 - edge);
self.channel_lightning([
clamp(hit.location.x, hit.block.x),
clamp(hit.location.y, hit.block.y),
clamp(hit.location.z, hit.block.z),
]);
self.channel_lightning(
[
clamp(hit.location.x, hit.block.x),
clamp(hit.location.y, hit.block.y),
clamp(hit.location.z, hit.block.z),
],
guest,
);
}
/// `TridentItem.releaseUsing`.
pub(super) fn release_trident(&mut self, ticks: u32) {
@@ -514,7 +521,7 @@ impl Game<'_> {
if let Some(at) = hurt_at
&& t.stack.enchantment_level("minecraft:channeling") > 0
{
self.channel_lightning(at);
self.channel_lightning(at, t.guest);
}
if hurt_at.is_some()
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
@@ -558,7 +565,7 @@ impl Game<'_> {
);
if blocked {
if t.stack.enchantment_level("minecraft:channeling") > 0 {
self.channel_rod_hit(from, flight);
self.channel_rod_hit(from, flight, t.guest);
}
t.in_ground = true;
t.velocity = Vec3::ZERO;
@@ -240,6 +240,67 @@ impl Game<'_> {
}
}
/// `NearestItemSensor.doTick` for the villagers whose sensor is due: the
/// nearest dropped item within 32 blocks that `Villager.wantsToPickUp`
/// accepts and the villager sees, or none.
pub(super) fn villager_item_sense(&mut self) {
if self.dropped.is_empty() {
for m in &mut self.mobs {
if m.species() == Species::Villager && villager_brain::take_item_scan(m) {
villager_brain::set_wanted_item(m, None);
}
}
return;
}
let picks_up = self
.res
.items()
.item_tags
.get("minecraft:villager_picks_up")
.cloned();
let mut due = Vec::new();
for (i, m) in self.mobs.iter_mut().enumerate() {
if m.species() == Species::Villager && villager_brain::take_item_scan(m) {
due.push(i);
}
}
if due.is_empty() {
return;
}
let w = self.mob_world_base(&[]);
let mut found = Vec::new();
for i in due {
let m = &self.mobs[i];
let area = m.bounds().inflate(Vec3::new(32.0, 16.0, 32.0));
let requested = villager::requested_items(m);
let eye = Vec3::new(m.position.x, m.eye_y(), m.position.z);
let mut best: Option<(f64, Vec3)> = None;
for e in &self.dropped {
let d = e.position.distance_squared(m.position);
if best.is_some_and(|(b, _)| d >= b)
|| !e.bounds().intersects(area)
|| d >= 32.0 * 32.0
{
continue;
}
let item = &e.stack.item;
let wants = villager::food_points(item) > 0
|| picks_up.as_ref().is_some_and(|t| t.contains(&**item))
|| requested.contains(&&**item);
let fits =
villager::ext(m).is_none_or(|x| x.can_add(&e.stack, self.item_max_stack(item)));
// ItemEntity's eye height is 0.2125.
let at = Vec3::new(e.position.x, e.position.y + 0.2125, e.position.z);
if wants && fits && w.line_of_sight(eye, at) {
best = Some((d, e.position));
}
}
found.push((i, best.map(|(_, p)| [p.x, p.y, p.z])));
}
for (i, at) in found {
villager_brain::set_wanted_item(&mut self.mobs[i], at);
}
}
/// `Mob.aiStep` item pickup for villagers (`InventoryCarrier`): food,
/// seeds and what their profession wants, from within a block.
pub(super) fn villager_pickups(&mut self) {
+12 -1
View File
@@ -121,6 +121,13 @@ impl Game<'_> {
);
}
}
// An ender dragon part takes the point of damage too; a crystal is
// not hit (`AbstractWindCharge.canHitEntity`).
Who::Mob(id) if id >= super::dragon::SPECIAL => {
if id < super::dragon::SPECIAL + 8 {
self.hit_special(id, 1.0, p.owner == Some(Who::Player), false);
}
}
Who::Mob(id) => {
let source = Damage {
kind: DamageKind::Thrown,
@@ -570,11 +577,15 @@ impl Game<'_> {
poses.yrot("wind", age * 16.0_f32.to_radians());
let base = glam::Mat4::from_translation(at)
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0));
horizon_client::mobs::render::draw(
horizon_client::mobs::render::draw_scrolled(
model,
base,
&poses,
[r.u0, r.v0, r.u1, r.v1],
horizon_client::mobs::render::scroll_offset(
age,
horizon_client::mobs::render::WIND_CHARGE_SCROLL,
),
0xFFFF_FFFF,
light,
0,
+13 -5
View File
@@ -120,7 +120,8 @@ impl Game<'_> {
Ok(())
}
/// `WitherSkull.onHitEntity` / `onHit`: 8 damage (healing the wither 5
/// when it kills), withering for 10 seconds, then a power 1 blast.
/// when it kills), withering on a hit (10 seconds on Normal, 40 on Hard,
/// none on Easy), then a power 1 blast.
pub(super) fn wither_skull_hit(
&mut self,
p: &Projectile,
@@ -132,17 +133,24 @@ impl Game<'_> {
_ => None,
};
let amount = if owner.is_some() { 8.0 } else { 5.0 };
let wither_ticks = match self.difficulty_id() {
2 => 200,
3 => 800,
_ => 0,
};
match who {
Some(Who::Player) => {
if !self.creative {
// WitherSkull.onHitEntity's source: the wither launched it.
self.combat.projectile_owner = owner;
self.mob_hits_player_kind(
let hit = self.mob_hits_player(
amount,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:wither_skull",
);
self.give_player_effect("minecraft:wither", 200, 1);
if hit && wither_ticks > 0 {
self.give_player_effect("minecraft:wither", wither_ticks, 1);
}
}
}
Some(Who::Mob(id)) => {
@@ -154,8 +162,8 @@ impl Game<'_> {
let killed = match self.mobs.iter_mut().find(|m| m.id == id) {
Some(m) => {
let hit = m.damage(amount, source);
if hit && m.alive() && m.species() != Species::Wither {
m.add_effect("minecraft:wither", 200, 1);
if hit && m.alive() && m.species() != Species::Wither && wither_ticks > 0 {
m.add_effect("minecraft:wither", wither_ticks, 1);
}
hit && !m.alive()
}
@@ -246,10 +246,14 @@ pub(super) fn preload_world_data(
.spawn(move || {
horizon_threads::behind_server();
// Errors are left for the world opening to report.
let _ = horizon_client::vanillagen::shared_data(
if let Ok(data) = horizon_client::vanillagen::shared_data(
&resources.join("worldgen"),
registry,
);
) {
// The Overworld's feature decoration, which every
// seed shares: about 140 ms off opening the world.
let _ = data.prepare_decoration("minecraft:normal", "minecraft:overworld");
}
let _ = horizon_client::structure_blocks::loot_data(&resources);
let _ = super::lan::sync_data(&resources);
});
+22 -2
View File
@@ -539,6 +539,9 @@ pub struct Renderer {
hdr: Option<hdr::Hdr>,
/// The HDR output the window's surface can present.
hdr_output: Option<hdr::Output>,
/// The marker file of an HDR output that has not presented a frame yet
/// (`graphics::HDR_MARKER`).
hdr_marker: Option<std::path::PathBuf>,
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
@@ -1966,7 +1969,7 @@ impl Renderer {
// ulps covers that spread at any resolution while staying far below
// the 0.001 a model's feet sit higher, so shadows never bleed onto
// the feet.
let shadow_blob_pipeline = entity_pipeline(
let shadow_blob_pipeline = entity_pipeline_with(
"Entity shadows",
None,
false,
@@ -1978,6 +1981,8 @@ impl Renderer {
},
alpha_blend,
false,
"fs_entity_shadow",
wgpu::ColorWrites::ALL,
);
// RenderPipelines.ITEM_TRANSLUCENT: blended, depth tested. Drawn before
// translucent terrain, it cannot write depth: the water behind a
@@ -2644,6 +2649,7 @@ impl Renderer {
world_post,
hdr: None,
hdr_output,
hdr_marker: None,
pack_post: None,
native: None,
pass_timing: pack_timing::PassTiming::logging(),
@@ -3202,9 +3208,17 @@ impl Renderer {
}
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32) {
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32, marker: &std::path::Path) {
let output = self.hdr_output.filter(|_| on && self.surface.is_some());
if output.is_some() != self.hdr.is_some() {
// If the game dies before HDR output shows a frame, the marker is still there at
// the next start, which then comes up with HDR off.
if output.is_some() {
let _ = std::fs::write(marker, "HDR output was being switched on\n");
self.hdr_marker = Some(marker.to_owned());
} else if let Some(marker) = self.hdr_marker.take() {
let _ = std::fs::remove_file(marker);
}
let format = self.config.format.remove_srgb_suffix();
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
self.configure_surface();
@@ -5231,6 +5245,12 @@ impl Renderer {
startup_step(format_args!("presenting the first frame"));
}
self.queue.present(surface_frame);
if self.hdr.is_some()
&& let Some(marker) = self.hdr_marker.take()
{
let _ = std::fs::remove_file(marker);
log::info!("Horizon renderer: HDR output presented its first frame");
}
if reconfigure && let Some(surface) = &self.surface {
Self::configure(
&self.device,
+42 -49
View File
@@ -25,12 +25,26 @@ pub const TRANSLUCENT_ITEM_OVERLAY: u32 = 32;
/// A translucent item with an enchantment glint.
pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
/// The material of a quad by its overlay (`MATERIALS` order).
fn material(layer: u32) -> usize {
match layer {
11 => 1,
12 => 2,
WATER_MASK_OVERLAY => 3,
ENERGY_SWIRL_OVERLAY => 4,
SHADOW_OVERLAY => 5,
TRANSLUCENT_PARTICLE_OVERLAY => 6,
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 7,
_ => 0,
}
}
#[derive(Default)]
pub(super) struct EntityBatch {
// Quad indices avoid copying vertices twice while grouping materials.
// Keep initialized high-water storage; local counts delimit active entries.
// Each bucket can hold all quads, removing per-quad Vec growth checks.
buckets: [Vec<usize>; MATERIALS],
// Runs of consecutive quads of one material (first quad, quad count): a
// model's quads share one, so whole runs are copied at once. Cleared each
// frame, keeping their storage.
runs: [Vec<(usize, usize)>; MATERIALS],
pub(super) combined: Vec<Vertex>,
}
@@ -43,50 +57,29 @@ impl EntityBatch {
see_through: &[Vertex],
) -> [u32; MATERIALS] {
let quads = entities.as_chunks::<4>().0;
for bucket in &mut self.buckets {
if bucket.len() < quads.len() {
bucket.resize(quads.len(), 0);
}
for runs in &mut self.runs {
runs.clear();
}
let mut counts = [0usize; MATERIALS];
for (index, quad) in quads.iter().enumerate() {
let material = match quad[0].layer {
11 => 1,
12 => 2,
WATER_MASK_OVERLAY => 3,
ENERGY_SWIRL_OVERLAY => 4,
SHADOW_OVERLAY => 5,
TRANSLUCENT_PARTICLE_OVERLAY => 6,
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 7,
_ => 0,
};
self.buckets[material][counts[material]] = index;
counts[material] += 1;
let mut start = 0;
while start < quads.len() {
let kind = material(quads[start][0].layer);
let end = quads[start + 1..]
.iter()
.position(|quad| material(quad[0].layer) != kind)
.map_or(quads.len(), |n| start + 1 + n);
self.runs[kind].push((start, end - start));
counts[kind] += end - start;
start = end;
}
let entity_vertices = quads.len() * 4;
self.combined.reserve(
(entity_vertices + additive.len() + hand.len() + see_through.len())
.saturating_sub(self.combined.len()),
);
self.combined.clear();
self.combined
.resize(entity_vertices, bytemuck::Zeroable::zeroed());
if counts.contains(&quads.len()) {
// Uniform materials are already ordered; copy the whole batch.
self.combined.copy_from_slice(&entities[..entity_vertices]);
} else {
let mut offset = 0;
for (bucket, &count) in self.buckets.iter().zip(&counts) {
let bucket = &bucket[..count];
let end = offset + count * 4;
for (output, &index) in self.combined[offset..end]
.as_chunks_mut::<4>()
.0
.iter_mut()
.zip(bucket)
{
*output = quads[index];
}
offset = end;
.reserve(entity_vertices + additive.len() + hand.len() + see_through.len());
for runs in &self.runs {
for &(first, count) in runs {
self.combined
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
}
}
self.combined.extend_from_slice(additive);
@@ -174,12 +167,12 @@ mod tests {
let hand = geometry(2, false);
// Warm every bucket and the combined buffer to their maximum sizes.
equivalent(&mut batch, &entities, &additive, &hand);
let capacities = batch.buckets.each_ref().map(|b| b.capacity());
let capacities = batch.runs.each_ref().map(|b| b.capacity());
let capacity = batch.combined.capacity();
let pointer = batch.combined.as_ptr();
for length in [0, 1, 3, 4, 511, entities.len(), 17, 0, entities.len()] {
equivalent(&mut batch, &entities[..length], &additive[..5], &hand[..3]);
assert_eq!(batch.buckets.each_ref().map(|b| b.capacity()), capacities);
assert_eq!(batch.runs.each_ref().map(|b| b.capacity()), capacities);
assert_eq!(batch.combined.capacity(), capacity);
assert_eq!(batch.combined.as_ptr(), pointer);
}
@@ -188,11 +181,11 @@ mod tests {
// Grow again, then confirm empty/small frames retain the new storage.
let larger = geometry(8192, true);
equivalent(&mut batch, &larger, &additive, &hand);
let capacities = batch.buckets.each_ref().map(|b| b.capacity());
let capacities = batch.runs.each_ref().map(|b| b.capacity());
let capacity = batch.combined.capacity();
for entities in [&larger[..7], &larger[..], &[]] {
equivalent(&mut batch, entities, &[], &[]);
assert_eq!(batch.buckets.each_ref().map(|b| b.capacity()), capacities);
assert_eq!(batch.runs.each_ref().map(|b| b.capacity()), capacities);
assert_eq!(batch.combined.capacity(), capacity);
}
}
@@ -255,9 +248,9 @@ mod tests {
before[7] as f64 / after[7] as f64,
batch.combined.capacity() * std::mem::size_of::<Vertex>()
+ batch
.buckets
.runs
.iter()
.map(|b| b.capacity() * std::mem::size_of::<usize>())
.map(|b| b.capacity() * std::mem::size_of::<(usize, usize)>())
.sum::<usize>()
);
}
+28 -2
View File
@@ -5,6 +5,9 @@
use std::{fs, path::Path};
pub const FILE: &str = "horizon-graphics.txt";
/// Written beside it before HDR output is switched on and removed once the first HDR frame is
/// presented. Found at the next start, it means HDR output took the game down, so HDR starts off.
pub const HDR_MARKER: &str = "horizon-hdr-pending";
/// Simple-level presets. `Custom` leaves every option as the player set it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
@@ -310,9 +313,17 @@ fn bool_text(v: bool) -> &'static str {
}
impl Graphics {
pub fn load(dir: &Path) -> Self {
fs::read_to_string(dir.join(FILE))
let mut g: Self = fs::read_to_string(dir.join(FILE))
.map(|t| Self::parse(&t))
.unwrap_or_default()
.unwrap_or_default();
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr {
g.hdr = false;
g.save(dir);
log::warn!(
"the last start ended before HDR output showed a frame; HDR is off now (graphics option hdr, in {FILE})"
);
}
g
}
pub fn parse(text: &str) -> Self {
let mut g = Self::default();
@@ -588,6 +599,21 @@ mod tests {
}
}
#[test]
fn every_preset_keeps_the_animated_textures_on() {
// The Performance preset used to turn them off, which froze the fire,
// the water, the lava and the portals (#2064).
assert!(Graphics::default().animated_textures);
for preset in Preset::ALL {
let mut s = crate::settings::Settings::default();
apply_preset(preset, &mut s);
assert!(
s.horizon.animated_textures,
"{preset:?} turned the animated textures off"
);
}
}
#[test]
fn automatic_workers_leave_a_core_as_vanilla_does() {
let g = Graphics::default();
+9 -5
View File
@@ -801,6 +801,8 @@ impl HealthHud {
/// nausea blits (their blending needs the screen-effect pipeline).
pub fn paint(batch: &mut crate::hud::HudBatch, atlas: &crate::hud::HudAtlas, draws: &[GuiDraw]) {
let mut stack = vec![IDENTITY];
// One buffer for the sprite keys, instead of a string per drawn sprite.
let mut name = String::new();
for d in draws {
let start = batch.vertices.len();
let pose = *stack.last().unwrap_or(&IDENTITY);
@@ -817,12 +819,14 @@ pub fn paint(batch: &mut crate::hud::HudBatch, atlas: &crate::hud::HudAtlas, dra
} => {
// Effect sprites belong to the mob_effect atlas directory;
// the other blitSprite ids refer to GUI sprites.
let name = if sprite.starts_with("mob_effect/") {
sprite.to_string()
name.clear();
if sprite.starts_with("mob_effect/") {
name.push_str(sprite);
} else {
format!("gui/sprites/{sprite}")
};
let Some(mut r) = atlas.sprites.get(&name).copied() else {
name.push_str("gui/sprites/");
name.push_str(sprite);
}
let Some(mut r) = atlas.sprites.get(name.as_str()).copied() else {
continue;
};
if let Some([sw, sh, u, v]) = *region {
+11 -4
View File
@@ -1,5 +1,7 @@
//! `EffectsInInventory`: active effect panels beside survival and creative
//! inventories, with compact icons when there is little room on the right.
use std::borrow::Cow;
use crate::{
effects::EffectInstance,
hud::{HudAtlas, HudBatch},
@@ -123,17 +125,19 @@ pub fn paint(
cursor: [i32; 2],
) -> Option<[(String, u32); 2]> {
let mut tooltip = None;
let mut icon = String::new();
for panel in panels {
background(batch, atlas, panel);
let text_width = panel.width - 39;
let clipped = atlas.font.width(&panel.name).ceil() as i32 > text_width;
if text_width > 0 {
let shown = if clipped {
let shown: Cow<'_, str> = if clipped {
let available = text_width as f32 - atlas.font.width("...");
let count = atlas.font.fit(&panel.name, available);
format!("{}...", panel.name.chars().take(count).collect::<String>())
let text: String = panel.name.chars().take(count).collect();
Cow::Owned(format!("{text}..."))
} else {
panel.name.clone()
Cow::Borrowed(panel.name.as_str())
};
batch.text(
atlas,
@@ -152,9 +156,12 @@ pub fn paint(
true,
);
}
icon.clear();
icon.push_str("mob_effect/");
icon.push_str(&panel.icon.id);
batch.sprite_scaled(
atlas,
&format!("mob_effect/{}", panel.icon.id),
&icon,
(panel.x + 7) as f32,
(panel.y + 7) as f32,
18.0,
+1
View File
@@ -69,6 +69,7 @@ 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 gfx;
+42 -1
View File
@@ -538,6 +538,8 @@ pub enum DamageKind {
Freeze,
/// `fell_out_of_world`: below the world's floor.
OutOfWorld,
/// `cramming`: too many entities in one place (`maxEntityCramming`).
Cramming,
}
/// A damage source: its type, the attacking entity and where it came from.
#[derive(Clone, Copy, Debug)]
@@ -616,6 +618,7 @@ impl Damage {
| DamageKind::SonicBoom
| DamageKind::Freeze
| DamageKind::OutOfWorld
| DamageKind::Cramming
)
}
pub fn is_fire(&self) -> bool {
@@ -642,6 +645,7 @@ impl Damage {
| DamageKind::InWall
| DamageKind::Freeze
| DamageKind::OutOfWorld
| DamageKind::Cramming
)
}
pub fn is_projectile(&self) -> bool {
@@ -980,6 +984,17 @@ pub enum MobEvent {
count: u32,
},
Sound(&'static str, [f64; 3], f32, f32),
/// `UseBonemeal`: the villager's bone meal grows the crop at `pos`
/// (`BoneMealItem.growCrop`, then level event 1505).
Bonemeal {
pos: [i32; 3],
},
/// `RingBell`: the villager rings the meeting-point bell
/// (`BellBlock.attemptToRing`: the bell shakes, the sound and the game
/// event follow).
RingBell {
bell: [i32; 3],
},
/// `ChestBlockEntity.startOpen` / `stopOpen` by a copper golem: the
/// chest's opener count goes up or down (interaction range 3).
ChestOpen {
@@ -1108,10 +1123,12 @@ pub enum MobEvent {
/// The mob the `Explode` right after this comes from
/// (`ServerExplosion.source`), and whether it is a charged creeper: the
/// exploding creeper is already out of the mob list when the game applies
/// the blast, so its victims' loot cannot look it up.
/// the blast, so its victims' loot cannot look it up, nor the death
/// message its name (`name`, `getDisplayName`).
ExplosionSource {
mob: u32,
charged: bool,
name: crate::text::Text,
},
/// The centre, the radius and the kind of the mob blowing up (it may be
/// gone from the list by the time the blast is handled).
@@ -1811,6 +1828,8 @@ pub struct MobWorld<'a> {
pub mob_griefing: bool,
/// The `mob_drops` game rule (`shouldDropLoot`).
pub mob_drops: bool,
/// The `max_entity_cramming` game rule.
pub max_entity_cramming: i32,
/// `getCurrentDifficultyAt` at the mob being ticked (near the player
/// outside the mob tick).
pub difficulty: spawning::LocalDifficulty,
@@ -2088,6 +2107,18 @@ impl Mob {
if epf > 0.0 {
amount *= 1.0 - epf / 25.0;
}
// Witch.getDamageAfterMagicAbsorb: none from its own doing, else
// 85% less from `#witch_resistant_to`.
if self.species() == Species::Witch {
if source.attacker == Some(Who::Mob(self.id)) {
amount = 0.0;
} else if matches!(
source.kind,
DamageKind::Magic | DamageKind::SonicBoom | DamageKind::Thorns
) {
amount *= 0.15;
}
}
// Wolf.actuallyHurt: wolf armor takes the damage instead of the wolf.
let absorbed = self.wolf_armor_absorbs(&source);
let mut knock = true;
@@ -2170,6 +2201,15 @@ impl Mob {
}
true
}
/// A splash or cloud's harming effect on this mob: the potion's
/// `indirect_magic` is in `#witch_resistant_to`, so a witch takes 15%.
pub fn potion_harm(&self, amount: f32) -> f32 {
if self.species() == Species::Witch {
amount * 0.15
} else {
amount
}
}
/// `Wolf.canArmorAbsorb`: it wears wolf armor and the damage type is not
/// in `#bypasses_wolf_armor` (drowning, magic, thorns, in a wall, out of
/// the world and the untyped kills).
@@ -2183,6 +2223,7 @@ impl Mob {
| DamageKind::InWall
| DamageKind::Thorns
| DamageKind::OutOfWorld
| DamageKind::Cramming
| DamageKind::Generic
)
}
+126 -40
View File
@@ -533,9 +533,9 @@ pub fn tick(
true
}
/// `LivingEntity.aiStep` -> `Entity.applyEffectsFromBlocks` for the blocks
/// that hurt or hold a mob: the cactus (`CactusBlock.entityInside`, 1) and the
/// sweet berry bush (`SweetBerryBushBlock.entityInside`: stuck at 0.8, 0.75,
/// 0.8, and a grown bush pricks for 1 when the mob moved at least 0.003 on X
/// that hurt or hold a mob: the cactus (`CactusBlock.entityInside`, 1), the
/// cobweb (stuck at 0.25, 0.05, 0.25) and the sweet berry bush
/// (`SweetBerryBushBlock.entityInside`: stuck at 0.8, 0.75, 0.8, and a grown bush pricks for 1 when the mob moved at least 0.003 on X
/// or Z this tick). The blocks are those the mob's box touches anywhere along
/// its move this tick, from where it started to where it ended.
fn effects_from_blocks(m: &mut Mob, w: &MobWorld) {
@@ -562,6 +562,21 @@ fn effects_from_blocks(m: &mut Mob, w: &MobWorld) {
"minecraft:cactus" => {
m.damage(1.0, Damage::of(DamageKind::Cactus));
}
// WebBlock.entityInside -> makeStuckInBlock, which a
// spider ignores for cobwebs and a wither always.
"minecraft:cobweb"
if !matches!(
m.species(),
Species::Spider | Species::CaveSpider | Species::Wither
) =>
{
m.fall_distance = 0.0;
m.stuck_multiplier = if m.has_effect("minecraft:weaving") {
Vec3::new(0.5, 0.25, 0.5)
} else {
Vec3::new(0.25, f64::from(0.05_f32), 0.25)
};
}
"minecraft:sweet_berry_bush"
if !matches!(m.species(), Species::Fox | Species::Bee) =>
{
@@ -828,17 +843,28 @@ fn play_ambient(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
// Nautilus/ZombieNautilus.getAmbientSound: by age, under water or on land.
Species::Nautilus | Species::ZombieNautilus => super::nautilus::sounds(m)[0],
Species::Enderman if m.ai.target.is_some() => "entity.enderman.scream",
Species::Wolf if m.ai.target.is_some() => "entity.wolf.growl",
Species::Wolf if m.ai.target.is_some() => {
super::finalize::variant_sound(m, "growl").unwrap_or("entity.wolf.growl")
}
Species::Wolf if m.random.next_int_bound(3).unwrap_or(1) == 0 => {
if m.extra.tame && m.health < m.attrs.max_health / 2.0 {
"entity.wolf.whine"
let kind = if m.extra.tame && m.health < m.attrs.max_health / 2.0 {
"whine"
} else {
"entity.wolf.pant"
}
"pant"
};
super::finalize::variant_sound(m, kind).unwrap_or("entity.wolf.pant")
}
Species::Parrot => super::parrot::ambient(m, w),
Species::Cat if m.extra.tame && m.extra.in_love > 0 => "entity.cat.purr",
Species::Cat if !m.extra.tame => "entity.cat.stray_ambient",
// Cat.getAmbientSound: purr in love, purreow one time in four, else ambient.
Species::Cat if m.extra.tame && m.extra.in_love > 0 => {
super::finalize::variant_sound(m, "purr").unwrap_or("entity.cat.purr")
}
Species::Cat if m.extra.tame && m.random.next_int_bound(4).unwrap_or(1) == 0 => {
super::finalize::variant_sound(m, "purreow").unwrap_or("entity.cat.purreow")
}
Species::Cat if !m.extra.tame => {
super::finalize::variant_sound(m, "stray_ambient").unwrap_or("entity.cat.stray_ambient")
}
Species::PolarBear if m.is_baby() => "entity.polar_bear.ambient_baby",
Species::Dolphin if !m.fluids.in_water => "entity.dolphin.ambient",
Species::Guardian if !m.fluids.in_water => "entity.guardian.ambient_land",
@@ -1058,7 +1084,8 @@ fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
&& m.species() != Species::Shulker
&& !on_climbable(m, w)
&& (m.species() != Species::Creaking || super::creaking::can_move(m));
let mut targets: Vec<(Who, Vec3)> = Vec::new();
// Each with whether it rides a vehicle (`Entity.isPassenger`).
let mut targets: Vec<(Who, Vec3, bool)> = Vec::new();
if let Some(p) = &w.player
// Entity.push: passengers of the same vehicle never push each other.
&& m.rider.is_none()
@@ -1066,21 +1093,27 @@ fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
&& !p.spectator
&& p.bounds.intersects(bounds)
{
targets.push((Who::Player, p.position));
targets.push((Who::Player, p.position, p.passenger));
}
for o in w.others_near(bounds) {
if o.id != m.id && o.alive && o.pushable && o.bounds.intersects(bounds) {
targets.push((Who::Mob(o.id), o.position));
targets.push((Who::Mob(o.id), o.position, o.passenger));
}
}
// EntitySelector.pushableBy(this): the teams' collision rules.
let own = w.teams.of(Who::Mob(m.id));
targets.retain(|(who, _)| crate::scoreboard::pushable_by(own, w.teams.of(*who), true));
// Cramming (maxEntityCramming 24).
if targets.len() > 23 {
let _ = m.random.next_int_bound(4);
targets.retain(|(who, ..)| crate::scoreboard::pushable_by(own, w.teams.of(*who), true));
// Cramming: a quarter of the ticks spent with `maxEntityCramming` or more
// others around that are not riding anything hurts it by 6.
let limit = w.max_entity_cramming;
if limit > 0
&& targets.len() as i64 > i64::from(limit) - 1
&& m.random.next_int_bound(4).unwrap_or(1) == 0
&& targets.iter().filter(|t| !t.2).count() as i64 > i64::from(limit) - 1
{
m.damage(6.0, Damage::of(DamageKind::Cramming));
}
for (who, at) in targets {
for (who, at, _) in targets {
// Warden.doPush: touching it angers it, then the push itself.
if m.species() == Species::Warden {
super::warden::do_push(m, w, who, events);
@@ -1455,17 +1488,66 @@ pub fn voice_sounds(m: &Mob) -> [&'static str; 3] {
};
[ambient, m.kind.sounds[1], m.kind.sounds[2]]
}
// The sound variant's baby_sounds (classic sets, like the rest here).
Species::Pig if m.is_baby() => [
"entity.baby_pig.ambient",
"entity.baby_pig.hurt",
"entity.baby_pig.death",
// Horse.getAmbientSound, getHurtSound and getDeathSound by age.
Species::Horse if m.is_baby() => [
"entity.baby_horse.ambient",
"entity.baby_horse.hurt",
"entity.baby_horse.death",
],
Species::Chicken if m.is_baby() => [
"entity.baby_chicken.ambient",
"entity.baby_chicken.hurt",
"entity.baby_chicken.death",
// Drowned, Guardian and ElderGuardian: the hurt and death sounds by
// water or land.
Species::Drowned if m.fluids.in_water => [
"entity.drowned.ambient_water",
"entity.drowned.hurt_water",
"entity.drowned.death_water",
],
Species::Guardian if !m.fluids.in_water => [
m.kind.sounds[0],
"entity.guardian.hurt_land",
"entity.guardian.death_land",
],
Species::ElderGuardian if !m.fluids.in_water => [
m.kind.sounds[0],
"entity.elder_guardian.hurt_land",
"entity.elder_guardian.death_land",
],
// Slime and MagmaCube.getHurtSound and getDeathSound: tiny ones.
Species::Slime if m.extra.size <= 1 => [
m.kind.sounds[0],
"entity.slime.hurt_small",
"entity.slime.death_small",
],
Species::MagmaCube if m.extra.size <= 1 => [
m.kind.sounds[0],
"entity.magma_cube.hurt_small",
"entity.magma_cube.death_small",
],
// Shulker.getHurtSound: a closed shell.
Species::Shulker if m.extra.counter == 0 => [
m.kind.sounds[0],
"entity.shulker.hurt_closed",
m.kind.sounds[2],
],
// Turtle.getHurtSound and getDeathSound by age.
Species::Turtle if m.is_baby() => [
m.kind.sounds[0],
"entity.turtle.hurt_baby",
"entity.turtle.death_baby",
],
// SkeletonHorse.getAmbientSound: its own under water.
Species::SkeletonHorse if m.fluids.eye_in_water => [
"entity.skeleton_horse.ambient_water",
m.kind.sounds[1],
m.kind.sounds[2],
],
// The mob's sound variant set, the baby one for a baby.
Species::Pig | Species::Cow | Species::Chicken | Species::Wolf | Species::Cat => {
use super::finalize::variant_sound as v;
match (v(m, "ambient"), v(m, "hurt"), v(m, "death")) {
(Some(a), Some(h), Some(d)) => [a, h, d],
_ => m.kind.sounds,
}
}
_ => m.kind.sounds,
}
}
@@ -1514,13 +1596,12 @@ pub fn age_up(m: &mut Mob, seconds: i32, forced: bool) {
}
}
/// `Animal.playEatingSound` (empty) and its overrides, as (sound, volume,
/// pitch); `level` is the level's random. Pigs and cats use the classic sound
/// set, like their other sounds here.
/// pitch); `level` is the level's random. Pigs and cats play their sound
/// variant's set.
pub fn eating_sound(
m: &mut Mob,
level: &mut impl RandomSource,
) -> Option<(&'static str, f32, f32)> {
let baby = m.is_baby();
// Mth.randomBetween(level.getRandom(), 0.8, 1.2).
let mut between = || level.next_float() * 0.4 + 0.8;
let (sound, volume, pitch, entity_sound) = match m.species() {
@@ -1528,16 +1609,21 @@ pub fn eating_sound(
Species::Armadillo | Species::Pig | Species::SulfurCube => {
let sound = match m.species() {
Species::Armadillo => "entity.armadillo.eat",
Species::Pig if baby => "entity.baby_pig.eat",
Species::Pig => "entity.pig.eat",
Species::Pig => {
super::finalize::variant_sound(m, "eat").unwrap_or("entity.pig.eat")
}
_ => "entity.small_sulfur_cube.eat",
};
let pitch = m.voice_pitch();
(sound, sound_volume(m), pitch, true)
}
// playSound(sound, 1, 1).
Species::Cat if baby => ("entity.baby_cat.eat", 1.0, 1.0, true),
Species::Cat => ("entity.cat.eat", 1.0, 1.0, true),
Species::Cat => (
super::finalize::variant_sound(m, "eat").unwrap_or("entity.cat.eat"),
1.0,
1.0,
true,
),
Species::Fox => ("entity.fox.eat", 1.0, 1.0, true),
// level().playSound(null, this, sound, NEUTRAL, volume, pitch).
Species::Frog => ("entity.frog.eat", 2.0, 1.0, false),
@@ -2013,12 +2099,10 @@ pub fn step_sound(
| S::ElderGuardian
| S::Breeze => None,
S::Armadillo => quiet("entity.armadillo.step"),
S::Chicken if baby => quiet("entity.baby_chicken.step"),
S::Chicken => quiet("entity.chicken.step"),
S::Cow | S::Mooshroom => quiet("entity.cow.step"),
S::Pig if baby => quiet("entity.baby_pig.step"),
S::Pig => quiet("entity.pig.step"),
S::Wolf => quiet("entity.wolf.step"),
S::Chicken | S::Cow | S::Pig | S::Wolf => {
super::finalize::variant_sound(m, "step").and_then(quiet)
}
S::Mooshroom => quiet("entity.cow.step"),
S::Llama | S::TraderLlama => quiet("entity.llama.step"),
S::Frog => quiet("entity.frog.step"),
S::Goat => quiet("entity.goat.step"),
@@ -2103,6 +2187,8 @@ pub fn swim_sound(m: &Mob) -> &'static str {
Species::Nautilus => "entity.nautilus.swim",
Species::ZombieNautilus => "entity.zombie_nautilus.swim",
Species::Turtle => "entity.turtle.swim",
// Monster.getSwimSound, and Hoglin's own override of it.
s if s == Species::Hoglin || is_monster(s) => "entity.hostile.swim",
_ => "entity.generic.swim",
}
}
+15 -2
View File
@@ -95,7 +95,8 @@ impl Goal for Deliver {
}
}
}
/// `StayCloseToTarget`: back within 4 blocks of the deposit.
/// `StayCloseToTarget.create(.., 4, 16, 2.25)`: acts only once the deposit is
/// 16 blocks away or more, then walks until within 4.
#[derive(Clone, Debug)]
struct StayClose;
impl Goal for StayClose {
@@ -109,7 +110,19 @@ impl Goal for StayClose {
let d = (p[0] - m.position.x).powi(2)
+ (p[1] - m.position.y).powi(2)
+ (p[2] - m.position.z).powi(2);
d > 16.0
d >= 256.0
})
}
/// `MoveToTargetSink.reachedTarget`: the walk target it set is reached
/// within a Manhattan block distance of 4 (`closeEnough`).
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
m.extra.point.is_none()
&& deposit(m, cx).is_some_and(|p| {
let at = [m.position.x, m.position.y, m.position.z];
(0..3)
.map(|i| (p[i].floor() as i32 - at[i].floor() as i32).abs())
.sum::<i32>()
> 4
})
}
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
+8 -2
View File
@@ -534,7 +534,7 @@ impl Goal for PufferfishPuff {
cx.w.player
.as_ref()
.is_some_and(|p| p.alive && !p.creative && area.intersects(p.bounds));
// TARGETING_CONDITIONS: not other fish (not_scary_for_pufferfish) and alive.
// TARGETING_CONDITIONS: not #not_scary_for_pufferfish, and alive.
let mob = cx.w.others_near(area).any(|s| {
s.id != m.id
&& s.alive
@@ -547,7 +547,13 @@ impl Goal for PufferfishPuff {
| Species::Squid
| Species::GlowSquid
| Species::Dolphin
| Species::Axolotl
| Species::Turtle
| Species::Guardian
| Species::ElderGuardian
| Species::Tadpole
| Species::Nautilus
| Species::ZombieNautilus
| Species::SulfurCube
)
&& area.intersects(s.bounds)
});
+144 -21
View File
@@ -114,28 +114,40 @@ pub fn register(m: &mut Mob) {
g.add(3, Tempt::new(1.25, tempt, false));
}
}
/// `generateMaxHealth`, `generateSpeed`, `generateJumpStrength` and the coat.
/// `randomizeAttributes` of each class: horses roll health, speed and jump,
/// donkeys and mules only health (their jump stays 0.5), skeleton horses only
/// the jump, zombie horses a smaller jump and their own speed.
pub fn finalize(m: &mut Mob) {
let species = m.species();
let r = &mut m.random;
let health =
15.0 + r.next_int_bound(8).unwrap_or(0) as f32 + r.next_int_bound(9).unwrap_or(0) as f32;
let speed =
(0.45 + r.next_double() * 0.3 + r.next_double() * 0.3 + r.next_double() * 0.3) * 0.25;
let jump = 0.4 + r.next_double() * 0.2 + r.next_double() * 0.2 + r.next_double() * 0.2;
match m.species() {
if matches!(species, Species::Horse | Species::Donkey | Species::Mule) {
m.attrs.max_health = 15.0
+ r.next_int_bound(8).unwrap_or(0) as f32
+ r.next_int_bound(9).unwrap_or(0) as f32;
}
match species {
Species::Horse => {
m.attrs.max_health = health;
m.attrs.speed = speed;
m.extra.vector[0] = jump;
m.extra.variant = m.random.next_int_bound(7).unwrap_or(0) as u8;
m.extra.variant2 = m.random.next_int_bound(5).unwrap_or(0) as u8;
m.attrs.speed =
(0.45 + r.next_double() * 0.3 + r.next_double() * 0.3 + r.next_double() * 0.3)
* 0.25;
m.extra.vector[0] =
0.4 + r.next_double() * 0.2 + r.next_double() * 0.2 + r.next_double() * 0.2;
m.extra.variant = r.next_int_bound(7).unwrap_or(0) as u8;
m.extra.variant2 = r.next_int_bound(5).unwrap_or(0) as u8;
}
Species::Donkey | Species::Mule => {
// AbstractChestedHorse.randomizeAttributes: fixed speed, generated health.
m.attrs.max_health = health;
m.extra.vector[0] = jump;
Species::Donkey | Species::Mule => m.extra.vector[0] = 0.5,
Species::ZombieHorse => {
// ZombieHorse.generateZombieHorseJumpStrength and ...Speed.
let d = 0.066_666_666_666_666_67;
m.extra.vector[0] =
0.5 + r.next_double() * d + r.next_double() * d + r.next_double() * d;
m.attrs.speed =
(9.0 + r.next_double() + r.next_double() + r.next_double()) / f64::from(42.16f32);
}
_ => {
m.extra.vector[0] =
0.4 + r.next_double() * 0.2 + r.next_double() * 0.2 + r.next_double() * 0.2
}
_ => m.extra.vector[0] = jump,
}
m.health = m.attrs.max_health;
}
@@ -280,13 +292,17 @@ pub fn make_mad(m: &mut Mob, events: &mut Vec<MobEvent>) {
m.extra.counter = 20;
m.extra.flag = false;
}
// getAngrySound: null (silent) for the skeleton horse.
let sound = match m.species() {
Species::SkeletonHorse => return,
Species::Donkey => "entity.donkey.angry",
Species::Mule => "entity.mule.angry",
Species::ZombieHorse => "entity.zombie_horse.angry",
Species::Llama | Species::TraderLlama => "entity.llama.angry",
_ if m.is_baby() => "entity.baby_horse.angry",
_ => "entity.horse.angry",
};
events.push(MobEvent::Sound(sound, m.center(), 1.0, 1.0));
m.voice(sound, events);
}
/// `RandomStandGoal`.
#[derive(Clone, Debug)]
@@ -312,10 +328,113 @@ impl Goal for RandomStand {
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
m.extra.counter = 20;
m.extra.flag = false;
if m.species() == Species::Horse {
cx.events
.push(MobEvent::Sound("entity.horse.angry", m.center(), 1.0, 1.0));
// playStandSound: getAmbientStandSound is the ambient sound, plain
// playSound(sound) at volume and pitch 1.
let sound = super::ai::voice_sounds(m)[0];
let at = [m.position.x, m.position.y, m.position.z];
cx.events.push(MobEvent::Sound(sound, at, 1.0, 1.0));
}
}
/// Horses that step with `AbstractHorse.playStepSound` (llamas and camels
/// have their own).
pub fn hoof_steps(s: Species) -> bool {
matches!(
s,
Species::Horse
| Species::Donkey
| Species::Mule
| Species::SkeletonHorse
| Species::ZombieHorse
)
}
/// `AbstractHorse.playStepSound` (and `Horse.playGallopSound`) on the block
/// `pos`: silent on liquids; a ridden horse walks on wood for five steps,
/// then gallops every third; unridden, wood or the plain step by age.
/// Donkeys and mules never gallop (`canGallop`).
pub fn play_step(
m: &mut Mob,
w: &MobWorld,
pos: [i32; 3],
state: horizon_world::registry::BlockStateId,
events: &mut Vec<MobEvent>,
) {
if matches!(
&*w.facts.get(state).name,
"minecraft:water" | "minecraft:lava" | "minecraft:bubble_column"
) {
return;
}
let above = w.world.block(pos[0], pos[1] + 1, pos[2]);
let sound_type = if &*w.facts.get(above).name == "minecraft:snow" {
&w.facts.get(above).sounds
} else {
&w.facts.get(state).sounds
};
let (volume, pitch) = (sound_type.volume, sound_type.pitch);
let at = [m.position.x, m.position.y, m.position.z];
let baby = m.is_baby();
let can_gallop = !matches!(m.species(), Species::Donkey | Species::Mule);
if m.rider.is_some() && can_gallop {
m.ai.steps.gallop += 1;
let count = m.ai.steps.gallop;
if count > 5 && count % 3 == 0 {
events.push(MobEvent::Sound(
"entity.horse.gallop",
at,
volume * 0.15,
pitch,
));
// Horse.playGallopSound: now and then it breathes too.
if m.species() == Species::Horse && m.random.next_int_bound(10).unwrap_or(1) == 0 {
let breathe = if baby {
"entity.baby_horse.breathe"
} else {
"entity.horse.breathe"
};
events.push(MobEvent::Sound(breathe, at, volume * 0.6, pitch));
}
} else if count <= 5 {
events.push(MobEvent::Sound(
"entity.horse.step_wood",
at,
volume * 0.15,
pitch,
));
}
} else if is_wood_sound_type(sound_type) {
events.push(MobEvent::Sound(
"entity.horse.step_wood",
at,
volume * 0.15,
pitch,
));
} else {
let step = if baby {
"entity.baby_horse.step"
} else {
"entity.horse.step"
};
events.push(MobEvent::Sound(step, at, volume * 0.15, pitch));
}
}
/// `AbstractHorse.isWoodSoundType`: WOOD, NETHER_WOOD, STEM, CHERRY_WOOD and
/// BAMBOO_WOOD (not HARD_CROP, which only shares WOOD's step).
fn is_wood_sound_type(sounds: &horizon_items::blocks::Sounds) -> bool {
let step = sounds
.step
.strip_prefix("minecraft:")
.unwrap_or(&sounds.step);
let place = sounds
.place
.strip_prefix("minecraft:")
.unwrap_or(&sounds.place);
match step {
"block.wood.step" => place == "block.wood.place",
"block.nether_wood.step"
| "block.stem.step"
| "block.cherry_wood.step"
| "block.bamboo_wood.step" => true,
_ => false,
}
}
/// The mount takes its rider's input (`getRiddenInput`, `tickRidden`).
@@ -323,6 +442,10 @@ pub fn ridden_step(m: &mut Mob) {
let Some(r) = m.rider else {
return;
};
// AbstractHorse.tickRidden: not pressing forward ends the gallop.
if hoof_steps(m.species()) && r.forward <= 0.0 {
m.ai.steps.gallop = 0;
}
// AbstractNautilus.tickRidden turns half way toward the rider.
if super::nautilus::is_nautilus(m.species()) {
return super::nautilus::ridden(m);
@@ -821,6 +821,22 @@ fn age_boundary_reached(m: &mut Mob) {
_ => {}
}
}
/// `SpawnEggItem.spawnsEntity`: the spawn egg is for the mob's own kind, so
/// it is used on it (`Mob.checkAndHandleImportantInteractions`).
pub fn spawn_egg_is_for(egg: &str, kind: &str) -> bool {
egg.strip_suffix("_spawn_egg") == Some(kind)
}
/// `SpawnEggItem.spawnOffspringFromSpawnEgg`: the offspring of a spawn egg
/// used on a mob of the same kind is a baby of that kind: it starts with
/// the age of a baby and keeps the parent's variant and colour.
pub fn spawn_egg_offspring(child: &mut Mob, species: Species, variant: u8, color: u8) {
set_baby(child, true);
child.extra.variant = variant;
if species == Species::Sheep {
child.extra.color = color;
}
}
/// `setBaby`: AgeableMob through its age, zombies, piglins and zoglins.
pub fn set_baby(m: &mut Mob, baby: bool) {
let s = m.species();
@@ -2046,6 +2062,46 @@ pub fn sound_variants(s: Species) -> &'static [&'static str] {
_ => &[],
}
}
/// A sound of the mob's `*_sound_variant` set (`Pig.getSoundSet`,
/// `Wolf.getSoundSet`, `Cat.getSoundSet`, ...): `kind` is the sound's key
/// (`ambient`, `hurt`, `step`, `growl`, `purreow`...). Babies use the species'
/// baby set (cows have none); an adult's set is `entity.<species>.*` for
/// `classic` and `entity.<species>_<variant>.*` otherwise, except the
/// footsteps that every variant shares (not the cow's). None for a species
/// without sound variants.
pub fn variant_sound(m: &Mob, kind: &str) -> Option<&'static str> {
use std::{collections::HashMap, sync::Mutex};
let species = m.species();
let name = match species {
Species::Pig => "pig",
Species::Cow => "cow",
Species::Chicken => "chicken",
Species::Wolf => "wolf",
Species::Cat => "cat",
_ => return None,
};
let variant = m
.extra
.sound_variant
.and_then(|i| sound_variants(species).get(i as usize))
.copied()
.unwrap_or("classic");
let id = if m.is_baby() && species != Species::Cow {
format!("entity.baby_{name}.{kind}")
} else if variant == "classic" || (kind == "step" && species != Species::Cow) {
format!("entity.{name}.{kind}")
} else {
format!("entity.{name}_{variant}.{kind}")
};
// A handful of ids in all: keep each one as a `&'static str`.
static IDS: Mutex<Option<HashMap<String, &'static str>>> = Mutex::new(None);
let mut ids = IDS.lock().ok()?;
let ids = ids.get_or_insert_with(HashMap::new);
Some(
*ids.entry(id)
.or_insert_with_key(|k| Box::leak(k.clone().into_boxed_str())),
)
}
/// The registry name of the game's variant index.
pub fn variant_name(s: Species, index: u8) -> &'static str {
let i = index as usize;
@@ -2098,6 +2154,39 @@ mod tests {
assert_eq!(panda_variant(4, 4), 4);
assert_eq!(panda_variant(6, 0), 6);
}
/// #2269: the offspring of a spawn egg used on a mob is a baby of that
/// kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`), and only the mob's
/// own kind takes the egg.
#[test]
fn a_spawn_egg_offspring_is_a_baby() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let kind = std::sync::Arc::new(data.types.get("minecraft:cow").unwrap().clone());
let mut child = crate::mobs::Mob::new(
2,
kind,
horizon_core::geometry::Vec3::new(0.0, 4.0, 0.0),
0.0,
);
assert!(!child.is_baby());
spawn_egg_offspring(&mut child, Species::Cow, 3, 0);
// The offspring is a baby of the mob's kind.
assert!(child.is_baby());
// It keeps the parent's variant.
assert_eq!(child.extra.variant, 3);
// The egg is for the mob's own kind.
assert!(spawn_egg_is_for("minecraft:cow_spawn_egg", "minecraft:cow"));
assert!(!spawn_egg_is_for(
"minecraft:pig_spawn_egg",
"minecraft:cow"
));
assert!(!spawn_egg_is_for("minecraft:stick", "minecraft:cow"));
}
#[test]
fn reasons_round_trip() {
for r in Reason::ALL {
+18 -1
View File
@@ -1065,6 +1065,8 @@ pub struct MeleeAttack {
raise_arm: i32,
/// Ticks between attacks (20 unless set).
pub interval: i32,
/// Ticks between attacks for a baby, when they differ.
pub baby_interval: Option<i32>,
}
impl MeleeAttack {
pub fn new(speed: f64, follow_unseen: bool) -> Self {
@@ -1079,6 +1081,7 @@ impl MeleeAttack {
last_check: 0,
raise_arm: 0,
interval: 20,
baby_interval: None,
}
}
pub fn zombie(mut self) -> Self {
@@ -1093,6 +1096,12 @@ impl MeleeAttack {
self.interval = ticks;
self
}
/// `MeleeAttack.create(adult)` for adults, `create(baby)` for babies.
pub fn intervals(mut self, adult: i32, baby: i32) -> Self {
self.interval = adult;
self.baby_interval = Some(baby);
self
}
}
impl Goal for MeleeAttack {
goal_clone!();
@@ -1191,7 +1200,11 @@ impl Goal for MeleeAttack {
cx,
);
} else if self.until_attack <= 0 && m.within_melee_range(&t) && m.sees(cx.w, who) {
self.until_attack = adjusted(true, self.interval);
let interval = match self.baby_interval {
Some(baby) if m.is_baby() => baby,
_ => self.interval,
};
self.until_attack = adjusted(true, interval);
m.swing();
super::ai::do_hurt_target(m, who, cx);
}
@@ -1893,6 +1906,10 @@ impl Goal for Breed {
if tamable && (!m.extra.tame || m.extra.sitting) {
return false;
}
// AbstractHorse.canMate is false and Mule keeps it: mules never breed.
if species == Species::Mule {
return false;
}
// TurtleBreedGoal.canUse: not while carrying an egg.
if species == Species::Turtle && super::turtle::has_egg(m) {
return false;
+3 -2
View File
@@ -6,7 +6,7 @@ use super::{
anim::{Poses, State},
goals::{
Cx, Goal, HurtByTarget, LOOK, LookAtPlayer, MOVE, MeleeAttack, NearestTarget, Prey,
RandomLookAround, Stroll, goal_clone,
RandomLookAround, Stroll, goal_clone, reduced,
},
};
use crate::projectiles::{Projectile, ProjectileKind};
@@ -158,7 +158,8 @@ impl Goal for OfferFlower {
self.ticks > 0
}
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
self.ticks = 400;
// adjustedTickDelay(400): the goal ticks every other game tick.
self.ticks = reduced(400);
m.extra.counter = 400;
// IronGolem.offerFlower(true): entity event 11.
entity_event(m, 11, cx);
@@ -326,9 +326,17 @@ pub fn creeper_tick(m: &mut Mob, events: &mut Vec<MobEvent>) {
if m.swell >= 30 {
m.swell = 30;
let radius = if m.extra.charged { 6.0 } else { 3.0 };
let name = match &m.custom_name {
Some(name) => crate::text::Text::literal(name.clone()),
None => crate::text::Text::translate(
&format!("entity.{}", m.kind.id.replace(':', ".")),
&[],
),
};
events.push(MobEvent::ExplosionSource {
mob: m.id,
charged: m.extra.charged,
name,
});
events.push(MobEvent::Explode(
[m.position.x, m.position.y, m.position.z],
+2 -2
View File
@@ -542,12 +542,12 @@ fn shoot_crossbow(m: &mut Mob, who: Who, cx: &mut Cx) {
Some(Who::Mob(m.id)),
);
p.set_bonus(super::gear::arrow_bonus(m));
// rangedAttackUncertainty at normal difficulty: 14 - 2 * 4.
// rangedAttackUncertainty: 14 - 4 * difficulty.
p.shoot(
&mut crate::projectiles::own_random(m),
[dx, dy, dz],
1.6,
6.0,
(14 - i32::from(cx.w.difficulty.id) * 4) as f32,
);
cx.events.push(MobEvent::Projectile(p));
let pitch = 1.0 / (m.random.next_float() * 0.5 + 1.8) + 0.53;
+15 -5
View File
@@ -25,6 +25,8 @@ pub struct Steps {
pub move_dist: f32,
pub fly_dist: f32,
pub next_step: f32,
/// `AbstractHorse.gallopSoundCounter`.
pub gallop: i32,
}
impl Default for Steps {
fn default() -> Self {
@@ -32,6 +34,7 @@ impl Default for Steps {
move_dist: 0.0,
fly_dist: 0.0,
next_step: 1.0,
gallop: 0,
}
}
}
@@ -706,7 +709,11 @@ impl Mob {
&& (self.on_ground || climbable)
&& !self.fluids_swimming();
if produced {
if let Some((sound, volume, pitch)) =
if super::equine::hoof_steps(self.species()) {
if !self.silent {
super::equine::play_step(self, w, legacy, legacy_state, events);
}
} else if let Some((sound, volume, pitch)) =
super::ai::step_sound(self, w, legacy, legacy_state)
&& !self.silent
{
@@ -863,10 +870,13 @@ impl Mob {
}
if damage > 0.0 {
let at = [self.position.x, self.position.y, self.position.z];
let sound = if damage > 4.0 {
"entity.generic.big_fall"
} else {
"entity.generic.small_fall"
// Monster.getFallSounds: the hostile pair.
let monster = super::ai::is_monster(self.species());
let sound = match (monster, damage > 4.0) {
(true, true) => "entity.hostile.big_fall",
(true, false) => "entity.hostile.small_fall",
(false, true) => "entity.generic.big_fall",
(false, false) => "entity.generic.small_fall",
};
events.push(MobEvent::Sound(sound, at, 1.0, 1.0));
self.damage(damage as f32, super::Damage::fall());
+4 -2
View File
@@ -169,7 +169,8 @@ pub fn register_hoglin(m: &mut Mob) {
let g = &mut m.ai.goals;
g.add(0, Float);
g.add(1, BabyPanic(super::goals::Panic::new(1.25)));
g.add(2, MeleeAttack::new(1.0, true));
// HoglinAi: MeleeAttack.create(40) for adults, 15 for babies.
g.add(2, MeleeAttack::new(1.0, true).intervals(40, 15));
g.add(3, super::goals::Breed::new(1.0));
g.add(3, HoglinAvoidRepellent);
g.add(4, super::goals::FollowParent::new(1.1));
@@ -185,7 +186,8 @@ pub fn register_hoglin(m: &mut Mob) {
pub fn register_zoglin(m: &mut Mob) {
let g = &mut m.ai.goals;
g.add(0, Float);
g.add(2, MeleeAttack::new(1.0, true));
// Zoglin.initMemories: MeleeAttack.create(40) for adults, 15 for babies.
g.add(2, MeleeAttack::new(1.0, true).intervals(40, 15));
g.add(5, Stroll::avoiding_water(0.4));
g.add(6, LookAtPlayer::new(8.0));
m.ai.targets.add(1, HurtByTarget::new());
+84 -12
View File
@@ -20,12 +20,32 @@ pub type Atlas<'a> = &'a dyn Fn(&str) -> Option<[f32; 4]>;
/// the model UV and reads `enchanted_glint_armor`.
pub const ARMOR_GLINT: u32 = 13;
/// `RenderTypes.breezeWind`: the texture of the breeze's wind layer scrolls
/// by 0.02 of the texture per tick.
pub const BREEZE_WIND_SCROLL: f32 = 0.02;
/// `WindChargeRenderer.xOffset`: the wind charge's texture scrolls by 0.03 of
/// the texture per tick.
pub const WIND_CHARGE_SCROLL: f32 = 0.03;
/// The offset of a scrolling texture: `RenderTypes.breezeWind` takes the
/// texture's age times the speed, modulo one.
pub fn scroll_offset(age: f32, speed: f32) -> f32 {
(age * speed) % 1.0
}
/// The vertex's U with the scroll: the texture matrix of
/// `RenderTypes.breezeWind` moves the texture under the model, so the U
/// moves by the offset and wraps around the texture.
fn scroll_u(u: f32, scroll: f32) -> f32 {
if scroll == 0.0 { u } else { (u + scroll) % 1.0 }
}
/// One draw of a model with a texture.
pub struct Layer {
pub model: String,
pub texture: String,
pub color: u32,
pub full_bright: bool,
/// `RenderTypes.breezeWind`: the texture scrolls with the entity's age.
pub scroll: f32,
}
/// DyeColor.getTextureDiffuseColor (RGB).
pub const DYE: [u32; 16] = [
@@ -437,6 +457,7 @@ pub fn layers(m: &Mob) -> Vec<Layer> {
texture: texture.into(),
color,
full_bright,
scroll: 0.0,
})
};
match m.species() {
@@ -490,16 +511,17 @@ pub fn layers(m: &Mob) -> Vec<Layer> {
push(&mut v, "happy_ghast_harness#main", &texture, white, false);
}
}
// BreezeWindLayer (the scroll is not reproduced: atlas sprites do
// not wrap) and BreezeEyesLayer.
// BreezeWindLayer and BreezeEyesLayer. The wind layer's texture
// scrolls (`RenderTypes.breezeWind`): the offset is taken inside the
// sprite, so the atlas does not have to wrap.
S::Breeze => {
push(
&mut v,
"breeze#wind",
"entity/breeze/breeze_wind",
white,
false,
);
v.push(Layer {
model: "breeze#wind".into(),
texture: "entity/breeze/breeze_wind".into(),
color: white,
full_bright: false,
scroll: scroll_offset(m.age as f32, BREEZE_WIND_SCROLL),
});
push(
&mut v,
"breeze#eyes",
@@ -827,6 +849,7 @@ fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
texture: format!("entity/equipment/{folder}/{asset}"),
color: tint,
full_bright: false,
scroll: 0.0,
});
if asset == "leather" {
v.push(Layer {
@@ -834,6 +857,7 @@ fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
texture: format!("entity/equipment/{folder}/leather_overlay"),
color: 0xFFFF_FFFF,
full_bright: false,
scroll: 0.0,
});
}
}
@@ -1046,6 +1070,23 @@ pub fn draw(
light: u32,
overlay: u32,
out: &mut Vec<Vertex>,
) {
draw_scrolled(part, base, poses, uv, 0.0, color, light, overlay, out);
}
/// `draw` for a texture that scrolls: the vertex's U is moved by the offset
/// and wraps inside the texture, as the texture matrix of
/// `RenderTypes.breezeWind` does.
#[allow(clippy::too_many_arguments)]
pub fn draw_scrolled(
part: &Part,
base: glam::Mat4,
poses: &Poses,
uv: [f32; 4],
scroll: f32,
color: u32,
light: u32,
overlay: u32,
out: &mut Vec<Vertex>,
) {
draw_normals(
part,
@@ -1053,6 +1094,7 @@ pub fn draw(
ModelNormals::new(base),
poses,
uv,
scroll,
color,
light,
overlay,
@@ -1066,6 +1108,7 @@ fn draw_normals(
normals: ModelNormals,
poses: &Poses,
uv: [f32; 4],
scroll: f32,
color: u32,
light: u32,
overlay: u32,
@@ -1103,7 +1146,7 @@ fn draw_normals(
normal,
pos: pos.to_array(),
uv: [
uv[0] + (uv[2] - uv[0]) * v[3],
uv[0] + (uv[2] - uv[0]) * scroll_u(v[3], scroll),
uv[1] + (uv[3] - uv[1]) * v[4],
],
layer: overlay,
@@ -1115,7 +1158,7 @@ fn draw_normals(
}
}
for c in &part.children {
draw_normals(c, m, normals, poses, uv, color, light, overlay, out);
draw_normals(c, m, normals, poses, uv, scroll, color, light, overlay, out);
}
}
/// Render a mob with its variant model, texture and layers.
@@ -1236,6 +1279,7 @@ fn render_visible(
texture: texture.into(),
color: ((a.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF,
full_bright: true,
scroll: 0.0,
});
}
}
@@ -1277,11 +1321,12 @@ fn render_visible(
None => overlay,
};
let start = out.len();
draw(
draw_scrolled(
&part,
base,
&layer_poses,
uv,
layer.scroll,
layer.color,
light,
overlay,
@@ -1671,3 +1716,30 @@ mod tests {
}
}
}
#[cfg(test)]
mod scroll_tests {
use super::*;
#[test]
fn the_scrolling_texture_wraps_around() {
// RenderTypes.breezeWind: the breeze's wind layer's texture scrolls
// by 0.02 of the texture per tick, the wind charge's by 0.03.
assert_eq!(scroll_offset(0.0, BREEZE_WIND_SCROLL), 0.0);
assert!((scroll_offset(25.0, BREEZE_WIND_SCROLL) - 0.5).abs() < 1e-6);
assert!((scroll_offset(50.0, BREEZE_WIND_SCROLL) - 0.0).abs() < 1e-6);
assert!((scroll_offset(50.0, WIND_CHARGE_SCROLL) - 0.5).abs() < 1e-6);
assert!((scroll_offset(100.0, WIND_CHARGE_SCROLL) - 0.0).abs() < 1e-6);
}
#[test]
fn the_scrolled_u_wraps_inside_the_texture() {
// Without a scroll the U is the vertex's own.
assert_eq!(scroll_u(0.25, 0.0), 0.25);
assert_eq!(scroll_u(1.0, 0.0), 1.0);
// With one, it moves and wraps around the texture.
assert!((scroll_u(0.25, 0.5) - 0.75).abs() < 1e-6);
assert!((scroll_u(0.75, 0.5) - 0.25).abs() < 1e-6);
assert!((scroll_u(1.0, 0.5) - 0.5).abs() < 1e-6);
}
}
+62
View File
@@ -593,3 +593,65 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses) {
p.xrot("head", s.x_rot.to_radians());
p.yrot("head", (s.y_rot - 180.0).to_radians());
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mobs::sensor_scan_tests::kind;
use std::sync::Arc;
/// A shulker fixed to a block face.
fn shulker() -> Mob {
let mut kind = kind();
let k = Arc::make_mut(&mut kind);
k.id = "minecraft:shulker".into();
k.species = Species::Shulker;
k.max_head_y_rot = 180.0;
let mut m = Mob::new(1, kind, Vec3::ZERO, 0.0);
m.extra.variant3 = 2;
m
}
#[test]
fn the_body_stays_square_while_the_head_turns() {
// Shulker.recreateFromPacket sets the body rotation to 0 and
// Shulker.ShulkerBodyRotationControl.clientTick is empty, so the
// shulker's body never turns: it stays square to the block it is
// fixed to and only its head turns (#1661).
let mut m = shulker();
m.yaw = 90.0;
m.head_yaw = 90.0;
// It moved, so a body that follows the mob would turn with it.
m.previous = Vec3::new(1.0, 0.0, 0.0);
m.body_rotation_tick();
assert_eq!(
m.body_yaw, 0.0,
"the shulker's body stays square to the block"
);
assert_eq!(m.previous_body_yaw, 0.0);
// A mob that moves turns its body to where it goes.
let mut other = Mob::new(2, kind(), Vec3::ZERO, 0.0);
other.yaw = 90.0;
other.previous = Vec3::new(1.0, 0.0, 0.0);
other.body_rotation_tick();
assert_eq!(other.body_yaw, 90.0);
}
#[test]
fn the_head_turns_to_its_target() {
// ShulkerLookControl.clampHeadRotationToBody does nothing, so the
// head is not clamped to the square body: it turns to the target.
let mut m = shulker();
m.ai.look.kind = LookKind::Shulker;
for _ in 0..20 {
// A goal keeps the look up to date every tick.
m.ai.look.set_look_at([5.0, 0.0, 0.0], 10.0, 10.0);
m.look_control_tick();
}
assert!(
m.head_yaw.abs() > 45.0,
"the head turns to the target, not back to the square body"
);
assert_eq!(m.body_yaw, 0.0);
}
}
@@ -23,61 +23,7 @@ use horizon_core::{
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
/// 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()
}
pub use crate::biome::{SpawnCost, parse_costs, parse_entries};
/// `MobCategory`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -168,6 +168,12 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) {
Climate::Warm => 1,
Climate::Cold => 2,
};
// `Registry.getRandom` of the `*_sound_variant` registry (pig, cow,
// chicken, cat and wolf), drawn by their finalizeSpawn.
let sounds = super::finalize::sound_variants(m.species()).len();
if sounds > 0 {
m.extra.sound_variant = Some(m.random.next_int_bound(sounds as i32).unwrap_or(0) as u8);
}
match m.species() {
// Warden.finalizeSpawn: a minute before it digs back down.
S::Warden => super::warden::finalize(m, false, &mut Vec::new()),
@@ -301,6 +307,17 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) {
}
S::Fox | S::Panda | S::Dolphin => m.extra.pick_up_loot = true,
S::TropicalFish => super::aquatic::finalize(m),
// Salmon.finalizeSpawn: a weighted size (small 30, medium 50, large 15).
S::Salmon => {
let r = m.random.next_int_bound(95).unwrap_or(0);
m.extra.variant = if r < 30 {
0
} else if r < 80 {
1
} else {
2
};
}
S::Slime | S::MagmaCube => super::cube::finalize(m),
_ => {}
}
@@ -1602,6 +1602,8 @@ impl Def {
pub fn water_slowdown(&self) -> f32 {
match self.species {
Species::PolarBear => 0.98,
// SkeletonHorse.getWaterSlowDown.
Species::SkeletonHorse => 0.96,
_ => 0.8,
}
}
+10 -9
View File
@@ -529,15 +529,14 @@ struct Beg {
ticks: i32,
}
impl Beg {
fn interesting(&self, m: &Mob, w: &MobWorld) -> bool {
fn interesting(&self, w: &MobWorld) -> bool {
let Some(p) = &w.player else {
return false;
};
let held = |i: &Option<std::sync::Arc<str>>| {
i.as_deref().is_some_and(|i| {
(!m.extra.tame && i == "minecraft:bone")
|| (m.extra.tame && self.food.iter().any(|f| f == i))
})
// `BegGoal.playerHoldingInteresting`: a bone or food, tame or not.
i.as_deref()
.is_some_and(|i| i == "minecraft:bone" || self.food.iter().any(|f| f == i))
};
p.alive && !p.spectator && (held(&p.main_hand) || held(&p.off_hand))
}
@@ -551,14 +550,14 @@ impl Goal for Beg {
cx.w.player
.as_ref()
.is_some_and(|p| m.distance_sqr(p.position) <= 64.0)
&& self.interesting(m, cx.w)
&& self.interesting(cx.w)
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
cx.w.player
.as_ref()
.is_some_and(|p| p.alive && m.distance_sqr(p.position) <= 64.0)
&& self.ticks > 0
&& self.interesting(m, cx.w)
&& self.interesting(cx.w)
}
fn start(&mut self, m: &mut Mob, _: &mut Cx) {
m.extra.flag = true;
@@ -829,7 +828,8 @@ impl Goal for CatTempt {
// Cat.tick: a wild cat being tempted begs every 100 ticks.
if m.age.is_multiple_of(100) {
cx.events.push(MobEvent::Sound(
"entity.cat.beg_for_food",
super::finalize::variant_sound(m, "beg_for_food")
.unwrap_or("entity.cat.beg_for_food"),
m.center(),
1.0,
1.0,
@@ -1197,7 +1197,8 @@ pub fn cat_ai_step(m: &mut Mob, events: &mut Vec<MobEvent>) {
cat_amounts(m);
if (m.extra.flag || m.extra.relax_one) && m.age.is_multiple_of(5) {
let volume = 0.6 + 0.4 * (m.random.next_float() - m.random.next_float());
events.push(MobEvent::Sound("entity.cat.purr", m.center(), volume, 1.0));
let sound = super::finalize::variant_sound(m, "purr").unwrap_or("entity.cat.purr");
events.push(MobEvent::Sound(sound, m.center(), volume, 1.0));
}
}
/// The lying and relaxing amounts of `Cat.tick` (also a client's, from the
@@ -106,6 +106,67 @@ impl TrialConfig {
.unwrap_or(d.items_to_drop_when_ominous),
})
}
/// `TrialSpawnerConfig.DIRECT_CODEC`'s encoding: the fields that differ
/// from their defaults (`optionalFieldOf` with a default).
pub fn encode(&self) -> Tag {
let d = Self::default();
let mut out = Vec::new();
if self.spawn_range != d.spawn_range {
out.push(("spawn_range", Tag::Int(self.spawn_range)));
}
for (key, value, default) in [
("total_mobs", self.total_mobs, d.total_mobs),
(
"simultaneous_mobs",
self.simultaneous_mobs,
d.simultaneous_mobs,
),
(
"total_mobs_added_per_player",
self.total_mobs_added_per_player,
d.total_mobs_added_per_player,
),
(
"simultaneous_mobs_added_per_player",
self.simultaneous_mobs_added_per_player,
d.simultaneous_mobs_added_per_player,
),
] {
if value != default {
out.push((key, Tag::Float(value.to_bits())));
}
}
if self.ticks_between_spawn != d.ticks_between_spawn {
out.push(("ticks_between_spawn", Tag::Int(self.ticks_between_spawn)));
}
let weighted = |data: Tag, weight: i32| {
Tag::compound(vec![("data", data), ("weight", Tag::Int(weight))])
};
if !self.spawn_potentials.is_empty() {
let list = self.spawn_potentials.iter();
out.push((
"spawn_potentials",
Tag::List(list.map(|(data, w)| weighted(data.encode(), *w)).collect()),
));
}
if self.loot_tables_to_eject != d.loot_tables_to_eject {
let list = self.loot_tables_to_eject.iter();
out.push((
"loot_tables_to_eject",
Tag::List(
list.map(|(id, w)| weighted(Tag::String(id.as_str().into()), *w))
.collect(),
),
));
}
if self.items_to_drop_when_ominous != d.items_to_drop_when_ominous {
out.push((
"items_to_drop_when_ominous",
Tag::String(self.items_to_drop_when_ominous.as_str().into()),
));
}
Tag::compound(out)
}
/// `calculateTargetTotalMobs`.
pub fn target_total_mobs(&self, additional_players: i32) -> i32 {
(self.total_mobs + self.total_mobs_added_per_player * additional_players as f32).floor()
@@ -487,6 +548,18 @@ impl TrialSpawner {
self.next_spawn_data = None;
self.reset_statistics();
}
/// `overrideEntityToSpawn` (a spawn egg's `setEntityId`): the data resets
/// and both configs spawn only `id` (`withSpawning`), saved inline as
/// `Holder.direct`. The caller then sets the state to `inactive`.
pub fn override_entity(&mut self, id: &str) {
self.reset();
for slot in [&mut self.normal, &mut self.ominous] {
let mut entity = Compound::new();
entity.insert("id".into(), Tag::String(id.into()));
slot.config.spawn_potentials = vec![(SpawnData::new(entity, None, None), 1)];
slot.saved = Some(slot.config.encode());
}
}
/// `getOrCreateNextSpawnData`: a potential at random, else empty data.
fn next_or_create(
&mut self,
@@ -1030,6 +1103,37 @@ mod tests {
assert!(s.save().is_empty());
}
#[test]
fn a_spawn_egg_overrides_both_configs_inline() {
let breeze = TrialConfig {
total_mobs: 2.0,
..TrialConfig::default()
};
let registry = |_: &str| Some(breeze.clone());
let mut s = TrialSpawner::load_with(
&tag(
r#"{normal_config:"minecraft:trial_chamber/breeze/normal",spawn_data:{entity:{}},total_mobs_spawned:3}"#,
),
&registry,
);
s.override_entity("minecraft:zombie");
assert_eq!(s.next_spawn_data, None);
assert_eq!(s.total_mobs_spawned, 0);
for slot in [&s.normal, &s.ominous] {
assert_eq!(slot.config.spawn_potentials.len(), 1);
assert_eq!(
slot.config.spawn_potentials[0].0.id().as_deref(),
Some("minecraft:zombie")
);
let saved = slot.saved.as_ref().expect("saved inline");
assert_eq!(TrialConfig::decode(saved).as_ref(), Some(&slot.config));
}
assert_eq!(
s.normal.saved.as_ref().and_then(|t| t.get("total_mobs")),
Some(&Tag::Float(2.0f32.to_bits()))
);
}
#[test]
fn registry_configs_resolve_and_are_saved_by_id() {
let breeze = TrialConfig {
+41
View File
@@ -542,6 +542,47 @@ mod tests {
assert_eq!(m.velocity.y, 0.0);
}
#[test]
fn the_leg_animation_runs_at_vanilla_s_rate() {
// LivingEntity.updateWalkAnimation turns the distance a tick into
// min(distance * 4, 1), and TurtleModel.setupAnim swings the legs
// by 8 * that speed, so the rate of the walk animation is the rate
// of the walk. The turtle walks at its own pace, so the animation
// runs at vanilla's rate instead of at the maximum (#1776).
let mut m = strolling_turtle();
let mut last = m.position;
let mut distance = 0.0_f64;
for _ in 0..40 {
// Entity.tick keeps the position the tick started at.
m.previous = last;
// TurtleMoveControl.tick: the forward input.
move_control(&mut m);
// LivingEntity.travelInAir on grass: the friction influenced
// speed is the input speed (grass' friction is 0.6, so it is
// not above it), the move uses the motion as it is, and the
// motion is then scaled by the friction (0.6) times the air
// drag (0.91). The move itself needs a world, so the flat
// ground is stepped here.
m.move_relative(m.ai.speed, Vec3::new(0.0, 0.0, 1.0));
m.position.x += m.velocity.x;
m.position.z += m.velocity.z;
let drag = f64::from(0.6_f32 * 0.91_f32);
m.velocity.x *= drag;
m.velocity.z *= drag;
// LivingEntity.updateWalkAnimation.
m.update_walk_animation();
distance = (m.position.x - m.previous.x).hypot(m.position.z - m.previous.z);
last = m.position;
}
// The ground speed: the input (0.06 + 0.125 * (0.25 - 0.06) =
// 0.08375) over one minus the drag (0.546) = 0.18447 blocks a tick.
assert!((distance - 0.083_75 / (1.0 - 0.546)).abs() < 1.0e-6);
// min(distance * 4, 1) for that pace: 0.73789. The generic
// MoveControl walked the turtle at the full 0.25, which put it at
// 0.55066 blocks a tick and saturated the walk animation at 1.0.
assert!((m.limb_speed - 0.737_885_5).abs() < 1.0e-6);
}
#[test]
fn water_speed_is_cut_away_from_home_and_for_babies() {
// updateSpeed in water near home: no cut, the lerp climbs towards
+7 -1
View File
@@ -318,7 +318,11 @@ pub fn work_sound(profession: u8) -> Option<&'static str> {
pub fn crop(w: &MobWorld, p: [i32; 3]) -> bool {
matches!(
w.block_name(p[0], p[1], p[2]),
"minecraft:wheat" | "minecraft:carrots" | "minecraft:potatoes" | "minecraft:beetroots"
"minecraft:wheat"
| "minecraft:carrots"
| "minecraft:potatoes"
| "minecraft:beetroots"
| "minecraft:torchflower_crop"
)
}
/// `CropBlock.isMaxAge` for the block at `p`.
@@ -326,6 +330,8 @@ pub fn ripe_crop(w: &MobWorld, p: [i32; 3]) -> bool {
let max = match w.block_name(p[0], p[1], p[2]) {
"minecraft:wheat" | "minecraft:carrots" | "minecraft:potatoes" => "7",
"minecraft:beetroots" => "3",
// TorchflowerCropBlock.getMaxAge is 2, past its age property (0-1):
// the crop is never at max age (age 2 is the torchflower itself).
_ => return false,
};
w.prop(p[0], p[1], p[2], "age") == Some(max)
+233 -35
View File
@@ -204,6 +204,8 @@ pub struct Memories {
pub nearest_hostile: Slot<Who>,
pub hiding_place: Slot<[i32; 3]>,
pub heard_bell_time: Slot<i64>,
/// `NEAREST_VISIBLE_WANTED_ITEM`: where the item entity lies.
pub nearest_wanted_item: Slot<[f64; 3]>,
pub cant_reach_walk_target_since: Slot<i64>,
pub golem_detected_recently: Slot<bool>,
pub last_slept: Slot<i64>,
@@ -236,6 +238,7 @@ impl Memories {
self.nearest_hostile.tick();
self.hiding_place.tick();
self.heard_bell_time.tick();
self.nearest_wanted_item.tick();
self.cant_reach_walk_target_since.tick();
self.golem_detected_recently.tick();
self.last_slept.tick();
@@ -322,6 +325,8 @@ pub struct Brain {
baby: bool,
/// Whether the nearest-bed sensor's batch cache is used (`NearestBedSensor`).
bed_cache: Vec<([i32; 3], i64)>,
/// `NearestItemSensor` is due; the game, which knows the items, answers.
scan_items: bool,
}
#[derive(Clone, Debug)]
struct Entry {
@@ -362,6 +367,7 @@ impl Brain {
shuffle_seed,
baby,
bed_cache: Vec::new(),
scan_items: false,
};
b.list = packages(baby, shuffle_seed, farmer);
b
@@ -556,7 +562,7 @@ fn tick_sensors(m: &mut Mob, w: &MobWorld, lr: &mut LegacyRandom) {
match i {
0 => sense_living(m, w),
1 => sense_players(m, w),
2 => {}
2 => brain_mut(m).scan_items = true,
3 => sense_bed(m, w, lr),
4 => sense_hurt(m, w),
5 => sense_hostiles(m, w),
@@ -1245,7 +1251,16 @@ enum B {
valid: Vec<[i32; 3]>,
worked: i32,
},
UseBonemeal(Timed),
UseBonemeal {
t: Timed,
next_work: i64,
last_session: i64,
worked: i32,
crop: Option<[i32; 3]>,
/// The next crop is picked on the next tick, when the level shows the
/// age the meal gave (vanilla grows the crop at once).
repick: bool,
},
WalkToBlockMemory {
running: bool,
which: PoiMem,
@@ -1347,13 +1362,13 @@ impl B {
| B::GoToPotentialJobSite(t)
| B::DoNothing(t)
| B::TradeWithVillager(t)
| B::UseBonemeal(t)
| B::Celebrate(t) => t.running,
B::MoveToTargetSink { t, .. }
| B::GiveGiftToHero { t, .. }
| B::ShowTrades { t, .. }
| B::WorkAtPoi { t, .. }
| B::HarvestFarmland { t, .. }
| B::UseBonemeal { t, .. }
| B::JumpOnBed { t, .. }
| B::MakeLove { t, .. }
| B::SleepInBed { t, .. } => t.running,
@@ -1679,17 +1694,38 @@ impl B {
}
true
}
B::UseBonemeal(t) => {
B::UseBonemeal {
t,
next_work,
last_session,
worked,
crop,
repick,
} => {
let mm = mem(m);
if mm.look_target.present() || mm.walk_target.present() {
return false;
}
// Bone meal in the inventory is needed; checked every 10 ticks.
let has = super::villager::ext(m)
.is_some_and(|e| e.count_item("minecraft:bone_meal") > 0);
let _ = t;
let _ = has;
false
// Checked every 10 ticks, and not within 160 ticks of the last session.
let age = i64::from(m.age);
if age % 10 != 0 || (*last_session != 0 && *last_session + 160 > age) {
return false;
}
if super::villager::ext(m).is_none_or(|e| e.count_item("minecraft:bone_meal") == 0)
{
return false;
}
*crop = pick_bonemeal_crop(m, cx);
if crop.is_none() {
return false;
}
t.begin(cx.lr, cx.now);
bonemeal_target(m, *crop);
m.extra.main_hand = Some("minecraft:bone_meal".into());
*next_work = cx.now;
*worked = 0;
*repick = false;
true
}
// -- OneShots.
B::InteractWithDoor {
@@ -1824,7 +1860,32 @@ impl B {
},
r,
),
B::GoToWantedItem(_) => false,
B::GoToWantedItem(r) => one(
|| {
let mm = mem(m);
if mm.walk_target.present() {
return false;
}
let Some(at) = mm.nearest_wanted_item.value() else {
return false;
};
// item.closerThan(body, 4); a villager can pick up loot.
if m.position.distance_squared(Vec3::new(at[0], at[1], at[2])) >= 16.0 {
return false;
}
let mm = mem(m);
// The item's eye height is 0.2125.
mm.look_target
.set(Tracker::Point([at[0], at[1] + 0.2125, at[2]]));
mm.walk_target.set(WalkTarget {
target: Tracker::Point(at),
speed: 0.5,
close: 0,
});
true
},
r,
),
B::AcquirePoi {
running,
filter,
@@ -2494,16 +2555,7 @@ impl B {
if dist_sqr(bell, block_of(m.position)) < 9
&& cx.w.block_name(bell[0], bell[1], bell[2]) == "minecraft:bell"
{
cx.events.push(MobEvent::SoundId(
"minecraft:block.bell.use".into(),
[
f64::from(bell[0]) + 0.5,
f64::from(bell[1]) + 0.5,
f64::from(bell[2]) + 0.5,
],
2.0,
1.0,
));
cx.events.push(MobEvent::RingBell { bell });
}
true
},
@@ -2649,17 +2701,61 @@ impl B {
if cx.lr.next_int_bound(10).unwrap_or(0) != 0 {
return false;
}
// Who chases whom is in the other kids' brains: chase the
// first friend.
if let Some(&kid) = friends.first() {
let mm = mem(m);
mm.interaction_target.set(kid);
mm.look_target.set(Tracker::Entity(kid, true));
mm.walk_target.set(WalkTarget {
target: Tracker::Entity(kid, false),
speed: 0.6,
close: 1,
});
// What a friend chases is its INTERACTION_TARGET.
let chasing = |who: Who| match who {
Who::Mob(id) => {
cx.w.seen(id)
.and_then(|s| s.villager.as_ref())
.and_then(|v| v.interaction_target)
}
Who::Player => None,
};
let me = Who::Mob(m.id);
if !friends.iter().any(|&f| chasing(f) == Some(me)) {
// The kid chased by the fewest (one to five) chasers, else any friend.
let mut chased: Vec<(Who, i32)> = Vec::new();
for &f in &friends {
if let Some(c) = chasing(f) {
match chased.iter_mut().find(|(k, _)| *k == c) {
Some(e) => e.1 += 1,
None => chased.push((c, 1)),
}
}
}
let target = chased
.iter()
.filter(|(_, n)| *n > 0 && *n <= 5)
.min_by_key(|(_, n)| *n)
.map(|&(k, _)| k)
.or_else(|| friends.first().copied());
if let Some(kid) = target {
let mm = mem(m);
mm.interaction_target.set(kid);
mm.look_target.set(Tracker::Entity(kid, true));
mm.walk_target.set(WalkTarget {
target: Tracker::Entity(kid, false),
speed: 0.6,
close: 1,
});
}
} else {
// Chased: run to a spot inside the village.
let villages = village_sections(m, cx.w);
for _ in 0..10 {
let Some(p) = m.land_random_pos(cx.w, 20, 8) else {
continue;
};
let at = [
p[0].floor() as i32,
p[1].floor() as i32,
p[2].floor() as i32,
];
let section = [at[0] >> 4, at[1] >> 4, at[2] >> 4];
if sections_to_village(&villages, section) <= 1 {
mem(m).walk_target.set(WalkTarget::point(p, 0.6, 0));
break;
}
}
}
true
},
@@ -2974,7 +3070,45 @@ impl B {
});
}
}
B::UseBonemeal(_) => self.do_stop(m, cx),
B::UseBonemeal {
t,
next_work,
worked,
crop,
repick,
..
} => {
if *repick {
*repick = false;
*crop = pick_bonemeal_crop(m, cx);
bonemeal_target(m, *crop);
}
// canStillUse: fewer than 80 work cycles and a crop to work on.
if now > t.end || *worked >= 80 || crop.is_none() {
self.do_stop(m, cx);
return;
}
let Some(p) = *crop else { return };
if now >= *next_work && closer_to_center(p, m.position, 1.0) {
let e = super::villager::ext_mut(m);
// BoneMealItem.growCrop: a CropBlock below max age takes the meal.
if let Some(slot) = e
.inventory
.iter()
.position(|s| s.count > 0 && &*s.item == "minecraft:bone_meal")
&& bonemeal_valid(cx.w, p)
{
e.inventory[slot].count -= 1;
if e.inventory[slot].count == 0 {
e.inventory.remove(slot);
}
cx.events.push(MobEvent::Bonemeal { pos: p });
*repick = true;
*next_work = now + 40;
}
*worked += 1;
}
}
_ => {}
}
}
@@ -3062,7 +3196,13 @@ impl B {
t.running = false;
mem(m).breed_target.clear();
}
B::UseBonemeal(t) => t.running = false,
B::UseBonemeal {
t, last_session, ..
} => {
t.running = false;
m.extra.main_hand = None;
*last_session = i64::from(m.age);
}
B::WorkAtPoi { t, .. } => t.running = false,
B::HarvestFarmland {
t, next_ok, worked, ..
@@ -3330,6 +3470,39 @@ fn harvest_tick(
}
}
}
/// `UseBonemeal.validPos`: a `CropBlock` below its max age.
fn bonemeal_valid(w: &MobWorld, p: [i32; 3]) -> bool {
super::villager::crop(w, p) && !super::villager::ripe_crop(w, p)
}
/// `UseBonemeal.pickNextTarget`: one of the valid blocks around the villager,
/// each equally likely (reservoir sampling with the level random).
fn pick_bonemeal_crop(m: &Mob, cx: &mut Bx) -> Option<[i32; 3]> {
let c = block_of(m.position);
let mut result = None;
let mut count = 0;
for x in -1..=1 {
for y in -1..=1 {
for z in -1..=1 {
let p = [c[0] + x, c[1] + y, c[2] + z];
if bonemeal_valid(cx.w, p) {
count += 1;
if cx.lr.next_int_bound(count).unwrap_or(1) == 0 {
result = Some(p);
}
}
}
}
}
result
}
/// `UseBonemeal.setCurrentCropAsTarget`.
fn bonemeal_target(m: &mut Mob, crop: Option<[i32; 3]>) {
if let Some(p) = crop {
let mm = mem(m);
mm.look_target.set(Tracker::Block(p));
mm.walk_target.set(WalkTarget::block(p, 0.5, 1));
}
}
/// The remembered POI positions a behaviour reads.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PoiMem {
@@ -3825,7 +3998,17 @@ fn packages(baby: bool, seed: i64, farmer: bool) -> Vec<Entry> {
},
if farmer { 2 } else { 5 },
),
(B::UseBonemeal(timed(60, 60)), if farmer { 4 } else { 7 }),
(
B::UseBonemeal {
t: timed(60, 60),
next_work: 0,
last_session: 0,
worked: 0,
crop: None,
repick: false,
},
if farmer { 4 } else { 7 },
),
],
),
);
@@ -4208,6 +4391,21 @@ pub fn woken(m: &mut Mob, now: i64) {
mem(m).last_woken.set(now);
}
}
/// `BellBlockEntity.updateEntities`: a bell rung within 32 blocks sets
/// `HEARD_BELL_TIME` to the game time.
pub fn heard_bell(m: &mut Mob, now: i64) {
if brain(m).is_some() {
mem(m).heard_bell_time.set(now);
}
}
/// Whether `NearestItemSensor` is due for this villager (it is then reset).
pub fn take_item_scan(m: &mut Mob) -> bool {
brain(m).is_some() && std::mem::take(&mut brain_mut(m).scan_items)
}
/// `NearestItemSensor.doTick`: the nearest wanted item in sight, or none.
pub fn set_wanted_item(m: &mut Mob, at: Option<[f64; 3]>) {
mem(m).nearest_wanted_item.set_opt(at);
}
/// What other villagers see of this one's brain.
pub fn potential_job_site(m: &Mob) -> Option<[i32; 3]> {
mem_ref(m).and_then(|mm| mm.potential_job_site.value())
+21 -7
View File
@@ -489,7 +489,8 @@ pub fn can_receive_vibration(m: &Mob, _now: i64, ctx: &Context) -> bool {
}
/// `VibrationSystem.Listener.handleGameEvent` for the warden: a free
/// listener within 16 blocks of its eyes schedules what it can hear.
/// `occluded` is `VibrationSystem.Listener.isOccluded`.
/// `occluded` is `VibrationSystem.Listener.isOccluded`. Returns true when a
/// sneaking player's step was avoided (AVOID_VIBRATION) instead.
pub fn listen(
m: &mut Mob,
now: i64,
@@ -497,23 +498,35 @@ pub fn listen(
pos: [f64; 3],
ctx: &Context,
occluded: impl Fn([f64; 3], [f64; 3]) -> bool,
) {
) -> bool {
let eye = [m.position.x, m.position.y + m.kind.eye_height, m.position.z];
let Some(st) = m.extra.warden.as_ref() else {
return;
return false;
};
if st.vibration.current.is_some() {
return;
return false;
}
let block = |p: [f64; 3]| p.map(|v| i64::from(v.floor() as i32));
let (source, listener) = (block(pos), block(eye));
let d = [0, 1, 2].map(|i| source[i] - listener[i]);
if d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > 256
|| !sculk::valid_vibration(sculk::warden_can_listen(event), event, ctx)
if d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > 256 {
return false;
}
// isValidVibration: the warden can trigger avoid vibration.
let listens = sculk::warden_can_listen(event);
if listens
&& ctx.source == Some(Who::Player)
&& !ctx.spectator
&& ctx.stepping_carefully
&& sculk::ignored_when_sneaking(event)
{
return true;
}
if !sculk::valid_vibration(listens, event, ctx)
|| !can_receive_vibration(m, i64::from(m.age), ctx)
|| occluded(pos, eye)
{
return;
return false;
}
let d = [pos[0] - eye[0], pos[1] - eye[1], pos[2] - eye[2]];
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
@@ -529,6 +542,7 @@ pub fn listen(
now,
);
}
false
}
/// `Level.gameEvent` for the mobs' own movement events from `from` on, as
/// the mob AI probe has no sculk layer: every warden's listener hears them.
+13
View File
@@ -1257,6 +1257,19 @@ impl MotionWorld<'_, '_, '_> {
if magma && !self.player.shift_key_down {
self.environment_hurt("minecraft:hot_floor", 1.0)?;
}
// TurtleEggBlock.stepOn: a step that is not careful may break an egg.
if !self.player.shift_key_down {
let state = self.block(on);
self.resources
.landing
.step_on(
self,
state,
on,
horizon_world::block_landing::LandingEntityKind::Player,
)
.map_err(|e| e.to_string())?;
}
// SlimeBlock.stepOn: a slow walk on slime drags the horizontal
// speed (the client moves its own player, so not the server's copy).
if slime && !damage_only && !self.player.shift_key_down {
+4 -12
View File
@@ -734,7 +734,7 @@ impl Screen {
motd.resolve_legacy(),
Ping::player_count(*players),
format!(
"server_list/ping_{}",
"gui/sprites/server_list/ping_{}",
match ms {
0..150 => 5,
150..300 => 4,
@@ -748,7 +748,7 @@ impl Screen {
Text::translate("multiplayer.status.cannot_connect", &[])
.with_color(0xFF0000),
Text::empty(),
"server_list/unreachable".to_owned(),
"gui/sprites/server_list/unreachable".to_owned(),
),
_ => {
// The pinging bars bounce, offset by the row.
@@ -757,7 +757,7 @@ impl Screen {
(
Text::translate("multiplayer.status.pinging", &[]),
Text::empty(),
format!("server_list/pinging_{}", frame + 1),
format!("gui/sprites/server_list/pinging_{}", frame + 1),
)
}
};
@@ -778,15 +778,7 @@ impl Screen {
);
}
let icon_x = cx + ROW_WIDTH - 4.0 - 15.0;
batch.sprite_scaled(
atlas,
&format!("gui/sprites/{sprite}"),
icon_x,
cy,
10.0,
8.0,
u32::MAX,
);
batch.sprite_scaled(atlas, &sprite, icon_x, cy, 10.0, 8.0, u32::MAX);
let sw = atlas.font.width_text(&status).ceil();
batch.text_styled(
atlas,
+4 -3
View File
@@ -767,9 +767,10 @@ impl Projectile {
}
ProjectileKind::Potion { .. } => "item/splash_potion",
ProjectileKind::LlamaSpit => "entity/llama/llama_spit",
ProjectileKind::ShulkerBullet { .. } => "entity/shulker/spark",
// Drawn as `WindChargeModel` by the game.
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge => return None,
// Drawn as `WindChargeModel` and `ShulkerBulletModel` by the game.
ProjectileKind::WindCharge
| ProjectileKind::BreezeWindCharge
| ProjectileKind::ShulkerBullet { .. } => return None,
ProjectileKind::WitherSkull { dangerous: false } => "entity/wither/wither",
ProjectileKind::WitherSkull { dangerous: true } => "entity/wither/wither_invulnerable",
// Drawn as their models by the game.
+73 -57
View File
@@ -221,6 +221,10 @@ pub struct Streaming {
results: mpsc::Receiver<Done>,
threads: Vec<thread::JoinHandle<()>>,
order: u64,
/// Ticks since the columns and protos that fell out of range were swept.
scan_age: u8,
/// The centre and radius the sweep was last reset for.
scan_area: ((i32, i32), i32),
generating: FxHashSet<(i32, i32)>,
generation_scan: GenerationScan,
/// Proto chunks: `None` while checked out to a decoration or save job.
@@ -555,6 +559,8 @@ impl Streaming {
results,
threads,
order: 0,
scan_age: 0,
scan_area: ((i32::MIN, i32::MIN), i32::MIN),
generating: Default::default(),
generation_scan: GenerationScan::default(),
protos: Default::default(),
@@ -893,6 +899,13 @@ impl Streaming {
return (uploads, Vec::new());
}
ExtraCover::refresh(&mut self.cover, &self.extra);
// The sweeps below walk every loaded column and proto chunk. Run them
// at once when the view moves and every eighth tick otherwise: a
// column can stay a little past the edge without anyone noticing.
let area = (self.center, self.radius);
let sweep = area != self.scan_area || self.scan_age >= 8;
self.scan_area = area;
self.scan_age = if sweep { 0 } else { self.scan_age + 1 };
self.requeue();
self.relight_edits(world);
let deferred = std::mem::take(&mut self.deferred);
@@ -967,66 +980,69 @@ impl Streaming {
}
}
}
// Unload far columns.
let far: Vec<_> = world
.chunks
.keys()
.copied()
.filter(|&(x, z)| !self.remote && self.ranges().unloaded(x, z, self.decorates))
.collect();
for key in &far {
self.decorated.remove(key);
}
let mut removed = Vec::with_capacity(far.len());
for key in &far {
if let Some(chunk) = world.chunks.remove(key) {
removed.push(chunk);
let mut removed = Vec::new();
if sweep {
// Unload far columns.
let far: Vec<_> = world
.chunks
.keys()
.copied()
.filter(|&(x, z)| !self.remote && self.ranges().unloaded(x, z, self.decorates))
.collect();
for key in &far {
self.decorated.remove(key);
}
self.lit.remove(key);
self.meshed.remove(key);
self.lighting.remove(key);
self.light_snapshots.remove(key);
}
if !far.is_empty() {
let far: HashSet<(i32, i32)> = far.iter().copied().collect();
self.meshing.retain(|k, _| !far.contains(&(k.0, k.2)));
self.waiting.retain(|k| !far.contains(&(k.0, k.2)));
world
.dirty_sections
.retain_columns(|x, z| !far.contains(&(x, z)));
}
// Drop far proto chunks that are not checked out.
let (center, r4) = (self.center, i64::from(self.radius + 4));
let ranges = self.ranges();
let far: Vec<(i32, i32)> = self
.protos
.iter()
.filter(|&(&(x, z), p)| {
p.is_some()
&& i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2) > r4 * r4
&& !ranges.in_extra(x, z, 4)
&& !ranges.in_band(x, z, 4)
&& !ranges
.forced
.iter()
.any(|f| (x - f.0).abs() <= 3 && (z - f.1).abs() <= 3)
})
.map(|(k, _)| *k)
.collect();
if !far.is_empty() {
self.generation_scan.exhausted = false;
}
// Those decoration wrote to are saved first (see
// `ChunkGenerator::save_protos`), and dropped once back.
let mut written = Vec::new();
for key in far {
if self.written.remove(&key) {
written.push(key);
} else {
self.protos.remove(&key);
removed.reserve(far.len());
for key in &far {
if let Some(chunk) = world.chunks.remove(key) {
removed.push(chunk);
}
self.lit.remove(key);
self.meshed.remove(key);
self.lighting.remove(key);
self.light_snapshots.remove(key);
}
if !far.is_empty() {
let far: HashSet<(i32, i32)> = far.iter().copied().collect();
self.meshing.retain(|k, _| !far.contains(&(k.0, k.2)));
self.waiting.retain(|k| !far.contains(&(k.0, k.2)));
world
.dirty_sections
.retain_columns(|x, z| !far.contains(&(x, z)));
}
// Drop far proto chunks that are not checked out.
let (center, r4) = (self.center, i64::from(self.radius + 4));
let ranges = self.ranges();
let far: Vec<(i32, i32)> = self
.protos
.iter()
.filter(|&(&(x, z), p)| {
p.is_some()
&& i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2) > r4 * r4
&& !ranges.in_extra(x, z, 4)
&& !ranges.in_band(x, z, 4)
&& !ranges
.forced
.iter()
.any(|f| (x - f.0).abs() <= 3 && (z - f.1).abs() <= 3)
})
.map(|(k, _)| *k)
.collect();
if !far.is_empty() {
self.generation_scan.exhausted = false;
}
// Those decoration wrote to are saved first (see
// `ChunkGenerator::save_protos`), and dropped once back.
let mut written = Vec::new();
for key in far {
if self.written.remove(&key) {
written.push(key);
} else {
self.protos.remove(&key);
}
}
self.save_checked_out(written);
}
self.save_checked_out(written);
let (protos, finishing) = (&self.protos, &self.finishing);
self.decorated.retain(|k| {
protos.contains_key(k) || finishing.contains(k) || world.chunks.contains_key(k)
+9 -1
View File
@@ -298,15 +298,23 @@ impl Title {
options: WorldOptions::default(),
}
}
/// WorldSelectionList.handleNewLevels: a singleplayer list that comes
/// back empty (the last world deleted, say) opens a fresh Create New
/// World, whose Cancel goes to the title screen.
fn return_to_list(&mut self, folder: Option<&str>) {
let worlds = list_worlds(&self.saves);
*self.recent.borrow_mut() = None;
if worlds.is_empty() {
self.create_return_to_main = true;
self.mode = Self::new_world();
return;
}
let selected = folder.and_then(|folder| {
worlds
.iter()
.position(|w| w.folder == folder && self.matches_search(w))
});
self.mode = Mode::Select { worlds, selected };
*self.recent.borrow_mut() = None;
}
/// Deletes the world `Mode::Delete` asks about and goes back to the
/// refreshed list; a failure (the world is open elsewhere, say) shows the
+28 -3
View File
@@ -288,12 +288,37 @@ fn delete_requires_confirmation_and_both_cancel_paths_preserve_world() {
assert_eq!((delete.x, delete.y, delete.w), (88.0, 146.0, 150.0));
f.click("Delete");
assert!(!f.title.saves.join("World").exists());
assert!(matches!(&f.title.mode, Mode::Select { worlds, selected: None } if worlds.is_empty()));
f.click("Back");
// The last world gone, vanilla opens a fresh Create New World, not an empty list.
assert!(matches!(&f.title.mode, Mode::Create { name, .. } if name == "New World"));
f.click("Cancel");
assert!(matches!(f.title.mode, Mode::Main));
f.click("Singleplayer");
assert!(matches!(f.title.mode, Mode::Create { .. }));
}
#[test]
fn deleting_one_of_two_worlds_keeps_the_list() {
let mut f = Fixture::new();
f.world("One", "One");
f.world("Two", "Two");
f.select();
f.click("Delete");
f.click("Delete");
assert!(matches!(&f.title.mode, Mode::Select { worlds, .. } if worlds.len() == 1));
}
#[test]
fn a_list_emptied_elsewhere_opens_create_world() {
let mut f = Fixture::new();
f.world("World", "World");
f.title.enter_singleplayer();
world_management::delete(&f.title.saves, "World").unwrap();
f.title.worlds_changed();
assert!(matches!(f.title.mode, Mode::Create { .. }));
f.title.escape();
assert!(matches!(f.title.mode, Mode::Main));
}
#[test]
fn backup_zip_preserves_nested_files_excludes_lock_and_keeps_duplicate_backups() {
let f = Fixture::new();
@@ -600,7 +625,7 @@ fn world_management_buttons_sound_when_enabled() {
assert_eq!(f.click_with_sound("Cancel"), (Action::None, true));
assert_eq!(f.click_with_sound("Delete"), (Action::None, true));
assert_eq!(f.click_with_sound("Delete"), (Action::None, true));
assert!(matches!(f.title.mode, Mode::Select { .. }));
assert!(matches!(f.title.mode, Mode::Create { .. }));
}
#[test]
+3 -1
View File
@@ -355,7 +355,9 @@ impl World {
false
}
/// The block entities to tick this tick, in order (`BlockEntities::tickers`).
pub fn block_entity_tickers(&mut self) -> Vec<(crate::block_entities::Pos, u32)> {
pub fn block_entity_tickers(
&mut self,
) -> std::sync::Arc<Vec<(crate::block_entities::Pos, u32)>> {
let (chunks, air) = (&self.chunks, self.air);
self.block_entities.tickers(|(x, y, z)| {
chunks
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff. Show more