Compare commits

...
Author SHA1 Message Date
levzzz cef8fa7e6d Merge pull request #3240 from some-du6e/fix/spear-jab-animation
Spears: the jab animates in first and third person
2026-10-29 19:17:10 +05:00
levzzz de8ffbb294 Merge pull request #3288 from hectoreccles/feat/beacon-beam-lod
Keep beacon beams visible outside the render distance
2026-10-29 19:07:51 +05:00
Ludovic Marie c177fef871 Merge pull request #2986 from kacpherek/fix/entity-rendering-parity
🤖 Agent: PR triage (Ludovic's AI agent, Claude): read in full, building on rdbt's approving review. Enderman's carried block is its baked model (seed 42, not centred) as CarriedBlockLayer, FoxHeldItemLayer draws the mouth item in the ground context (sleeping/baby offsets), IronGolemFlowerLayer the poppy while getOfferFlowerTick() > 0, Skeleton.DATA_STRAY_CONVERSION_ID (16) is sent and read so freezing skeletons shake, and the shadow radii match GiantMobRenderer (0.5 x 6), ElderGuardian 1.2, HappyGhast 2.0, Armadillo 0.4, Shulker 0. verify green; CI parity on this head.
2026-10-29 15:04:46 +01:00
Ludovic Marie 45ecf7bef0 Merge pull request #3718 from horizon-rust/feat/launcher-java-mod-toggle
🤖 Agent: launcher matrix 40883773220 green on all platforms at this head; queued.
2026-10-29 14:57:38 +01:00
Ludovic Marie 181040539d Merge pull request #3713 from horizon-rust/feat/launcher-post-exit
🤖 Agent: launcher matrix 40884459459 green on all platforms at this head; queued.
2026-10-29 14:57:33 +01:00
levzzz 57d4801bff Merge pull request #3583 from d3grando/fix-3430
Mobs: undead with NoAI burn in daylight
2026-10-29 18:44:13 +05:00
Ludovic Marie 0050a65600 Merge remote-tracking branch 'origin/main' into feat/launcher-post-exit
# Conflicts:
#	launcher/src/cli.rs
#	launcher/src/main.rs
2026-10-29 14:42:02 +01:00
Ludovic MarieandClaude Opus 5.5 0914c90455 Launcher: the post-exit test's Windows command keeps its last word
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:38:52 +01:00
Ludovic MarieandClaude Opus 5.5 2dd4903b5e Launcher: turn single mods on and off in Java instances
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:37:50 +01:00
levzzz a3b4d135aa Merge pull request #2706 from RoyalPixelsDE/perf/surface-skip-same-writes
Worldgen: the surface step skips writes that change nothing
2026-10-29 18:35:56 +05:00
levzzz 8474ac55c9 Merge pull request #3423 from d3grando/fix-3362
GUI: screens keep vanilla's int layout at any window size
2026-10-29 18:33:46 +05:00
levzzz bcb3d50624 Merge pull request #3349 from degepie/fix/world-list-hardcore
World list: show Hardcore Mode in red, and adventure and spectator by name (#3298)
2026-10-29 18:33:40 +05:00
Ludovic Marie 9c62d4f850 Merge pull request #3688 from horizon-rust/feat/launcher-java-instance-settings
🤖 Agent: launcher matrix 40876178731 green on all platforms at this head; queued.
2026-10-29 14:33:04 +01:00
Ludovic Marie ed45e27d15 Merge pull request #3697 from horizon-rust/fix/parity-test-macos-tmp
🤖 Agent: test-only fix (macOS temp symlink); verify and launcher green, 40 tests OK on macOS; queued.
2026-10-29 14:27:30 +01:00
Ludovic MarieandClaude Opus 5.5 edd340a621 Launcher: a command after the game exits, and the instance's variables
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:22:49 +01:00
levzzz 9c15e18c6f Merge pull request #2951 from kacpherek/fix/list-damage-datapack-commands
Fix /list, the missing /damage types and /datapack create
2026-10-29 18:14:54 +05:00
levzzz e5338ff202 Merge pull request #2713 from iLostName/reset-hud-between-servers
remote: titles, tab list, scoreboard and effects don't carry over to the next server
2026-10-29 18:14:33 +05:00
Ludovic Marie b13b4f2c7f Merge pull request #3679 from horizon-rust/feat/launcher-import-apps
🤖 Agent: launcher matrix 40874504029 green on all platforms at this head; queued.
2026-10-29 14:14:00 +01:00
levzzz 2730949b41 Merge pull request #3339 from josegil1909/feat/launcher-i18n-poc
Launcher: Spanish language support
2026-10-29 18:05:50 +05:00
levzzz 73cbd863af Merge pull request #2411 from kacpherek/fix/death-advancement-messages-to-guests
Send the host's death and advancement messages to guests and follow team visibility
2026-10-29 18:05:31 +05:00
Ludovic MarieandClaude Opus 5.5 9c67dd3b76 tests: resolve the temp root in the parity dependency-path test
On macOS the temp folder is under /var, a symlink to /private/var. dependencies()
resolves each path before comparing it with ROOT, so an unresolved temp ROOT
dropped every file and the test failed on macOS (Linux CI didn't see it).
The real ROOT is already resolved.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:03:24 +01:00
levzzz 702f7e2b93 Merge pull request #3359 from hamza-cskn/fix/invisible-armor-stands
Invisible armor stands: draw their equipment, and send their names to clients
2026-10-29 18:01:20 +05:00
levzzzandClaude Opus 5.5 765cdb6bb0 Merge main into fix-3362: keep both new multiplayer tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:00:27 +05:00
levzzz 4103382062 Merge pull request #2995 from ASOwnerYT/fix/2869-horse-max-health
Horses, donkeys, mules and llamas sync their rolled max health
2026-10-29 17:56:44 +05:00
levzzz 7f9cc4084d Merge pull request #2345 from kacpherek/fix/burn-in-daylight-list
Follow the burn in daylight tag for mobs that burn in the sun
2026-10-29 17:56:40 +05:00
levzzz 89f949155c Merge pull request #3589 from d3grando/fix-3427
Commands: coordinate suggestions skip entities and offer the hit point
2026-10-29 17:55:49 +05:00
levzzz 129ad4fdf8 Merge pull request #3573 from Vadyanik/fix/xkb-caps-swapescape
Follow the XKB keymap for Escape and Caps Lock
2026-10-29 17:55:43 +05:00
levzzz ea3c3532b6 Merge pull request #3570 from d3grando/fix-3426
Creative tooltips list the tabs that hold the item, as vanilla does
2026-10-29 17:55:36 +05:00
levzzz 46a5e18630 Merge pull request #3569 from d3grando/fix-3431
Villager gossip: hits and murders count against the player who did them
2026-10-29 17:55:29 +05:00
levzzz 703ba156da Merge pull request #3565 from d3grando/fix-2862
Copper golem eyes glow on the body model, not the empty eyes layer
2026-10-29 17:55:22 +05:00
levzzz dd40744480 Merge pull request #3564 from d3grando/fix-3158
Key binds: a moved key no longer keeps its default action
2026-10-29 17:55:16 +05:00
levzzz cca5298864 Merge pull request #3560 from d3grando/fix-3429
Count a zombie villager's cure down in its tick, with or without AI
2026-10-29 17:55:09 +05:00
levzzz 44c88b6c7b Merge pull request #3559 from d3grando/fix-3209
Attack indicator shows at a spear's extra range
2026-10-29 17:55:02 +05:00
levzzz 6a9ff4a38f Merge pull request #3558 from d3grando/fix-3136
Draw the arms under a map held in first person
2026-10-29 17:54:56 +05:00
levzzz 4c33a0fdfe Merge pull request #3557 from d3grando/fix-3289
Invisible mobs cast no shadow
2026-10-29 17:54:49 +05:00
levzzz 2e01c336a3 Merge pull request #3554 from d3grando/fix-3293
Draw glowing sign text in front of its outline
2026-10-29 17:54:42 +05:00
levzzz d26740a103 Merge pull request #3553 from d3grando/fix-3381
Projectiles hit a seated enderman
2026-10-29 17:54:36 +05:00
levzzzandClaude Opus 5.5 fdcefc0d90 /list uuids in single player shows the player's saved profile id, as the other commands do, instead of the offline id of the name.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:54:35 +05:00
levzzz 1c87226ac9 Merge pull request #3511 from Domanffe/fix/windows-overlay-hardlink-render
Windows: link the overlay's mirrored files instead of copying them
2026-10-29 17:54:28 +05:00
levzzz 9c86860e40 Merge pull request #3498 from JustKarkat/fix/mob-drop-scatter
Mob-origin item drops get the ItemEntity constructor's random motion
2026-10-29 17:54:19 +05:00
levzzz ca3d2d32af Merge pull request #3467 from AHHhHHHHH-H/pr/far-sampling-threads
Far terrain: sample on up to 6 threads when the machine has 12 or more, behind the game
2026-10-29 17:54:10 +05:00
levzzz 1b0923d8cf Merge pull request #3464 from d3grando/native-settings-refresh-on-open
Native mod settings: ask for the labels again when the page opens
2026-10-29 17:54:04 +05:00
levzzz 68f50bbd41 Merge pull request #3463 from d3grando/native-hooks-f3-corners
native_corner_shown: F3 takes the top right and its charts' corners
2026-10-29 17:53:55 +05:00
levzzz 47bce76078 Merge pull request #3456 from d3grando/fix-3312
Tooltips: potion effect and armour trim lines in the selected language
2026-10-29 17:53:48 +05:00
levzzz 125e540126 Merge pull request #3449 from d3grando/fix-3302
Keep the player model's legs still while flying straight up
2026-10-29 17:53:41 +05:00
levzzz 1578a97c79 Merge pull request #3444 from d3grando/fix-3408
Fog option off clears the Nether's fog too
2026-10-29 17:53:34 +05:00
levzzz dbd5fb7f62 Merge pull request #3438 from Joansitoh/fix/windows-overlay-junctions
Windows: link untouched directories into the pack overlay with junctions
2026-10-29 17:53:27 +05:00
levzzz 2a8dc29c69 Merge pull request #3437 from d3grando/fix-resize-absurd-size
Window: a size above the GPU's largest texture no longer crashes
2026-10-29 17:53:20 +05:00
levzzz 81a0e7e9a5 Merge pull request #3428 from d3grando/win-parity-sighup
parity: catch SIGHUP only where it exists, so parity.py starts on Windows
2026-10-29 17:53:13 +05:00
levzzz ce980a90de Merge pull request #3416 from d3grando/fix-3084-cyrillic-bold
Name tags: draw text as Font.prepareText does (bold, tall glyphs)
2026-10-29 17:53:07 +05:00
levzzz dd32d3dc38 Merge pull request #3399 from d3grando/fix-mod-screen-list-clip
Mod screens: a list's rows are cut at its frame
2026-10-29 17:53:00 +05:00
Ludovic MarieandClaude Opus 5.5 5667e9ebd1 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:51:56 +01:00
levzzzandClaude Opus 5.5 39592697c7 The launcher's Spanish covers the strings added on main: native mods, moving an instance to the Trash, and the build's new time estimate.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:50:21 +05:00
Ludovic MarieandClaude Opus 5.5 d785041833 Launcher: Java instances get a window size and join a server on launch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:46:35 +01:00
levzzz 08c0fc1286 Merge remote-tracking branch 'hr/main' into pr-2951 2026-10-29 17:40:55 +05:00
levzzz e81e23e64e Merge remote-tracking branch 'hr/main' into pr-3339 2026-10-29 17:40:54 +05:00
Ludovic Marie 5005a7ad9f Merge remote-tracking branch 'origin/main' into feat/launcher-import-apps
# Conflicts:
#	launcher/src/cli.rs
#	launcher/src/other_launchers.rs
2026-10-29 13:34:39 +01:00
Ludovic MarieandClaude Opus 5.5 dc66e37847 Launcher: copy CurseForge app and Modrinth App instances into Java instances
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:25:16 +01:00
JustKarkatandJustKarkat's AI agent (ZCode) 16090d3d4a Drop motion from the ItemEntity's own random, not the level's
Review fix on #3498: the 5-arg ItemEntity(level, x, y, z, stack)
constructor draws its push from this.random, a fresh source — not
this.level().random. Death loot, gift loot and MobEvent::DropItem now
use dispenser::new_item_motion() (already the item's own random), and
the DropGift arm on main had the same level-random draw and joins them.
The level random stays untouched for whatever reads it next (orb merge
ids, block ticks).

Co-Authored-By: JustKarkat's AI agent (ZCode)
2026-10-29 06:41:30 -05:00
d3grando 31cfdb4b95 Merge remote-tracking branch 'origin/main' into fix-3408
# Conflicts:
#	crates/horizon-client/src/game/render_tick.rs
2026-10-29 18:34:07 +07:00
Ludovic MarieandClaude Opus 5.5 5c18ae844b Drop a stray doc line after MAX_LEVEL
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:45:13 +01:00
Ludovic Marie 584a59ad8c Merge remote-tracking branch 'origin/main' into upd/3467 2026-10-29 10:45:08 +01:00
Ludovic MarieandClaude Opus 5.5 cdefed7a19 Merge main into this branch
#damages_helmet holds falling_anvil, falling_block and falling_stalactite (26.3's
tag): main added the stalactite, this branch the anvil; both kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:27:54 +01:00
Ludovic MarieandClaude Opus 5.5 db8fa8377d Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:18:46 +01:00
Ludovic MarieandClaude Opus 5.5 70a6aa45aa Merge main into this branch after the render crate split (#3186)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:15:19 +01:00
Ludovic MarieandClaude Opus 5.5 8c0b674fe8 Merge main into this branch (German launcher strings, old-version pruning)
Keeps Spanish beside German, adds the six strings main added since, and drops
the nightly strings main removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:14:54 +01:00
d3grandoandClaude Opus 5.5 4bd3210f78 Commands: coordinate suggestions skip entities and offer the hit point
ClientSuggestionProvider.getRelevantCoordinates and getAbsoluteCoordinates
read Minecraft.hitResult only when it is a block hit. Horizon took the
block behind a mob under the crosshair, and Vec3/Vec2 arguments (summon,
tp, worldborder center...) only ever offered ~. Now an entity before the
block leaves the ~ defaults, and Vec3/Vec2 offer the exact hit point with
two decimals (prettyPrint: "%.2f", Locale.ROOT).

Fixes #3427

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:52:30 +07:00
vadyanikandClaude Sonnet 5.5 6255c02096 Follow the XKB keymap for Escape and Caps Lock
Key events carry the scancode's US-layout code, which ignores options like
caps:swapescape. Take Escape and Caps Lock from the resolved keysym instead.

Fixes #3133

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 10:42:32 +02:00
d3grandoandClaude Opus 5.5 cf0b5a8e63 Mobs: undead with NoAI burn in daylight
NoAI only skips Mob.serverAiStep; Mob.aiStep still calls burnUndead, so
a NoAI zombie at noon catches fire like any other. Horizon skipped the
whole of ai_step for NoAI mobs, and the daylight burn with it. The burn
is now its own burn_undead, run with or without AI.

The mob_ai harness gets zombie_noai_day_burn (a NoAI zombie in the sun).

Fixes #3430

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:35:53 +07:00
d3grandoandClaude Opus 5.5 6a59002283 Creative tooltips list the tabs that hold the item, as vanilla does
CreativeModeInventoryScreen.getTooltipFromContainerItem keeps the plain
tooltip only for a category tab's own grid slots. Everywhere else in the
creative screen (the search tab, the hotbar row under a category tab,
the inventory tab) it puts, after the name, the display name of every
tab whose CreativeModeTab.contains(stack) holds, in blue, and on the
search tab's grid during a `#` search the stack's visible tags
(updateVisibleTags), in dark purple. Horizon drew the plain tooltip
everywhere.

Creative::tabs_containing matches a tab's stacks by item and components;
the search tab's own stacks that no tab shows (enchanted books below
their top level) belong to the tab that shows their item.
Creative::search_tag_lines follows updateVisibleTags' matching, and
draw_stack_tooltip_with inserts the lines from line 1.

Fixes #3426

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:27:35 +07:00
d3grandoandClaude Opus 5.5 113cafd2d1 Copper golem eyes glow on the body model, not the empty eyes layer
CopperGolemRenderer's LivingEntityEmissiveLayer (RenderTypes.eyes) draws
the eye texture on a CopperGolemModel baked from ModelLayers.COPPER_GOLEM,
the body's layer. Horizon drew it on copper_golem#eyes, whose prepared model
has no polygons, so the eyes showed the body texture's black sockets.

Fixes #2862

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:24:31 +07:00
d3grandoandClaude Opus 5.5 b2a5abb885 Key binds: a moved key no longer keeps its default action
KeyBinds::translate_all turns a pressed key into the default keys of the
mappings bound to it, so the game's code follows the player's bindings.
It also followed the debug mappings (F3+A reload chunks, F3+S, F3+1 the
profiler chart...), whose defaults are the gameplay keys A, S, D, T, L
and 1 to 4. So after moving key.hotbar.1 off 1, or key.left off A, the
old key was still "bound" to a debug mapping whose default was itself,
and kept selecting slot 1 or strafing left.

The debug keys act only with F3 held (KeyboardHandler.handleDebugKeys),
and debug_keys.rs already reads the physical key and the binding for
them, so translation now leaves them out.

Fixes #3158

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:21:02 +07:00
d3grandoandClaude Opus 5.5 1da38a5c46 Villager gossip: hits and murders count against the player who did them
On the server thread every player is a guest of the server's Game, but a
villager's hurt gossip (minor_negative) and its witnesses' murder gossip
(major_negative) were filed under self.level.player_uuid, the world's
singleplayer UUID. Trading reads the reputation under the guest's own UUID,
so hurting or killing villagers never raised that player's prices.

Both now go to the player in last_hurt_by_player (the host, or the guest by
connection), as Villager.setLastHurtByMob and
tellWitnessesThatIWasMurdered report the attacking player.

Fixes #3431

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:20:21 +07:00
d3grandoandClaude Opus 5.5 2bb3903b49 Draw the arms under a map held in first person
renderOneHandedMap draws the holding arm (renderPlayerArm, turned 10
degrees outwards) and renderTwoHandedMap both arms (renderMapHand), each
with renderPlayerHand, unless the player is invisible. Horizon drew only
the map. first_person::map_arm_poses gives those poses; the bare arm's
pose (player_arm_pose) and the two-handed map's base
(two_handed_map_base) move to first_person.rs so the empty hand and the
map share them. The game keeps both arms of the player models, and
first_person_arm draws either with the skin and renderHand's 0.1 radian
zRot.

Fixes #3136

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:19:44 +07:00
d3grandoandClaude Opus 5.5 c8915be58c Attack indicator shows at a spear's extra range
The crosshair's living target came only from mob_hit, capped at the
3 blocks of entityInteractionRange, so the attack indicator never showed
for a mob a spear could reach at 4 blocks. LocalPlayer.raycastHitResult
first picks along the held item's AttackRange (getClosesetHit: from the
min reach to the max plus the player's movement, boxes grown by the
hitbox margin, stopped by block outlines) and only falls back to the
plain pick on a miss. crosshair_living now does the same, then keeps
Hud's AttackRange.isInRange check.

The spear's stab search (getHitEntitiesAlong) becomes free functions
over mob boxes with a block clip, shared by the stab (collider) and the
crosshair (outline).

Fixes #3209

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:14:13 +07:00
d3grandoandClaude Opus 5.5 8dffd2b847 Count a zombie villager's cure down in its tick, with or without AI
ZombieVillager.tick counts villagerConversionTime down before super.tick
for every living, converting zombie villager; NoAI only skips
serverAiStep. Horizon counted it in customServerAiStep, so a NoAI zombie
villager never finished curing.

Fixes #3429

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:08:17 +07:00
d3grandoandClaude Opus 5.5 4245540711 Invisible mobs cast no shadow
EntityRenderer.extractShadow skips an entity whose render state is
invisible (Entity.isInvisible). The shadow pass took every mob: the
casters now come from shadows::mob_casters, which leaves out a mob with
the shared invisible flag from a server or the Invisibility effect on a
mob ticked in the window (Mob::is_invisible).

Fixes #3289

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:05:01 +07:00
d3grandoandClaude Opus 5.5 6bd34b64d6 Draw glowing sign text in front of its outline
draw_text emitted the eight outline copies and the text on the same
plane in one mesh, so glowing sign text z-fought with its outline and
broke up. Vanilla's TextFeatureRenderer.renderText draws the outline
NORMAL and the text over it with Font.DisplayMode.POLYGON_OFFSET; the
block-entity mesh has no such pass, so an outlined call's text is now
lifted a twentieth of a font pixel towards the reader.

Fixes #3293

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:04:14 +07:00
d3grandoandClaude Opus 5.5 a690ccef3c Projectiles hit a seated enderman
Enderman.hurtServer teleports away from projectile damage only when
!isPassenger(). Horizon's on_damage rejected every projectile, so an
enderman riding a boat or a mob made arrows vanish and shrugged off
snowballs and eggs. A free enderman still dodges.

Fixes #3381

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:55:02 +07:00
Alexander KrylovandHermes Agent 3c7e4ba574 Windows: link the overlay's mirrored files instead of copying them
`link` copied every file the mirror hands the overlay, so a selection with a
pack on wrote the prepared root out again: 639 MB and 28 181 files here, and a
cold overlay build of 34.4 s against 20.8 s when the same files are hardlinks.
Unix already links them and the overlay only reads them.

A different volume, or a filesystem without hardlinks, still copies.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-29 08:21:34 +03:00
d3grandoandClaude Opus 5.5 168bc8ffd5 Native mod settings: ask for the labels again when the page opens
A native mod's settings page showed the labels native_settings gave
when Options > Mods was listed, refreshed only after a click on one of
its buttons, so after a language change (or a change in the mod's own
state) the page kept the old labels until clicked. Opening the page now
sends Event::NativeSettings, and the game asks the mod again
(native_settings_refresh, split from native_settings_event).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:42:07 +07:00
d3grandoandClaude Opus 5.5 0352ad0e19 Name tags: draw text as Font.prepareText does (bold, tall glyphs)
Name tags had their own glyph loop: every glyph an 8x8 quad at the line
top and no style. accented.png's 9x12 glyphs (й, Й, Ё...) were squeezed
into the line, bold, italic, underline, strikethrough, obfuscated and the
font style were ignored, the line was centred at -trunc(width / 2) and the
background stopped 8 pixels below the line top.

Vanilla (SubmitNodeCollection.submitNameTag -> TextFeatureRenderer ->
Font.prepareText) draws them like any text, centred at -Font.width / 2,
on a background from y - 1 to y + 9 (PreparedTextBuilder.markBackground).
HudBatch::name_tag lays the line out with text_styled, and names.rs maps
its quads into the world. Fixes #3084.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:41:56 +07:00
d3grandoandClaude Opus 5.5 526887be89 Tooltips: potion effect and armour trim lines in the selected language
stack_tooltip_lines read the potion effect names and the armour trim
lines through Enchantments::translate, which holds en_us only, so a
Russian game showed "Slowness IV" and "Upgrade: Coast Armor Trim" among
Russian lines. They now go through the same translator as the other
tooltip lines: the selected language, then the pinned English names.

Vanilla builds these lines from translatable components
(PotionContents.addPotionTooltip and getPotionDescription,
ArmorTrim.addToTooltip).

Part of #3312 (the raw %2$s: %1$s attribute lines are #3241).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:41:55 +07:00
d3grandoandClaude Opus 5.5 a5ae075a5d Fog option off clears the Nether's fog too
The Horizon Fog option only cleared the distance fog when that fog ended
at or beyond the render distance. The Nether's visual/fog_end_distance is
96 blocks, so its fog stayed at any render distance over 6 chunks. Clear
it whenever the camera's fog is atmospheric and no blindness, darkness or
boss world fog applies; the fog of lava, powder snow and water keeps its
limits, as before.

Fixes #3408

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:41:54 +07:00
d3grandoandClaude Opus 5.5 14a4d2726d Window: a size above the GPU's largest texture no longer crashes
Windows lets a program size a window up to 65535 pixels tall with
SetWindowPos; its client area (984x65496 here) is past the 32768 an RTX
5080 takes, and Renderer::resize handed it to Surface::configure, which
panics ("width and height must be within the maximum supported texture
size"). Renderer::resize now treats a size the swapchain can't take like
a minimized window: it keeps the last size and draws nothing until the
next resize. Renderer::new clamps its first size to the same limit.

Vanilla: Window.onFramebufferResize ignores a size that is not positive
and keeps the last one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:41:48 +07:00
JustKarkatandJustKarkat's AI agent (ZCode) d372e9ece9 Mob-origin item drops get the ItemEntity constructor's random motion
A mob's death loot, its gift table and spawnAtLocation drops (a fox's
berries, a panda's held food, a goat's horn) spawned with a fixed
[0, 0.2, 0] motion, so every item landed stacked at one exact point; the
5-arg ItemEntity(level, x, y, z, stack) constructor throws each one with
a random horizontal push of ±0.1 and 0.2 up, like vanilla. Block drops
(already going popResource's path) unaffected.

Fixes #3144.

Co-Authored-By: JustKarkat's AI agent (ZCode)
2026-10-28 21:37:01 -05:00
Ludovic MarieandClaude Opus 5.5 21ba38e633 Merge main into fix/list-damage-datapack-commands
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:00:37 +01:00
AHHhHHHHH-HandClaude Sonnet 5.5 940b642233 Far terrain: sample on up to 6 threads when the machine has 12 or more, behind the game
A cold 1024-chunk LOD loaded about a quarter sooner with 6 sampling threads than with 3. The extra slots only open once the nearby chunks are in, and the far threads now run behind the server like the chunk workers, so frames and ticks keep their cores.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-28 21:50:15 -03:00
d3grandoandClaude Opus 5.5 d192946a6f native_corner_shown: F3 takes the top right and its charts' corners
Vanilla's debug screen draws a column in each top corner
(DebugScreenOverlay.extractLines, left- and right-aligned), but the
helper only reported the top left as taken, so a native mod's box in the
top right was drawn under F3's right column. It now reports both top
corners while F3 is open, and a bottom corner while a debug chart is
drawn there (frame and tick times, bandwidth and ping, the lightmap
view), from the new DebugCharts::corners, which follows draw().

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 07:50:00 +07:00
Ludovic MarieandClaude Opus 5.5 af3aa00377 Merge main into fix/2869-horse-max-health
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:43:53 +01:00
d3grandoandClaude Opus 5.5 bf7179c48b Keep the player model's legs still while flying straight up
The walk animation counted vertical movement while flying or gliding.
Vanilla's LivingEntity.calculateEntityAnimation only counts it for
omnidirectional air movers, and Entity.omnidirectionalAirMover is false
for every player, so only the horizontal distance walks the model.

Fixes #3302

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 07:08:09 +07:00
Joansitoh dc44f98818 Packs overlay: quote mklink's paths, compile the junction only on Windows
cmd reads its own command line and Rust quotes only paths with spaces, so a
path with `&` or `^` split or changed the mklink call and that player fell back
to the slow mirror. The overlay test now runs under a path with `&` and checks
the untouched directory is a link, not a copy.
2026-10-29 00:30:13 +01:00
Joansitoh 90014f63d2 Windows: link untouched directories into the pack overlay with junctions, not a file-by-file mirror
Refs #3385, #3234
2026-10-29 00:15:45 +01:00
d3grandoandClaude Opus 5.5 2882f0764d parity: catch SIGHUP only where it exists, so parity.py starts on Windows
Python's signal module has no SIGHUP on Windows, so exit_on_termination
raised AttributeError and every parity.py command (--list, --changed,
--status, and pr_ready's local harness run) stopped before doing anything.
SIGTERM is still caught everywhere, SIGHUP wherever the platform has it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:41:08 +07:00
d3grandoandClaude Opus 5.5 e8896336da GUI: screens keep vanilla's int layout at any window size
Direct Connection, Add/Edit Server, Join a Friend, Connection Lost, the
death screen and the sign editor's Done button placed their widgets at
width / 2.0 - 100 and height / 4.0 + N in floats. Vanilla's Screen
width and height are ints, so `this.height / 4` drops the remainder;
Horizon kept it, and whenever the GUI height was not a multiple of 4 (a
maximized or tall window: 1009 / 4 = 253 GUI pixels) or the width was
odd, buttons, boxes and centred text landed on a half or quarter GUI
pixel. At scale 3 that is 1.5 device pixels: borders came out thinner on
one side and glyph rows uneven (#3362).

The positions now use int halves and quarters as DirectJoinServerScreen,
ManageServerScreen, DeathScreen and AbstractSignEditScreen do, and
centred text is width / 2 - font.width / 2 (GuiGraphicsExtractor
.centeredText).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:27:47 +07:00
d3grandoandClaude Opus 5.5 8f00332979 Mod screens: a list's rows are cut at its frame
A mod screen's `list` drew one row more than its frame holds (the row a
scroll shows in part), uncut, so without scrolling the 7th row of a long
list sat below the frame, over the screen. As vanilla's
AbstractSelectionList, which draws its entries between enableScissor and
disableScissor, the rows (with their hover and selection marks) are now cut
at the frame's inside: the partly shown row is drawn in part. Fixes #3237.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 04:15:07 +07:00
Hamza CoşkunandClaude Opus 5.5 0e2b244120 Send an armor stand's custom name and its visibility to its clients
The server never sent DATA_CUSTOM_NAME or DATA_CUSTOM_NAME_VISIBLE for an
armor stand, so hologram text didn't show for its clients: single player on
its server thread, LAN guests and dedicated servers. Both are sent now, and go
back to none / false when /data takes them away.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:57:42 +03:00
Hamza CoşkunandClaude Opus 5.5 835674f45d Draw an invisible armor stand's equipment, not just skip it
LivingEntityRenderer.submit gives an invisible stand no body render type but
still submits its layers: held items, the custom head and the armour. The
renderer skipped the whole stand instead, so floating heads, item displays and
armour on invisible stands never showed, in-frame and on any server that sends
the invisible flag (vanilla, Paper, Minestom). Only the wooden body is skipped now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:57:42 +03:00
degepieandClaude Opus 5.5 2acf1d7798 World list: show Hardcore Mode in red, and adventure and spectator by name
The Select World list built its info line from a creative flag only, so a
hardcore world read "Survival Mode" and adventure and spectator worlds read
"Survival Mode" too (#3298). LevelSummary.createInfo uses `gameMode.hardcore`
in CommonColors.RED (-65536) for a hardcore world, else `gameMode.` + the
GameType's name, then commands and the version. WorldEntry now carries the
level's game mode and hardcore flag (both already read from level.dat), and
`world_info` builds that line as styled text. The horizon-ctl JSON keeps its
`creative` / `mode` fields as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 00:48:01 +05:00
Jose Gil c9bd057d70 feat(launcher): add Spanish language support 2026-10-28 15:43:54 -04:00
Hector EcclesandDeepSeek V4.1 Flash 22a4620643 Build beacon beam sections on server clients
A chunk's block entity data carries a beacon's levels and powers but not
its beam sections, so in the default server-thread path and on LAN or
multiplayer clients the sections stayed empty and no beam was drawn.
Tick beacons with ClientLevel.tickBlockEntities' beam pass, without the
effects, sounds and advancement the server owns.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-10-28 12:54:44 -04:00
Hector EcclesandDeepSeek V4.1 Flash 8422225047 Keep beacon beams visible past the render distance
Remember each active beacon's beam sections when its chunk unloads, so
far terrain LOD keeps drawing the beam until the chunk loads again and
its block entity decides whether it is still there.

The cache only runs with far terrain on, and clears on world, dimension
or LOD changes.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-10-28 12:54:44 -04:00
some-du6eandClaude Opus 5.5 f52af64e89 Spears: the jab animates in first and third person
A spear's left click is a STAB swing (SwingAnimationType 2), but every
swing was drawn as a WHACK. Port SpearAnimations.firstPersonAttack,
thirdPersonAttackHand and thirdPersonAttackItem, and keep a swing's type
for mobs and other players (from ClientboundSwingAnimationPacket) and
the local player's model.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 09:02:21 -06:00
Ludovic MarieandClaude Opus 5.5 150a29974d Merge main into fix/burn-in-daylight-list
lan.rs mob_meta now builds all shared flags on main: its on-fire bit uses
is_on_fire, so a fire-immune mob never reports burning. game.rs keeps main's
charged-blaze flames with is_on_fire.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:43:53 +01:00
Ludovic MarieandClaude Opus 5.5 eff8ae41c6 Merge main into fix/death-advancement-messages-to-guests
Kept system_message_to next to main's overlay_message in lan/hub.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:39:30 +01:00
Kacpherek c5dead28a0 Draw the poppy an iron golem offers
IronGolemFlowerLayer draws a poppy at the right arm while getOfferFlowerTick() is above 0, with NO_OVERLAY; Horizon only ran the offer, never drew it.
2026-10-27 22:33:06 +01:00
Kacpherek 15099724ed Draw the item a fox holds in its mouth
FoxHeldItemLayer puts the main hand item at the head's position, rolled and turned by the render state, in the ground display context; Horizon drew nothing.
2026-10-27 22:33:06 +01:00
Kacpherek 52a820e708 Match vanilla's shadow radius for armadillos, elder guardians, happy ghasts and shulkers
ArmadilloRenderer 0.4, ElderGuardianRenderer 1.2, HappyGhastRenderer 2.0 and ShulkerRenderer 0.0; Horizon used the 0.5 default for all four.
2026-10-27 22:32:59 +01:00
Kacpherek 85f2a9ad87 Draw an enderman's carried block as its baked model
CarriedBlockLayer submits the block's model on the unit cube. Horizon drew a cube of one face sprite, centred half a block off, so grass lost its sides and flowers became cubes.
2026-10-27 22:31:30 +01:00
Kacpherek 16d71b0a24 Cast a giant's shadow at 3 blocks
GiantMobRenderer passes 0.5 times its scale of 6 as the shadow radius; the default 0.5 was used.
2026-10-27 22:30:06 +01:00
Kacpherek f83310a891 Shake a skeleton while it freezes into a stray
Skeleton.isShaking is its freezing conversion, synced as DATA_STRAY_CONVERSION_ID; Horizon never sent or read it, so the shake never showed.
2026-10-27 22:30:06 +01:00
asownerytandClaude Opus 5.5 d321b656f6 Horses, donkeys, mules and llamas sync their rolled max health
finalizeSpawn's randomizeAttributes, setOffspringAttributes and a loaded
save wrote the rolled max health (and a horse's speed) straight into the
mob's stats instead of the attribute bases, so the server never sent them
and a rider's client drew the type's 53 health: 26 hearts. They are bases
now (AttributeInstance.setBaseValue), synced like vanilla's.

Fixes #2869

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 10:14:00 +13:00
Kacpherek 41f60562fd Add /datapack create
/datapack create <id> <description> fell into the disable branch and replied with an empty unknown-pack key. It now writes <world>/datapacks/<id>/ with a data folder and a pack.mcmeta, and replies with vanilla's success and failure keys (DataPackCommand.createPack).
2026-10-27 21:59:46 +01:00
Kacpherek 136b352e4f Give 15 vanilla damage types their own /damage kind
/damage fell back to the Generic kind for cramming, falling_anvil, falling_block, fireworks, generic_kill, lightning_bolt, out_of_world, outside_border, sonic_boom, spit, stalagmite, starve, sulfur_cube_hot, wind_charge and wither. Each now has its own kind and follows the vanilla damage type tags. Creaking, Warden and Wither ask "bypasses invulnerability" instead of "is Generic".
2026-10-27 21:59:46 +01:00
Kacpherek ff1b01616f List every online player in /list
/list printed a fixed "1 of a max of 8" with only the host. It now lists the host of a single-player or LAN world first, then the placed guests in join order, with the real count and the hub's max, as ListPlayersCommand does. /list uuids shows each name with its UUID. A single-player owner is already a guest of the in-process server and is not listed twice.
2026-10-27 21:59:46 +01:00
iLostName 758d66bf44 remote: titles, tab list, scoreboard and effects don't carry over to the next server 2026-10-27 10:09:12 +01:00
FusselTVandClaude Opus 5.5 3788dff6a1 Worldgen: the surface step skips writes that change nothing
Most of the Nether's surface rule ends in netherrack, written over the
netherrack the noise step placed: every solid block paid for a section
write, two state table reads, four heightmap updates and a fluid check
for nothing. Column::set_block now leaves a block that already holds the
state (a fluid is still marked for post-processing, as every write marks
it).

chunkgen_bench, 576 chunks, 1 thread: Nether surface 1.81 -> 1.52 ms per
chunk; the terrain snapshots of seeds 12345 and 1 (Nether and Overworld)
are byte for byte the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:54:40 +01:00
Kacpherek 4e96037c78 Never show fire-immune mobs as on fire 2026-10-26 20:42:58 +01:00
Kacpherek 5b066b1b78 Send the host's death and advancement messages to guests and follow team visibility 2026-10-26 19:53:09 +01:00
Kacpherek 4eb95c4d3e Follow the burn in daylight tag for mobs that burn in the sun 2026-10-26 18:53:26 +01:00
97 changed files with 3800 additions and 785 deletions

No files matched your search

@@ -54,6 +54,9 @@ pub struct Registries {
pub function_tags: Vec<String>,
/// The block the source is looking at (relevant coordinates).
pub target_block: Option<[i32; 3]>,
/// `ClientSuggestionProvider.getAbsoluteCoordinates`: the exact point of
/// that block hit, each coordinate as `%.2f` (`Locale.ROOT`).
pub target_point: Option<[String; 3]>,
/// Every registry's element and tag ids (`minecraft:item` -> ...), as
/// the server's registry access and reloadable registries hold them.
pub by_registry: BTreeMap<String, RegistryIds>,
+16 -4
View File
@@ -662,10 +662,22 @@ pub fn name_shown(own: Option<&Team>, viewer: Option<&Team>, v: NameView) -> boo
}
!v.hud_hidden && !v.is_camera && visible && !v.is_vehicle
}
/// `ServerPlayer.die`: whether a player's death message reaches the player
/// themselves (the team broadcasts leave out the dying player).
pub fn own_death_message_shown(team: Option<&Team>) -> bool {
team.is_none_or(|t| t.death_message_visibility == Visibility::Always)
/// `ServerPlayer.die`: whether `other` hears the death message of `dying`
/// (`broadcastSystemMessage`, `broadcastSystemToTeam` or
/// `broadcastSystemToAllExceptTeam`, by the team's death message visibility).
pub fn death_message_reaches(board: &Scoreboard, dying: &str, other: &str) -> bool {
let Some(team) = board.players_team(dying) else {
return true;
};
let same_team = board
.players_team(other)
.is_some_and(|t| t.name == team.name);
match team.death_message_visibility {
Visibility::Always => true,
Visibility::Never => false,
Visibility::HideForOtherTeams => same_team && other != dying,
Visibility::HideForOwnTeam => !same_team,
}
}
// ---- objectives and scores ---------------------------------------------
+48 -13
View File
@@ -300,9 +300,8 @@ impl Default for KeyBinds {
}
}
/// Each default key once, in order: two mappings that share a default key
/// (Advancements and the debug profiler both on L) are one press of it, not
/// two (the second would close the screen the first opened). Vanilla clicks
/// both mappings, but the profiler one only acts with F3 held.
/// (Pick Block and the spectator hotbar both on the middle button) are one
/// press of it, not two.
fn distinct<T: PartialEq>(keys: Vec<T>) -> Vec<T> {
let mut out = Vec::with_capacity(keys.len());
for k in keys {
@@ -368,6 +367,25 @@ impl KeyBinds {
.map(|m| m.0)
.filter(move |n| self.get(n) == key)
}
/// The mappings bound to `key` that [`KeyBinds::translate_all`] follows:
/// all but the debug keys. Those act only with F3 held
/// (`KeyboardHandler.handleDebugKeys`, `debug_keys.rs` reads the physical
/// key and the binding itself); their defaults (A, S, D, T, L, 1 to 4...)
/// would otherwise turn the key back into its old gameplay meaning.
fn translated_mappings<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'static str> + 'a {
MAPPINGS
.iter()
.filter(|m| m.2 != "debug")
.map(|m| m.0)
.filter(move |n| self.get(n) == key)
}
/// Whether a mapping (other than a debug key) whose default is `key` was
/// bound elsewhere.
fn default_moved(&self, key: &str) -> bool {
MAPPINGS
.iter()
.any(|(n, d, c)| *d == key && *c != "debug" && !self.is_default(n))
}
/// Translate a physical key into the default key of the mapping it is
/// bound to, so game code written against vanilla defaults follows the
/// player's bindings. `None` means the key now does nothing; mouse
@@ -384,7 +402,7 @@ impl KeyBinds {
return vec![Ok(key)];
};
let bound: Vec<_> = self
.mappings_for(name)
.translated_mappings(name)
.filter_map(|mapping| {
let default = Self::default_of(mapping);
match key_code(default) {
@@ -397,10 +415,7 @@ impl KeyBinds {
return distinct(bound);
}
// An unbound key whose default meaning was moved elsewhere is inert.
if MAPPINGS
.iter()
.any(|(n, d, _)| *d == name && !self.is_default(n))
{
if self.default_moved(name) {
return Vec::new();
}
vec![Ok(key)]
@@ -413,7 +428,7 @@ impl KeyBinds {
pub fn translate_mouse_all(&self, button: MouseButton) -> Vec<Result<KeyCode, MouseButton>> {
let name = mouse_name(button);
let bound: Vec<_> = self
.mappings_for(&name)
.translated_mappings(&name)
.filter_map(|mapping| {
let default = Self::default_of(mapping);
match mouse_button(default) {
@@ -425,10 +440,7 @@ impl KeyBinds {
if !bound.is_empty() {
return distinct(bound);
}
if MAPPINGS
.iter()
.any(|(n, d, _)| *d == name && !self.is_default(n))
{
if self.default_moved(&name) {
return Vec::new();
}
vec![Err(button)]
@@ -508,6 +520,29 @@ mod tests {
assert_eq!(k.conflicts("key.forward"), ["key.jump"]);
}
#[test]
fn a_moved_key_keeps_no_default_action() {
// #3158: the debug keys (F3+A, F3+1...) share these keys' defaults,
// and must not hand them back their gameplay meaning.
let mut k = KeyBinds::default();
k.set("key.hotbar.1", "key.keyboard.q");
assert_eq!(k.translate_all(KeyCode::Digit1), []);
assert_eq!(
k.translate_all(KeyCode::KeyQ),
[Ok(KeyCode::KeyQ), Ok(KeyCode::Digit1)]
);
k.set("key.inventory", "key.keyboard.a");
k.set("key.left", "key.keyboard.e");
assert_eq!(k.translate_all(KeyCode::KeyA), [Ok(KeyCode::KeyE)]);
assert_eq!(k.translate_all(KeyCode::KeyE), [Ok(KeyCode::KeyA)]);
k.set("key.back", "key.keyboard.down");
assert_eq!(k.translate_all(KeyCode::KeyS), []);
// Keys left alone still act, and a moved debug key changes nothing.
k.set("key.debug.fpsCharts", "key.keyboard.9");
assert_eq!(k.translate_all(KeyCode::Digit2), [Ok(KeyCode::Digit2)]);
assert_eq!(k.translate_all(KeyCode::Digit9), [Ok(KeyCode::Digit9)]);
assert_eq!(k.translate_all(KeyCode::F3), [Ok(KeyCode::F3)]);
}
#[test]
fn display_names() {
assert_eq!(display_name("key.keyboard.w").plain(), "W");
assert_eq!(display_name("key.keyboard.slash").plain(), "/");
@@ -654,10 +654,20 @@ fn sign_text_calls(
.collect()
}
/// How far the text of an outlined call sits in front of its outline, in
/// text units (`Font.DisplayMode.POLYGON_OFFSET`, see `draw_text`).
const TEXT_OVER_OUTLINE: f32 = 0.05;
/// Draws a text call with the font atlas: 8x8 glyph cells, the outline as
/// eight one-pixel offsets under the text.
///
/// `TextFeatureRenderer.renderText` draws the outline glyphs NORMAL and the
/// text over them POLYGON_OFFSET, so the coplanar text always wins the depth
/// test. Here both share one mesh, so the text is lifted a hair towards the
/// reader instead (a twentieth of a font pixel, invisible) rather than
/// z-fighting with the outline under it.
fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
let mut emit = |dx: f32, dy: f32, argb: u32| {
let mut emit = |dx: f32, dy: f32, z: f32, argb: u32| {
let mut x = call.x + dx;
let y0 = call.y + dy;
for ch in call.text.chars() {
@@ -672,7 +682,7 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
normal: [0; 4],
pos: call
.matrix
.transform_point3(Vec3::new(cx, cy, 0.0))
.transform_point3(Vec3::new(cx, cy, z))
.to_array(),
uv,
layer: 0,
@@ -697,10 +707,15 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
(0.0, 1.0),
(1.0, 1.0),
] {
emit(dx, dy, call.outline);
emit(dx, dy, 0.0, call.outline);
}
}
emit(0.0, 0.0, call.color);
let z = if call.outline != 0 {
TEXT_OVER_OUTLINE
} else {
0.0
};
emit(0.0, 0.0, z, call.color);
}
/// Block-entity fields the level ticks, at one position (vanilla's own field
@@ -2258,6 +2273,69 @@ mod tests {
assert_eq!(out[2].uv, [0.5, 0.5]);
}
/// TextFeatureRenderer.renderText: an outlined call's text is drawn over
/// its outline (Font.DisplayMode.POLYGON_OFFSET), never level with it,
/// and plain text stays on the sign's plane.
#[test]
fn outlined_sign_text_sits_in_front_of_its_outline() {
let world = World::new(BlockStateId(0), 1);
let sprites = HashMap::new();
let glyph = |_: char| {
let rect = Rect {
u0: 0.0,
v0: 0.0,
u1: 0.0625,
v1: 0.0625,
w: 8,
h: 8,
};
(Some(rect), 6.0)
};
let scene = Scene {
camera: DVec3::ZERO,
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: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
};
let mut call = TextCall {
matrix: Mat4::IDENTITY,
x: 0.0,
y: 0.0,
text: "Hi".into(),
color: 0xFFFF_FF00,
outline: 0xFF33_3300,
light: 240 | 240 << 8,
};
let mut out = Vec::new();
draw_text(&scene, &call, &mut out);
// Eight outline copies, then the text, four corners a glyph.
assert_eq!(out.len(), 9 * 2 * 4);
let (outline, text) = out.split_at(8 * 2 * 4);
let front = outline.iter().map(|v| v.pos[2]).fold(f32::MIN, f32::max);
assert!(
text.iter().all(|v| v.pos[2] > front),
"text z {:?} not in front of the outline at {front}",
text.iter().map(|v| v.pos[2]).collect::<Vec<_>>()
);
call.outline = 0;
out.clear();
draw_text(&scene, &call, &mut out);
assert_eq!(out.len(), 2 * 4);
assert!(out.iter().all(|v| v.pos[2] == 0.0));
}
#[test]
fn cached_sign_text_matches_and_follows_changes() {
let renderer = BlockEntityRenderer {
@@ -386,4 +386,23 @@ mod tests {
stand.tick(&brewing, &items, &providers);
assert_eq!(stand.brew_time, 0);
}
/// #3312: `PotionContents.getPotionDescription` names the effects with
/// translatable components, so a Russian game reads them from ru_ru.json
/// (the prepared one; skipped without it), as the tooltip's other lines.
#[test]
fn effect_lines_read_the_selected_language() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("assets/minecraft/lang/ru_ru.json").exists() {
return;
}
let ru = crate::lang::Language::load(&[&root], "ru_ru");
let translate = |key: &str| ru.get(key).map(str::to_owned);
let lines = tooltip(Some("minecraft:turtle_master"), 0.125, &translate);
assert_eq!(lines.len(), 2);
assert!(lines[0].0.starts_with("Замедление IV ("), "{lines:?}");
assert!(lines[1].0.starts_with("Сопротивление III ("), "{lines:?}");
}
}
+66
View File
@@ -379,6 +379,22 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
}
fog
}
/// Horizon's Fog option turned off (`horizon-graphics.txt` `fog:false`):
/// no distance fog, in any dimension: the dimension's and biome's own fog
/// (`visual/fog_start_distance`, `fog_end_distance`: the Nether's ends at 96
/// blocks) and the render-distance fog. The fog of the fluid or powder snow
/// the camera is in, blindness, darkness and a boss's world fog keep their
/// limits. Vanilla has no such option.
pub fn clear_distance_fog(fog: &mut FogData, input: &FogInputs) {
if input.fog_type == FogType::Atmospheric
&& input.darkness.is_none()
&& !input.boss_world_fog
&& input.blindness.is_none()
{
(fog.render_distance_start, fog.render_distance_end) = (1e9, 2e9);
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
}
}
/// `AtmosphericFogEnvironment.updateRainFogState`.
pub fn update_rain_fog(
state: &mut FogState,
@@ -452,6 +468,56 @@ mod tests {
assert_eq!(water.environmental_start, -8.0);
}
#[test]
fn fog_off_clears_the_nether_fog_too() {
// The Nether's visual/fog_start_distance and fog_end_distance (10, 96)
// end before the render distance; the Fog option still clears them.
let nether = Sky {
fog_start: 10.0,
fog_end: 96.0,
..Sky::default()
};
let tables = FogTables {
water_fog_end: vec![1.0],
};
let mut input = inputs(&nether);
input.skybox = false;
let mut fog = setup_fog(&tables, &FogState::default(), &input);
assert_eq!(fog.environmental_end, 96.0);
clear_distance_fog(&mut fog, &input);
assert!(fog.environmental_start >= 1e9 && fog.render_distance_start >= 1e9);
}
#[test]
fn fog_off_keeps_fluid_and_effect_fog() {
let sky = Sky {
fog_start: 10.0,
fog_end: 96.0,
..Sky::default()
};
let tables = FogTables {
water_fog_end: vec![1.0],
};
let kept = |input: &FogInputs| {
let mut fog = setup_fog(&tables, &FogState::default(), input);
let before = fog;
clear_distance_fog(&mut fog, input);
fog == before
};
for fog_type in [FogType::Lava, FogType::PowderSnow, FogType::Water] {
let mut input = inputs(&sky);
input.fog_type = fog_type;
assert!(kept(&input), "{fog_type:?}");
}
let mut input = inputs(&sky);
input.blindness = Some((100, false));
assert!(kept(&input));
let mut input = inputs(&sky);
input.darkness = Some(0.5);
assert!(kept(&input));
let mut input = inputs(&sky);
input.boss_world_fog = true;
assert!(kept(&input));
}
#[test]
fn boss_darkening_dims_fog_color() {
// FogRenderer.computeFogColor: lerp toward 70/60/60% of each channel.
let sky = Sky {
+38 -5
View File
@@ -879,6 +879,14 @@ fn check_layers(limits: &wgpu::Limits, layers: u32) -> Result<()> {
}
Ok(())
}
/// Whether the swapchain can take a window of `width` x `height`: each side from 1 to the
/// device's `max_texture_dimension_2d` (`max`), else `Surface::configure` panics. 0 is a
/// minimized window. Past `max`: Windows lets a program size a window up to 65535 pixels
/// (`SetWindowPos`), a 984x65496 client area, when an RTX 5080 takes 32768. Vanilla's
/// `Window.onFramebufferResize` likewise keeps the last size for one that is not positive.
fn drawable_size(width: u32, height: u32, max: u32) -> bool {
(1..=max).contains(&width) && (1..=max).contains(&height)
}
/// The swapchain format: a non-sRGB target, because vanilla blends and tints in gamma
/// space, and 8-bit, because wgpu gives `Rgba16Float` the linear `ExtendedSrgb` color
/// space: the compositor would read these gamma-encoded values as linear light and wash
@@ -1126,9 +1134,14 @@ impl Renderer {
let info = adapter.get_info();
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
let size = window.inner_size();
let max_side = device.limits().max_texture_dimension_2d;
let (config, present_modes, hdr_output) = if let Some(surface) = &surface {
let mut config = surface
.get_default_config(&adapter, size.width.max(1), size.height.max(1))
.get_default_config(
&adapter,
size.width.clamp(1, max_side),
size.height.clamp(1, max_side),
)
.ok_or("surface has no supported configuration")?;
let caps = surface.get_capabilities(&adapter);
config.format = surface_format(&caps.formats, config.format);
@@ -1167,8 +1180,8 @@ impl Renderer {
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
format,
width: size.width.max(1),
height: size.height.max(1),
width: size.width.clamp(1, max_side),
height: size.height.clamp(1, max_side),
present_mode: wgpu::PresentMode::Immediate,
desired_maximum_frame_latency: 2,
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
@@ -2710,7 +2723,7 @@ impl Renderer {
hud,
menu_blur: None,
info,
suspended: size.width == 0 || size.height == 0,
suspended: !drawable_size(size.width, size.height, max_side),
drawn_sections: 0,
section_draws: 0,
section_draw_calls: 0,
@@ -2870,8 +2883,11 @@ impl Renderer {
);
}
}
/// Follows the window to `width` x `height`. A size the swapchain can't take
/// ([`drawable_size`]) keeps the last one and draws nothing until the next.
pub fn resize(&mut self, width: u32, height: u32) {
self.suspended = width == 0 || height == 0;
let max = self.device.limits().max_texture_dimension_2d;
self.suspended = !drawable_size(width, height, max);
if self.suspended {
return;
}
@@ -5553,6 +5569,23 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
#[cfg(test)]
mod tests {
#[test]
fn drawable_size_skips_minimized_and_oversized_windows() {
use super::drawable_size;
assert!(drawable_size(1280, 720, 8192));
assert!(drawable_size(1, 1, 8192));
assert!(drawable_size(8192, 8192, 8192));
// Minimized.
assert!(!drawable_size(0, 720, 8192));
assert!(!drawable_size(1280, 0, 8192));
assert!(!drawable_size(0, 0, 8192));
// One past the device's largest texture.
assert!(!drawable_size(8193, 720, 8192));
assert!(!drawable_size(1280, 8193, 8192));
// The crash: a window `SetWindowPos` made 65535 pixels tall, on an RTX 5080 (32768).
assert!(!drawable_size(984, 65496, 32768));
assert!(!drawable_size(984, u32::MAX, 32768));
}
#[test]
fn surface_is_8_bit_and_not_srgb() {
use super::surface_format;
+86
View File
@@ -1162,6 +1162,33 @@ impl HudBatch {
}
cursor - x
}
/// SubmitNodeCollection.submitNameTag's text in its own space (x right,
/// y down, in GUI pixels): centred at `-Font.width / 2`, drawn as
/// Font.prepareText draws any text (styles, fonts and glyph boxes; no
/// shadow), over `background` (RGBA) unless its alpha is 0. Returns how
/// many vertices at the start of the batch are the background.
pub fn name_tag(
&mut self,
atlas: &HudAtlas,
text: &crate::text::Text,
y: f32,
color: u32,
background: u32,
) -> usize {
let x = -atlas.font.width_text(text) / 2.0;
let start = self.vertices.len();
let advance = self.text_styled(atlas, text, x, y, color, false);
if background >> 24 == 0 {
return 0;
}
// PreparedTextBuilder.markBackground: one pixel round the advances,
// from a pixel above the line to 9 below its top.
let glyphs = self.vertices.split_off(start);
self.fill(atlas, x - 1.0, y - 1.0, advance + 1.0, 10.0, background);
let behind = self.vertices.len() - start;
self.vertices.extend(glyphs);
behind
}
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
@@ -1877,6 +1904,65 @@ mod tests {
assert!(missing.rect.is_none() && missing.right == 5.0);
}
#[test]
fn name_tags_draw_bold_text_and_tall_glyphs_like_any_text() {
// `ascii.png`'s 8x8 'a' (4 pixels wide) and `accented.png`'s 9x12
// 'й' (height 12, ascent 10: the breve above the line), 7 wide.
let root = std::env::temp_dir().join(format!("horizon-name-tag-{}", std::process::id()));
let textures = root.join("assets/minecraft/textures/font");
fs::create_dir_all(root.join("assets/minecraft/font")).unwrap();
fs::create_dir_all(&textures).unwrap();
fs::write(
root.join("assets/minecraft/font/default.json"),
r#"{"providers":[
{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["a"]},
{"type":"bitmap","file":"minecraft:font/tall.png","height":12,"ascent":10,"chars":["й"]}]}"#,
)
.unwrap();
for (name, (w, h), used) in [("a", (8, 8), 4), ("tall", (9, 12), 7)] {
let pixels: Vec<u8> = (0..w * h)
.flat_map(|i| [255, 255, 255, if i % w < used { 255 } else { 0 }])
.collect();
let file = fs::File::create(textures.join(format!("{name}.png"))).unwrap();
let mut encoder = png::Encoder::new(file, w, h);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&pixels).unwrap();
}
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let text = crate::text::Text::literal("aй").bold();
let mut batch = HudBatch::new([1, 1]);
let behind = batch.name_tag(&atlas, &text, 0.0, u32::MAX, 0x3F00_0000);
// Each quad's box: x0, y0, x1, y1.
let boxes: Vec<[f32; 4]> = batch
.vertices
.as_chunks::<6>()
.0
.iter()
.map(|q| [q[0].pos[0], q[0].pos[1], q[2].pos[0], q[2].pos[1]])
.collect();
// Advances 5 and 8, plus 1 each for bold: Font.width 15, centred.
let x = -7.5;
// Each glyph twice (bold), the copy a pixel right; 'й' in its 9x12
// box from 3 pixels above the line, each bold quad 0.1 larger.
let near = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-4);
let expected = [
[x - 0.1, -0.1, x + 8.1, 8.1],
[x + 0.9, -0.1, x + 9.1, 8.1],
[x + 5.9, -3.1, x + 15.1, 9.1],
[x + 6.9, -3.1, x + 16.1, 9.1],
];
for (got, want) in boxes[1..].iter().zip(expected) {
assert!(near(*got, want), "{got:?} != {want:?}");
}
assert_eq!(boxes.len(), 1 + expected.len());
// The background first: a pixel round the advances, 10 pixels tall.
assert_eq!(behind, 6);
assert_eq!(boxes[0], [x - 1.0, -1.0, x + 15.0, 9.0]);
}
#[test]
fn effect_icons_are_painted_from_the_mob_effect_directory() {
use crate::{effects::EffectInstance, hud_layout, hud_overlays, inventory_effects};
+58 -7
View File
@@ -25,6 +25,16 @@ const MAX_SAVED: usize = 1024;
/// (`Lod::observing`).
pub const OBSERVE_AHEAD: usize = 8;
const SAMPLE_JOBS: usize = 4;
/// Threads that sample far tiles beside the mesher. Machines with 12 or more
/// threads use up to 6 (measured: a cold 1024-chunk LoD loads about a quarter
/// sooner than with 3); the rest keep a quarter of their threads, at most 3.
fn sampling_threads(cores: usize) -> usize {
if cores >= 12 {
(cores / 2).min(6)
} else {
(cores / 4).clamp(1, 3)
}
}
/// While the near terrain loads on a machine with four threads or fewer, far
/// terrain starts one sample job at most this often and no detail: it would
/// otherwise take the cores the near chunks and the frame need.
@@ -550,6 +560,8 @@ pub struct Lod {
detail_request_at: std::time::Instant,
/// Four threads or fewer: far work waits for the near terrain (`hold`).
small: bool,
/// Sample jobs that may be queued or running at once.
sample_jobs: usize,
/// The near terrain is loading (`set_near_loading`).
near_loading: bool,
/// When `request_samples` last started a job while held.
@@ -570,6 +582,7 @@ pub struct Lod {
}
impl Lod {
pub fn new(source: Arc<dyn LodSource>, context: Arc<WorkerContext>) -> Self {
let cores = thread::available_parallelism().map_or(1, |n| n.get());
let shared = Arc::new(Shared {
jobs: Mutex::new(VecDeque::new()),
observed: Mutex::new(FxHashMap::default()),
@@ -583,10 +596,10 @@ impl Lod {
let handle = thread::Builder::new()
.name("Far terrain".into())
.spawn(move || {
// Ticks and frames come first (horizon_threads).
horizon_threads::behind_server();
let mut sampler = source.sampler();
let count = thread::available_parallelism()
.map_or(1, |n| n.get() / 4)
.clamp(1, 3);
let count = sampling_threads(cores);
let mut sampling = Vec::new();
for _ in 0..count {
let Some(mut sample) = sampler.fork() else {
@@ -598,6 +611,7 @@ impl Lod {
thread::Builder::new()
.name("Far sampling".into())
.spawn(move || {
horizon_threads::behind_server();
loop {
let key = {
let mut jobs = shared.jobs.lock().unwrap();
@@ -746,7 +760,8 @@ impl Lod {
detail_masks: FxHashMap::default(),
detail_request_pending: false,
detail_request_at: std::time::Instant::now(),
small: thread::available_parallelism().map_or(1, |n| n.get()) <= 4,
small: cores <= 4,
sample_jobs: SAMPLE_JOBS.max(sampling_threads(cores) + 1),
near_loading: false,
held_sample_at: std::time::Instant::now(),
ready: FxHashMap::default(),
@@ -1243,7 +1258,13 @@ impl Lod {
self.pending.is_empty() && self.held_sample_at.elapsed() >= HELD_SAMPLE_STEP,
)
} else {
SAMPLE_JOBS.saturating_sub(self.pending.len())
// The extra sampling threads wait until the near terrain is in.
let slots = if self.near_loading {
SAMPLE_JOBS
} else {
self.sample_jobs
};
slots.saturating_sub(self.pending.len())
};
if free == 0 {
return;
@@ -1291,8 +1312,8 @@ impl Lod {
}
// Only the first `free` keys not already pending are taken. A key is
// listed at most twice (as a root and as a node), so the best
// `2 * (SAMPLE_JOBS + pending)` entries hold them, in order.
let best = 2 * (SAMPLE_JOBS + self.pending.len());
// `2 * (sample_jobs + pending)` entries hold them, in order.
let best = 2 * (self.sample_jobs + self.pending.len());
if wanted.len() > best {
wanted.select_nth_unstable(best);
wanted.truncate(best);
@@ -2000,6 +2021,7 @@ mod tests {
lod.ready
.insert(parent, Arc::new(vec![Column::default(); 256]));
lod.active = [parent].into_iter().collect();
lod.sample_jobs = SAMPLE_JOBS;
lod.pending = (0..SAMPLE_JOBS - 1)
.map(|i| TileKey {
x: 999 + i as i32,
@@ -2058,6 +2080,7 @@ mod tests {
}
lod.active = [near, far].into_iter().collect();
lod.shared.jobs.lock().unwrap().clear();
lod.sample_jobs = SAMPLE_JOBS;
lod.pending = (0..SAMPLE_JOBS - 1)
.map(|i| TileKey {
x: 999 + i as i32,
@@ -2091,6 +2114,34 @@ mod tests {
(1..=32).map(|x| (x, 0)).collect::<Vec<_>>(),
"one unfinished fine tile must not starve neighbouring requests"
);
// The extra sampling threads' slots open once the near terrain is in.
lod.small = false;
lod.sample_jobs = SAMPLE_JOBS + 3;
lod.plan = Plan::new([-256, -256], 2, 64);
lod.ready.clear();
lod.active.clear();
lod.pending.clear();
lod.meshing = true;
lod.shared.jobs.lock().unwrap().clear();
let queued = |lod: &mut Lod, near_loading: bool| {
lod.set_near_loading(near_loading);
lod.shared.jobs.lock().unwrap().clear();
lod.pending.clear();
lod.update([-256., 64., -256.], 0., 2, 64, Some(FxHashSet::default()));
let jobs = lod.shared.jobs.lock().unwrap();
jobs.iter()
.filter(|job| matches!(job, Job::Sample(_)))
.count()
};
assert_eq!(queued(&mut lod, true), SAMPLE_JOBS);
assert_eq!(queued(&mut lod, false), SAMPLE_JOBS + 3);
Ok(())
}
#[test]
fn extra_sampling_threads_only_on_large_machines() {
assert_eq!([1, 2, 4, 8, 11].map(sampling_threads), [1, 1, 1, 2, 2]);
assert_eq!([12, 16, 64].map(sampling_threads), [6, 6, 6]);
}
}
@@ -1022,6 +1022,9 @@ pub enum Event {
/// Open the mod's own settings page (capability `ui`): the installed
/// mod's index in `mods.installed`.
ModSettings(usize),
/// Native mod `.0`'s page opened: the game asks the mod for its labels
/// again (they were made with the list, maybe in another language).
NativeSettings(usize),
/// Button `.1` of native mod `.0`'s page was pressed: the game tells the
/// mod and refreshes the page.
NativeButton(usize, usize),
@@ -2714,6 +2717,7 @@ impl OptionsUi {
events.push(Event::ButtonClick);
if s.mods.installed.get(i).is_some_and(|m| m.native.is_some()) {
self.push(Page::NativeMod(i));
events.push(Event::NativeSettings(i));
} else {
events.push(Event::ModSettings(i));
}
@@ -3332,6 +3336,50 @@ mod tests {
assert_eq!(ui.page(), Page::Main);
}
#[test]
fn opening_a_native_mods_page_asks_the_game_for_its_labels_again() {
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
let size = (480.0, 240.0);
s.mods.installed.push(crate::mod_list::Installed {
id: "native".into(),
title: "Native 1.0".into(),
description: String::new(),
problem: None,
dev: false,
has_settings: false,
native: Some(crate::mod_list::NativePage::default()),
});
ui.show(Page::Mods);
let (widgets, ..) = ui.layout(&s, size.0, size.1);
let settings = widgets
.iter()
.find(|w| {
matches!(
w.kind,
WKind::Button {
act: Act::ModSettings(0),
..
}
)
})
.expect("a native mod's row has a Settings button");
let events = ui.click(
&mut s,
&atlas,
settings.x + 1.0,
settings.y + 1.0,
size,
[480, 240],
false,
false,
);
// Its labels were made with the list, maybe in another language.
assert_eq!(events.last(), Some(&Event::NativeSettings(0)));
assert_eq!(ui.page(), Page::NativeMod(0));
}
#[test]
fn option_tooltip_keys_follow_value_dependent_descriptions() {
let mut s = Settings::default();
+66 -7
View File
@@ -380,11 +380,45 @@ fn link(target: &Path, at: &Path) -> io::Result<()> {
if target.is_dir() {
Ok(())
} else {
fs::copy(target, at).map(|_| ())
// The mirror only reads these files, and unix links them: a hardlink gives
// the same view without writing the 639 MB prepared root again, which was
// 33 s of a cold Windows launch with a pack on. Another volume (or a
// filesystem without hardlinks) still copies.
fs::hard_link(target, at).or_else(|_| fs::copy(target, at).map(|_| ()))
}
}
/// A directory junction, which Windows creates without privileges (a directory symlink needs
/// developer mode). The mirror falls back to copying the tree when it fails.
#[cfg(windows)]
fn junction(target: &Path, at: &Path) -> io::Result<()> {
use std::os::windows::process::CommandExt;
// cmd parses its own line: Rust quotes only paths with spaces, so `&` or `^` in an
// unquoted path would split or change the command. A Windows path cannot hold `"`.
let quoted = |path: &Path| -> io::Result<std::ffi::OsString> {
let mut quoted = std::ffi::OsString::from("\"");
quoted.push(std::path::absolute(path)?);
quoted.push("\"");
Ok(quoted)
};
let status = std::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.raw_arg(quoted(at)?)
.raw_arg(quoted(target)?)
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()?;
status
.success()
.then_some(())
.ok_or_else(|| io::Error::other("mklink /J failed"))
}
#[cfg(not(windows))]
fn junction(_: &Path, _: &Path) -> io::Result<()> {
Err(io::ErrorKind::Unsupported.into())
}
/// Build `out` as `vanilla` with the packs' files layered on top (packs in
/// load order, last wins). Untouched directories are symlinks into vanilla.
/// load order, last wins). Untouched directories are links into vanilla.
pub fn build_overlay(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<()> {
cache::build(vanilla, packs, out)
}
@@ -516,11 +550,17 @@ fn build_overlay_uncached(
}
}
}
} else if filters.is_empty()
&& !touched.contains(&child)
&& vanilla.join(&child).is_dir()
&& junction(&vanilla.join(&child), &out.join(&child)).is_ok()
{
// Linked whole, like the unix symlink: no file in it is visited or copied.
} else if touched.contains(&child)
// Windows cannot link a directory the way unix does (`link` above), so an
// untouched one has to be mirrored: skipping it hid textures/font and the
// title panorama from the overlay, and a font json that names those sheets
// then failed to load (#1688).
// Without a junction Windows cannot link a directory the way unix does (`link`
// above), so an untouched one has to be mirrored: skipping it hid textures/font
// and the title panorama from the overlay, and a font json that names those
// sheets then failed to load (#1688).
|| (vanilla.join(&child).is_dir() && (cfg!(not(unix)) || !filters.is_empty()))
{
mirror(
@@ -939,7 +979,8 @@ mod tests {
}
#[test]
fn overlay_layers_packs_over_vanilla() {
let base = std::env::temp_dir().join(format!("horizon-packs-{}", std::process::id()));
// `&` would split an unquoted `cmd /C mklink` line.
let base = std::env::temp_dir().join(format!("horizon-packs-&{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let vanilla = base.join("vanilla");
fs::create_dir_all(vanilla.join("assets/minecraft/textures/block")).unwrap();
@@ -960,6 +1001,13 @@ mod tests {
)
.unwrap();
fs::write(vanilla.join("facts.json"), b"[]").unwrap();
// A directory no pack touches (#1688: a font json names sheets in it).
fs::create_dir_all(vanilla.join("assets/minecraft/textures/font")).unwrap();
fs::write(
vanilla.join("assets/minecraft/textures/font/ascii.png"),
b"font",
)
.unwrap();
let pack_dir = base.join("packs/Mine");
fs::create_dir_all(pack_dir.join("assets/minecraft/textures/block")).unwrap();
fs::write(
@@ -997,6 +1045,17 @@ mod tests {
br#"{"a":"vanilla","b":"pack"}"#
);
assert_eq!(fs::read(out.join("facts.json")).unwrap(), b"[]");
assert_eq!(
fs::read(out.join("assets/minecraft/textures/font/ascii.png")).unwrap(),
b"font"
);
// Linked, not mirrored: a junction on Windows, a symlink on unix.
assert!(
fs::symlink_metadata(out.join("assets/minecraft/textures/font"))
.unwrap()
.file_type()
.is_symlink()
);
let _ = fs::remove_dir_all(base);
}
#[test]
@@ -8,8 +8,9 @@ use std::io::Read;
use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
// Bump when overlay/merge semantics change. Markers are disposable cache data.
// Version 4 mirrored every untouched directory file by file on Windows.
// Version 3 could omit untouched vanilla directories on Windows (#1649, #1688).
const VERSION: u32 = 4;
const VERSION: u32 = 5;
#[derive(Default, Serialize, Deserialize)]
pub(super) struct Dependencies {
+165
View File
@@ -527,6 +527,53 @@ impl Creative {
pub fn scroller_offset(&self) -> f32 {
((Self::SCROLL_HEIGHT - 17.0) * self.scroll) as i32 as f32
}
/// The tabs whose `CreativeModeTab.contains(stack)` holds, in
/// `CreativeModeTabs.tabs()` order, the search tab left out: the stack is
/// among the tab's search-tab items, by item and components. The export
/// lists each tab's own items; the search tab's stacks that no tab shows
/// (the enchanted books below their top level, `SEARCH_TAB_ONLY`) belong
/// to the tabs that show their item.
pub fn tabs_containing(&self, stack: &ItemStack) -> Vec<usize> {
let category = |t: &Tab| !t.search && !t.inventory;
let tabs_where = |shows: &dyn Fn(&ItemStack) -> bool| -> Vec<usize> {
(0..self.tabs.len())
.filter(|&i| category(&self.tabs[i]) && self.tabs[i].items.iter().any(shows))
.collect()
};
let found = tabs_where(&|s| same_components(s, stack));
if found.is_empty() && self.search_items.iter().any(|s| same_components(s, stack)) {
return tabs_where(&|s| s.item == stack.item);
}
found
}
/// The `#namespace:path` lines `getTooltipFromContainerItem` adds for a
/// search tab stack while the search is a `#` query: the item tags
/// `updateVisibleTags` keeps for the text after `#` (the path contains it;
/// with a colon, the namespace and the path each contain their part) that
/// the stack has.
pub fn search_tag_lines(&self, stack: &ItemStack) -> Vec<String> {
let Some(text) = self.search.value.strip_prefix('#') else {
return Vec::new();
};
let Some(entry) = self
.search_items
.iter()
.position(|s| same_components(s, stack))
.and_then(|i| self.search_index.get(i))
else {
return Vec::new();
};
let visible = |(ns, path): &(String, String)| match text.split_once(':') {
None => path.contains(text),
Some((n, p)) => ns.contains(java_trim(n)) && path.contains(java_trim(p)),
};
entry
.tags
.iter()
.filter(|t| visible(t))
.map(|(ns, path)| format!("#{ns}:{path}"))
.collect()
}
/// Tab button position relative to the screen origin (CreativeModeInventoryScreen.getTabX/Y).
pub fn tab_position(tab: &Tab) -> (f32, f32) {
let mut x = tab.column as f32 * 27.0;
@@ -543,6 +590,21 @@ impl Creative {
fn any<'a>(strings: impl IntoIterator<Item = &'a str>, text: &str) -> bool {
strings.into_iter().any(|s| s.contains(text))
}
/// `ItemStack.isSameItemSameComponents`, the count left out, as the
/// tabs' `ItemStackLinkedSet` compares: no components and empty ones match.
fn same_components(a: &ItemStack, b: &ItemStack) -> bool {
let none = crate::item_data::StackData::default();
a.item == b.item
&& a.damage == b.damage
&& a.data().unwrap_or(&none) == b.data().unwrap_or(&none)
}
/// `CreativeModeInventoryScreen.getTooltipFromContainerItem`: the plain
/// tooltip only for a category tab's own grid slots (`CustomCreativeSlot`);
/// everywhere else in the screen the tab names (and, on the search tab's
/// grid, the tag lines) follow the item's name.
pub fn tooltip_lists_tabs(category_tab: bool, grid_slot: bool) -> bool {
!(category_tab && grid_slot)
}
/// String.toLowerCase(Locale.ROOT).
fn java_lower(s: &str) -> String {
s.to_lowercase()
@@ -690,6 +752,109 @@ mod tests {
(carried, effect)
}
/// Tabs as the export lists them: two block tabs sharing the oak log,
/// ingredients with the top-level book only, the search tab with every
/// stack (a level 1 book only there) and its tags, the inventory.
fn tooltip_tabs() -> Creative {
let book = |level: u8| {
format!(
r#"{{components:{{"minecraft:stored_enchantments":{{"minecraft:sharpness":{level}}}}},count:1,id:"minecraft:enchanted_book"}}"#
)
};
let log = r#"{count:1,id:"minecraft:oak_log"}"#;
let dirt = r#"{count:1,id:"minecraft:dirt"}"#;
let tab = |id: &str, kind: &str, items: Vec<String>| serde_json::json!({"id": id, "type": kind, "title": id, "icon": log, "items": items});
let mut search = tab(
"minecraft:search",
"SEARCH",
vec![log.into(), dirt.into(), book(1), book(5)],
);
search["lines"] = serde_json::json!([[], [], [], []]);
search["tags"] = serde_json::json!([
[
"minecraft:logs",
"minecraft:oak_logs",
"minecraft:logs_that_burn"
],
["minecraft:dirt"],
[],
[]
]);
Creative::from_json(&serde_json::json!({"tabs": [
tab("minecraft:building_blocks", "CATEGORY", vec![log.into()]),
tab("minecraft:natural_blocks", "CATEGORY", vec![log.into(), dirt.into()]),
tab("minecraft:hotbar", "HOTBAR", vec![]),
search,
tab("minecraft:ingredients", "CATEGORY", vec![book(5)]),
tab("minecraft:inventory", "INVENTORY", vec![]),
]}))
.unwrap()
}
#[test]
fn tooltip_lists_the_tabs_that_contain_the_stack() {
let c = tooltip_tabs();
let ids = |s: &ItemStack| -> Vec<&str> {
c.tabs_containing(s)
.into_iter()
.map(|i| c.tabs[i].id.as_str())
.collect()
};
// In tab order, the search and inventory tabs left out; the count
// doesn't matter.
assert_eq!(
ids(&stack("oak_log", 37)),
["minecraft:building_blocks", "minecraft:natural_blocks"]
);
assert_eq!(ids(&stack("dirt", 1)), ["minecraft:natural_blocks"]);
// A stack no tab holds: other components, or no creative item.
let mut damaged = stack("oak_log", 1);
damaged.damage = 3;
assert!(ids(&damaged).is_empty());
assert!(ids(&stack("barrier", 1)).is_empty());
// A level 1 book is a search-tab-only stack of the ingredients tab.
let books: Vec<&ItemStack> = c
.search_items()
.iter()
.filter(|s| &*s.item == "minecraft:enchanted_book")
.collect();
assert_eq!(ids(books[0]), ["minecraft:ingredients"]);
assert_eq!(ids(books[1]), ["minecraft:ingredients"]);
}
#[test]
fn tooltip_tab_names_skip_only_a_category_tab_grid() {
assert!(!tooltip_lists_tabs(true, true));
// The hotbar row of a category tab, the search tab's grid and hotbar,
// the inventory tab.
assert!(tooltip_lists_tabs(true, false));
assert!(tooltip_lists_tabs(false, true));
assert!(tooltip_lists_tabs(false, false));
}
#[test]
fn tag_search_lists_the_matching_tags_of_the_stack() {
let mut c = tooltip_tabs();
let log = stack("oak_log", 1);
assert!(c.search_tag_lines(&log).is_empty());
c.search.set_value("logs");
assert!(c.search_tag_lines(&log).is_empty());
c.search.set_value("#logs");
assert_eq!(
c.search_tag_lines(&log),
[
"#minecraft:logs",
"#minecraft:oak_logs",
"#minecraft:logs_that_burn"
]
);
c.search.set_value("#oak");
assert_eq!(c.search_tag_lines(&log), ["#minecraft:oak_logs"]);
c.search.set_value("#mine : burn");
assert_eq!(c.search_tag_lines(&log), ["#minecraft:logs_that_burn"]);
assert!(c.search_tag_lines(&stack("dirt", 1)).is_empty());
}
#[test]
fn search_tab_number_keys_swap_over_a_slot_before_typing() {
let filled = Hover::Display { filled: true };
+28
View File
@@ -152,6 +152,14 @@ impl DebugCharts {
}
self.packet_tick = (self.packet_tick + 1) % 20;
}
/// Whether `draw` (or the lightmap view) puts something in the bottom
/// left and the bottom right corner, for a `remote` server or not.
pub fn corners(&self, remote: bool) -> [bool; 2] {
[
self.fps || (self.network && remote),
(self.fps && !self.tick_times.is_empty()) || self.network || self.lightmap,
]
}
/// The charts along the bottom of the screen (`extractRenderState`):
/// frame times left and tick times right, or bandwidth left (remote
/// servers only) and ping right. `mspt` is the level's milliseconds
@@ -525,6 +533,26 @@ mod tests {
assert!(overlay && c.fps);
}
#[test]
fn corners_are_where_the_charts_are_drawn() {
let mut c = DebugCharts::default();
assert_eq!(c.corners(true), [false, false]);
// Frame times left; tick times right once the server logged a tick.
c.fps = true;
assert_eq!(c.corners(false), [true, false]);
c.tick_times.log(1);
assert_eq!(c.corners(false), [true, true]);
// Ping right; bandwidth left on a remote server only.
c.fps = false;
c.network = true;
assert_eq!(c.corners(false), [false, true]);
assert_eq!(c.corners(true), [true, true]);
// The lightmap view, bottom right.
c.network = false;
c.lightmap = true;
assert_eq!(c.corners(true), [false, true]);
}
#[test]
fn the_lightmap_puts_full_sky_light_at_the_top_left() {
let mut pixels = [0u8; 16 * 16 * 4];
+13
View File
@@ -953,7 +953,20 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
continue;
};
let v = f64::from_bits(*bits);
// A mount's rolled health and speed stay attribute bases, which
// the server syncs (its hearts over the rider's hotbar).
let equine = crate::mobs::equine::is_equine(m.species());
match id.as_str() {
"minecraft:max_health" if equine => crate::mobs::finalize::set_base(
&mut m,
crate::mobs::finalize::Attr::MaxHealth,
v,
),
"minecraft:movement_speed" if equine => crate::mobs::finalize::set_base(
&mut m,
crate::mobs::finalize::Attr::MovementSpeed,
v,
),
"minecraft:max_health" => m.attrs.max_health = v as f32,
"minecraft:movement_speed" => m.attrs.speed = v,
"minecraft:attack_damage" => m.attrs.attack_damage = v as f32,
+169 -1
View File
@@ -12,6 +12,8 @@
#[derive(Clone, Copy, Debug)]
pub enum ItemPose {
Swing,
/// A STAB swing (`SwingAnimationType` 2): a spear's jab.
Stab,
Using,
Eat {
remaining: f32,
@@ -86,6 +88,9 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
return Mat4::from_translation(Vec3::new(invert * 0.56, -0.52, -0.72))
* crate::spear::first_person_use(&kinetic, ticks_since_hit, time_held, !left);
}
if matches!(pose, ItemPose::Stab) {
return m * crate::spear::first_person_attack(attack, !left);
}
if matches!(pose, ItemPose::SpinAttack) {
return m
* Mat4::from_translation(Vec3::new(invert * -0.4, 0.8, 0.3))
@@ -172,8 +177,94 @@ impl MapHold {
}
}
/// `renderPlayerArm` up to `renderPlayerHand`: the pose of the bare `left`
/// or right arm.
pub fn player_arm_pose(left: bool, inverse_height: f32, attack: f32) -> glam::Mat4 {
use glam::{Mat4, Vec3};
use horizon_core::geometry::trig::Mth;
let f = if left { -1.0 } else { 1.0 };
let pi = std::f32::consts::PI;
let root = attack.sqrt();
let x = -0.3 * (root * pi).msin();
let y = 0.4 * (root * std::f32::consts::TAU).msin();
let z = -0.4 * (attack * pi).msin();
let z_rot = (attack * attack * pi).msin();
let y_rot = (root * pi).msin();
let deg = |d: f32| d.to_radians();
Mat4::from_translation(Vec3::new(
f * (x + 0.640_000_05),
y - 0.6 + inverse_height * -0.6,
z + -0.719_999_97,
)) * Mat4::from_rotation_y(deg(f * 45.0))
* Mat4::from_rotation_y(deg(f * y_rot * 70.0))
* Mat4::from_rotation_z(deg(f * z_rot * -20.0))
* Mat4::from_translation(Vec3::new(-f, 3.6, 3.5))
* Mat4::from_rotation_z(deg(f * 120.0))
* Mat4::from_rotation_x(deg(200.0))
* Mat4::from_rotation_y(deg(f * -135.0))
* Mat4::from_translation(Vec3::new(f * 5.6, 0.0, 0.0))
}
/// `renderTwoHandedMap` up to its arms: the swing, the tilt from the view
/// pitch (`calculateMapTilt`) and the turn towards the player.
pub fn two_handed_map_base(pitch: f32, inverse_height: f32, attack: f32) -> glam::Mat4 {
use glam::{Mat4, Vec3};
use horizon_core::geometry::trig::Mth;
let pi = std::f32::consts::PI;
let y_swing = -0.2 * (attack * pi).msin();
let z_swing = -0.4 * (attack.sqrt() * pi).msin();
let tilt = (1.0 - pitch / 45.0 + 0.1).clamp(0.0, 1.0);
let tilt = -(tilt * pi).mcos() * 0.5 + 0.5;
Mat4::from_translation(Vec3::new(0.0, -y_swing / 2.0, z_swing))
* Mat4::from_translation(Vec3::new(
0.0,
0.04 + inverse_height * -1.2 + tilt * -0.5,
-0.72,
))
* Mat4::from_rotation_x((tilt * -85.0).to_radians())
}
/// The arms under a held map, as (left arm, pose before `renderPlayerHand`):
/// `renderOneHandedMap`'s arm turned 10 degrees outwards, or
/// `renderTwoHandedMap`'s two `renderMapHand` arms under the map's
/// `two_handed_map_base`. Vanilla skips them for an invisible player.
pub fn map_arm_poses(
hold: MapHold,
left: bool,
pitch: f32,
inverse_height: f32,
attack: f32,
) -> Vec<(bool, glam::Mat4)> {
use glam::{Mat4, Vec3};
let deg = |d: f32| d.to_radians();
match hold {
MapHold::OneHanded => {
let f = if left { -1.0 } else { 1.0 };
let m = Mat4::from_translation(Vec3::new(f * 0.125, -0.125, 0.0))
* Mat4::from_rotation_z(deg(f * 10.0))
* player_arm_pose(left, inverse_height, attack);
vec![(left, m)]
}
MapHold::TwoHanded => {
let base = two_handed_map_base(pitch, inverse_height, attack)
* Mat4::from_rotation_y(deg(90.0));
[false, true]
.into_iter()
.map(|left| {
let f = if left { -1.0 } else { 1.0 };
let hand = Mat4::from_rotation_y(deg(92.0))
* Mat4::from_rotation_x(deg(45.0))
* Mat4::from_rotation_z(deg(f * -41.0))
* Mat4::from_translation(Vec3::new(f * 0.3, -1.1, 0.45));
(left, base * hand)
})
.collect()
}
}
}
/// `renderOneHandedMap` up to `renderMap`: the pose of a map in the hand on
/// the `left` or right arm (the arm under it is not drawn).
/// the `left` or right arm (its arm: [`map_arm_poses`]).
pub fn one_handed_map_pose(left: bool, inverse_height: f32, attack: f32) -> glam::Mat4 {
use glam::{Mat4, Vec3};
use horizon_core::geometry::trig::Mth;
@@ -945,6 +1036,83 @@ mod item_pose_tests {
}
}
#[test]
fn maps_draw_the_arms_under_them_as_vanilla() {
// PoseStack.last().pose() (column-major) where renderOneHandedMap's
// renderPlayerArm and renderTwoHandedMap's renderMapHand call
// renderPlayerHand, replayed with vanilla's PoseStack, Axis and Mth.
let near = |got: glam::Mat4, want: [f32; 16], what: &str| {
let got = got.to_cols_array();
for (g, w) in got.iter().zip(want) {
assert!((g - w).abs() < 1e-5, "{what}: {got:?}");
}
};
// (left, inverse height, attack, arm)
#[rustfmt::skip]
let one: [(bool, f32, f32, [f32; 16]); 3] = [
(false, 0.0, 0.0, [
-0.2350201, -0.7860474, -0.5717472, 0.0,
0.24694103, 0.52063686, -0.8172866, 0.0,
0.9400987, -0.33326668, 0.07174723, 0.0,
0.65913033, -1.1543381, -0.73980355, 1.0,
]),
(true, 0.25, 0.3, [
-0.4967606, 0.84289914, -0.20676011, 0.0,
0.7202682, 0.2674863, -0.6400506, 0.0,
-0.4841926, -0.46687466, -0.7399901, 0.0,
-0.59715486, -1.4160433, -0.7328731, 1.0,
]),
(false, 0.6, 0.85, [
-0.47358897, -0.8403256, -0.2637542, 0.0,
0.081554025, 0.25634, -0.96313995, 0.0,
0.87696207, -0.47764266, -0.052867785, 0.0,
0.61887383, -1.6482887, -0.7229744, 1.0,
]),
];
for (left, inverse_height, attack, want) in one {
let arms = map_arm_poses(MapHold::OneHanded, left, 0.0, inverse_height, attack);
assert_eq!(arms.len(), 1, "one arm under a one-handed map");
assert_eq!(arms[0].0, left, "the arm of the hand holding it");
near(
arms[0].1,
want,
&format!("one {left} {inverse_height} {attack}"),
);
}
// (pitch, inverse height, attack, right arm, left arm)
#[rustfmt::skip]
let two: [(f32, f32, f32, [f32; 16], [f32; 16]); 2] = [
(0.0, 0.0, 0.0, [
-0.7380599, 0.44766384, 0.50484115, 0.0,
-0.67428374, -0.46198538, -0.57611704, 0.0,
-0.024677532, -0.76561517, 0.64282537, 0.0,
0.5091893, -0.16204369, 0.3544525, 1.0,
], [
-0.77043974, -0.3951866, -0.50025004, 0.0,
0.637035, -0.5076031, -0.58010805, 0.0,
-0.024677532, -0.76561517, 0.64282537, 0.0,
-0.48071152, -0.12760738, 0.35746527, 1.0,
]),
(30.0, 0.2, 0.5, [
-0.7380599, -0.11453738, 0.6649426, 0.0,
-0.67428374, 0.16124588, -0.72065324, 0.0,
-0.024677532, -0.98024535, -0.1962398, 0.0,
0.5091893, -0.9508533, -0.13439482, 1.0,
], [
-0.77043974, 0.14373614, -0.6210978, 0.0,
0.637035, 0.13586378, -0.75876695, 0.0,
-0.024677532, -0.98024535, -0.1962398, 0.0,
-0.48071152, -0.9316926, -0.1056232, 1.0,
]),
];
for (pitch, inverse_height, attack, right, left) in two {
let arms = map_arm_poses(MapHold::TwoHanded, false, pitch, inverse_height, attack);
assert_eq!(arms.len(), 2, "both arms under a two-handed map");
assert_eq!((arms[0].0, arms[1].0), (false, true), "right, then left");
near(arms[0].1, right, &format!("two right {pitch}"));
near(arms[1].1, left, &format!("two left {pitch}"));
}
}
#[test]
fn ranged_draw_uses_native_charge_curves_and_caps() {
for (ticks, stretch) in [
(0.0, 1.0),
+147 -94
View File
@@ -1200,9 +1200,10 @@ struct WorldRest {
nether_environment: Environment,
end_environment: Environment,
behaviours: Behaviours,
player_arm: Option<horizon_client::mobs::Part>,
/// The slim model's right arm (3 pixels wide).
player_arm_slim: Option<horizon_client::mobs::Part>,
/// The player model's right and left arms.
player_arm: [Option<horizon_client::mobs::Part>; 2],
/// The slim model's right and left arms (3 pixels wide).
player_arm_slim: [Option<horizon_client::mobs::Part>; 2],
player_type: Option<Arc<horizon_client::mobs::MobType>>,
/// `SpinAttackEffectModel` (Riptide's spinning boxes).
spin_attack: Option<horizon_client::mobs::Part>,
@@ -1275,12 +1276,13 @@ impl Resources {
fn threads(&self) -> usize {
self.core.threads
}
fn player_arm(&self, slim: bool) -> &Option<horizon_client::mobs::Part> {
if slim {
fn player_arm(&self, slim: bool, left: bool) -> &Option<horizon_client::mobs::Part> {
let arms = if slim {
&self.rest().player_arm_slim
} else {
&self.rest().player_arm
}
};
&arms[usize::from(left)]
}
fn player_type(&self) -> &Option<Arc<horizon_client::mobs::MobType>> {
&self.rest().player_type
@@ -1384,6 +1386,23 @@ fn ticking_chunk(world: &World, x: i32, z: i32) -> bool {
world.chunks.get(&(x, z)).is_some_and(|c| c.lit)
&& (-1..=1).all(|dz| (-1..=1).all(|dx| world.chunks.contains_key(&(x + dx, z + dz))))
}
/// Key events carry the scancode's US-layout code, which ignores XKB options like
/// `caps:swapescape`. For the keys whose meaning the keymap may remap, take the code
/// from the keysym the keymap resolved instead.
fn follow_keymap(event: &mut WindowEvent) {
use winit::keyboard::{Key, NamedKey};
if let WindowEvent::KeyboardInput { event, .. } = event
&& let Key::Named(named) = &event.logical_key
{
let code = match named {
NamedKey::Escape => KeyCode::Escape,
NamedKey::CapsLock => KeyCode::CapsLock,
_ => return,
};
event.physical_key = PhysicalKey::Code(code);
}
}
fn drop_whole_stack(physical_keys: &HashSet<KeyCode>) -> bool {
physical_keys.contains(&KeyCode::ControlLeft) || physical_keys.contains(&KeyCode::ControlRight)
}
@@ -1432,6 +1451,9 @@ struct Game<'a> {
lod: Option<horizon_client::lod::Lod>,
lod_source_attempted: bool,
lod_observe: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
/// Beacon beam sections kept after their chunk unloads, so far terrain
/// LOD still draws their beams (`beacon_geometry` refreshes them).
far_beacons: HashMap<(i32, i32, i32), Vec<(u32, i32)>>,
/// The detail revision, centre and radius `Lod::update` last had covered for.
lod_covered_at: Option<(u64, (i32, i32), i32)>,
options: Options,
@@ -8091,7 +8113,7 @@ impl Game<'_> {
for m in &self.mobs {
// Blaze.isOnFire: a charged blaze burns.
let blazing = m.species() == horizon_client::mobs::Species::Blaze && m.extra.flag;
if (m.fire_ticks > 0 || blazing) && !m.removed {
if (m.is_on_fire() || blazing) && !m.removed {
let at = m.previous.lerp(m.position, alpha);
horizon_client::fire_render::flames(
[at.x, at.y, at.z],
@@ -8223,11 +8245,8 @@ impl Game<'_> {
p.z + (q.z - p.z) * a,
)
};
let mut casters: Vec<(glam::DVec3, &str)> = self
.mobs
.iter()
.map(|m| (lerp(m.previous, m.position), m.kind.id.as_str()))
.collect();
let mut casters: Vec<(glam::DVec3, &str)> =
horizon_client::shadows::mob_casters(&self.mobs, a).collect();
casters.extend(
self.dropped
.iter()
@@ -10644,6 +10663,36 @@ impl Game<'_> {
}
self.damage_held(1);
}
/// `renderPlayerHand` (`AvatarRenderer.renderHand`): the player's own
/// `left` or right arm with its skin at pose `m`, in its rest pose
/// turned 0.1 radians outwards.
fn first_person_arm(&self, left: bool, m: glam::Mat4, light: u32, out: &mut Vec<Vertex>) {
let slim = self.player_model.as_ref().is_some_and(|m| m.slim);
let Some(arm) = self.res.player_arm(slim, left).as_ref() else {
return;
};
// The player's own skin, as the inventory's model shows it.
let sprites = &self.gui.atlas.sprites;
let Some(r) = self
.player_model
.as_ref()
.and_then(|m| m.skin.as_deref())
.and_then(|skin| sprites.get(skin))
.or_else(|| sprites.get("entity/player/wide/steve"))
.copied()
else {
return;
};
let z_rot = if left { -0.1 } else { 0.1 };
let pose = |p: &horizon_client::mobs::Part| {
let mut pose = p.pose;
pose[3] = 0.0;
pose[4] = 0.0;
pose[5] = if p.name == arm.name { z_rot } else { 0.0 };
pose
};
horizon_client::mobs::render_model(arm, m, &pose, [r.u0, r.v0, r.u1, r.v1], light, 0, out);
}
/// FirstPersonHandsAndItemsRenderer: the right arm or the held items of
/// both hands in view space, converted to camera-relative world space.
fn hand_geometry(&mut self, alpha: f64) -> Vec<Vertex> {
@@ -10687,7 +10736,6 @@ impl Game<'_> {
eye[2].floor() as i32,
);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
let deg = |d: f32| d.to_radians();
let shown = self.hands_to_render();
let used_hand = self.used_hand();
let spinning = self
@@ -10701,7 +10749,6 @@ impl Game<'_> {
continue;
}
let (attack, inverse_height) = (attacks[i], inverse_heights[i]);
let sqrt_attack = attack.sqrt();
// The main hand on its arm, the off hand on the other one.
let left = self.settings.main_hand_left != (hand == Hand::Off);
let Some(stack) = item else {
@@ -10709,63 +10756,24 @@ impl Game<'_> {
if hand == Hand::Off {
continue;
}
let slim = self.player_model.as_ref().is_some_and(|m| m.slim);
let Some(arm) = self.res.player_arm(slim).clone() else {
continue;
};
// The player's own skin, as the inventory's model shows it.
let sprites = &self.gui.atlas.sprites;
let Some(r) = self
.player_model
.as_ref()
.and_then(|m| m.skin.as_deref())
.and_then(|skin| sprites.get(skin))
.or_else(|| sprites.get("entity/player/wide/steve"))
.copied()
else {
continue;
};
// renderPlayerArm (right arm)
let x_swing = -0.3 * (sqrt_attack * std::f32::consts::PI).sin();
let y_swing = 0.4 * (sqrt_attack * std::f32::consts::TAU).sin();
let z_swing = -0.4 * (attack * std::f32::consts::PI).sin();
let z_rot = (attack * attack * std::f32::consts::PI).sin();
let y_rot = (sqrt_attack * std::f32::consts::PI).sin();
let m = to_world
* glam::Mat4::from_translation(glam::Vec3::new(
x_swing + 0.64,
y_swing - 0.6 + inverse_height * -0.6,
z_swing - 0.72,
))
* glam::Mat4::from_rotation_y(deg(45.0))
* glam::Mat4::from_rotation_y(deg(y_rot * 70.0))
* glam::Mat4::from_rotation_z(deg(z_rot * -20.0))
* glam::Mat4::from_translation(glam::Vec3::new(-1.0, 3.6, 3.5))
* glam::Mat4::from_rotation_z(deg(120.0))
* glam::Mat4::from_rotation_x(deg(200.0))
* glam::Mat4::from_rotation_y(deg(-135.0))
* glam::Mat4::from_translation(glam::Vec3::new(5.6, 0.0, 0.0));
let pose = |p: &horizon_client::mobs::Part| {
let mut pose = p.pose;
pose[3] = 0.0;
pose[4] = 0.0;
pose[5] = 0.0;
pose
};
horizon_client::mobs::render_model(
&arm,
m,
&pose,
[r.u0, r.v0, r.u1, r.v1],
packed,
0,
&mut out,
);
* horizon_client::first_person::player_arm_pose(false, inverse_height, attack);
self.first_person_arm(false, m, packed, &mut out);
continue;
};
if let Some(id) = maps::map_id(&stack) {
use horizon_client::first_person::{MapHold, one_handed_map_pose};
match MapHold::of(hand, off_hand_empty) {
use horizon_client::first_person::{MapHold, map_arm_poses, one_handed_map_pose};
let hold = MapHold::of(hand, off_hand_empty);
// renderOneHandedMap and renderTwoHandedMap: the arms under
// the map, unless the player is invisible.
if self.potions.effects.get("invisibility").is_none() {
for (arm, pose) in map_arm_poses(hold, left, self.pitch, inverse_height, attack)
{
self.first_person_arm(arm, to_world * pose, packed, &mut out);
}
}
match hold {
MapHold::TwoHanded => self.two_handed_map_geometry(
to_world,
attack,
@@ -10846,6 +10854,14 @@ impl Game<'_> {
}
}
_ if spinning => ItemPose::SpinAttack,
// ItemInHandRenderer: a STAB swing of this hand jabs.
_ if self
.swing
.current
.is_some_and(|c| c.hand == hand && c.animation.kind == 2) =>
{
ItemPose::Stab
}
_ => ItemPose::Swing,
};
let m = to_world
@@ -11286,6 +11302,38 @@ impl Game<'_> {
batch.fill(atlas, x - 3.0, y + 10.0, w + 6.0, 1.0, rgba(40, 0, 127, 80));
batch.text(atlas, text, x, y, u32::MAX, true);
}
/// `CreativeModeInventoryScreen.getTooltipFromContainerItem`: the lines
/// that follow the item's name, none for a category tab's own grid slots
/// (`grid`): the names of the tabs containing the stack, in blue, then on
/// the search tab's grid the tags a `#` search shows, in dark purple.
fn creative_tooltip_tabs(
&self,
stack: &ItemStack,
grid: bool,
) -> Vec<(horizon_client::text::Text, u32)> {
let screen = &self.creative_screen;
let tab = screen.tab();
let category = !tab.search && !tab.inventory;
if !horizon_client::creative::tooltip_lists_tabs(category, grid) {
return Vec::new();
}
let blue = rgba(85, 85, 255, 255);
let dark_purple = rgba(170, 0, 170, 255);
let mut lines: Vec<(horizon_client::text::Text, u32)> = screen
.tabs_containing(stack)
.into_iter()
.map(|i| {
let title = self.creative_tab_title(&screen.tabs[i]);
(horizon_client::text::Text::literal(title), blue)
})
.collect();
if tab.search && grid {
for tag in screen.search_tag_lines(stack) {
lines.push((horizon_client::text::Text::literal(tag), dark_purple));
}
}
lines
}
fn draw_creative(&mut self, batch: &mut HudBatch) {
let (ox, oy) = Self::creative_origin(batch);
let selected = self.creative_screen.selected;
@@ -11368,7 +11416,8 @@ impl Game<'_> {
search.max_length = clipboard::CREATIVE_SEARCH_CHARS;
search.draw(batch, atlas, None);
}
let mut hovered: Option<ItemStack> = None;
// The hovered stack, and whether it is a grid slot (`CustomCreativeSlot`).
let mut hovered: Option<(ItemStack, bool)> = None;
if tab.inventory {
self.draw_player_preview(
batch,
@@ -11418,7 +11467,7 @@ impl Game<'_> {
&& self.cursor.1 < y + 16.0
{
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
hovered = Some(stack.clone());
hovered = Some((stack.clone(), true));
}
}
}
@@ -11432,7 +11481,7 @@ impl Game<'_> {
&& self.cursor.1 < y + 16.0
{
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
hovered = self.inventory[c].clone();
hovered = self.inventory[c].clone().map(|s| (s, false));
}
}
let scrollable = self.creative_screen.rows(&self.pack.names) > 5;
@@ -11467,13 +11516,18 @@ impl Game<'_> {
if tab.inventory {
if Self::creative_trash_hovered(batch, self.cursor) {
self.tooltip(batch, "Destroy Item", self.cursor.0, self.cursor.1);
} else {
self.draw_slot_tooltip(batch);
} else if self.carried.is_none()
&& let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1)
&& let Some(stack) = self.slot_view(slot)
{
let tabs = self.creative_tooltip_tabs(&stack, false);
self.draw_stack_tooltip_with(batch, &stack, self.cursor.0, self.cursor.1, tabs);
}
} else if self.carried.is_none()
&& let Some(item) = hovered
&& let Some((item, grid)) = hovered
{
self.draw_stack_tooltip(batch, &item, self.cursor.0, self.cursor.1);
let tabs = self.creative_tooltip_tabs(&item, grid);
self.draw_stack_tooltip_with(batch, &item, self.cursor.0, self.cursor.1, tabs);
}
self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32);
}
@@ -12089,21 +12143,7 @@ impl Game<'_> {
environment_fog: (self.dim == Dim::Nether).then_some((sky.fog_start, sky.fog_end)),
cave_culling,
water_fog,
fog: Some({
let mut fog = self.frame_fog(camera, &sky, biome, water_fog);
// Horizon's fog setting: no distance fog (fluids keep theirs).
if !self.settings.horizon.fog
&& !underwater
&& self.darkness_fog().is_none()
&& !self.boss_overlay.should_create_world_fog()
&& self.potions.effects.get("blindness").is_none()
&& fog.environmental_end >= (self.streaming.radius * 16) as f32
{
(fog.render_distance_start, fog.render_distance_end) = (1e9, 2e9);
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
}
fog
}),
fog: Some(self.frame_fog(camera, &sky, biome, water_fog)),
dark_disc: self.dark_disc(camera),
outline,
outline_after_terrain,
@@ -12587,6 +12627,7 @@ impl Game<'_> {
self.lod = None;
self.lod_source_attempted = false;
self.lod_observe.clear();
self.far_beacons.clear();
self.lod_covered_at = None;
if let Some(renderer) = &mut self.renderer {
renderer.clear_lods();
@@ -12836,10 +12877,11 @@ impl ApplicationHandler for Game<'_> {
self.apply_settings();
self.previous = Instant::now();
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, mut event: WindowEvent) {
if self.window.as_ref().is_none_or(|w| w.id() != id) {
return;
}
follow_keymap(&mut event);
if let WindowEvent::KeyboardInput { event, .. } = &event
&& let PhysicalKey::Code(key) = event.physical_key
{
@@ -13955,6 +13997,7 @@ impl<'a> Game<'a> {
streaming,
lod: None,
lod_source_attempted: false,
far_beacons: HashMap::new(),
lod_observe: Default::default(),
lod_covered_at: None,
options,
@@ -14883,10 +14926,8 @@ fn load_world_rest(
.collect(),
mob_data,
player_type: perspective::player_type(root),
player_arm: horizon_client::mobs::load_model(root, "minecraft:player#main")
.and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")),
player_arm_slim: horizon_client::mobs::load_model(root, "minecraft:player_slim#main")
.and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")),
player_arm: player_arms(root, "minecraft:player#main"),
player_arm_slim: player_arms(root, "minecraft:player_slim#main"),
spin_attack: horizon_client::mobs::load_model(root, "minecraft:spin_attack#main"),
})
}
@@ -15334,10 +15375,11 @@ impl Loading {
horizon_client::hud::GUI_SCALE.store(self.settings.gui_scale, Ordering::Relaxed);
self.window = Some(window);
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, mut event: WindowEvent) {
if self.window.as_ref().is_none_or(|w| w.id() != id) {
return;
}
follow_keymap(&mut event);
if let Err(error) = self.menu_event(event_loop, event) {
self.error = Some(error);
event_loop.exit();
@@ -15732,6 +15774,17 @@ pub fn run() -> Result<()> {
Ok(())
}
/// The right and left arms of a player model layer (`PlayerModel.rightArm`
/// and `leftArm`, with their sleeves).
fn player_arms(root: &std::path::Path, key: &str) -> [Option<horizon_client::mobs::Part>; 2] {
let model = horizon_client::mobs::load_model(root, key);
["right_arm", "left_arm"].map(|name| {
model
.as_ref()
.and_then(|m| m.children.iter().find(|c| c.name == name).cloned())
})
}
// Player.tick: cap the sleeping fade at 100, then fade out over 10 waking ticks.
fn tick_sleep_timer(timer: u32, sleeping: bool) -> u32 {
if sleeping {
+11 -4
View File
@@ -588,10 +588,17 @@ impl Game<'_> {
.announced
.push(Text::translate(key, &[player, name]));
}
None => self.system_message(Text::translate(
key,
&[Text::literal(super::chat::PLAYER_NAME), name],
)),
None => {
let text = Text::translate(key, &[self.player_display_text(), name]);
// The guests hear of the host's advancement too.
if let Some(host) = &self.lan {
host.hub
.system_message(horizon_client::lan::data::component_nbt(
&text.component(),
));
}
self.system_message(text);
}
}
}
}
+4 -2
View File
@@ -536,7 +536,9 @@ impl Game<'_> {
title
.recent_worlds()
.iter()
.map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.creative }))
.map(
|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1 }),
)
.collect::<Vec<_>>()
};
match &title.mode {
@@ -546,7 +548,7 @@ impl Game<'_> {
selected,
} => json!({
"mode": "select-world",
"worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.creative })).collect::<Vec<_>>(),
"worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1 })).collect::<Vec<_>>(),
"selected": selected,
}),
Mode::Create {
+14 -13
View File
@@ -616,9 +616,6 @@ impl Game<'_> {
};
for a in &self.armor_stands {
let entity_start = out.len();
if a.invisible {
continue;
}
// ArmorStand.shouldRenderAtSqrDistance: 256 times the box's mean
// edge (4 for a marker's empty box), whatever the entity distance.
// LivingEntityRenderer grows the culling box by 0.5 for a dragon head.
@@ -690,16 +687,20 @@ impl Game<'_> {
a.position.z.floor() as i32,
);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
horizon_client::mobs::render::draw(
model,
base,
&poses,
wood,
0xFFFF_FFFF,
packed,
0,
out,
);
// LivingEntityRenderer.submit: an invisible stand has no body render type, but its
// layers (held items, custom head, armour) are submitted all the same.
if !a.invisible {
horizon_client::mobs::render::draw(
model,
base,
&poses,
wood,
0xFFFF_FFFF,
packed,
0,
out,
);
}
self.armor_stand_items(a, model, base, &posed, packed, out);
// HumanoidArmorLayer: head, chest, legs, feet.
for (slot, layer) in [
+77 -23
View File
@@ -105,10 +105,28 @@ impl Game<'_> {
let Some(BlockEntity::Beacon(mut b)) = self.world.block_entities.take(&key) else {
return;
};
self.tick_beacon(key, &mut b);
self.tick_beacon(key, &mut b, false);
self.world.block_entities.put(key, BlockEntity::Beacon(b));
}
fn tick_beacon(&mut self, (x, y, z): (i32, i32, i32), b: &mut Beacon) {
/// `ClientLevel.tickBlockEntities` for beacons: a server's chunk data
/// carries no beam sections, so the client builds them too; effects,
/// sounds and the advancement stay with the server.
pub(super) fn client_tick_beacons(&mut self) {
let keys: Vec<_> = self
.world
.block_entities
.iter()
.filter_map(|(k, e)| matches!(e, BlockEntity::Beacon(_)).then_some(*k))
.collect();
for key in keys {
let Some(BlockEntity::Beacon(mut b)) = self.world.block_entities.take(&key) else {
continue;
};
self.tick_beacon(key, &mut b, true);
self.world.block_entities.put(key, BlockEntity::Beacon(b));
}
}
fn tick_beacon(&mut self, (x, y, z): (i32, i32, i32), b: &mut Beacon, client: bool) {
let mut check = if b.last_check_y < y {
b.checking.clear();
b.last_check_y = y - 1;
@@ -153,7 +171,7 @@ impl Game<'_> {
if !b.sections.is_empty() {
b.levels = self.beacon_base(x, y, z);
}
if b.levels > 0 && !b.sections.is_empty() {
if !client && b.levels > 0 && !b.sections.is_empty() {
self.beacon_effects([x, y, z], b);
self.beacon_sound([x, y, z], "minecraft:block.beacon.ambient");
}
@@ -163,10 +181,12 @@ impl Game<'_> {
b.sections = std::mem::take(&mut b.checking);
let (was, is) = (previous > 0, b.levels > 0);
if !was && is {
self.beacon_sound([x, y, z], "minecraft:block.beacon.activate");
let levels = b.levels;
self.for_each_player(|g| g.beacon_constructed([x, y, z], levels));
} else if was && !is {
if !client {
self.beacon_sound([x, y, z], "minecraft:block.beacon.activate");
let levels = b.levels;
self.for_each_player(|g| g.beacon_constructed([x, y, z], levels));
}
} else if was && !is && !client {
self.beacon_sound([x, y, z], "minecraft:block.beacon.deactivate");
}
}
@@ -551,7 +571,12 @@ impl Game<'_> {
}
}
/// `BeaconRenderer`: each section's rotating solid beam and its glow.
pub(super) fn beacon_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
pub(super) fn beacon_geometry(
&mut self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
let Some(s) = self
.gui
.atlas
@@ -563,15 +588,28 @@ impl Game<'_> {
};
let time = (self.world.game_time.rem_euclid(40)) as f32 + alpha as f32;
let scoping = self.scoping();
let view = f64::from(self.settings.render_distance * 16);
for (k, e) in &self.world.block_entities {
let BlockEntity::Beacon(b) = e else { continue };
// `getBeamSections` is empty while Levels is 0.
if b.levels == 0 || b.sections.is_empty() {
continue;
// `BeaconRenderer.shouldRender` culls at the render distance; far
// terrain extends that to its own distance.
let view = f64::from(self.terrain_distance() * 16);
let world = &self.world;
if self.lod.is_some() {
// The beam stays after its chunk unloads; once the chunk is back,
// its block entity decides whether the beam is still there.
self.far_beacons.retain(|&pos, _| {
let (x, _, z) = pos;
!world.chunks.contains_key(&(x >> 4, z >> 4))
|| world.block_entities.get(&pos).and_then(beam).is_some()
});
} else {
self.far_beacons.clear();
}
for (k, e) in &world.block_entities {
if let Some(sections) = beam(e) {
self.far_beacons.insert(*k, sections.to_vec());
}
}
for (k, sections) in &self.far_beacons {
let base = glam::DVec3::new(f64::from(k.0) + 0.5, f64::from(k.1), f64::from(k.2) + 0.5);
// `BeaconRenderer.shouldRender`: horizontal distance under render distance × 16.
let offset = base - camera;
let horizontal = glam::DVec2::new(offset.x, offset.z).length();
if horizontal >= view {
@@ -586,14 +624,30 @@ impl Game<'_> {
let origin = (glam::DVec3::new(f64::from(k.0), f64::from(k.1), f64::from(k.2))
- camera)
.as_vec3();
horizon_client::block_entity_render::beacon_beam(
origin,
&b.sections,
time,
scale,
s,
out,
);
horizon_client::block_entity_render::beacon_beam(origin, sections, time, scale, s, out);
}
}
}
/// The beam sections of an active beacon (`BeaconRenderer` draws no beam for
/// a beacon without a pyramid or with all sections cleared).
fn beam(e: &BlockEntity) -> Option<&[(u32, i32)]> {
match e {
BlockEntity::Beacon(b) if b.levels > 0 && !b.sections.is_empty() => Some(&b.sections),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_active_beacons_have_beams() {
let mut b = Beacon::default();
assert!(beam(&BlockEntity::Beacon(Box::new(b.clone()))).is_none());
b.levels = 1;
b.sections.push((0xFFFFFF, 10));
assert_eq!(
beam(&BlockEntity::Beacon(Box::new(b))),
Some(&[(0xFFFFFF, 10)][..])
);
}
}
+24 -4
View File
@@ -428,8 +428,18 @@ impl Game<'_> {
.collect();
}
/// The block the player is looking at, for coordinate suggestions.
/// `ClientSuggestionProvider.getRelevantCoordinates` and
/// `getAbsoluteCoordinates` read `Minecraft.hitResult` only when it is a
/// block: an entity in front of the block leaves the `~` defaults.
fn refresh_dynamic_registries(&mut self) {
self.ui.registries.target_block = self.pick().map(|h| h.pos);
let hit = if self.pick_entity_hit().is_some() {
None
} else {
self.pick()
};
self.ui.registries.target_block = hit.as_ref().map(|h| h.pos);
self.ui.registries.target_point =
hit.map(|h| h.point.map(horizon_client::java_text::format_fixed2));
// ClientPacketListener.getOnlinePlayerNames on a server.
if let Some(names) = self.remote_online_names() {
self.ui.registries.players = names;
@@ -1138,9 +1148,19 @@ impl Game<'_> {
None => self.combat_death_message(),
};
self.ui.death_text = Some(message.clone());
// ServerPlayer.die: a team that hides death messages keeps it out of
// the dying player's own chat (the death screen still shows it).
if horizon_client::scoreboard::own_death_message_shown(self.player_team()) {
// ServerPlayer.die: the team's death message visibility decides who
// hears it, the dying player's own chat included (the death screen
// still shows it).
let board = &self.score.board;
if let Some(host) = &self.lan {
host.hub.system_message_to(
horizon_client::lan::data::component_nbt(&message.component()),
|other| {
horizon_client::scoreboard::death_message_reaches(board, PLAYER_NAME, other)
},
);
}
if horizon_client::scoreboard::death_message_reaches(board, PLAYER_NAME, PLAYER_NAME) {
self.system_message(message);
}
}
+58 -18
View File
@@ -925,24 +925,7 @@ impl Game<'_> {
Ok(1)
}
"say" | "me" | "msg" => self.cmd_say(id, ctx),
"list" => {
let names = if ctx.has("uuids") {
Text::translate(
"commands.list.nameAndId",
&[
Text::literal(PLAYER_NAME),
Text::literal(self.player_uuid_string()),
],
)
} else {
Text::literal(PLAYER_NAME)
};
self.system_message(Text::translate(
"commands.list.players",
&[Text::literal("1"), Text::literal("8"), names],
));
Ok(1)
}
"list" => self.cmd_list(ctx),
_ => Err(err("command.failed", &[])),
}
}
@@ -3099,6 +3082,63 @@ impl Game<'_> {
}
out
}
/// ListPlayersCommand: every online player, the host of a hosted world
/// first and then the guests in join order, with the count and the max.
fn cmd_list(&mut self, ctx: &Context) -> R<i32> {
// A single-player owner is a placed guest of the in-process server, so
// it is the host and needs no entry of its own.
let owner_listed = self
.lan
.as_ref()
.is_some_and(|h| h.hub.singleplayer_owner.is_some());
// The host is online unless this is a dedicated server, which has none.
let host = (self.options.dedicated.is_none() && !owner_listed).then(|| match &self.lan {
Some(h) => (h.host_uuid, h.host_name.clone()),
None => (self.player_uuid(), PLAYER_NAME.to_owned()),
});
let guests = self
.lan
.iter()
.flat_map(|h| h.hub.guests.iter())
.filter(|g| g.placed)
.map(|g| (g.uuid, g.name.clone()));
let players: Vec<(u128, String)> = host.into_iter().chain(guests).collect();
let max = self
.lan
.as_ref()
.map_or(horizon_client::lan::MAX_PLAYERS, |h| {
h.hub.lan.shared.max_players()
});
let uuids = ctx.has("uuids");
let mut names = Text::empty();
for (k, (uuid, name)) in players.iter().enumerate() {
if k > 0 {
// ComponentUtils.formatList: gray commas.
names.push(Text::colored(", ", text::GRAY));
}
if uuids {
names.push(Text::translate(
"commands.list.nameAndId",
&[
Text::literal(name.as_str()),
Text::literal(horizon_protocol::crypto::uuid_string(*uuid)),
],
));
} else {
names.push(self.player_text(name, *uuid));
}
}
let count = players.len();
self.system_message(Text::translate(
"commands.list.players",
&[
Text::literal(count.to_string()),
Text::literal(max.to_string()),
names,
],
));
Ok(count as i32)
}
fn cmd_say(&mut self, id: &str, ctx: &Context) -> R<i32> {
let raw = match ctx.get(if id == "me" { "action" } else { "message" }) {
Some(Arg::Str(s)) => s.clone(),
@@ -1115,10 +1115,10 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// The player's profile id as `UUID.toString()`: the saved id, else the
/// offline id of the player's name.
pub(in crate::game) fn player_uuid_string(&self) -> String {
let id = self.level.player_uuid.map_or_else(
/// The player's profile id: the saved id, else the offline id of the
/// player's name.
pub(in crate::game) fn player_uuid(&self) -> u128 {
self.level.player_uuid.map_or_else(
|| horizon_protocol::crypto::offline_uuid(super::super::chat::PLAYER_NAME),
|[a, b, c, d]| {
(u128::from(a as u32) << 96)
@@ -1126,15 +1126,6 @@ impl Game<'_> {
| (u128::from(c as u32) << 32)
| u128::from(d as u32)
},
);
let h = format!("{id:032x}");
format!(
"{}-{}-{}-{}-{}",
&h[..8],
&h[8..12],
&h[12..16],
&h[16..20],
&h[20..]
)
}
}
@@ -6,7 +6,7 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
impl Game<'_> {
/// ComponentArgument with selectors resolved for the source
/// (ComponentUtils.updateForEntity: names joined by ", ").
fn component_arg(&mut self, ctx: &Context, name: &str) -> R<Text> {
pub(in crate::game) fn component_arg(&mut self, ctx: &Context, name: &str) -> R<Text> {
let Some(Arg::Str(json)) = ctx.get(name) else {
return Ok(Text::empty());
};
@@ -124,6 +124,21 @@ impl Game<'_> {
"freeze" => DamageKind::Freeze,
"in_wall" => DamageKind::InWall,
"thorns" => DamageKind::Thorns,
"cramming" => DamageKind::Cramming,
"falling_anvil" => DamageKind::FallingAnvil,
"falling_block" => DamageKind::FallingBlock,
"fireworks" => DamageKind::Fireworks,
"generic_kill" => DamageKind::GenericKill,
"lightning_bolt" => DamageKind::LightningBolt,
"out_of_world" => DamageKind::OutOfWorld,
"outside_border" => DamageKind::OutsideBorder,
"sonic_boom" => DamageKind::SonicBoom,
"spit" => DamageKind::Spit,
"stalagmite" => DamageKind::Stalagmite,
"starve" => DamageKind::Starve,
"sulfur_cube_hot" => DamageKind::SulfurCubeHot,
"wind_charge" => DamageKind::WindCharge,
"wither" => DamageKind::Wither,
_ => DamageKind::Generic,
}
}
+31 -13
View File
@@ -2,7 +2,7 @@
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
use super::{Dim, Game, view_vector};
use super::{Dim, Game, dispenser, view_vector};
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
Result,
@@ -857,7 +857,7 @@ impl Game<'_> {
}
self.score_entity_removed(m.id);
self.raider_died(m, by_player);
self.villager_murdered(m, by_player);
self.villager_murdered(m, by_player, killer.flatten());
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
self.drop_death_loot(&mut dying, by_player, consumed, pos);
self.create_wither_rose(m);
@@ -1019,7 +1019,13 @@ impl Game<'_> {
} else {
from_loot(&stack)
};
self.spawn_item(item, pos, [0.0, 0.2, 0.0], 10);
// Every death drop is its own ItemEntity, and the drop
// entity's own random (not the level's) gives it the
// constructor's horizontal push (its motion otherwise
// stays [0, 0.2, 0] and the drops land stacked at the
// mob's centre, without disturbing the level random that
// the orbs' merge ids and block ticks read next).
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
}
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
drops::Drop::Orb(_) => {}
@@ -1125,7 +1131,9 @@ impl Game<'_> {
.loot_table(table, false, None, &mut self.world.random),
};
for d in stacks {
self.spawn_item(d, at, [0.0, 0.2, 0.0], 10);
// The ItemEntity constructor's motion, from the entity's own
// random (not the level's).
self.spawn_item(d, at, dispenser::new_item_motion(), 10);
}
}
/// Adds a projectile, giving it an id from the entity counter
@@ -1469,7 +1477,11 @@ impl Game<'_> {
extra: None,
},
at,
[0.0, 0.2, 0.0],
// The ItemEntity constructor's motion, from the entity's
// own random (not the level's), so spawnAtLocation drops
// (a fox's berries, a panda's held food, a goat's horn)
// land apart.
dispenser::new_item_motion(),
10,
),
MobEvent::DropGift { table, at } => {
@@ -1478,14 +1490,9 @@ impl Game<'_> {
.items()
.loot_table(table, false, None, &mut self.world.random);
for stack in stacks {
// The ItemEntity constructor's random motion.
let r = &mut self.world.random;
let v = [
r.next_double() * 0.2 - 0.1,
0.2,
r.next_double() * 0.2 - 0.1,
];
self.spawn_item(stack, at, v, 10);
// The ItemEntity constructor's motion, from the
// entity's own random (not the level's).
self.spawn_item(stack, at, dispenser::new_item_motion(), 10);
}
}
// `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop
@@ -4877,6 +4884,17 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::Freeze => "minecraft:freeze",
K::OutOfWorld => "minecraft:out_of_world",
K::Cramming => "minecraft:cramming",
K::FallingAnvil => "minecraft:falling_anvil",
K::Fireworks => "minecraft:fireworks",
K::GenericKill => "minecraft:generic_kill",
K::LightningBolt => "minecraft:lightning_bolt",
K::OutsideBorder => "minecraft:outside_border",
K::Spit => "minecraft:spit",
K::Stalagmite => "minecraft:stalagmite",
K::Starve => "minecraft:starve",
K::SulfurCubeHot => "minecraft:sulfur_cube_hot",
K::WindCharge => "minecraft:wind_charge",
K::Wither => "minecraft:wither",
}
}
/// A Horizon item stack as a loot stack (id, count, damage, enchantments).
@@ -572,6 +572,75 @@ impl Game<'_> {
.chain(self.fns.enabled.iter().cloned())
.collect()
}
/// `FileUtil.isPathPartPortable`: no trailing dot and no Windows device
/// name (CON, PRN, AUX, NUL, COM and LPT with a digit from 1 to 9).
fn is_portable_name(name: &str) -> bool {
let stem = name.split('.').next().unwrap_or_default();
let device = match stem.len() {
3 => matches!(stem, "com" | "con" | "prn" | "aux" | "nul"),
4 => matches!(&stem[..3], "com" | "lpt") && matches!(stem.as_bytes()[3], b'1'..=b'9'),
_ => false,
};
!device && !name.ends_with('.')
}
/// `DataPackCommand.createPack`: a pack folder with its `data` folder and a
/// `pack.mcmeta` in the world's datapacks folder.
fn create_pack(&mut self, ctx: &Context) -> R<i32> {
let id = match ctx.get("id") {
Some(Arg::Str(s)) => s.clone(),
_ => String::new(),
};
let description = self.component_arg(ctx, "description")?;
// FileUtil.isValidPathSegment: lowercase letters, digits, '-', '.' and '_'.
let valid = !id.is_empty()
&& id != "."
&& id != ".."
&& id
.chars()
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-' | '.' | '_'));
if !valid {
return Err(err("commands.datapack.create.invalid_name", &[&id]));
}
if !Self::is_portable_name(&id) {
return Err(err("commands.datapack.create.invalid_full_name", &[&id]));
}
// Without a world there is no datapacks folder to write to.
if self.world_root.as_os_str().is_empty() {
return Err(err("commands.datapack.create.io_failure", &[&id]));
}
let datapacks = self.world_root.join("datapacks");
let pack = datapacks.join(&id);
if pack.exists() {
return Err(err("commands.datapack.create.already_exists", &[&id]));
}
// The data pack version (SharedConstants), written as a bare major
// number for both bounds, as vanilla does.
let pack_format = horizon_core::version::DATA_PACK_FORMAT.0;
let mcmeta = serde_json::json!({
"pack": {
"description": description.component(),
"min_format": pack_format,
"max_format": pack_format,
}
});
let written = std::fs::create_dir_all(&datapacks)
.and_then(|()| std::fs::create_dir(&pack))
.and_then(|()| std::fs::create_dir(pack.join("data")))
.and_then(|()| {
std::fs::write(
pack.join("pack.mcmeta"),
serde_json::to_string_pretty(&mcmeta).unwrap_or_default(),
)
});
if written.is_err() {
return Err(err("commands.datapack.create.io_failure", &[&id]));
}
self.feedback(Text::translate(
"commands.datapack.create.success",
&[Text::literal(id)],
));
Ok(1)
}
pub(super) fn cmd_datapack(&mut self, id: &str, ctx: &Context) -> R<i32> {
self.ensure_datapacks();
let name = match ctx.get("name") {
@@ -590,6 +659,7 @@ impl Game<'_> {
|| g.fns.packs.iter().any(|p| p.id == n)
};
match id {
"datapack.create" => self.create_pack(ctx),
"datapack.list" => {
let want_enabled = ctx.has("enabled") || (!ctx.has("available"));
let want_available = ctx.has("available") || (!ctx.has("enabled"));
+103 -19
View File
@@ -10,12 +10,14 @@ use horizon_client::{
first_person::{Hand, HandWorld, Hit, SwingAnimation, UseResult, swing_duration},
items::ItemStack,
};
use horizon_core::geometry::{Aabb, Vec3};
/// The off hand's place in `visible_hands` (the main hand uses its hotbar slot).
const OFFHAND: usize = usize::MAX;
/// `AttackRange.isInRange`: float bounds, then inclusive double comparison.
fn attack_range_contains(range: &serde_json::Value, distance: f64, creative: bool) -> bool {
/// `AttackRange.effectiveMinRange`, `effectiveMaxRange` (for a player) and
/// `hitboxMargin` of an `attack_range` component.
fn attack_reach(range: &serde_json::Value, creative: bool) -> (f32, f32, f32) {
let value = |key: &str, default: f32| range[key].as_f64().map_or(default, |v| v as f32);
let (min, max) = if creative {
(
@@ -25,7 +27,34 @@ fn attack_range_contains(range: &serde_json::Value, distance: f64, creative: boo
} else {
(value("min_reach", 0.0), value("max_reach", 3.0))
};
let margin = value("hitbox_margin", 0.3);
(min, max, value("hitbox_margin", 0.3))
}
fn v3(v: [f64; 3]) -> Vec3 {
Vec3::new(v[0], v[1], v[2])
}
/// `LocalPlayer.raycastHitResult` with an item that has an attack range
/// (`AttackRange.getClosesetHit`): the distance from the eye to the nearest
/// box hit along the item's own `reach`, which goes past the plain pick's
/// 3 blocks (a spear's 4.5). A miss falls back to the plain pick (`None`).
fn reach_pick(
boxes: &[(usize, Aabb)],
eye: Vec3,
look: Vec3,
movement: Vec3,
reach: (f32, f32, f32),
clip: impl Fn(Vec3, Vec3) -> Option<Vec3>,
) -> Option<f64> {
super::spear::hits_in_reach(boxes, eye, look, movement, reach, clip)
.into_iter()
.map(|(_, at)| eye.distance_squared(at).sqrt())
.reduce(f64::min)
}
/// `AttackRange.isInRange`: float bounds, then inclusive double comparison.
fn attack_range_contains(range: &serde_json::Value, distance: f64, creative: bool) -> bool {
let (min, max, margin) = attack_reach(range, creative);
distance >= f64::from(min - margin) && distance <= f64::from(max + margin)
}
@@ -261,10 +290,6 @@ impl Game<'_> {
/// it is alive (the nearest entity under the crosshair, before any block).
pub(super) fn crosshair_living(&self) -> Option<bool> {
let nearest = |hits: &[Option<f64>]| hits.iter().flatten().copied().reduce(f64::min);
let living = nearest(&[
self.mob_hit().map(|h| h.1),
self.armor_stand_hit().map(|h| h.1),
])?;
let other = nearest(&[
self.painting_hit().map(|h| h.1),
self.frame_hit().map(|h| h.1),
@@ -272,24 +297,49 @@ impl Game<'_> {
self.cart_hit().map(|h| h.1),
self.dragon_hit().map(|h| h.1),
]);
if !self.before_block(living) || other.is_some_and(|o| o <= living) {
return None;
}
let active = self
.using
.and_then(|(slot, _)| self.inventory.get(slot).and_then(Option::as_ref))
.or_else(|| self.held());
if let Some(stack) = active {
let components = self
.pack
let range = active.and_then(|stack| {
self.pack
.item_renderer
.models
.components(&horizon_client::item_render::stack_view(stack));
if let Some(range) = components.get("minecraft:attack_range")
&& !attack_range_contains(range, living, self.creative)
{
return None;
}
.components(&horizon_client::item_render::stack_view(stack))
.get("minecraft:attack_range")
.cloned()
});
let spear = range.as_ref().and_then(|r| {
let eye = self.eye_position()?;
let look = super::view_vector(self.yaw, self.pitch);
let reach = attack_reach(r, self.creative);
let boxes = self.hittable_mob_boxes();
let clip = |from: Vec3, to: Vec3| {
let length = from.distance_squared(to).sqrt();
let d = to.subtract(from).scale(1.0 / length);
(length > 0.0)
.then(|| self.clip_ray([from.x, from.y, from.z], [d.x, d.y, d.z], length))
.flatten()
.map(|hit| Vec3::new(hit.point[0], hit.point[1], hit.point[2]))
};
let movement = self.known_player_movement();
reach_pick(&boxes, v3(eye), v3(look), movement, reach, clip)
});
let living = match spear {
Some(distance) => distance,
None => nearest(&[
self.mob_hit().map(|h| h.1),
self.armor_stand_hit().map(|h| h.1),
])?,
};
if (spear.is_none() && !self.before_block(living)) || other.is_some_and(|o| o <= living) {
return None;
}
// Hud.extractCrosshair: with an attack range, the hit must be in it.
if let Some(range) = &range
&& !attack_range_contains(range, living, self.creative)
{
return None;
}
// Only alive mobs are hit, so a picked living entity is alive.
Some(true)
@@ -564,6 +614,40 @@ impl HandWorld for GameHands<'_, '_> {
mod tests {
use super::*;
/// A zombie-sized box whose near face is `distance` blocks ahead of an
/// eye at the origin looking along +z.
fn mob_ahead(distance: f64) -> Vec<(usize, Aabb)> {
let min = Vec3::new(-0.3, -1.0, distance);
vec![(0, Aabb::new(min, min.add_vector(Vec3::new(0.6, 1.95, 0.6))))]
}
#[test]
fn a_spear_picks_a_mob_past_the_plain_reach() {
// #3209: a mob 4 blocks ahead is out of the plain pick's 3 blocks but
// within a spear's attack range (min 2, max 4.5, margin 0.125), so the
// crosshair picks it and the attack indicator shows.
let spear = serde_json::json!({
"min_reach": 2.0, "max_reach": 4.5,
"min_creative_reach": 2.0, "max_creative_reach": 6.5,
"hitbox_margin": 0.125,
});
let (eye, look) = (Vec3::ZERO, Vec3::new(0.0, 0.0, 1.0));
let boxes = mob_ahead(4.0);
let reach = attack_reach(&spear, false);
let pick = reach_pick(&boxes, eye, look, Vec3::ZERO, reach, |_, _| None);
assert_eq!(pick, Some(4.0));
assert!(attack_range_contains(&spear, 4.0, false));
// Past the maximum plus the margin: no pick.
let far = mob_ahead(4.7);
assert_eq!(
reach_pick(&far, eye, look, Vec3::ZERO, reach, |_, _| None),
None
);
// A block nearer than the minimum reach takes the pick.
let wall = |from: Vec3, _: Vec3| Some(from.add_vector(Vec3::new(0.0, 0.0, 1.0)));
assert_eq!(reach_pick(&boxes, eye, look, Vec3::ZERO, reach, wall), None);
}
#[test]
fn attack_range_uses_inclusive_float_bounds() {
let range = serde_json::json!({
+9 -1
View File
@@ -328,7 +328,7 @@ fn mob_meta(
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, swimming 0x10 (a drowned
// under water, `Drowned.setSwimming`), invisible 0x20 (the effect's flag)
// and glowing 0x40 (LivingEntity.updateGlowingStatus).
let shared = i8::from(m.fire_ticks > 0)
let shared = i8::from(m.is_on_fire())
| i8::from(m.species() == Species::Drowned && m.extra.flag) << 4
| i8::from(m.invisible || horizon_client::mobs::trader::is_invisible(m)) << 5
| i8::from(m.is_currently_glowing()) << 6;
@@ -801,6 +801,14 @@ fn mob_meta(
}
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
}
// Skeleton.DATA_STRAY_CONVERSION_ID (the first entry after Mob's):
// the shaking of the freezing conversion.
"minecraft:skeleton" => {
if e.counter2 > 0 {
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
}
// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two).
"minecraft:wandering_trader" => unhappy_counter(m, meta, defaults),
// Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and
+22 -8
View File
@@ -1877,8 +1877,11 @@ impl Game<'_> {
// ListPlayersCommand needs no permission and lists every player.
let words: Vec<&str> = command.trim_start_matches('/').split(' ').collect();
if words[0] == "list" && (words.len() == 1 || words[1..] == ["uuids"]) {
let me = (self.options.dedicated.is_none() && self.player.is_some())
.then_some((host.host_uuid, host.host_name.as_str()));
// A single-player owner is a placed guest already: no host entry.
let me = (self.options.dedicated.is_none()
&& self.player.is_some()
&& host.hub.singleplayer_owner.is_none())
.then_some((host.host_uuid, host.host_name.as_str()));
let content = host.hub.list_reply(me, words.len() == 2);
host.hub.send_to(
conn,
@@ -2361,14 +2364,25 @@ impl Game<'_> {
None => self.guest_death_message(conn, me),
};
let message = horizon_client::lan::data::component_nbt(&text.component());
let team = self.score.board.players_team(&name);
if horizon_client::scoreboard::own_death_message_shown(team) {
host.hub.system_message(message.clone());
if self.options.dedicated.is_some() {
let board = &self.score.board;
host.hub.system_message_to(message.clone(), |other| {
horizon_client::scoreboard::death_message_reaches(board, &name, other)
});
if self.options.dedicated.is_some() {
// Only a message for everyone is logged (`broadcastSystemMessage`).
if board.players_team(&name).is_none_or(|t| {
t.death_message_visibility == horizon_client::scoreboard::Visibility::Always
}) {
super::dedicated::log("INFO", &format!("System chat: {}", text.plain()));
} else if self.player.is_some() {
self.system_message(text);
}
} else if self.player.is_some()
&& horizon_client::scoreboard::death_message_reaches(
board,
&name,
super::chat::PLAYER_NAME,
)
{
self.system_message(text);
}
message
} else {
@@ -167,6 +167,24 @@ impl Game<'_> {
e.meta.push((15, serializer::BYTE, Value::Byte(flags)));
}
e.meta.extend(stand_poses(a));
// Entity.DATA_CUSTOM_NAME and DATA_CUSTOM_NAME_VISIBLE: a hologram's text.
if let Some(name) = &a.custom_name {
e.meta.push((
2,
serializer::OPTIONAL_COMPONENT,
Value::OptText(Some(horizon_client::scoreboard::json_to_tag(
&name.component(),
))),
));
if a.name_visible {
e.meta.push((3, serializer::BOOLEAN, Value::Bool(true)));
}
}
// A name taken away or hidden again (`/data`) is sent as none / false.
e.defaults
.push((2, serializer::OPTIONAL_COMPONENT, Value::OptText(None)));
e.defaults
.push((3, serializer::BOOLEAN, Value::Bool(false)));
// A pose put back to its default (`/data`) is sent as that default.
e.defaults.push((15, serializer::BYTE, Value::Byte(0)));
for (i, default) in horizon_client::entities::ArmorStand::DEFAULT_POSE
+4 -15
View File
@@ -1308,7 +1308,7 @@ impl Game<'_> {
}
}
/// `renderTwoHandedMap`: map `id` held in both hands, in view space (the
/// arms under it are not drawn).
/// arms under it: `first_person::map_arm_poses`).
pub(super) fn two_handed_map_geometry(
&mut self,
to_world: glam::Mat4,
@@ -1318,22 +1318,11 @@ impl Game<'_> {
light: u32,
out: &mut Vec<Vertex>,
) {
use horizon_core::geometry::trig::Mth;
let deg = |d: f32| d.to_radians();
let sqrt_attack = attack.sqrt();
let y_swing = -0.2 * (attack * std::f32::consts::PI).sin();
let z_swing = -0.4 * (sqrt_attack * std::f32::consts::PI).sin();
// calculateMapTilt
let tilt = (1.0 - self.pitch / 45.0 + 0.1).clamp(0.0, 1.0);
let tilt = -(tilt * std::f32::consts::PI).cos() * 0.5 + 0.5;
let base = to_world
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -y_swing / 2.0, z_swing))
* glam::Mat4::from_translation(glam::Vec3::new(
0.0,
0.04 + inverse_height * -1.2 + tilt * -0.5,
-0.72,
))
* glam::Mat4::from_rotation_x(deg(tilt * -85.0));
let swing = (sqrt_attack * std::f32::consts::PI).sin();
* horizon_client::first_person::two_handed_map_base(self.pitch, inverse_height, attack);
let swing = (attack.sqrt() * std::f32::consts::PI).msin();
let m = base
* glam::Mat4::from_rotation_x(deg(swing * 20.0))
* glam::Mat4::from_scale(glam::Vec3::splat(2.0));
@@ -1227,6 +1227,20 @@ impl Game<'_> {
light: u32,
diffuse: bool,
out: &mut Vec<Vertex>,
) {
self.block_model_vertices_tinted(state, seed, m, light, diffuse, 0xFF_FFFF, out);
}
/// `block_model_vertices` with `tint` (RGB) on the faces that have a tint index.
#[allow(clippy::too_many_arguments)]
pub(super) fn block_model_vertices_tinted(
&self,
state: BlockStateId,
seed: i64,
m: glam::Mat4,
light: u32,
diffuse: bool,
tint: u32,
out: &mut Vec<Vertex>,
) {
let Ok(models) = self.pack.render.model_catalog.collect_seeded(state, seed) else {
return;
@@ -1253,6 +1267,12 @@ impl Game<'_> {
} else {
0xFF00_0000 | shade << 16 | shade << 8 | shade
};
let color = if face.quad.tint_index >= 0 {
let tint = horizon_client::mobs::render::abgr(tint);
horizon_client::hud_layout::multiply(color, tint)
} else {
color
};
let normal = if diffuse {
use horizon_client::item_render::{direction_vector, transform_normal};
let n = transform_normal(m, direction_vector(face.quad.facing));
+44 -90
View File
@@ -1,6 +1,6 @@
//! Blocks mobs carry: block items in a hand (`ItemInHandLayer`) and the
//! block an enderman holds (`CarriedBlockLayer`), drawn as shaded cubes like
//! dropped block items. Also the block models mobs wear (`SnowGolemHeadLayer`,
//! block an enderman holds (`CarriedBlockLayer`), drawn as baked block models.
//! Also the block models mobs wear (`SnowGolemHeadLayer`,
//! `MushroomCowMushroomLayer`).
use super::Game;
use horizon_client::{
@@ -9,46 +9,6 @@ use horizon_client::{
mobs::{Mob, Species, render},
};
/// The six faces of the unit cube with their shade: top, bottom, the two
/// z faces, the two x faces.
const FACES: [([[f32; 3]; 4], f32, u8); 6] = [
(
[[0., 1., 1.], [1., 1., 1.], [1., 1., 0.], [0., 1., 0.]],
1.0,
0,
),
(
[[0., 0., 0.], [1., 0., 0.], [1., 0., 1.], [0., 0., 1.]],
0.5,
0,
),
(
[[1., 0., 0.], [0., 0., 0.], [0., 1., 0.], [1., 1., 0.]],
0.8,
1,
),
(
[[0., 0., 1.], [1., 0., 1.], [1., 1., 1.], [0., 1., 1.]],
0.8,
1,
),
(
[[0., 0., 0.], [0., 0., 1.], [0., 1., 1.], [0., 1., 0.]],
0.6,
2,
),
(
[[1., 0., 1.], [1., 0., 0.], [1., 1., 0.], [1., 1., 1.]],
0.6,
2,
),
];
/// The sprites of a cube's top, z sides and x sides, and the top's tint.
struct Cube<'a> {
sprites: [&'a str; 3],
tint: u32,
}
/// `CustomHeadLayer.translateToHead` and the banner's `head` display.
fn banner_matrix(head: glam::Mat4) -> glam::Mat4 {
head * glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0))
@@ -178,38 +138,6 @@ impl Game<'_> {
render::draw(flag, m, &flag_poses, r, color, light, 0, out);
}
}
fn push_cube(
&self,
t: glam::Mat4,
cube: &Cube,
light: u32,
overlay: u32,
out: &mut Vec<Vertex>,
) {
for (corners, shade, side) in FACES {
let name = cube.sprites[usize::from(side)];
let Some(r) = self.gui.atlas.sprites.get(name).copied() else {
continue;
};
let tint = if side == 0 { cube.tint } else { 0xFF_FFFF };
let c = |sft: u32| (((tint >> sft) & 255) as f32 * shade) as u32;
let color = 0xFF00_0000 | c(0) << 16 | c(8) << 8 | c(16);
let uvs = [[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]];
for (corner, uv) in corners.iter().zip(uvs) {
let p = t.transform_point3(glam::Vec3::from(*corner));
out.push(Vertex {
normal: [0; 4],
pos: p.to_array(),
uv,
layer: overlay,
color,
light,
glint_uv: [0.0, 0.0],
});
}
}
}
/// The blocks one mob holds or carries.
pub(super) fn mob_block_geometry(
&self,
@@ -222,11 +150,13 @@ impl Game<'_> {
self.captain_banner_geometry(mob, camera, alpha, light, out);
self.held_item_geometry(mob, camera, alpha, light, true, out);
self.worn_block_geometry(mob, camera, alpha, light, out);
self.golem_flower_geometry(mob, camera, alpha, light, out);
if mob.species() == Species::Enderman
&& let Some(state) = mob.extra.carried
&& let Some((sprite, tint)) = self.block_face_sprite(state)
&& let Some((_, tint)) = self.block_face_sprite(state)
{
// CarriedBlockLayer: OverlayTexture.NO_OVERLAY, never the hurt tint.
// CarriedBlockLayer: OverlayTexture.NO_OVERLAY, never the hurt tint;
// the block's baked model (seed 42) on the unit cube, not centred.
let base = render::base_transform(mob, alpha as f32, camera);
let t = base
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.6875, -0.75))
@@ -234,20 +164,43 @@ impl Game<'_> {
* glam::Mat4::from_rotation_y(45.0f32.to_radians())
* glam::Mat4::from_translation(glam::Vec3::new(0.25, 0.1875, 0.25))
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5))
* glam::Mat4::from_rotation_y(90.0f32.to_radians())
* glam::Mat4::from_translation(glam::Vec3::splat(-0.5));
self.push_cube(
t,
&Cube {
sprites: [&sprite, &sprite, &sprite],
tint,
},
light,
0,
out,
);
* glam::Mat4::from_rotation_y(90.0f32.to_radians());
self.block_model_vertices_tinted(state, 42, t, light, true, tint, out);
}
}
/// `IronGolemFlowerLayer`: the poppy in the right arm while the golem
/// offers it (`getOfferFlowerTick() > 0`), drawn with `NO_OVERLAY`.
fn golem_flower_geometry(
&self,
mob: &Mob,
camera: glam::DVec3,
alpha: f64,
light: u32,
out: &mut Vec<Vertex>,
) {
use glam::{Mat4, Vec3};
if mob.species() != Species::IronGolem
|| mob.extra.counter <= 0
|| horizon_client::mobs::trader::is_invisible(mob)
{
return;
}
let Some(state) = self.pack.assets.registry.default_state("minecraft:poppy") else {
return;
};
let Some(arm) =
render::part_transform(mob, alpha as f32, camera, self.res.mob_data(), "right_arm")
else {
return;
};
let t = arm
* Mat4::from_translation(Vec3::new(-1.1875, 1.0625, -0.9375))
* Mat4::from_translation(Vec3::splat(0.5))
* Mat4::from_scale(Vec3::splat(0.5))
* Mat4::from_rotation_x((-90.0f32).to_radians())
* Mat4::from_translation(Vec3::splat(-0.5));
self.block_model_vertices(state, 42, t, light, true, out);
}
/// `SnowGolemHeadLayer` and `MushroomCowMushroomLayer`: the carved pumpkin
/// on a snow golem's head, and the mushrooms on an adult mooshroom's back
/// and head, as baked block models.
@@ -387,9 +340,10 @@ impl Game<'_> {
owner.crossbow_charge_duration = 25;
}
use horizon_assets::item_model::DisplayContext as Context;
// PandaHoldsItemLayer shows the item as it lies on the ground.
// PandaHoldsItemLayer and FoxHeldItemLayer show the item as it lies
// on the ground.
let (context, ordinal) = match (mob.species(), left) {
(Species::Panda, _) => (Context::Ground, 7),
(Species::Panda | Species::Fox, _) => (Context::Ground, 7),
(_, true) => (Context::ThirdPersonLeftHand, 1),
(_, false) => (Context::ThirdPersonRightHand, 2),
};
+96 -20
View File
@@ -1074,26 +1074,15 @@ impl Game<'_> {
batch.fill(atlas, xx, yy, 1.0, wh, 0xFF50_5050);
}
let content = l.rows.len() as f32 * 12.0;
let first = (scroll.amount / 12.0) as usize;
let last = (first + ((rows_bottom - rows_top) / 12.0).ceil() as usize + 1)
.min(l.rows.len());
for (vi, ri) in (first..last).enumerate() {
let ry = rows_top + vi as f32 * 12.0 - (scroll.amount % 12.0);
if ui::hovered(self.cursor, x, ry, MOD_UI_COLUMN - 6.0, 12.0) {
batch.fill(atlas, x + 1.0, ry, MOD_UI_COLUMN - 8.0, 12.0, 0x20FF_FFFF);
}
if l.selected == Some(ri as u32) {
batch.fill(atlas, x + 1.0, ry, 3.0, 12.0, 0xFFFF_FFFF);
}
batch.text_styled(
atlas,
&Text::literal(l.rows[ri].clone()),
x + 6.0,
ry + 2.0,
u32::MAX,
true,
);
}
draw_list_rows(
batch,
atlas,
self.cursor,
&l.rows,
l.selected,
scroll.amount,
(x, wy, wh),
);
ui::scrollbar(
batch,
atlas,
@@ -1231,6 +1220,51 @@ impl Game<'_> {
}
}
/// A mod screen's `list` rows in its frame at (`x`, `wy`), `wh` high, scrolled by `scroll`: the
/// rows in view, the hovered one lit and the selected one marked. As AbstractSelectionList draws
/// its entries inside `enableScissor`, they are cut at the frame's inside: the row a scroll shows
/// in part, and the one below a full view, never draw over the frame or below it.
#[cfg(feature = "mods")]
fn draw_list_rows(
batch: &mut HudBatch,
atlas: &horizon_client::hud::HudAtlas,
cursor: (f32, f32),
rows: &[String],
selected: Option<u32>,
scroll: f32,
(x, wy, wh): (f32, f32, f32),
) {
let rows_top = wy + 4.0;
let rows_bottom = wy + wh - 4.0;
let first = (scroll / 12.0) as usize;
let last = (first + ((rows_bottom - rows_top) / 12.0).ceil() as usize + 1).min(rows.len());
let start = batch.vertices.len();
for (vi, ri) in (first..last).enumerate() {
let ry = rows_top + vi as f32 * 12.0 - (scroll % 12.0);
if ui::hovered(cursor, x, ry, MOD_UI_COLUMN - 6.0, 12.0) {
batch.fill(atlas, x + 1.0, ry, MOD_UI_COLUMN - 8.0, 12.0, 0x20FF_FFFF);
}
if selected == Some(ri as u32) {
batch.fill(atlas, x + 1.0, ry, 3.0, 12.0, 0xFFFF_FFFF);
}
batch.text_styled(
atlas,
&Text::literal(rows[ri].clone()),
x + 6.0,
ry + 2.0,
u32::MAX,
true,
);
}
batch.clip_since(
start,
x + 1.0,
wy + 1.0,
x + MOD_UI_COLUMN - 1.0,
wy + wh - 1.0,
);
}
#[cfg(feature = "mods")]
const MOD_UI_TOP: f32 = 40.0;
#[cfg(feature = "mods")]
@@ -1238,3 +1272,45 @@ const MOD_UI_COLUMN: f32 = 200.0;
/// A key row's height with its gap, as the widgets below it space out.
#[cfg(feature = "mods")]
const MOD_UI_KEY_ROW: f32 = 26.0;
#[cfg(all(test, feature = "mods"))]
mod tests {
use super::*;
use horizon_client::hud::HudAtlas;
#[test]
fn list_rows_stay_inside_the_frame_scrolled_or_not() {
let atlas =
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
// #3237: 22 rows in a frame of 6 (mod_ui_layout's height for a long list). The 7th row
// (index 6), drawn for a scroll that shows it in part, is selected and under the
// cursor, so its fills reach past the frame unless they are cut.
let rows: Vec<String> = (0..22).map(|i| format!("Row {i}")).collect();
let (x, wy, wh) = (100.0, 40.0, 6.0 * 12.0 + 8.0);
for scroll in [0.0, 6.0, 30.0] {
let mut batch = HudBatch::new([640, 480]);
let seventh_row = wy + 4.0 + 6.0 * 12.0 - scroll % 12.0;
draw_list_rows(
&mut batch,
&atlas,
(x + 10.0, seventh_row + 2.0),
&rows,
Some(6 + (scroll / 12.0) as u32),
scroll,
(x, wy, wh),
);
assert!(!batch.vertices.is_empty(), "scroll {scroll}: rows drawn");
let (top, bottom) = batch
.vertices
.iter()
.map(|v| v.pos[1])
.fold((f32::MAX, f32::MIN), |(lo, hi), y| (lo.min(y), hi.max(y)));
assert!(
top >= wy + 1.0 && bottom <= wy + wh - 1.0,
"scroll {scroll}: rows span {top}..{bottom}, the frame's inside is {}..{}",
wy + 1.0,
wy + wh - 1.0
);
}
}
}
+36 -66
View File
@@ -7,7 +7,7 @@
//! or a server) show whenever `LivingEntityRenderer.shouldShowName` allows.
use super::{Game, view_vector};
use horizon_client::{
block_entity_render::vertex_light, mesher::Vertex, mobs::Mob, save::uuid_text,
block_entity_render::vertex_light, hud::HudBatch, mesher::Vertex, mobs::Mob, save::uuid_text,
scoreboard as sb, text::Text,
};
@@ -289,7 +289,7 @@ impl Game<'_> {
/// faded, on its background; any other in full over the world, its faded
/// copy and background in `see_through`, over everything.
pub(super) fn name_tag_geometry(
&self,
&mut self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
@@ -298,26 +298,31 @@ impl Game<'_> {
// The text alpha that goes with getBackgroundOpacity(0.25):
// max((0.25 + 0.75) / 2, 0.5), as ARGB.color (the floor of 255 times it).
const FADED: u32 = 0x7F;
let mut patches = Vec::new();
for tag in self.name_tags(camera, alpha) {
let o = tag.at - camera;
// `gbuffers_entities` draws name tag text (2.3); a sneaking player's faded tag, on its
// background, blends.
if tag.discrete {
self.mark_translucent_entity(out.len(), "minecraft:name_tag");
self.name_tag_quads(o, &tag.lines, FADED, true, 0, vertex_light(tag.light), out);
let light = vertex_light(tag.light);
patches.extend(self.name_tag_quads(o, &tag.lines, FADED, true, 0, light, out));
continue;
}
// LightCoordsUtil.lightCoordsWithEmission(lightCoords, 2).
let lit = (tag.light >> 4).max(2) << 4 | (tag.light & 15).max(2);
self.mark_entity(out.len(), "minecraft:name_tag");
self.name_tag_quads(o, &tag.lines, 0xFF, false, 0, vertex_light(lit), out);
let light = vertex_light(lit);
patches.extend(self.name_tag_quads(o, &tag.lines, 0xFF, false, 0, light, out));
// RenderPipelines.TEXT_SEE_THROUGH: no depth test, no lightmap.
self.name_tag_quads(o, &tag.lines, FADED, true, 2, 0, see_through);
patches.extend(self.name_tag_quads(o, &tag.lines, FADED, true, 2, 0, see_through));
}
self.map_patches.extend(patches);
}
/// The lines of one name tag, `o` from the camera: the text at
/// `text_alpha` in its layer, on the translucent black background
/// (in `text_layer` like the glyphs, a hair behind) when `background`.
/// Returns the glyph pixels the atlas still needs.
#[allow(clippy::too_many_arguments)]
fn name_tag_quads(
&self,
@@ -328,7 +333,7 @@ impl Game<'_> {
text_layer: u32,
light: u32,
out: &mut Vec<Vertex>,
) {
) -> Vec<horizon_client::font::Patch> {
// Options.getBackgroundOpacity(0.25) black, as ARGB.color.
const BACKGROUND: u32 = 0x3F00_0000;
// The camera's facing (Camera.rotation()), turned round in third-person
@@ -342,85 +347,50 @@ impl Game<'_> {
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();
let away = forward.as_vec3() * 0.002;
let mut batch = HudBatch::new([1, 1]);
for (text, lift) in lines {
let o = (o + glam::DVec3::new(0.0, *lift, 0.0)).as_vec3();
let (r, u) = (right.as_vec3() * 0.025, up.as_vec3() * 0.025);
// Text space: x right, y down (scale 0.025, -0.025).
let at = |x: f32, y: f32| o + r * x - u * y;
// Component.getVisualOrderText.
let shown = horizon_client::bidi::visual(text);
let chars: Vec<(char, u32)> = shown
.spans
.iter()
.flat_map(|s| {
let rgb = s.style.color.unwrap_or(0xFF_FF_FF);
// RGBA8, alpha in the top byte.
let color = text_alpha << 24
| ((rgb & 0xFF) << 16)
| (rgb & 0xFF00)
| ((rgb >> 16) & 0xFF);
s.text.chars().map(move |c| (c, color))
})
.collect();
let width: f32 = chars
.iter()
.map(|(c, _)| self.gui.atlas.glyph(*c).map_or(6.0, |g| g.1))
.sum();
let x0 = -(width / 2.0).trunc();
let y0 = if text.plain() == "deadmau5" {
-10.0
} else {
0.0
};
let mut quad = |a: [f32; 4], uv: [f32; 4], color: u32, behind: bool, layer: u32| {
let corners = [(a[0], a[3]), (a[2], a[3]), (a[2], a[1]), (a[0], a[1])];
let uvs = [
[uv[0], uv[3]],
[uv[2], uv[3]],
[uv[2], uv[1]],
[uv[0], uv[1]],
];
for ((x, y), uv) in corners.into_iter().zip(uvs) {
batch.vertices.clear();
// Font.prepareText, as any text: bold, italic, fonts and each
// glyph's own box (accented.png's are 12 pixels tall).
let behind = batch.name_tag(
&self.gui.atlas,
text,
y0,
text_alpha << 24 | 0xFF_FFFF,
if background { BACKGROUND } else { 0 },
);
for (i, quad) in batch.vertices.as_chunks::<6>().0.iter().enumerate() {
let shift = if i * 6 < behind {
away
} else {
glam::Vec3::ZERO
};
// The corners from the bottom left, as the world draws quads.
for v in [quad[5], quad[2], quad[1], quad[0]] {
out.push(Vertex {
normal: [0; 4],
pos: (at(x, y) + if behind { away } else { glam::Vec3::ZERO }).to_array(),
uv,
layer,
color,
pos: (at(v.pos[0], v.pos[1]) + shift).to_array(),
uv: v.uv,
layer: text_layer,
color: v.color,
light,
glint_uv: [0.0, 0.0],
});
}
};
if background {
quad(
[x0 - 1.0, y0 - 1.0, x0 + width, y0 + 8.0],
[white.u0, white.v0, white.u1, white.v1],
BACKGROUND,
true,
text_layer,
);
}
let mut x = x0;
for (ch, color) in chars {
let Some((g, advance)) = self.gui.atlas.glyph(ch) else {
x += 6.0;
continue;
};
if ch != ' ' {
quad(
[x, y0, x + 8.0, y0 + 8.0],
[g.u0, g.v0, g.u1, g.v1],
color,
false,
text_layer,
);
}
x += advance;
}
}
// Glyphs rasterized on first use (Unifont's) upload with the frame.
batch.patches
}
}
+45 -3
View File
@@ -305,7 +305,8 @@ impl Game<'_> {
let _ = mods;
}
/// The buttons of mod `mod_id`'s settings page, under its keys: push
/// their labels when it is yours. Asked again after each click.
/// their labels when it is yours. Asked again each time the page opens
/// and after each click.
pub(super) fn native_settings(&mut self, mod_id: &str, buttons: &mut Vec<String>) {
let _ = (mod_id, buttons);
}
@@ -341,9 +342,12 @@ impl Game<'_> {
pub(super) fn native_window(&self) -> bool {
self.options.execution_role == ExecutionRole::Client
}
/// Whether a box in `corner` shows: F3's debug screen takes the top left.
/// Whether a box in `corner` shows: F3's debug screen takes both top
/// corners (its left and right columns), and a bottom corner while one
/// of its charts is drawn there.
pub(super) fn native_corner_shown(&self, corner: Corner) -> bool {
!self.debug || corner != Corner::TopLeft
let charts = self.debug_charts.corners(!self.has_singleplayer_server());
!debug_takes(corner, self.debug, charts)
}
/// The width of `text` in GUI pixels.
pub(super) fn native_text_width(&self, text: &str) -> f32 {
@@ -533,6 +537,14 @@ impl Game<'_> {
return;
};
self.native_settings_click(&id, button);
self.native_settings_refresh(i);
}
/// Native mod `i`'s page opened: its labels asked again, so that they
/// follow the language (and the mod's state) since the list was made.
pub(super) fn native_settings_refresh(&mut self, i: usize) {
let Some(id) = self.settings.mods.installed.get(i).map(|m| m.id.clone()) else {
return;
};
let mut buttons = Vec::new();
self.native_settings(&id, &mut buttons);
let row = self.settings.mods.installed.get_mut(i);
@@ -602,6 +614,19 @@ impl Game<'_> {
}
}
/// Whether F3's debug screen, open when `debug`, covers `corner`
/// (`DebugScreenOverlay.extractRenderState`): its left and right columns
/// the top corners, and its charts the bottom corners `charts` (bottom left,
/// bottom right) says they are drawn in.
fn debug_takes(corner: Corner, debug: bool, charts: [bool; 2]) -> bool {
debug
&& match corner {
Corner::TopLeft | Corner::TopRight => true,
Corner::BottomLeft => charts[0],
Corner::BottomRight => charts[1],
}
}
/// `v` within `lo..=hi`; `lo` when it is not a number.
fn finite(v: f32, lo: f32, hi: f32) -> f32 {
if v.is_finite() { v.clamp(lo, hi) } else { lo }
@@ -625,4 +650,21 @@ mod tests {
}
}
}
/// Vanilla's F3 draws a column in each top corner (`extractLines`, left
/// and right aligned), and its charts along the bottom.
#[test]
fn the_debug_screen_takes_both_top_corners_and_its_charts_corners() {
use super::{Corner, debug_takes};
for corner in Corner::ALL {
assert!(!debug_takes(corner, false, [true, true]), "{corner:?}");
}
assert!(debug_takes(Corner::TopLeft, true, [false, false]));
assert!(debug_takes(Corner::TopRight, true, [false, false]));
assert!(!debug_takes(Corner::BottomLeft, true, [false, false]));
assert!(!debug_takes(Corner::BottomRight, true, [false, false]));
assert!(debug_takes(Corner::BottomLeft, true, [true, false]));
assert!(!debug_takes(Corner::BottomRight, true, [true, false]));
assert!(debug_takes(Corner::BottomRight, true, [false, true]));
}
}
@@ -249,6 +249,7 @@ impl Game<'_> {
super::packs::open_folder(&dir);
}
Event::ModSettings(i) => self.open_mod_screen(i, true),
Event::NativeSettings(i) => self.native_settings_refresh(i),
Event::NativeButton(i, b) => self.native_settings_event(i, b),
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
}
+24 -3
View File
@@ -327,7 +327,7 @@ impl Game<'_> {
self.player_model = Some(Mob::new(0, kind, p.position, self.yaw));
}
let crouching = p.pose == Pose::Crouching;
let (position, flying) = (p.position, p.flying || p.fall_flying);
let position = p.position;
let (fall_flying, fly_ticks, velocity) = (p.fall_flying, p.fall_fly_ticks, p.velocity);
let spinning = p.auto_spin_attack_ticks > 0;
let using = self.using.map(|(_, ticks)| ticks as i32 + 1).unwrap_or(0);
@@ -380,6 +380,7 @@ impl Game<'_> {
at.y - m.position.y,
at.z - m.position.z,
);
let walked = player_walk_distance(m.position, at);
m.previous = m.position;
m.position = at;
if dx * dx + dy * dy + dz * dz > 100.0 {
@@ -414,9 +415,8 @@ impl Game<'_> {
m.body_yaw += diff - diff.signum() * 50.0;
}
// WalkAnimationState.update(min(distance * 4, 1), 0.4).
let dist = (dx * dx + dz * dz + if flying { dy * dy } else { 0.0 }).sqrt() as f32;
m.previous_limb_speed = m.limb_speed;
m.limb_speed += ((dist * 4.0).min(1.0) - m.limb_speed) * 0.4;
m.limb_speed += ((walked * 4.0).min(1.0) - m.limb_speed) * 0.4;
m.limb_position += m.limb_speed;
m.age = m.age.wrapping_add(1);
m.anim.attack_anim_o = attack.0;
@@ -446,6 +446,7 @@ impl Game<'_> {
m.extra.avatar.cloak_o[1] -= 0.125;
}
m.extra.swing_right_arm = swing_right_arm;
m.extra.swing_stab = self.swing.current.is_some_and(|c| c.animation.kind == 2);
m.hurt_time = hurt;
}
/// `LevelExtractor`'s skip of the camera entity: the player's own body
@@ -792,10 +793,30 @@ fn player_preview_quads(
quads
}
/// The distance `LivingEntity.calculateEntityAnimation` walks the player's
/// model for a move from `from` to `to`. `omnidirectionalAirMover()` is false
/// for every player (`Entity`'s, not overridden), so only the horizontal
/// distance counts, flying and gliding included: rising or sinking straight
/// up or down leaves the legs and arms still.
fn player_walk_distance(from: Vec3, to: Vec3) -> f32 {
let (dx, dz) = (to.x - from.x, to.z - from.z);
(dx * dx + dz * dz).sqrt() as f32
}
#[cfg(test)]
mod preview_tests {
use super::*;
#[test]
fn flying_straight_up_does_not_walk_the_player_model() {
let from = Vec3::new(0.5, 80.0, 0.5);
// Creative flight straight up (#3302) and straight down.
assert_eq!(player_walk_distance(from, Vec3::new(0.5, 80.375, 0.5)), 0.0);
assert_eq!(player_walk_distance(from, Vec3::new(0.5, 78.5, 0.5)), 0.0);
// Moving sideways while rising walks only the horizontal part.
let d = player_walk_distance(from, Vec3::new(0.8, 81.0, 0.9));
assert!((d - 0.5).abs() < 1e-6, "{d}");
}
#[test]
fn preview_keeps_crossing_faces_depth_and_clips_without_dropping_faces() {
use horizon_client::{
hud::{Blend, HudBatch},
+28 -6
View File
@@ -549,7 +549,10 @@ impl Game<'_> {
self.extras_left();
}
self.clear_waypoints();
self.boss_overlay.reset();
self.hud_disconnected();
// Vanilla's Hud also ticks on the title and connect screens, so the
// action bar has run out by the next world; Horizon's ticks in one.
self.titles.overlay_time = 0;
if let Some(mut r) = self.remote.take() {
r.session.disconnect();
}
@@ -574,8 +577,6 @@ impl Game<'_> {
self.clock = Default::default();
self.adv = Default::default();
self.stats = Default::default();
self.debug_charts.reset();
self.tab_list.reset();
self.score.board = Default::default();
self.streaming = Streaming::new(Arc::new(NoTerrain), self.pack.context.clone(), 1);
self.streaming.paused = true;
@@ -619,6 +620,17 @@ impl Game<'_> {
self.capture(false);
Ok(())
}
/// `Hud.onDisconnected`, from `Minecraft.disconnect` and `clearClientLevel`
/// (a proxy's reconfiguration too), but the chat: joining clears it and a
/// reconfiguration keeps it (`ChatComponent.storeState`).
fn hud_disconnected(&mut self) {
self.tab_list.reset();
self.boss_overlay.reset();
self.adv.toasts.clear();
self.debug_charts.reset();
self.titles.clear();
self.titles.reset_times();
}
pub(super) fn send(&self, packet: S) {
if let Some(r) = &self.remote {
@@ -910,7 +922,15 @@ impl Game<'_> {
}
}
Event::Reconfigured(registries) => {
self.boss_overlay.reset();
// handleConfigurationStart → clearClientLevel, and the next
// ClientPacketListener starts with no players or scoreboard.
self.hud_disconnected();
self.score.board = Default::default();
if let Some(r) = self.remote.as_mut() {
r.players.clear();
r.tab_players.clear();
r.listed.clear();
}
self.set_registries(registries);
}
// handleTransfer: "Cannot transfer to server from
@@ -1796,8 +1816,9 @@ impl Game<'_> {
C::Login(p) => {
self.level.hardcore = p.hardcore;
// handleLogin makes a new MultiPlayerGameMode (carried index 0) and a
// new LocalPlayer (slot 0).
// new LocalPlayer (slot 0, no effects).
self.selected = 0;
self.potions.effects.clear();
if let Some(r) = self.remote.as_mut() {
r.carried = Some(0);
r.online_mode = p.online_mode;
@@ -2559,7 +2580,7 @@ impl Game<'_> {
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
self.swing_hand(hand, animation);
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.swing_hand_modified(hand == Hand::Off, animation.duration);
m.swing_hand_modified(hand == Hand::Off, animation);
}
}
// The player's own instances: its maximum health (the hearts the
@@ -3770,6 +3791,7 @@ impl Game<'_> {
// and the moving pistons a block event's client-side moveBlocks made
// (their progress advances between the server's block updates).
self.tick_block_entity_anims();
self.client_tick_beacons();
self.with_level(|b, level, ticks| {
b.client_tick_block_entities(level, ticks);
});
@@ -246,6 +246,11 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
(Species::Zombie | Species::Husk, 18, Value::Bool(converting)) => {
m.extra.counter2 = i32::from(*converting);
}
// Skeleton.DATA_STRAY_CONVERSION_ID (the first entry after Mob's):
// the freezing conversion's shaking.
(Species::Skeleton, 16, Value::Bool(converting)) => {
m.extra.counter2 = i32::from(*converting);
}
// ZombieVillager.DATA_CONVERTING_ID (before its villager data).
(Species::ZombieVillager, 19, Value::Bool(converting)) => {
zombie_villager::set_converting(m, *converting);
+35 -34
View File
@@ -68,7 +68,7 @@ impl Game<'_> {
yaw.cos() * pitch.cos(),
]
}
/// `FogRenderer.setupFog` for the frame's camera.
/// `FogRenderer.setupFog` for the frame's camera, and Horizon's Fog option.
pub(super) fn frame_fog(
&self,
camera: glam::DVec3,
@@ -90,39 +90,40 @@ impl Game<'_> {
} else {
self.res.fog()
};
horizon_client::fog::setup_fog(
tables,
&self.fog,
&FogInputs {
fog_type,
sky,
water_fog_color,
biome,
render_distance_chunks: self.terrain_distance(),
distant_terrain: self.lod.is_some(),
cloud_range_chunks: self.settings.cloud_range,
camera_y: camera.y,
min_y: self.level_min_y(),
flat: self.level_flat(),
forward: forward.map(|c| c as f32),
rain,
thunder,
skybox: self.dim == Dim::Overworld,
blindness: self
.potions
.effects
.get("blindness")
.map(|effect| (effect.duration, effect.duration == -1)),
darkness: self.darkness_fog(),
darken_world: self
.boss_overlay
.darken_amount(self.clock.fraction() as f32),
boss_world_fog: self.boss_overlay.should_create_world_fog(),
spectator: self.spectator(),
fire_resistance: self.potions.effects.get("fire_resistance").is_some(),
night_vision: self.fog_night_vision(),
},
)
let input = FogInputs {
fog_type,
sky,
water_fog_color,
biome,
render_distance_chunks: self.terrain_distance(),
distant_terrain: self.lod.is_some(),
cloud_range_chunks: self.settings.cloud_range,
camera_y: camera.y,
min_y: self.level_min_y(),
flat: self.level_flat(),
forward: forward.map(|c| c as f32),
rain,
thunder,
skybox: self.dim == Dim::Overworld,
blindness: self
.potions
.effects
.get("blindness")
.map(|effect| (effect.duration, effect.duration == -1)),
darkness: self.darkness_fog(),
darken_world: self
.boss_overlay
.darken_amount(self.clock.fraction() as f32),
boss_world_fog: self.boss_overlay.should_create_world_fog(),
spectator: self.spectator(),
fire_resistance: self.potions.effects.get("fire_resistance").is_some(),
night_vision: self.fog_night_vision(),
};
let mut fog = horizon_client::fog::setup_fog(tables, &self.fog, &input);
if !self.settings.horizon.fog {
horizon_client::fog::clear_distance_fog(&mut fog, &input);
}
fog
}
/// `FogRenderer.computeFogColor`'s night vision brightenFactor:
/// `GameRenderer.nightVisionScale`, but not while Darkness is on.
@@ -50,7 +50,7 @@ impl Game<'_> {
json!({
"folder": w.folder,
"name": w.name,
"mode": if w.creative { "creative" } else { "survival" },
"mode": if w.game_mode == 1 { "creative" } else { "survival" },
"last_played": w.modified.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64),
"commands": w.commands,
"version": w.version,
+15 -8
View File
@@ -118,14 +118,12 @@ fn death_buttons(w: f32, h: f32, hardcore: bool) -> [(f32, f32, &'static str, u8
} else {
"deathScreen.respawn"
};
// DeathScreen.init: `width / 2 - 100`, `height / 4 + 72` and `+ 96`, in
// int GUI pixels.
let (x, y) = ((w.ceil() / 2.0).floor() - 100.0, (h.ceil() / 4.0).floor());
[
(w / 2.0 - 100.0, h / 4.0 + 72.0, respawn, 0),
(
w / 2.0 - 100.0,
h / 4.0 + 96.0,
"deathScreen.titleScreen",
1,
),
(x, y + 72.0, respawn, 0),
(x, y + 96.0, "deathScreen.titleScreen", 1),
]
}
impl Game<'_> {
@@ -469,7 +467,16 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::{Screen, pause_buttons, pause_icons};
use super::{Screen, death_buttons, pause_buttons, pause_icons};
#[test]
fn death_buttons_sit_on_whole_gui_pixels() {
// DeathScreen.init in ints: a 427x253 GUI (1709x1009 at scale 4) puts
// Respawn at (113, 63 + 72), not (113.5, 63.25 + 72).
let at = |w, h| death_buttons(w, h, false).map(|b| (b.0, b.1));
assert_eq!(at(427.0, 253.0), [(113.0, 135.0), (113.0, 159.0)]);
assert_eq!(at(320.0, 334.0), [(60.0, 155.0), (60.0, 179.0)]);
}
#[test]
fn menu_blur_matches_vanilla_screen_backgrounds() {
@@ -204,7 +204,7 @@ mod run {
&& let Some(p) = &self.player
{
out.push(ServerPlayer {
id: self.player_uuid_string(),
id: uuid_string(self.player_uuid()),
name: crate::game::chat::PLAYER_NAME.to_owned(),
x: p.position.x,
y: p.position.y,
@@ -272,7 +272,7 @@ mod run {
for event in std::mem::take(&mut self.mods.events) {
let who = |w: &Who| {
let id = match w {
Who::Local => self.player_uuid_string(),
Who::Local => uuid_string(self.player_uuid()),
Who::Uuid(u) => uuid_string(*u),
Who::Conn(c) => {
let guests = self.lan.iter().flat_map(|h| &h.hub.guests);
@@ -490,7 +490,7 @@ mod run {
let local = self.options.execution_role.is_client()
&& self.remote.is_none()
&& self.player.is_some();
if local && self.player_uuid_string() == id {
if local && uuid_string(self.player_uuid()) == id {
self.add_mod_chat_line(line);
return;
}
+22 -3
View File
@@ -34,6 +34,15 @@ fn sign_box() -> EditBox {
edit
}
/// AbstractSignEditScreen.init's Done button: `width / 2 - 100`,
/// `height / 4 + 144`, int GUI pixels.
fn done_button(w: f32, h: f32) -> (f32, f32) {
(
(w.ceil() / 2.0).floor() - 100.0,
(h.ceil() / 4.0).floor() + 144.0,
)
}
impl Game<'_> {
/// `SignBlockEntity.getSlotPlayerIsFacing`: whether the player stands in
/// front of the sign's text (within 90 degrees of its facing).
@@ -464,7 +473,7 @@ impl Game<'_> {
/// or a click on a line (`EditBox.onClick`).
pub(super) fn sign_edit_click(&mut self, size: [u32; 2]) {
let b = HudBatch::new(size);
let (x, y) = (b.width / 2.0 - 100.0, b.height / 4.0 + 144.0);
let (x, y) = done_button(b.width, b.height);
let (mx, my) = self.cursor;
if mx >= x && mx < x + 200.0 && my >= y && my < y + 20.0 {
self.audio.play_button_click();
@@ -628,7 +637,7 @@ impl Game<'_> {
);
}
}
let (bx, by) = (w / 2.0 - 100.0, h / 4.0 + 144.0);
let (bx, by) = done_button(w, h);
batch.sprite_scaled(
atlas,
"gui/sprites/widget/button",
@@ -642,10 +651,20 @@ impl Game<'_> {
batch.text(
atlas,
"Done",
bx + (200.0 - tw) / 2.0,
bx + 100.0 - (tw / 2.0).floor(),
by + 6.0,
u32::MAX,
true,
);
}
}
#[cfg(test)]
mod tests {
#[test]
fn done_button_sits_on_whole_gui_pixels() {
// `width / 2 - 100, height / 4 + 144` in ints: 334 high is 83 + 144.
assert_eq!(super::done_button(427.0, 334.0), (113.0, 227.0));
assert_eq!(super::done_button(320.0, 240.0), (60.0, 204.0));
}
}
+72 -38
View File
@@ -99,36 +99,16 @@ impl Game<'_> {
.flatten()
}
/// `ProjectileUtil.getHitEntitiesAlong` for the mobs: those whose box the
/// segment `from`..`to` meets, or comes within the margin of with no
/// block between.
fn mobs_along(&self, from: Vec3, to: Vec3, margin: f64) -> Vec<usize> {
let grow = Vec3::new(margin, margin, margin);
let mut hits = Vec::new();
for (i, m) in self.mobs.iter().enumerate() {
// PiercingWeapon.canHitEntity: alive, and not the vehicle ridden.
if !m.alive() || m.removed || self.riding == Some(m.id) {
continue;
}
let bounds: Aabb = m.bounds();
let hit = bounds.contains(from)
|| bounds.clip(from, to).is_some()
|| (margin > 0.0
&& bounds.inflate(grow).clip(from, to).is_some_and(|outside| {
let centre = bounds.center();
let toward = self.clip_collider(outside, centre).unwrap_or(centre);
bounds.clip(outside, toward).is_some()
}));
if hit {
hits.push(i);
}
}
hits
/// The boxes of the mobs a spear can hit: alive, and not the vehicle
/// ridden (`PiercingWeapon.canHitEntity`), with their index.
pub(super) fn hittable_mob_boxes(&self) -> Vec<(usize, Aabb)> {
(self.mobs.iter().enumerate())
.filter(|(_, m)| m.alive() && !m.removed && self.riding != Some(m.id))
.map(|(i, m)| (i, m.bounds()))
.collect()
}
/// `ProjectileUtil.getHitEntitiesAlong` with the spear's `AttackRange`: the
/// mobs along the view from the minimum reach to the maximum plus the
/// attacker's own movement, none when a block is closer than the minimum.
/// `ProjectileUtil.getHitEntitiesAlong` with the spear's `AttackRange`.
fn stab_targets(&self, eye: Vec3, look: Vec3, movement: Vec3) -> Vec<usize> {
// AttackRange.effectiveMinRange and effectiveMaxRange for a player.
let (min, max) = if self.creative {
@@ -136,16 +116,13 @@ impl Game<'_> {
} else {
(REACH.min, REACH.max)
};
let from = eye.add_vector(look.scale(f64::from(min)));
let mut to = eye.add_vector(look.scale(f64::from(max) + movement.dot(look).max(0.0)));
// A block closer than the minimum reach takes the whole stab.
if let Some(block) = self.clip_collider(eye, to) {
to = block;
if eye.distance_squared(to) < eye.distance_squared(from) {
return Vec::new();
}
}
self.mobs_along(from, to, f64::from(REACH.hitbox_margin))
let reach = (min, max, REACH.hitbox_margin);
let clip = |from, to| self.clip_collider(from, to);
let boxes = self.hittable_mob_boxes();
hits_in_reach(&boxes, eye, look, movement, reach, clip)
.into_iter()
.map(|(i, _)| i)
.collect()
}
/// `KineticWeapon.damageEntities`: one tick of the spear in use, `ticks`
@@ -411,3 +388,60 @@ impl Game<'_> {
}
}
}
/// `ProjectileUtil.getManyEntityHitResult` over `boxes`: those the segment
/// `from`..`to` meets, or comes within the margin of with no block between
/// (`clip`: where a segment first meets a block), each with where it is hit.
fn hits_along(
boxes: &[(usize, Aabb)],
from: Vec3,
to: Vec3,
margin: f64,
clip: &impl Fn(Vec3, Vec3) -> Option<Vec3>,
) -> Vec<(usize, Vec3)> {
let grow = Vec3::new(margin, margin, margin);
let mut hits = Vec::new();
for &(i, bounds) in boxes {
let hit = if bounds.contains(from) {
Some(from)
} else if let Some(at) = bounds.clip(from, to) {
Some(at)
} else if margin > 0.0 {
bounds.inflate(grow).clip(from, to).and_then(|outside| {
let centre = bounds.center();
let toward = clip(outside, centre).unwrap_or(centre);
bounds.clip(outside, toward)
})
} else {
None
};
if let Some(at) = hit {
hits.push((i, at));
}
}
hits
}
/// `ProjectileUtil.getHitEntitiesAlong` with an `AttackRange` of `reach` (its
/// effective min and max range and hitbox margin): the boxes along `look`
/// from the minimum reach to the maximum plus the attacker's own movement,
/// none when a block (`clip`) is closer than the minimum.
pub(super) fn hits_in_reach(
boxes: &[(usize, Aabb)],
eye: Vec3,
look: Vec3,
movement: Vec3,
(min, max, margin): (f32, f32, f32),
clip: impl Fn(Vec3, Vec3) -> Option<Vec3>,
) -> Vec<(usize, Vec3)> {
let from = eye.add_vector(look.scale(f64::from(min)));
let mut to = eye.add_vector(look.scale(f64::from(max) + movement.dot(look).max(0.0)));
// A block closer than the minimum reach takes the whole stab.
if let Some(block) = clip(eye, to) {
to = block;
if eye.distance_squared(to) < eye.distance_squared(from) {
return Vec::new();
}
}
hits_along(boxes, from, to, f64::from(margin), &clip)
}
@@ -396,7 +396,7 @@ impl Loading {
}
}
// Mods' settings pages open only in a running game.
Event::ModSettings(_) | Event::NativeButton(..) => {}
Event::ModSettings(_) | Event::NativeSettings(_) | Event::NativeButton(..) => {}
// The startup menu runs before the game opens a control socket.
Event::ButtonClick | Event::CopyAgentDetails => {}
}
+24 -13
View File
@@ -1875,9 +1875,9 @@ impl Game<'_> {
"minecraft:tipped_arrow" => 0.125,
_ => 1.0,
};
for (text, cat) in brewing::tooltip(stack.potion(), scale, &|k| {
e.translate(k).map(str::to_owned)
}) {
// PotionContents.getPotionDescription: the effect names are
// translatable, read in the selected language like every other line.
for (text, cat) in brewing::tooltip(stack.potion(), scale, &translate) {
let c = match cat {
brewing::Category::Beneficial => rgba(85, 85, 255, 255),
brewing::Category::Harmful => rgba(255, 85, 85, 255),
@@ -1902,18 +1902,15 @@ impl Game<'_> {
{
push(
&mut lines,
e.translate("item.minecraft.smithing_template.upgrade")
.unwrap_or("Upgrade:")
.to_owned(),
translate("item.minecraft.smithing_template.upgrade")
.unwrap_or_else(|| "Upgrade:".to_owned()),
rgba(170, 170, 170, 255),
);
let short = |s: &str| s.trim_start_matches("minecraft:").to_owned();
let p = e
.translate(&format!("trim_pattern.minecraft.{}", short(&pattern)))
.map_or_else(|| short(&pattern), str::to_owned);
let m = e
.translate(&format!("trim_material.minecraft.{}", short(&material)))
.map_or_else(|| short(&material), str::to_owned);
let p = translate(&format!("trim_pattern.minecraft.{}", short(&pattern)))
.unwrap_or_else(|| short(&pattern));
let m = translate(&format!("trim_material.minecraft.{}", short(&material)))
.unwrap_or_else(|| short(&material));
push(&mut lines, format!(" {p}"), rgba(170, 170, 170, 255));
push(&mut lines, format!(" {m}"), rgba(170, 170, 170, 255));
}
@@ -2251,10 +2248,24 @@ impl Game<'_> {
x: f32,
y: f32,
) {
let lines = Self::resolved(&self.stack_tooltip(stack));
self.draw_stack_tooltip_with(batch, stack, x, y, Vec::new());
}
/// [`Self::draw_stack_tooltip`] with `after_name` inserted from line 1,
/// as the creative screen adds its tab names.
pub(super) fn draw_stack_tooltip_with(
&mut self,
batch: &mut HudBatch,
stack: &ItemStack,
x: f32,
y: f32,
after_name: Vec<(horizon_client::text::Text, u32)>,
) {
let mut lines = self.stack_tooltip(stack);
if lines.is_empty() {
return;
}
lines.splice(1..1, after_name);
let lines = Self::resolved(&lines);
// The component's height does not depend on where it is drawn, so a
// probe at the origin gives the box its extra height.
let probe = horizon_client::bundle::tooltip_layout(self.res.items(), stack, 0.0, 0.0, 96.0);
+35 -19
View File
@@ -364,14 +364,24 @@ impl Game<'_> {
}
}
/// `Villager.setLastHurtByMob` and `ServerLevel.onReputationEvent`: a hit
/// from the player counts against it with the villager that was hit.
pub(super) fn villager_reputation_tick(&mut self) {
let Some(uuid) = self.level.player_uuid else {
return;
/// The UUID of the player who last hurt a mob (`last_hurt_by_player`:
/// None for the host, else the guest's connection): the reputation a
/// villager keeps is that player's, the one trading looks up.
fn player_uuid_of(&self, who: Option<u32>) -> Option<[i32; 4]> {
let uuid = match who {
None => return self.level.player_uuid,
// While that guest acts its own UUID is swapped in.
Some(conn) if self.lan_acting == Some(conn) => self.lan_acting_uuid?,
Some(conn) => self.lan.as_ref()?.hub.guest(conn)?.uuid,
};
Some(horizon_client::lan::playerdata::uuid_ints(uuid))
}
/// `Villager.setLastHurtByMob` and `ServerLevel.onReputationEvent`: a hit
/// from a player counts against that player with the villager that was hit.
pub(super) fn villager_reputation_tick(&mut self) {
let mut angry = Vec::new();
for m in &mut self.mobs {
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if m.species() != Species::Villager
|| m.ai.last_hurt_by != Some(horizon_client::mobs::Who::Player)
{
@@ -380,17 +390,22 @@ impl Game<'_> {
let time = m.ai.last_hurt_by_time;
let alive = m.alive();
let at = m.id;
let e = villager::ext_mut(m);
if e.hurt_seen != time {
e.hurt_seen = time;
e.gossips.add(
uuid,
GossipType::MinorNegative,
gossip::REPUTATION_CHANGE_PER_EVENT,
);
if alive {
angry.push(at);
}
if villager::ext(m).is_some_and(|e| e.hurt_seen == time) {
continue;
}
let hurt_by = self.last_hurt_by_player.get(&at).copied().flatten();
let Some(uuid) = self.player_uuid_of(hurt_by) else {
continue;
};
let e = villager::ext_mut(&mut self.mobs[i]);
e.hurt_seen = time;
e.gossips.add(
uuid,
GossipType::MinorNegative,
gossip::REPUTATION_CHANGE_PER_EVENT,
);
if alive {
angry.push(at);
}
}
// Villager.setLastHurtByMob: entity event 13.
@@ -400,11 +415,12 @@ impl Game<'_> {
}
/// `Villager.tellWitnessesThatIWasMurdered`: the villagers that saw a
/// player kill one remember it as a major wrong.
pub(super) fn villager_murdered(&mut self, victim: &Mob, by_player: bool) {
/// `killer` is the guest connection that last hurt it (None: the host).
pub(super) fn villager_murdered(&mut self, victim: &Mob, by_player: bool, killer: Option<u32>) {
if victim.species() != Species::Villager || !by_player {
return;
}
let Some(uuid) = self.level.player_uuid else {
let Some(uuid) = self.player_uuid_of(killer) else {
return;
};
let eye = Vec3::new(victim.position.x, victim.eye_y(), victim.position.z);
+12
View File
@@ -493,6 +493,18 @@ impl Hub {
overlay: false,
}));
}
/// A system message for the guests `hears` picks by name
/// (`PlayerList.broadcastSystemToTeam` and its kin).
pub fn system_message_to(&self, content: Tag, hears: impl Fn(&str) -> bool) {
let body = C::SystemChat(play::SystemChat {
content,
overlay: false,
})
.encode();
for g in self.guests.iter().filter(|g| g.placed && hears(&g.name)) {
g.sender.send(body.clone());
}
}
/// `ServerPlayer.sendOverlayMessage` to every guest (the action bar).
pub fn overlay_message(&self, content: Tag) {
self.broadcast(&C::SystemChat(play::SystemChat {
+81 -7
View File
@@ -539,8 +539,36 @@ pub enum DamageKind {
Freeze,
/// `fell_out_of_world`: below the world's floor.
OutOfWorld,
/// `cramming`: too many entities in one place (`maxEntityCramming`).
/// `cramming`: crushed by the mobs around it.
Cramming,
/// `falling_anvil`: an anvil landing on it (it damages a helmet).
FallingAnvil,
/// `fireworks`: a firework rocket's burst (an explosion for Blast Protection).
Fireworks,
/// `generic_kill`: a kill that bypasses invulnerability.
GenericKill,
/// `lightning_bolt`.
LightningBolt,
/// `outside_border`: past the world border.
OutsideBorder,
/// `spit`: a llama's spit.
Spit,
/// `stalagmite`: a fall onto a stalagmite (a fall for Feather Falling).
Stalagmite,
/// `starve`: no food left.
Starve,
/// `sulfur_cube_hot`: a sulfur cube's hot floor (a fire).
SulfurCubeHot,
/// `wind_charge`: a wind charge's burst (a projectile).
WindCharge,
/// `wither`: the wither effect.
Wither,
}
impl DamageKind {
/// DamageTypeTags.BYPASSES_INVULNERABILITY
pub fn bypasses_invulnerability(self) -> bool {
matches!(self, DamageKind::OutOfWorld | DamageKind::GenericKill)
}
}
/// A damage source: its type, the attacking entity and where it came from.
#[derive(Clone, Copy, Debug)]
@@ -604,6 +632,15 @@ impl Damage {
| DamageKind::Generic
| DamageKind::InWall
| DamageKind::SweetBerryBush
| DamageKind::OutOfWorld
| DamageKind::FallingBlock
| DamageKind::FallingAnvil
| DamageKind::Cramming
| DamageKind::GenericKill
| DamageKind::OutsideBorder
| DamageKind::Spit
| DamageKind::Stalagmite
| DamageKind::Starve
)
}
/// DamageTypeTags.BYPASSES_ARMOR
@@ -620,12 +657,21 @@ impl Damage {
| DamageKind::Freeze
| DamageKind::OutOfWorld
| DamageKind::Cramming
| DamageKind::GenericKill
| DamageKind::OutsideBorder
| DamageKind::Stalagmite
| DamageKind::Starve
| DamageKind::Wither
)
}
pub fn is_fire(&self) -> bool {
matches!(
self.kind,
DamageKind::OnFire | DamageKind::InFire | DamageKind::Lava | DamageKind::Fireball
DamageKind::OnFire
| DamageKind::InFire
| DamageKind::Lava
| DamageKind::Fireball
| DamageKind::SulfurCubeHot
)
}
/// DamageTypeTags.NO_KNOCKBACK
@@ -646,13 +692,20 @@ impl Damage {
| DamageKind::InWall
| DamageKind::Freeze
| DamageKind::OutOfWorld
| DamageKind::LightningBolt
| DamageKind::Cramming
| DamageKind::GenericKill
| DamageKind::OutsideBorder
| DamageKind::Stalagmite
| DamageKind::Starve
| DamageKind::SulfurCubeHot
| DamageKind::Wither
)
}
pub fn is_projectile(&self) -> bool {
matches!(
self.kind,
DamageKind::Arrow | DamageKind::Thrown | DamageKind::Fireball
DamageKind::Arrow | DamageKind::Thrown | DamageKind::Fireball | DamageKind::WindCharge
)
}
}
@@ -779,6 +832,8 @@ pub struct Extra {
/// The arm the current swing moves (`currentSwing.hand().asArm`): `Some(true)`
/// for the right arm.
pub swing_right_arm: Option<bool>,
/// The current swing is a STAB (`SwingAnimationType` 2): a spear's jab.
pub swing_stab: bool,
pub flag: bool,
pub flag2: bool,
/// Species scratch values (the wither's head targets and clocks).
@@ -1569,6 +1624,11 @@ impl Mob {
.sum();
(base - 0.15 * f64::from(protection)).max(0.0)
}
/// Entity.isOnFire: fire-immune mobs never count as burning, even on the
/// tick a sun or fire check set their timer before `base_tick` clears it.
pub fn is_on_fire(&self) -> bool {
!self.kind.flags.fire_immune && self.fire_ticks > 0
}
pub fn ignite_for_ticks(&mut self, ticks: i32) {
self.fire_ticks = self
.fire_ticks
@@ -2093,7 +2153,11 @@ impl Mob {
) -> bool {
axolotl::before_damage(self, amount, &source);
raider::note_hurt(self);
if !self.alive() || self.removed || self.invulnerable_flag {
// LivingEntity.isInvulnerableTo: only #bypasses_invulnerability gets past it.
if !self.alive()
|| self.removed
|| (self.invulnerable_flag && !source.kind.bypasses_invulnerability())
{
return false;
}
// Ghast.isReflectedFireball: a fireball a player turned back is
@@ -2111,9 +2175,10 @@ impl Mob {
if self.species() == Species::Armadillo {
amount = armadillo::reduce_damage(self, amount);
}
// #damages_helmet: falling anvils, blocks and stalactites.
if matches!(
source.kind,
DamageKind::FallingBlock | DamageKind::FallingStalactite
DamageKind::FallingBlock | DamageKind::FallingAnvil | DamageKind::FallingStalactite
) && !species::body_slot(self.species())
&& self.extra.head.is_some()
{
@@ -2269,6 +2334,10 @@ impl Mob {
| DamageKind::OutOfWorld
| DamageKind::Cramming
| DamageKind::Generic
| DamageKind::GenericKill
| DamageKind::OutsideBorder
| DamageKind::Starve
| DamageKind::Wither
)
}
/// `Wolf.actuallyHurt`: the armor loses `ceil(damage)` durability, cracks
@@ -2364,6 +2433,11 @@ impl Mob {
pub fn is_currently_glowing(&self) -> bool {
self.glowing || self.has_effect("minecraft:glowing")
}
/// `Entity.isInvisible`: the shared flag 0x20 a server sends (`invisible`),
/// or the Invisibility effect on a mob this game ticks itself.
pub fn is_invisible(&self) -> bool {
self.invisible || self.has_effect("minecraft:invisibility")
}
pub fn add_effect(&mut self, effect: &'static str, ticks: i32, amplifier: i32) {
if let Some(e) = self.extra.effects.iter_mut().find(|e| e.0 == effect) {
// MobEffectInstance.update: a stronger effect replaces with its
@@ -2866,12 +2940,12 @@ mod teams_tests {
}
}
#[cfg(test)]
mod sensor_scan_tests {
pub(crate) mod sensor_scan_tests {
use super::*;
use horizon_core::geometry::Vec3;
use serde_json::json;
pub(super) fn kind() -> Arc<MobType> {
pub(crate) fn kind() -> Arc<MobType> {
Arc::new(MobType {
id: "minecraft:cow".into(),
species: Species::Cow,
+30 -16
View File
@@ -83,6 +83,7 @@ impl Mob {
pub fn swing(&mut self) {
if self.start_swing(6) {
self.extra.swing_right_arm = None;
self.extra.swing_stab = false;
}
}
/// `LivingEntity.swing(hand, animation, false)` for a swing of `duration`
@@ -90,24 +91,25 @@ impl Mob {
pub fn swing_hand(&mut self, off_hand: bool, duration: i32) {
if self.start_swing(duration) {
self.extra.swing_right_arm = Some(!off_hand != self.main_arm_left());
self.extra.swing_stab = false;
}
}
/// `swing_hand` for a swing of `base` ticks, lengthened or shortened by
/// `swing_hand` for a swing of `animation`, lengthened or shortened by
/// this mob's haste, conduit power or mining fatigue
/// (`LivingEntity.getModifiedSwingDuration`).
pub fn swing_hand_modified(&mut self, off_hand: bool, base: i32) {
pub fn swing_hand_modified(
&mut self,
off_hand: bool,
animation: crate::first_person::SwingAnimation,
) {
let amplifier = |id: &str| self.extra.effects.iter().find(|e| e.0 == id).map(|e| e.2);
let dig_speed = amplifier("minecraft:haste").max(amplifier("minecraft:conduit_power"));
let fatigue = amplifier("minecraft:mining_fatigue");
let duration = crate::first_person::swing_duration(
crate::first_person::SwingAnimation {
kind: 1,
duration: base,
},
dig_speed,
fatigue,
);
self.swing_hand(off_hand, duration);
let duration = crate::first_person::swing_duration(animation, dig_speed, fatigue);
if self.start_swing(duration) {
self.extra.swing_right_arm = Some(!off_hand != self.main_arm_left());
self.extra.swing_stab = animation.kind == 2;
}
}
}
/// The module implementing a species' behaviour.
@@ -472,6 +474,11 @@ pub fn tick(
if m.removed {
return false;
}
if m.species() == Species::ZombieVillager {
// ZombieVillager.tick counts the cure down before super.tick, with
// or without AI (NoAI only skips serverAiStep).
super::zombie_villager::step(m, &mut Cx { w, events });
}
super::warden::pre_tick(m, w, events);
if m.species() == Species::Strider {
super::strider::pre_tick(m, w, events);
@@ -489,7 +496,9 @@ pub fn tick(
}
if m.no_ai {
// Mob.aiStep still runs without AI (only serverAiStep is skipped),
// so the endermite's life keeps counting.
// so undead still burn in daylight and the endermite's life keeps
// counting.
burn_undead(m, w);
if m.species() == Species::Endermite {
endermite_lifetime(m);
}
@@ -610,6 +619,7 @@ pub fn tick_swing(m: &mut Mob) {
m.ai.swinging = false;
m.ai.swing_duration = 0;
m.extra.swing_right_arm = None;
m.extra.swing_stab = false;
}
} else {
m.ai.swing_time = 0;
@@ -1044,10 +1054,7 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
}
}
}
// Mob.aiStep: undead burn in daylight.
if m.kind.flags.burns_in_day && m.alive() && sun_burn_tick(m, w) && m.extra.head.is_none() {
m.ignite_for_ticks(160);
}
burn_undead(m, w);
species_ai_step(m, w, level_random, events);
}
impl Mob {
@@ -1146,6 +1153,13 @@ fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
}
}
/// `Mob.aiStep` -> `Mob.burnUndead`: undead burn in daylight. It runs with
/// or without AI (`NoAI` only skips `serverAiStep`).
fn burn_undead(m: &mut Mob, w: &MobWorld) {
if m.kind.flags.burns_in_day && m.alive() && sun_burn_tick(m, w) && m.extra.head.is_none() {
m.ignite_for_ticks(160);
}
}
fn sun_burn_tick(m: &mut Mob, w: &MobWorld) -> bool {
// Mob.isSunBurnTick: the monsters_burn attribute, not the sky darkness.
if !w.monsters_burn {
+43 -12
View File
@@ -295,6 +295,8 @@ pub struct Hum {
pub using_item: bool,
/// The arm the current swing moves (`Some(true)` for the right one).
pub swing_right_arm: Option<bool>,
/// The swing is a STAB (`SwingAnimationType` 2): a spear's jab.
pub stab: bool,
/// `mainArm` is the left one (`Mob.isLeftHanded`): a mob's swing uses it.
pub left_main: bool,
/// The spear each arm (right, left) holds, and how long that arm has
@@ -318,6 +320,7 @@ impl Hum {
swim: 0.0,
using_item: false,
swing_right_arm: None,
stab: false,
left_main: false,
spear: [None; 2],
spear_ticks: [0.0; 2],
@@ -453,7 +456,7 @@ pub(super) fn humanoid(p: &mut Poses, s: &State, h: &Hum) {
pose_arm(p, h.right, true, h);
}
}
// setupAttackAnimation (WHACK swing with the arm of the swinging hand).
// setupAttackAnimation: a WHACK or STAB swing of the swinging hand's arm.
let hx = p.get("head")[3];
let swing = s.swing;
let swing_left = h.swing_right_arm.map_or(h.left_main, |right| !right);
@@ -476,17 +479,23 @@ pub(super) fn humanoid(p: &mut Poses, s: &State, h: &Hum) {
l[2] = -body_y.msin() * 5.0 * age_scale;
l[0] = body_y.mcos() * 5.0 * age_scale;
}
p.get("right_arm")[4] += body_y;
p.get("left_arm")[4] += body_y;
p.get("left_arm")[3] += body_y;
// Ease.outQuart
let eased = 1.0 - (1.0 - swing).powi(4);
let aa = (eased * PI).msin();
let bb = (swing * PI).msin() * -(hx - 0.7) * 0.75;
let r = p.get(if swing_left { "left_arm" } else { "right_arm" });
r[3] -= aa * 1.2 + bb;
r[4] += body_y * 2.0;
r[5] += (swing * PI).msin() * -0.4;
let arm = if swing_left { "left_arm" } else { "right_arm" };
if h.stab {
// SpearAnimations.thirdPersonAttackHand: the arms keep their turn.
p.get(arm)[3] += crate::spear::third_person_attack_arm(swing);
} else {
p.get("right_arm")[4] += body_y;
p.get("left_arm")[4] += body_y;
p.get("left_arm")[3] += body_y;
// Ease.outQuart
let eased = 1.0 - (1.0 - swing).powi(4);
let aa = (eased * PI).msin();
let bb = (swing * PI).msin() * -(hx - 0.7) * 0.75;
let r = p.get(arm);
r[3] -= aa * 1.2 + bb;
r[4] += body_y * 2.0;
r[5] += (swing * PI).msin() * -0.4;
}
}
if let Some(aggressive) = h.zombie {
// AnimationUtils.animateZombieArms
@@ -789,6 +798,7 @@ pub fn setup(
h.swim = m.extra.swim.amount(s.partial);
h.using_item = m.extra.using_item > 0;
h.swing_right_arm = m.extra.swing_right_arm;
h.stab = m.extra.swing_stab;
h.left_main = m.main_arm_left();
let main_side = usize::from(m.main_arm_left());
h.spear[main_side] = main_spear;
@@ -1047,6 +1057,27 @@ mod flight_pose_tests {
);
assert!(swinging.get("right_arm")[4] < 1.0);
assert!(same_angle(swinging.get("left_arm")[4], PI));
// A STAB swing turns that arm's pitch alone, by the spear's curve.
let mut stabbing = make();
h.swim = 0.0;
h.stab = true;
let mut still = make();
humanoid(
&mut still,
&state,
&Hum::new(ArmPose::Empty, ArmPose::Empty),
);
let stab = State {
swing: 0.2,
..state
};
humanoid(&mut stabbing, &stab, &h);
let arm_pitch = crate::spear::third_person_attack_arm(0.2);
assert!(same_angle(
stabbing.get("right_arm")[3],
still.get("right_arm")[3] + arm_pitch
));
assert_eq!(stabbing.get("left_arm")[3], still.get("left_arm")[3]);
// Out of the pose nothing changes.
let mut dry = make();
humanoid(&mut dry, &state, &Hum::new(ArmPose::Empty, ArmPose::Empty));
+1 -1
View File
@@ -106,7 +106,7 @@ pub fn tear_down(m: &mut Mob, events: &mut Vec<MobEvent>) {
/// invulnerability animation and the heart's reaction. Returns whether the
/// normal damage path should run.
pub fn accepts_damage(m: &mut Mob, source: &Damage) -> bool {
if !heart_bound(m) || source.kind == DamageKind::Generic {
if !heart_bound(m) || source.kind.bypasses_invulnerability() {
return true;
}
if source.is_fire() || !m.alive() || m.extra.ints[INVULNERABLE] > 0 {
+32 -2
View File
@@ -182,9 +182,10 @@ pub fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) {
// Being angry (target set) makes the head "creepy" (render).
m.extra.flag = m.ai.target.is_some();
}
/// Enderman.hurtServer: projectiles miss (teleport away), others may teleport.
/// Enderman.hurtServer: projectiles miss (teleport away) unless it rides
/// something (`!isPassenger()`), others may teleport.
pub fn on_damage(m: &mut Mob, source: &Damage) -> bool {
if source.is_projectile() {
if source.is_projectile() && !m.extra.passenger {
// repeatedlyTryToTeleport is applied by the next AI step.
m.extra.counter2 = 64;
return false;
@@ -434,3 +435,32 @@ impl Goal for LeaveBlock {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mobs::DamageKind;
use std::sync::Arc;
fn enderman() -> Mob {
let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
kind.species = Species::Enderman;
Mob::new(1, Arc::new(kind), Vec3::ZERO, 0.0)
}
/// Enderman.hurtServer: a projectile makes a free enderman teleport away
/// unhurt, but one that rides something (`isPassenger`) takes the hit.
#[test]
fn projectiles_hit_a_seated_enderman() {
for kind in [DamageKind::Arrow, DamageKind::Thrown, DamageKind::Fireball] {
let mut free = enderman();
assert!(!on_damage(&mut free, &Damage::of(kind)), "{kind:?}");
assert_eq!(free.extra.counter2, 64);
let mut seated = enderman();
seated.extra.passenger = true;
assert!(on_damage(&mut seated, &Damage::of(kind)), "{kind:?}");
assert_eq!(seated.extra.counter2, 0, "no teleport while seated");
}
}
}
+59 -13
View File
@@ -1,6 +1,7 @@
//! Horses, donkeys, mules and the undead horses (`AbstractHorse`): random
//! stats, coat colours and markings, eating, rearing, taming by riding,
//! the ridden movement and jump, offspring stats and `AbstractEquineModel`.
use super::finalize::{Attr, set_base};
use super::{
Mob, MobEvent, MobWorld, Species,
anim::{Poses, State},
@@ -125,20 +126,22 @@ pub fn register(m: &mut Mob) {
}
/// `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.
/// the jump, zombie horses a smaller jump and their own speed. The health and
/// speed are attribute bases (`setBaseValue`), so the server syncs them to
/// the clients that draw the mount's hearts.
pub fn finalize(m: &mut Mob) {
let species = m.species();
let r = &mut m.random;
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;
}
let health = matches!(species, Species::Horse | Species::Donkey | Species::Mule).then(|| {
15.0 + r.next_int_bound(8).unwrap_or(0) as f32 + r.next_int_bound(9).unwrap_or(0) as f32
});
let mut speed = None;
match species {
Species::Horse => {
m.attrs.speed =
speed = Some(
(0.45 + r.next_double() * 0.3 + r.next_double() * 0.3 + r.next_double() * 0.3)
* 0.25;
* 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;
@@ -150,14 +153,21 @@ pub fn finalize(m: &mut Mob) {
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);
speed = Some(
(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
}
}
if let Some(health) = health {
set_base(m, Attr::MaxHealth, f64::from(health));
}
if let Some(speed) = speed {
set_base(m, Attr::MovementSpeed, speed);
}
m.health = m.attrs.max_health;
}
/// `AbstractHorse.createOffspringAttribute`.
@@ -177,16 +187,18 @@ pub fn offspring(a: f64, b: f64, min: f64, max: f64, r: &mut impl RandomSource)
}
/// `AbstractHorse.setOffspringAttributes` from two parents.
pub fn inherit(child: &mut Mob, a: &Mob, b: &Mob, r: &mut impl RandomSource) {
child.attrs.max_health = offspring(
let health = offspring(
f64::from(a.attrs.max_health),
f64::from(b.attrs.max_health),
15.0,
30.0,
r,
) as f32;
);
set_base(child, Attr::MaxHealth, f64::from(health as f32));
child.health = child.attrs.max_health;
child.extra.vector[0] = offspring(a.extra.vector[0], b.extra.vector[0], 0.4, 1.0, r);
child.attrs.speed = offspring(a.attrs.speed, b.attrs.speed, 0.1125, 0.3375, r);
let speed = offspring(a.attrs.speed, b.attrs.speed, 0.1125, 0.3375, r);
set_base(child, Attr::MovementSpeed, speed);
}
/// The rider controls the mount: saddled, with a player on it.
pub fn controlled(m: &Mob) -> bool {
@@ -844,3 +856,37 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses) {
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mobs::finalize::base_value;
use horizon_core::geometry::Vec3;
use std::sync::Arc;
fn horse(species: Species) -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let k = Arc::make_mut(&mut kind);
k.species = species;
k.max_health = 53.0;
Mob::new(1, kind, Vec3::ZERO, 0.0)
}
/// The rolled health is the attribute's base, which the server syncs: a
/// rider's client draws the horse's hearts from it, not the type's 53.
#[test]
fn rolled_health_and_speed_are_attribute_bases() {
for species in [Species::Horse, Species::Donkey, Species::Mule] {
let mut m = horse(species);
finalize(&mut m);
assert!((15.0..=30.0).contains(&m.attrs.max_health));
assert_eq!(
base_value(&m, Attr::MaxHealth),
f64::from(m.attrs.max_health)
);
assert_eq!(m.health, m.attrs.max_health);
}
let mut m = horse(Species::Horse);
finalize(&mut m);
assert_eq!(base_value(&m, Attr::MovementSpeed), m.attrs.speed);
}
}
+18 -5
View File
@@ -173,16 +173,21 @@ pub fn protection(m: &Mob, kind: DamageKind) -> f32 {
protection_points(g, kind).min(20.0)
}
pub fn protection_points(g: &Gear, kind: DamageKind) -> f32 {
if kind == DamageKind::Generic {
// Protection's `damage_protection` skips #bypasses_invulnerability.
if kind == DamageKind::Generic || kind.bypasses_invulnerability() {
return 0.0;
}
let fire = matches!(
kind,
DamageKind::OnFire | DamageKind::InFire | DamageKind::Lava | DamageKind::Fireball
DamageKind::OnFire
| DamageKind::InFire
| DamageKind::Lava
| DamageKind::Fireball
| DamageKind::SulfurCubeHot
);
let projectile = matches!(
kind,
DamageKind::Arrow | DamageKind::Thrown | DamageKind::Fireball
DamageKind::Arrow | DamageKind::Thrown | DamageKind::Fireball | DamageKind::WindCharge
);
let mut total = 0.0;
for slot in ARMOR {
@@ -191,9 +196,17 @@ pub fn protection_points(g: &Gear, kind: DamageKind) -> f32 {
total += match &**id {
"minecraft:protection" => l,
"minecraft:fire_protection" if fire => 2.0 * l,
"minecraft:blast_protection" if kind == DamageKind::Explosion => 2.0 * l,
"minecraft:blast_protection"
if matches!(kind, DamageKind::Explosion | DamageKind::Fireworks) =>
{
2.0 * l
}
"minecraft:projectile_protection" if projectile => 2.0 * l,
"minecraft:feather_falling" if kind == DamageKind::Fall => 3.0 * l,
"minecraft:feather_falling"
if matches!(kind, DamageKind::Fall | DamageKind::Stalagmite) =>
{
3.0 * l
}
_ => 0.0,
};
}
+4 -3
View File
@@ -2,6 +2,7 @@
//! strength, chests and carpets, taming by riding (maximum temper 30), the
//! spit ranged attack, hunting wolves (and, for traders, zombies and
//! illagers) and `LlamaModel`.
use super::finalize::{Attr, set_base};
use super::{
Mob, MobEvent, MobWorld, Seen, Species, Who, equine,
goals::{
@@ -103,7 +104,7 @@ pub fn finalize(m: &mut Mob) {
let max = if r.next_float() < 0.04 { 5 } else { 3 };
let strength = 1 + r.next_int_bound(max).unwrap_or(0);
let variant = r.next_int_bound(4).unwrap_or(0);
m.attrs.max_health = health;
set_base(m, Attr::MaxHealth, f64::from(health));
m.health = health;
m.extra.variant2 = strength as u8;
m.extra.variant = variant as u8;
@@ -170,10 +171,10 @@ pub fn breed_offspring(m: &mut Mob, partner: &Seen) -> Offspring {
}
/// The game makes the baby drawn by `breed_offspring`.
pub fn apply_offspring(child: &mut Mob, o: &Offspring) {
child.attrs.max_health = o.max_health;
set_base(child, Attr::MaxHealth, f64::from(o.max_health));
child.health = o.max_health;
child.extra.vector[0] = o.jump;
child.attrs.speed = o.speed;
set_base(child, Attr::MovementSpeed, o.speed);
child.extra.variant2 = o.strength;
child.extra.variant = o.variant;
if is_trader(child) {
+112 -4
View File
@@ -691,10 +691,12 @@ pub fn layers(m: &Mob) -> Vec<Layer> {
white,
true,
),
// The copper golem's eyes glow (LivingEntityEmissiveLayer, eyes render type).
// The copper golem's eyes glow: CopperGolemRenderer's
// LivingEntityEmissiveLayer (eyes render type) draws on a model baked
// from ModelLayers.COPPER_GOLEM, the body's, not COPPER_GOLEM_EYES.
S::CopperGolem => {
let eyes = super::copper_golem::eye_texture(m);
push(&mut v, "copper_golem#eyes", &eyes, white, true);
push(&mut v, &m.kind.model_key(), &eyes, white, true);
}
S::Spider | S::CaveSpider => push(
&mut v,
@@ -1007,7 +1009,7 @@ fn shaking(m: &Mob) -> bool {
match m.species() {
Species::Strider => super::strider::suffocating(m),
Species::ZombieVillager => super::zombie_villager::is_converting(m),
Species::Zombie | Species::Husk => m.extra.counter2 > 0,
Species::Zombie | Species::Husk | Species::Skeleton => m.extra.counter2 > 0,
Species::Piglin | Species::PiglinBrute | Species::Hoglin => {
!m.extra.flag2 && m.extra.counter2 > 0
}
@@ -1568,6 +1570,43 @@ pub fn part_transform(
anim::setup(m, &s, &mut poses, &data.animations);
part_matrix(&model, base, &poses, part, 0.0)
}
/// `FoxHeldItemLayer`: the item sits at the head's position under the render
/// state's head roll, yaw and pitch (not the posed head's, which differ while
/// it sleeps), pushed out to the mouth.
fn fox_item_transform(m: &Mob, alpha: f32, camera: glam::DVec3, data: &MobData) -> glam::Mat4 {
use glam::{Mat4, Vec3};
let s = anim::state(m, alpha);
let (model_key, _) = body(m);
let model = m
.kind
.layer(&model_key)
.cloned()
.unwrap_or_else(|| m.kind.model.clone());
let mut poses = Poses::new(&model);
anim::setup(m, &s, &mut poses, &data.animations);
let head = *poses.get("head");
let fox = super::fox::pose(m, alpha);
let baby = m.is_baby();
let mut t = base_transform(m, alpha, camera)
* Mat4::from_translation(Vec3::new(head[0], head[1], head[2]) / 16.0);
if baby {
t *= Mat4::from_scale(Vec3::splat(0.75));
}
t *= Mat4::from_rotation_z(fox.head_roll_angle)
* Mat4::from_rotation_y(s.y_rot.to_radians())
* Mat4::from_rotation_x(s.x_rot.to_radians());
let (x, y, z) = match (baby, fox.sleeping) {
(true, true) => (0.4, 0.26, 0.15),
(true, false) => (0.06, 0.26, -0.5),
(false, true) => (0.46, 0.26, 0.22),
(false, false) => (0.06, 0.27, -0.5),
};
t *= Mat4::from_translation(Vec3::new(x, y, z)) * Mat4::from_rotation_x(90.0f32.to_radians());
if fox.sleeping {
t *= Mat4::from_rotation_z(90.0f32.to_radians());
}
t
}
/// The pose a mob's held item is submitted under (`ItemInHandLayer`:
/// `translateToHand`, then the hand offset; the item model adds its own
/// display transform), or `None` when the mob shows no held items.
@@ -1584,6 +1623,10 @@ pub fn held_item_transform(
.filter(|_| !left)
.map(|offset| base_transform(m, alpha, camera) * offset);
}
// FoxHeldItemLayer: the main hand's item in its mouth.
if m.species() == Species::Fox {
return (!left).then(|| fox_item_transform(m, alpha, camera, data));
}
if !super::illager::shows_item(m) {
return None;
}
@@ -1598,7 +1641,20 @@ pub fn held_item_transform(
let mut poses = Poses::new(&model);
anim::setup(m, &s, &mut poses, &data.animations);
let mut t = hand_base(m, &model, base, &poses, left)?;
// ItemInHandLayer: a spear in use (ArmPose.SPEAR's animateUseItem).
// ItemInHandLayer: a STAB swing of this arm thrusts its item out
// (SpearAnimations.thirdPersonAttackItem, the main hand's forward
// movement), and a spear in use moves with it (ArmPose.SPEAR's
// animateUseItem).
let swing_left = m.extra.swing_right_arm.map_or(m.main_arm_left(), |r| !r);
if m.extra.swing_stab && swing_left == left {
let forward = m
.extra
.main_hand
.as_deref()
.and_then(crate::spear::Kinetic::of)
.map_or(0.0, |k| k.forward_movement);
t *= crate::spear::third_person_attack_item(s.swing, forward);
}
let off_hand = left != m.main_arm_left();
if m.extra.using_item > 0
&& m.extra.using_off_hand == off_hand
@@ -1742,6 +1798,58 @@ mod tests {
assert!(vertices.iter().all(|v| v.uv[0] >= 0.5));
}
}
/// CopperGolemRenderer: the emissive eye layer draws the eye texture on
/// the body's model, every polygon of it, at each oxidation level. The
/// prepared `copper_golem#eyes` model has no polygons, so the eyes
/// showed the body texture's black sockets.
#[test]
fn copper_golem_eyes_are_drawn_on_the_body_model() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("entity-models.json").exists() {
return;
}
let types = super::super::MobTypes::load(&root).unwrap();
let kind = std::sync::Arc::new(types.types["minecraft:copper_golem"].clone());
let data = MobData {
states: Vec::new(),
placements: Default::default(),
heightmaps: Default::default(),
animations: Default::default(),
biome_tags: Default::default(),
entity_tags: Default::default(),
villager_hats: Default::default(),
pois: Default::default(),
};
let atlas = |name: &str| {
Some(if name.contains("copper_golem_eyes") {
[0.5, 0.5, 0.75, 0.75]
} else {
[0.0, 0.0, 0.25, 0.25]
})
};
for variant in 0..4 {
let mut mob = Mob::new(1, kind.clone(), horizon_core::geometry::Vec3::ZERO, 0.0);
mob.extra.variant = variant;
let mut vertices = Vec::new();
render(
&mob,
0.5,
glam::DVec3::ZERO,
&atlas,
0xf0f0,
&data,
&mut vertices,
);
let body = vertices.iter().filter(|v| v.uv[0] <= 0.25).count();
let eyes = vertices.iter().filter(|v| v.uv[0] >= 0.5).count();
assert!(body > 0, "variant {variant}: no body drawn");
assert_eq!(
eyes, body,
"variant {variant}: the eye layer covers the body"
);
}
}
/// `VexModel.translateToHand`: the held item is drawn at 0.55 scale and
/// moved by the vex's own offset, so the vex's sword is the tiny one
/// vanilla shows. It was drawn at the full size, like a zombie's.
+3 -1
View File
@@ -1470,15 +1470,17 @@ impl Def {
| S::Zoglin
| S::Wither
),
// The `burn_in_daylight` entity type tag.
burns_in_day: matches!(
s,
S::Zombie
| S::ZombieHorse
| S::Drowned
| S::ZombieVillager
| S::Skeleton
| S::Stray
| S::Bogged
| S::Parched
| S::WitherSkeleton
| S::Phantom
| S::ZombieNautilus
),
-3
View File
@@ -564,9 +564,6 @@ fn skeleton_freezing(m: &mut Mob, cx: &mut Cx) {
}
}
pub fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) {
if m.species() == Species::ZombieVillager {
super::zombie_villager::step(m, cx);
}
if m.species() == Species::Skeleton && m.alive() && !m.no_ai {
skeleton_freezing(m, cx);
}
+1 -1
View File
@@ -395,7 +395,7 @@ pub fn anger_level(m: &Mob) -> AngerLevel {
/// `Warden.isInvulnerableTo`: while digging or emerging.
pub fn accepts_damage(m: &Mob, source: &Damage) -> bool {
!(digging_or_emerging(m) && source.kind != DamageKind::Generic)
!(digging_or_emerging(m) && !source.kind.bypasses_invulnerability())
}
/// `Warden.hurtServer`'s reaction, handled at the warden's next tick.
pub fn hurt(m: &mut Mob, source: &Damage) {
+1 -1
View File
@@ -401,7 +401,7 @@ pub fn custom_server_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEven
/// `WitherBoss.hurtServer` filter: not while charging, no arrows once
/// powered, nothing from the undead.
pub fn accepts(m: &mut Mob, kind: DamageKind, attacker: Option<Who>, others_undead: bool) -> bool {
if m.extra.counter > 0 && kind != DamageKind::Generic {
if m.extra.counter > 0 && !kind.bypasses_invulnerability() {
return false;
}
if powered(m) && kind == DamageKind::Arrow {
@@ -162,6 +162,93 @@ mod tests {
put_cure(&mut map, Some(cure));
assert_eq!(parse_cure(&Tag::Compound(map)), Some(cure));
}
/// `ZombieVillager.tick` counts the cure down before `Zombie.tick`,
/// whatever the AI: a `NoAI` zombie villager finishes curing too (#3429).
#[test]
fn a_no_ai_zombie_villager_still_cures() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("entity-types.json").exists()
|| !root.join("movement-properties.nbt").exists()
{
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let registry = assets.registry;
let facts = crate::blocks::BlockFacts::load(&root, registry).unwrap();
let motion = crate::motion::MotionResources::load(&root, &assets).unwrap();
let data = super::super::MobData::load(&root, &facts);
let types = super::super::MobTypes::load(&root).unwrap();
let biomes = crate::biome::Biomes::load(&root).unwrap();
let items = crate::items::Items::load(&root, registry, &facts).unwrap();
let collision = crate::collisions::load(&root, registry).unwrap();
let air = registry.default_state("minecraft:air").unwrap();
let world = crate::world::World::new(air, 0);
let lid_openers = std::collections::HashMap::new();
let w = MobWorld {
teams: Default::default(),
world: &world,
registry,
facts: &facts,
fluid_tables: &motion.fluids,
movement: &motion.blocks,
collision: &collision,
data: &data,
player: None,
nearest_player: None,
others: &[],
others_grid: None,
others_index: None,
daylight: true,
monsters_burn: false,
game_time: 1000,
day_time: 18000,
raining: false,
thundering: false,
nether: false,
block_tags: &items.block_tags,
biomes: &biomes,
mob_griefing: true,
mob_drops: true,
max_entity_cramming: 24,
difficulty: super::super::spawning::LocalDifficulty::new(2, 18000, 0, 0.0),
end: false,
world_seed: 0,
sea_level: 63,
items: Some(&items),
lid_openers: &lid_openers,
};
for no_ai in [false, true] {
let kind = std::sync::Arc::new(types.types["minecraft:zombie_villager"].clone());
let mut m = Mob::new(
1,
kind,
horizon_core::geometry::Vec3::new(0.5, 0.0, 0.5),
0.0,
);
m.persistent = true;
m.no_ai = no_ai;
start_converting(&mut m, None, 3, &mut Vec::new());
let mut level_random = horizon_core::random::LegacyRandom::new(0);
let mut ticks = 0;
while ticks < 10 {
let mut events = Vec::new();
m.tick(&w, &mut level_random, &mut events);
ticks += 1;
if events
.iter()
.any(|e| matches!(e, MobEvent::CureZombieVillager { mob: 1 }))
{
break;
}
}
assert!(
ticks <= 3,
"no_ai={no_ai}: the cure must finish within its 3 ticks, took {ticks}"
);
}
}
#[test]
fn no_cure_saves_minus_one() {
let mut map = Compound::new();
+72 -25
View File
@@ -132,6 +132,22 @@ pub enum Outcome {
/// A button: x, y, width, label, enabled, id.
pub type ButtonSpec = (f32, f32, f32, String, bool, u8);
/// `this.width / 2` (or `height / 2`): `Screen`'s size is an int, so the
/// division drops the half and widgets stay on whole GUI pixels. A half
/// pixel lands between device pixels and draws thinner borders and uneven
/// glyphs at most window heights.
fn half(size: f32) -> f32 {
(size.ceil() / 2.0).floor()
}
/// `this.height / 4`, as an int division.
fn quarter(size: f32) -> f32 {
(size.ceil() / 4.0).floor()
}
/// `centeredText` around `width / 2`: `x - font.width(text) / 2`.
fn centered_x(width: f32, text_width: f32) -> f32 {
half(width) - half(text_width)
}
pub fn servers_file(saves: &Path) -> PathBuf {
saves
.parent()
@@ -548,24 +564,24 @@ impl Screen {
Self::Edit { address, packs, .. } => vec![
// ManageServerScreen's CycleButton, above Done.
b(
w / 2.0 - 100.0,
h / 4.0 + 72.0,
half(w) - 100.0,
quarter(h) + 72.0,
200.0,
&pack_setting_label(*packs),
true,
60,
),
b(
w / 2.0 - 100.0,
h / 4.0 + 96.0 + 18.0,
half(w) - 100.0,
quarter(h) + 96.0 + 18.0,
200.0,
&crate::lang::get_or("gui.done", "Done"),
servers::parse_address(address).is_some(),
50,
),
b(
w / 2.0 - 100.0,
h / 4.0 + 120.0 + 18.0,
half(w) - 100.0,
quarter(h) + 120.0 + 18.0,
200.0,
&crate::lang::get_or("gui.cancel", "Cancel"),
true,
@@ -597,16 +613,16 @@ impl Screen {
}
Self::Direct { address } => vec![
b(
w / 2.0 - 100.0,
h / 4.0 + 96.0 + 12.0,
half(w) - 100.0,
quarter(h) + 96.0 + 12.0,
200.0,
&crate::lang::get_or("selectServer.select", "Join Server"),
servers::parse_address(address).is_some(),
52,
),
b(
w / 2.0 - 100.0,
h / 4.0 + 120.0 + 12.0,
half(w) - 100.0,
quarter(h) + 120.0 + 12.0,
200.0,
&crate::lang::get_or("gui.cancel", "Cancel"),
true,
@@ -615,16 +631,16 @@ impl Screen {
],
Self::Friend { code, .. } => vec![
b(
w / 2.0 - 100.0,
h / 4.0 + 96.0 + 18.0,
half(w) - 100.0,
quarter(h) + 96.0 + 18.0,
200.0,
"Join",
horizon_friends::invite::parse(code).is_some(),
56,
),
b(
w / 2.0 - 100.0,
h / 4.0 + 120.0 + 18.0,
half(w) - 100.0,
quarter(h) + 120.0 + 18.0,
200.0,
&crate::lang::get_or("gui.cancel", "Cancel"),
true,
@@ -633,8 +649,8 @@ impl Screen {
],
Self::Disconnected { retry, .. } => {
let mut v = vec![b(
w / 2.0 - 100.0,
h / 2.0 + 30.0,
half(w) - 100.0,
half(h) + 30.0,
200.0,
&crate::lang::get_or("gui.toMenu", "Back to Server List"),
true,
@@ -642,8 +658,8 @@ impl Screen {
)];
if retry.is_some() {
v.push(b(
w / 2.0 - 100.0,
h / 2.0 + 54.0,
half(w) - 100.0,
half(h) + 54.0,
200.0,
"Cancel Reconnecting",
true,
@@ -698,9 +714,10 @@ impl Screen {
let (w, h) = (batch.width, batch.height);
let max = self.max_length();
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
// GuiGraphicsExtractor.centeredText at width / 2: x - width(text) / 2.
let centered = |batch: &mut HudBatch, text: &str, y: f32, color: u32| {
let tw = HudBatch::text_width(atlas, text);
batch.text(atlas, text, (w - tw) / 2.0, y, color, true);
batch.text(atlas, text, centered_x(w, tw), y, color, true);
};
match self {
Self::List {
@@ -1042,15 +1059,15 @@ impl Screen {
centered(
batch,
&crate::lang::get_or("disconnect.lost", "Connection Lost"),
h / 2.0 - 40.0,
half(h) - 40.0,
rgba(170, 170, 170, 255),
);
for (i, line) in wrap(reason, 300.0, atlas).iter().enumerate() {
centered(batch, line, h / 2.0 - 16.0 + i as f32 * 9.0, u32::MAX);
centered(batch, line, half(h) - 16.0 + i as f32 * 9.0, u32::MAX);
}
if let Some(r) = retry {
let text = r.text(std::time::Instant::now());
centered(batch, &text, h / 2.0 + 16.0, rgba(170, 170, 170, 255));
centered(batch, &text, half(h) + 16.0, rgba(170, 170, 170, 255));
}
}
}
@@ -1281,7 +1298,7 @@ impl Screen {
unreachable!()
};
for (i, fy) in [(0u8, boxes[0]), (1, boxes[1])] {
if y >= fy && y < fy + 20.0 && (x - w / 2.0).abs() < 100.0 {
if y >= fy && y < fy + 20.0 && (half(w) - 100.0..half(w) + 100.0).contains(&x) {
*focus = i;
}
}
@@ -1539,6 +1556,35 @@ mod tests {
);
}
#[test]
fn screens_lay_out_on_whole_gui_pixels_at_any_window_size() {
// A 1000-pixel-high window at GUI scale 3 is 334 GUI pixels high and
// a 1281-wide one 427 wide: vanilla's `height / 4` is 83 and
// `width / 2` 213, never 83.5 and 213.5.
let direct = Screen::Direct {
address: "localhost".into(),
};
let at = |screen: &Screen, w, h| -> Vec<(f32, f32)> {
screen.buttons(w, h).iter().map(|b| (b.0, b.1)).collect()
};
assert_eq!(at(&direct, 427.0, 334.0), [(113.0, 191.0), (113.0, 215.0)]);
assert_eq!(at(&direct, 320.0, 240.0), [(60.0, 168.0), (60.0, 192.0)]);
let friend = Screen::Friend {
code: String::new(),
relay: String::new(),
focus: 0,
};
assert_eq!(at(&friend, 427.0, 253.0), [(113.0, 177.0), (113.0, 201.0)]);
let lost = Screen::Disconnected {
reason: String::new(),
retry: None,
};
assert_eq!(at(&lost, 427.0, 253.0), [(113.0, 156.0)]);
// centeredText: `width / 2 - font.width(text) / 2`.
assert_eq!(centered_x(427.0, 85.0), 171.0);
assert_eq!(centered_x(320.0, 84.0), 118.0);
}
#[test]
fn player_list_styles_sample_names_and_counts_the_rest() {
let players = serde_json::json!({
@@ -1820,11 +1866,12 @@ mod tests {
.find(|b| b.5 == 60)
.map(|b| (b.0, b.1, b.2, b.3))
};
// ManageServerScreen: 200 wide at height / 4 + 72, the saved value first.
// ManageServerScreen: 200 wide at width / 2 - 100 (ints: 427 / 2 is
// 213) and height / 4 + 72, the saved value first.
assert_eq!(
cycle(&screen),
Some((
113.5,
113.0,
132.0,
200.0,
"Server Resource Packs: Disabled".into()
+43 -2
View File
@@ -2,7 +2,7 @@
//! `misc/shadow.png` projected onto the tops of full-collision blocks under
//! an entity, faded by camera distance, depth below the entity and the light
//! at each block.
use crate::{blocks::BlockFacts, hud::Rect, mesher::Vertex, world::World};
use crate::{blocks::BlockFacts, hud::Rect, mesher::Vertex, mobs::Mob, world::World};
use glam::DVec3;
/// Vanilla renderer shadow radius and strength for an entity type id.
@@ -16,7 +16,7 @@ pub fn shadow_params(entity: &str) -> (f32, f32) {
"chicken" | "cod" | "parrot" | "rabbit" | "frog" | "silverfish" | "endermite" | "vex" => {
0.3
}
"cat" | "ocelot" | "fox" | "bee" | "allay" | "salmon" => 0.4,
"cat" | "ocelot" | "fox" | "bee" | "allay" | "salmon" | "armadillo" => 0.4,
"zombie" | "husk" | "drowned" | "zombie_villager" | "zombified_piglin" | "skeleton"
| "stray" | "wither_skeleton" | "bogged" | "creeper" | "enderman" | "villager"
| "wandering_trader" | "witch" | "pillager" | "vindicator" | "evoker" | "illusioner"
@@ -30,6 +30,12 @@ pub fn shadow_params(entity: &str) -> (f32, f32) {
"horse" | "donkey" | "mule" | "skeleton_horse" | "zombie_horse" | "phantom" => 0.75,
"spider" | "boat" => 0.8,
"polar_bear" | "panda" | "warden" => 0.9,
// GiantMobRenderer: 0.5 times its scale of 6.
"giant" => 3.0,
"elder_guardian" => 1.2,
"happy_ghast" => 2.0,
// ShulkerRenderer: none.
"shulker" => 0.0,
"wither" => 1.0,
"sniffer" | "ravager" => 1.1,
"ghast" => 1.5,
@@ -43,6 +49,21 @@ pub fn shadow_params(entity: &str) -> (f32, f32) {
(radius, strength)
}
/// The mobs that cast a shadow, at their feet position interpolated by
/// `alpha`, with their type id. `EntityRenderer.extractShadow` skips an entity
/// whose `EntityRenderState.isInvisible` is set (`Entity.isInvisible`).
pub fn mob_casters(mobs: &[Mob], alpha: f64) -> impl Iterator<Item = (DVec3, &str)> {
mobs.iter().filter(|m| !m.is_invisible()).map(move |m| {
let (p, q) = (m.previous, m.position);
let pos = DVec3::new(
p.x + (q.x - p.x) * alpha,
p.y + (q.y - p.y) * alpha,
p.z + (q.z - p.z) * alpha,
);
(pos, m.kind.id.as_str())
})
}
/// Shadow quads for an entity at `pos` (interpolated feet position).
#[allow(clippy::too_many_arguments)]
pub fn shadow(
@@ -178,6 +199,26 @@ mod tests {
}
}
#[test]
fn invisible_mobs_cast_no_shadow() {
use horizon_core::geometry::Vec3;
let kind = crate::mobs::sensor_scan_tests::kind();
let mut seen = Mob::new(1, kind.clone(), Vec3::new(1.0, 64.0, 1.0), 0.0);
seen.previous = Vec3::new(0.0, 64.0, 1.0);
// A server's invisible mob (shared flag 0x20) and one ticked here
// with the Invisibility effect.
let mut flagged = Mob::new(2, kind.clone(), Vec3::ZERO, 0.0);
flagged.invisible = true;
let mut potion = Mob::new(3, kind, Vec3::ZERO, 0.0);
potion.add_effect("minecraft:invisibility", 600, 0);
let mobs = [seen, flagged, potion];
let casters: Vec<_> = mob_casters(&mobs, 0.5).collect();
assert_eq!(
casters,
vec![(DVec3::new(0.5, 64.0, 1.0), "minecraft:cow")],
"only the visible mob casts a shadow"
);
}
#[test]
fn vanilla_radii() {
assert_eq!(shadow_params("minecraft:zombie"), (0.5, 1.0));
assert_eq!(shadow_params("minecraft:item"), (0.15, 0.75));
+62
View File
@@ -370,3 +370,65 @@ pub fn third_person_use_item(
Vec3::new(0.0, 0.0, 0.125),
)
}
/// `SpearAnimations.firstPersonAttack`, after `applyItemArmTransform`: the
/// jab (a STAB swing), `attack` into the swing.
pub fn first_person_attack(attack: f32, right: bool) -> Mat4 {
let invert = if right { 1.0 } else { -1.0 };
let start = in_out_sine(progress(attack, 0.0, 0.05));
let middle = out_back(progress(attack, 0.05, 0.2));
let end = in_out_expo(progress(attack, 0.4, 1.0));
Mat4::from_translation(Vec3::new(
invert * 0.1 * (start - middle),
-0.075 * (start - end),
0.65 * (start - middle),
)) * Mat4::from_rotation_x((-70.0 * (start - end)).to_radians())
* Mat4::from_translation(Vec3::new(0.0, 0.0, -0.25 * (end - middle)))
}
/// `SpearAnimations.thirdPersonAttackHand`: what a STAB swing adds to the
/// swinging arm's `xRot`, `swing` into the swing.
pub fn third_person_attack_arm(swing: f32) -> f32 {
let prepare = in_out_sine(progress(swing, 0.0, 0.05));
let attack = in_quad(progress(swing, 0.05, 0.2));
let retract = in_out_expo(progress(swing, 0.4, 1.0));
(90.0 * prepare - 120.0 * attack + 30.0 * retract).to_radians()
}
/// `SpearAnimations.thirdPersonAttackItem`: the item in the swinging hand
/// thrust out by a STAB swing, `forward` the main hand's `forward_movement`
/// (0 without a kinetic weapon there).
pub fn third_person_attack_item(swing: f32, forward: f32) -> Mat4 {
if swing <= 0.0 {
return Mat4::IDENTITY;
}
let attack = in_quad(progress(swing, 0.05, 0.2));
let retract = in_out_expo(progress(swing, 0.4, 1.0));
rotate_around(
Mat4::from_rotation_x((-70.0 * (attack - retract)).to_radians()),
Vec3::new(0.0, -0.125, 0.125),
) * Mat4::from_translation(Vec3::new(0.0, forward * (attack - retract), 0.0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_jab_starts_and_ends_at_rest() {
assert_eq!(first_person_attack(0.0, true), Mat4::IDENTITY);
assert!(first_person_attack(1.0, true).abs_diff_eq(Mat4::IDENTITY, 1e-5));
// Thrust out and turned mid-jab, mirrored in the left hand.
let right = first_person_attack(0.2, true);
assert!(!right.abs_diff_eq(Mat4::IDENTITY, 1e-3));
let left = first_person_attack(0.2, false);
assert!((left.w_axis.x + right.w_axis.x).abs() < 1e-6);
assert_eq!(third_person_attack_item(0.0, 0.38), Mat4::IDENTITY);
assert!(third_person_attack_item(1.0, 0.38).abs_diff_eq(Mat4::IDENTITY, 1e-5));
// The arm draws back, thrusts forward, then returns.
assert_eq!(third_person_attack_arm(0.0), 0.0);
assert!((third_person_attack_arm(0.05) - 90f32.to_radians()).abs() < 1e-5);
assert!(third_person_attack_arm(0.2) < 0.0);
assert!(third_person_attack_arm(1.0).abs() < 1e-5);
}
}
+32 -18
View File
@@ -78,7 +78,9 @@ impl WorldToast {
pub struct WorldEntry {
pub folder: String,
pub name: String,
pub creative: bool,
/// `GameType`: 0 survival, 1 creative, 2 adventure, 3 spectator.
pub game_mode: i32,
pub hardcore: bool,
pub modified: std::time::SystemTime,
pub commands: bool,
pub version: String,
@@ -221,7 +223,8 @@ pub fn list_worlds(saves: &Path) -> Vec<WorldEntry> {
Some(WorldEntry {
folder: e.file_name().to_string_lossy().into_owned(),
name: level.name,
creative: level.creative,
game_mode: level.game_mode,
hardcore: level.hardcore,
modified,
commands: matches!(data.get("allowCommands"), Some(horizon_world::nbt::Tag::Byte(v)) if *v != 0),
version: data.get("Version").and_then(|v| v.get("Name"))
@@ -233,6 +236,31 @@ pub fn list_worlds(saves: &Path) -> Vec<WorldEntry> {
worlds.sort_by_key(|w| std::cmp::Reverse(w.modified));
worlds
}
/// LevelSummary.createInfo: a hardcore world's mode in red (CommonColors.RED),
/// else `gameMode.` + its GameType name, then commands and the version.
fn world_info(world: &WorldEntry) -> crate::text::Text {
let mode = if world.hardcore {
crate::text::Text::colored(lang::get_or("gameMode.hardcore", "Hardcore Mode"), 0xFF0000)
} else {
let (key, fallback) = match world.game_mode {
1 => ("gameMode.creative", "Creative Mode"),
2 => ("gameMode.adventure", "Adventure Mode"),
3 => ("gameMode.spectator", "Spectator Mode"),
_ => ("gameMode.survival", "Survival Mode"),
};
crate::text::Text::literal(lang::get_or(key, fallback))
};
let commands = if world.commands {
format!(", {}", lang::get_or("selectWorld.commands", "Commands"))
} else {
String::new()
};
mode.append(crate::text::Text::literal(format!(
"{commands}, {} {}",
lang::get_or("selectWorld.version", "Version:"),
world.version
)))
}
fn choose_splash(text: &str, index: usize, date: (u32, u32)) -> Option<String> {
// SplashManager checks SpecialDates before the resource list (local date).
let special = match date {
@@ -1145,22 +1173,8 @@ impl Title {
rgba(128, 128, 128, 255),
true,
);
let mode = if world.creative {
lang::get_or("gameMode.creative", "Creative Mode")
} else {
lang::get_or("gameMode.survival", "Survival Mode")
};
let commands = if world.commands {
format!(", {}", lang::get_or("selectWorld.commands", "Commands"))
} else {
String::new()
};
let details = format!(
"{mode}{commands}, {} {}",
lang::get_or("selectWorld.version", "Version:"),
world.version
);
batch.text(
let details = world_info(world);
batch.text_styled(
atlas,
&details,
x + 35.0,
+45
View File
@@ -954,3 +954,48 @@ fn server_fields_edit_at_their_cursor() {
};
assert_eq!(address, "one ");
}
#[test]
fn world_list_names_every_game_mode_and_hardcore_in_red() {
let f = Fixture::new();
f.world("Creative", "Creative");
let level = |folder: &str, game_type: i32, hardcore: bool| {
crate::save::write_dat(
&f.title.saves.join(folder).join("level.dat"),
Tag::compound([(
"Data",
Tag::compound([
("LevelName", Tag::String(folder.into())),
("GameType", Tag::Int(game_type)),
(
"difficulty_settings",
Tag::compound([
("difficulty", Tag::String("hard".into())),
("hardcore", Tag::Byte(i8::from(hardcore))),
]),
),
(
"Version",
Tag::compound([("Name", Tag::String("26.3".into()))]),
),
]),
)]),
)
.unwrap();
};
level("Hardcore", 0, true);
level("Adventure", 2, false);
level("Spectator", 3, false);
let worlds = list_worlds(&f.title.saves);
let info = |folder: &str| world_info(worlds.iter().find(|w| w.folder == folder).unwrap());
let hardcore = info("Hardcore");
assert_eq!(hardcore.plain(), "Hardcore Mode, Version: 26.3");
assert_eq!(hardcore.spans[0].text, "Hardcore Mode");
assert_eq!(hardcore.spans[0].style.color, Some(0xFF0000));
assert!(hardcore.spans[1..].iter().all(|s| s.style.color.is_none()));
assert_eq!(info("Adventure").plain(), "Adventure Mode, Version: 26.3");
assert_eq!(info("Spectator").plain(), "Spectator Mode, Version: 26.3");
let creative = info("Creative");
assert_eq!(creative.plain(), "Creative Mode, Commands, Version: 26.3");
assert!(creative.spans.iter().all(|s| s.style.color.is_none()));
}
+5 -1
View File
@@ -798,7 +798,9 @@ fn java_split(s: &str) -> Vec<&str> {
parts
}
/// SharedSuggestionProvider.suggestCoordinates / suggest2DCoordinates: the
/// candidates `ty` parses completely.
/// candidates `ty` parses completely. `relevant` arguments (block and column
/// positions) offer the targeted block (`getRelevantCoordinates`), the others
/// (`Vec3`, `Vec2`) the exact hit point (`getAbsoluteCoordinates`).
fn suggest_coordinates(
builder: &mut SuggestionsBuilder,
reg: &Registries,
@@ -811,6 +813,8 @@ fn suggest_coordinates(
["^".into(), "^".into(), "^".into()]
} else if let (true, Some(t)) = (relevant, reg.target_block) {
[t[0].to_string(), t[1].to_string(), t[2].to_string()]
} else if let (false, Some(p)) = (relevant, &reg.target_point) {
p.clone()
} else {
["~".into(), "~".into(), "~".into()]
};
+39
View File
@@ -256,6 +256,45 @@ fn suggestions_like_vanilla() {
assert_eq!(suggestions("time set 5"), ["d", "s", "t"]);
assert!(suggestions("time set ").contains(&"minecraft:day".to_owned()));
}
/// ClientSuggestionProvider: block and column positions offer the targeted
/// block (`getRelevantCoordinates`), `Vec3` and `Vec2` the exact hit point
/// with two decimals (`getAbsoluteCoordinates`); with no block hit, `~`.
#[test]
fn coordinates_suggest_the_crosshair_block_or_hit_point() {
let d = vanilla::dispatcher();
let suggest = |reg: &Registries, input: &str| -> Vec<String> {
let parse = d.parse(input, reg);
d.suggestions(&parse, input.len(), reg)
.1
.into_iter()
.map(|s| s.text)
.collect()
};
let mut hit = reg();
hit.target_block = Some([-29, 73, 8]);
hit.target_point = Some(["-28.77".into(), "73.28".into(), "8.00".into()]);
assert_eq!(suggest(&hit, "setblock "), ["-29", "-29 73", "-29 73 8"]);
assert_eq!(
suggest(&hit, "summon minecraft:pig "),
["-28.77", "-28.77 73.28", "-28.77 73.28 8.00"]
);
assert_eq!(
suggest(&hit, "summon minecraft:pig -28.77 "),
["-28.77 73.28", "-28.77 73.28 8.00"]
);
// Vec2Argument: x and z.
assert_eq!(
suggest(&hit, "worldborder center "),
["-28.77", "-28.77 8.00"]
);
// An entity in front of the block (or no hit): the defaults.
let none = reg();
assert_eq!(suggest(&none, "setblock "), ["~", "~ ~", "~ ~ ~"]);
assert_eq!(
suggest(&none, "summon minecraft:pig "),
["~", "~ ~", "~ ~ ~"]
);
}
#[test]
fn smart_usage_matches_brigadier() {
let d = vanilla::dispatcher();
@@ -92,7 +92,10 @@ impl<B: TerrainBlockStates> MaterialColumn for Column<'_, '_, B> {
}
fn set_block(&mut self, y: i32, state: BlockStateId) -> Result<(), SurfaceError> {
if y >= self.min && y <= self.max {
self.chunk.set_block_worldgen(self.x, y, self.z, state)?;
// Most of the Nether's rule writes netherrack over netherrack.
if self.chunk.block(self.x, y, self.z) != state {
self.chunk.set_block_worldgen(self.x, y, self.z, state)?;
}
if self.chunk.block_properties(state).has_fluid() {
self.chunk.mark_postprocessing(self.x, y, self.z);
}
+1 -1
View File
@@ -43,7 +43,7 @@ Unknown areas are sampled directly from the dimension's seed-bound density and s
A separate bounded worker refines the nearest LOD levels with disposable ordinary chunk generation, including supported carvers, structures and decoration. Groups of 4×4 chunks share their generation halos, and each chunk is published once every neighbouring feature center has completed. Farther tiles use direct sampling rather than waiting for every intervening ordinary chunk. Saved and visited blocks take precedence over both paths. These temporary chunks never enter gameplay, simulation or normal save queues. Generated-column caches and request queues are bounded; far data is regenerated after reopening a world.
Coverage and detail improve progressively; a large selected radius does not mean all its target detail is already resident. The control status reports target and sampled target tile counts separately. Direct far sampling approximates vertical noise interpolation, fluid boundaries and lighting; surface slopes and column extensions, small features and partial block shapes can differ. Unexplored distant structures are not synthesized by the direct path, and native decoration order can differ from subsequent gameplay generation. Horizon-supported data-driven noise and tree configurations can be reused; this does not execute arbitrary Fabric/NeoForge worldgen or structure mods.
Coverage and detail improve progressively; a large selected radius does not mean all its target detail is already resident. Far sampling runs behind the game's frame and tick threads; on machines with 12 or more threads it uses up to 6 sampling threads once the nearby chunks have loaded (a quarter of the threads, at most 3, elsewhere). The control status reports target and sampled target tile counts separately. Direct far sampling approximates vertical noise interpolation, fluid boundaries and lighting; surface slopes and column extensions, small features and partial block shapes can differ. Unexplored distant structures are not synthesized by the direct path, and native decoration order can differ from subsequent gameplay generation. Horizon-supported data-driven noise and tree configurations can be reused; this does not execute arbitrary Fabric/NeoForge worldgen or structure mods.
Ordinary Overworld atmospheric fog extends with the selected LOD distance. Fog Off disables distance haze at large ranges too; fluid, darkness and boss fog retain their limits.
+7 -4
View File
@@ -363,6 +363,7 @@ A Java instance's Java: its own Java home or the automatic pick, memory and JVM
- `--auto`: Go back to the Java the version needs, picked or downloaded by the launcher
- `--memory`: The most memory the game may use, in MiB
- `--args`: Extra JVM arguments (one string; "" clears them)
- `--window`: The game window's size, as 1280x720; "default" goes back to the game's own
MCP: `instance_java`.
@@ -456,13 +457,14 @@ MCP: `instance_export`.
## `horizon-launcher instance launch`
Show or set what runs around an instance's game: a shell command before it (the game starts only if it succeeds), a wrapper it runs through (`nice -n 5`), and environment variables ("NAME=value NAME2=value"). An empty value clears one. On Linux, also GameMode, MangoHud and the dedicated graphics card, when the computer has them.
Show or set what runs around an instance's game: a shell command before it (the game starts only if it succeeds), a wrapper it runs through (`nice -n 5`), environment variables ("NAME=value NAME2=value") and a shell command after it exits. An empty value clears one. The game and the commands also get INST_NAME, INST_ID, INST_DIR, INST_MC_DIR and, for a Java instance, INST_JAVA. On Linux, also GameMode, MangoHud and the dedicated graphics card, when the computer has them.
horizon-launcher instance launch <INSTANCE>
- `INSTANCE`
- `--pre-launch`
- `--wrapper`
- `--post-exit`
- `--env`
- `--gamemode`: Linux: run it through GameMode (gamemoderun), on or off
- `--mangohud`: Linux: MangoHud's overlay, on or off
@@ -494,7 +496,7 @@ MCP: `instance_import`.
## `horizon-launcher instance others`
List the instances other launchers keep (Prism Launcher, MultiMC, the official launcher), read without changing anything there.
List the instances other launchers keep (Prism Launcher, MultiMC, the official launcher, the CurseForge app, the Modrinth App), read without changing anything there.
horizon-launcher instance others
@@ -511,6 +513,7 @@ Copy another launcher's instance into a new Java instance: its Minecraft version
- `NAME`: Its name, as `instance others` lists it
- `--folder`: Look in this folder instead: a launcher's folder, its instances/ or one instance
- `--as`: The new instance's name (default: the same)
- `--minecraft`: The Minecraft version it plays, when its launcher doesn't say (the CurseForge app, the Modrinth App)
MCP: `instance_copy_from`.
@@ -846,7 +849,7 @@ MCP: `mods_remove`, needs `confirm: true`.
## `horizon-launcher mods enable`
Turn on a mod the browser installed (horizon-mods.txt, as the game's Options > Mods does).
Turn on a mod the browser installed (horizon-mods.txt, as the game's Options > Mods does; in a Java instance, its file loses `.disabled`).
horizon-launcher mods enable <PROJECT>
@@ -857,7 +860,7 @@ MCP: `mods_enable`.
## `horizon-launcher mods disable`
Turn off a mod the browser installed; it stays installed.
Turn off a mod the browser installed; it stays installed (in a Java instance, as `<file>.disabled`).
horizon-launcher mods disable <PROJECT>
+196
View File
@@ -0,0 +1,196 @@
{
"Home": "Inicio",
"Mods": "Mods",
"Servers": "Servidores",
"Versions": "Versiones",
"Patch notes": "Notas de la versión",
"Settings": "Ajustes",
"Accounts, updates, and the launcher itself.": "Cuentas, actualizaciones y el propio lanzador.",
"Account": "Cuenta",
"Updates": "Actualizaciones",
"Launcher": "Lanzador",
"Network": "Red",
"Game": "Juego",
"AI": "IA",
"About": "Acerca de",
"Install new versions automatically": "Instalar nuevas versiones automáticamente",
"Your selected version only changes if you were playing the newest one.": "Tu versión seleccionada solo cambia si estabas jugando a la más reciente.",
"Appearance": "Apariencia",
"Dark": "Oscuro",
"Light": "Claro",
"System": "Sistema",
"Dark by default. System follows your computer's light or dark.": "Oscuro por defecto. Sistema sigue el modo claro u oscuro de tu equipo.",
"Language": "Idioma",
"System follows your computer's language: {language} here. A string not translated yet shows in English.": "Sistema sigue el idioma de tu equipo: {language} aquí. Un texto aún no traducido se muestra en inglés.",
"While playing": "Al jugar",
"Stay": "Mantener",
"Minimise": "Minimizar",
"Hide": "Ocultar",
"The launcher stays open while you play.": "El lanzador permanece abierto mientras juegas.",
"The launcher goes down when the game starts, and comes back when it closes.": "El lanzador se minimiza cuando el juego inicia y vuelve cuando se cierra.",
"The launcher hides when the game starts, and comes back when it closes.": "El lanzador se oculta cuando el juego inicia y vuelve cuando se cierra.",
"Show the opening": "Mostrar animación de inicio",
"The wordmark, while the release list loads.": "El logotipo mientras carga la lista de versiones.",
"{version} available": "{version} disponible",
"Up to date": "Actualizado",
"up to date": "actualizado",
"Checking…": "Comprobando…",
"downloading {version}, {percent}%": "descargando {version}, {percent}%",
"Updated to {version}. Restart the launcher to use it.": "Actualizado a {version}. Reinicia el lanzador para usarlo.",
"the update failed: {error}": "la actualización falló: {error}",
"Install": "Instalar",
"Restart": "Reiniciar",
"Check now": "Comprobar ahora",
"Open": "Abrir",
"Data folder": "Carpeta de datos",
"Logs": "Registros",
"crash reports beside it": "informes de fallos al lado",
"Applications menu": "Menú de aplicaciones",
"Horizon and its icon are in your desktop's menu.": "Horizon y su icono están en el menú de tu escritorio.",
"Not in your desktop's menu.": "No está en el menú de tu escritorio.",
"Microsoft account": "Cuenta de Microsoft",
"Another account": "Otra cuenta",
"Microsoft account · plays": "Cuenta de Microsoft · jugando",
"Microsoft account · signed in": "Cuenta de Microsoft · sesión iniciada",
"Asking Microsoft…": "Consultando a Microsoft…",
"Add another account.": "Añadir otra cuenta.",
"Copy code": "Copiar código",
"Open link": "Abrir enlace",
"Play as": "Jugar como",
"Play offline": "Jugar sin conexión",
"Sign out": "Cerrar sesión",
"Offline": "Sin conexión",
"Microsoft": "Microsoft",
"Offline, the game's name": "Sin conexión, el nombre en el juego",
"Manage accounts": "Gestionar cuentas",
"Offline name": "Nombre sin conexión",
"Offline name · plays": "Nombre sin conexión · jugando",
"A name, 3 to 16 letters": "Un nombre, de 3 a 16 letras",
"Play offline under another name, and switch from the top bar. Online servers don't take offline names.": "Juega sin conexión con otro nombre y cámbialo desde la barra superior. Los servidores en línea no aceptan nombres sin conexión.",
"Add": "Añadir",
"Skin…": "Aspecto…",
"Forget": "Olvidar",
"{name}'s skin": "Aspecto de {name}",
"{name}'s skin is kept.": "Se conserva el aspecto de {name}.",
"{name}'s cape is kept.": "Se conserva la capa de {name}.",
"Choose a skin…": "Elegir un aspecto…",
"Default skin": "Aspecto por defecto",
"Classic arms": "Brazos clásicos",
"Slim arms": "Brazos delgados",
"Choose a cape…": "Elegir una capa…",
"No cape": "Sin capa",
"Default": "Por defecto",
"A 64×64 (or 64×32) skin PNG and a 64×32 cape PNG, kept by the launcher and given to the game when this name plays. The game draws the default skins for now; it uses these once it can. A Microsoft account's skin and cape: coming soon.": "Un PNG de aspecto de 64×64 (o 64×32) y un PNG de capa de 64×32, guardados por el lanzador y proporcionados al juego cuando juega con este nombre. Por ahora el juego muestra los aspectos por defecto; usará estos en cuanto sea posible. El aspecto y capa de una cuenta de Microsoft estarán disponibles pronto.",
"Accent colour": "Color de acento",
"Horizon's violet by default. Every colour keeps text as easy to read.": "Violeta de Horizon por defecto. Cada color mantiene el texto igual de legible.",
"Choose": "Elegir",
"Choose a picture…": "Elegir una imagen…",
"Landscape": "Paisaje",
"Your picture, behind Home, cropped to fill it.": "Tu imagen, detrás de Inicio, recortada para llenarlo.",
"Horizon's landscape. Pick a PNG, JPEG or WebP picture of your own.": "El paisaje de Horizon. Elige tu propia imagen en PNG, JPEG o WebP.",
"Home background": "Fondo de Inicio",
"Play": "Jugar",
"Running": "En ejecución",
"Stopped": "Detenido",
"Start the game, or run a server for others to join.": "Inicia el juego o ejecuta un servidor para que otros se unan.",
"No version installed yet.": "Aún no hay ninguna versión instalada.",
"Choose a version": "Elegir una versión",
"Host a server": "Alojar un servidor",
"Preparing…": "Preparando…",
"Prepare": "Preparar",
"Prepare again": "Preparar de nuevo",
"None needed: Horizon downloads Minecraft from Mojang for your account.": "No hace falta: Horizon descarga Minecraft de Mojang para tu cuenta.",
"Not found. Choose the folder where your launcher keeps Minecraft.": "No encontrado. Elige la carpeta donde tu lanzador guarda Minecraft.",
"Ready to play": "Listo para jugar",
"Resources": "Recursos",
"Minecraft folder": "Carpeta de Minecraft",
"Choose…": "Elegir…",
"Playing offline": "Jugando sin conexión",
"Online servers need a Microsoft account.": "Los servidores en línea requieren una cuenta de Microsoft.",
"Accounts…": "Cuentas…",
"The first preparation takes a few minutes.": "La primera preparación toma unos minutos.",
"Horizon crashed": "Horizon se cerró inesperadamente",
"When you report it on Discord, attach the crash report file (or paste it with Copy report) and say what you were doing.": "Al reportarlo en Discord, adjunta el archivo del informe de fallos (o pégalo con Copiar informe) y describe qué estabas haciendo.",
"The game ended without a crash report. When you report it on Discord, attach latest.log from the logs folder and say what you were doing.": "El juego terminó sin un informe de fallos. Al reportarlo en Discord, adjunta latest.log de la carpeta de registros y describe qué estabas haciendo.",
"Open report": "Abrir informe",
"Copied": "Copiado",
"Copy report": "Copiar informe",
"Open logs folder": "Abrir carpeta de registros",
"Report on Discord": "Reportar en Discord",
"Dismiss": "Descartar",
"INSTANCES": "INSTANCIAS",
"SERVERS": "SERVIDORES",
"HIDE": "OCULTAR",
"Sponsor": "Patrocinar",
"Website": "Sitio web",
"Ready": "Listo",
"Needs preparing": "Requiere preparación",
"{version} is not installed": "{version} no está instalada",
"No version installed": "Ninguna versión instalada",
"Modpack": "Paquete de mods",
"From Horizon's registry": "Del registro de Horizon",
"Instance": "Instancia",
"Edit…": "Editar…",
"Copy…": "Copiar…",
"Export…": "Exportar…",
"Close export": "Cerrar exportación",
"Launch…": "Lanzar…",
"Close launch": "Cerrar lanzamiento",
"Open folder": "Abrir carpeta",
"Join a friend": "Unirse a un amigo",
"Paste an invite code": "Pega un código de invitación",
"Paste the code a friend shows when they invite you.": "Pega el código que un amigo muestra cuando te invita.",
"Join": "Unirse",
"Shortcut": "Acceso directo",
"Remove shortcut": "Eliminar acceso directo",
"today": "hoy",
"yesterday": "ayer",
"{days} days ago": "hace {days} días",
"{time} in total · last played {when}": "{time} en total · última vez jugado {when}",
"Not played yet": "Aún no jugado",
"under a minute": "menos de un minuto",
"PARTNER": "COLABORADOR",
"Your Minecraft server, {online}.": "Tu servidor de Minecraft, {online}.",
"24/7 online": "en línea 24/7",
"Ready in under a minute": "Listo en menos de un minuto",
"Get my server": "Obtener mi servidor",
"15% off": "15% de descuento",
"Not prepared. Horizon needs the files of your own Minecraft {version}.": "No preparado. Horizon necesita los archivos de tu propia instalación de Minecraft {version}.",
"Prepared for Minecraft {version}": "Preparado para Minecraft {version}",
"Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "Preparado para Minecraft {version}, sin sonidos: el juego estará en silencio. Prepara de nuevo para copiarlos desde tu instalación de Minecraft.",
"This build of the launcher has no Microsoft sign-in (HORIZON_MSA_CLIENT_ID), so it can't start the game.": "Esta compilación del lanzador no tiene inicio de sesión con Microsoft (HORIZON_MSA_CLIENT_ID), por lo que no puede iniciar el juego.",
"Sign in with a Microsoft account that owns Minecraft: Java Edition to play": "Inicia sesión con una cuenta de Microsoft que posea Minecraft: Java Edition para jugar",
"Horizon needs a Microsoft account that owns Minecraft: Java Edition, like other launchers. It also plays online servers with your name and skin.": "Horizon necesita una cuenta de Microsoft que posea Minecraft: Java Edition, como otros lanzadores. También permite jugar en servidores en línea con tu nombre y aspecto.",
"Sign in": "Iniciar sesión",
"Sign in with Microsoft": "Iniciar sesión con Microsoft",
"Not now": "Ahora no",
"Horizon needs a Microsoft account that owns Minecraft: Java Edition, like other launchers.": "Horizon necesita una cuenta de Microsoft que posea Minecraft: Java Edition, como otros lanzadores.",
"Your worlds, offline names and settings stay where they are.": "Tus mundos, nombres sin conexión y ajustes se mantienen en su lugar.",
"Open {link} in your browser and enter this code:": "Abre {link} en tu navegador e introduce este código:",
"From my install": "Desde mi instalación",
"Download and prepare": "Descargar y preparar",
"Not prepared. Play downloads Minecraft {version} from Mojang for your account and prepares it.": "Sin preparar. Jugar descarga Minecraft {version} de Mojang para tu cuenta y lo prepara.",
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "No preparado. Horizon puede descargar Minecraft {version} desde Mojang para tu cuenta, o usar tu propia instalación.",
"Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "No preparado. Inicia sesión primero con la cuenta de Microsoft que posea Minecraft {version}.",
"Cancel": "Cancelar",
"Native mods (experimental)": "Mods nativos (experimental)",
"Native mods from the Mods page compile into the game with the launcher's own Rust. Not sandboxed: they can do anything you can on this computer.": "Los mods nativos de la página Mods se compilan en el juego con el compilador Rust del propio lanzador. No están aislados: pueden hacer cualquier cosa que tú puedas hacer en este ordenador.",
"{version} plays in this instance.": "{version} se ejecuta en esta instancia.",
"Install Horizon first.": "Instala Horizon primero.",
"Starting the build": "Iniciando compilación",
"These mods will run with full access to your computer": "Estos mods se ejecutarán con acceso completo a tu ordenador",
"They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.": "Pueden leer, modificar o eliminar tus archivos y usar tus cuentas y red, como cualquier programa que ejecutes. Compílalos únicamente si confías en sus autores: la página Mods los enumera.",
"Mods: {mods}": "Mods: {mods}",
"Delete old versions automatically": "Eliminar automáticamente las versiones antiguas",
"Keeps the {count} newest, the one in use and the ones your instances use. Deleted versions install again from the Versions page.": "Conserva las {count} más recientes, la que está en uso y las que usan tus instancias. Las versiones eliminadas se vuelven a instalar desde la página Versiones.",
"Installed versions use {size}.": "Las versiones instaladas ocupan {size}.",
"{size} on disk": "{size} en disco",
"The build compiles the game: 20 to 30 minutes and a few GB of disk the first time (it downloads Rust), a few minutes after that.": "La compilación construye el juego: de 20 a 30 minutos y unos pocos GB de disco la primera vez (descarga Rust), y unos minutos después.",
"Delete…": "Eliminar…",
"Keep": "Conservar",
"Move to Trash": "Mover a la papelera",
"Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "¿Mover {name} a la papelera, con sus mundos y ajustes? Podrás recuperarlo desde allí.",
"Install and turn on native mods": "Instalar y activar los mods nativos",
"Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Edition). La primera vez, Jugar lo descarga de Mojang para tu cuenta, junto con el Java que necesita.",
"Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Los mods nativos se compilan en un juego propio. Al instalar uno se activan los mods nativos: después compilas el juego con él en Ajustes > Mods nativos. La primera compilación descarga Rust y puede tardar de 20 a 30 minutos; las siguientes son más rápidas."
}
+10 -1
View File
@@ -1320,8 +1320,10 @@ pub fn download_version_for_owner(
/// version downloaded from Mojang the first time (then it starts offline too), the Java it needs
/// (the instance's own, else one found here, else Mojang's, downloaded), and the game in the
/// instance's folder. The caller wraps it (`launch::wrap`) and never logs it: it holds the token.
/// A Java instance's game, ready to start; `server` (`host[:port]`) is joined once it has.
pub fn java_game(
instance: &crate::instances::Instance,
server: Option<&str>,
log: &dyn Fn(String),
) -> Result<Command, String> {
let game = instance.java.as_ref().ok_or_else(|| {
@@ -1391,6 +1393,8 @@ pub fn java_game(
jvm_args: &jvm_args,
player: &player,
os: crate::java_launch::os_name(),
window: game.window,
server,
},
)
}
@@ -2767,7 +2771,12 @@ pub enum Msg {
Native(Result<String, String>),
/// A Java instance's game is ready to start (`java_game`, wrapped): the instance's id and
/// game folder, and the command or why not.
JavaGame(String, PathBuf, Result<Command, String>),
/// A Java instance's game ready to start, and its variables for the post-exit command.
JavaGame(
String,
PathBuf,
Result<(Command, crate::launch::Vars), String>,
),
}
/// A running process; its output arrives as `Msg::Line`, its end as `Msg::Exit`.
+51 -5
View File
@@ -164,7 +164,51 @@ pub fn install(
) -> Result<Vec<Entry>, String> {
let steps = plan(source, target, project)?;
let folder = |kind| target.folder(kind).expect("plan checks every kind");
installed::put(source, steps, home, &folder, progress)
let was = Installed::load(home).entries;
let done = installed::put(source, steps, home, &folder, progress)?;
// A mod the player turned off stays off through an update, and its old file goes.
for e in &done {
let Some(old) = was
.iter()
.find(|o| o.source == e.source && o.project == e.project && !is_on(home, o))
else {
continue;
};
if old.file != e.file {
let _ = fs::remove_file(off(home, old));
}
set_on(home, e, false)?;
}
Ok(done)
}
/// A mod turned off keeps its file under this suffix, which the loaders don't read: they load
/// the `.jar` files in `mods/`.
const OFF: &str = ".disabled";
fn off(home: &Path, entry: &Entry) -> std::path::PathBuf {
home.join(format!("{}{OFF}", entry.file))
}
/// Whether the game loads `entry`: everything but a mod turned off.
pub fn is_on(home: &Path, entry: &Entry) -> bool {
entry.kind != Kind::Mod || !off(home, entry).is_file()
}
/// Turns the mod `entry` on or off by renaming its file; it stays installed.
pub fn set_on(home: &Path, entry: &Entry, on: bool) -> Result<(), String> {
if entry.kind != Kind::Mod {
return Err(format!(
"{} isn't a mod: only mods turn on and off.",
entry.title
));
}
let (jar, off) = (home.join(&entry.file), off(home, entry));
let (from, to) = if on { (off, jar) } else { (jar, off) };
if !from.exists() && to.is_file() {
return Ok(());
}
fs::rename(&from, &to).map_err(|e| format!("{}: {e}", from.display()))
}
/// The newer version `entry` can be updated to, if any.
@@ -188,10 +232,12 @@ pub fn remove(home: &Path, name: &str) -> Result<Entry, String> {
.lookup(name)
.cloned()
.ok_or_else(|| format!("{name} wasn't installed by the browser."))?;
match fs::remove_file(home.join(&entry.file)) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("{}: {e}", entry.file)),
for path in [home.join(&entry.file), off(home, &entry)] {
match fs::remove_file(&path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("{}: {e}", path.display())),
}
}
installed
.entries
+23 -3
View File
@@ -232,14 +232,34 @@ fn installs_jars_with_dependencies_then_updates_and_removes() {
fake.release("map", Kind::Mod, "2", &["1.21.1"], &["fabric"], &["lib"]);
let entry = Installed::load(&home).lookup("map").cloned().unwrap();
assert_eq!(update_for(&fake, &t, &entry).unwrap().unwrap().number, "2");
// Turned off, the mod is kept as `.jar.disabled`, which the loaders skip.
set_on(&home, &entry, false).unwrap();
assert!(!is_on(&home, &entry));
assert!(
home.join("mods/map-1.jar.disabled").is_file() && !home.join("mods/map-1.jar").exists()
);
set_on(&home, &entry, false).unwrap();
// It stays off through an update, and the old file goes.
install(&fake, &t, &map, &home, NONE).unwrap();
assert!(!home.join("mods/map-1.jar").exists());
assert!(home.join("mods/map-2.jar").is_file());
assert!(
!home.join("mods/map-1.jar").exists() && !home.join("mods/map-1.jar.disabled").exists()
);
assert!(
home.join("mods/map-2.jar.disabled").is_file() && !home.join("mods/map-2.jar").exists()
);
let entry = Installed::load(&home).lookup("map").cloned().unwrap();
assert!(!is_on(&home, &entry));
assert!(update_for(&fake, &t, &entry).unwrap().is_none());
install(&fake, &t, &map, &home, NONE).unwrap();
assert!(!is_on(&home, &entry));
set_on(&home, &entry, true).unwrap();
assert!(is_on(&home, &entry) && home.join("mods/map-2.jar").is_file());
set_on(&home, &entry, false).unwrap();
remove(&home, "map-slug").unwrap();
assert!(!home.join("mods/map-2.jar").exists());
assert!(
!home.join("mods/map-2.jar").exists() && !home.join("mods/map-2.jar.disabled").exists()
);
assert!(Installed::load(&home).lookup("map").is_none());
assert!(home.join("mods/lib-1.jar").is_file());
let _ = fs::remove_dir_all(&home);
+107 -25
View File
@@ -249,6 +249,9 @@ enum InstanceCmd {
/// Extra JVM arguments (one string; "" clears them)
#[arg(long, allow_hyphen_values = true)]
args: Option<String>,
/// The game window's size, as 1280x720; "default" goes back to the game's own
#[arg(long)]
window: Option<String>,
},
/// A Java instance's mod loader: fabric or quilt, or none. --list shows its versions
Loader {
@@ -294,9 +297,11 @@ enum InstanceCmd {
out: Option<PathBuf>,
},
/// Show or set what runs around an instance's game: a shell command before it (the game
/// starts only if it succeeds), a wrapper it runs through (`nice -n 5`), and environment
/// variables ("NAME=value NAME2=value"). An empty value clears one. On Linux, also GameMode,
/// MangoHud and the dedicated graphics card, when the computer has them
/// starts only if it succeeds), a wrapper it runs through (`nice -n 5`), environment
/// variables ("NAME=value NAME2=value") and a shell command after it exits. An empty value
/// clears one. The game and the commands also get INST_NAME, INST_ID, INST_DIR,
/// INST_MC_DIR and, for a Java instance, INST_JAVA. On Linux, also GameMode, MangoHud and
/// the dedicated graphics card, when the computer has them
Launch {
instance: String,
#[arg(long)]
@@ -304,6 +309,8 @@ enum InstanceCmd {
#[arg(long)]
wrapper: Option<String>,
#[arg(long)]
post_exit: Option<String>,
#[arg(long)]
env: Option<String>,
/// Linux: run it through GameMode (gamemoderun), on or off
#[arg(long)]
@@ -335,7 +342,7 @@ enum InstanceCmd {
instance: Option<String>,
},
/// List the instances other launchers keep (Prism Launcher, MultiMC, the official
/// launcher), read without changing anything there
/// launcher, the CurseForge app, the Modrinth App), read without changing anything there
Others {
/// Look in this folder instead: a launcher's folder, its instances/ or one instance
#[arg(long)]
@@ -352,6 +359,10 @@ enum InstanceCmd {
/// The new instance's name (default: the same)
#[arg(long = "as")]
new_name: Option<String>,
/// The Minecraft version it plays, when its launcher doesn't say (the CurseForge app,
/// the Modrinth App)
#[arg(long)]
minecraft: Option<String>,
},
}
@@ -583,14 +594,16 @@ enum ModsCmd {
#[arg(long)]
instance: Option<String>,
},
/// Turn on a mod the browser installed (horizon-mods.txt, as the game's Options > Mods does)
/// Turn on a mod the browser installed (horizon-mods.txt, as the game's Options > Mods does;
/// in a Java instance, its file loses `.disabled`)
Enable {
project: String,
/// The instance (default: the selected one)
#[arg(long)]
instance: Option<String>,
},
/// Turn off a mod the browser installed; it stays installed
/// Turn off a mod the browser installed; it stays installed (in a Java instance, as
/// `<file>.disabled`)
Disable {
project: String,
/// The instance (default: the selected one)
@@ -1189,25 +1202,28 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
None => instances::selected(),
};
if instance.java.is_some() {
if version.is_some()
|| world.is_some()
|| join.is_some()
|| server.is_some()
|| creative
{
if version.is_some() || world.is_some() || join.is_some() || creative {
return Err(format!(
"{} plays Minecraft: Java Edition: --version, --world, --creative, --join \
and --server are for Horizon instances.",
"{} plays Minecraft: Java Edition: --version, --world, --creative and \
--join are for Horizon instances.",
instance.name
));
}
let mut cmd = backend::java_game(&instance, &|line| eprintln!("{line}"))?;
let server = server.map(|server| {
let list = server_list::List::load(&instance.home);
server_list::find(&list, &server).map_or(server, |(_, s)| s.ip)
});
let mut cmd =
backend::java_game(&instance, server.as_deref(), &|line| eprintln!("{line}"))?;
cmd.args(arg);
let vars = instance.vars(cmd.get_program());
cmd.envs(vars.iter().map(|(k, v)| (k, v)));
let started = std::time::Instant::now();
let played = attached(horizon_launcher::launch::wrap(cmd, &instance.launch));
if let Err(e) = instances::record_play(&instance.id, started.elapsed()) {
eprintln!("Play time not saved: {e}");
}
after_exit(&instance, &vars);
return played;
}
let mut cmd = backend::client(&instance.resolve_version(version.as_deref())?)?;
@@ -1233,10 +1249,13 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
cmd.arg("--name").arg(name);
}
cmd.envs(accounts.look_env());
let vars = instance.vars(cmd.get_program());
cmd.envs(vars.iter().map(|(k, v)| (k, v)));
let played = attached(horizon_launcher::launch::wrap(cmd, &instance.launch));
if let Err(e) = instances::record_play(&instance.id, started.elapsed()) {
eprintln!("Play time not saved: {e}");
}
after_exit(&instance, &vars);
played?
}
Cmd::Server {
@@ -1989,9 +2008,11 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
);
}
for e in list.iter().filter(|_| !as_json) {
let off = java.is_some() && !java_instance::is_on(&home, e);
println!(
"{} {} ({}, {})\n {} {}",
"{}{} {} ({}, {})\n {} {}",
e.title,
if off { " (off)" } else { "" },
e.number,
e.source.label(),
e.slug,
@@ -2273,11 +2294,13 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
};
println!("Removed {} ({}).", e.title, e.file);
}
ModsCmd::Enable { .. } | ModsCmd::Disable { .. } if java.is_some() => {
return Err(
"A Java instance loads every mod in its mods/ folder: remove one to turn it off."
.into(),
);
ModsCmd::Enable { project, .. } | ModsCmd::Disable { project, .. } if java.is_some() => {
let e = installed::Installed::load(&home)
.lookup(&project)
.cloned()
.ok_or_else(|| format!("{project} wasn't installed by the browser."))?;
java_instance::set_on(&home, &e, on)?;
println!("{} is {}.", e.title, if on { "on" } else { "off" });
}
ModsCmd::Enable { project, .. } | ModsCmd::Disable { project, .. } => {
let e = installed::Installed::load(&home)
@@ -2320,9 +2343,10 @@ fn other_instances(folder: Option<&Path>) -> Result<Vec<other_launchers::Found>,
}
fn other_plays(f: &other_launchers::Found) -> String {
match &f.loader {
Some(l) => format!("Minecraft {} with {} {}", f.minecraft, l.name, l.version),
None => format!("Minecraft {}", f.minecraft),
match (&f.minecraft, &f.loader) {
(None, _) => "Minecraft version not recorded: copy-from --minecraft <version>".into(),
(Some(v), Some(l)) => format!("Minecraft {v} with {} {}", l.name, l.version),
(Some(v), None) => format!("Minecraft {v}"),
}
}
@@ -2335,6 +2359,38 @@ fn other_json(f: &other_launchers::Found) -> serde_json::Value {
})
}
/// Runs the instance's post-exit command in this terminal, if it has one, and waits for it.
fn after_exit(instance: &instances::Instance, vars: &[(&'static str, std::ffi::OsString)]) {
let Some(mut cmd) =
horizon_launcher::launch::after_exit(&instance.launch, &instance.home, vars)
else {
return;
};
match cmd.status() {
Ok(s) if s.success() => {}
Ok(s) => eprintln!("The post-exit command failed ({s})."),
Err(e) => eprintln!("The post-exit command didn't run: {e}"),
}
}
/// `1280x720` (or `1280×720`), or `default` for none.
fn parse_window(text: &str) -> Result<Option<(u32, u32)>, String> {
let text = text.trim();
if text.eq_ignore_ascii_case("default") {
return Ok(None);
}
let size = text
.split_once(['x', 'X', '×'])
.and_then(|(w, h)| Some((w.trim().parse().ok()?, h.trim().parse().ok()?)))
.filter(|&(w, h): &(u32, u32)| (1..=16384).contains(&w) && (1..=16384).contains(&h));
match size {
Some(size) => Ok(Some(size)),
None => Err(format!(
"{text:?} isn't a window size: 1280x720, or default."
)),
}
}
fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
use instances::Icon;
let value = |i: &instances::Instance, selected: bool| {
@@ -2469,7 +2525,9 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
auto,
memory,
args,
window,
} => {
let window = window.as_deref().map(parse_window).transpose()?;
if instances::find(&instance)?.java.is_none() {
return Err(format!(
"{instance} is a Horizon instance: Horizon is not a Java program."
@@ -2485,6 +2543,9 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
if let Some(args) = args {
java.jvm_args = args.trim().to_string();
}
if let Some(window) = window {
java.window = window;
}
})?)
}
InstanceCmd::Delete { instance } => {
@@ -2520,6 +2581,7 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
gamemode,
mangohud,
dedicated_gpu,
post_exit,
} => {
use horizon_launcher::launch;
let env = env.as_deref().map(launch::parse_env).transpose()?;
@@ -2544,6 +2606,7 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
}
let changing = pre_launch.is_some()
|| wrapper.is_some()
|| post_exit.is_some()
|| env.is_some()
|| gamemode.is_some()
|| mangohud.is_some()
@@ -2557,6 +2620,9 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
if let Some(w) = wrapper {
m.launch.wrapper = w.trim().to_string();
}
if let Some(p) = post_exit {
m.launch.post_exit = p.trim().to_string();
}
if let Some(e) = env {
m.launch.env = e;
}
@@ -2575,6 +2641,7 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
if as_json {
json(serde_json::json!({
"instance": i.id, "pre_launch": l.pre_launch, "wrapper": l.wrapper, "env": l.env,
"post_exit": l.post_exit,
"gamemode": l.gamemode, "mangohud": l.mangohud, "dedicated_gpu": l.dedicated_gpu,
"found": {
"gamemode": found.gamemode, "mangohud": found.mangohud,
@@ -2590,6 +2657,7 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
println!(" pre-launch: {}", l.pre_launch);
println!(" wrapper: {}", l.wrapper);
println!(" env: {}", launch::env_line(&l.env));
println!(" post-exit: {}", l.post_exit);
let yes = |b: bool| if b { "on" } else { "off" };
for (name, on, has) in [
("gamemode:", l.gamemode, found.gamemode),
@@ -2643,6 +2711,7 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
name,
folder,
new_name,
minecraft,
} => {
let found = other_instances(folder.as_deref())?;
let matching: Vec<_> = found.iter().filter(|f| f.name == name).collect();
@@ -2661,7 +2730,11 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
))
}
};
let made = other_launchers::import(f, new_name.as_deref().unwrap_or(&f.name))?;
let made = other_launchers::import(
f,
new_name.as_deref().unwrap_or(&f.name),
minecraft.as_deref(),
)?;
let unsupported = other_launchers::loader(f).is_none();
if let Some(l) = f.loader.as_ref().filter(|_| unsupported && !as_json) {
println!(
@@ -3182,6 +3255,15 @@ mod tests {
}
}
#[test]
fn window_sizes() {
assert_eq!(parse_window("1280x720"), Ok(Some((1280, 720))));
assert_eq!(parse_window(" 1920 × 1080 "), Ok(Some((1920, 1080))));
assert_eq!(parse_window("Default"), Ok(None));
assert!(parse_window("0x720").is_err());
assert!(parse_window("big").is_err());
}
#[test]
fn mods_commands_name_their_instance() {
let parse = |args: &[&str]| {
+16 -5
View File
@@ -1,7 +1,7 @@
//! The launcher's languages: English (the strings in the code), French, Russian, and
//! German, the languages Horizon's own game menus have. A table per language
//! (`launcher/lang/<code>.json`) maps each English string to its translation; a string
//! the table lacks shows in English.
//! The launcher's languages: English (the strings in the code), French, Russian, German and
//! Spanish, the languages Horizon's own game menus have. A table per language
//! (`launcher/lang/<code>.json`) maps each English string to its translation; a string the
//! table lacks shows in English.
//!
//! The language follows the system's unless the player picks one in Settings
//! (`Settings::language`). Translations are Horizon's own, written for this launcher.
@@ -21,10 +21,18 @@ pub enum Language {
Russian,
#[serde(rename = "de")]
German,
#[serde(rename = "es")]
Spanish,
}
impl Language {
pub const ALL: [Language; 4] = [Self::English, Self::French, Self::Russian, Self::German];
pub const ALL: [Language; 5] = [
Self::English,
Self::French,
Self::Russian,
Self::German,
Self::Spanish,
];
/// Its code in `settings.json` and on the command line.
pub fn code(self) -> &'static str {
@@ -33,6 +41,7 @@ impl Language {
Self::French => "fr",
Self::Russian => "ru",
Self::German => "de",
Self::Spanish => "es",
}
}
@@ -43,6 +52,7 @@ impl Language {
Self::French => "Français",
Self::Russian => "Русский",
Self::German => "Deutsch",
Self::Spanish => "Español",
}
}
@@ -74,6 +84,7 @@ impl Language {
Self::French => include_str!("../lang/fr.json"),
Self::Russian => include_str!("../lang/ru.json"),
Self::German => include_str!("../lang/de.json"),
Self::Spanish => include_str!("../lang/es.json"),
}
}
}
+11 -1
View File
@@ -91,7 +91,12 @@ fn holes(s: &str) -> BTreeSet<&str> {
fn every_language_has_every_string_the_code_asks_for() {
let (asked, all) = strings();
assert!(asked.contains("Language"), "the scan finds tr(\"…\")");
for language in [Language::French, Language::Russian, Language::German] {
for language in [
Language::French,
Language::Russian,
Language::German,
Language::Spanish,
] {
let table: HashMap<String, String> = serde_json::from_str(language.source())
.unwrap_or_else(|e| panic!("lang/{}.json: {e}", language.code()));
let missing: Vec<_> = asked.iter().filter(|s| !table.contains_key(*s)).collect();
@@ -122,11 +127,15 @@ fn the_system_language_is_the_first_one_the_launcher_has() {
assert_eq!(Language::pick(["fr-CH", "en-US"]), Language::French);
assert_eq!(Language::pick(["de-CH", "ru_RU.UTF-8"]), Language::German);
assert_eq!(Language::pick(["de-DE", "ja"]), Language::German);
assert_eq!(Language::pick(["es-ES", "en-US"]), Language::Spanish);
assert_eq!(Language::pick(["ja", "ko"]), Language::English);
assert_eq!(Language::pick([]), Language::English);
assert_eq!(Language::parse("FR"), Some(Language::French));
assert_eq!(Language::parse("DE"), Some(Language::German));
assert_eq!(Language::parse("ES"), Some(Language::Spanish));
assert_eq!(Language::parse("german"), None);
assert_eq!(serde_json::to_string(&Language::German).unwrap(), "\"de\"");
assert_eq!(serde_json::to_string(&Language::Spanish).unwrap(), "\"es\"");
}
#[test]
@@ -134,6 +143,7 @@ fn a_missing_string_shows_in_english() {
assert_eq!(tr_in(Language::French, "Settings"), "Paramètres");
assert_eq!(tr_in(Language::Russian, "Settings"), "Настройки");
assert_eq!(tr_in(Language::German, "Settings"), "Einstellungen");
assert_eq!(tr_in(Language::Spanish, "Settings"), "Ajustes");
assert_eq!(tr_in(Language::English, "Settings"), "Settings");
assert_eq!(tr_in(Language::French, "No such string"), "No such string");
}
+12
View File
@@ -124,6 +124,18 @@ pub struct JavaGame {
/// The mod loader it plays with, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub loader: Option<crate::loaders::Loader>,
/// The game window's size, width then height; none: the game's own 854×480.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub window: Option<(u32, u32)>,
}
impl Instance {
/// Its variables for the game started as `program` and the commands around it
/// ([`crate::launch::instance_vars`]).
pub fn vars(&self, program: &std::ffi::OsStr) -> crate::launch::Vars {
let java = self.java.as_ref().map(|_| program);
crate::launch::instance_vars(&self.name, &self.id, &self.home, java)
}
}
impl JavaGame {
+85 -11
View File
@@ -44,6 +44,48 @@ pub struct Launch<'a> {
pub player: &'a Player,
/// `osx`, `linux` or `windows`, as version files name systems.
pub os: &'a str,
/// The window's size (`--width`, `--height`); none: the game's own 854×480.
pub window: Option<(u32, u32)>,
/// A server to join once the game has started: `host` or `host:port`.
pub server: Option<&'a str>,
}
impl Launch<'_> {
/// The optional features of a version's argument rules this launch turns on
/// (<https://minecraft.wiki/w/Client.json>).
fn features(&self) -> Vec<&'static str> {
let mut on = Vec::new();
if self.window.is_some() {
on.push("has_custom_resolution");
}
if self.server.is_some() {
on.push("is_quick_play_multiplayer");
}
on
}
}
/// `host:port` as the game's arguments before 1.20 take it: `--server host --port port`
/// (25565 when none is given; an IPv6 address keeps its own colons).
fn host_and_port(address: &str) -> (&str, u16) {
match address.rsplit_once(':') {
Some((host, port)) if !host.contains(':') || host.ends_with(']') => match port.parse() {
Ok(port) => (host.trim_matches(['[', ']']), port),
Err(_) => (address, 25565),
},
_ => (address, 25565),
}
}
/// Whether one of `args`' rules asks for `feature`.
fn mentions(args: &Value, feature: &str) -> bool {
args.as_array().into_iter().flatten().any(|a| {
a["rules"]
.as_array()
.into_iter()
.flatten()
.any(|r| r["features"].get(feature).is_some())
})
}
/// This system's name in version files.
@@ -56,8 +98,8 @@ pub fn os_name() -> &'static str {
}
/// Whether a rule list lets this system in: the last matching rule decides; no rules allow.
/// Every optional feature (demo, custom resolution, quick play) is off.
fn allowed(rules: &Value, os: &str) -> bool {
/// The optional features (demo, custom resolution, quick play) are on only when named in `on`.
fn allowed(rules: &Value, os: &str, on: &[&str]) -> bool {
let Some(rules) = rules.as_array() else {
return true;
};
@@ -72,9 +114,10 @@ fn allowed(rules: &Value, os: &str) -> bool {
let system = &rule["os"];
let matches = system["name"].as_str().is_none_or(|n| n == os)
&& system["arch"].as_str().is_none_or(arch_matches)
&& rule["features"]
.as_object()
.is_none_or(|f| f.values().all(|v| v == false));
&& rule["features"].as_object().is_none_or(|f| {
f.iter()
.all(|(name, v)| v.as_bool() == Some(on.contains(&name.as_str())))
});
if matches {
allow = rule["action"] == "allow";
}
@@ -174,7 +217,7 @@ pub fn classpath(version: &Value, minecraft: &Path, os: &str) -> Result<Vec<Path
let jar = version["jar"].as_str().unwrap_or(id);
let mut paths: Vec<PathBuf> = Vec::new();
for library in version["libraries"].as_array().into_iter().flatten() {
if !allowed(&library["rules"], os) {
if !allowed(&library["rules"], os, &[]) {
continue;
}
if let Some(path) = library["downloads"]["artifact"]["path"].as_str() {
@@ -203,7 +246,7 @@ pub fn unpack_natives(
) -> Result<(), String> {
fs::create_dir_all(to).map_err(|e| format!("{}: {e}", to.display()))?;
for library in version["libraries"].as_array().into_iter().flatten() {
if !allowed(&library["rules"], os) {
if !allowed(&library["rules"], os, &[]) {
continue;
}
let Some(key) = mojang::native_classifier(library, os) else {
@@ -275,12 +318,12 @@ pub fn game_assets(version: &Value, minecraft: &Path, game_dir: &Path) -> Result
}
/// The arguments of one list (`arguments.jvm` or `arguments.game`), their rules applied.
fn list(args: &Value, os: &str) -> Vec<String> {
fn list(args: &Value, os: &str, on: &[&str]) -> Vec<String> {
let mut out = Vec::new();
for arg in args.as_array().into_iter().flatten() {
match arg {
Value::String(s) => out.push(s.clone()),
Value::Object(o) if allowed(&o["rules"], os) => match &o["value"] {
Value::Object(o) if allowed(&o["rules"], os, on) => match &o["value"] {
Value::String(s) => out.push(s.clone()),
Value::Array(values) => {
out.extend(values.iter().filter_map(Value::as_str).map(String::from))
@@ -366,6 +409,18 @@ pub fn command(version: &Value, launch: &Launch) -> Result<Command, String> {
.join(separator),
),
("launcher_name", "horizon-launcher".into()),
(
"resolution_width",
launch.window.map(|w| w.0.to_string()).unwrap_or_default(),
),
(
"resolution_height",
launch.window.map(|w| w.1.to_string()).unwrap_or_default(),
),
(
"quickPlayMultiplayer",
launch.server.unwrap_or_default().to_string(),
),
("launcher_version", env!("CARGO_PKG_VERSION").into()),
]);
let mut jvm = vec![
@@ -398,8 +453,27 @@ pub fn command(version: &Value, launch: &Launch) -> Result<Command, String> {
);
game.extend(line.split_whitespace().map(String::from));
}
jvm_list.extend(list(&arguments["jvm"], launch.os));
game.extend(list(&arguments["game"], launch.os));
let on = launch.features();
jvm_list.extend(list(&arguments["jvm"], launch.os, &on));
game.extend(list(&arguments["game"], launch.os, &on));
// Versions whose rules don't ask for them still take the arguments (since 1.6.1): the
// window's size, and before 1.20 (23w14a) a server as `--server` and `--port`.
// <https://minecraft.wiki/w/Java_Edition_client_command_line_arguments>
if let Some((width, height)) = launch
.window
.filter(|_| !mentions(&arguments["game"], "has_custom_resolution"))
{
game.extend(["--width".into(), width.to_string()]);
game.extend(["--height".into(), height.to_string()]);
}
if let Some(server) = launch
.server
.filter(|_| !mentions(&arguments["game"], "is_quick_play_multiplayer"))
{
let (host, port) = host_and_port(server);
game.extend(["--server".into(), host.to_string()]);
game.extend(["--port".into(), port.to_string()]);
}
jvm.extend(jvm_list.iter().map(|a| fill(a, &vars)));
jvm.extend(launch.jvm_args.iter().cloned());
let mut cmd = Command::new(launch.java);
+95 -1
View File
@@ -44,7 +44,9 @@ fn modern() -> Value {
"--userType", "${user_type}",
{"rules": [{"action": "allow", "features": {"is_demo_user": true}}], "value": "--demo"},
{"rules": [{"action": "allow", "features": {"has_custom_resolution": true}}],
"value": ["--width", "${resolution_width}"]}],
"value": ["--width", "${resolution_width}", "--height", "${resolution_height}"]},
{"rules": [{"action": "allow", "features": {"is_quick_play_multiplayer": true}}],
"value": ["--quickPlayMultiplayer", "${quickPlayMultiplayer}"]}],
"jvm": [{"rules": [{"action": "allow", "os": {"name": "osx"}}], "value": ["-XstartOnFirstThread"]},
{"rules": [{"action": "allow", "os": {"name": "windows", "arch": "x86"}}], "value": "-Xss1M"},
"-Djava.library.path=${natives_directory}", "-Dminecraft.launcher.brand=${launcher_name}",
@@ -79,6 +81,8 @@ fn a_modern_version_gets_its_rules_placeholders_and_classpath() {
jvm_args: &extra,
player: &p,
os,
window: None,
server: None,
};
let cmd = command(&modern(), &launch("osx")).unwrap();
let a = args(&cmd);
@@ -179,6 +183,8 @@ fn an_old_version_gets_its_natives_and_assets_by_name() {
jvm_args: &[],
player: &p,
os: "osx",
window: None,
server: None,
};
let a = args(&command(&version, &launch).unwrap());
assert_eq!(fs::read(natives.join("liblwjgl.dylib")).unwrap(), b"lib");
@@ -262,6 +268,8 @@ fn a_loader_profile_plays_on_its_game_version() {
jvm_args: &[],
player: &p,
os: "linux",
window: None,
server: None,
};
let a = args(&command(&version, &launch).unwrap());
let at = |s: &str| a.iter().position(|x| x == s).unwrap();
@@ -317,3 +325,89 @@ fn a_version_that_inherits_from_itself_is_refused() {
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_window_size_and_a_server_to_join() {
let dir = scratch("join");
let (mc, game, natives, java) = (
dir.join("mc"),
dir.join("game"),
dir.join("natives"),
dir.join("java"),
);
let p = player();
let launch = |window, server| Launch {
minecraft: &mc,
game_dir: &game,
natives: &natives,
java: &java,
memory_mb: 4096,
jvm_args: &[],
player: &p,
os: "linux",
window,
server,
};
let after = |a: &[String], flag: &str| {
a.iter()
.position(|x| x == flag)
.map(|i| a[i + 1..].iter().take(3).cloned().collect::<Vec<_>>())
};
// A modern version asks for both through its rules.
let a = args(
&command(
&modern(),
&launch(Some((1280, 720)), Some("play.example.net:25570")),
)
.unwrap(),
);
assert_eq!(
after(&a, "--width").unwrap()[..3],
["1280", "--height", "720"]
);
assert_eq!(
after(&a, "--quickPlayMultiplayer").unwrap()[0],
"play.example.net:25570"
);
assert!(!a.contains(&"--server".into()) && !a.contains(&"--demo".into()));
// Off unless asked.
let a = args(&command(&modern(), &launch(None, None)).unwrap());
assert!(!a.contains(&"--width".into()) && !a.contains(&"--quickPlayMultiplayer".into()));
// Before 1.13: no rules, the arguments themselves; before 1.20 a server is --server/--port.
let old = json!({
"id": "1.12.2", "mainClass": "net.minecraft.client.main.Main", "assetIndex": {"id": "1.12"},
"minecraftArguments": "--username ${auth_player_name}", "libraries": []
});
let a = args(&command(&old, &launch(Some((1280, 720)), Some("mc.example.org"))).unwrap());
let tail: Vec<&str> = a[a.len() - 8..].iter().map(String::as_str).collect();
assert_eq!(
tail,
[
"--width",
"1280",
"--height",
"720",
"--server",
"mc.example.org",
"--port",
"25565"
]
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn server_addresses_split_into_host_and_port() {
assert_eq!(host_and_port("mc.example.org"), ("mc.example.org", 25565));
assert_eq!(
host_and_port("mc.example.org:25570"),
("mc.example.org", 25570)
);
assert_eq!(host_and_port("[2001:db8::1]:25570"), ("2001:db8::1", 25570));
assert_eq!(host_and_port("2001:db8::1"), ("2001:db8::1", 25565));
assert_eq!(
host_and_port("mc.example.org:x"),
("mc.example.org:x", 25565)
);
}
+78 -1
View File
@@ -1,5 +1,7 @@
//! What runs around the game for one instance (`instances::Meta::launch`): a command run before
//! it, a wrapper it runs through, and environment variables. All empty by default.
//! it, a wrapper it runs through, environment variables, and a command run after it exits
//! ([`after_exit`], started by the launcher once it sees the game end). All empty by default.
//! The game and the commands also get the instance's own variables ([`instance_vars`]).
//!
//! [`wrap`] turns the game's command into one process that does all three, so the launcher still
//! follows one program (its output, its exit, Stop):
@@ -26,6 +28,9 @@ pub struct Launch {
/// A command line the game runs through, such as `nice -n 5` (quotes group a word).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub wrapper: String,
/// A shell command run in the instance's folder after the game exits, whatever its exit.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub post_exit: String,
/// Environment variables for the game (and the two commands above).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
@@ -157,6 +162,9 @@ impl Launch {
if !self.wrapper.trim().is_empty() {
parts.push(format!("through {}", self.wrapper.trim()));
}
if !self.post_exit.trim().is_empty() {
parts.push(format!("runs {} after", self.post_exit.trim()));
}
if self.gamemode {
parts.push("with GameMode".into());
}
@@ -213,6 +221,75 @@ pub fn env_line(env: &BTreeMap<String, String>) -> String {
.join(" ")
}
/// The instance's variables for the game and the commands around it, named as Prism Launcher
/// names them so its scripts carry over (<https://prismlauncher.org/wiki/help-pages/custom-commands/>):
/// `INST_NAME`, `INST_ID`, `INST_DIR` and `INST_MC_DIR` (both the instance's game folder), and
/// for a Java instance `INST_JAVA`, the `java` it runs on.
pub fn instance_vars(name: &str, id: &str, dir: &std::path::Path, java: Option<&OsStr>) -> Vars {
let mut vars = vec![
("INST_NAME", OsString::from(name)),
("INST_ID", OsString::from(id)),
("INST_DIR", dir.as_os_str().to_owned()),
("INST_MC_DIR", dir.as_os_str().to_owned()),
];
if let Some(java) = java {
vars.push(("INST_JAVA", java.to_owned()));
}
vars
}
/// An instance's variables ([`instance_vars`]).
pub type Vars = Vec<(&'static str, OsString)>;
/// The post-exit command, ready to run in `dir` with the instance's `vars` and variables; none
/// when there is none. `/bin/sh -c`, or `cmd /D /S /C` on Windows.
pub fn after_exit(
launch: &Launch,
dir: &std::path::Path,
vars: &[(&'static str, OsString)],
) -> Option<Command> {
let line = launch.post_exit.trim();
if line.is_empty() {
return None;
}
let mut cmd = if cfg!(windows) {
let mut c = Command::new("cmd.exe");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
c.raw_arg(format!("/D /S /C \"{line}\""));
}
#[cfg(not(windows))]
c.args(["/D", "/S", "/C", line]);
c
} else {
let mut c = Command::new("/bin/sh");
c.arg("-c").arg(line);
c
};
cmd.current_dir(dir)
.envs(vars.iter().map(|(k, v)| (k, v)))
.envs(&launch.env);
Some(cmd)
}
/// Runs the post-exit command, if any, on its own thread: nothing waits for it, its output is
/// dropped.
pub fn run_after_exit(launch: &Launch, dir: &std::path::Path, vars: &[(&'static str, OsString)]) {
let Some(mut cmd) = after_exit(launch, dir, vars) else {
return;
};
crate::process::hide_console(&mut cmd);
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
std::thread::spawn(move || {
if let Err(e) = cmd.status() {
eprintln!("The post-exit command didn't run: {e}");
}
});
}
/// The Unix script: the pre-launch command (in a subshell, so its `exit` doesn't end the script
/// before the message), then the rest of the arguments as the program.
fn script(pre_launch: &str) -> String {
+46
View File
@@ -193,3 +193,49 @@ fn the_linux_switches_act_only_where_their_tools_are() {
let text = serde_json::to_string(&Launch::default()).unwrap();
assert_eq!(text, "{}");
}
#[test]
fn the_post_exit_command_runs_with_the_instance_variables() {
let dir = std::env::temp_dir().join(format!("horizon-post-exit-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
assert!(after_exit(&Launch::default(), &dir, &[]).is_none());
let launch = Launch {
post_exit: if cfg!(windows) {
// The redirection first: `1> after.txt` would redirect stdout and drop the 1.
"> after.txt echo %INST_NAME% %INST_ID% %INST_JAVA% %MINE%".into()
} else {
"echo \"$INST_NAME $INST_ID $INST_JAVA $MINE\" > after.txt".into()
},
env: parse_env("MINE=1").unwrap(),
..Launch::default()
};
assert_eq!(
launch.summary().unwrap(),
format!("runs {} after, with 1 variable", launch.post_exit)
);
let vars = instance_vars("My Pack", "my-pack", &dir, Some(OsStr::new("java")));
let names: Vec<&str> = vars.iter().map(|(k, _)| *k).collect();
assert_eq!(
names,
[
"INST_NAME",
"INST_ID",
"INST_DIR",
"INST_MC_DIR",
"INST_JAVA"
]
);
assert!(after_exit(&launch, &dir, &vars)
.unwrap()
.status()
.unwrap()
.success());
let written = std::fs::read_to_string(dir.join("after.txt")).unwrap();
assert_eq!(written.trim(), "My Pack my-pack java 1");
// A Horizon instance has no Java.
assert!(!instance_vars("H", "horizon", &dir, None)
.iter()
.any(|(k, _)| *k == "INST_JAVA"));
let _ = std::fs::remove_dir_all(&dir);
}
+13 -1
View File
@@ -32,6 +32,7 @@ impl Launcher {
*self.servers.text(Field::PreLaunch) = i.launch.pre_launch.clone();
*self.servers.text(Field::Wrapper) = i.launch.wrapper.clone();
*self.servers.text(Field::LaunchEnv) = launch::env_line(&i.launch.env);
*self.servers.text(Field::PostExit) = i.launch.post_exit.clone();
self.launch = Some(LaunchPanel {
instance: i.id,
saved: None,
@@ -45,12 +46,14 @@ impl Launcher {
};
let pre = self.servers.value(Field::PreLaunch).trim().to_string();
let wrapper = self.servers.value(Field::Wrapper).trim().to_string();
let post_exit = self.servers.value(Field::PostExit).trim().to_string();
let saved = launch::parse_env(self.servers.value(Field::LaunchEnv)).and_then(|env| {
instances::edit(&id, |m| {
m.launch = launch::Launch {
pre_launch: pre,
wrapper,
env,
post_exit,
..m.launch.clone()
}
})
@@ -199,7 +202,15 @@ impl Launcher {
"Environment variables",
Field::LaunchEnv,
"For example NAME=value OTHER=\"two words\"",
"For the game and both commands above.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.",
);
let post = row(
self,
cx,
"After the game",
Field::PostExit,
"For example ./backup.sh",
"A shell command, run in the instance's folder once the game has exited.",
);
let save = button("launch-save", "Save", Kind::Secondary, &t).on_click(cx.listener(
|this, _: &ClickEvent, _, cx| {
@@ -223,6 +234,7 @@ impl Launcher {
.child(pre)
.child(wrapper)
.child(env)
.child(post)
.children(self.linux_switches(cx))
.child(
div()
+27 -6
View File
@@ -236,6 +236,8 @@ struct Launcher {
quick_play: Option<(&'static str, String)>,
/// The instance the running game plays and when it started: its play time grows on exit.
client_run: Option<(String, Instant)>,
/// The running game's instance variables, for its post-exit command.
client_vars: horizon_launcher::launch::Vars,
/// Settings' Game arguments field: the line, whether it has focus, and its focus handle.
args_input: line_edit::LineEdit,
args_focused: bool,
@@ -442,6 +444,7 @@ impl Launcher {
joining: None,
quick_play: None,
client_run: None,
client_vars: Vec::new(),
collection: collection::Collection::load(),
agents: agents_page::AgentsUi::default(),
playing: None,
@@ -680,7 +683,11 @@ impl Launcher {
Msg::Playing(playing) => self.playing = playing,
Msg::JavaGame(id, home, cmd) => {
self.starting_client = None;
match cmd.and_then(|cmd| backend::spawn(Job::Client, cmd, self.tx.clone())) {
let spawned = cmd.and_then(|(cmd, vars)| {
self.client_vars = vars;
backend::spawn(Job::Client, cmd, self.tx.clone())
});
match spawned {
Ok(r) => {
self.client = Some(r);
self.client_home = Some(home);
@@ -759,11 +766,15 @@ impl Launcher {
backend::append_game_args(&mut cmd);
// Last, around every argument: the instance's pre-launch command,
// wrapper and variables.
let launch = run
let instance = run
.as_ref()
.and_then(|id| instances::list().into_iter().find(|i| &i.id == id))
.map(|i| i.launch)
.and_then(|id| instances::list().into_iter().find(|i| &i.id == id));
self.client_vars = instance
.as_ref()
.map(|i| i.vars(cmd.get_program()))
.unwrap_or_default();
cmd.envs(self.client_vars.iter().map(|(k, v)| (k, v)));
let launch = instance.map(|i| i.launch).unwrap_or_default();
let cmd = horizon_launcher::launch::wrap(cmd, &launch);
self.client_home = cmd.get_current_dir().map(|d| d.to_path_buf());
match backend::spawn(Job::Client, cmd, self.tx.clone()) {
@@ -923,10 +934,17 @@ impl Launcher {
self.multiplayer.stale();
self.screenshots.stale();
self.game_packs.stale();
let vars = std::mem::take(&mut self.client_vars);
if let Some((id, started)) = self.client_run.take() {
if let Err(e) = instances::record_play(&id, started.elapsed()) {
eprintln!("Play time not saved: {e}");
}
if let Some(i) = instances::list().into_iter().find(|i| i.id == id)
{
horizon_launcher::launch::run_after_exit(
&i.launch, &i.home, &vars,
);
}
self.reload_instances();
}
// The run's own game folder: an instance's or a pack's keeps its reports.
@@ -1168,8 +1186,11 @@ impl Launcher {
let log = |line: String| {
let _ = tx.send(Msg::Line(Job::Client, line));
};
let cmd = backend::java_game(&instance, &log)
.map(|cmd| horizon_launcher::launch::wrap(cmd, &instance.launch));
let cmd = backend::java_game(&instance, None, &log).map(|mut cmd| {
let vars = instance.vars(cmd.get_program());
cmd.envs(vars.iter().map(|(k, v)| (k, v)));
(horizon_launcher::launch::wrap(cmd, &instance.launch), vars)
});
let _ = tx.send(Msg::JavaGame(instance.id, instance.home, cmd));
});
}
+22 -4
View File
@@ -463,9 +463,21 @@ impl Launcher {
});
}
/// Turns a Horizon mod the browser installed on or off, as the game's Options > Mods does.
/// Turns a Horizon mod the browser installed on or off, as the game's Options > Mods does;
/// in a Java instance, renames its file.
fn mods_switch(&mut self, e: &installed::Entry) {
let home = self.mods.home();
if self.mods.java.is_some() {
let on = !java_instance::is_on(&home, e);
self.mods.message = Some(java_instance::set_on(&home, e, on).map(|()| {
format!(
"{} is {}. The game reads this when it starts.",
e.title,
if on { "on" } else { "off" }
)
}));
return;
}
let Some(id) = horizon::mod_id(e) else {
return;
};
@@ -1184,9 +1196,15 @@ impl Launcher {
}
cx.notify();
}));
if e.source == SourceId::Horizon && e.kind == PackKind::Mod {
let on =
horizon::mod_id(e).is_some_and(|id| java::is_enabled(&self.mods.home(), id));
let on = match &self.mods.java {
_ if e.kind != PackKind::Mod => None,
Some(_) => Some(java_instance::is_on(&self.mods.home(), e)),
None if e.source == SourceId::Horizon => Some(
horizon::mod_id(e).is_some_and(|id| java::is_enabled(&self.mods.home(), id)),
),
None => None,
};
if let Some(on) = on {
let entry = e.clone();
right = right.child(
div()
+108 -12
View File
@@ -12,6 +12,20 @@
//! (`custom`, `latest-release`, `latest-snapshot`), the version it plays (`lastVersionId`, an
//! id of `versions/<id>/<id>.json`) and its game folder (`gameDir`, else the launcher's own).
//! <https://minecraft.wiki/w/Launcher_profiles.json>
//! - The CurseForge app: its modding folder (`~/curseforge/minecraft` on Windows,
//! `~/Documents/curseforge/minecraft` on macOS and Linux) keeps each modpack in
//! `Instances/<name>/`, the game folder itself beside the app's `minecraftinstance.json` and
//! `.curseclient`.
//! <https://support.curseforge.com/support/solutions/articles/9000218572-minecraft-getting-started>,
//! <https://support.curseforge.com/support/solutions/articles/9000238895-how-to-move-your-minecraft-modding-folder>,
//! <https://help.akliz.net/docs/make-a-curseforge-launcher-modpack-and-server-pack>
//! - The Modrinth App: its folder (`ModrinthApp`, `com.modrinth.theseus` before 0.8.0) keeps each
//! instance's game folder in `profiles/<name>/`.
//! <https://support.modrinth.com/en/articles/8797641-modrinth-app-storage-folder-location>,
//! <https://github.com/modrinth/code/issues/5825>
//!
//! Neither app describes where it records an instance's Minecraft version, so the player names
//! it when copying one.
use crate::instances::{self, Instance};
use crate::loaders;
use crate::sources::{self, Folders, Os};
@@ -34,8 +48,9 @@ pub struct Loader {
pub struct Found {
pub launcher: &'static str,
pub name: String,
/// The Minecraft version: an id (`1.21.1`), `latest` or `latest-snapshot`.
pub minecraft: String,
/// The Minecraft version: an id (`1.21.1`), `latest` or `latest-snapshot`; none when the
/// launcher keeps it where Horizon doesn't read.
pub minecraft: Option<String>,
pub loader: Option<Loader>,
/// Its game folder: saves, mods, packs, options.
pub game: PathBuf,
@@ -56,8 +71,16 @@ const SHARED: [&str; 9] = [
"servers.dat",
"optionsshaders.txt",
];
/// What is never copied from an instance's own game folder.
const SKIPPED: [&str; 2] = ["logs", "crash-reports"];
/// What is never copied from an instance's own game folder: logs, and the CurseForge app's own
/// files.
const SKIPPED: [&str; 4] = [
"logs",
"crash-reports",
"minecraftinstance.json",
".curseclient",
];
const CURSEFORGE: &str = "the CurseForge app";
const MODRINTH_APP: &str = "the Modrinth App";
fn loader_of_uid(uid: &str) -> Option<&'static str> {
match uid {
@@ -122,7 +145,7 @@ pub fn prism_instance(dir: &Path, launcher: &'static str) -> Option<Found> {
Some(Found {
launcher,
name,
minecraft,
minecraft: Some(minecraft),
loader,
game,
shared: false,
@@ -191,7 +214,7 @@ pub fn official_profiles(root: &Path) -> Vec<Found> {
.filter_map(|(_, p)| {
let kind = p["type"].as_str().unwrap_or("custom");
let id = p["lastVersionId"].as_str().unwrap_or_default();
let (minecraft, loader) = match kind {
let (minecraft, loader): (String, _) = match kind {
"latest-release" => ("latest".to_string(), None),
"latest-snapshot" => ("latest-snapshot".to_string(), None),
_ if id.is_empty() => return None,
@@ -211,7 +234,7 @@ pub fn official_profiles(root: &Path) -> Vec<Found> {
Some(Found {
launcher: OFFICIAL,
name,
minecraft,
minecraft: Some(minecraft),
loader,
shared: game.as_ref().is_none_or(|g| g == root),
game: game.unwrap_or_else(|| root.to_path_buf()),
@@ -222,12 +245,64 @@ pub fn official_profiles(root: &Path) -> Vec<Found> {
found
}
/// The game folders in `dir` (each folder in it that holds one), as `launcher`'s instances.
fn game_folders(dir: &Path, launcher: &'static str) -> Vec<Found> {
let mut found: Vec<Found> = fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| {
["saves", "mods", "options.txt", "resourcepacks", "config"]
.iter()
.any(|f| p.join(f).exists())
})
.filter_map(|game| {
Some(Found {
launcher,
name: game.file_name()?.to_string_lossy().into_owned(),
minecraft: None,
loader: None,
game,
shared: false,
})
})
.collect();
found.sort_by_key(|f| f.name.to_lowercase());
found
}
/// The CurseForge app's instances: in its modding folder's `Instances/`.
pub fn curseforge_instances(root: &Path) -> Vec<Found> {
game_folders(&root.join("Instances"), CURSEFORGE)
}
/// The Modrinth App's instances: in its folder's `profiles/`.
pub fn modrinth_app_profiles(root: &Path) -> Vec<Found> {
game_folders(&root.join("profiles"), MODRINTH_APP)
}
/// What one folder holds: an official launcher's profiles, a Prism or MultiMC root (or its
/// `instances/`), or a single instance folder.
/// `instances/`), the CurseForge app's modding folder, the Modrinth App's folder, or a single
/// instance folder.
pub fn in_folder(dir: &Path, launcher: Option<&'static str>) -> Vec<Found> {
if dir.join("launcher_profiles.json").is_file() {
return official_profiles(dir);
}
if dir.join("minecraftinstance.json").is_file() {
let parent = dir.parent().unwrap_or(dir);
let name = dir.file_name();
return game_folders(parent, CURSEFORGE)
.into_iter()
.filter(|f| f.game.file_name() == name)
.collect();
}
if dir.join("Instances").is_dir() {
return curseforge_instances(dir);
}
if dir.join("profiles").is_dir() {
return modrinth_app_profiles(dir);
}
let named = |names: &[&str]| {
dir.ancestors()
.filter_map(Path::file_name)
@@ -258,12 +333,26 @@ pub fn scan(os: Os, folders: &Folders) -> Vec<Found> {
match launcher {
OFFICIAL => found.extend(official_profiles(&dir)),
"Prism Launcher" | "MultiMC" => found.extend(prism_instances(&dir, launcher)),
"Modrinth App" => found.extend(modrinth_app_profiles(&dir)),
_ => {}
}
}
found.extend(
curseforge_folders(os, folders)
.iter()
.flat_map(|dir| curseforge_instances(dir)),
);
found
}
/// The CurseForge app's default modding folder on `os`.
pub fn curseforge_folders(os: Os, folders: &Folders) -> Vec<PathBuf> {
match os {
Os::Windows => vec![folders.home.join("curseforge/minecraft")],
Os::MacOs | Os::Linux => vec![folders.home.join("Documents/curseforge/minecraft")],
}
}
/// Copies `found`'s game folder into `to`: all of it from an instance's own folder but its
/// logs, and only what an instance holds from a folder shared with the launcher. Nothing in
/// the other launcher's folder changes.
@@ -302,11 +391,18 @@ pub fn loader(found: &Found) -> Option<loaders::Loader> {
})
}
/// A new Java instance named `name` playing what `found` plays, with its loader when Horizon
/// installs it ([`loader`]) and a copy of its game folder.
pub fn import(found: &Found, name: &str) -> Result<Instance, String> {
/// A new Java instance named `name` playing what `found` plays (or `minecraft`, the version the
/// player names), with its loader when Horizon installs it ([`loader`]) and a copy of its game
/// folder.
pub fn import(found: &Found, name: &str, minecraft: Option<&str>) -> Result<Instance, String> {
let Some(minecraft) = minecraft.or(found.minecraft.as_deref()) else {
return Err(format!(
"{} doesn't say which Minecraft version {} plays here: name it with --minecraft.",
found.launcher, found.name
));
};
let versions = crate::minecraft_versions::load()?;
let version = versions.find(&found.minecraft)?.id.clone();
let version = versions.find(minecraft)?.id.clone();
let instance = instances::create_java(name, &version)?;
if let Err(e) = copy_game(found, &instance.home) {
let _ = instances::delete(&instance.id);
+58 -4
View File
@@ -48,7 +48,7 @@ fn prism_and_multimc_instances() {
Found {
launcher: "Prism Launcher",
name: "My Fabric".into(),
minecraft: "1.21.1".into(),
minecraft: Some("1.21.1".into()),
loader: Some(Loader {
name: "fabric",
version: "0.16.9".into()
@@ -59,7 +59,7 @@ fn prism_and_multimc_instances() {
Found {
launcher: "Prism Launcher",
name: "old".into(),
minecraft: "1.12.2".into(),
minecraft: Some("1.12.2".into()),
loader: Some(Loader {
name: "forge",
version: "14.23.5.2860".into()
@@ -116,7 +116,7 @@ fn official_launcher_profiles() {
.map(|f| {
(
f.name.as_str(),
f.minecraft.as_str(),
f.minecraft.as_deref().unwrap(),
f.loader.as_ref().map(|l| (l.name, l.version.as_str())),
f.shared,
)
@@ -154,7 +154,7 @@ fn copies_an_instance_but_only_what_a_shared_folder_holds_for_one() {
let mut found = Found {
launcher: "Prism Launcher",
name: "x".into(),
minecraft: "1.21.1".into(),
minecraft: Some("1.21.1".into()),
loader: None,
game: game.clone(),
shared: false,
@@ -193,3 +193,57 @@ fn copies_an_instance_but_only_what_a_shared_folder_holds_for_one() {
assert!(game.join("logs/latest.log").is_file());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn curseforge_and_modrinth_app_instances_are_their_game_folders() {
let root = scratch("apps");
let curseforge = root.join("Documents/curseforge/minecraft");
write(&curseforge.join("Instances/All the Mods/mods/a.jar"), "jar");
write(
&curseforge.join("Instances/All the Mods/minecraftinstance.json"),
"{}",
);
write(&curseforge.join("Instances/All the Mods/.curseclient"), "");
fs::create_dir_all(curseforge.join("Instances/empty")).unwrap();
let modrinth = root.join("data/ModrinthApp");
write(
&modrinth.join("profiles/Fabulously Optimized/options.txt"),
"",
);
let folders = Folders {
home: root.clone(),
data: root.join("data"),
config: root.join("config"),
};
let found = scan(Os::MacOs, &folders);
let short: Vec<_> = found
.iter()
.map(|f| (f.launcher, f.name.as_str(), f.minecraft.is_none(), f.shared))
.collect();
assert_eq!(
short,
[
(MODRINTH_APP, "Fabulously Optimized", true, false),
(CURSEFORGE, "All the Mods", true, false),
]
);
let pack = curseforge.join("Instances/All the Mods");
assert_eq!(in_folder(&curseforge, None), [found[1].clone()]);
assert_eq!(in_folder(&pack, None), [found[1].clone()]);
assert_eq!(in_folder(&modrinth, None), [found[0].clone()]);
// The app's own files stay behind.
let to = root.join("copy");
copy_game(&found[1], &to).unwrap();
assert!(to.join("mods/a.jar").is_file());
assert!(!to.join("minecraftinstance.json").exists() && !to.join(".curseclient").exists());
let err = import(&found[1], "x", None).unwrap_err();
assert_eq!(
err,
"the CurseForge app doesn't say which Minecraft version All the Mods plays here: name \
it with --minecraft."
);
let _ = fs::remove_dir_all(&root);
}
+6 -2
View File
@@ -44,13 +44,14 @@ pub(super) enum Field {
PreLaunch,
Wrapper,
LaunchEnv,
PostExit,
/// Settings' new offline name (`switch_page.rs`).
OfflineName,
DeleteServer,
}
impl Field {
const ALL: [Field; 19] = [
const ALL: [Field; 20] = [
Field::Name,
Field::Seed,
Field::Players,
@@ -68,6 +69,7 @@ impl Field {
Field::PreLaunch,
Field::Wrapper,
Field::LaunchEnv,
Field::PostExit,
Field::OfflineName,
Field::DeleteServer,
];
@@ -1081,7 +1083,9 @@ impl Launcher {
| Field::InstanceGroup
| Field::ConsoleSearch
| Field::DeleteServer => {}
Field::PreLaunch | Field::Wrapper | Field::LaunchEnv => self.save_launch(),
Field::PreLaunch | Field::Wrapper | Field::LaunchEnv | Field::PostExit => {
self.save_launch()
}
Field::OfflineName => self.offline_name_enter(),
Field::ServerName | Field::ServerAddress => self.add_server(),
}
+10 -1
View File
@@ -2,6 +2,7 @@ from pathlib import Path
import json
import os
import tempfile
from types import SimpleNamespace
import unittest
from unittest import mock
@@ -146,6 +147,14 @@ class ParityRunnerTests(unittest.TestCase):
with mock.patch.object(parity.sys, "platform", "darwin"):
self.assertEqual(parity.platform_skips(config), {"options": "libm"})
def test_termination_signals_are_caught_where_they_exist(self):
for numbers in ({"SIGTERM": 15, "SIGHUP": 1}, {"SIGTERM": 15}): # Windows has no SIGHUP
handled = []
fake = SimpleNamespace(**numbers, signal=lambda number, _handler: handled.append(number))
with self.subTest(numbers=numbers), mock.patch.object(parity, "signal", fake):
parity.exit_on_termination()
self.assertEqual(handled, list(numbers.values()))
def test_a_hosts_excludes_are_skipped_when_it_runs_parity_itself(self):
config = {"hosts": {"playhorizon": {"exclude": {"remote_fireworks": "starved"}}, "macmini": {"exclude": {}}}}
with mock.patch.object(parity.sys, "platform", "linux"):
@@ -180,7 +189,7 @@ class ParityRunnerTests(unittest.TestCase):
class DependencyPathTests(unittest.TestCase):
def test_traced_dependencies_use_repository_posix_paths(self):
with tempfile.TemporaryDirectory() as temp, mock.patch.object(parity, "ROOT", Path(temp)):
with tempfile.TemporaryDirectory() as temp, mock.patch.object(parity, "ROOT", Path(temp).resolve()):
source = Path(temp, "crates/w/src/lib.rs")
source.parent.mkdir(parents=True)
source.write_text("")
+4 -3
View File
@@ -1143,9 +1143,10 @@ def run(selected, state, hasher, environment_digest, executors, reuse_oracles, r
def exit_on_termination():
"""SIGTERM and SIGHUP (a cancelled CI job, a dropped ssh) end this process through its finally blocks, which stop
the running harnesses. Each runs in its own session, so without that they, and the vanilla servers they started,
outlive it (orphans of days were found on the Mac mini's runners, 2026-10-26)."""
for number in (signal.SIGTERM, signal.SIGHUP):
signal.signal(number, lambda received, _frame: sys.exit(128 + received))
outlive it (orphans of days were found on the Mac mini's runners, 2026-10-26). Windows has no SIGHUP."""
for name in ("SIGTERM", "SIGHUP"):
if hasattr(signal, name):
signal.signal(getattr(signal, name), lambda received, _frame: sys.exit(128 + received))
def worker_main(shared_local):
+2
View File
@@ -66,6 +66,8 @@ def scenarios():
# Night: undead do not burn, monsters seek darkness.
add('zombie_night', 200, ['player 10.5 -60 0.5', 'mob minecraft:zombie 0.5 -60 0.5 0 31 persistent'], 'time=18000')
add('skeleton_day_burn', 120, ['mob minecraft:skeleton 0.5 -60 0.5 0 32 persistent'])
# NoAI only skips serverAiStep: Mob.aiStep's burnUndead still sets a NoAI zombie alight (#3430).
add('zombie_noai_day_burn', 120, ['mob minecraft:zombie 0.5 -60 0.5 0 33 persistent noai'])
# The wither (summoned, not built: no invulnerable spawn phase).
add('wither_player', 200, ['player 10.5 -60 0.5', 'mob minecraft:wither 0.5 -58 0.5 0 41 persistent'])
# A villager with a job site, a bed and a bell.