Compare commits

..
Author SHA1 Message Date
Ludovic Marie 86a9d9e669 Merge pull request #10236 from horizon-rust/fix/launcher-screenshot-memory
🤖 Agent: merging. The screenshot tiles, viewer and background no longer go through GPUI's never-evicted img(path) cache. Two 32k×16k screenshots on Home went from an 8.7 GiB peak to 277 MiB on the desktop. One risk to watch: the 2 s grace lets the budget overflow while more than 256 MiB of tiles is on screen at once (about 280 tiles with Show all), which is bounded by the list itself.
2026-11-05 21:37:05 +01:00
Horizon agentandClaude Opus 5.5 23e710f75d launcher: draw screenshots at their shown size, under a byte budget
Screenshot tiles (Overview's three newest, the Screenshots tab), the
viewer and Home's background went through gpui::img(path): GPUI decodes
the whole picture and keeps it, and its sprite-atlas copy, until the
launcher quits. One 32768x16384 screenshot is 2 GiB decoded; with two
of them Home alone reached 8.7 GiB (24 GiB reported on Linux).

- pictures (lib): PNGs are read a row at a time and box-filtered down to
  the drawn size, never held whole; other formats decode whole only
  under 256 MiB. Lru keeps made pictures under a byte budget, sparing
  those drawn in the last 2 s.
- picture_cache: makes them off the UI thread (3 at once), 640 px for
  tiles, 3840 px for the viewer and background, 256 MiB budget; evicted
  images are dropped from the window's sprite atlas.
- glass_shot_blur's blurred copies go through it too, from a shrunk read
  instead of image::open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:35:43 +01:00
Ludovic Marie 3d33b0299d Merge pull request #10223 from horizon-rust/feat/shop-tab-button-label
🤖 Agent: the Wardrobe button opens the launcher's Shop tab now, so its label drops "on playhorizon.dev"; the key is renamed in place in all 86 tables (no stale key left), and the i18n test passes.
2026-11-05 21:20:36 +01:00
Horizon agentandClaude Opus 5.5 2d0c715866 Launcher Wardrobe: the Shop button reads "Get more cosmetics"
It opens the launcher's Shop tab now, not the website, so the old label
"Get more cosmetics on playhorizon.dev" was wrong. The key is renamed in all 86
tables, in place.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:08:34 +01:00
Ludovic Marie f00c4c6392 Merge pull request #10139 from horizon-rust/feat/shop-tab-3
🤖 Agent: Pay checks that the checkout's uuid matches the recipient and only opens playhorizon.dev/shop/pay with a validated txn_ id; no card entry in the launcher.
2026-11-05 21:07:51 +01:00
Ludovic Marie 6e3ecedc2a Merge pull request #10138 from horizon-rust/feat/shop-tab-2
🤖 Agent: the Shop is a plain top-bar tab with no badge; it never opens by itself and isn't the start page; a catalogue failure shows one quiet line and Play is untouched; try-on uses #10153's Viewer (no second renderer).
2026-11-05 21:05:28 +01:00
Ludovic Marie c58f498288 Merge pull request #10137 from horizon-rust/feat/shop-tab-1
🤖 Agent: shop price rules (sets partly owned, items inside a set in the basket, Supporter once) match the site's pricing.ts and are tested against hand-worked values; nothing player-visible yet. Requeued after merging main (#10153's lang tails).
2026-11-05 20:39:00 +01:00
Ludovic Marie 1fdf875085 Merge pull request #9918 from oxi-does-dev/fix/interact-hit-location
🤖 Agent: Requeued on the same head: the parity run died in 1 s on the runner's missing .horizon-prepare.lock (environment, no harness ran), so it's rerunning (42040505129). The interact packet now carries the ray's hit point from the mob's feet. Union on main dd675b574 green.
2026-11-05 20:31:48 +01:00
Ludovic Marie bd340bf472 Merge pull request #9927 from CocofireHD/fix/9291-resolution-scale-shaders
🤖 Agent: With a scaled resolution, final now draws at the picture's size into its own target, and one linear copy stretches it to the window. Same-size output and packs without a final keep their old paths, so only resolution-scaled pack users take the extra pass. Union on main dd675b574 green (fmt, clippy, 4426 client tests including the render crate).
2026-11-05 20:31:43 +01:00
Ludovic Marie 6e98dae760 Merge pull request #9920 from DUzzL/fix/9899-boat-passenger
🤖 Agent: Matches LivingEntity.calculateEntityAnimation: a passenger calls walkAnimation.stop(), which zeroes speed, old speed and position, so the seat moving with the boat no longer swings the legs. Union on main dd675b574 green (fmt, clippy, 4426 client tests).
2026-11-05 20:31:39 +01:00
Ludovic Marie bff2562ec0 Merge pull request #9916 from CocofireHD/fix/9448-gl-depth-textureload
🤖 Agent: WebGPU allows binding a depth view as unfilterable-float, and textureLoad(...).r is the same texel, so Vulkan, DX12 and Metal read the same depth. The translation test now fails on any GL-run entry point that doesn't translate, except the one known glint case. Union on main dd675b574 green (fmt, clippy, 4426 client tests).
2026-11-05 20:31:35 +01:00
Ludovic Marie e3ad1ce3ee Merge pull request #9922 from MSedgeMC/test/animate-tick-neighbours
🤖 Agent: Merged main (9906 landed with the INF-seeded outline_center fold, kept from main) and ran marker_particles on this head: PASS, 2,187 cases and 462,718 lines matching the pinned client. The hive's loop now draws nextInt(1) again on the check that ends it, as BeehiveBlock.animateTick's for-condition does.
2026-11-05 20:27:42 +01:00
Ludovic Marie 5b4823c198 Merge pull request #10093 from horizon-rust/emotes/3-network
🤖 Agent: emotes, part 3 (#10069): others see your emote through the cosmetics service, now based on main. CI verify is green. Polling is bounded: at most once a second, only while Horizon players are in view, and only with Other players' cosmetics on Shown. Sending is signed and only while Show me as Horizon is on. Without horizon-cosmetics#14 deployed, the service answers 404 and the game backs off.
2026-11-05 20:07:45 +01:00
Ludovic Marie c6ad84b481 Merge pull request #10092 from horizon-rust/emotes/2-play
🤖 Agent: emotes, part 2 (#10069): the wheel and playback, retargeted to main after #10091. CI verify is green. Two clients played every emote in game; captures are in the PR. Visual only, nothing sent to game servers. #10093 follows.
2026-11-05 20:05:22 +01:00
Ludovic Marie 0508903777 Merge pull request #10091 from horizon-rust/emotes/1-format
🤖 Agent: emote format, the bottom of the emotes stack (#10069). pr_ready on the whole stack is green except the NixOS-only uname test. pose_body copies the target at full weight: 0.26–1.1 µs per emoting player and frame, measured. #10092 follows once this lands.
2026-11-05 20:01:16 +01:00
Horizon agentandClaude Opus 5.5 e7c5cb7331 Merge main (via feat/shop-tab-2) into feat/shop-tab-3
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:54:27 +01:00
Horizon agentandClaude Opus 5.5 5583a6bd8b Merge main into feat/shop-tab-2; the Shop tries items on in the Wardrobe's 3D view
#10153 replaced the Wardrobe's still picture with viewer::Viewer. The Shop now
uses its own Viewer (drag to turn, scroll to zoom, Reset view) instead of the
still picture and its Turn button, so there is one renderer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:54:15 +01:00
Horizon agentandClaude Opus 5.5 e15d46d371 Merge main into feat/shop-tab-1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:52:52 +01:00
Ludovic Marie f2f95dad8a Merge pull request #10153 from horizon-rust/agent/wardrobe-3d
🤖 Agent: verified on the desktop at this tree: horizon-cosmetics fmt/clippy/tests (40) and the launcher's fmt/clippy/tests (695) green; pr_ready skipped because the desktop disk was under 45 GB. Point to watch: preview::render now takes a Camera (launcher is its only caller), and lib.rs will conflict by one line with the emote format PR #10091.
2026-11-05 19:38:53 +01:00
Ludovic Marie 56349b9713 Merge pull request #9926 from masa-masa-0314/feat/realms-settings-tab
🤖 Agent: Fixed to the 26.3 jar's DTOs: the region preference travels as its id (0/1/2) and the region as its nameId, so a Realm that has a preference parses again (it used to fail the whole RealmServer parse). preferredRegions reads regionDataList/regionName, and the configuration POST puts the slot under options with update_slot's RealmsSlotUpdateDto. New tests pin the POST body and the parsing; CI verify is green on this head.
2026-11-05 19:34:24 +01:00
Horizon agentandClaude Opus 5.5 4f0af1f5f2 Merge main (via feat/shop-tab-2) into feat/shop-tab-3
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:29:24 +01:00
Horizon agentandClaude Opus 5.5 75adf421e0 Merge main (via feat/shop-tab-1) into feat/shop-tab-2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:29:22 +01:00
Horizon agentandClaude Opus 5.5 ec78951af1 Merge main into feat/shop-tab-1 (lang tables re-appended)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:29:20 +01:00
Horizon agentandClaude Opus 5.5 85056bcaaf Realms settings: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:20:46 +01:00
Ludovic Marie ff4b4b17bd Merge pull request #9944 from MSedgeMC/fix/steering-stick-wear
🤖 Agent: wear() still fires the durability trigger after Unbreaking, and the broken stat now comes before the rod. The parity failure at ~17:00 was the playhorizon runner's dead .horizon-prepare.lock link, not this change: rerun on this head.
2026-11-05 19:09:06 +01:00
Ludovic Marie 120fe4d7e3 Merge pull request #9941 from MSedgeMC/fix/bundle-tooltip-display
🤖 Agent: The height probe and the draw share bundle_tooltip, so a hidden grid can't leave an empty box taller than its lines. The parity failure at ~17:00 was the playhorizon runner's dead .horizon-prepare.lock link, not this change: rerun on this head.
2026-11-05 19:09:02 +01:00
Ludovic Marie bebaecd65a Merge pull request #9939 from degepie/fix/name-tag-interact-trigger
🤖 Agent: The tag is copied before name_mob uses it up, like handleInteract's itemStackBefore. Follow-up gap, not this PR: vanilla also fires the trigger with an empty stack on non-item successes. The parity failure at ~17:00 was the playhorizon runner's dead .horizon-prepare.lock link, not this change: rerun on this head.
2026-11-05 19:08:58 +01:00
Ludovic Marie 674de01447 Merge pull request #9938 from degepie/fix/drink-consume-trigger
🤖 Agent: Same two calls finish_consuming makes, before the stack shrinks, as Consumable.onConsume orders them. drink_ominous_bottle awarded neither, so nothing counts twice. The parity failure at ~17:00 was the playhorizon runner's dead .horizon-prepare.lock link, not this change: rerun on this head.
2026-11-05 19:08:52 +01:00
Ludovic Marie d1dc1cb937 Merge pull request #9937 from degepie/fix/structure-tag-predicates
🤖 Agent: The pack-less structure tags are a OnceLock keyed on the first resource root, as StructureSpawns::shared is. location_structure_tags_hold_their_structures passes with prepared resources. The parity failure at ~17:00 was the playhorizon runner's dead .horizon-prepare.lock link, not this change: rerun on this head.
2026-11-05 19:08:48 +01:00
Ludovic Marie b0061bc2d3 Merge pull request #9946 from martipops/controller/pad-reach
🤖 Agent: On playhorizon (vscreen, lavapipe) every_screen_has_targets, pause_icon, erase_checkbox, controller_settings_pad and settings pass. The empty-label target can add a second target on a rect that already called target_as: drawn_targets dedups only against labelled widgets, not between targets. Nothing on main seems to do that today. Union run on playhorizon (main a1e0c0db0 + this gate's 11 PRs): fmt, clippy, all touched crates' tests pass.
2026-11-05 19:08:44 +01:00
Horizon agentandClaude Opus 5.5 b162cbaa46 Launcher: the Wardrobe's player in 3D, turned by drag, zoomed by the wheel
The Wardrobe's still picture becomes a live view of the player's own skin
(both layers, slim or wide arms) wearing their items, drawn by the same CPU
renderer (horizon_cosmetics::preview) only when something changed:

- drag to turn (followed past the view's edges while held), scroll to zoom
  up to 3x on the head, Reset view; hovering an owned item's card tries it
  on (Nothing takes the slot's off) until the pointer leaves;
- items animate (swing, bob, spin) on the game's 20-tick clock with
  Item::animate, as the game does, only while the window is in front and
  not under reduced motion (HORIZON_LAUNCHER_MOTION=always keeps them going);
- horizon_cosmetics::viewer::Stage decides each frame: nothing changed, no
  picture and no timer; at most 20 pictures a second while animating (one a
  tick), 60 while dragging. Pictures are drawn at the view's size in points
  (240x330, about 2 ms) and baked up to device pixels;
- preview::Camera holds the turn, tilt and zoom (with headroom above the
  head for tall hats and back items), Draw::item_posed keeps items on posed
  bones, and viewer::BodyMotion is the hook an emote plays through
  (Viewer::play), for the emote format in flight (#10091).

The view is a GPUI view of its own, so a new picture rebuilds it alone;
the pointer is handled by the page's element around it, since GPUI Fast
hit-tests the page's own elements. "Turn" gives way to "Reset view" and a
hint, in every language.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:08:41 +01:00
Ludovic Marie 0ad6666970 Merge pull request #9942 from TheMrZZ/fix/enderman-neighbour-placement
🤖 Agent: Deferring the attempt to the event phase lets update_from_neighbour_shapes see the real level and ticks, as EndermanLeaveBlockGoal.tick does. leave_block_goal_uses_real_neighbor_shapes_and_keeps_air_placement passes with prepared resources. Still missing: canSurvive and the BLOCK_PLACE game event. Union run on playhorizon (main a1e0c0db0 + this gate's 11 PRs): fmt, clippy, all touched crates' tests pass.
2026-11-05 19:08:39 +01:00
Horizon agent 1011abe707 Merge branch 'emotes/2-play' into emotes/3-network 2026-11-05 19:04:32 +01:00
Horizon agentandClaude Opus 5.5 6f88fac9a9 Emotes: the wheel's key release and click in emote_wheel.rs (keeps game.rs's release block whole)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:04:32 +01:00
Ludovic Marie baa9153c82 Merge pull request #9943 from CocofireHD/fix/9439-tank-refuse-fluid
🤖 Agent: native_fluid_at now allocates the block id String on every call, drains and tank queries included. That's fine for mod helpers, but worth an &'static str if a tick path ever calls it. The fragment stamps (tests.test_stamp_native_hooks passed in the union run). I ran the native_fluids tests on this head on playhorizon, 12 passed, the new dispatch test included.
2026-11-05 19:03:54 +01:00
Ludovic Marie 936594e71b Merge pull request #10078 from horizon-rust/pvp/hud-layout
🤖 Agent: merging. The layout lines only add keys to horizon-extras.txt; files without them load as before (every widget at its default place and style). Verify is green; stacked on #10077.
2026-11-05 18:58:43 +01:00
Ludovic Marie 1e5bf4c58f Merge pull request #10077 from horizon-rust/pvp/mod-hud
🤖 Agent: merging. Every widget reads local state only (no packets, no server queries); with every widget off, draw_pvp_hud returns after Hud::any(), so a player who never turns one on pays nothing. Verify is green; pr_ready --quick on playhorizon fails only the render crash:: tests that SIGSEGV under llvmpipe there, and crash.rs is untouched.
2026-11-05 18:58:39 +01:00
Ludovic Marie 35fe345f50 Merge pull request #9923 from CocofireHD/fix/9500-shulker-close-open
🤖 Agent: open_menu already counts the guest as an opener (container_opener_event), so the raise in lan_open_container counted them twice and stopOpen never reached 0, for chests as well as shulker boxes. The author's lan_e2e shulker_menu check pins the lid open, then shut, on both host and guest. Union on main dd675b574 green.
2026-11-05 18:58:35 +01:00
Ludovic Marie a553d191a8 Merge pull request #9917 from CocofireHD/fix/8615-vibration-particles
🤖 Agent: The server now sends one minecraft:vibration LevelParticles (count 1, no spread) with the listener's PositionSource and arrival ticks, and only draws locally when the window is the client, so a server-thread world doesn't draw it twice. Tested at a729b3cfe in a union on main dd675b574 (fmt, clippy, 4426 client tests green); this head only adds the author's main merge (test helper conflict) on top.
2026-11-05 18:58:26 +01:00
CocofireHDandopencode 1c712e434a Mod fluid tanks: prove a hook refusal through the dispatch
A focused test drives the public block_entity_accepts_fluid dispatch, as the game does: a water tank that refuses a mod's fluid leaves both insert passes empty and offers native_move_fluids no room, while water and a tank with no hook still fill.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 18:34:29 +01:00
Horizon agentandClaude Opus 5.5 ce9d8ae6cf Emotes: the measured cost per frame in docs/cosmetics.md
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:23:53 +01:00
Horizon agentandClaude Opus 5.5 4a59bae751 Emotes: the wheel reads the cursor as it is (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:23:53 +01:00
Horizon agentandClaude Opus 5.5 95244a792a Emotes: others see yours (the cosmetics service), the wheel lists the account's
- Mine: while an emote plays, the game tells the cosmetics service, signed in as for "Show me
  as Horizon" and only while that is on: `POST /v1/emote`, with a session kept an hour
  (`account::Kept`). A loop is said again every 30 s.
- Theirs: `horizon_cosmetics::Feed`, a thread, asks `POST /v1/emotes` which Horizon players in
  view play one, at most once a second and only while some are in view, then fetches the item
  files once. On failure it waits 15 s, doubling up to 10 min. Their models play the emote from
  where it is; a player who moves ends theirs at once.
- Follows "Other players' cosmetics": with anything but Shown, nothing is asked and nothing is
  drawn.
- The wheel: the account's emotes (`GET /v1/me`'s `emotes`), read when it opens, at most every
  10 minutes.

Nothing reaches game servers; vanilla clients see nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:23:53 +01:00
Horizon agentandClaude Opus 5.5 ff8997168c Emotes: horizon-ctl emote in the generated command reference
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:08:59 +01:00
Horizon agentandClaude Opus 5.5 2fed27fce9 Emotes: play one from the emote wheel, on your model and in the camera
- The wheel: Emote Wheel (`key.emote`, B by default, unbound on a controller; rebindable in
  Controls) shows up to 8 emotes around the middle of the screen. To play one: hold the key and
  release on an emote, click one, or press its number.
- The emote cancels (and fades out) on movement, an attack, mining, using an item, being hurt,
  riding, sleeping, gliding or sneaking. Only a standing player starts one.
- From first person, the camera turns to the front view and eases out from close for the emote,
  then comes back.
- Bones: `anim::setup` lays the emote over vanilla's animation, keyed bones only.
- Props: drawn with the cosmetics, growing in and out with the blend.
- `horizon-ctl emote [ID|stop]`.
- HORIZON_COSMETICS_LOCAL: the wheel offers the folder's emotes, and mannequins play its first
  one (the art and the bench).

Visual only: nothing reaches the game server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:08:59 +01:00
Horizon agentandClaude Opus 5.5 44c14af45a Emotes: fully in, the bones take the target as it is (no quaternion round trip)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:08:58 +01:00
Horizon agentandClaude Opus 5.5 071feb3308 Cosmetics: the emote format (keyframed bones, root, props)
An item of the new `emote` slot carries an `emote` block: per-part tracks of keys (rotation,
offset, a prop's scale) eased smooth (Catmull-Rom), linear, in_out or step, one-shot or looping,
faded in and out over `blend` ticks. `Item::parse` checks its limits (200 ticks, 16 tracks,
32 keys a track and 192 in all, 360° turns and 8 px moves, the root 720° and 16 px, props'
reach through every key). `Emote::pose_body` poses the six bones (keys replace a bone's
rotation, keyed bones only), `pose_props` the props, and `Playing` gives an emote's time and
weight, its cancel included. Visual only, nothing random.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:04:09 +01:00
Horizon agentandClaude Opus 5.5 1fcc43f60f Realms settings: the 26.3 JSON shapes for the region preference and the configuration
RegionSelectionPreference travels as its id (0, 1, 2) and the region as its nameId, so a
Realm with a preference no longer fails to parse as a String. PreferredRegionsDto reads
regionDataList / regionName / serviceQuality, RealmsConfigurationDto posts the slot under
options with update_slot's RealmsSlotUpdateDto (worldTemplateId, worldTemplateImage), a Realm
without a preference is automatic by owner (RegionSelectionPreferenceDto.DEFAULT), and the
labels use the realms.configuration.region* keys vanilla's settings tab shows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:58:19 +01:00
Horizon agentandClaude Opus 5.5 ad73e4a692 Launcher Shop: a basket, gifts, the Supporter badge, and Pay in the browser
Add to basket on the chosen card, the basket's lines and total with the
service's rules (owned items and items inside a set in the basket cost
nothing), a gift to a friend looked up by name, the Supporter badge's
amount within the catalogue's range, and "Ask whoever pays before you
buy. Refunds within 14 days for any reason." by Pay, which starts
Paddle's checkout on the service and opens playhorizon.dev/shop/pay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:52:57 +01:00
Horizon agentandClaude Opus 5.5 fac063268c Launcher: a plain Shop tab beside the Wardrobe
The catalogue's items, sets, collections and the Supporter badge as cards
with their prices, the chosen card tried on the account's own skin, and
the founder set's claim (one click where Paddle doesn't sell, else
Paddle's free checkout in the browser). No badge on the tab, it never
opens by itself, and a catalogue that doesn't load says so in one line.
The Wardrobe's one shop button now opens this tab.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:51:19 +01:00
Horizon agent 621d47ab6d Merge commit 'dd675b574115536446647051e29dac76f1055bc5' into cg51/pr-9922
# Conflicts:
#	crates/horizon-client/examples/probe/marker_particles_probe.rs
#	crates/horizon-client/src/animate_tick.rs
#	tools/test_marker_particles_parity.py
2026-11-05 17:50:19 +01:00
CocofireHD 49e8906c56 Merge remote-tracking branch 'origin/main' into fix/8615-vibration-particles 2026-11-05 17:47:05 +01:00
Horizon agentandClaude Opus 5.5 07c08509df Launcher Shop: prices, sets and the Supporter badge from the catalogue; the web checkout
The catalogue's shop fields (currencies, prices, sets, collections, the
Supporter badge), the player lookup and the web checkout in the wardrobe
client, and the basket's price rules in shop.rs, as the website applies
them. Nothing shows them yet: the Shop tab comes next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:40:05 +01:00
CocofireHDandopencode 40eeef6f80 lan_e2e: state the shulker lid check in the harness docs
Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 17:26:35 +01:00
CocofireHD a729b3cfe6 Merge remote-tracking branch 'origin/main' into fix/8615-vibration-particles 2026-11-05 17:18:40 +01:00
Horizon agentandClaude Opus 5.5 7b9daf7bbf PvP & HUD: document the widgets' style and layout
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:10:22 +01:00
Horizon agentandClaude Opus 5.5 afc6c3c43a PvP & HUD: free widget places, sizes, text colour, opacity and shadow
Each HUD widget can leave its corner for a place kept as fractions of the
screen's free room (so it stays on screen at any size) and a size from 50
to 200%, saved as hudPlace lines in horizon-extras.txt only once moved.
The page gains the boxes' opacity, the text colour and the text shadow.
Snapping (screen edges, middle, other boxes) is here for the editor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:10:22 +01:00
Horizon agentandClaude Opus 5.5 69874aef48 PvP & HUD: no widget on, no HUD work; document the new widgets
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:10:17 +01:00
Horizon agentandClaude Opus 5.5 9f46032653 PvP & HUD: FPS, CPS, keystrokes, clock, reach and speed widgets
Six more widgets on the PvP & HUD page, off by default, each from the
client's own state: the frame counter, presses of the last second, the
movement and mouse keys while held, the local time, the distance of the
last hit the crosshair's ray measured, and horizontal speed sampled from
the player's position. Each caches its lines and rebuilds them only when
the shown value changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:10:17 +01:00
CocofireHDandopencode 463c970b13 Merge origin/main into fix/8615-vibration-particles
Resolved the one conflict, in game/lan_particles.rs: test_hub keeps the
shared helper the vibration test needs, and its guests now carry main's
Joined.expected field and bind through NetConfig::loopback. The
sculk_vibration LAN check survives main's rewrite of tools/lan_e2e.py.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 17:08:57 +01:00
CocofireHDandopencode 58263dc4a0 lan_e2e: capture the shulker lid open and closed
The shulker_menu check now keeps host and guest screenshots of the lid before and after the guest closes the menu, so a stuck lid is visible as well as asserted.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 17:03:25 +01:00
masa-masa-0314andZCode df263fef74 Realms: the settings tab and the region preference screen
The configuration screen's Settings tab (RealmsSettingsTab): the world's name and
description fields (the click moves the caret between them), the region preference line
and its Region Preference button, and the open/close toggle moved here from the footer
(vanilla keeps it on this tab). Leaving the tab — switching tabs or Back/Escape — saves
the changed name, description and preference through RealmsClient.updateConfiguration
(POST worlds/{id}/configuration with RealmsConfigurationDto's JSON: the active slot, its
settings, the preference and the description).

RealmsPreferredRegionSelectionScreen: the automatic choices and every manual region, a
click selects, Done writes the choice back to the tab and Cancel drops it.

RealmsClient gains getPreferredRegionSelections (GET regions/preferredRegions) and
updateConfiguration; RealmServer reads regionSelectionPreference; RegionSelectionPreferenceDto,
RegionDataDto and RealmSetting serialization join the DTOs.

The tier's render/simulation distance options and the region quality icons are the next
PRs (the icons need sprites the GUI atlas does not carry yet); the region rows say their
name for now.

Co-Authored-By: ZCode <[email protected]>
2026-11-05 23:24:38 +08:00
CocofireHDandopencode f8d0840cd7 lan_e2e: prove a sculk sensor's vibration reaches the host and a guest
VibrationSystem.Ticker.trySelectAndScheduleVibration sends one vibration particle to every player within 32 blocks; the check asserts both the host's window and a LAN guest make it (#8615).

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 15:30:45 +01:00
MSedgeMC e51fd1dff4 port_map: ItemStack.hurtAndConvertOnBreak is the steering stick's rod
The row was `missing`; the conversion was in `use_steering_stick` but
built a fresh plain rod and skipped `hurtAndBreak`.
2026-11-05 14:24:14 +00:00
MSedgeMC c135993a63 test: a steering stick that breaks leaves the rod the stick was
`transmuteCopyIgnoreEmpty`: at 18 + 7 of 25 the carrot on a stick becomes
a fishing rod that kept its custom name and starts whole, where the old
path wrote a fresh plain rod. Wear that Unbreaking took away (0) breaks
nothing, and a stack with no durability never breaks.
2026-11-05 14:24:12 +00:00
MSedgeMC c942e133da fix: a steering stick's wear goes through the item's durability rules
`FoodOnAStickItem.use` wears the stick with
`ItemStack.hurtAndConvertOnBreak`, which runs `hurtAndBreak` - and
`hurtAndBreak` asks `processDurabilityChange` first, so Unbreaking has its
say before any damage lands.

`use_steering_stick` added the whole 7 (carrot on a stick) or 1 (warped
fungus on a stick) straight onto `stack.damage`, so an Unbreaking stick
lasted exactly as long as a plain one, and it never reached the mods'
item-damaged hook.
2026-11-05 14:23:55 +00:00
CocofireHDandopencode 8df11ffe4c Mod fluid tanks: let a tank refuse a fluid
A mod tank can now refuse a fluid through native_block_entity_accepts_fluid (NeoForge's IFluidHandler.isFluidValid). The server asks it before filling a tank in both insert passes and before native_move_fluids counts a tank's room; every tank accepts every fluid unless a mod cancels. A water tank can take no create:chocolate, and a removed mod leaves no fluid id it cannot use behind.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 15:21:31 +01:00
MSedgeMC 3ef40bc92f port_map: BundleItem.getTooltipImage names the tooltip display rule
The row pointed at the geometry and carried the missing rule as a gap;
the grid now goes through `item_tooltip::bundle_tooltip`, which asks
`TooltipDisplay::shows("minecraft:bundle_contents")` before the layout.
2026-11-05 14:12:07 +00:00
MSedgeMC 875b657878 test: a bundle grid hidden by the tooltip display
`minecraft:bundle_contents` in `hidden_components`, and `hide_tooltip`,
both take the grid away; a display that hides `minecraft:lore` leaves it.
2026-11-05 14:11:58 +00:00
MSedgeMC 2ec1ce0480 fix: a bundle's contents grid follows the tooltip display
`BundleItem.getTooltipImage` returns nothing when the stack's
`minecraft:tooltip_display` hides `minecraft:bundle_contents`, so a
bundle given with `hide_tooltip` or with that component hidden drew no
grid. Horizon drew the grid for every bundle, and the tooltip box grew to
hold it.

`item_tooltip::bundle_tooltip` asks the display first and then defers to
`bundle::tooltip_layout`; the tooltip draw uses it for the height probe
and for the draw alike.
2026-11-05 14:11:58 +00:00
CocofireHDandopencode 4993bc83ac Shaders: upscale the scaled pack target to the full surface
A Resolution Scale leaves the pack's picture smaller than the window. Its final pass drew at the window's size, so a shader using gl_FragCoord as texel coordinates (Photon's final) only filled a quarter of the screen. Draw final at the picture's size into an offscreen texture and copy it once to the window with a screen-row fullscreen pass; the no-pack path and same-size output are unchanged.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 15:10:10 +01:00
Florian ERNSTandCodex eb0193cb34 Normalize Enderman carried states before placement
Run neighbor-shape updates on the authoritative level before the existing eligibility checks, and clear the verified carrier after the handled placement.

Co-Authored-By: Codex <[email protected]>
2026-11-05 15:06:31 +01:00
CocofireHDandopencode 2816f17962 Shulker boxes: close the block entity when its menu is removed
A guest's use raised its opener count twice: open_menu counted the block entity's startOpen, then lan_open_container counted it again once the menu was sent. Closing lowered it once, to one, so stopOpen never ran and the lid stayed open with its raised collision (#9500).

The guest open path now relies on the count open_menu already made; the harness keeps the lid open and closed checks for both host and guest.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 15:01:27 +01:00
MSedgeMC afad8241b6 port_map: the two BeehiveBlock tick methods
animateTick and trySpawnDripParticles are block_animate_tick and beehive_drip;
the animateTick row records the attempt bound the loop re-reads.
2026-11-05 14:00:10 +00:00
MSedgeMC 677e00c72f test: a floor, a fluid and a whole sample box for the animateTick zoo
The tick cases' stand-in world stopped at y 32..96 and x/z -32..31, one block
short of every sample the loop takes and of the neighbours those samples read,
so a block at the edge compared air against the oracle's unending world: that
is where the beehive, the redstone ore and the pointed dripstone differences
came from. Fill the whole box plus a block either way, let a case name the
floor below y = 64, hand the loop the state's own fluid (a waterlogged block
has a water source, which it ticks and drips) and bind the client jar's block
tags in the oracle the way a datapack load does, which a bare bootstrap leaves
empty. Then add the families that read a neighbour, a shape or their own fluid:
dried ghast over soul sand, soul soil, stone and air; hives and a nest embedded
and hanging; a lit redstone ore and a deepslate one embedded and over air; and
stalactite tips over stone and water. 2,097 cases and 456,747 output lines
become 2,187 and 462,718.
2026-11-05 14:00:10 +00:00
MSedgeMC 9d11ae8abb fix: a hive asks the level random for its attempt bound until the loop ends
BeehiveBlock.animateTick is a `for (i = 0; i < random.nextInt(1) + 1; i++)`
loop, so the bound is drawn again on the check that ends it: a hive always
spends two nextInt(1) draws around its single trySpawnDripParticles attempt.
Horizon hoisted the bound out of the loop and spent one, which left the level
random a draw ahead of vanilla from the first hive the ambient loop touched:
every particle after it moved, and the honey drip of a hive hanging over air
came out at the wrong column.
2026-11-05 14:00:10 +00:00
MSedgeMC 25cb339d57 port_map: WitherRoseBlock.animateTick is block_animate_tick
The row records where the smell test sits now: three smoke puffs about the
centre of the pick shape, moved by the flower's per-column model offset.
2026-11-05 14:00:10 +00:00
MSedgeMC 3555aa81d3 test: a wither rose world in the marker-particles parity suite
The corpus had no wither rose case at all. Add a world that is all wither rose,
run the whole ClientLevel.animateTick loop over it and compare every smoke puff
by exact position bits in call order: twenty-seven cases, three seeds and
twenty-four at random ones. The probe now supplies the flower's per-column model
offset the way the client's mesh tables do (a case without it would compare a
shape the game never shows). 2,070 cases and 402,440 output lines become 2,097
and 456,747.
2026-11-05 14:00:10 +00:00
MSedgeMC af128082f4 fix: centre the wither rose's smoke on its pick shape
WitherRoseBlock.animateTick reads its centre off getShape(state, level, pos,
CollisionContext.empty()), a flower's own box whose bounds centre is 0.5/0.5
before the block's per-column model offset. AnimateContext::shape_y answers with
the collision shape, which is empty for every flower, so the centre came out as
0 and the whole smoke column sat half a block towards the block's north-west
corner. Take the centre from the pick shape's boxes (BlockFacts::outline) and
keep the model offset on top of it.
2026-11-05 14:00:10 +00:00
DUzzL 61a74fdd0a Stop the walk animation of a player riding a boat 2026-11-05 14:48:21 +01:00
martipopsandClaude Opus 5.5 e58ee9c714 Controllers: icon buttons and two checkboxes take the focus, and a screen with no targets fails a test
An icon-only button recorded nothing, so the pad could not reach the pause
menu's Report Bugs, Feedback and Player Reporting icons, or Realms' icons. An
active button with an empty label is now a focus target. So are the
Multiplayer warning's "Do not show again" box and Optimize World's Erase
Cached Data box, which A now ticks.

tools/pad_menu_e2e.py gains every_screen_has_targets, which walks the title's
submenus and every options page and fails on a focus screen with no targets.
Replay Studio is allowlisted because it registers nothing yet. New pause_icon
and erase_checkbox cases press the new targets. The pause menu now has
unlabelled widgets, so splitscreen_e2e's setup scenario reads labels with get.
controller_settings_pad's check for a Navigation page now matches exactly: it
tripped on the General page's Navigation Repeat rows.

docs/controllers.md no longer says every rectangle registers itself or that
both modes reach every screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 08:46:36 -05:00
CocofireHDandopencode 6492b16a7b Send minecraft:vibration particles from the server to nearby clients
Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 14:46:21 +01:00
CocofireHDandopencode 2cb008e118 Shaders: read depth as an unfilterable float so GLSL translates
The world's depth was bound as a depth texture and read with textureLoad, which naga's GLSL backend cannot emit, so TAA, the depth blit and the shader depth test failed to build on OpenGL. Bind those views as unfilterable float textures instead: wgpu accepts a depth view there, and textureLoad returns the same depth texel on every backend (Vulkan, DX12, Metal, GL).

The shader translation test now fails on any shader the GL backend runs that cannot translate, and pins fs_taa and fs_blit; the HUD's glint, which fails for an unrelated two-samplers-per-image naga limit, is the one allowed exception.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 14:45:43 +01:00
oxi-does-dev 0c897e3e33 Interact: send the point the ray hit, as vanilla does
MultiPlayerGameMode.interact sends EntityHitResult.getLocation() minus
entity.position() in every ServerboundInteractPacket. Horizon sent a
fixed [0.0, 1.0, 0.0] for mobs, a point in their middle: armour stands,
saddles, leashes and anything else that reads the click carried
interactAt to the wrong place, unlike vanilla, and a server's reach or
ghost-hand checks saw clicks that vanilla never makes.

mob_hit and the spear's attack_range pick now return the point along
the view ray where the mob's box was met, measured from its feet, and
every caller destructures the third element. A regression test checks
the packet's point against the ray.
2026-11-05 16:40:43 +03:00
degepieandClaude Opus 5.5 770e4461f8 Mobs: naming one or dyeing a sheep fires player_interacted_with_entity
ServerGamePacketListenerImpl.handleInteract fires the trigger with the
held stack as it was before the interaction whenever an item interaction
succeeds. NameTagItem.interactLivingEntity and DyeItem.interactLivingEntity
both succeed, but interact_mob returned after name_mob and after the sheep
dye before reaching the shared trigger call.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 18:40:41 +05:00
degepieandClaude Opus 5.5 a85fb7c819 Advancement predicates read structure tags
A location predicate whose `structures` named a tag
(`#minecraft:shipwreck`, or a data pack's own `#pack:village`) never
matched: the predicates' tag lookup had no `worldgen/structure` case and
the world's pack tags didn't hold that registry. The world's tables now
load the structure tags with the packs' over the game's own, like the
entity type, biome and damage type tags, and a world without packs
reads the game's own structure tags (loaded once).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 18:26:17 +05:00
degepieandClaude Opus 5.5 ab4f48abfe Potions: drinking one awards the used statistic and fires consume_item
Consumable.onConsume awards the item's used statistic and fires the
consume_item trigger for a server player before the consumable's listeners
run and the stack shrinks. A potion and an ominous bottle finish through
finish_drinking, which did neither; food, honey bottles and milk go
through finish_consuming, which already does both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 18:26:14 +05:00
194 changed files with 12510 additions and 941 deletions

No files matched your search

+1
View File
@@ -35,6 +35,7 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- **No hardcoded user-facing strings.** Keep UI translation-ready from the start. In the launcher (`launcher/`), wrap visible text with `tr("...")` or `trf("... {arg} ...", &[("arg", &val)])` from `crate::i18n` instead of raw string literals or `format!`. In the game, use translation keys (`Text::tr`, `lang::get`), never hardcoded English strings.
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
issue is taken: pick another. `/unclaim` when you stop; a claim with no open PR linking it is released after 5 days.
- **Controllers reach what registers.** Draw controls with a registering helper (`ui::button`, `settings_shell::foot_button`); `graphics_style::button` and `screen_draw::button` only paint, and registering one part of a screen ends its crosshair, so register all of it.
## Crates
Generated
+1
View File
@@ -1413,6 +1413,7 @@ dependencies = [
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
@@ -314,6 +314,8 @@ pub const ACTIONS: &[(&str, Option<Button>)] = &[
("key.screenshot", None),
("key.toggleGui", None),
("key.smoothCamera", None),
// Horizon's emote wheel: no button is free by default; the stick's cursor picks.
("key.emote", None),
];
/// How a bindable action reaches the game: one of its own handlers, shared
@@ -23,6 +23,8 @@ pub const MAPPINGS: &[(&str, &str, &str)] = &[
("key.pickItem", "key.mouse.middle", "gameplay"),
("key.command", "key.keyboard.slash", "multiplayer"),
("key.friends", "key.keyboard.o", "multiplayer"),
// Horizon's emote wheel (docs/cosmetics.md), on a key vanilla leaves free.
("key.emote", "key.keyboard.b", "multiplayer"),
("key.socialInteractions", "key.keyboard.p", "multiplayer"),
("key.toggleGui", "key.keyboard.f1", "misc"),
(
+2
View File
@@ -18,6 +18,8 @@ horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
# Emotes pose the player model (`Mob::emote`, `mobs::anim::setup`).
horizon-cosmetics = { path = "../horizon-cosmetics" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
+33 -4
View File
@@ -12227,7 +12227,8 @@ mod tests {
}
}
/// Every WGSL shader file translates to HLSL as wgpu's DirectX 12 backend
/// writes it for FXC (shader model 5.1), entry point by entry point.
/// writes it for FXC (shader model 5.1), entry point by entry point, and
/// every shader the OpenGL backend runs also translates to GLSL.
#[test]
fn shaders_translate_to_hlsl_for_directx_12_and_glsl_for_opengl() {
fn wgsl_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
@@ -12277,6 +12278,14 @@ mod tests {
}
}
let mut glsl_checked = 0;
// Shaders the OpenGL backend runs must translate: skipping the ones
// that fail hides exactly the bug where a shader GL needs cannot
// compile (#9448).
let mut glsl_failed = Vec::new();
let mut translated = std::collections::HashSet::new();
// naga's GLSL writer gives an image one sampler, and the HUD's glint
// samples one image through two; one failed entry does not hide the rest.
let known_unsupported = [("hud.wgsl", "fs_cit_glint")];
for (path, text) in sources {
// entity.wgsl comes after the overlay's constants (entity_shader).
let module = naga::front::wgsl::parse_str(&text)
@@ -12377,11 +12386,18 @@ mod tests {
Default::default(),
)
.and_then(|mut w| w.write());
// Some shaders use features GLSL cannot express (depth textureLoad): GL does
// not run them. The ones it does run must translate, checked below.
if glsl_result.is_err() {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if let Err(e) = glsl_result {
if !known_unsupported.contains(&(name, entry.name.as_str())) {
glsl_failed.push(format!(
"{} {} (GLSL {version}): {e}",
path.display(),
entry.name
));
}
continue;
}
translated.insert((name.to_owned(), entry.name.clone()));
glsl_checked += 1;
assert!(
!glsl.contains("ARB_arrays_of_arrays"),
@@ -12392,6 +12408,19 @@ mod tests {
}
}
}
assert!(
glsl_failed.is_empty(),
"shaders the OpenGL backend runs must translate:\n{}",
glsl_failed.join("\n")
);
// These read the world's depth through `textureLoad`; before #9448 their
// depth binding could not translate, so TAA and the blit never came up.
for (file, entry) in [("world_post.wgsl", "fs_taa"), ("pack_post.wgsl", "fs_blit")] {
assert!(
translated.contains(&(file.to_owned(), entry.to_owned())),
"{file} {entry} must translate to GLSL"
);
}
assert!(glsl_checked >= 10, "{glsl_checked} GLSL translations");
}
#[test]
+1 -1
View File
@@ -287,7 +287,7 @@ impl Cull {
0,
fragment,
wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
@@ -114,8 +114,10 @@ fn see(t: u32) {
}
}
// Phase 1's depth, where the test runs in the shader (gfx/cull.rs).
@group(1) @binding(0) var depth: texture_depth_2d;
// Phase 1's depth, where the test runs in the shader (gfx/cull.rs). A depth
// view bound as an unfilterable float: naga's GLSL backend cannot `textureLoad`
// a depth texture, and the texel is the same value.
@group(1) @binding(0) var depth: texture_2d<f32>;
// The world pass's reversed depth test, greater or equal passing, made here:
// where it cannot be forced before a shader that writes storage, a fragment
@@ -123,7 +125,7 @@ fn see(t: u32) {
// runs only for the fragments that pass it.
@fragment
fn fs_box(in: Box) {
if in.position.z >= textureLoad(depth, vec2<u32>(in.position.xy), 0) {
if in.position.z >= textureLoad(depth, vec2<u32>(in.position.xy), 0).r {
see(in.test);
}
}
@@ -52,7 +52,9 @@ struct Params {
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var linear_clamp: sampler;
@group(0) @binding(2) var input_depth: texture_depth_2d;
// A depth view bound as unfilterable float: naga's GLSL backend cannot
// `textureLoad` a depth texture, and the texel is the same value.
@group(0) @binding(2) var input_depth: texture_2d<f32>;
@group(0) @binding(3) var current_color: texture_2d<f32>;
@group(0) @binding(4) var previous_color: texture_2d<f32>;
@group(0) @binding(5) var dilated_depth_out: texture_storage_2d<r32float, write>;
@@ -333,7 +335,7 @@ fn load_input_depth(p: vec2<i32>) -> f32 {
if !is_on_screen(p, params.render_size) {
return 0.0;
}
return textureLoad(input_depth, p, 0);
return textureLoad(input_depth, p, 0).r;
}
@compute @workgroup_size(8, 8)
@@ -12,7 +12,9 @@ struct Params {
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var depth: texture_depth_2d;
// A depth view bound as unfilterable float: naga's GLSL backend cannot
// `textureLoad` a depth texture, and the texel is the same value.
@group(0) @binding(1) var depth: texture_2d<f32>;
@group(0) @binding(2) var atlas: texture_2d<f32>;
@group(0) @binding(3) var atlas_sampler: sampler;
@@ -38,7 +40,7 @@ struct Out {
// xy: the motion vector in UV (to where the pixel was last frame, FSR's convention), z: 1.
@fragment fn fs_motion(in: Out) -> @location(0) vec4<f32> {
let stored = textureLoad(depth, vec2<i32>(in.clip.xy), 0);
let stored = textureLoad(depth, vec2<i32>(in.clip.xy), 0).r;
let tolerance = stored * 1e-3 + 1e-7;
// Not what the world shows here: a transparent texel, or behind something.
if in.translucent != 0u {
@@ -16,14 +16,16 @@ const MAPPED: u8 = 3;
const ROW: u32 = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
const SHADER: &str = "
@group(0) @binding(0) var depth: texture_depth_2d;
// A depth view bound as unfilterable float: naga's GLSL backend cannot
// `textureLoad` a depth texture, and the texel is the same value.
@group(0) @binding(0) var depth: texture_2d<f32>;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4<f32> {
let centre = vec2<i32>(textureDimensions(depth) / 2u);
return vec4<f32>(textureLoad(depth, centre, 0), 0.0, 0.0, 1.0);
return vec4<f32>(textureLoad(depth, centre, 0).r, 0.0, 0.0, 1.0);
}
";
@@ -64,7 +66,7 @@ impl CenterDepth {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
@@ -433,6 +433,14 @@ struct Mips {
levels: Vec<(wgpu::TextureView, wgpu::BindGroup)>,
}
/// The picture a pack's `final` draws at the world's size while the window is another: it is
/// copied onto the window once, after the pack's passes, as the game's own post pass does
/// without a pack.
struct FinalTarget {
size: [u32; 2],
view: wgpu::TextureView,
}
struct Targets {
size: [u32; 2],
/// A and B of each buffer used.
@@ -664,6 +672,12 @@ pub struct PackPost {
blit_group: Option<([wgpu::TextureView; 2], wgpu::BindGroup)>,
/// Shows colortex0 when the pack has no `final` (OQ-5).
copy: (wgpu::RenderPipeline, wgpu::BindGroupLayout),
/// Copies the picture `final` drew at the world's size onto the window: no flip, it is
/// already in the screen's rows.
upscale: (wgpu::RenderPipeline, wgpu::BindGroupLayout),
/// The picture `final` draws while the world's size is not the window's.
final_target: Option<FinalTarget>,
screen: wgpu::TextureFormat,
uniforms: FrameUniforms,
}
@@ -2724,7 +2738,7 @@ impl PackPost {
},
&[
texture_entry(0, float),
texture_entry(1, wgpu::TextureSampleType::Depth),
texture_entry(1, wgpu::TextureSampleType::Float { filterable: false }),
],
&blit_targets,
// GL's window depth, written for depthtex0 and the terrain's depth test.
@@ -2751,6 +2765,20 @@ impl PackPost {
&[target(screen)],
None,
);
let upscale = helper(
device,
"vs_screen",
"fs_copy",
&[
texture_entry(0, float),
entry(
2,
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
),
],
&[target(screen)],
None,
);
let filterable = |b: usize| {
formats[b].sample_type(None, Some(device.features()))
== Some(wgpu::TextureSampleType::Float { filterable: true })
@@ -2952,6 +2980,9 @@ impl PackPost {
}),
blit,
copy,
upscale,
final_target: None,
screen,
uniforms: {
let mut u = FrameUniforms::default();
u.atlas = world.atlas.map_or([0; 2], |a| a.1);
@@ -4484,6 +4515,35 @@ impl PackPost {
terrain: &mut TerrainDraw<'_>,
) {
self.resize(device, size);
// A resolution scale leaves the picture smaller (or larger) than the window: the pack's
// `final` draws it at the picture's size, and one copy takes it to the window (#9291).
let final_view = if size != window && self.passes.iter().any(|p| p.group == Group::Final) {
if self.final_target.as_ref().is_none_or(|t| t.size != size) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack final"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.screen,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
self.final_target = Some(FinalTarget {
size,
view: texture.create_view(&Default::default()),
});
}
self.final_target.as_ref().map(|t| t.view.clone())
} else {
self.final_target = None;
None
};
if let Some((layout, ids, rects)) = &self.block_ids {
// A page that grew has a new buffer.
self.page_groups
@@ -4788,8 +4848,8 @@ impl PackPost {
self.mips_valid |= stale;
let final_pass = pass.group == Group::Final;
// A pass writing buffers of a size of their own draws at that size (they share it).
// `final` draws the picture at the world's size; a copy takes it to the window.
let out = match pass.writes.first() {
_ if final_pass => window,
Some(&(_, b)) => self.targets.as_ref().map_or(size, |t| t.sizes[b]),
None => size,
};
@@ -4812,7 +4872,7 @@ impl PackPost {
overwrite
};
if final_pass {
attachments.push(attach(screen, overwrite));
attachments.push(attach(final_view.as_ref().unwrap_or(screen), overwrite));
shown = true;
} else if pass.sink {
attachments.push(attach(&targets.sink, ops));
@@ -4895,6 +4955,31 @@ impl PackPost {
rp.set_pipeline(&self.copy.0);
rp.set_bind_group(0, &group, &[]);
rp.draw(0..3, 0..1);
} else if let Some(picture) = &final_view {
// The picture at the world's size onto the window: one upscale, smoothly.
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack final"),
layout: &self.upscale.1,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(picture),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
],
});
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack final"),
color_attachments: &[attach(screen, overwrite)],
timestamp_writes: self.timing.as_mut().and_then(|t| t.writes("final copy")),
..Default::default()
});
rp.set_pipeline(&self.upscale.0);
rp.set_bind_group(0, &group, &[]);
rp.draw(0..3, 0..1);
}
}
}
@@ -9,7 +9,9 @@ fn vs_full(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
}
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var world_depth: texture_depth_2d;
// A depth view read as an unfilterable float: naga's GLSL backend cannot
// `textureLoad` a depth texture, and the texel is the same either way.
@group(0) @binding(1) var world_depth: texture_2d<f32>;
struct World {
@location(0) color: vec4<f32>,
@@ -24,7 +26,7 @@ fn fs_blit(@builtin(position) p: vec4<f32>) -> World {
let at = vec2<i32>(i32(p.x), size.y - 1 - i32(p.y));
var out: World;
out.color = textureLoad(source, at, 0);
out.depth = 1.0 - textureLoad(world_depth, at, 0);
out.depth = 1.0 - textureLoad(world_depth, at, 0).r;
return out;
}
@@ -49,6 +51,13 @@ fn vs_uv(@builtin(vertex_index) i: u32) -> Full {
return Full(vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0), vec2<f32>(uv.x, 1.0 - uv.y));
}
// The screen's own rows: a picture a shader pack's `final` drew (not a GL-rows buffer).
@vertex
fn vs_screen(@builtin(vertex_index) i: u32) -> Full {
let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return Full(vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0), uv);
}
@fragment
fn fs_copy(v: Full) -> @location(0) vec4<f32> {
return textureSample(source, linear, v.uv);
@@ -43,6 +43,64 @@ fn a_pack_without_final_reads_the_replaced_world_colour() {
replacement(Change::Colour, false);
}
/// A Resolution Scale leaves the pack's picture smaller than the window. Its `final` draws at
/// the picture's size and one copy upsizes it to the window (#9291), so a final that fetches
/// its pixel by `gl_FragCoord` (as Photon's does) covers every pixel, not a quarter of them.
#[test]
fn a_scaled_picture_fills_the_whole_window() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
})) else {
return;
};
eprintln!("scaled picture adapter: {:?}", adapter.get_info());
let picture = [4, 2];
let window = [8, 4];
let colour = texture(&device, picture, T::Rgba8Unorm);
let depth = texture(&device, picture, T::Depth32Float);
clear(&device, &queue, &colour, &depth, [32, 64, 16, 255], 0.25);
let mut post = pack_with(
&device,
&queue,
&adapter,
&colour,
&[
(
"final.vsh",
"#version 120\nvoid main(){gl_Position=ftransform();}",
),
(
"final.fsh",
"#version 330 compatibility\nuniform sampler2D colortex0;\nvoid main(){gl_FragColor=texelFetch(colortex0,ivec2(gl_FragCoord.xy),0);}",
),
],
);
let hud = HudBatch::new(window);
let pixels = render(
&device,
&queue,
&mut post,
&colour,
&depth,
&frame(&hud),
picture,
window,
);
// Independently specified: the world's colour at every window pixel.
let wrong = pixels.iter().filter(|p| **p != [32, 64, 16, 255]).count();
assert_eq!(
wrong,
0,
"{wrong} of {} pixels are not the picture",
pixels.len()
);
}
fn replacement(change: Change, final_shader: bool) {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
@@ -62,7 +120,9 @@ fn replacement(change: Change, final_shader: bool) {
let mut post = pack(&device, &queue, &adapter, &colour, final_shader);
let hud = HudBatch::new(size);
let frame = frame(&hud);
let initial = render(&device, &queue, &mut post, &colour, &depth, &frame, size);
let initial = render(
&device, &queue, &mut post, &colour, &depth, &frame, size, size,
);
let expected = if final_shader {
[32, 64, 191, 255]
} else {
@@ -93,9 +153,13 @@ fn replacement(change: Change, final_shader: bool) {
0.75
};
clear(&device, &queue, &colour, &depth, pixel, reverse_depth);
let actual = render(&device, &queue, &mut post, &colour, &depth, &frame, size);
let actual = render(
&device, &queue, &mut post, &colour, &depth, &frame, size, size,
);
let mut fresh = pack(&device, &queue, &adapter, &colour, final_shader);
let reference = render(&device, &queue, &mut fresh, &colour, &depth, &frame, size);
let reference = render(
&device, &queue, &mut fresh, &colour, &depth, &frame, size, size,
);
// Independently specified RG channels and GL depth (1 - reverse-Z), in UNORM8.
let expected = if !final_shader {
[160, 96, 48, 255]
@@ -178,13 +242,8 @@ fn pack(
lightmap: &wgpu::TextureView,
final_shader: bool,
) -> PackPost {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
zip.start_file("shaders/shaders.properties", options)
.unwrap();
zip.write_all(b"oldLighting=false\n").unwrap();
if final_shader {
for (name, source) in [
let programs: &[(&str, &str)] = if final_shader {
&[
(
"final.vsh",
"#version 120\nvarying vec2 uv;\nvoid main(){gl_Position=ftransform();uv=gl_MultiTexCoord0.xy;}",
@@ -193,10 +252,29 @@ fn pack(
"final.fsh",
"#version 120\nvarying vec2 uv;uniform sampler2D colortex0;uniform sampler2D depthtex0;\nvoid main(){gl_FragColor=vec4(texture2D(colortex0,uv).rg,texture2D(depthtex0,uv).r,1.0);}",
),
] {
zip.start_file(format!("shaders/{name}"), options).unwrap();
zip.write_all(source.as_bytes()).unwrap();
}
]
} else {
&[]
};
pack_with(device, queue, adapter, lightmap, programs)
}
/// A pack of `programs` (`name`, GLSL source) over `lightmap`.
fn pack_with(
device: &wgpu::Device,
queue: &wgpu::Queue,
adapter: &wgpu::Adapter,
lightmap: &wgpu::TextureView,
programs: &[(&str, &str)],
) -> PackPost {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
zip.start_file("shaders/shaders.properties", options)
.unwrap();
zip.write_all(b"oldLighting=false\n").unwrap();
for (name, source) in programs {
zip.start_file(format!("shaders/{name}"), options).unwrap();
zip.write_all(source.as_bytes()).unwrap();
}
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-world-inputs",
@@ -241,12 +319,13 @@ fn render(
depth: &wgpu::TextureView,
frame: &Frame,
size: [u32; 2],
window: [u32; 2],
) -> Vec<[u8; 4]> {
let screen = texture(device, size, T::Rgba8Unorm);
let screen = texture(device, window, T::Rgba8Unorm);
let stride = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(stride * size[1]),
size: u64::from(stride * window[1]),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
@@ -261,7 +340,7 @@ fn render(
frame,
&Default::default(),
size,
size,
window,
&[],
&mut |_, _, _, _| {},
);
@@ -285,7 +364,7 @@ fn render(
.unwrap()
.chunks_exact(stride as usize)
.flat_map(|row| {
row[..size[0] as usize * 4]
row[..window[0] as usize * 4]
.as_chunks::<4>()
.0
.iter()
@@ -43,9 +43,7 @@ struct TaaUniform {
jitter: [f32; 4],
}
/// TAA's GPU resources, absent when the device cannot compile the shader
/// (the OpenGL backend's naga GLSL emitter does not support `textureLoad`
/// from depth textures).
/// TAA's GPU resources, absent when the device cannot compile the shader.
struct TaaResources {
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
@@ -146,9 +144,6 @@ impl Post {
cache: None,
})
};
// The OpenGL backend's GLSL translator does not support textureLoad
// from depth textures: on devices running through OpenGL (such as older
// Intel GPUs on Linux), TAA cannot compile its pipeline.
let taa_resources = {
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon TAA bindings"),
@@ -161,7 +156,9 @@ impl Post {
count: None,
},
texture(2, float),
texture(3, wgpu::TextureSampleType::Depth),
// A depth view through an unfilterable float binding: the
// texel is the same, and the GLSL backend can read it.
texture(3, wgpu::TextureSampleType::Float { filterable: false }),
wgpu::BindGroupLayoutEntry {
binding: 4,
visibility: wgpu::ShaderStages::FRAGMENT,
+13 -9
View File
@@ -17,6 +17,7 @@ use horizon_core::{
geometry::{Aabb, Axis, Vec3, trig},
random::{LegacyRandom, RandomSource},
};
use horizon_protocol::entity_data::PositionSource;
use horizon_world::block_collision::BlockCollisionRegistry;
use std::{
array,
@@ -50,9 +51,10 @@ pub struct ParticleOptions {
/// `GeyserParticleOptions.waterBlocks` and `GeyserBaseParticleOptions`'
/// `waterBlocks` and `burstImpulseBase` (0 where the type has none).
pub geyser: Option<(i32, f32)>,
/// `VibrationParticleOption`: the listener the signal flies to and its
/// arrival time in ticks (`ParticleEngine::vibration` is the same particle).
pub vibration: Option<([f64; 3], i32)>,
/// `VibrationParticleOption`: the listener's `PositionSource`, the point
/// the signal flies to and its arrival time in ticks
/// (`ParticleEngine::vibration` is the same particle).
pub vibration: Option<(PositionSource, [f64; 3], i32)>,
/// A native mod's own particle type, by its namespaced name (`mymod:spark`):
/// `SimpleParticleType` plus the provider `particles/<name>.json` names.
pub mod_particle: Option<String>,
@@ -94,10 +96,11 @@ impl ParticleOptions {
..Self::simple(ty)
}
}
/// `VibrationParticleOption(destination, arrivalInTicks)`.
pub fn vibration(dest: [f64; 3], ticks: i32) -> Self {
/// `VibrationParticleOption(destination, arrivalInTicks)`: the listener's
/// position source and the point this engine flies the signal to.
pub fn vibration(source: PositionSource, dest: [f64; 3], ticks: i32) -> Self {
Self {
vibration: Some((dest, ticks)),
vibration: Some((source, dest, ticks)),
..Self::simple(ParticleType::Vibration)
}
}
@@ -2988,8 +2991,8 @@ impl ParticleEngine {
// the listener the signal flies to and its arrival time; the sprite
// is the set's own draw from the engine's random.
T::Vibration => {
let (dest, ticks) = o.vibration?;
let mut q = Self::vibration_particle(p, dest, ticks);
let (_, dest, ticks) = o.vibration.as_ref()?;
let mut q = Self::vibration_particle(p, *dest, *ticks);
q.set = set;
q.sprite = self.sprite_from_set(ty);
q
@@ -5644,7 +5647,8 @@ mod tests {
#[test]
fn a_vibration_option_makes_the_signal_its_provider_draws() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
let options = ParticleOptions::vibration([10.5, 70.5, -3.5], 20);
let options =
ParticleOptions::vibration(PositionSource::Block([10, 70, -4]), [10.5, 70.5, -3.5], 20);
// Within the camera's 32 blocks, so only the provider decides.
engine.add_with(&options, [0.5, 1.5, 0.5], [0.0; 3], false, false);
assert_eq!(engine.made().get("vibration"), Some(&1));
+191 -1
View File
@@ -575,8 +575,53 @@ pub struct Hud {
/// Hits in a row the server confirmed, until this player is hurt or 3 seconds pass.
pub combo: bool,
pub combo_corner: Corner,
/// Frames drawn in the last second.
pub fps: bool,
pub fps_corner: Corner,
/// Left and right clicks in the last second.
pub cps: bool,
pub cps_corner: Corner,
/// The movement keys, mouse buttons and jump, lit while held, with clicks per second.
pub keys: bool,
pub keys_corner: Corner,
/// The computer's local time.
pub clock: bool,
pub clock_corner: Corner,
/// How far the last hit landed from the eyes, for 3 seconds; it only measures.
pub reach: bool,
pub reach_corner: Corner,
/// Horizontal speed in blocks per second.
pub speed: bool,
pub speed_corner: Corner,
/// A dark box behind each widget.
pub background: bool,
/// The boxes' opacity, in percent.
pub opacity: u8,
/// The widgets' text colour (durability keeps its own).
pub text: Colour,
/// A shadow under the widgets' text, as vanilla's HUD text has.
pub shadow: bool,
}
impl Hud {
/// Whether any widget is on: with none, the HUD draws nothing and reads nothing.
pub fn any(&self) -> bool {
[
self.armour,
self.effects,
self.held,
self.ping,
self.coords,
self.server,
self.combo,
self.fps,
self.cps,
self.keys,
self.clock,
self.reach,
self.speed,
]
.contains(&true)
}
}
impl Default for Hud {
fn default() -> Self {
@@ -596,11 +641,35 @@ impl Default for Hud {
server_corner: Corner::TopLeft,
combo: false,
combo_corner: Corner::TopLeft,
fps: false,
fps_corner: Corner::TopLeft,
cps: false,
cps_corner: Corner::TopLeft,
keys: false,
keys_corner: Corner::BottomLeft,
clock: false,
clock_corner: Corner::TopRight,
reach: false,
reach_corner: Corner::TopLeft,
speed: false,
speed_corner: Corner::TopLeft,
background: true,
opacity: HUD_OPACITY,
text: Colour::White,
shadow: true,
}
}
}
pub use crate::extras::Corner;
/// The widgets' boxes at first: the extras' 0x78 alpha, 47%.
pub const HUD_OPACITY: u8 = 47;
pub const HUD_OPACITIES: (u8, u8) = (0, 100);
impl Hud {
/// The boxes' colour, ARGB.
pub fn background_argb(&self) -> u32 {
alpha(self.opacity) << 24
}
}
/// The value of the view options while they follow the world.
fn world_label() -> String {
crate::lang::get_or("horizon.pvp.view.world", "World's")
@@ -690,6 +759,8 @@ pub struct Pvp {
pub hud: Hud,
pub tweaks: Tweaks,
pub chat: ChatOptions,
/// Where each HUD widget sits and its size.
pub layout: crate::pvp_hud::Layout,
}
fn flag(value: &str, d: bool) -> bool {
match value {
@@ -743,6 +814,14 @@ impl Pvp {
"hudHeld" => u.held = flag(value, u.held),
"hudHeldCorner" => u.held_corner = Corner::parse(value, u.held_corner),
"hudBackground" => u.background = flag(value, u.background),
"hudOpacity" => u.opacity = num(value, HUD_OPACITIES, u.opacity),
"hudTextColour" => {
u.text = Colour::parse(value)
.filter(|c| *c != Colour::Inverted)
.unwrap_or(u.text)
}
"hudShadow" => u.shadow = flag(value, u.shadow),
"hudPlace" => self.layout.parse(value),
"hudPing" => u.ping = flag(value, u.ping),
"hudPingCorner" => u.ping_corner = Corner::parse(value, u.ping_corner),
"hudCoords" => u.coords = flag(value, u.coords),
@@ -751,6 +830,18 @@ impl Pvp {
"hudServerCorner" => u.server_corner = Corner::parse(value, u.server_corner),
"hudCombo" => u.combo = flag(value, u.combo),
"hudComboCorner" => u.combo_corner = Corner::parse(value, u.combo_corner),
"hudFps" => u.fps = flag(value, u.fps),
"hudFpsCorner" => u.fps_corner = Corner::parse(value, u.fps_corner),
"hudCps" => u.cps = flag(value, u.cps),
"hudCpsCorner" => u.cps_corner = Corner::parse(value, u.cps_corner),
"hudKeys" => u.keys = flag(value, u.keys),
"hudKeysCorner" => u.keys_corner = Corner::parse(value, u.keys_corner),
"hudClock" => u.clock = flag(value, u.clock),
"hudClockCorner" => u.clock_corner = Corner::parse(value, u.clock_corner),
"hudReach" => u.reach = flag(value, u.reach),
"hudReachCorner" => u.reach_corner = Corner::parse(value, u.reach_corner),
"hudSpeed" => u.speed = flag(value, u.speed),
"hudSpeedCorner" => u.speed_corner = Corner::parse(value, u.speed_corner),
"numericEnchants" => t.numeric_enchants = flag(value, t.numeric_enchants),
"lowFire" => t.fire_lower = num(value, FIRE_LOWER, t.fire_lower),
"shieldSize" => t.shield_size = num(value, SHIELD_SIZES, t.shield_size),
@@ -838,6 +929,34 @@ impl Pvp {
u.combo,
u.combo_corner.name(),
);
let _ = write!(
out,
"hudFps:{}\nhudFpsCorner:{}\nhudCps:{}\nhudCpsCorner:{}\nhudKeys:{}\nhudKeysCorner:{}\n",
u.fps,
u.fps_corner.name(),
u.cps,
u.cps_corner.name(),
u.keys,
u.keys_corner.name(),
);
let _ = write!(
out,
"hudClock:{}\nhudClockCorner:{}\nhudReach:{}\nhudReachCorner:{}\nhudSpeed:{}\nhudSpeedCorner:{}\n",
u.clock,
u.clock_corner.name(),
u.reach,
u.reach_corner.name(),
u.speed,
u.speed_corner.name(),
);
let _ = write!(
out,
"hudOpacity:{}\nhudTextColour:{}\nhudShadow:{}\n",
u.opacity,
u.text.name(),
u.shadow,
);
self.layout.serialize(out);
let t = &self.tweaks;
let _ = write!(
out,
@@ -955,7 +1074,25 @@ opts! {
HudCombo, "hud_combo", "Combo counter",
"Hits in a row the server confirmed, until you are hurt or 3 seconds pass without one.";
HudComboCorner, "hud_combo_corner", "Combo corner", "Where the combo shows.";
HudFps, "hud_fps", "FPS", "Frames drawn in the last second.";
HudFpsCorner, "hud_fps_corner", "FPS corner", "Where the FPS shows.";
HudCps, "hud_cps", "Clicks per second", "Your left and right clicks in the last second.";
HudCpsCorner, "hud_cps_corner", "CPS corner", "Where the clicks per second show.";
HudKeys, "hud_keys", "Keystrokes",
"Your movement keys, mouse buttons and jump, lit while you hold them, with clicks per second.";
HudKeysCorner, "hud_keys_corner", "Keystrokes corner", "Where the keystrokes show.";
HudClock, "hud_clock", "Clock", "Your computer's time.";
HudClockCorner, "hud_clock_corner", "Clock corner", "Where the clock shows.";
HudReach, "hud_reach", "Reach",
"How far from your eyes your last hit landed, for 3 seconds. It only measures: your reach stays vanilla's.";
HudReachCorner, "hud_reach_corner", "Reach corner", "Where the reach shows.";
HudSpeed, "hud_speed", "Speed", "How fast you move across the ground, in blocks per second.";
HudSpeedCorner, "hud_speed_corner", "Speed corner", "Where the speed shows.";
HudBackground, "hud_background", "Widget backgrounds", "A dark box behind each widget.";
HudOpacity, "hud_opacity", "Background opacity", "How dark the boxes behind the widgets are.";
HudTextColour, "hud_text_colour", "Text colour",
"The widgets' text colour. Durability keeps its green to red.";
HudShadow, "hud_shadow", "Text shadow", "A shadow under the widgets' text, as vanilla's HUD text has.";
NumericEnchants, "numeric_enchants", "Enchantment levels as numbers",
"Tooltips and the enchanting table show Sharpness 5 instead of Sharpness V.";
FireLower, "fire_lower", "Lower fire",
@@ -1030,7 +1167,22 @@ pub fn rows() -> Vec<Result<Opt, &'static str>> {
HudServerCorner,
HudCombo,
HudComboCorner,
HudFps,
HudFpsCorner,
HudCps,
HudCpsCorner,
HudKeys,
HudKeysCorner,
HudClock,
HudClockCorner,
HudReach,
HudReachCorner,
HudSpeed,
HudSpeedCorner,
HudBackground,
HudOpacity,
HudTextColour,
HudShadow,
]
.map(Ok),
);
@@ -1054,7 +1206,15 @@ impl Opt {
OutlineColour | OutlineOpacity | OutlineThickness | OutlineFill => Some(Outline),
HitColour | HitOpacity => Some(Hit),
HudArmour | HudEffects | HudHeld | HudPing | HudCoords | HudServer | HudCombo
| HudBackground | NumericEnchants => None,
| HudFps | HudCps | HudKeys | HudClock | HudReach | HudSpeed | HudBackground
| HudTextColour | HudShadow | NumericEnchants => None,
HudOpacity => Some(HudBackground),
HudFpsCorner => Some(HudFps),
HudCpsCorner => Some(HudCps),
HudKeysCorner => Some(HudKeys),
HudClockCorner => Some(HudClock),
HudReachCorner => Some(HudReach),
HudSpeedCorner => Some(HudSpeed),
FireLower | ShieldSize | TotemSize | GlintColour | ClearWater | ClearLava => None,
ViewTime | ViewWeather => None,
ChatTimestamps | ChatCompact | ChatCopy | ChatSearch => None,
@@ -1081,10 +1241,17 @@ impl Opt {
Opt::HudEffects => p.hud.effects,
Opt::HudHeld => p.hud.held,
Opt::HudBackground => p.hud.background,
Opt::HudShadow => p.hud.shadow,
Opt::HudPing => p.hud.ping,
Opt::HudCoords => p.hud.coords,
Opt::HudServer => p.hud.server,
Opt::HudCombo => p.hud.combo,
Opt::HudFps => p.hud.fps,
Opt::HudCps => p.hud.cps,
Opt::HudKeys => p.hud.keys,
Opt::HudClock => p.hud.clock,
Opt::HudReach => p.hud.reach,
Opt::HudSpeed => p.hud.speed,
Opt::NumericEnchants => p.tweaks.numeric_enchants,
Opt::ClearWater => p.tweaks.clear_water,
Opt::ClearLava => p.tweaks.clear_lava,
@@ -1124,6 +1291,14 @@ impl Opt {
Opt::HudCoordsCorner => corner_label(p.hud.coords_corner),
Opt::HudServerCorner => corner_label(p.hud.server_corner),
Opt::HudComboCorner => corner_label(p.hud.combo_corner),
Opt::HudFpsCorner => corner_label(p.hud.fps_corner),
Opt::HudCpsCorner => corner_label(p.hud.cps_corner),
Opt::HudKeysCorner => corner_label(p.hud.keys_corner),
Opt::HudClockCorner => corner_label(p.hud.clock_corner),
Opt::HudReachCorner => corner_label(p.hud.reach_corner),
Opt::HudSpeedCorner => corner_label(p.hud.speed_corner),
Opt::HudOpacity => format!("{}%", p.hud.opacity),
Opt::HudTextColour => p.hud.text.label(),
Opt::FireLower => match p.tweaks.fire_lower {
0 => crate::lang::get_or("Off", "Off"),
v => format!("{v}%"),
@@ -1191,6 +1366,9 @@ impl Opt {
Opt::HudHeld => u.held = !u.held,
Opt::HudHeldCorner => u.held_corner = corner(u.held_corner),
Opt::HudBackground => u.background = !u.background,
Opt::HudOpacity => u.opacity = step_by(u.opacity, HUD_OPACITIES, 10, back),
Opt::HudTextColour => u.text = u.text.step_solid(back),
Opt::HudShadow => u.shadow = !u.shadow,
Opt::HudPing => u.ping = !u.ping,
Opt::HudPingCorner => u.ping_corner = corner(u.ping_corner),
Opt::HudCoords => u.coords = !u.coords,
@@ -1199,6 +1377,18 @@ impl Opt {
Opt::HudServerCorner => u.server_corner = corner(u.server_corner),
Opt::HudCombo => u.combo = !u.combo,
Opt::HudComboCorner => u.combo_corner = corner(u.combo_corner),
Opt::HudFps => u.fps = !u.fps,
Opt::HudFpsCorner => u.fps_corner = corner(u.fps_corner),
Opt::HudCps => u.cps = !u.cps,
Opt::HudCpsCorner => u.cps_corner = corner(u.cps_corner),
Opt::HudKeys => u.keys = !u.keys,
Opt::HudKeysCorner => u.keys_corner = corner(u.keys_corner),
Opt::HudClock => u.clock = !u.clock,
Opt::HudClockCorner => u.clock_corner = corner(u.clock_corner),
Opt::HudReach => u.reach = !u.reach,
Opt::HudReachCorner => u.reach_corner = corner(u.reach_corner),
Opt::HudSpeed => u.speed = !u.speed,
Opt::HudSpeedCorner => u.speed_corner = corner(u.speed_corner),
Opt::NumericEnchants => p.tweaks.numeric_enchants = !p.tweaks.numeric_enchants,
Opt::FireLower => t.fire_lower = step_by(t.fire_lower, FIRE_LOWER, 10, back),
Opt::ShieldSize => t.shield_size = step_by(t.shield_size, SHIELD_SIZES, 10, back),
+451
View File
@@ -214,6 +214,320 @@ impl Combo {
}
}
/// Clicks per second: the presses of the last second, kept for the widgets that are on.
#[derive(Default)]
pub struct Clicks {
/// Press times in seconds, oldest first; at most [`Clicks::KEEP`].
times: std::collections::VecDeque<f64>,
}
impl Clicks {
/// More than any hand clicks in a second: the ring never grows past it.
const KEEP: usize = 64;
pub fn press(&mut self, now: f64) {
if self.times.len() == Self::KEEP {
self.times.pop_front();
}
self.times.push_back(now);
}
/// The presses in the second up to `now`.
pub fn per_second(&mut self, now: f64) -> u32 {
while self.times.front().is_some_and(|&t| now - t >= 1.0) {
self.times.pop_front();
}
self.times.len() as u32
}
}
/// Horizontal speed in blocks per second, from the player's own position sampled at most
/// every [`Speed::SAMPLE`] seconds (nothing is asked of the server).
#[derive(Default)]
pub struct Speed {
last: Option<([f64; 2], f64)>,
/// Tenths of a block per second.
tenths: u32,
}
impl Speed {
pub const SAMPLE: f64 = 0.25;
pub fn sample(&mut self, x: f64, z: f64, now: f64) -> u32 {
match self.last {
Some((_, at)) if now - at < Self::SAMPLE => {}
Some(([lx, lz], at)) => {
let d = ((x - lx).powi(2) + (z - lz).powi(2)).sqrt();
// A teleport or respawn isn't speed.
let v = d / (now - at);
self.tenths = if v > 200.0 {
0
} else {
(v * 10.0).round() as u32
};
self.last = Some(([x, z], now));
}
None => self.last = Some(([x, z], now)),
}
self.tenths
}
}
/// The reach display: how far the last hit this player made was, measured from the eyes to
/// the point the crosshair's ray met the target's box. It measures what the client already
/// computed for the attack and changes nothing about it.
#[derive(Default)]
pub struct Reach {
last: Option<(f64, f64)>,
}
impl Reach {
/// How long the last hit's distance stays shown.
pub const SHOWN: f64 = 3.0;
pub fn hit(&mut self, distance: f64, now: f64) {
self.last = Some((distance, now));
}
/// Hundredths of a block, while the last hit is recent.
pub fn hundredths(&self, now: f64) -> Option<u32> {
let (d, at) = self.last?;
(now - at <= Self::SHOWN).then(|| (d * 100.0).round() as u32)
}
}
/// `n` hundredths as "3.05".
pub fn hundredths(n: u32) -> String {
format!("{}.{:02}", n / 100, n % 100)
}
/// `n` tenths as "4.3".
pub fn tenths(n: u32) -> String {
format!("{}.{}", n / 10, n % 10)
}
/// The local time as "14:05".
pub fn clock(hour: u8, minute: u8) -> String {
format!("{hour:02}:{minute:02}")
}
/// The keystrokes widget's keys, in drawing order.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Key {
Forward,
Left,
Back,
Right,
Attack,
Use,
Jump,
}
impl Key {
pub const ALL: [Key; 7] = [
Key::Forward,
Key::Left,
Key::Back,
Key::Right,
Key::Attack,
Key::Use,
Key::Jump,
];
/// The key mapping it shows.
pub fn mapping(self) -> &'static str {
match self {
Key::Forward => "key.forward",
Key::Left => "key.left",
Key::Back => "key.back",
Key::Right => "key.right",
Key::Attack => "key.attack",
Key::Use => "key.use",
Key::Jump => "key.jump",
}
}
}
/// The keystrokes' key size and the gap between keys, in GUI pixels.
pub const KEY: f32 = 22.0;
pub const KEY_GAP: f32 = 2.0;
/// The space bar's height.
pub const SPACE: f32 = 12.0;
/// Where each key of the keystrokes widget sits in its box: W above A S D, the two mouse
/// buttons under them, the space bar at the bottom, as `(x, y, w, h)`.
pub fn key_rect(key: Key) -> (f32, f32, f32, f32) {
let step = KEY + KEY_GAP;
let full = 3.0 * KEY + 2.0 * KEY_GAP;
let half = (full - KEY_GAP) / 2.0;
match key {
Key::Forward => (step, 0.0, KEY, KEY),
Key::Left => (0.0, step, KEY, KEY),
Key::Back => (step, step, KEY, KEY),
Key::Right => (2.0 * step, step, KEY, KEY),
Key::Attack => (0.0, 2.0 * step, half, KEY),
Key::Use => (half + KEY_GAP, 2.0 * step, half, KEY),
Key::Jump => (0.0, 3.0 * step, full, SPACE),
}
}
/// The keystrokes widget's size.
pub fn keys_size() -> (f32, f32) {
let (_, y, w, h) = key_rect(Key::Jump);
(w, y + h)
}
/// The HUD widgets, for their places on screen ([`Layout`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WidgetId {
Armour,
Effects,
Held,
Ping,
Coords,
Server,
Combo,
Fps,
Cps,
Keys,
Clock,
Reach,
Speed,
}
impl WidgetId {
pub const ALL: [WidgetId; 13] = [
WidgetId::Armour,
WidgetId::Effects,
WidgetId::Held,
WidgetId::Ping,
WidgetId::Coords,
WidgetId::Server,
WidgetId::Combo,
WidgetId::Fps,
WidgetId::Cps,
WidgetId::Keys,
WidgetId::Clock,
WidgetId::Reach,
WidgetId::Speed,
];
pub fn name(self) -> &'static str {
match self {
WidgetId::Armour => "armour",
WidgetId::Effects => "effects",
WidgetId::Held => "held",
WidgetId::Ping => "ping",
WidgetId::Coords => "coords",
WidgetId::Server => "server",
WidgetId::Combo => "combo",
WidgetId::Fps => "fps",
WidgetId::Cps => "cps",
WidgetId::Keys => "keys",
WidgetId::Clock => "clock",
WidgetId::Reach => "reach",
WidgetId::Speed => "speed",
}
}
pub fn index(self) -> usize {
self as usize
}
}
/// Where a widget sits: in its corner, stacked with the others there (`at` is `None`), or at a
/// place the player dragged it to, as fractions of the room the screen leaves around the box
/// (0 at the left or top edge, 1 at the right or bottom), so it stays on screen at any size.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Place {
pub at: Option<(f32, f32)>,
/// Its size in percent.
pub scale: u8,
}
impl Default for Place {
fn default() -> Self {
Self {
at: None,
scale: 100,
}
}
}
pub const SCALES: (u8, u8) = (50, 200);
/// Every widget's place.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Layout(pub [Place; 13]);
impl Layout {
pub fn get(&self, id: WidgetId) -> Place {
self.0[id.index()]
}
pub fn get_mut(&mut self, id: WidgetId) -> &mut Place {
&mut self.0[id.index()]
}
/// One `hudPlace` value: `armour,0.25,0.5,150`, or `armour,corner,,150`.
pub fn parse(&mut self, value: &str) {
let mut parts = value.split(',');
let Some(id) = parts
.next()
.and_then(|n| WidgetId::ALL.into_iter().find(|w| w.name() == n))
else {
return;
};
let (x, y, scale) = (parts.next(), parts.next(), parts.next());
let frac = |v: Option<&str>| {
v.and_then(|v| v.parse::<f32>().ok())
.filter(|v| v.is_finite())
.map(|v| v.clamp(0.0, 1.0))
};
let place = self.get_mut(id);
place.at = frac(x).zip(frac(y));
if let Some(s) = scale.and_then(|s| s.parse::<u8>().ok()) {
place.scale = s.clamp(SCALES.0, SCALES.1);
}
}
/// The `hudPlace` lines of the widgets that left their corner or their size.
pub fn serialize(&self, out: &mut String) {
use std::fmt::Write;
for id in WidgetId::ALL {
let p = self.get(id);
if p == Place::default() {
continue;
}
let _ = match p.at {
Some((x, y)) => writeln!(out, "hudPlace:{},{x:.4},{y:.4},{}", id.name(), p.scale),
None => writeln!(out, "hudPlace:{},corner,,{}", id.name(), p.scale),
};
}
}
}
/// A box `w` x `h` placed at fractions `(fx, fy)` of the room left on a `screen`.
pub fn from_fractions((fx, fy): (f32, f32), (w, h): (f32, f32), screen: (f32, f32)) -> (f32, f32) {
(
(fx * (screen.0 - w).max(0.0)).round(),
(fy * (screen.1 - h).max(0.0)).round(),
)
}
/// The fractions that put a `w` x `h` box at `(x, y)` on a `screen`, kept on screen.
pub fn to_fractions((x, y): (f32, f32), (w, h): (f32, f32), screen: (f32, f32)) -> (f32, f32) {
let frac = |v: f32, room: f32| {
if room > 0.0 {
(v / room).clamp(0.0, 1.0)
} else {
0.0
}
};
(frac(x, screen.0 - w), frac(y, screen.1 - h))
}
/// How near, in GUI pixels, an edge must come to another to snap to it.
pub const SNAP: f32 = 4.0;
/// Snaps a box's start `x` of length `w` along one axis of length `screen`: to the screen's
/// edges and middle, and to the edges of the `others` boxes (`(start, length)`), where one
/// is within [`SNAP`]. Returns the new start and the line it snapped to, if any.
pub fn snap(x: f32, w: f32, screen: f32, others: &[(f32, f32)]) -> (f32, Option<f32>) {
let mut best: Option<(f32, f32, f32)> = None;
let mut consider = |start: f32, line: f32| {
let d = (start - x).abs();
if d <= SNAP && best.is_none_or(|(b, _, _)| d < b) {
best = Some((d, start, line));
}
};
// The screen's edges, with the corners' margin, and its middle.
consider(2.0, 0.0);
consider(screen - 2.0 - w, screen);
consider((screen - w) / 2.0, screen / 2.0);
for &(o, ow) in others {
// Side by side, or lined up on the same edge.
consider(o + ow, o + ow);
consider(o - w, o);
consider(o, o);
consider(o + ow - w, o + ow);
}
match best {
Some((_, start, line)) => (start.clamp(0.0, (screen - w).max(0.0)), Some(line)),
None => (x.clamp(0.0, (screen - w).max(0.0)), None),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -338,4 +652,141 @@ mod tests {
assert_eq!(h, 18.0 + 10.0 + 20.0 + 2.0 * PAD - 2.0);
assert!(size(&[]) == (0.0, 0.0));
}
#[test]
fn clicks_count_the_presses_of_the_last_second() {
let mut c = Clicks::default();
for t in [0.0, 0.1, 0.5, 0.9] {
c.press(t);
}
assert_eq!(c.per_second(0.95), 4);
// 1.0 s after the first press it has left the window.
assert_eq!(c.per_second(1.0), 3);
assert_eq!(c.per_second(1.6), 1);
assert_eq!(c.per_second(5.0), 0);
// The ring never keeps more than 64 presses.
for i in 0..200 {
c.press(10.0 + f64::from(i) * 0.001);
}
assert_eq!(c.per_second(10.2), 64);
}
#[test]
fn speed_is_horizontal_blocks_per_second_between_samples() {
let mut s = Speed::default();
assert_eq!(s.sample(0.0, 0.0, 0.0), 0);
// Sprinting: 5.612 blocks per second along x, with a jump (y is ignored).
assert_eq!(s.sample(0.5, 0.0, 0.1), 0, "too soon for a new sample");
assert_eq!(s.sample(1.403, 0.0, 0.25), 56);
// 3 east and 4 south in a second: 5 blocks per second.
assert_eq!(s.sample(1.403 + 3.0, 4.0, 1.25), 50);
// A teleport reads as standing still.
assert_eq!(s.sample(1000.0, 4.0, 1.5), 0);
assert_eq!(tenths(56), "5.6");
}
#[test]
fn reach_shows_the_last_hit_for_three_seconds() {
let mut r = Reach::default();
assert_eq!(r.hundredths(0.0), None);
r.hit(2.954, 1.0);
assert_eq!(r.hundredths(1.5), Some(295));
assert_eq!(hundredths(295), "2.95");
assert_eq!(hundredths(300), "3.00");
assert_eq!(r.hundredths(4.1), None);
}
#[test]
fn the_clock_reads_hours_and_minutes() {
assert_eq!(clock(14, 5), "14:05");
assert_eq!(clock(0, 0), "00:00");
}
#[test]
fn keystrokes_keys_tile_their_box_without_overlap() {
let (w, h) = keys_size();
assert_eq!((w, h), (70.0, 3.0 * 24.0 + 12.0));
for (i, a) in Key::ALL.iter().enumerate() {
let (x, y, kw, kh) = key_rect(*a);
assert!(
x >= 0.0 && y >= 0.0 && x + kw <= w && y + kh <= h,
"{a:?} inside"
);
for b in &Key::ALL[i + 1..] {
let (bx, by, bw, bh) = key_rect(*b);
let apart = x + kw <= bx || bx + bw <= x || y + kh <= by || by + bh <= y;
assert!(apart, "{a:?} and {b:?} overlap");
}
}
}
#[test]
fn places_round_trip_and_only_moved_widgets_are_written() {
let mut l = Layout::default();
let mut text = String::new();
l.serialize(&mut text);
assert_eq!(text, "", "a vanilla layout writes nothing");
*l.get_mut(WidgetId::Keys) = Place {
at: Some((0.25, 1.0)),
scale: 150,
};
l.get_mut(WidgetId::Fps).scale = 75;
l.serialize(&mut text);
assert_eq!(
text,
"hudPlace:fps,corner,,75\nhudPlace:keys,0.2500,1.0000,150\n"
);
let mut back = Layout::default();
for line in text.lines() {
back.parse(line.split_once(':').unwrap().1);
}
assert_eq!(back, l);
// Out of range values are clamped, unknown widgets and bad numbers ignored.
back.parse("speed,-3,7,250");
assert_eq!(back.get(WidgetId::Speed).at, Some((0.0, 1.0)));
assert_eq!(back.get(WidgetId::Speed).scale, 200);
back.parse("radar,0.5,0.5,100");
back.parse("clock,NaN,0.5,100");
assert_eq!(back.get(WidgetId::Clock).at, None);
}
#[test]
fn fractions_keep_a_box_on_screen_at_any_size() {
let screen = (480.0, 270.0);
let at = to_fractions((100.0, 50.0), (80.0, 20.0), screen);
assert_eq!(from_fractions(at, (80.0, 20.0), screen), (100.0, 50.0));
// A box at the right edge stays at the right edge on a wider screen.
let right = to_fractions((400.0, 0.0), (80.0, 20.0), screen);
assert_eq!(right, (1.0, 0.0));
assert_eq!(
from_fractions(right, (80.0, 20.0), (960.0, 540.0)),
(880.0, 0.0)
);
// Dragged past the edge: kept inside.
assert_eq!(
to_fractions((-30.0, 900.0), (80.0, 20.0), screen),
(0.0, 1.0)
);
}
#[test]
fn boxes_snap_to_screen_edges_middle_and_each_other() {
// Near the left margin.
assert_eq!(snap(4.5, 50.0, 480.0, &[]), (2.0, Some(0.0)));
// Centred: the middle of 480 for a 50-wide box starts at 215.
assert_eq!(snap(213.0, 50.0, 480.0, &[]), (215.0, Some(240.0)));
// Beside another box (100..160): its start goes to 160.
assert_eq!(
snap(163.0, 50.0, 480.0, &[(100.0, 60.0)]),
(160.0, Some(160.0))
);
// Lined up with its left edge.
assert_eq!(
snap(98.0, 50.0, 480.0, &[(100.0, 60.0)]),
(100.0, Some(100.0))
);
// Too far from anything: stays, but never off screen.
assert_eq!(snap(300.0, 50.0, 480.0, &[(100.0, 60.0)]), (300.0, None));
assert_eq!(snap(470.0, 50.0, 480.0, &[]), (430.0, None));
}
}
+43 -1
View File
@@ -614,7 +614,12 @@ fn button_with_room(
room: f32,
narration: impl FnOnce([f32; 4]) -> crate::widgets::Narratable,
) {
crate::widgets::record(&label.plain(), [x, y, w, h], active);
let plain = label.plain();
crate::widgets::record(&plain, [x, y, w, h], active);
// An icon button has no label to record, and A presses it all the same.
if active && plain.trim().is_empty() {
crate::widgets::target([x, y, w, h]);
}
crate::widgets::narratable(|| narration([x, y, w, h]).hovered(hover));
crate::widgets::widget_cursor(hover, active);
let sprite = if !active {
@@ -1846,6 +1851,43 @@ mod tests {
assert_eq!(scrolled_left(10.0, 40.0, 1_000), 10.0 - offset);
}
/// A controller reaches an icon button only through its target, and can't press a disabled one.
#[test]
fn an_active_icon_button_is_a_focus_target_and_a_disabled_one_is_not() {
let atlas = atlas();
let mut batch = HudBatch::new([640, 480]);
// Whatever this thread drew before is the last frame's, then gone.
crate::widgets::end_frame(u64::MAX - 1);
button(
&mut batch,
&atlas,
10.0,
20.0,
20.0,
20.0,
&Text::empty(),
true,
false,
);
button(
&mut batch,
&atlas,
40.0,
20.0,
20.0,
20.0,
&Text::empty(),
false,
false,
);
crate::widgets::end_frame(u64::MAX);
let targets: Vec<[f32; 4]> = crate::widgets::last_targets()
.iter()
.map(|t| t.rect)
.collect();
assert_eq!(targets, [[10.0, 20.0, 20.0, 20.0]]);
}
#[test]
fn a_glyph_leaves_a_label_that_fits_centred() {
let atlas = atlas();
@@ -65,7 +65,11 @@ struct Taa {
jitter: vec4<f32>,
};
@group(0) @binding(2) var history: texture_2d<f32>;
@group(0) @binding(3) var depth: texture_depth_2d;
// The world's depth as an unfilterable float texture: naga's GLSL backend
// cannot emit `textureLoad` from a depth texture, and a depth view bound as
// `TextureSampleType::Float { filterable: false }` gives the same texel on
// every backend.
@group(0) @binding(3) var depth: texture_2d<f32>;
@group(0) @binding(4) var<uniform> taa: Taa;
// Catmull-Rom over the history in 5 bilinear taps: sharper than one under motion.
@@ -113,7 +117,7 @@ fn sample_history(uv: vec2<f32>) -> vec3<f32> {
let c = textureLoad(world, q, 0).rgb;
lo = min(lo, c);
hi = max(hi, c);
near = max(near, textureLoad(depth, q, 0));
near = max(near, textureLoad(depth, q, 0).r);
}
}
let ndc = vec2(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0);
@@ -357,6 +357,10 @@
"horizon.extras.header.cosmetics": "Kosmetik",
"horizon.extras.cosmetics_mine": "Meine Kosmetik",
"horizon.extras.cosmetics_mine.tooltip": "Ausgeblendet: Alle Horizon-Spieler sehen dich ohne deine Kosmetik, du selbst auch. Derselbe Schalter wie in der Garderobe des Launchers.",
"key.emote": "Emote-Rad",
"horizon.emote_wheel.title": "Emotes",
"horizon.emote_wheel.empty": "Noch keine Emotes",
"horizon.emote_wheel.help": "Loslassen oder klicken zum Abspielen · 1–8 · Esc schließt",
"horizon.extras.cosmetics_others": "Kosmetik anderer Spieler",
"horizon.extras.cosmetics_others.tooltip": "Was andere Horizon-Spieler tragen: alles, nur ihre Namensschild-Stile oder nichts. Ausgeblendet fragt das Spiel nie, was sie tragen.",
"horizon.extras.cosmetics_auras": "Auren und Spuren anderer",
@@ -557,6 +557,10 @@
"horizon.extras.header.cosmetics": "Cosmetics",
"horizon.extras.cosmetics_mine": "My cosmetics",
"horizon.extras.cosmetics_mine.tooltip": "Hidden: every Horizon player sees you without your cosmetics, you included. The same switch as the launcher's Wardrobe.",
"key.emote": "Emote Wheel",
"horizon.emote_wheel.title": "Emotes",
"horizon.emote_wheel.empty": "No emotes yet",
"horizon.emote_wheel.help": "Release or click to play · 1–8 · Esc closes",
"horizon.extras.cosmetics_others": "Other players' cosmetics",
"horizon.extras.cosmetics_others.tooltip": "What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden unless you own cosmetics, until you choose. Hidden, the game never asks what they wear.",
"horizon.extras.cosmetics_auras": "Others' auras and trails",
@@ -676,6 +680,12 @@
"horizon.pvp.hud_held_corner.tooltip": "Where the count shows.",
"horizon.pvp.hud_background": "Widget backgrounds",
"horizon.pvp.hud_background.tooltip": "A dark box behind each widget.",
"horizon.pvp.hud_opacity": "Background opacity",
"horizon.pvp.hud_opacity.tooltip": "How dark the boxes behind the widgets are.",
"horizon.pvp.hud_text_colour": "Text colour",
"horizon.pvp.hud_text_colour.tooltip": "The widgets' text colour. Durability keeps its green to red.",
"horizon.pvp.hud_shadow": "Text shadow",
"horizon.pvp.hud_shadow.tooltip": "A shadow under the widgets' text, as vanilla's HUD text has.",
"horizon.pvp.header.hud": "HUD widgets",
"horizon.pvp.corner.top_left": "Top left",
"horizon.pvp.corner.top_right": "Top right",
@@ -705,6 +715,37 @@
"horizon.pvp.hud.direction.south": "South",
"horizon.pvp.hud.direction.east": "East",
"horizon.pvp.hud.direction.west": "West",
"horizon.pvp.hud_fps": "FPS",
"horizon.pvp.hud_fps.tooltip": "Frames drawn in the last second.",
"horizon.pvp.hud_fps_corner": "FPS corner",
"horizon.pvp.hud_fps_corner.tooltip": "Where the FPS shows.",
"horizon.pvp.hud_cps": "Clicks per second",
"horizon.pvp.hud_cps.tooltip": "Your left and right clicks in the last second.",
"horizon.pvp.hud_cps_corner": "CPS corner",
"horizon.pvp.hud_cps_corner.tooltip": "Where the clicks per second show.",
"horizon.pvp.hud_keys": "Keystrokes",
"horizon.pvp.hud_keys.tooltip": "Your movement keys, mouse buttons and jump, lit while you hold them, with clicks per second.",
"horizon.pvp.hud_keys_corner": "Keystrokes corner",
"horizon.pvp.hud_keys_corner.tooltip": "Where the keystrokes show.",
"horizon.pvp.hud_clock": "Clock",
"horizon.pvp.hud_clock.tooltip": "Your computer's time.",
"horizon.pvp.hud_clock_corner": "Clock corner",
"horizon.pvp.hud_clock_corner.tooltip": "Where the clock shows.",
"horizon.pvp.hud_reach": "Reach",
"horizon.pvp.hud_reach.tooltip": "How far from your eyes your last hit landed, for 3 seconds. It only measures: your reach stays vanilla's.",
"horizon.pvp.hud_reach_corner": "Reach corner",
"horizon.pvp.hud_reach_corner.tooltip": "Where the reach shows.",
"horizon.pvp.hud_speed": "Speed",
"horizon.pvp.hud_speed.tooltip": "How fast you move across the ground, in blocks per second.",
"horizon.pvp.hud_speed_corner": "Speed corner",
"horizon.pvp.hud_speed_corner.tooltip": "Where the speed shows.",
"horizon.pvp.hud.fps": "%s FPS",
"horizon.pvp.hud.cps": "CPS: %s | %s",
"horizon.pvp.hud.reach": "Reach: %s",
"horizon.pvp.hud.speed": "Speed: %s b/s",
"horizon.pvp.hud.keys.attack": "LMB",
"horizon.pvp.hud.keys.use": "RMB",
"horizon.pvp.hud.keys.cps": "%s CPS",
"horizon.pvp.numeric_enchants": "Enchantment levels as numbers",
"horizon.pvp.numeric_enchants.tooltip": "Tooltips and the enchanting table show Sharpness 5 instead of Sharpness V.",
"horizon.pvp.header.items": "Items",
@@ -394,6 +394,10 @@
"horizon.extras.header.cosmetics": "Cosmétiques",
"horizon.extras.cosmetics_mine": "Mes cosmétiques",
"horizon.extras.cosmetics_mine.tooltip": "Masqués : tous les joueurs Horizon vous voient sans vos cosmétiques, vous compris. Le même réglage que la garde-robe du launcher.",
"key.emote": "Roue des emotes",
"horizon.emote_wheel.title": "Emotes",
"horizon.emote_wheel.empty": "Pas encore d'emote",
"horizon.emote_wheel.help": "Relâche ou clique pour jouer · 1–8 · Échap ferme",
"horizon.extras.cosmetics_others": "Cosmétiques des autres",
"horizon.extras.cosmetics_others.tooltip": "Ce que portent les autres joueurs Horizon : tout, seulement leur style d'étiquette de nom, ou rien. Masqués si vous n'avez pas de cosmétiques, tant que vous n'avez pas choisi. Masqués, le jeu ne demande jamais ce qu'ils portent.",
"horizon.extras.cosmetics_auras": "Auras et traînées des autres",
@@ -99,30 +99,40 @@ pub fn main() -> Result<()> {
let case: Value = serde_json::from_str(&line?)?;
let kind = case["kind"].as_str().unwrap_or("marker");
// A block's own tick through the whole ClientLevel.animateTick loop,
// over a world that is all of it: every particle and sound it asks the
// engine for, from the level random seeded by the case.
// over a world that is all of it above y = 64 and all of the case's
// floor below: every particle and sound it asks the engine for, from the
// level random seeded by the case.
if kind == "tick" {
let spec = case["state"].as_str().ok_or("state")?.to_owned();
if !tick_worlds.contains_key(&spec) {
let floor_spec = case["floor"].as_str().unwrap_or(&spec).to_owned();
let key = format!("{spec}|{floor_spec}");
if !tick_worlds.contains_key(&key) {
let state = assets.registry.resolve(&parse_state(&spec))?;
let floor = assets.registry.resolve(&parse_state(&floor_spec))?;
let mut world = World::new(facts.air, 0);
for cz in -2..2 {
for cx in -2..2 {
// The loop samples y 32 to 96, reads neighbours one block out
// and a stalactite climbs ten blocks to its root, so the
// stand-in world has to hold all of that: a case that ends
// inside the sample box compares air against the oracle's
// unending world.
for cz in -3..3 {
for cx in -3..3 {
world
.chunks
.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
for y in 33..96 {
for y in 31..=107 {
for z in 0..16 {
for x in 0..16 {
world.set(cx * 16 + x, y, cz * 16 + z, state)?;
let at = if y < 64 { floor } else { state };
world.set(cx * 16 + x, y, cz * 16 + z, at)?;
}
}
}
}
}
tick_worlds.insert(spec.clone(), world);
tick_worlds.insert(key.clone(), world);
}
let world = &tick_worlds[&spec];
let world = &tick_worlds[&key];
// A block whose model is jittered per column: the offset the client
// gives its model, so the oracle's shape moves exactly as the game
// moves it.
+6 -3
View File
@@ -1514,10 +1514,13 @@ fn block_animate_tick(
}
}
Animate::Beehive => {
// BeehiveBlock.animateTick: nextInt(1) + 1 attempts.
let attempts = next_int(engine.random(), 1) + 1;
for _ in 0..attempts {
// BeehiveBlock.animateTick: `for (i = 0; i < random.nextInt(1) + 1; i++)`
// re-reads its bound on every trip, so the level random is asked
// once before the attempt and once more to end the loop.
let mut attempt = 0;
while attempt < next_int(engine.random(), 1) + 1 {
beehive_drip(tables, engine, ctx, state, pos);
attempt += 1;
}
}
Animate::RedstoneOre => {
@@ -437,6 +437,10 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("look-at-entity", Move, "look-at-entity ID [--over S]", "face an entity (same as look-at entity)", "",
["horizon-ctl look-at-entity 42"], &[p("id", Ty::Int, "entity id from entities"), OVER], mcp: true, local: false, wire: "look-at entity"),
cmd!("tp", Move, "tp X Y Z", "teleport the player", "", ["horizon-ctl tp 0 80 0"], &XYZ, mcp: true, local: false),
cmd!("emote", Act, "emote [ID|stop]", "play one of the player's emotes, stop it, or list them",
"Without an argument: the emotes the player owns (the wheel's) and the one playing. With an ID it plays that emote as the emote wheel does (the player must stand still: moving, attacking or using an item cancels it); stop fades it out. Visual only: nothing is sent to the game server (docs/cosmetics.md).",
["horizon-ctl emote", "horizon-ctl emote wave_hello", "horizon-ctl emote stop"],
&[opt("id", Ty::Str, "an owned emote's id, or stop")], mcp: true, local: false),
// Act
cmd!("break", Act, "break X Y Z [--timeout S]", "mine a block, holding the attack button until it breaks",
"Aims at a visible face (the block must be within reach: use goto --near 2 first), mines with the held item, then waits up to a second for the drops. Replies with block_before, block, ticks, drops and picked_up (inventory change).",
+91 -8
View File
@@ -117,8 +117,13 @@ mod dispense_fixture;
mod dispenser;
mod dragon;
mod dynamic_lights;
mod emote_wheel;
mod emotes;
mod enchant_effects;
mod end;
mod enderman;
#[cfg(test)]
mod enderman_placement_tests;
mod entity;
mod entity_culling;
mod entity_sounds;
@@ -829,6 +834,8 @@ impl Options {
enum Screen {
Playing,
GameModeSwitcher,
/// Horizon's emote wheel (`game/emote_wheel.rs`): over the world, not pausing.
EmoteWheel,
Paused,
Options,
Chat,
@@ -872,6 +879,7 @@ impl Screen {
match self {
Screen::Playing => "playing",
Screen::GameModeSwitcher => "game_mode_switcher",
Screen::EmoteWheel => "emote_wheel",
Screen::Paused => "paused",
Screen::Options => "options",
Screen::Chat => "chat",
@@ -2022,15 +2030,20 @@ struct WorldTables {
/// The values of the data map types native mods registered
/// (`native_data_map_types`).
data_maps: native_data_maps::DataMaps,
/// The entity type, biome and damage type tags with the packs' over the
/// game's own, by registry (`TagLoader.loadTagsForExistingRegistries`):
/// The entity type, biome, structure and damage type tags with the packs'
/// over the game's own, by registry (`TagLoader.loadTagsForExistingRegistries`):
/// what the predicates' `#tag`s of those registries read.
pack_tags: HashMap<&'static str, HashMap<String, HashSet<String>>>,
}
impl WorldTables {
/// The registries [`WorldTables::pack_tags`] holds.
const PACK_TAG_REGISTRIES: [&'static str; 3] = ["entity_type", "worldgen/biome", "damage_type"];
const PACK_TAG_REGISTRIES: [&'static str; 4] = [
"entity_type",
"worldgen/biome",
"worldgen/structure",
"damage_type",
];
}
impl Resources {
@@ -2937,6 +2950,9 @@ struct Game<'a> {
skin_slots: horizon_client::skins::SkinSlots,
/// Cosmetics on players (game/cosmetics.rs), off by default.
cosmetics: cosmetics::Cosmetics,
/// Emotes: those the wheel offers and the one playing (`game/emotes.rs`).
emotes: emotes::Emotes,
emote_wheel: emote_wheel::Wheel,
/// 3D skin layers: each player's outer layer as one slab per painted
/// texel, built once per (skin, slim) (`mobs::skin_layers`).
skin_3d: RefCell<skin_models::SkinModels>,
@@ -3154,7 +3170,7 @@ struct Game<'a> {
/// The mob of that `hitResult`, picked with the item held at the pick
/// (`LocalPlayer.raycastHitResult`: a spear's `attack_range`), before the
/// tick's number keys change it.
tick_mob: Option<Option<(usize, f64)>>,
tick_mob: Option<Option<(usize, f64, [f64; 3])>>,
/// Number keys since the last tick (`keyHotbarSlots[i].consumeClick`):
/// `Minecraft.handleKeybinds` selects them after the tick's pick.
hotbar_presses: Vec<usize>,
@@ -3396,6 +3412,12 @@ fn facing_offset(facing: &str) -> [i32; 3] {
_ => [0, -1, 0],
}
}
/// The point a `ServerboundInteractPacket` carries: `EntityHitResult.getLocation()
/// - entity.position()`, measured from the entity's feet, as
/// `MultiPlayerGameMode.interact` computes it.
fn interact_location(point: Vec3, feet: Vec3) -> [f64; 3] {
[point.x - feet.x, point.y - feet.y, point.z - feet.z]
}
/// How far along `dir` (within `reach`) a ray from `eye` enters `b`.
fn ray_box(eye: [f64; 3], dir: [f64; 3], reach: f64, b: Aabb) -> Option<f64> {
let (min, max) = (b.min_position(), b.max_position());
@@ -9097,6 +9119,7 @@ impl Game<'_> {
);
}
Screen::GameModeSwitcher => self.draw_gamemode_switcher(&mut batch),
Screen::EmoteWheel => self.draw_emote_wheel(&mut batch),
Screen::Dead => self.draw_death_screen(&mut batch),
Screen::Stats => self.draw_stats(&mut batch),
Screen::Advancements => self.draw_advancements(&mut batch),
@@ -11930,13 +11953,16 @@ impl Game<'_> {
self.sky_darken_memo.set(Some((key, darken)));
darken
}
fn mob_hit(&self) -> Option<(usize, f64)> {
/// The mob hit along the view, the distance to it and the point the ray
/// met its box at, measured from the mob's feet (`EntityHitResult
/// .getLocation() - entity.position()`, the interact packet's location).
fn mob_hit(&self) -> Option<(usize, f64, [f64; 3])> {
if let Some(hit) = self.tick_mob {
return hit;
}
let (eye, dir) = self.aim()?;
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64)> = None;
let mut best: Option<(usize, f64, [f64; 3])> = None;
for (i, m) in self.mobs.iter().enumerate() {
// ProjectileUtil.getEntityHitResult: the mob the player rides
// shares its root vehicle and is never the hit, nor is the
@@ -11949,7 +11975,12 @@ impl Game<'_> {
if let Some(t) = ray_box(eye, dir, reach, m.bounds())
&& best.is_none_or(|b| t < b.1)
{
best = Some((i, t));
let point = Vec3::new(
eye[0] + dir[0] * t,
eye[1] + dir[1] * t,
eye[2] + dir[2] * t,
);
best = Some((i, t, interact_location(point, m.position)));
}
}
best
@@ -12163,7 +12194,7 @@ impl Game<'_> {
{
return true;
}
let Some((index, distance)) = self.mob_hit() else {
let Some((index, distance, _)) = self.mob_hit() else {
return false;
};
if !self.before_block(distance) || !self.held_range_allows(distance) {
@@ -12387,6 +12418,7 @@ impl Game<'_> {
self.spawn_phantoms();
self.tick_advancements();
self.tick_player_model();
self.tick_emotes();
Ok(())
}
/// SkyRenderer.renderSunMoonAndStars: bodies on a frame rotated Y -90 then X by angle.
@@ -16836,6 +16868,7 @@ impl Game<'_> {
self.capture(true);
}
Screen::GameModeSwitcher => self.close_gamemode_switcher(false),
Screen::EmoteWheel => self.close_emote_wheel(None),
Screen::Dead => {
if self.ui.death_confirm.is_some() {
self.death_confirm_answer(false)?;
@@ -16905,6 +16938,24 @@ impl Game<'_> {
self.release_use_for_keybind_screen();
}
KeyCode::KeyL if self.screen == Screen::Advancements => self.close_advancements(),
// key.emote (Horizon's): the emote wheel, a tap again closes it.
KeyCode::KeyB if self.screen == Screen::Playing && horizon_cosmetics::enabled() => {
self.open_emote_wheel();
}
KeyCode::KeyB if self.screen == Screen::EmoteWheel => self.close_emote_wheel(None),
KeyCode::Digit1
| KeyCode::Digit2
| KeyCode::Digit3
| KeyCode::Digit4
| KeyCode::Digit5
| KeyCode::Digit6
| KeyCode::Digit7
| KeyCode::Digit8
if self.screen == Screen::EmoteWheel =>
{
let slot = (key as u32).wrapping_sub(KeyCode::Digit1 as u32) as usize;
self.close_emote_wheel(Some(slot));
}
// key.quickActions: only open if a datapack provides dialog actions.
KeyCode::KeyG if self.screen == Screen::Playing => {
if self.has_quick_actions() {
@@ -17031,6 +17082,9 @@ impl Game<'_> {
}
/// A mouse button while playing (window or control socket).
fn play_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
if pressed {
self.pvp_click(button);
}
if self.sleeping.is_some() {
return Ok(());
}
@@ -18008,6 +18062,10 @@ impl Game<'_> {
{
self.track_drag_button(*button, *state == ElementState::Pressed);
}
// The emote key let go over the wheel, a click on it.
if self.screen == Screen::EmoteWheel && self.emote_wheel_event(&event) {
return Ok(());
}
if let WindowEvent::KeyboardInput { event, .. } = &event
&& event.state == ElementState::Released
&& let PhysicalKey::Code(key) = event.physical_key
@@ -19642,6 +19700,8 @@ impl<'a> Game<'a> {
skin_profiles: RefCell::new(horizon_client::skins::Resolver::new()),
skin_slots: horizon_client::skins::SkinSlots::new(horizon_client::hud::SKIN_SLOTS),
cosmetics: Default::default(),
emotes: Default::default(),
emote_wheel: Default::default(),
skin_3d: Default::default(),
trim_slots: Vec::new(),
cart_models: prepared.cart_models,
@@ -22708,6 +22768,7 @@ mod tests {
let all = [
Screen::Playing,
Screen::GameModeSwitcher,
Screen::EmoteWheel,
Screen::Paused,
Screen::Options,
Screen::Chat,
@@ -22874,6 +22935,28 @@ mod tests {
assert!(named_mob_pickable(true, 17, Some(23), Some(29)));
}
/// The packet's location is `EntityHitResult.getLocation() -
/// entity.position()`, the point the view ray met the box at measured
/// from the mob's feet (`MultiPlayerGameMode.interact`). The old fixed
/// [0, 1, 0] was a point inside every mob, which the server's
/// `interactAt` paths (armour stands, saddles, leashes) read as the click.
#[test]
fn an_interact_packet_carries_the_point_the_ray_hit() {
let feet = Vec3::new(0.5, 0.0, 2.0);
let eye = [0.5, 1.5, 1.0];
let dir = [0.0, 0.0, 1.0];
let box_at = Aabb::new(feet, Vec3::new(1.5, 2.0, 3.0));
let t = ray_box(eye, dir, 3.0, box_at).unwrap();
let point = Vec3::new(
eye[0] + dir[0] * t,
eye[1] + dir[1] * t,
eye[2] + dir[2] * t,
);
// The ray enters the box's near face halfway up it, directly above
// the feet: not the old centre point of the entity.
assert_eq!(interact_location(point, feet), [0.0, 1.5, 0.0]);
}
/// A mod screen on a hopper or dispenser menu owns its slots: the stock
/// grid stands back so `slot_position` reaches the mod's own slots
/// (`native_screen_slot`) and its `inventory_y` (#5141). Without a mod
@@ -230,6 +230,9 @@ impl adv::World for View<'_, '_> {
"block" => items.block_in_tag(tag, id),
"entity_type" => self.0.res.mob_data().entity_has(tag, id),
"worldgen/biome" => self.0.res.mob_data().biome_has(tag, id),
"worldgen/structure" => vanilla_structure_tags(&self.0.options.resources)
.get(tag)
.is_some_and(|t| t.contains(id)),
"damage_type" => self
.0
.health_resources
@@ -240,6 +243,21 @@ impl adv::World for View<'_, '_> {
}
}
}
type Tags = std::collections::HashMap<String, std::collections::HashSet<String>>;
/// The game's own `worldgen/structure` tags (a world without data packs has
/// no `WorldTables` to hold them), loaded once for the resource root first
/// asked for, as `StructureSpawns::shared`.
fn vanilla_structure_tags(root: &std::path::Path) -> &'static Tags {
static TAGS: std::sync::OnceLock<Tags> = std::sync::OnceLock::new();
TAGS.get_or_init(|| {
horizon_client::items::load_tags(
root,
"worldgen/structure",
&horizon_world::loot::PackOverlay::NONE,
)
.unwrap_or_default()
})
}
fn now_text() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -2728,3 +2746,96 @@ mod tests {
);
}
}
#[cfg(test)]
mod structure_tag_tests {
use super::View;
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::advancements::{World, id_matches};
use serde_json::json;
/// `LocationPredicate.structures` names a `#tag`: the game's own
/// `worldgen/structure` tags, and with a world's data packs theirs over
/// the game's.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn location_structure_tags_hold_their_structures() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let set = |s: &str| json!(s);
let holds = |game: &Game, tag: &str, id: &str| {
id_matches(&set(tag), "worldgen/structure", id, &View(game))
};
// A world without packs: the game's own tags.
assert!(game.world_tables.is_none());
assert!(holds(
&game,
"#minecraft:village",
"minecraft:village_taiga"
));
assert!(holds(&game, "#shipwreck", "minecraft:shipwreck_beached"));
assert!(!holds(&game, "#minecraft:village", "minecraft:shipwreck"));
assert!(!View(&game).in_tag("worldgen/structure", "mypack:village", "minecraft:igloo"));
// A pack's own tag (nesting the game's) and an addition to the game's.
let dir =
std::env::temp_dir().join(format!("horizon-structure-tags-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let pack = dir.join("datapacks").join("mypack");
let tags = pack.join("data/mypack/tags/worldgen/structure");
let game_tags = pack.join("data/minecraft/tags/worldgen/structure");
std::fs::create_dir_all(&tags).unwrap();
std::fs::create_dir_all(&game_tags).unwrap();
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
std::fs::write(
tags.join("village.json"),
r##"{"values": ["#minecraft:village", "minecraft:igloo"]}"##,
)
.unwrap();
std::fs::write(
game_tags.join("shipwreck.json"),
r#"{"values": ["minecraft:ocean_ruin_cold"]}"#,
)
.unwrap();
let settings = json!({"enabled": ["file/mypack"], "disabled": []});
std::fs::write(dir.join("datapacks.json"), settings.to_string()).unwrap();
game.world_root = dir.clone();
let (tables, _) = game.build_world_tables(false, &[]).unwrap();
game.world_tables = tables;
assert!(game.world_tables.is_some());
assert!(holds(&game, "#mypack:village", "minecraft:igloo"));
assert!(holds(&game, "#mypack:village", "minecraft:village_plains"));
assert!(!holds(&game, "#mypack:village", "minecraft:shipwreck"));
assert!(holds(
&game,
"#minecraft:shipwreck",
"minecraft:ocean_ruin_cold"
));
assert!(holds(&game, "#minecraft:shipwreck", "minecraft:shipwreck"));
let _ = std::fs::remove_dir_all(&dir);
}
}
+3 -2
View File
@@ -413,6 +413,7 @@ impl Game<'_> {
match self.screen {
Screen::Playing => "playing",
Screen::GameModeSwitcher => "gamemode-switcher",
Screen::EmoteWheel => "emote-wheel",
Screen::Paused => "paused",
Screen::Options => "options",
Screen::Inventory if self.station.is_some() => "station",
@@ -460,7 +461,7 @@ impl Game<'_> {
v["distance"] = json!(dist(eye, hit.point));
v
});
let entity = self.mob_hit().map(|(i, d)| {
let entity = self.mob_hit().map(|(i, d, _)| {
let m = &self.mobs[i];
json!({ "id": m.id, "type": m.kind.id, "health": m.health, "distance": d,
"pos": [m.position.x, m.position.y, m.position.z] })
@@ -1299,7 +1300,7 @@ impl Game<'_> {
}
let target = self
.mob_hit()
.map(|(i, _)| (self.mobs[i].id, self.mobs[i].health));
.map(|(i, _, _)| (self.mobs[i].id, self.mobs[i].health));
if id.is_some() && target.map(|t| t.0) != id {
return Err("that entity is out of reach or hidden; get closer".into());
}
@@ -278,14 +278,20 @@ impl Game<'_> {
})?;
let messages = self.chat_capture.take().unwrap_or_default();
let results = self.cmds.results.take().unwrap_or_default();
if op["record"].as_bool() != Some(false) {
if op["record"].as_bool() != Some(false) || op["trace"].as_bool() == Some(true)
{
let record = json!({
"cmd": command,
"messages": messages.iter().map(spans).collect::<Vec<_>>(),
"results": results.iter().map(|(s, v)| json!([s, v])).collect::<Vec<_>>(),
});
out.push_str(&record.to_string());
out.push('\n');
if op["trace"].as_bool() == Some(true) {
eprintln!("command trace: {record}");
}
if op["record"].as_bool() != Some(false) {
out.push_str(&record.to_string());
out.push('\n');
}
}
}
"mob_anger" => {
+18
View File
@@ -786,6 +786,23 @@ impl Game<'_> {
self.previous_position = to;
ok()
}
"emote" => match words.get(1).copied() {
None => {
let owned: Vec<String> =
self.owned_emotes().iter().map(|i| i.id.clone()).collect();
Ok(Some(
json!({ "owned": owned, "playing": self.emote_playing() }),
))
}
Some("stop") => {
self.stop_emote();
ok()
}
Some(id) if self.play_emote(id) => ok(),
Some(id) => {
Err(format!("can't play emote {id} now (not owned, or not standing)").into())
}
},
"hotbar" => {
let slot: usize = arg(1)?.parse()?;
if !(1..=9).contains(&slot) {
@@ -1238,6 +1255,7 @@ impl Game<'_> {
match self.screen {
Screen::Playing => "playing",
Screen::GameModeSwitcher => "gamemode-switcher",
Screen::EmoteWheel => "emote-wheel",
Screen::Paused => "paused",
Screen::Options => "options",
Screen::Inventory => "inventory",
+231 -7
View File
@@ -61,6 +61,62 @@ pub(super) struct Cosmetics {
listed: AccountSync,
/// Whether the signed-in account owns a cosmetic.
owner: OwnerCheck,
/// Which other players play an emote (`horizon_cosmetics::Feed`), started with the client.
feed: Option<horizon_cosmetics::Feed>,
/// My emote on its way to the service.
emote_out: EmoteOut,
}
/// The emote the player plays, told to the service: the last wish sent (`Some(None)`: stopped),
/// the wish waiting, the request on its way, and the session kept for it.
#[derive(Default)]
struct EmoteOut {
sent: Option<Option<String>>,
/// When a looping emote is said again (`/v1/emote` keeps one a minute).
again: Option<Instant>,
sending: Option<Receiver<Result<(), String>>>,
retry: Option<(Instant, Duration)>,
kept: Arc<std::sync::Mutex<horizon_cosmetics::account::Kept>>,
}
/// Tells the service the player plays `emote` (`None`: stopped).
fn send_emote(
kept: &std::sync::Mutex<horizon_cosmetics::account::Kept>,
emote: Option<&str>,
) -> Result<(), String> {
let Some(session) = horizon_client::auth::current() else {
return Ok(());
};
let mut kept = kept
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
horizon_cosmetics::account::play_emote(
&mut kept,
&http(),
&session.name,
|id| horizon_client::auth::join_server(&session, id).map_err(|e| format!("{e:?}")),
emote,
)
}
/// The emotes the signed-in account owns, for the wheel (none signed in: none).
fn load_owned_emotes(
kept: &std::sync::Mutex<horizon_cosmetics::account::Kept>,
) -> Result<Vec<horizon_cosmetics::account::OwnedEmote>, String> {
let Some(session) = horizon_client::auth::current() else {
return Ok(Vec::new());
};
let service = http();
let mut kept = kept
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
horizon_cosmetics::account::owned_emotes(
&mut kept,
&service,
&session.name,
|id| horizon_client::auth::join_server(&session, id).map_err(|e| format!("{e:?}")),
|path| horizon_cosmetics::Transport::get(&service, path),
)
}
/// Whether the signed-in account owns a cosmetic: the answer kept from the last run first
@@ -256,6 +312,9 @@ fn own_uuid(
}
}
/// How often a looping emote is said again (the service keeps one a minute).
const LOOP_AGAIN: Duration = Duration::from_secs(30);
/// The game's model of an item's part.
fn part(p: &horizon_cosmetics::Part) -> Part {
Part {
@@ -298,7 +357,7 @@ fn covered(m: &Mob, slot: Slot) -> bool {
.chest
.as_deref()
.is_some_and(|c| c != "minecraft:elytra"),
Slot::NameTag => true,
Slot::NameTag | Slot::Emote => true,
}
}
@@ -334,6 +393,10 @@ impl Game<'_> {
let (client, local) = Cosmetics::client();
c.client = Some(client);
c.local = local;
// Others' emotes come from the service only (a local folder has no one playing).
if !local {
c.feed = Some(horizon_cosmetics::Feed::new(http()));
}
}
c.frame.set(c.frame.get().wrapping_add(1));
// Item files from a folder (the art, the bench): shown as to an owner, nothing asked.
@@ -379,7 +442,47 @@ impl Game<'_> {
let busy =
c.owner.asking.is_some() || c.mine.sending.is_some() || c.listed.sending.is_some();
let extras = &self.settings.extras;
if busy {
if let Some(done) = c
.emote_out
.sending
.as_ref()
.and_then(|rx| rx.try_recv().ok())
{
c.emote_out.sending = None;
match done {
Ok(()) => c.emote_out.retry = None,
Err(_) => {
c.emote_out.retry = backoff(c.emote_out.retry);
c.emote_out.sent = None;
}
}
}
// My emote: only while I show myself as a Horizon player (playing one says so).
let playing = self.emotes.sending();
let looping = playing.as_ref().is_some_and(|(_, looping)| *looping);
let wish = playing.map(|(id, _)| id).filter(|_| extras.horizon_listed);
let o = &mut c.emote_out;
let changed = o.sent.as_ref() != Some(&wish);
let again = looping && o.again.is_some_and(|at| Instant::now() >= at);
let waited = o.retry.is_none_or(|(at, _)| Instant::now() >= at);
// Nothing sent yet and nothing playing: nothing to say (a failed send forgets what
// was sent: the service ends a one-shot by itself, a loop within a minute).
let quiet = wish.is_none() && o.sent.is_none();
let emote_due = !quiet && waited && (changed || again);
// One sign-in at a time, the emote's included.
if busy || o.sending.is_some() {
} else if emote_due {
o.sent = Some(wish.clone());
o.again = looping.then(|| Instant::now() + LOOP_AGAIN);
let (tx, rx) = channel();
o.sending = Some(rx);
let kept = o.kept.clone();
std::thread::Builder::new()
.name("Cosmetics emote".into())
.spawn(move || {
let _ = tx.send(send_emote(&kept, wish.as_deref()));
})
.ok();
} else if c.mine.due(extras.cosmetics_mine) {
c.mine.start(extras.cosmetics_mine, send_mine);
} else if c.listed.due(extras.horizon_listed) {
@@ -400,6 +503,112 @@ impl Game<'_> {
.retain(|_, b| Arc::strong_count(&b.item) > 1);
}
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
/// they play on their models; one that moved ends theirs here at once. Others' emotes show
/// with "Other players' cosmetics" on Shown only; otherwise nothing is asked.
pub(super) fn tick_others_emotes(&mut self) {
let shown = self.settings.extras.effective_others() == Others::Shown;
// Items from a folder (the art, the bench): every mannequin plays its first emote, again
// and again.
if self.cosmetics.local && shown {
let Some(first) = self.owned_emotes().first().cloned() else {
return;
};
for m in self.mobs.iter_mut() {
let age = m.age as f32;
if &*m.kind.id == "minecraft:mannequin"
&& m.emote.as_ref().is_none_or(|p| p.at(age).is_none())
{
m.emote = Some(horizon_cosmetics::Playing {
item: first.clone(),
// Out of step, so a crowd doesn't move as one.
start: age - (m.id % 40) as f32,
stop: None,
});
}
}
return;
}
let (Some(feed), Some(client)) = (&self.cosmetics.feed, &self.cosmetics.client) else {
return;
};
if !shown {
feed.ask([]);
for m in self.mobs.iter_mut() {
m.emote = None;
}
return;
}
let mut frame = client.frame();
let mut horizon = Vec::new();
for m in self.mobs.iter() {
if &*m.kind.id == "minecraft:player"
&& let Some(uuid) = self.remote_player_uuid(m.id)
&& frame.horizon(uuid)
{
horizon.push((m.id, uuid));
}
}
drop(frame);
feed.ask(horizon.iter().map(|(_, u)| *u));
let now = Instant::now();
for (id, uuid) in horizon {
let seen = feed.playing(uuid);
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
continue;
};
let age = m.age as f32;
let moved = {
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
dx * dx + dz * dz > 1e-4
};
match (seen, &mut m.emote) {
(Some(seen), Some(p)) if Arc::ptr_eq(&p.item, &seen.item) => {
if moved && p.stop.is_none() {
p.stop = Some(age);
}
}
(Some(seen), slot) if !moved => {
let ticks = now.duration_since(seen.started).as_secs_f32() * 20.0;
*slot = Some(horizon_cosmetics::Playing {
item: seen.item,
start: age - ticks,
stop: None,
});
}
(_, Some(p)) if p.stop.is_none() => p.stop = Some(age),
_ => {}
}
if m.emote.as_ref().is_some_and(|p| p.at(age).is_none()) {
m.emote = None;
}
}
}
/// Asks the service which emotes the account owns, for the wheel: when it opens, at most
/// every 10 minutes (a folder's emotes need no asking; an offline account has none).
pub(super) fn refresh_owned_emotes(&mut self) {
const AGAIN: Duration = Duration::from_secs(600);
let e = &mut self.emotes;
if self.cosmetics.local
|| !horizon_cosmetics::enabled()
|| horizon_client::auth::current().is_none()
|| e.loading.is_some()
|| e.loaded.is_some_and(|at| at.elapsed() < AGAIN)
{
return;
}
let (tx, rx) = channel();
e.loading = Some(rx);
let kept = self.cosmetics.emote_out.kept.clone();
std::thread::Builder::new()
.name("Cosmetics emotes owned".into())
.spawn(move || {
let _ = tx.send(load_owned_emotes(&kept));
})
.ok();
}
/// Items from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins are dressed and marked too.
pub(super) fn cosmetics_local(&self) -> bool {
self.cosmetics.local
@@ -516,8 +725,14 @@ impl Game<'_> {
out: &mut Vec<horizon_client::mesher::Vertex>,
) -> Option<(glam::Vec3, glam::Vec3)> {
use horizon_client::mobs::{anim, render};
let worn = self.worn(m, own)?;
if horizon_client::mobs::trader::is_invisible(m) {
let worn = self.worn(m, own);
// An emote's props: its time and weight while it plays.
let ticks = m.age as f32 + alpha;
let emote = m.emote.as_ref().and_then(|p| {
let (t, w) = p.at(ticks)?;
(p.item.vertices > 0).then_some((&p.item, t, w))
});
if worn.is_none() && emote.is_none() || horizon_client::mobs::trader::is_invisible(m) {
return None;
}
// The avatar's body, posed as it is drawn.
@@ -528,7 +743,6 @@ impl Game<'_> {
body_poses.reset(body);
let data = self.res.mob_data();
anim::setup(m, &anim::state(m, alpha), body_poses, &data.animations);
let ticks = m.age as f32 + alpha;
let frame = self.cosmetics.frame.get();
let mut bounds = None;
// Items on one bone (a back item, a shoulder pet and a shirt ride the body) share its
@@ -536,8 +750,10 @@ impl Game<'_> {
let mut bones: [Option<(horizon_cosmetics::Bone, glam::Mat4)>; 4] = [None; 4];
// "Name tags only": other players' styles beside their names, nothing on their bodies.
let bodies = own || self.settings.extras.effective_others() == Others::Shown;
for item in worn.iter() {
if !bodies || covered(m, item.slot) {
let worn_items = worn.iter().flat_map(|w| w.iter()).map(|i| (i, None));
let emote_items = emote.map(|(i, t, w)| (i, Some((t, w))));
for (item, emoting) in worn_items.chain(emote_items) {
if emoting.is_none() && (!bodies || covered(m, item.slot)) {
continue;
}
let mut baked = self.cosmetics.baked.borrow_mut();
@@ -580,6 +796,14 @@ impl Game<'_> {
};
poses.reset(model);
item.animate(ticks, |name, pose| *poses.get(name) = pose);
if let (Some((t, w)), Some(e)) = (emoting, &item.emote) {
e.pose_props(t, |n| item.rest(n), |n, pose| *poses.get(n) = pose);
// Props grow in and shrink out with the emote's blend.
let root = poses.get(&model.name);
for s in &mut root[6..9] {
*s *= w;
}
}
if gpu {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
+27 -5
View File
@@ -2666,6 +2666,14 @@ impl Game<'_> {
// `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop
// above trampled farmland breaks and drops.
MobEvent::SetBlock { pos, state } => self.level_set(pos, state)?,
MobEvent::EndermanPlaceBlock {
mob,
pos,
carried,
occupants,
} => {
self.enderman_place_block(mob, pos, carried, &occupants)?;
}
MobEvent::CropRaided {
pos,
state,
@@ -3000,9 +3008,12 @@ impl Game<'_> {
horizon_client::mobs::happy_ghast::notify_leash_holder(m);
}
}
MobEvent::Vibration { from, to, ticks } => {
self.mob_vibration_particle(from, to, ticks)
}
MobEvent::Vibration {
from,
to,
ticks,
source,
} => self.mob_vibration_particle(source, from, to, ticks),
MobEvent::DigParticles { at } => self.warden_dig_particles(at),
MobEvent::BlockBreakEffect { pos, block } => {
// Turtle.aiStep broadcasts LevelEvent 2001, not the
@@ -5515,7 +5526,7 @@ impl Game<'_> {
/// crosshair or on the one a guest's interact packet named: returns true
/// when consumed.
pub(super) fn interact_mob(&mut self, target: Option<InteractTarget>) -> Result<bool> {
let Some((index, distance)) = (match target {
let Some((index, distance, _)) = (match target {
// The id the packet named, under the filters the crosshair pick
// applies (`mob_hit`): a mob in its death animation is not clicked
// by either path, so naming one in a packet must not reach the
@@ -5525,7 +5536,7 @@ impl Game<'_> {
let m = &self.mobs[i];
named_mob_pickable(m.alive(), m.id, self.riding, self.remote_camera_id())
})
.map(|i| (i, 0.0)),
.map(|i| (i, 0.0, [0.0; 3])),
None => self.mob_hit(),
}) else {
return Ok(false);
@@ -5543,7 +5554,13 @@ impl Game<'_> {
return Ok(false);
}
}
// PlayerInteractTrigger: the name tag as it was before it named the mob.
let used = self.held().cloned();
if self.name_mob(index) {
if let Some(stack) = &used {
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
}
return Ok(true);
}
if self
@@ -5678,6 +5695,11 @@ impl Game<'_> {
if !self.mobs[index].kind.food.contains(&item)
&& self.interact_sheep_dye(index, self.hand, false)
{
// PlayerInteractTrigger: the dye as it was before it coloured the sheep.
if let Some(stack) = &held {
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
}
self.swing_arm();
return Ok(true);
}
+1 -1
View File
@@ -802,7 +802,7 @@ impl Game<'_> {
"client"
};
if self.entity_targeted()
&& let Some((index, _)) = self.mob_hit()
&& let Some((index, _, _)) = self.mob_hit()
{
let (kind, position, id) = {
let m = &self.mobs[index];
@@ -499,7 +499,7 @@ impl Game<'_> {
};
offer(
self.mob_hit()
.map(|(i, d)| (d, self.mobs[i].kind.id.to_string())),
.map(|(i, d, _)| (d, self.mobs[i].kind.id.to_string())),
);
offer(
self.boat_hit()
@@ -511,6 +511,7 @@ fn debug_options_interrupts(screen: Screen, playing_overlay: bool) -> bool {
Screen::Inventory
| Screen::Creative
| Screen::GameModeSwitcher
| Screen::EmoteWheel
| Screen::Stats
| Screen::Advancements
| Screen::Friends
+1 -1
View File
@@ -109,7 +109,7 @@ impl Game<'_> {
let Some((i, t)) = self.redirectable_hit() else {
return false;
};
if self.mob_hit().is_some_and(|(_, d)| d < t) {
if self.mob_hit().is_some_and(|(_, d, _)| d < t) {
return false;
}
if let (Some(hit), Some(eye)) = (self.pick_unfiltered(), self.eye_position()) {
@@ -0,0 +1,216 @@
//! The emote wheel (`key.emote`, B by default; bindable on a controller): the player's emotes
//! around the middle of the screen. Hover one and release the key, click it, or press its
//! number; Escape or the key again closes it. The game doesn't pause and nothing is sent to the
//! game server.
use super::{Game, Screen};
use horizon_client::{hud::HudBatch, text::Text};
use std::time::{Duration, Instant};
use winit::event::{ElementState, MouseButton, WindowEvent};
use winit::keyboard::{KeyCode, PhysicalKey};
/// Emotes on the wheel.
pub(super) const SLOTS: usize = 8;
/// The ring, in GUI pixels from the middle; inside `INNER` nothing is hovered.
const INNER: f32 = 30.0;
const OUTER: f32 = 92.0;
/// Held at least this long, releasing the key plays what is hovered (or closes); a tap leaves the
/// wheel open.
const HOLD: Duration = Duration::from_millis(250);
#[derive(Default)]
pub(super) struct Wheel {
opened: Option<Instant>,
hovered: Option<usize>,
}
/// The slice under a point `(dx, dy)` from the middle (GUI pixels, y down) of a wheel of `n`:
/// slice 0 is straight up, then clockwise. None in the dead middle or past the ring.
pub(super) fn slice_at(dx: f32, dy: f32, n: usize) -> Option<usize> {
let r = dx.hypot(dy);
if n == 0 || r < INNER || r > OUTER * 1.6 {
return None;
}
let turn = std::f32::consts::TAU / n as f32;
// Clockwise from up, centred on each slice.
let angle = (dx.atan2(-dy) + turn / 2.0).rem_euclid(std::f32::consts::TAU);
Some(((angle / turn) as usize).min(n - 1))
}
/// A point at `radius` and `angle` (radians clockwise from up) around `(cx, cy)`.
fn at(cx: f32, cy: f32, radius: f32, angle: f32) -> [f32; 2] {
[cx + radius * angle.sin(), cy - radius * angle.cos()]
}
impl Game<'_> {
/// Opens the wheel from play.
pub(super) fn open_emote_wheel(&mut self) {
self.refresh_owned_emotes();
self.emote_wheel = Wheel {
opened: Some(Instant::now()),
hovered: None,
};
self.screen = Screen::EmoteWheel;
self.keys.clear();
self.left = false;
self.right = false;
self.mining = None;
self.capture(false);
}
/// Closes the wheel; `play`: the slice to play first.
pub(super) fn close_emote_wheel(&mut self, play: Option<usize>) {
self.emote_wheel = Wheel::default();
self.screen = Screen::Playing;
self.capture(true);
let id = play.and_then(|i| self.owned_emotes().get(i).map(|e| e.id.clone()));
if let Some(id) = id {
self.play_emote(&id);
}
}
/// The emote key let go: a held press plays what is hovered, a tap keeps the wheel open.
pub(super) fn emote_wheel_released(&mut self) {
let held = self
.emote_wheel
.opened
.is_some_and(|at| at.elapsed() >= HOLD);
if held || self.emote_wheel.hovered.is_some() {
self.close_emote_wheel(self.emote_wheel.hovered);
}
}
/// A window event while the wheel is open: the emote key let go, or a left click. True when
/// the wheel took it.
pub(super) fn emote_wheel_event(&mut self, event: &WindowEvent) -> bool {
match event {
WindowEvent::KeyboardInput { event, .. }
if event.state == ElementState::Released
&& matches!(event.physical_key, PhysicalKey::Code(key)
if self.settings.keys.translate(key) == Some(Ok(KeyCode::KeyB))) =>
{
self.emote_wheel_released();
true
}
WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Left,
..
} => {
self.emote_wheel_click();
true
}
_ => false,
}
}
/// A click on the wheel: plays the hovered emote, or closes.
pub(super) fn emote_wheel_click(&mut self) {
self.close_emote_wheel(self.emote_wheel.hovered);
}
pub(super) fn draw_emote_wheel(&mut self, batch: &mut HudBatch) {
horizon_client::widgets::begin_screen(|| {
horizon_client::widgets::ScreenInfo::new("emote_wheel", "horizon.emote_wheel.title")
});
let ids: Vec<String> = self
.owned_emotes()
.iter()
.take(SLOTS)
.map(|e| e.id.clone())
.collect();
let names: Vec<String> = ids.iter().map(|id| self.emote_name(id)).collect();
let (w, h) = (batch.width, batch.height);
let (cx, cy) = (w / 2.0, h / 2.0);
let n = names.len();
// The cursor is in GUI pixels, as the batch.
let (mx, my) = self.cursor;
self.emote_wheel.hovered = slice_at(mx - cx, my - cy, n);
let hovered = self.emote_wheel.hovered;
let atlas = &self.gui.atlas;
// The ring: each slice a fan of quads, the hovered one brighter.
let slices = n.max(1);
let turn = std::f32::consts::TAU / slices as f32;
let gap = 0.03;
for i in 0..slices {
let colour = match (n, hovered == Some(i)) {
(0, _) => 0x9010_1018,
(_, true) => 0xD0A9_8FFF,
_ => 0xA010_1018,
};
let from = i as f32 * turn - turn / 2.0 + gap;
let span = turn - 2.0 * gap;
for k in 0..6 {
let (a0, a1) = (
from + span * k as f32 / 6.0,
from + span * (k + 1) as f32 / 6.0,
);
batch.fill_quad(
atlas,
[
at(cx, cy, INNER, a0),
at(cx, cy, OUTER, a0),
at(cx, cy, OUTER, a1),
at(cx, cy, INNER, a1),
],
colour,
);
}
}
// Each slice's number and name, in the middle of its band.
for (i, name) in names.iter().enumerate() {
let [x, y] = at(cx, cy, (INNER + OUTER) / 2.0, i as f32 * turn);
let label = format!("{} {name}", i + 1);
let lw = HudBatch::text_width(atlas, &label);
let colour = if hovered == Some(i) {
u32::MAX
} else {
0xFFC8_C8D0
};
batch.text(atlas, &label, x - lw / 2.0, y - 4.0, colour, true);
}
let title = match (n, hovered) {
(0, _) => Text::translate("horizon.emote_wheel.empty", &[]),
(_, Some(i)) => Text::literal(&names[i]),
_ => Text::translate("horizon.emote_wheel.title", &[]),
};
let plain = title.plain();
let tw = HudBatch::text_width(atlas, &plain);
batch.text_styled(atlas, &title, cx - tw / 2.0, cy - 4.0, u32::MAX, true);
let help = Text::translate("horizon.emote_wheel.help", &[]);
let hw = HudBatch::text_width(atlas, &help.plain());
batch.text_styled(
atlas,
&help,
cx - hw / 2.0,
cy + OUTER + 10.0,
0xFFA0_A0A8,
true,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slices_go_clockwise_from_the_top_with_a_dead_middle() {
// Eight slices: up, up-right, right... each centred on its direction.
assert_eq!(slice_at(0.0, -60.0, 8), Some(0));
assert_eq!(slice_at(42.0, -42.0, 8), Some(1));
assert_eq!(slice_at(60.0, 0.0, 8), Some(2));
assert_eq!(slice_at(0.0, 60.0, 8), Some(4));
assert_eq!(slice_at(-60.0, 0.0, 8), Some(6));
// Just left of up is still the top slice; a bit more is the last.
assert_eq!(slice_at(-10.0, -60.0, 8), Some(0));
assert_eq!(slice_at(-35.0, -60.0, 8), Some(7));
// The middle and far away: nothing. No emotes: nothing.
assert_eq!(slice_at(5.0, 5.0, 8), None);
assert_eq!(slice_at(0.0, -400.0, 8), None);
assert_eq!(slice_at(0.0, -60.0, 0), None);
// Two: top and bottom halves.
assert_eq!(slice_at(50.0, -10.0, 2), Some(0));
assert_eq!(slice_at(50.0, 10.0, 2), Some(1));
}
}
+228
View File
@@ -0,0 +1,228 @@
//! Emotes ([docs/cosmetics.md](../../../../docs/cosmetics.md)): short animations of the player
//! model, played from the emote wheel. Visual only: the game protocol carries nothing, a game
//! server sees a player standing still. Moving, attacking, using an item, being hurt, riding,
//! sleeping, gliding or sneaking cancels one; the camera pulls back to the front view while it
//! plays (from first person only) and comes back when it ends.
use super::Game;
use super::perspective::CameraType;
use horizon_cosmetics::account::OwnedEmote;
use horizon_cosmetics::{Item, Playing, Slot};
use std::sync::Arc;
/// How far the player may drift (blocks) before the emote stops: a nudge, not a step.
const DRIFT: f64 = 0.01;
#[derive(Default)]
pub(super) struct Emotes {
/// The emotes the wheel offers, in order.
pub(super) owned: Vec<Arc<Item>>,
/// Their names by locale, by id (the service's), when known.
pub(super) names: std::collections::HashMap<String, std::collections::BTreeMap<String, String>>,
/// The account's emotes on their way from the service, and when they last came.
pub(super) loading: Option<std::sync::mpsc::Receiver<Result<Vec<OwnedEmote>, String>>>,
pub(super) loaded: Option<std::time::Instant>,
/// `owned` was read from `HORIZON_COSMETICS_LOCAL`'s folder.
local_read: bool,
playing: Option<Playing>,
/// The camera the emote pulled back from, put back when it ends (unless the player chose
/// another meanwhile).
camera_from: Option<CameraType>,
/// Where the player stood when it started.
from: Option<horizon_core::geometry::Vec3>,
}
impl Emotes {
/// The emote to tell the service about, while it plays: its id and whether it loops.
pub(super) fn sending(&self) -> Option<(String, bool)> {
let p = self.playing.as_ref().filter(|p| p.stop.is_none())?;
let looping = p.item.emote.as_ref().is_some_and(|e| e.looping);
Some((p.item.id.clone(), looping))
}
}
/// The emotes among a folder's item files, by id.
fn read_folder(dir: &std::path::Path) -> Vec<Arc<Item>> {
let mut emotes: Vec<Arc<Item>> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| Item::parse(&std::fs::read(e.path()).ok()?).ok())
.filter(|i| i.slot == Slot::Emote)
.map(Arc::new)
.collect();
emotes.sort_by(|a, b| a.id.cmp(&b.id));
emotes
}
impl Game<'_> {
/// The emotes the wheel offers: a local folder's (`HORIZON_COSMETICS_LOCAL`), else those the
/// account owns.
pub(super) fn owned_emotes(&mut self) -> &[Arc<Item>] {
if !self.emotes.local_read {
self.emotes.local_read = true;
if let Some(dir) = std::env::var_os("HORIZON_COSMETICS_LOCAL") {
self.emotes.owned = read_folder(std::path::Path::new(&dir));
}
}
&self.emotes.owned
}
/// An emote's name: the catalogue's, else its id in words (`wave_hello`: "Wave hello").
pub(super) fn emote_name(&self, id: &str) -> String {
let names = self.emotes.names.get(id);
let lang = horizon_client::lang::code();
if let Some(name) = names.and_then(|n| n.get(&lang).or_else(|| n.get("en_us"))) {
return name.clone();
}
let words = id.replace('_', " ");
let mut chars = words.chars();
chars
.next()
.map_or_else(String::new, |c| c.to_uppercase().chain(chars).collect())
}
/// Whether the player could start an emote now: in a world, standing, alive, not a spectator.
fn may_emote(&self) -> bool {
let Some(p) = self.player.as_ref() else {
return false;
};
horizon_cosmetics::enabled()
&& self.player_model.is_some()
&& !p.health.dead_or_dying()
&& !p.fall_flying
&& p.pose == horizon_client::pose::Pose::Standing
&& self.riding.is_none()
&& self.sleeping.is_none()
&& !self.spectator()
}
/// Plays the owned emote `id` (again from its start if it plays already). False if it isn't
/// owned or the player can't emote now.
pub(super) fn play_emote(&mut self, id: &str) -> bool {
let Some(item) = self.owned_emotes().iter().find(|i| i.id == id).cloned() else {
return false;
};
if !self.may_emote() {
return false;
}
let (Some(m), Some(p)) = (self.player_model.as_ref(), self.player.as_ref()) else {
return false;
};
let start = m.age as f32;
self.emotes.from = Some(p.position);
self.emotes.playing = Some(Playing {
item,
start,
stop: None,
});
if self.camera_type.first_person() {
self.emotes.camera_from = Some(self.camera_type);
self.camera_type = CameraType::ThirdPersonFront;
self.check_entity_post_effect(false);
}
true
}
/// Stops the emote playing, fading it out.
pub(super) fn stop_emote(&mut self) {
let now = self.player_model.as_ref().map_or(0.0, |m| m.age as f32);
if let Some(p) = self.emotes.playing.as_mut()
&& p.stop.is_none()
{
p.stop = Some(now);
}
}
/// The emote playing, if any (its id).
pub(super) fn emote_playing(&self) -> Option<&str> {
let p = self.emotes.playing.as_ref()?;
p.stop.is_none().then_some(p.item.id.as_str())
}
/// Once a tick after the player's model: cancels the emote on movement or action, ends it,
/// puts the camera back, and hands it to the model.
fn tick_emote(&mut self) {
if self.emotes.playing.is_none() {
if let Some(m) = self.player_model.as_mut() {
m.emote = None;
}
return;
}
let moved = match (self.player.as_ref(), self.emotes.from) {
(Some(p), Some(from)) => {
let (dx, dy, dz) = (
p.position.x - from.x,
p.position.y - from.y,
p.position.z - from.z,
);
dx * dx + dy * dy + dz * dz > DRIFT * DRIFT
}
_ => true,
};
let hurt = self
.player
.as_ref()
.is_some_and(|p| p.health.hurt_animation_time > 0);
if moved || hurt || self.swing.swinging() || self.using.is_some() || !self.may_emote() {
self.stop_emote();
}
let now = self.player_model.as_ref().map_or(0.0, |m| m.age as f32);
if self
.emotes
.playing
.as_ref()
.and_then(|p| p.at(now))
.is_none()
{
self.emotes.playing = None;
self.emotes.from = None;
// The pulled-back view goes back to first person, unless the player turned it.
if let Some(from) = self.emotes.camera_from.take()
&& self.camera_type == CameraType::ThirdPersonFront
{
self.camera_type = from;
self.check_entity_post_effect(from.first_person());
}
}
if let Some(m) = self.player_model.as_mut() {
m.emote = self.emotes.playing.clone();
}
}
/// Once a tick: my emote, then the other players'.
pub(super) fn tick_emotes(&mut self) {
if let Some(done) = self
.emotes
.loading
.as_ref()
.and_then(|rx| rx.try_recv().ok())
{
self.emotes.loading = None;
if let Ok(owned) = done {
self.emotes.loaded = Some(std::time::Instant::now());
self.emotes.names.clear();
self.emotes.owned.clear();
for (item, names) in owned {
self.emotes.names.insert(item.id.clone(), names);
self.emotes.owned.push(Arc::new(item));
}
}
}
self.tick_emote();
self.tick_others_emotes();
}
/// How far the camera stands back while an emote pulls it (1: the usual distance): it eases
/// out over the emote's first quarter second.
pub(super) fn emote_camera_pull(&self, partial: f32) -> f32 {
let (Some(p), Some(m)) = (&self.emotes.playing, &self.player_model) else {
return 1.0;
};
if self.emotes.camera_from.is_none() {
return 1.0;
}
let t = ((m.age as f32 + partial - p.start) / 5.0).clamp(0.0, 1.0);
0.35 + 0.65 * t * t * (3.0 - 2.0 * t)
}
}
@@ -0,0 +1,70 @@
//! Enderman leave-block attempts need the authoritative block simulation.
use super::Game;
use horizon_client::{Result, mobs::Species};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
};
use horizon_world::{
level::{LevelAccess, flags},
registry::BlockStateId,
};
impl Game<'_> {
/// `EndermanLeaveBlockGoal.tick`: normalize, check, place, then clear.
pub(super) fn enderman_place_block(
&mut self,
mob: u32,
pos: [i32; 3],
carried: BlockStateId,
occupants: &[Aabb],
) -> Result<()> {
// Runtime ids identify the actual mob in this level. An obsolete attempt
// must not clear another mob's hand or a subsequently changed stack.
if !self.mobs.iter().any(|m| {
m.id == mob
&& m.alive()
&& m.species() == Species::Enderman
&& m.extra.carried == Some(carried)
}) {
return Ok(());
}
let at = BlockPos::new(pos[0], pos[1], pos[2]);
let area = Aabb::new(
Vec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])),
Vec3::new(
f64::from(pos[0] + 1),
f64::from(pos[1] + 1),
f64::from(pos[2] + 1),
),
);
let placed = self.with_level(|b, level, ticks| {
let target = level.block_state(at);
let below = level.block_state(at.offset(0, -1, 0));
// Must run even when eligibility fails: updateShape can schedule
// ticks and invoke native hooks on the real level.
let state = b.update_from_neighbour_shapes(level, ticks, at, carried);
let support = level.facts.get(below);
if !level.facts.is_air(target)
|| level.facts.is_air(below)
|| &*support.name == "minecraft:bedrock"
|| !support.full_collision
|| occupants.iter().any(|bounds| bounds.intersects(area))
{
return false;
}
// AIR is a legitimate normalized state. setBlock may report no
// change for it; the eligible operation still empties the hand.
b.set_block(level, ticks, at, state, flags::UPDATE_ALL);
true
});
if placed
&& let Some(m) = self.mobs.iter_mut().find(|m| {
m.id == mob && m.species() == Species::Enderman && m.extra.carried == Some(carried)
})
{
m.extra.carried = None;
}
self.process_level_events()
}
}
@@ -0,0 +1,183 @@
//! Exercise the actual registered leave-block goal and Game event handler.
use super::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::{
chunk::Chunk,
mobs::{Mob, MobEvent, goals::Cx},
};
use horizon_core::{geometry::Vec3, position::ChunkPos, random::LegacyRandom};
use std::sync::Arc;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn leave_block_goal_uses_real_neighbor_shapes_and_keeps_air_placement() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = super::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
let registry = game.pack.assets.registry;
let air = registry.default_state("minecraft:air").unwrap();
let stone = registry.default_state("minecraft:stone").unwrap();
let kind = game
.res
.mob_types()
.get("minecraft:enderman")
.unwrap()
.clone();
for (name, expected_name) in [("poppy", "air"), ("note_block", "note_block")] {
for x in 0..8 {
for z in 0..8 {
for y in 3..7 {
game.world
.set(x, y, z, if y == 3 { stone } else { air })
.unwrap();
}
}
}
game.mobs.clear();
let carried = registry
.default_state(&format!("minecraft:{name}"))
.unwrap();
let mut mob = Mob::new(325, kind.clone(), Vec3::new(3.5, 4.0, 3.5), 0.0);
mob.random = LegacyRandom::new(325);
mob.extra.carried = Some(carried);
game.mobs.push(mob);
for _ in 0..48_000 {
let mut mob = game.mobs.pop().unwrap();
let mut goals = std::mem::take(&mut mob.ai.goals);
let snapshot = [super::creatures::seen(&mob, None)];
let mut events = Vec::<MobEvent>::new();
let mut lr = game.world.random.clone();
{
let world = game.mob_world_base(&snapshot);
goals.tick(
&mut mob,
&mut Cx {
w: &world,
lr: &mut lr,
events: &mut events,
},
);
}
game.world.random = lr;
mob.ai.goals = goals;
game.mobs.push(mob);
game.handle_mob_events(events).unwrap();
if game.mobs[0].extra.carried.is_none() {
break;
}
}
assert_eq!(
game.mobs[0].extra.carried, None,
"eligible {name} must empty its hand"
);
let mut states = Vec::new();
for x in 0..8 {
for z in 0..8 {
for y in 4..7 {
let state = game.world.block(x, y, z);
if state != air {
states.push(registry.state(state).unwrap());
}
}
}
}
if expected_name == "air" {
assert!(
states.is_empty(),
"poppy normalizes to AIR before placement, not a dropped unsupported flower"
);
} else {
assert_eq!(states.len(), 1);
assert_eq!(states[0].name, "minecraft:note_block");
assert_eq!(
states[0].properties.get("instrument").map(String::as_str),
Some("basedrum")
);
}
}
// A waterlogged candidate schedules the real level's fluid tick even
// when a preceding world write makes the placement ineligible.
let fence = registry.default_state("minecraft:oak_fence").unwrap();
let mut descriptor = registry.state(fence).unwrap().clone();
descriptor
.properties
.insert("waterlogged".into(), "true".into());
let fence = registry.resolve(&descriptor).unwrap();
let mut mob = Mob::new(326, kind, Vec3::new(3.5, 4.0, 3.5), 0.0);
mob.random = LegacyRandom::new(325);
mob.extra.carried = Some(fence);
game.mobs.clear();
game.mobs.push(mob);
let mut attempt = None;
for _ in 0..48_000 {
let mut mob = game.mobs.pop().unwrap();
let mut goals = std::mem::take(&mut mob.ai.goals);
let snapshot = [super::creatures::seen(&mob, None)];
let mut events = Vec::new();
let mut lr = game.world.random.clone();
goals.tick(
&mut mob,
&mut Cx {
w: &game.mob_world_base(&snapshot),
lr: &mut lr,
events: &mut events,
},
);
game.world.random = lr;
mob.ai.goals = goals;
game.mobs.push(mob);
if let Some(event) = events
.into_iter()
.find(|e| matches!(e, MobEvent::EndermanPlaceBlock { .. }))
{
attempt = Some(event);
break;
}
}
let attempt = attempt.expect("the registered leave goal emits an attempt");
let MobEvent::EndermanPlaceBlock { pos, mob, .. } = &attempt else {
unreachable!()
};
assert_eq!(*mob, 326);
game.world.set(pos[0], pos[1], pos[2], stone).unwrap();
let before = game.block_ticks.fluids.count();
// A stale carried-state identity cannot mutate the world or clear a new hand.
game.mobs[0].extra.carried = Some(stone);
game.handle_mob_events(vec![attempt.clone()]).unwrap();
assert_eq!(game.block_ticks.fluids.count(), before);
assert_eq!(game.mobs[0].extra.carried, Some(stone));
game.mobs[0].extra.carried = Some(fence);
game.handle_mob_events(vec![attempt.clone()]).unwrap();
assert!(game.block_ticks.fluids.count() > before);
assert_eq!(game.world.block(pos[0], pos[1], pos[2]), stone);
assert_eq!(game.mobs[0].extra.carried, Some(fence));
}
+12 -2
View File
@@ -263,7 +263,10 @@ impl Game<'_> {
/// block within the block reach (`getHitEntitiesAlong`'s block result,
/// kept by `filterHitResult`); `None` on a miss, where the plain pick
/// decides.
fn attack_range_pick(&self, range: &serde_json::Value) -> Option<Option<(usize, f64)>> {
fn attack_range_pick(
&self,
range: &serde_json::Value,
) -> Option<Option<(usize, f64, [f64; 3])>> {
let eye = v3(self.eye_position()?);
let look = v3(super::view_vector(self.yaw, self.pitch));
let reach = attack_reach(range, self.creative);
@@ -283,7 +286,14 @@ impl Game<'_> {
let movement = self.known_player_movement();
let nearest = super::spear::hits_in_reach(&boxes, eye, look, movement, reach, clip)
.into_iter()
.map(|(i, at)| (i, eye.distance_squared(at).sqrt()))
.map(|(i, at)| {
let feet = self.mobs[i].position;
(
i,
eye.distance_squared(at).sqrt(),
super::interact_location(at, feet),
)
})
.reduce(|a, b| if b.1 < a.1 { b } else { a });
if nearest.is_some() {
return Some(nearest);
+5 -16
View File
@@ -1434,9 +1434,6 @@ impl Game<'_> {
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
// A block's or a double chest's menu counts among the openers of its
// lid (a vehicle keeps its own `Container`, an ender chest has none).
let counts_openers = matches!(slots, Slots::Block(_) | Slots::Double(_, _));
let hidden = self.native_hidden_slots(pos, items.len());
// A mod menu type's block tells the client where it is, with what its
// mod writes (`openMenu` with an extra-data writer); a stock menu
@@ -1463,19 +1460,11 @@ impl Game<'_> {
if !hidden.is_empty() {
host.hub.hide_menu_slots(conn, hidden);
}
// `ContainerOpenersCounter.incrementOpeners` (`ChestBlockEntity.startOpen`,
// `ShulkerBoxBlockEntity.startOpen`): the lid opens for the guest, and
// when this is the first opener the open sound plays for everyone
// around, the opener included (`playSound(null, …)`, which the capture
// relay sends on to the guests). Without it the host's copy never
// learned of the open, so a guest's box opened silently.
// A spectator never joins the openers (`startOpen` returns on
// `player.isSpectator()`): its menu opens but the lid stays shut, no
// sound plays, and `stopOpen` has nothing to decrement, which is why
// the count must not be raised here either (#8517).
if counts_openers && !spectator && self.container_openers_changed(pos, true) {
self.container_edge_sound(pos, true);
}
// The opener count was already raised by the guest's use of the block
// (`open_menu`, through `BlockEntity.triggerEvent`/`startOpen`); raising
// it again here counted one player twice. Only the last closer would
// then reach zero and `stopOpen`, so a shulker box stayed open forever
// (#9500).
true
}
+124 -71
View File
@@ -382,7 +382,7 @@ impl Game<'_> {
/// `ParticleTypes.STREAM_CODEC`'s value for `options`: the type's
/// network id and the data it carries (`state` for the block particles).
/// None while not serving, or for a particle whose data isn't sent from
/// here (items, vibrations, trails).
/// here (items, trails).
pub(super) fn network_particle(
&self,
options: &ParticleOptions,
@@ -432,7 +432,11 @@ impl Game<'_> {
let (water_blocks, burst_impulse_base) = options.geyser?;
Some(ParticleData::GeyserBase(water_blocks, burst_impulse_base))
}
T::Item | T::Trail | T::Vibration => return None,
T::Vibration => {
let (source, _, ticks) = options.vibration.clone()?;
Some(ParticleData::Vibration { source, ticks })
}
T::Item | T::Trail => return None,
_ => None,
};
Some(Particle { id, data })
@@ -493,9 +497,11 @@ impl Game<'_> {
Some(ParticleData::Trail(target, color, duration)) => {
ParticleOptions::trail(*target, *color as u32 & 0xFF_FFFF, *duration)
}
Some(ParticleData::Vibration { source, ticks }) => {
ParticleOptions::vibration(self.vibration_destination(source)?, *ticks)
}
Some(ParticleData::Vibration { source, ticks }) => ParticleOptions::vibration(
source.clone(),
self.vibration_destination(source)?,
*ticks,
),
Some(_) => return None,
})
}
@@ -911,18 +917,25 @@ mod tests {
);
}
/// `ShulkerBullet.hurtServer`: fifteen crit particles (spread 0.2, speed 0.0)
/// are sent to tracking players within 32 blocks.
#[test]
fn hitting_shulker_bullet_sends_15_crit_particles_to_players() {
/// A hub whose `minecraft:particle_type` registry holds `particle_names`
/// in registry order, plus a near guest at (5, 64, 5) and a far one at
/// (100, 64, 100).
fn test_hub(
label: &str,
particle_names: Vec<String>,
) -> (
Hub,
std::sync::mpsc::Receiver<Out>,
std::sync::mpsc::Receiver<Out>,
) {
let config = NetConfig {
announce: false,
..NetConfig::loopback(0, "ShulkerBulletTest".into(), 2)
..NetConfig::loopback(0, label.into(), 2)
};
let mut sync = data::SyncData::empty();
sync.builtin.registries.insert(
"minecraft:particle_type".to_string(),
horizon_protocol::registry::Ids::from_names(vec!["minecraft:crit".to_string()]),
horizon_protocol::registry::Ids::from_names(particle_names),
);
let sync = std::sync::Arc::new(sync);
let lan = Lan::start_with(sync.clone(), config).unwrap();
@@ -948,49 +961,59 @@ mod tests {
};
let mut hub = Hub::new(lan, sync, encoder, hub_config);
let guest = |conn, uuid, name: &str, at, tx| {
let mut g = Guest::new(
Joined {
conn,
uuid,
name: name.into(),
properties: Vec::new(),
info: horizon_protocol::packets::configuration::ClientInformation::new(8),
brand: "vanilla".into(),
sender: PlayerSender::new(tx, std::sync::Arc::default()),
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
address: format!("127.0.0.1:{}", 25565 + conn).parse().unwrap(),
expected: false,
},
1,
at,
8,
);
g.placed = true;
g.dimension = "minecraft:overworld".into();
g
};
let (tx_near, rx_near) = std::sync::mpsc::channel();
let mut near = Guest::new(
Joined {
conn: 1,
uuid: 1,
name: "NearPlayer".into(),
properties: Vec::new(),
info: horizon_protocol::packets::configuration::ClientInformation::new(8),
brand: "vanilla".into(),
sender: PlayerSender::new(tx_near, std::sync::Arc::default()),
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
},
1,
[5.0, 64.0, 5.0],
8,
);
near.placed = true;
near.dimension = "minecraft:overworld".into();
hub.guests.push(near);
hub.guests
.push(guest(1, 1, "NearPlayer", [5.0, 64.0, 5.0], tx_near));
let (tx_far, rx_far) = std::sync::mpsc::channel();
let mut far = Guest::new(
Joined {
conn: 2,
uuid: 2,
name: "FarPlayer".into(),
properties: Vec::new(),
info: horizon_protocol::packets::configuration::ClientInformation::new(8),
brand: "vanilla".into(),
sender: PlayerSender::new(tx_far, std::sync::Arc::default()),
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
address: "127.0.0.1:25566".parse().unwrap(),
expected: false,
},
2,
[100.0, 64.0, 100.0],
8,
);
far.placed = true;
far.dimension = "minecraft:overworld".into();
hub.guests.push(far);
hub.guests
.push(guest(2, 2, "FarPlayer", [100.0, 64.0, 100.0], tx_far));
(hub, rx_near, rx_far)
}
/// The one `ClientboundLevelParticlesPacket` a guest receives.
fn level_particles(rx: &std::sync::mpsc::Receiver<Out>) -> play::LevelParticles {
let out = rx
.try_recv()
.expect("guest should receive a LevelParticles packet");
let body = match out {
Out::Packet(body) => body,
_ => panic!("expected Out::Packet"),
};
let mut r = horizon_protocol::buf::Reader::new(&body);
let id = r.varint().unwrap();
match play::Clientbound::decode(id, r.rest()).unwrap() {
play::Clientbound::LevelParticles(p) => p,
other => panic!("expected LevelParticles packet, got {other:?}"),
}
}
/// `ShulkerBullet.hurtServer`: fifteen crit particles (spread 0.2, speed 0.0)
/// are sent to tracking players within 32 blocks.
#[test]
fn hitting_shulker_bullet_sends_15_crit_particles_to_players() {
let (hub, rx_near, rx_far) = test_hub("ShulkerBulletTest", vec!["minecraft:crit".into()]);
let at = [0.0, 64.0, 0.0];
let options = ParticleOptions::simple(ParticleType::from_name("crit").unwrap());
@@ -1001,25 +1024,55 @@ mod tests {
"far guest should not receive particles"
);
let out = rx_near
.try_recv()
.expect("near guest should receive LevelParticles packet");
let body = match out {
Out::Packet(body) => body,
_ => panic!("expected Out::Packet"),
let p = level_particles(&rx_near);
assert_eq!(p.count, 15);
assert_eq!(p.dist, [0.2, 0.2, 0.2]);
assert_eq!(p.max_speed, [0.0; 3]);
assert_eq!(p.pos, at);
assert_eq!(p.particle.id, 0);
}
/// `VibrationSystem.Ticker.trySelectAndScheduleVibration`: the one packet
/// is at the vibration's origin, count 1, no spread or speed, and carries
/// the listener's `PositionSource` and the arrival time in ticks. The
/// block listeners send a block position; the warden and allay send
/// themselves (`EntityPositionSource`).
#[test]
fn vibration_particles_carry_their_listener_and_arrival_ticks() {
// `Particle::read` reads a vibration's data from its pinned id, 58.
let mut names = vec!["minecraft:empty".to_string(); 58];
names.push("minecraft:vibration".to_string());
let (hub, rx_near, rx_far) = test_hub("VibrationTest", names);
let source = PositionSource::Block([3, 64, -7]);
let at = [1.25, 64.0, 3.75];
let options = ParticleOptions::vibration(source.clone(), [3.5, 64.5, -6.5], 14);
send_hub_level_particles(&hub, &options, at, [0.0; 3], 0.0, 1, None);
assert!(rx_far.try_recv().is_err(), "only players within 32 blocks");
let p = level_particles(&rx_near);
assert_eq!(p.count, 1, "one signal");
assert_eq!(p.dist, [0.0; 3]);
assert_eq!(p.max_speed, [0.0; 3]);
assert_eq!(p.randomization, 0, "DEFAULT");
assert_eq!(p.pos, at);
assert_eq!(p.particle.id, 58);
assert_eq!(
p.particle.data,
Some(ParticleData::Vibration { source, ticks: 14 })
);
// A listener entity travels as `EntityPositionSource`: its id and the
// offset to its eyes.
let source = PositionSource::Entity {
id: 42,
y_offset: 1.15,
};
let mut r = horizon_protocol::buf::Reader::new(&body);
let id = r.varint().unwrap();
let packet = play::Clientbound::decode(id, r.rest()).unwrap();
match packet {
play::Clientbound::LevelParticles(p) => {
assert_eq!(p.count, 15);
assert_eq!(p.dist, [0.2, 0.2, 0.2]);
assert_eq!(p.max_speed, [0.0; 3]);
assert_eq!(p.pos, at);
assert_eq!(p.particle.id, 0);
}
other => panic!("expected LevelParticles packet, got {other:?}"),
}
let options = ParticleOptions::vibration(source.clone(), [0.5, 65.0, 0.5], 7);
let particle = Game::hub_particle(&hub, &options, None).expect("a vibration particle");
assert_eq!(
particle.data,
Some(ParticleData::Vibration { source, ticks: 7 })
);
}
}
@@ -88,7 +88,11 @@ fn drain_take(level: u8, amount: i32) -> (i32, u8) {
/// (`CauldronFluids`). An empty cauldron is fillable; powder snow is not a
/// fluid (`FluidKind` has none), so a powder snow cauldron holds none.
enum FluidAt {
Mod(Arc<ModFluidTanks>),
/// A mod block's tanks, and its block id (`mymod:tank`).
Mod {
tanks: Arc<ModFluidTanks>,
block: String,
},
Cauldron(CauldronFluids),
}
#[derive(Clone, Copy)]
@@ -153,6 +157,61 @@ fn write_tanks(nbt: &mut Compound, tanks: &[Tank]) {
nbt.insert(JavaString::from("fluids"), Tag::List(list));
}
/// Fills `state` from `amount` of `fluid`: first the tanks holding it, then
/// empty ones, by the side `reached` and only where `accepts` lets it in
/// (`native_block_entity_accepts_fluid`); what did not fit. The choice is
/// made again in both passes, as `HopperBlockEntity.addItem` tries its slots.
fn fill_tanks(
tanks: &ModFluidTanks,
state: &mut [Tank],
reached: &[usize],
fluid: &str,
amount: i32,
accepts: impl Fn(usize) -> bool,
) -> i32 {
let n = tanks.tanks.len();
let mut left = amount.max(0);
for pass in 0..2 {
for &tank in reached {
if left == 0 || !tanks.inputs.contains(&tank) || tank >= n {
continue;
}
if !accepts(tank) {
continue;
}
let entry = &mut state[tank];
let fits = match pass {
0 => entry.0.as_deref() == Some(fluid),
_ => entry.0.is_none(),
};
if !fits {
continue;
}
let room = tanks.tanks[tank] - entry.1;
if room <= 0 {
continue;
}
let add = room.min(left);
entry.1 += add;
left -= add;
entry.0 = Some(fluid.to_owned());
}
}
left
}
/// How much of `fluid` the side `tanks` names has room for: the fillable
/// tanks holding it or none, and only where `accepts` lets it in
/// (`native_block_entity_accepts_fluid`).
fn tank_room(tanks: &[NativeFluidTank], fluid: &str, accepts: impl Fn(usize) -> bool) -> i32 {
tanks
.iter()
.filter(|t| t.can_fill && t.fluid.as_deref().is_none_or(|f| f == fluid) && accepts(t.tank))
.fold(0_i32, |room, t| {
room.saturating_add((t.capacity - t.amount).max(0))
})
}
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
@@ -166,12 +225,16 @@ impl Game<'_> {
let key = (pos[0], pos[1], pos[2]);
let state = self.world.block(pos[0], pos[1], pos[2]);
if let Some(tanks) = self.world.block_entities.mod_fluids(state.0) {
let block = self.pack.assets.registry.state(state)?.name.to_string();
// The block entity is out of the world while it ticks.
return matches!(
self.world.block_entities.get(&key),
Some(horizon_client::containers::BlockEntity::Mod(m)) if !m.id.is_empty()
)
.then(|| FluidAt::Mod(Arc::clone(tanks)));
.then(|| FluidAt::Mod {
tanks: Arc::clone(tanks),
block,
});
}
let info = self.pack.assets.registry.state(state)?;
match &*info.name {
@@ -211,13 +274,23 @@ impl Game<'_> {
Err(_) => false,
}
}
/// Whether tank `tank` of the mod block `block` takes `fluid`: a mod's
/// answer to `native_block_entity_accepts_fluid`, true where none
/// refuses it.
fn native_fluid_accepts(&self, block: &str, tank: usize, fluid: &str) -> bool {
let mut cancel = false;
self.native_block_entity_accepts_fluid(block, tank, fluid, &mut cancel);
!cancel
}
/// `native_fluid_insert`: up to `amount` millibuckets of `fluid`
/// (`minecraft:water`, `minecraft:lava`, or since API
/// @API:mod_fluids@ a mod's own, `create:chocolate`) into the block at
/// `pos`, through its side `face`, in the level in place: a mod block's
/// tanks first those with the same fluid, then empty ones, by the sides
/// and tanks its table gave; a cauldron by thirds of a bucket, a lava one
/// only whole, and none of a mod's fluid. What did not fit. API 54.
/// and tanks its table gave and every fillable tank that accepts the
/// fluid (`native_block_entity_accepts_fluid`); a cauldron by thirds of a
/// bucket, a lava one only whole, and none of a mod's fluid. What did not
/// fit. API 54.
pub(in crate::game) fn native_fluid_insert(
&mut self,
pos: [i32; 3],
@@ -232,7 +305,7 @@ impl Game<'_> {
return amount;
}
match self.native_fluid_at(self.dim.id(), pos) {
Some(FluidAt::Mod(tanks)) => {
Some(FluidAt::Mod { tanks, block }) => {
let key = (pos[0], pos[1], pos[2]);
let n = tanks.tanks.len();
let reached = tanks.faces[face as usize].clone();
@@ -244,32 +317,9 @@ impl Game<'_> {
};
read_tanks(&m.nbt, n)
};
let mut left = amount.max(0);
// First into tanks holding the same fluid, then into empty
// ones, as `HopperBlockEntity.addItem` tries its slots.
for pass in 0..2 {
for &tank in &reached {
if left == 0 || !tanks.inputs.contains(&tank) || tank >= n {
continue;
}
let entry = &mut state[tank];
let fits = match pass {
0 => entry.0.as_deref() == Some(fluid),
_ => entry.0.is_none(),
};
if !fits {
continue;
}
let room = tanks.tanks[tank] - entry.1;
if room <= 0 {
continue;
}
let add = room.min(left);
entry.1 += add;
left -= add;
entry.0 = Some(fluid.to_owned());
}
}
let left = fill_tanks(&tanks, &mut state, &reached, fluid, amount, |tank| {
self.native_fluid_accepts(&block, tank, fluid)
});
if left < amount {
let Some(horizon_client::containers::BlockEntity::Mod(m)) =
self.world.block_entities.get_mut(&key)
@@ -323,7 +373,7 @@ impl Game<'_> {
face: Direction,
) -> Option<Vec<NativeFluidTank>> {
match self.native_fluid_at(dimension, pos)? {
FluidAt::Mod(tanks) => {
FluidAt::Mod { tanks, .. } => {
let key = (pos[0], pos[1], pos[2]);
let state = {
let Some(horizon_client::containers::BlockEntity::Mod(m)) =
@@ -399,7 +449,7 @@ impl Game<'_> {
return None;
}
match self.native_fluid_at(dimension, pos)? {
FluidAt::Mod(tanks) => {
FluidAt::Mod { tanks, .. } => {
let key = (pos[0], pos[1], pos[2]);
let n = tanks.tanks.len();
let mut state = {
@@ -469,8 +519,9 @@ impl Game<'_> {
/// into the source, so none is lost — a cauldron's whole thirds included.
/// The fluid and how much moved; none where `native_fluid_tanks` is none
/// at `from`, where nothing drainable is, where no block holds fluids at
/// `to` or it has no room for that fluid, and within one block. The
/// source is asked for no more than the target's room. API 55.
/// `to` or it has no room for that fluid (a tank that refuses it,
/// `native_block_entity_accepts_fluid`, has none), and within one block.
/// The source is asked for no more than the target's room. API 55.
pub(in crate::game) fn native_move_fluids(
&mut self,
dimension: &str,
@@ -523,23 +574,25 @@ impl Game<'_> {
.into_iter()
.find(|t| t.can_drain && t.amount > 0)?
.fluid?;
let block = match self.native_fluid_at(dimension, to)? {
FluidAt::Mod { block, .. } => Some(block),
FluidAt::Cauldron(_) => None,
};
// A cauldron reports itself fillable, but holds only vanilla's fluids:
// no room for a mod's, so a move answers none rather than draining
// and putting it all back.
if matches!(
self.native_fluid_at(dimension, to),
Some(FluidAt::Cauldron(_))
) && !matches!(fluid.as_str(), "minecraft:water" | "minecraft:lava")
{
if block.is_none() && !matches!(fluid.as_str(), "minecraft:water" | "minecraft:lava") {
return Some(0);
}
let room = self
.native_fluid_tanks(dimension, to, to_face)?
.iter()
.filter(|t| t.can_fill && t.fluid.as_deref().is_none_or(|f| f == fluid))
.fold(0_i32, |room, t| {
room.saturating_add((t.capacity - t.amount).max(0))
});
let room = tank_room(
&self.native_fluid_tanks(dimension, to, to_face)?,
&fluid,
|tank| {
block
.as_deref()
.is_none_or(|block| self.native_fluid_accepts(block, tank, &fluid))
},
);
Some(room)
}
/// Puts `amount` of `fluid` back into the tanks `receipt` names, drained
@@ -553,7 +606,7 @@ impl Game<'_> {
mut amount: i32,
receipt: &[(usize, i32)],
) -> bool {
let Some(FluidAt::Mod(tanks)) = self.native_fluid_at(dimension, pos) else {
let Some(FluidAt::Mod { tanks, .. }) = self.native_fluid_at(dimension, pos) else {
return false;
};
let key = (pos[0], pos[1], pos[2]);
@@ -709,6 +762,212 @@ mod tests {
assert_eq!(back, held);
}
fn tank_table(capacities: &[i32], inputs: &[usize]) -> ModFluidTanks {
ModFluidTanks {
tanks: capacities.to_vec(),
inputs: inputs.to_vec(),
..Default::default()
}
}
#[test]
fn a_refused_tank_takes_no_fluid_in_either_insert_pass() {
let table = tank_table(&[4000, 4000], &[0, 1]);
// Pass 0 tops up a tank already holding the fluid; refused, it is
// left exactly as it was.
let mut state = tanks(&[(Some("create:chocolate"), 1000), (None, 0)]);
assert_eq!(
fill_tanks(&table, &mut state, &[0], "create:chocolate", 500, |_| false),
500
);
assert_eq!(state[0], (Some("create:chocolate".to_owned()), 1000));
// Pass 1 fills an empty tank; refused, it stays empty.
let mut state = tanks(&[(None, 0), (None, 0)]);
assert_eq!(
fill_tanks(&table, &mut state, &[1], "create:chocolate", 500, |_| false),
500
);
assert_eq!(state[1], (None, 0));
// Only the refusing tank is skipped: the other still takes the fluid.
let mut state = tanks(&[(Some("create:chocolate"), 1000), (None, 0)]);
assert_eq!(
fill_tanks(
&table,
&mut state,
&[0, 1],
"create:chocolate",
500,
|tank| { tank == 1 }
),
0
);
assert_eq!(
state,
tanks(&[
(Some("create:chocolate"), 1000),
(Some("create:chocolate"), 500),
])
);
}
#[test]
fn every_tank_takes_every_fluid_unless_it_refuses_it() {
let table = tank_table(&[4000, 4000], &[0, 1]);
let mut state = tanks(&[(None, 0), (None, 0)]);
assert_eq!(
fill_tanks(
&table,
&mut state,
&[0, 1],
"create:chocolate",
1000,
|_| true
),
0
);
assert_eq!(state[0], (Some("create:chocolate".to_owned()), 1000));
assert_eq!(state[1], (None, 0));
}
#[test]
fn a_move_finds_room_only_in_tanks_that_accept_the_fluid() {
let tank = NativeFluidTank {
tank: 0,
fluid: None,
amount: 1000,
capacity: 4000,
can_fill: true,
can_drain: true,
};
// A tank that refuses the fluid has no room, so `native_move_fluids`
// asks its source for nothing rather than draining and putting it all
// back.
assert_eq!(
tank_room(std::slice::from_ref(&tank), "create:chocolate", |_| false),
0
);
assert_eq!(tank_room(&[tank], "create:chocolate", |_| true), 3000);
}
/// The hook the game asks (`native_block_entity_accepts_fluid`) through
/// the same public dispatch `Game::native_fluid_accepts` calls: a water
/// tank refuses a mod's fluid, so neither insert pass fills it and it
/// offers no room to `native_move_fluids`; water still goes in, and with
/// no mod every tank takes every fluid.
#[test]
fn a_hook_that_refuses_a_fluid_leaves_both_passes_empty_and_offers_no_room() {
use horizon_client::mod_api::{
BlockEntityAcceptsFluidEvent, Flow, Hooks, Host, dispatch_block_entity_accepts_fluid,
};
struct TestHost;
impl Host for TestHost {}
struct WaterTank;
impl Hooks for WaterTank {
fn block_entity_accepts_fluid(
&self,
_host: &dyn Host,
event: &mut BlockEntityAcceptsFluidEvent<'_>,
) -> Flow {
if event.block == "mymod:water_tank" && event.fluid != "minecraft:water" {
Flow::Cancel
} else {
Flow::Continue
}
}
}
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(WaterTank)];
let accepts = |tank: usize| {
let mut cancel = false;
dispatch_block_entity_accepts_fluid(
&TestHost,
&mods,
"mymod:water_tank",
tank,
"create:chocolate",
&mut cancel,
);
!cancel
};
let table = tank_table(&[4000, 4000], &[0, 1]);
// Pass 0 tops up a tank already holding the fluid: refused, it keeps
// exactly what it had.
let mut state = tanks(&[(Some("create:chocolate"), 1000), (None, 0)]);
assert_eq!(
fill_tanks(&table, &mut state, &[0], "create:chocolate", 500, accepts),
500
);
assert_eq!(state[0], (Some("create:chocolate".to_owned()), 1000));
// Pass 1 fills an empty tank: refused, it stays empty.
let mut state = tanks(&[(None, 0), (None, 0)]);
assert_eq!(
fill_tanks(&table, &mut state, &[1], "create:chocolate", 500, accepts),
500
);
assert_eq!(state[1], (None, 0));
// `native_move_fluids` counts no room in the tank that refuses it, so
// it asks its source for nothing.
let tank = NativeFluidTank {
tank: 0,
fluid: None,
amount: 1000,
capacity: 4000,
can_fill: true,
can_drain: true,
};
assert_eq!(
tank_room(std::slice::from_ref(&tank), "create:chocolate", accepts),
0
);
// The same tank takes water (the hook lets it through) and, with no
// mod at all, every fluid: existing mods are unchanged.
let water = |tank: usize| {
let mut cancel = false;
dispatch_block_entity_accepts_fluid(
&TestHost,
&mods,
"mymod:water_tank",
tank,
"minecraft:water",
&mut cancel,
);
!cancel
};
assert_eq!(
tank_room(std::slice::from_ref(&tank), "minecraft:water", water),
3000
);
let none: [Box<dyn Hooks>; 0] = [];
let no_mod = |tank: usize| {
let mut cancel = false;
dispatch_block_entity_accepts_fluid(
&TestHost,
&none,
"mymod:water_tank",
tank,
"create:chocolate",
&mut cancel,
);
!cancel
};
let mut state = tanks(&[(None, 0), (None, 0)]);
assert_eq!(
fill_tanks(
&table,
&mut state,
&[0, 1],
"create:chocolate",
1000,
no_mod
),
0
);
assert_eq!(state[0], (Some("create:chocolate".to_owned()), 1000));
assert_eq!(
tank_room(std::slice::from_ref(&tank), "create:chocolate", no_mod),
3000
);
}
#[test]
fn an_empty_tank_writes_the_empty_fluid_not_none() {
let mut nbt = Compound::new();
+61 -14
View File
@@ -17,6 +17,19 @@ use horizon_core::{
random::RandomSource,
};
/// `ItemStack.hurtAndConvertOnBreak`: the stack after `wear` more damage, or -
/// when that wear breaks it - the copy it leaves behind
/// (`transmuteCopyIgnoreEmpty`: the components carry over, and a damageable
/// copy starts whole). None while it still holds, so the caller adds the wear.
fn stick_on_break(stack: &ItemStack, wear: u32, max: u32) -> Option<ItemStack> {
if max == 0 || stack.damage + wear < max {
return None;
}
let mut rod = stack.transmute("minecraft:fishing_rod", 1);
rod.damage = 0;
Some(rod)
}
fn steering_item(species: Species, main: Option<&str>, offhand: Option<&str>) -> bool {
let item = match species {
Species::Pig => "minecraft:carrot_on_a_stick",
@@ -211,23 +224,30 @@ impl Game<'_> {
if !self.creative
&& let Some(stack) = self.inventory[self.selected].clone()
{
self.trigger_item_durability(&stack, stack.damage + wear);
if let Some(stack) = self.inventory[self.selected].as_mut() {
stack.damage += wear;
let max = self
.res
.items()
.get(&stack.item)
.map_or(25, |i| i.max_damage);
if stack.damage >= max {
let broken = stack.clone();
self.inventory[self.selected] =
Some(ItemStack::new("minecraft:fishing_rod", 1));
// ItemStack.hurtAndBreak: onEquippedItemBroken, then the replacement.
// `ItemStack.hurtAndBreak`: Unbreaking has its say first, and the
// durability trigger only hears the wear it leaves.
let wear = self.wear(&stack, wear);
let max = self
.res
.items()
.get(&stack.item)
.map_or(25, |i| i.max_damage);
match stick_on_break(&stack, wear, max) {
Some(rod) => {
// `hurtAndConvertOnBreak`: the stick breaks (the broken
// statistic and `onEquippedItemBroken`), then what is
// left is the rod, not a fresh plain one.
self.award_stat("broken", &stack.item, 1);
self.player_equipment_broke(
horizon_world::player_inventory::EquipmentSlot::MainHand,
&broken,
&stack,
);
self.inventory[self.selected] = Some(rod);
}
None => {
if let Some(stack) = self.inventory[self.selected].as_mut() {
stack.damage += wear;
}
}
}
}
@@ -1132,6 +1152,33 @@ mod tests {
))
}
/// `ItemStack.hurtAndConvertOnBreak`: the seven points a pig's boost
/// wears break a carrot on a stick at 25, and what it leaves is a fishing
/// rod that kept the stick's components - not a fresh plain rod.
#[test]
fn a_steering_stick_that_breaks_leaves_a_fishing_rod() {
let mut stick = ItemStack::new("minecraft:carrot_on_a_stick", 1);
stick.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Old Nag".into()));
// One point short of the break: the caller wears it and it holds.
stick.damage = 17;
assert!(stick_on_break(&stick, 7, 25).is_none());
// 18 + 7 is the break: the rod it leaves is whole and keeps the name.
stick.damage = 18;
let rod = stick_on_break(&stick, 7, 25).expect("the wear breaks it");
assert_eq!(&*rod.item, "minecraft:fishing_rod");
assert_eq!(rod.count, 1);
assert_eq!(rod.damage, 0, "the rod starts whole");
assert_eq!(
rod.custom_name().as_deref(),
Some("Old Nag"),
"the components carry over"
);
// Wear Unbreaking took away breaks nothing, and a stack with no
// durability at all never breaks.
assert!(stick_on_break(&stick, 0, 25).is_none());
assert!(stick_on_break(&stick, 25, 0).is_none());
}
/// `handlePlayerCommand` START_RIDING_JUMP reaches the ridden vehicle's
/// `handleStartJump`: a saddled horse rears, slides on the rider's keys and
/// its jump sound is played at 0.4; a donkey and a mule have their own
+2 -2
View File
@@ -124,7 +124,7 @@ impl Game<'_> {
self.name_tags_with_hooks(camera, alpha, !mod_api::mods().is_empty())
}
fn name_tags_with_hooks(&self, camera: glam::DVec3, alpha: f64, hooks: bool) -> Vec<NameTag> {
let target = self.mob_hit().map(|(i, _)| self.mobs[i].id);
let target = self.mob_hit().map(|(i, _, _)| self.mobs[i].id);
let viewer = self.player_team();
let mut tags = Vec::new();
let players = self.other_players();
@@ -397,7 +397,7 @@ impl Game<'_> {
.then(|| self.frame_hit())
.flatten()
.filter(|(_, distance)| {
self.before_block(*distance) && self.mob_hit().is_none_or(|(_, d)| d > *distance)
self.before_block(*distance) && self.mob_hit().is_none_or(|(_, d, _)| d > *distance)
})
.map(|(index, _)| index);
let frames = if hooks {
@@ -3134,6 +3134,34 @@ impl Game<'_> {
) {
mod_api::dispatch_block_entity_fluids(self, mod_api::mods(), block, fluids);
}
/// Does tank `tank` of the mod block `block` take `fluid`
/// (`minecraft:water`, `minecraft:lava`, or a mod's own,
/// `create:chocolate`)? NeoForge's `IFluidHandler.isFluidValid`: a water
/// tank refuses a mod's fluid, so a removed mod leaves no id it cannot
/// use behind. Asked on the server before `native_fluid_insert` fills a
/// tank that the block's `ModFluidTanks` lets be filled through that
/// face, in both passes (same fluid first, then empty), and before
/// `native_move_fluids` counts that tank's room. Set `cancel` to refuse
/// the fluid there; the fill tries the next tank. Every tank accepts
/// every fluid unless a mod says otherwise; a cauldron, which holds only
/// water and lava, and a drain never ask it. Bundles need "hooks":
/// @API:block_entity_accepts_fluid@.
pub(super) fn native_block_entity_accepts_fluid(
&self,
block: &str,
tank: usize,
fluid: &str,
cancel: &mut bool,
) {
mod_api::dispatch_block_entity_accepts_fluid(
self,
mod_api::mods(),
block,
tank,
fluid,
cancel,
);
}
/// Block `block`'s block entity has an energy buffer
/// (`Capabilities.Energy.BLOCK`): set `energy` when the block is yours,
/// as `SimpleEnergyHandler` is built: its capacity, the most one insert
+16 -5
View File
@@ -259,6 +259,8 @@ impl Game<'_> {
}
};
let mut view = self.view;
// An emote pulls the camera back from the player's face (`game/emotes.rs`).
let pull = self.emote_camera_pull(partial);
let kind = match self.camera_type {
CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson,
CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack,
@@ -287,7 +289,7 @@ impl Game<'_> {
partial,
kind,
|distance| {
let mut distance = distance;
let mut distance = distance * pull;
self.native_camera_distance(partial, kind.is_mirrored(), &entity, &mut distance);
distance
},
@@ -494,10 +496,19 @@ impl Game<'_> {
if diff.abs() > 50.0 {
m.body_yaw += diff - diff.signum() * 50.0;
}
// WalkAnimationState.update(min(distance * 4, 1), 0.4).
m.previous_limb_speed = m.limb_speed;
m.limb_speed += ((walked * 4.0).min(1.0) - m.limb_speed) * 0.4;
m.limb_position += m.limb_speed;
if riding {
// LivingEntity.calculateEntityAnimation: a passenger stops the
// walk animation (WalkAnimationState.stop), so the seat moving with
// the vehicle does not swing the legs.
m.previous_limb_speed = 0.0;
m.limb_speed = 0.0;
m.limb_position = 0.0;
} else {
// WalkAnimationState.update(min(distance * 4, 1), 0.4).
m.previous_limb_speed = m.limb_speed;
m.limb_speed += ((walked * 4.0).min(1.0) - m.limb_speed) * 0.4;
m.limb_position += m.limb_speed;
}
// Its idle swing holds with the world under `HORIZON_STILL`.
if self.clock.runs_normally() || !self.clock.still() {
m.age = m.age.wrapping_add(1);
@@ -20,7 +20,7 @@ impl Game<'_> {
let id = (!self.interactions.contains_key(&hit.id)).then_some(hit.id);
offer(hit.distance, id);
}
if let Some((i, d)) = self.mob_hit() {
if let Some((i, d, _)) = self.mob_hit() {
offer(d, Some(self.mobs[i].id as i32));
}
if let Some((_, d)) = self.redirectable_hit() {
@@ -48,7 +48,7 @@ impl Game<'_> {
if let Some(hit) = self.remote_object_hit(true) {
offer(hit.distance, uuid_of(hit.id as u32));
}
if let Some((i, d)) = self.mob_hit() {
if let Some((i, d, _)) = self.mob_hit() {
offer(d, uuid_of(self.mobs[i].id));
}
if let Some((i, d)) = self.redirectable_hit() {
@@ -775,6 +775,10 @@ impl Game<'_> {
let eye = self.eye_position().unwrap_or_default();
self.using = None;
self.send_use_item_complete();
// Consumable.onConsume: the used statistic and the consume_item
// trigger, with the stack as drunk (before it shrinks).
self.award_stat("used", &stack.item, 1);
self.trigger_item("minecraft:consume_item", stack);
self.post_entity_event("minecraft:drink", horizon_client::mobs::Who::Player);
if ominous {
self.drink_ominous_bottle(slot, stack);
+310 -20
View File
@@ -7,9 +7,11 @@ use horizon_client::{
hud::{HudBatch, rgba},
mesher::Vertex,
pvp::{Colour, CrosshairCache},
pvp_hud::{self, Combo, Durability, Icon, Line, Widget},
pvp_hud::{self, Combo, Durability, Icon, Key, Line, Widget},
text::Text,
};
use std::sync::Arc;
use winit::event::MouseButton;
#[derive(Default)]
pub(super) struct PvpState {
@@ -26,6 +28,20 @@ pub(super) struct PvpState {
server: Widget<Option<String>>,
combo: Combo,
combo_count: Widget<u32>,
fps: Widget<u32>,
/// Left and right presses, counted while the CPS or keystrokes widget is on.
clicks: [pvp_hud::Clicks; 2],
cps: Widget<(u32, u32)>,
keys: Keystrokes,
/// The local hour and minute, and when they were read.
clock: Widget<(u8, u8)>,
clock_read: f64,
reach: pvp_hud::Reach,
reach_shown: Widget<Option<u32>>,
speed: pvp_hud::Speed,
speed_shown: Widget<u32>,
/// Each widget drawn last frame and its box on screen, for the HUD editor.
pub(super) boxes: Vec<(pvp_hud::WidgetId, [f32; 4])>,
/// The open chat's box holds a chat search's query (Ctrl+F).
pub(super) chat_search: bool,
/// What the chat box held when the search borrowed it.
@@ -33,8 +49,25 @@ pub(super) struct PvpState {
}
/// `GuiGraphicsExtractor`'s grey for a detail line (an effect's time left).
const DETAIL: u32 = 0xFF7F_7F7F;
/// The extras' box colour.
const BACKGROUND: u32 = 0x7800_0000;
/// How the widgets look: their boxes' colour (none without boxes), text colour and shadow.
struct Style {
background: Option<u32>,
text: u32,
shadow: bool,
}
/// A held key's box in the keystrokes widget, and its text.
const KEY_HELD: u32 = 0xB4FF_FFFF;
const KEY_HELD_TEXT: u32 = 0xFF00_0000;
/// The keystrokes widget: each key's label, made again only when its binding changes, which
/// keys are held, and the clicks per second under the mouse buttons.
#[derive(Default)]
struct Keystrokes {
bound: [String; 7],
labels: Vec<(Text, f32)>,
held: [bool; 7],
cps: Widget<(u32, u32)>,
}
const ARROWS: [&str; 3] = [
"minecraft:arrow",
"minecraft:spectral_arrow",
@@ -165,10 +198,35 @@ impl Game<'_> {
_ => self.weather_levels(partial),
}
}
/// This player attacked entity `id` (the combo counter waits for the server's report).
pub(super) fn pvp_attacked(&mut self, id: i32) {
/// This player attacked entity `id`, `distance` from the eyes to where the crosshair's
/// ray met its box (the combo counter waits for the server's report; the reach widget
/// shows the distance).
pub(super) fn pvp_attacked(&mut self, id: i32, distance: f64) {
let now = self.started.elapsed().as_secs_f64();
self.pvp.combo.attacked(id, now);
self.pvp.reach.hit(distance, now);
}
/// A mouse press, counted for clicks per second while playing with a widget that shows them.
pub(super) fn pvp_click(&mut self, button: MouseButton) {
let hud = &self.settings.extras.pvp.hud;
if !(hud.cps || hud.keys) || self.screen != super::Screen::Playing {
return;
}
let i = match button {
MouseButton::Left => 0,
MouseButton::Right => 1,
_ => return,
};
let now = self.started.elapsed().as_secs_f64();
self.pvp.clicks[i].press(now);
}
/// Whether the key `mapping` is bound to is held: a keyboard key or a mouse button.
fn binding_held(&self, mapping: &str) -> bool {
let bound = self.settings.keys.get(mapping);
if let Some(button) = horizon_client::keybinds::mouse_button(bound) {
return self.physical_buttons.contains(&button);
}
self.mapped_key_down(mapping)
}
/// The server reports entity `id` hurt; `own` when it is this player,
/// `by_me` whether this player was the damage's direct entity (if named).
@@ -188,7 +246,7 @@ impl Game<'_> {
/// lines only when what it shows changed.
pub(super) fn draw_pvp_hud(&mut self, batch: &mut HudBatch, corners: &mut Corners) {
let hud = self.settings.extras.pvp.hud;
if self.player.is_none() {
if self.player.is_none() || !hud.any() {
return;
}
// What each widget shows, read before its cache is borrowed.
@@ -210,6 +268,13 @@ impl Game<'_> {
let server = (hud.server && self.pvp.server.next.as_deref() != server)
.then(|| server.map(str::to_owned));
let now = self.started.elapsed().as_secs_f64();
let fps = self.fps.round() as u32;
let position = self.player.as_ref().map(|p| (p.position.x, p.position.z));
if hud.keys {
let held = Key::ALL.map(|k| self.binding_held(k.mapping()));
self.pvp.keys.held = held;
self.keystroke_labels();
}
let atlas = &self.gui.atlas;
let width = |s: &str| HudBatch::text_width(atlas, s);
let state = &mut self.pvp;
@@ -338,46 +403,264 @@ impl Game<'_> {
} else {
state.combo_count.clear();
}
let background = hud.background;
let cps = if hud.cps || hud.keys {
[0, 1].map(|i| state.clicks[i].per_second(now))
} else {
[0; 2]
};
if hud.fps {
state.fps.next = fps;
state
.fps
.refresh(|fps, lines| lines.push(text("horizon.pvp.hud.fps", &[fps])));
} else {
state.fps.clear();
}
if hud.cps {
state.cps.next = (cps[0], cps[1]);
state.cps.refresh(|(l, r), lines| {
lines.push(text("horizon.pvp.hud.cps", &[l, r]));
});
} else {
state.cps.clear();
}
if hud.keys {
state.keys.cps.next = (cps[0], cps[1]);
state.keys.cps.refresh(|(l, r), lines| {
for n in [l, r] {
lines.push(text("horizon.pvp.hud.keys.cps", &[n]));
}
});
}
if hud.clock {
// The time is read once a second, not every frame.
if now - state.clock_read >= 1.0 || state.clock.lines.is_empty() {
use chrono::Timelike;
let t = chrono::Local::now();
state.clock.next = (t.hour() as u8, t.minute() as u8);
state.clock_read = now;
}
state.clock.refresh(|&(h, m), lines| {
lines.push(Line::new(Icon::None, pvp_hud::clock(h, m), &width));
});
} else {
state.clock.clear();
}
if hud.reach {
state.reach_shown.next = state.reach.hundredths(now);
state.reach_shown.refresh(|reach, lines| {
if let Some(n) = reach {
lines.push(text("horizon.pvp.hud.reach", &[&pvp_hud::hundredths(*n)]));
}
});
} else {
state.reach_shown.clear();
}
if hud.speed
&& let Some((x, z)) = position
{
state.speed_shown.next = state.speed.sample(x, z, now);
state.speed_shown.refresh(|n, lines| {
lines.push(text("horizon.pvp.hud.speed", &[&pvp_hud::tenths(*n)]));
});
} else if !hud.speed {
state.speed_shown.clear();
}
let style = Style {
background: hud.background.then(|| hud.background_argb()),
text: hud.text.argb(),
shadow: hud.shadow,
};
let layout = self.settings.extras.pvp.layout;
let screen = (batch.width, batch.height);
let mut boxes = std::mem::take(&mut self.pvp.boxes);
boxes.clear();
let p = &self.pvp;
use pvp_hud::WidgetId as W;
let widgets = [
(
W::Armour,
hud.armour,
hud.armour_corner,
&p.armour.lines,
p.armour.size,
),
(
W::Effects,
hud.effects,
hud.effects_corner,
&p.effects.lines,
p.effects.size,
),
(hud.held, hud.held_corner, &p.held.lines, p.held.size),
(hud.ping, hud.ping_corner, &p.ping.lines, p.ping.size),
(
W::Held,
hud.held,
hud.held_corner,
&p.held.lines,
p.held.size,
),
(
W::Ping,
hud.ping,
hud.ping_corner,
&p.ping.lines,
p.ping.size,
),
(
W::Coords,
hud.coords,
hud.coords_corner,
&p.coords.lines,
p.coords.size,
),
(
W::Server,
hud.server,
hud.server_corner,
&p.server.lines,
p.server.size,
),
(
W::Combo,
hud.combo,
hud.combo_corner,
&p.combo_count.lines,
p.combo_count.size,
),
(W::Fps, hud.fps, hud.fps_corner, &p.fps.lines, p.fps.size),
(W::Cps, hud.cps, hud.cps_corner, &p.cps.lines, p.cps.size),
(
W::Clock,
hud.clock,
hud.clock_corner,
&p.clock.lines,
p.clock.size,
),
(
W::Reach,
hud.reach,
hud.reach_corner,
&p.reach_shown.lines,
p.reach_shown.size,
),
(
W::Speed,
hud.speed,
hud.speed_corner,
&p.speed_shown.lines,
p.speed_shown.size,
),
];
for (on, corner, lines, size) in widgets {
if on && !lines.is_empty() && self.native_corner_shown(corner) {
self.paint_widget(batch, corners, corner, lines, size, background);
for (id, on, corner, lines, size) in widgets {
if !on || lines.is_empty() {
continue;
}
let place = layout.get(id);
if let Some(rect) = self.widget_rect(corners, place, corner, size, screen) {
self.paint_widget(batch, rect, lines, size, &style);
boxes.push((id, rect));
}
}
if hud.keys {
let place = layout.get(W::Keys);
let size = pvp_hud::keys_size();
if let Some(rect) = self.widget_rect(corners, place, hud.keys_corner, size, screen) {
self.paint_keys(batch, rect, &style);
boxes.push((W::Keys, rect));
}
}
self.pvp.boxes = boxes;
}
/// Where a widget of `size` goes at its `place`'s scale: where the player put it, else in
/// its corner (unless a mod hides that corner). `[x, y, w, h]`, scaled.
fn widget_rect(
&self,
corners: &mut Corners,
place: pvp_hud::Place,
corner: Corner,
(w, h): (f32, f32),
screen: (f32, f32),
) -> Option<[f32; 4]> {
let scale = f32::from(place.scale) / 100.0;
let size = (w * scale, h * scale);
let (x, y) = match place.at {
Some(at) => pvp_hud::from_fractions(at, size, screen),
None if self.native_corner_shown(corner) => corners.place(corner, size.0, size.1),
None => return None,
};
Some([x, y, size.0, size.1])
}
/// The keystrokes' labels: each key's name, made again only when a binding changed.
fn keystroke_labels(&mut self) {
let keys = &self.settings.keys;
let k = &mut self.pvp.keys;
let same = Key::ALL
.iter()
.zip(&k.bound)
.all(|(key, b)| keys.get(key.mapping()) == b);
if same && k.labels.len() == Key::ALL.len() {
return;
}
let atlas = &self.gui.atlas;
k.labels.clear();
for (i, key) in Key::ALL.into_iter().enumerate() {
let bound = keys.get(key.mapping());
k.bound[i] = bound.to_owned();
let label = match horizon_client::keybinds::mouse_button(bound) {
Some(MouseButton::Left) => Text::tr("horizon.pvp.hud.keys.attack", &[]),
Some(MouseButton::Right) => Text::tr("horizon.pvp.hud.keys.use", &[]),
_ => horizon_client::keybinds::display_name(bound),
};
let w = HudBatch::text_width(atlas, &label.plain());
k.labels.push((label, w));
}
}
/// The keystrokes widget at `(x, y)`: a box per key, light while held, its name centred,
/// and under each mouse button its clicks per second.
fn paint_keys(&self, batch: &mut HudBatch, [x, y, sw, _]: [f32; 4], style: &Style) {
let start = batch.vertices.len();
let atlas = &self.gui.atlas;
let k = &self.pvp.keys;
let colour = |c: u32| {
let [a, r, g, b] = c.to_be_bytes();
rgba(r, g, b, a)
};
for (i, key) in Key::ALL.into_iter().enumerate() {
let (kx, ky, w, h) = pvp_hud::key_rect(key);
let (kx, ky) = (x + kx, y + ky);
let held = k.held[i];
if held {
batch.fill(atlas, kx, ky, w, h, colour(KEY_HELD));
} else if let Some(background) = style.background {
batch.fill(atlas, kx, ky, w, h, colour(background));
}
let text_colour = colour(if held { KEY_HELD_TEXT } else { style.text });
let Some((label, lw)) = k.labels.get(i) else {
continue;
};
let cps = match key {
Key::Attack => k.cps.lines.first(),
Key::Use => k.cps.lines.get(1),
_ => None,
};
let top = match cps {
Some(_) => ky + 2.0,
None => ky + (h - 8.0) / 2.0,
};
let shadow = style.shadow && !held;
batch.text_styled(atlas, label, kx + (w - lw) / 2.0, top, text_colour, shadow);
if let Some(line) = cps {
// The count at half size under the name.
let start = batch.vertices.len();
let (cx, cy) = (kx + w / 2.0, ky + h - 6.0);
let tx = cx - line.text_width / 2.0;
batch.text_styled(atlas, &line.text, tx, cy, text_colour, false);
batch.scale_since(start, cx, cy, 0.5, 0.5);
}
}
let scale = sw / pvp_hud::keys_size().0;
if scale != 1.0 {
batch.scale_since(start, x, y, scale, scale);
}
}
/// The held item count: the first slot holding what is counted, its item, and the total in
@@ -403,16 +686,15 @@ impl Game<'_> {
fn paint_widget(
&self,
batch: &mut HudBatch,
corners: &mut Corners,
corner: Corner,
[x, y, sw, _]: [f32; 4],
lines: &[Line],
(w, h): (f32, f32),
background: bool,
style: &Style,
) {
let start = batch.vertices.len();
let atlas = &self.gui.atlas;
let (x, y) = corners.place(corner, w, h);
if background {
let [a, r, g, b] = BACKGROUND.to_be_bytes();
if let Some(background) = style.background {
let [a, r, g, b] = background.to_be_bytes();
batch.fill(atlas, x, y, w, h, rgba(r, g, b, a));
}
let icons = pvp_hud::icon_column(lines);
@@ -446,11 +728,19 @@ impl Game<'_> {
let [a, r, g, b] = c.to_be_bytes();
rgba(r, g, b, a)
};
batch.text_styled(atlas, &line.text, tx, ty, argb(line.colour), true);
// White lines take the widgets' text colour; durability keeps its own.
let colour = match line.colour {
0xFFFF_FFFF => style.text,
c => c,
};
batch.text_styled(atlas, &line.text, tx, ty, argb(colour), style.shadow);
if let Some(detail) = &line.detail {
batch.text_styled(atlas, detail, tx, ty + 10.0, argb(DETAIL), true);
batch.text_styled(atlas, detail, tx, ty + 10.0, argb(DETAIL), style.shadow);
}
top += line.height();
}
if sw != w {
batch.scale_since(start, x, y, sw / w, sw / w);
}
}
}
+8 -7
View File
@@ -6394,6 +6394,7 @@ impl Game<'_> {
}
// The player's own model (third person, the inventory's preview).
self.tick_player_model();
self.tick_emotes();
if !dead {
self.remote_tick_using();
}
@@ -7629,7 +7630,7 @@ impl Game<'_> {
// A boat, minecart, item frame, armor stand or painting nearer than
// any mob: the server uses it (AbstractBoat.interact boards it).
if let Some(hit) = self.remote_object_hit(false)
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
&& self.mob_hit().is_none_or(|(_, d, _)| hit.distance < d)
&& self.before_block(hit.distance)
{
// Interaction.interact answers SUCCESS when it responds and
@@ -7713,14 +7714,14 @@ impl Game<'_> {
}
return true;
}
if let Some((i, distance)) = self.mob_hit()
if let Some((i, distance, location)) = self.mob_hit()
&& self.before_block(distance)
{
let id = self.mobs[i].id as i32;
self.send(S::Interact(play::Interact {
id,
hand: hand_id(hand),
location: [0.0, 1.0, 0.0],
location,
secondary: sneaking,
}));
// Player.interactOn: another player passes, and so does a mob
@@ -9018,7 +9019,7 @@ impl Game<'_> {
// A ghast's fireball or a wind charge nearer than any mob: the
// server turns it (game/deflect.rs).
if let Some((i, distance)) = self.redirectable_hit()
&& self.mob_hit().is_none_or(|(_, d)| distance <= d)
&& self.mob_hit().is_none_or(|(_, d, _)| distance <= d)
&& self.before_block(distance)
&& self.creatures.projectiles[i].id != 0
{
@@ -9027,14 +9028,14 @@ impl Game<'_> {
}));
self.reset_attack_strength();
} else if let Some(hit) = self.remote_object_hit(true)
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
&& self.mob_hit().is_none_or(|(_, d, _)| hit.distance < d)
&& self.before_block(hit.distance)
{
// VehicleEntity.hurtServer, ItemFrame and ArmorStand hurtServer,
// a painting broken or an EndCrystal hurtServer: the server decides.
self.send(S::Attack(play::Attack { id: hit.id }));
self.reset_attack_strength();
} else if let Some((i, distance)) = self.mob_hit()
} else if let Some((i, distance, _)) = self.mob_hit()
&& self.before_block(distance)
{
// Minecraft.startAttack: an item's attack_range must hold the hit.
@@ -9042,7 +9043,7 @@ impl Game<'_> {
self.send(S::Attack(play::Attack {
id: self.mobs[i].id as i32,
}));
self.pvp_attacked(self.mobs[i].id as i32);
self.pvp_attacked(self.mobs[i].id as i32, distance);
self.remote_player_attack(i);
self.reset_attack_strength();
}
@@ -105,7 +105,7 @@ impl Game<'_> {
/// The entity under a spectator's crosshair (`Minecraft.hitResult` as an
/// `EntityHitResult`): the nearest mob or object before any block.
pub(super) fn spectator_click_target(&self) -> Option<i32> {
let mob = self.mob_hit().map(|(i, d)| (self.mobs[i].id as i32, d));
let mob = self.mob_hit().map(|(i, d, _)| (self.mobs[i].id as i32, d));
let object = self.remote_object_hit(false).map(|h| (h.id, h.distance));
[mob, object]
.into_iter()
@@ -30,6 +30,7 @@ impl Screen {
| Self::DebugOptions => true,
Self::Playing
| Self::GameModeSwitcher
| Self::EmoteWheel
| Self::Chat
| Self::Creative
| Self::Inventory
+36 -5
View File
@@ -17,6 +17,7 @@ use horizon_core::{
position::BlockPos,
random::RandomSource,
};
use horizon_protocol::entity_data::PositionSource;
use horizon_world::registry::BlockStateId;
use std::collections::HashSet;
use winit::keyboard::KeyCode;
@@ -896,7 +897,8 @@ impl Game<'_> {
.vibration
.tick(time, adjacent, true, |d| d.floor() as i32);
if let Some((info, travel)) = tick.started {
self.vibration_particle(info.pos, center(key), travel);
let source = PositionSource::Block([key.0, key.1, key.2]);
self.vibration_particle(source, info.pos, center(key), travel);
}
if let Some(info) = tick.received {
self.sculk_receive(key, info)?;
@@ -1559,12 +1561,41 @@ impl Game<'_> {
}
}
fn vibration_particle(&mut self, from: [f64; 3], to: [f64; 3], ticks: i32) {
self.particles.vibration(from, to, ticks);
/// `ServerLevel.sendParticles(new VibrationParticleOption(...), origin,
/// 1, 0, 0, 0, 0)` from `VibrationSystem.Ticker.trySelectAndScheduleVibration`:
/// the window draws it when it is a client, and every player within 32
/// blocks is sent it.
fn vibration_particle(
&mut self,
source: PositionSource,
from: [f64; 3],
to: [f64; 3],
ticks: i32,
) {
let options = ParticleOptions::vibration(source, to, ticks);
if self.options.execution_role.is_client() {
self.particles.vibration(from, to, ticks);
}
self.lan_send_particles(super::lan_particles::QueuedParticle {
dimension: self.dim.id(),
options,
state: None,
at: from,
spread: [0.0; 3],
speed: [0.0; 3],
count: 1,
randomization: 0,
});
}
/// A vibration travelling to a warden (`MobEvent::Vibration`).
pub(super) fn mob_vibration_particle(&mut self, from: [f64; 3], to: [f64; 3], ticks: i32) {
self.vibration_particle(from, to, ticks);
pub(super) fn mob_vibration_particle(
&mut self,
source: PositionSource,
from: [f64; 3],
to: [f64; 3],
ticks: i32,
) {
self.vibration_particle(source, from, to, ticks);
}
/// `Warden.clientDiggingParticles`: thirty crumbs of the block underfoot.
pub(super) fn warden_dig_particles(&mut self, at: [f64; 3]) {
+9 -4
View File
@@ -2740,7 +2740,8 @@ impl Game<'_> {
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.items(), stack, 0.0, 0.0, 96.0);
let probe =
horizon_client::item_tooltip::bundle_tooltip(self.items(), stack, 0.0, 0.0, 96.0);
let bundle_extra = probe.as_ref().map_or(0.0, |l| l.height + 2.0);
let mut component = None;
self.native_tooltip_component(stack, &mut component);
@@ -2759,9 +2760,13 @@ impl Game<'_> {
self.tooltip_box(batch, Some(stack), x, y, w, h);
let text_h = h - extra;
Self::tooltip_text(batch, atlas, &lines, x, y);
if let Some(layout) =
horizon_client::bundle::tooltip_layout(self.items(), stack, x, y + text_h + 2.0, w)
{
if let Some(layout) = horizon_client::item_tooltip::bundle_tooltip(
self.items(),
stack,
x,
y + text_h + 2.0,
w,
) {
self.draw_bundle_tooltip(batch, &layout);
self.draw_bundle_selected_name(batch, &layout, x, y + text_h + 2.0, w);
}
+58 -1
View File
@@ -2,8 +2,9 @@
//! and lines without player context for the recipe book's search tree.
use crate::text::{GRAY, Text};
use crate::{
bundle::TooltipLayout,
hud::rgba,
items::{ItemInfo, ItemStack},
items::{ItemInfo, ItemStack, Items},
};
use horizon_world::nbt::Tag;
@@ -315,6 +316,23 @@ pub fn rarity<'a>(stack: &ItemStack, default: &'a str) -> &'a str {
}
}
/// `BundleItem.getTooltipImage`: the contents grid of a bundle, or None when
/// the stack is not one or `minecraft:tooltip_display` hides
/// `minecraft:bundle_contents` - the image is that component's tooltip
/// detail, so a display that hides the component hides the grid too.
pub fn bundle_tooltip(
items: &Items,
stack: &ItemStack,
x: f32,
y: f32,
box_width: f32,
) -> Option<TooltipLayout> {
TooltipDisplay::for_stack(stack)
.shows("minecraft:bundle_contents")
.then(|| crate::bundle::tooltip_layout(items, stack, x, y, box_width))
.flatten()
}
fn is_beehive(path: &str) -> bool {
matches!(path, "beehive" | "bee_nest")
}
@@ -1232,6 +1250,45 @@ mod tests {
assert!(!display.shows("minecraft:attribute_modifiers"));
}
/// `BundleItem.getTooltipImage` is a tooltip detail of
/// `minecraft:bundle_contents`, so a `minecraft:tooltip_display` that
/// hides the component - or the whole tooltip - takes the contents grid
/// with it. The draw path asks for the grid through the same function.
#[test]
fn a_bundle_contents_grid_follows_the_tooltip_display() {
let items = Items::for_tests(Default::default());
let mut bundle = ItemStack::new("minecraft:bundle", 1);
bundle.data_mut().bundle_contents = vec![ItemStack::new("minecraft:stone", 3)];
assert!(bundle_tooltip(&items, &bundle, 0.0, 0.0, 96.0).is_some());
// A stack that is not a bundle has no grid.
let stone = ItemStack::new("minecraft:stone", 1);
assert!(bundle_tooltip(&items, &stone, 0.0, 0.0, 96.0).is_none());
// A display that hides the contents component hides the grid.
bundle.data_mut().set_other(
"minecraft:tooltip_display",
Some(Tag::compound([(
"hidden_components",
Tag::List(vec![Tag::String("minecraft:bundle_contents".into())]),
)])),
);
assert!(bundle_tooltip(&items, &bundle, 0.0, 0.0, 96.0).is_none());
// So does one that hides the whole tooltip.
bundle.data_mut().set_other(
"minecraft:tooltip_display",
Some(Tag::compound([("hide_tooltip", Tag::Byte(1))])),
);
assert!(bundle_tooltip(&items, &bundle, 0.0, 0.0, 96.0).is_none());
// A component the display does not name leaves the grid alone.
bundle.data_mut().set_other(
"minecraft:tooltip_display",
Some(Tag::compound([(
"hidden_components",
Tag::List(vec![Tag::String("minecraft:lore".into())]),
)])),
);
assert!(bundle_tooltip(&items, &bundle, 0.0, 0.0, 96.0).is_some());
}
#[test]
fn stew_effects_show_only_with_the_creative_flag() {
let translate = |key: &str| {
+13
View File
@@ -1442,6 +1442,9 @@ pub struct Mob {
/// `PlayerSkin.cape`: a player's cape texture, once every download of
/// its skin is ready.
pub cape: Option<String>,
/// A Horizon emote this player plays (docs/cosmetics.md), timed on `age`: it poses the bones
/// after the vanilla animation (`anim::setup`). Visual only.
pub emote: Option<horizon_cosmetics::Playing>,
/// A mannequin's `DATA_PROFILE` (ResolvableProfile): the profile's
/// `textures` property resolves to `skin` like a player's.
pub profile: Option<horizon_protocol::entity_data::Profile>,
@@ -1937,6 +1940,13 @@ pub enum MobEvent {
table: &'static str,
at: [f64; 3],
},
/// A leave-block goal's sampled candidate, before neighbor-shape updates.
EndermanPlaceBlock {
mob: u32,
pos: [i32; 3],
carried: horizon_world::registry::BlockStateId,
occupants: Vec<horizon_core::geometry::Aabb>,
},
SetBlock {
pos: [i32; 3],
state: horizon_world::registry::BlockStateId,
@@ -2204,6 +2214,8 @@ pub enum MobEvent {
from: [f64; 3],
to: [f64; 3],
ticks: i32,
/// `VibrationSystem.User.getPositionSource`: the listener itself.
source: horizon_protocol::entity_data::PositionSource,
},
/// The crumbs of the block under a digging or emerging warden.
DigParticles {
@@ -3072,6 +3084,7 @@ impl Mob {
slim: false,
skin_3d: None,
cape: None,
emote: None,
profile: None,
random_textures: Default::default(),
attrs: Attrs {
+7
View File
@@ -12,6 +12,7 @@ use crate::items::ItemStack;
use crate::sculk::{self, Context, Info};
use horizon_core::geometry::trig::Mth;
use horizon_core::geometry::{Aabb, Vec3};
use horizon_protocol::entity_data::PositionSource;
pub mod interaction;
@@ -399,6 +400,12 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
from: info.pos,
to: [m.position.x, m.eye_y(), m.position.z],
ticks: travel,
// `Allay.VibrationUser`: `new EntityPositionSource(allay,
// allay.getEyeHeight())`.
source: PositionSource::Entity {
id: m.id as i32,
y_offset: (m.eye_y() - m.position.y) as f32,
},
});
}
if let Some(info) = tick.received {
+90 -1
View File
@@ -759,12 +759,49 @@ fn swim(p: &mut Poses, pos: f32, h: &Hum) {
lerp(amount, x, 0.3 * (pos * 0.333_333_34).mcos()),
);
}
/// Apply the mob's model animation to `p`.
/// Apply the mob's model animation to `p` (at rest), then the emote it plays, if any.
pub fn setup(
m: &Mob,
s: &State,
p: &mut Poses,
w_anims: &HashMap<String, super::keyframes::Animation>,
) {
let Some(playing) = &m.emote else {
return vanilla_setup(m, s, p, w_anims);
};
let rest = horizon_cosmetics::BODY.map(|b| p.has(b.part()).then(|| *p.get(b.part())));
vanilla_setup(m, s, p, w_anims);
emote(playing, s.age, rest, p);
}
/// Poses the bones `rest` names (those the model has; at rest) as `playing` has them at `age`.
fn emote(
playing: &horizon_cosmetics::Playing,
age: f32,
rest: [Option<[f32; 9]>; 6],
p: &mut Poses,
) {
let (Some(emote), Some((t, weight))) = (&playing.item.emote, playing.at(age)) else {
return;
};
const IDENTITY: [f32; 9] = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
let bones = horizon_cosmetics::BODY;
let at_rest = rest.map(|r| r.unwrap_or(IDENTITY));
let mut poses: [[f32; 9]; 6] =
std::array::from_fn(|i| rest[i].map_or(IDENTITY, |_| *p.get(bones[i].part())));
emote.pose_body(t, weight, &at_rest, &mut poses);
for (i, pose) in poses.into_iter().enumerate() {
if rest[i].is_some() {
*p.get(bones[i].part()) = pose;
}
}
}
fn vanilla_setup(
m: &Mob,
s: &State,
p: &mut Poses,
w_anims: &HashMap<String, super::keyframes::Animation>,
) {
if super::body_anim::anim(m, s, p, w_anims) {
return;
@@ -1088,6 +1125,58 @@ fn player_model_visibility(p: &mut Poses, hidden: u8, spectator: bool) {
p.visible(part, hidden & (1 << bit) == 0);
}
}
#[cfg(test)]
mod emote_tests {
use super::*;
#[test]
fn an_emote_poses_the_bones_the_model_has_after_its_animation() {
// The right arm up to 90° about z at tick 10, the body hopping 4 px; the model has no legs.
let item = horizon_cosmetics::Item::parse(
br#"{"format": 1, "id": "up", "slot": "emote", "parts": [],
"emote": {"length": 20, "blend": 0, "tracks": [
{"part": "right_arm", "keys": [{"t": 10, "rotation": [0, 0, 90]}]},
{"part": "root", "keys": [{"t": 0, "offset": [0, -4, 0]}]}]}}"#,
)
.unwrap();
let rest = |x: f32, y: f32| [x, y, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
let mut p = Poses {
map: [("head", rest(0.0, 0.0)), ("right_arm", rest(-5.0, 2.0))]
.into_iter()
.map(|(n, pose)| (key(n), pose))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
hidden: Default::default(),
version: 0,
};
let at_rest = horizon_cosmetics::BODY.map(|b| p.has(b.part()).then(|| *p.get(b.part())));
// What the walk animation did: the head looks aside, the arm swings.
p.get("head")[4] = 0.4;
p.get("right_arm")[3] = 0.7;
let playing = horizon_cosmetics::Playing {
item: std::sync::Arc::new(item),
start: 100.0,
stop: None,
};
emote(&playing, 110.0, at_rest, &mut p);
let arm = *p.get("right_arm");
assert!(
(arm[5] - std::f32::consts::FRAC_PI_2).abs() < 1e-5 && arm[3].abs() < 1e-5,
"{arm:?}"
);
assert!((arm[0] + 5.0).abs() < 1e-5 && (arm[1] - (2.0 - 4.0)).abs() < 1e-5);
// The head keeps looking aside, lifted with the body.
let head = *p.get("head");
assert!((head[4] - 0.4).abs() < 1e-5 && (head[1] + 4.0).abs() < 1e-5);
// Nothing was added for the bones the model lacks, and past its end the emote is gone.
assert_eq!(p.map.len(), 2);
let before = *p.get("right_arm");
emote(&playing, 125.0, at_rest, &mut p);
assert_eq!(*p.get("right_arm"), before);
}
}
#[cfg(test)]
mod flight_pose_tests {
use super::*;
+11 -17
View File
@@ -460,26 +460,20 @@ impl Goal for LeaveBlock {
let x = (m.position.x - 1.0 + m.random.next_double() * 2.0).floor() as i32;
let y = (m.position.y + m.random.next_double() * 2.0).floor() as i32;
let z = (m.position.z - 1.0 + m.random.next_double() * 2.0).floor() as i32;
let target = cx.w.world.block(x, y, z);
let below = cx.w.world.block(x, y - 1, z);
let bf = cx.w.facts.get(below);
let block = Aabb::new(
let area = Aabb::new(
Vec3::new(f64::from(x), f64::from(y), f64::from(z)),
Vec3::new(f64::from(x + 1), f64::from(y + 1), f64::from(z + 1)),
);
let free = !cx.w.others_near(block).any(|s| s.bounds.intersects(block));
if cx.w.facts.is_air(target)
&& !cx.w.facts.is_air(below)
&& &*bf.name != "minecraft:bedrock"
&& bf.full_collision
&& free
{
cx.events.push(MobEvent::SetBlock {
pos: [x, y, z],
state: carried,
});
m.extra.carried = None;
}
// Keep the per-mob occupancy view: later mobs have not moved yet.
let occupants = cx.w.others_near(area).map(|s| s.bounds).collect();
// Game owns the mutable level, its scheduled ticks, RNG and shape hooks.
// Defer the complete attempt, including failed candidates' shape updates.
cx.events.push(MobEvent::EndermanPlaceBlock {
mob: m.id,
pos: [x, y, z],
carried,
occupants,
});
}
}
+7
View File
@@ -14,6 +14,7 @@ use horizon_core::{
geometry::{Vec3, trig::Mth},
random::{LegacyRandom, RandomSource},
};
use horizon_protocol::entity_data::PositionSource;
use std::{collections::HashMap, f32::consts::PI};
pub const EMERGE_DURATION: i64 = warden_brain::EMERGE_DURATION as i64;
@@ -665,6 +666,12 @@ pub fn pre_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
from: info.pos,
to: eye,
ticks: travel,
// `Warden.VibrationUser`: `new EntityPositionSource(warden,
// warden.getEyeHeight())`.
source: PositionSource::Entity {
id: m.id as i32,
y_offset: m.kind.eye_height as f32,
},
});
}
if let Some(info) = tick.received {
+540 -20
View File
@@ -13,7 +13,7 @@ use crate::hud::{HudAtlas, HudBatch, rgba};
use crate::text::Text;
use client::{
PendingInviteDto, PlayerInfo, RealmAddress, RealmServer, RealmSlot, RealmWorldOptions,
RealmsClient, RealmsError, Subscription,
RealmsClient, RealmsError, RegionDataDto, RegionSelectionPreferenceDto, Subscription,
};
use horizon_client_render::auth::{self, AuthSession};
use std::sync::atomic::{AtomicBool, Ordering};
@@ -35,6 +35,8 @@ const INFO: u32 = 0xFFBE_887D;
const REALMS_INVITE_CODE: crate::clipboard::FieldId = ("realms.invite_code", 0);
const REALMS_PLAYER_NAME: crate::clipboard::FieldId = ("realms.player_name", 0);
const REALMS_SLOT_NAME: crate::clipboard::FieldId = ("realms.slot_name", 0);
const REALMS_SETTINGS_NAME: crate::clipboard::FieldId = ("realms.settings_name", 0);
const REALMS_SETTINGS_DESC: crate::clipboard::FieldId = ("realms.settings_desc", 0);
/// `RealmsSlotOptionsScreen.DIFFICULTIES` and `GAME_MODES`, in menu order (the index
/// is what the service stores).
@@ -166,6 +168,85 @@ fn extend_link(server: &RealmServer, profile: u128) -> String {
}
}
/// `RealmsSettingsTab`'s fields: the name, the description and the region preference,
/// as the player is editing them.
#[derive(Clone, Debug, PartialEq)]
pub struct SettingsDraft {
pub name: String,
pub description: String,
/// `RegionSelectionPreference`'s id: [`client::AUTOMATIC_PLAYER`],
/// [`client::AUTOMATIC_OWNER`] or [`client::MANUAL`].
pub preference: i32,
/// The `RealmsRegion`'s `nameId`, while `preference` is `MANUAL`.
pub region: Option<String>,
/// The field with the caret: the name (false) or the description (true).
pub on_description: bool,
}
impl SettingsDraft {
/// The draft the tab opens with (`RealmsSettingsTab.updateData`): a Realm without a
/// preference has `RegionSelectionPreferenceDto.DEFAULT`, automatic by owner.
fn of(server: &RealmServer) -> Self {
let (preference, region) = server.region_selection_preference.as_ref().map_or(
(client::AUTOMATIC_OWNER, None),
|p| {
(
p.region_selection_preference,
p.preferred_region.clone().filter(|r| !r.is_empty()),
)
},
);
Self {
name: server.name.clone(),
description: server.motd.clone(),
preference,
region,
on_description: false,
}
}
/// The preference as `RegionSelectionPreferenceDto`: the region only for `MANUAL`.
fn preference_dto(&self) -> RegionSelectionPreferenceDto {
RegionSelectionPreferenceDto {
region_selection_preference: self.preference,
preferred_region: self
.region
.clone()
.filter(|_| self.preference == client::MANUAL),
}
}
}
/// `RealmsSettingsTab.getTranslatableFromPreference`: a manual choice shows its region,
/// the automatic ones their own name.
fn preference_label(preference: i32, region: Option<&str>) -> String {
let t = crate::lang::get;
match (preference, region) {
(client::MANUAL, Some(region)) => {
client::region_translation_key(region).map_or_else(|| region.to_owned(), t)
}
(client::AUTOMATIC_PLAYER, _) => {
t("realms.configuration.region_preference.automatic_player")
}
_ => t("realms.configuration.region_preference.automatic_owner"),
}
}
/// `RealmsPreferredRegionSelectionScreen.RegionSelectionList`'s rows: automatic by player,
/// automatic by owner, then every region as a manual choice.
fn region_rows(regions: &[RegionDataDto]) -> Vec<(i32, Option<String>)> {
[
(client::AUTOMATIC_PLAYER, None),
(client::AUTOMATIC_OWNER, None),
]
.into_iter()
.chain(
regions
.iter()
.map(|r| (client::MANUAL, Some(r.region.clone()))),
)
.collect()
}
/// `RealmsConfigureWorldScreen`'s tabs, in `MenuTabBar` order.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConfigureTab {
@@ -338,6 +419,13 @@ pub enum SubScreen {
deleting: Option<Job<Result<(), RealmsError>>>,
/// `RealmsClient.op`/`deop`/`uninvite` on a worker, from the players tab.
member_job: Option<Job<Result<(), RealmsError>>>,
/// The settings tab's editable fields, once the tab has been opened.
settings: Option<SettingsDraft>,
/// `RealmsClient.preferredRegions` for the region preference screen.
regions: Option<Result<Vec<RegionDataDto>, RealmsError>>,
regions_job: Option<Job<Result<Vec<RegionDataDto>, RealmsError>>>,
/// `RealmsClient.updateConfiguration` on a worker.
saving: Option<Job<Result<(), RealmsError>>>,
error: Option<String>,
/// `RealmsClient.open`/`close` on a worker.
job: Option<Job<Result<(), RealmsError>>>,
@@ -355,6 +443,15 @@ pub enum SubScreen {
job: Option<Job<Result<(), RealmsError>>>,
error: Option<String>,
},
/// `RealmsPreferredRegionSelectionScreen`: the region preference list.
RegionPreference {
server: RealmServer,
/// The settings tab's draft, which Done writes the choice back to.
settings: SettingsDraft,
regions: Vec<RegionDataDto>,
/// The highlighted row: `(preference, region)`.
selected: Option<(i32, Option<String>)>,
},
/// `RealmsInviteScreen`: a name to invite to the Realm.
Invite {
server: RealmServer,
@@ -499,6 +596,7 @@ impl Screen {
let mut refresh = false;
let mut refetch_detail = false;
let mut slot_options_saved = false;
let mut open_region_screen = false;
match &mut self.subscreen {
SubScreen::JoinCode { error, job, .. } => {
if let Some(result) = job.as_ref().and_then(take) {
@@ -528,6 +626,10 @@ impl Screen {
detail_job,
deleting,
member_job,
settings,
regions,
regions_job,
saving,
error,
job,
..
@@ -559,6 +661,31 @@ impl Screen {
Err(e) => *error = Some(e.text()),
}
}
if let Some(result) = regions_job.as_ref().and_then(take) {
*regions_job = None;
match result {
Ok(list) => {
*regions = Some(Ok(list));
open_region_screen = true;
}
Err(e) => *error = Some(e.text()),
}
}
if let Some(result) = saving.as_ref().and_then(take) {
*saving = None;
match result {
// `saveSettings` updates the screen's own copy and reads again.
Ok(()) => {
if let Some(draft) = settings.as_ref() {
server.name = draft.name.trim().to_owned();
server.motd = draft.description.clone();
server.region_selection_preference = Some(draft.preference_dto());
}
refetch_detail = true;
}
Err(e) => *error = Some(e.text()),
}
}
if let Some(result) = deleting.as_ref().and_then(take) {
*deleting = None;
match result {
@@ -601,6 +728,10 @@ impl Screen {
detail_job: None,
deleting: None,
member_job: None,
settings: None,
regions: None,
regions_job: None,
saving: None,
error: None,
job: None,
};
@@ -614,6 +745,9 @@ impl Screen {
SubScreen::Connecting { .. } => return self.poll_join(),
_ => {}
}
if open_region_screen {
self.open_region_preference();
}
if slot_options_saved {
self.back_to_worlds();
}
@@ -688,6 +822,18 @@ impl Screen {
SubScreen::JoinCode { .. } => Some([(w / 2.0).floor() - 100.0, 64.0, 200.0, 20.0]),
SubScreen::Invite { .. } => Some([(w / 2.0).floor() - 100.0, 62.0, 200.0, 20.0]),
SubScreen::SlotOptions { .. } => Some([(w / 2.0).floor() - 154.0, 62.0, 152.0, 20.0]),
SubScreen::Configure {
tab: ConfigureTab::Settings,
settings: Some(draft),
..
} => {
let left = (w / 2.0).floor() - 106.0;
Some(if draft.on_description {
[left, 86.0, 212.0, 20.0]
} else {
[left, 46.0, 212.0, 20.0]
})
}
_ => None,
}
}
@@ -697,6 +843,15 @@ impl Screen {
SubScreen::JoinCode { code, .. } => Some((REALMS_INVITE_CODE, code, 128)),
SubScreen::Invite { name, .. } => Some((REALMS_PLAYER_NAME, name, 16)),
SubScreen::SlotOptions { name, .. } => Some((REALMS_SLOT_NAME, name, 16)),
SubScreen::Configure {
tab: ConfigureTab::Settings,
settings: Some(draft),
..
} => Some(if draft.on_description {
(REALMS_SETTINGS_DESC, &mut draft.description, 32)
} else {
(REALMS_SETTINGS_NAME, &mut draft.name, 32)
}),
_ => None,
}
}
@@ -714,6 +869,7 @@ impl Screen {
}
fn back_to_list(&mut self) {
self.save_settings_tab();
if let Some((_, aborted)) = self.joining.take() {
aborted.store(true, Ordering::Relaxed);
}
@@ -829,6 +985,94 @@ impl Screen {
}
}
/// A chosen region preference: back to the settings tab with it.
fn back_to_settings(&mut self, server: RealmServer, settings: SettingsDraft) {
self.subscreen = SubScreen::Configure {
server,
tab: ConfigureTab::Settings,
subscription: None,
subscription_job: None,
detail: None,
detail_job: None,
deleting: None,
member_job: None,
settings: Some(settings),
regions: None,
regions_job: None,
saving: None,
error: None,
job: None,
};
self.start_configure_subscription();
self.start_configure_detail();
}
/// `RealmsSettingsTab.onDeselected`: the edited name, description and region preference,
/// saved through `RealmsClient.updateConfiguration` when any of them changed.
fn save_settings_tab(&mut self) {
let SubScreen::Configure {
tab: ConfigureTab::Settings,
server,
detail,
settings: Some(draft),
saving,
..
} = &mut self.subscreen
else {
return;
};
if saving.is_some() {
return;
}
let wanted = draft.preference_dto();
let region_changed = server.region_selection_preference.as_ref().map_or(
wanted.region_selection_preference != client::AUTOMATIC_OWNER,
|p| *p != wanted,
);
let changed =
draft.name.trim() != server.name || draft.description != server.motd || region_changed;
if !changed {
return;
}
let Some(Ok(detail)) = detail else {
return;
};
let Some(slot) = detail.slots.get(&detail.active_slot).cloned() else {
return;
};
if let Some(client) = client() {
let (id, name, description) = (
server.id,
draft.name.trim().to_owned(),
draft.description.clone(),
);
*saving = Some(spawn(move || {
client.update_configuration(id, &name, &description, &wanted, &slot)
}));
}
}
/// `RealmsSettingsTab.openPreferenceSelector`, once the region list is there.
fn open_region_preference(&mut self) {
let SubScreen::Configure {
server,
settings: Some(draft),
regions: Some(Ok(list)),
..
} = &self.subscreen
else {
return;
};
let (server, settings, regions) = (server.clone(), draft.clone(), list.clone());
let selected = Some((settings.preference, settings.region.clone()));
self.subscreen = SubScreen::RegionPreference {
server,
settings,
regions,
selected,
};
}
/// `RealmsSlotOptionsScreen.onClose`: back to the worlds tab.
fn back_to_worlds(&mut self) {
let SubScreen::SlotOptions { server, .. } = &self.subscreen else {
@@ -844,6 +1088,10 @@ impl Screen {
detail_job: None,
deleting: None,
member_job: None,
settings: None,
regions: None,
regions_job: None,
saving: None,
error: None,
job: None,
};
@@ -929,6 +1177,10 @@ impl Screen {
detail_job: None,
deleting: None,
member_job: None,
settings: None,
regions: None,
regions_job: None,
saving: None,
error: None,
job: None,
};
@@ -951,6 +1203,10 @@ impl Screen {
detail_job: None,
deleting: None,
member_job: None,
settings: None,
regions: None,
regions_job: None,
saving: None,
error: None,
job: None,
};
@@ -1111,6 +1367,71 @@ impl Screen {
}
Outcome::None
}
// RealmsPreferredRegionSelectionScreen.onDone / onClose.
Some(87) => {
if let SubScreen::RegionPreference {
server,
settings,
selected,
..
} = &self.subscreen
&& let Some((preference, region)) = selected
{
let server = server.clone();
let mut settings = settings.clone();
settings.preference = *preference;
settings.region = region.clone();
self.back_to_settings(server, settings);
}
Outcome::None
}
Some(86) => {
if let SubScreen::RegionPreference {
server, settings, ..
} = &self.subscreen
{
let server = server.clone();
let settings = settings.clone();
self.back_to_settings(server, settings);
}
Outcome::None
}
// RealmsSettingsTab.openPreferenceSelector: the region list, then the screen.
Some(89) => {
if let SubScreen::Configure {
server,
settings: Some(draft),
regions,
..
} = &self.subscreen
{
let draft = draft.clone();
let server = server.clone();
let loaded = matches!(regions, Some(Ok(_)));
if loaded {
if let SubScreen::Configure {
regions: Some(Ok(regions)),
..
} = &self.subscreen
{
let regions = regions.clone();
let selected = Some((draft.preference, draft.region.clone()));
self.subscreen = SubScreen::RegionPreference {
server,
settings: draft,
regions,
selected,
};
}
} else if let SubScreen::Configure { regions_job, .. } = &mut self.subscreen
&& regions_job.is_none()
&& let Some(client) = client()
{
*regions_job = Some(spawn(move || client.preferred_regions()));
}
}
Outcome::None
}
// RealmsSlotOptionsScreen: cycle, toggle, Done and Cancel.
Some(95) => {
if let SubScreen::SlotOptions {
@@ -1261,14 +1582,67 @@ impl Screen {
let (bar_x, tab_w) = tab_geometry(w);
if y < TAB_HEIGHT && x >= bar_x {
let index = ((x - bar_x) / tab_w).floor() as usize;
if let SubScreen::Configure { server, tab, .. } = &mut self.subscreen
let current = match &self.subscreen {
SubScreen::Configure { tab, .. } => Some(*tab),
_ => None,
};
if let (Some(current), Some(wanted)) = (current, ConfigureTab::ALL.get(index))
&& *wanted != current
&& current == ConfigureTab::Settings
{
self.save_settings_tab();
}
if let SubScreen::Configure {
server,
tab,
settings,
..
} = &mut self.subscreen
&& let Some(wanted) = ConfigureTab::ALL.get(index)
&& wanted.enabled(server.expired)
{
if *wanted == ConfigureTab::Settings && settings.is_none() {
*settings = Some(SettingsDraft::of(server));
}
*tab = *wanted;
}
return Outcome::None;
}
// RealmsSettingsTab's two fields: the click moves the caret between them.
if let SubScreen::Configure {
tab: ConfigureTab::Settings,
settings: Some(draft),
..
} = &mut self.subscreen
{
let left = (w / 2.0).floor() - 106.0;
if (left..left + 212.0).contains(&x) {
if (46.0..66.0).contains(&y) {
draft.on_description = false;
return Outcome::None;
}
if (86.0..106.0).contains(&y) {
draft.on_description = true;
return Outcome::None;
}
}
}
// RealmsPreferredRegionSelectionScreen: a row is the preference (the region
// rows are MANUAL + that region).
if let SubScreen::RegionPreference {
regions, selected, ..
} = &mut self.subscreen
{
let rows = region_rows(regions);
let top = 40.0;
let row = ((y - top) / 16.0).floor() as usize;
if y >= top
&& let Some(pick) = rows.get(row)
{
*selected = Some(pick.clone());
}
return Outcome::None;
}
// RealmsSlotOptionsScreen's spawn-protection slider: a click on the track
// sets the value (there is no drag event on this screen yet).
if let SubScreen::SlotOptions {
@@ -1370,6 +1744,10 @@ impl Screen {
detail_job: None,
deleting: None,
member_job: Some(spawn(move || client.switch_slot(id, slot).map(drop))),
settings: None,
regions: None,
regions_job: None,
saving: None,
error: None,
job: None,
};
@@ -1393,6 +1771,10 @@ impl Screen {
detail_job: None,
deleting: None,
member_job: Some(spawn(move || client.uninvite_player(id, &uuid))),
settings: None,
regions: None,
regions_job: None,
saving: None,
error: None,
job: None,
};
@@ -1417,6 +1799,10 @@ impl Screen {
detail_job: None,
deleting: Some(spawn(move || client.delete_realm(id))),
member_job: None,
settings: None,
regions: None,
regions_job: None,
saving: None,
error: None,
job: None,
};
@@ -1533,7 +1919,7 @@ impl Screen {
// The footer: Play, the open/close toggle (vanilla keeps it on the settings
// tab; it stays here until that tab lands) and Back.
buttons.push((
cx - 155.0,
cx - 102.0,
h - 26.0,
100.0,
t("mco.selectServer.play"),
@@ -1541,13 +1927,26 @@ impl Screen {
64,
None,
));
let toggle = if server.is_open() {
t("mco.configure.world.buttons.close")
} else {
t("mco.configure.world.buttons.open")
};
buttons.push((cx - 50.0, h - 26.0, 100.0, toggle, job.is_none(), 30, None));
buttons.push((cx + 55.0, h - 26.0, 100.0, t("gui.back"), true, 32, None));
buttons.push((cx + 2.0, h - 26.0, 100.0, t("gui.back"), true, 32, None));
// RealmsSettingsTab: the region preference and the open/close toggle
// (vanilla keeps the toggle on this tab).
if *tab == ConfigureTab::Settings {
buttons.push((
cx - 106.0,
146.0,
212.0,
t("mco.configure.world.buttons.region_preference"),
true,
89,
None,
));
let toggle = if server.is_open() {
t("mco.configure.world.buttons.close")
} else {
t("mco.configure.world.buttons.open")
};
buttons.push((cx - 106.0, 174.0, 212.0, toggle, job.is_none(), 30, None));
}
// RealmsSubscriptionTab's own two buttons.
if *tab == ConfigureTab::Subscription && matches!(subscription, Some(Ok(_))) {
buttons.push((
@@ -1626,6 +2025,10 @@ impl Screen {
));
}
}
SubScreen::RegionPreference { .. } => {
buttons.push((cx - 102.0, h - 36.0, 100.0, t("gui.done"), true, 87, None));
buttons.push((cx + 2.0, h - 36.0, 100.0, t("gui.cancel"), true, 86, None));
}
SubScreen::SlotOptions {
server,
slot,
@@ -1978,6 +2381,9 @@ impl Screen {
tab,
subscription,
detail,
settings,
regions,
saving,
deleting,
error,
job,
@@ -2187,16 +2593,77 @@ impl Screen {
u32::MAX,
),
},
// The Worlds and Settings tabs land in follow-ups; vanilla shows this
// while its own fetches run.
_ => centered(
batch,
atlas,
&t("mco.configure.world.loading"),
cx,
60.0,
u32::MAX,
),
ConfigureTab::Settings => match settings {
Some(draft) => {
// `RealmsSettingsTab`: the name and description fields, the
// region preference line and the open/close button.
let left = cx - 106.0;
batch.text(
atlas,
&t("mco.configure.world.name"),
left,
34.0,
GREY,
true,
);
caret.draw(
batch,
atlas,
(REALMS_SETTINGS_NAME, &draft.name, 32),
[left, 46.0, 212.0, 20.0],
!draft.on_description,
focused_at,
);
batch.text(
atlas,
&t("mco.configure.world.description"),
left,
74.0,
GREY,
true,
);
caret.draw(
batch,
atlas,
(REALMS_SETTINGS_DESC, &draft.description, 32),
[left, 86.0, 212.0, 20.0],
draft.on_description,
focused_at,
);
batch.text(
atlas,
&t("mco.configure.world.region_preference"),
left,
114.0,
GREY,
true,
);
let current =
preference_label(draft.preference, draft.region.as_deref());
batch.text(atlas, &current, left, 126.0, GREY, true);
if saving.is_some() {
centered(
batch,
atlas,
&t("mco.configure.world.loading"),
cx,
200.0,
u32::MAX,
);
}
if regions.is_none() {
// The region list is loading: the button says so when clicked.
}
}
None => centered(
batch,
atlas,
&t("mco.configure.world.loading"),
cx,
60.0,
u32::MAX,
),
},
}
let status = if job.is_some() {
Some((
@@ -2218,6 +2685,34 @@ impl Screen {
}
}
}
SubScreen::RegionPreference {
regions, selected, ..
} => {
// `RealmsPreferredRegionSelectionScreen`: the preference rows, then the
// MANUAL rows per region.
centered(
batch,
atlas,
&t("mco.configure.world.region_preference.title"),
cx,
14.0,
u32::MAX,
);
let top = 40.0;
let rows = region_rows(regions);
let left = ((w - 300.0) / 2.0).floor();
for (i, (preference, region)) in rows.iter().enumerate() {
let y = top + i as f32 * 16.0;
let picked = selected
.as_ref()
.is_some_and(|(p, r)| p == preference && *r == *region);
let label = preference_label(*preference, region.as_deref());
if picked {
batch.fill(atlas, left, y, 300.0, 14.0, 0xFF30_3030);
}
batch.text(atlas, &label, left + 5.0, y + 2.0, u32::MAX, true);
}
}
SubScreen::SlotOptions {
server,
slot,
@@ -2735,6 +3230,31 @@ mod tests {
assert_eq!(enabled(15), Some(false), "invites");
}
/// `RealmsSettingsTab.updateData`: the draft starts from the Realm's own name,
/// description and region preference; a Realm without one is automatic by owner
/// (`RegionSelectionPreferenceDto.DEFAULT`), and only a manual choice sends its region.
#[test]
fn the_settings_draft_starts_from_the_realm() {
let server: RealmServer = serde_json::from_str(
r#"{"id":7,"name":"Mine","motd":"Hello",
"regionSelectionPreference":{"regionSelectionPreference":2,"preferredRegion":"EastUs2"}}"#,
)
.unwrap();
let mut draft = SettingsDraft::of(&server);
assert_eq!(
(draft.name.as_str(), draft.description.as_str()),
("Mine", "Hello")
);
assert_eq!(
(draft.preference, draft.region.as_deref()),
(2, Some("EastUs2"))
);
draft.preference = client::AUTOMATIC_PLAYER;
assert_eq!(draft.preference_dto().preferred_region, None);
let bare = SettingsDraft::of(&RealmServer::default());
assert_eq!((bare.preference, bare.region), (1, None));
}
/// `RealmsWorldOptions.getSlotName`: the player's name, else the empty marker, else
/// "World N" by the slot.
#[test]
+290 -16
View File
@@ -5,7 +5,7 @@
//! `x-mojangauth` header vanilla adds for the invite-code service); it is never logged, never
//! put in an error and never sent anywhere else.
use horizon_core::version;
use serde::{Deserialize, Deserializer};
use serde::{Deserialize, Deserializer, Serialize};
use std::time::Duration;
/// `RealmsClient.Environment.PRODUCTION`'s base URL.
@@ -70,6 +70,9 @@ pub struct RealmServer {
/// The world slots by id: only in the detail answer too (`RealmsServer.slots`).
#[serde(default = "empty_slots", deserialize_with = "slots")]
pub slots: std::collections::HashMap<i32, RealmSlot>,
/// The settings tab's region preference (`RealmsServer.regionSelectionPreference`).
#[serde(default)]
pub region_selection_preference: Option<RegionSelectionPreferenceDto>,
}
/// `PlayerInfo`: a Realm's invited player.
@@ -96,7 +99,7 @@ pub struct Ops {
}
/// `RealmsSetting`: a slot's named setting (the hardcore marker).
#[derive(Clone, Debug, Deserialize, Default, PartialEq)]
#[derive(Clone, Debug, Deserialize, Serialize, Default, PartialEq)]
pub struct RealmSetting {
#[serde(default, deserialize_with = "nullable")]
pub name: String,
@@ -174,6 +177,160 @@ impl RealmSlot {
}
}
/// `RegionSelectionPreference`'s ids, as `RegionSelectionPreferenceJsonAdapter` writes them.
pub const AUTOMATIC_PLAYER: i32 = 0;
pub const AUTOMATIC_OWNER: i32 = 1;
pub const MANUAL: i32 = 2;
/// `RegionSelectionPreferenceDto`: how the Realm picks its region. The preference travels
/// as its id ([`AUTOMATIC_PLAYER`], [`AUTOMATIC_OWNER`], [`MANUAL`]) and the region as its
/// `RealmsRegion.nameId` (`EastUs2`).
#[derive(Clone, Debug, Deserialize, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RegionSelectionPreferenceDto {
/// `RegionSelectionPreferenceDto.DEFAULT`'s when the service sends none.
#[serde(default = "automatic_owner", deserialize_with = "preference_id")]
pub region_selection_preference: i32,
#[serde(default)]
pub preferred_region: Option<String>,
}
fn automatic_owner() -> i32 {
AUTOMATIC_OWNER
}
/// `RegionSelectionPreferenceJsonAdapter.read`: an id it does not know (or null) is
/// `DEFAULT_SELECTION`, automatic by player.
fn preference_id<'de, D: Deserializer<'de>>(d: D) -> Result<i32, D::Error> {
Ok(Option::<i32>::deserialize(d)?
.filter(|id| (AUTOMATIC_PLAYER..=MANUAL).contains(id))
.unwrap_or(AUTOMATIC_PLAYER))
}
/// `RegionDataDto`: one region's service quality, from `preferredRegions`.
#[derive(Clone, Debug, Deserialize, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RegionDataDto {
/// `RealmsRegion.nameId`.
#[serde(rename = "regionName", default, deserialize_with = "nullable")]
pub region: String,
/// `ServiceQuality.value`: 1 great to 4 poor, 5 unknown.
#[serde(default, deserialize_with = "nullable")]
pub service_quality: i32,
}
/// `PreferredRegionsDto`.
#[derive(Clone, Debug, Deserialize, Default)]
struct PreferredRegionsResponse {
#[serde(rename = "regionDataList", default, deserialize_with = "nullable")]
region_data: Vec<RegionDataDto>,
}
/// `RealmsRegion`: each region's `nameId` and its translation key.
pub fn region_translation_key(name_id: &str) -> Option<&'static str> {
const REGIONS: [(&str, &str); 27] = [
(
"AustraliaEast",
"realms.configuration.region.australia_east",
),
(
"AustraliaSoutheast",
"realms.configuration.region.australia_southeast",
),
("BrazilSouth", "realms.configuration.region.brazil_south"),
(
"CanadaCentral",
"realms.configuration.region.canada_central",
),
("CentralIndia", "realms.configuration.region.central_india"),
("CentralUs", "realms.configuration.region.central_us"),
("EastAsia", "realms.configuration.region.east_asia"),
("EastUs", "realms.configuration.region.east_us"),
("EastUs2", "realms.configuration.region.east_us_2"),
(
"FranceCentral",
"realms.configuration.region.france_central",
),
("JapanEast", "realms.configuration.region.japan_east"),
("JapanWest", "realms.configuration.region.japan_west"),
("KoreaCentral", "realms.configuration.region.korea_central"),
(
"MexicoCentral",
"realms.configuration.region.mexico_central",
),
(
"NorthCentralUs",
"realms.configuration.region.north_central_us",
),
("NorthEurope", "realms.configuration.region.north_europe"),
(
"SouthCentralUs",
"realms.configuration.region.south_central_us",
),
(
"SoutheastAsia",
"realms.configuration.region.southeast_asia",
),
(
"SouthAfricaNorth",
"realms.configuration.region.south_africa_north",
),
(
"SwedenCentral",
"realms.configuration.region.sweden_central",
),
("UAENorth", "realms.configuration.region.uae_north"),
("UKSouth", "realms.configuration.region.uk_south"),
(
"WestCentralUs",
"realms.configuration.region.west_central_us",
),
("WestEurope", "realms.configuration.region.west_europe"),
("WestUs", "realms.configuration.region.west_us"),
("WestUs2", "realms.configuration.region.west_us_2"),
("WestUs3", "realms.configuration.region.west_us_3"),
];
REGIONS
.iter()
.find(|(id, _)| *id == name_id)
.map(|(_, key)| *key)
}
/// `RealmsSlotUpdateDto`'s JSON for `slot`.
fn slot_update(slot: &RealmSlot) -> serde_json::Value {
serde_json::json!({
"slotId": slot.slot_id,
"spawnProtection": slot.options.spawn_protection,
"forceGameMode": slot.options.force_game_mode,
"difficulty": slot.options.difficulty,
"gameMode": slot.options.game_mode,
"slotName": slot.options.slot_name,
"version": slot.options.version,
"compatibility": slot.options.compatibility,
"worldTemplateId": slot.options.template_id,
"worldTemplateImage": slot.options.template_image,
"hardcore": slot.is_hardcore(),
})
}
/// `RealmsConfigurationDto`'s JSON.
fn configuration_body(
name: &str,
description: &str,
preference: &RegionSelectionPreferenceDto,
slot: &RealmSlot,
) -> serde_json::Value {
serde_json::json!({
"options": slot_update(slot),
"settings": slot.settings,
"regionSelectionPreference": {
"regionSelectionPreference": preference.region_selection_preference,
"preferredRegion": preference.preferred_region,
},
"description": { "name": name, "description": description },
})
}
/// `RealmsWorldOptions`: a slot's options. Fields the JSON lacks keep the values of its
/// constructor (`Default`); `empty` is never in the JSON (`RealmsSlot.defaults` sets it).
#[derive(Clone, Debug, Deserialize, PartialEq)]
@@ -618,20 +775,7 @@ impl RealmsClient {
/// `RealmsClient.updateSlot`: `POST worlds/{id}/slot/{slotId}` with
/// `RealmsSlotUpdateDto`'s JSON.
pub fn update_slot(&self, realm_id: i64, slot: &RealmSlot) -> Result<(), RealmsError> {
let body = serde_json::json!({
"slotId": slot.slot_id,
"spawnProtection": slot.options.spawn_protection,
"forceGameMode": slot.options.force_game_mode,
"difficulty": slot.options.difficulty,
"gameMode": slot.options.game_mode,
"slotName": slot.options.slot_name,
"version": slot.options.version,
"compatibility": slot.options.compatibility,
"worldTemplateId": slot.options.template_id,
"worldTemplateImage": slot.options.template_image,
"hardcore": slot.is_hardcore(),
})
.to_string();
let body = slot_update(slot).to_string();
let url = self.url(&format!("worlds/{realm_id}/slot/{}", slot.slot_id));
Self::execute(self.request("POST", &url, READ_TIMEOUT), Some(&body)).map(drop)
}
@@ -643,6 +787,34 @@ impl RealmsClient {
json.trim().parse::<bool>().map_err(|_| RealmsError::Parse)
}
/// `RealmsClient.getPreferredRegionSelections`: every region and its quality.
pub fn preferred_regions(&self) -> Result<Vec<RegionDataDto>, RealmsError> {
let json = Self::execute(
self.request("GET", &self.url("regions/preferredRegions"), READ_TIMEOUT),
None,
)?;
serde_json::from_str::<PreferredRegionsResponse>(&json)
.map(|r| r.region_data)
.map_err(|_| RealmsError::Parse)
}
/// `RealmsClient.updateConfiguration`: `POST worlds/{id}/configuration` with
/// `RealmsConfigurationDto`'s JSON: the active slot's `RealmsSlotUpdateDto` as `options`,
/// its settings, the region preference (its id and the region's `nameId`) and the
/// name and description.
pub fn update_configuration(
&self,
realm_id: i64,
name: &str,
description: &str,
preference: &RegionSelectionPreferenceDto,
slot: &RealmSlot,
) -> Result<(), RealmsError> {
let body = configuration_body(name, description, preference, slot).to_string();
let url = self.url(&format!("worlds/{realm_id}/configuration"));
Self::execute(self.request("POST", &url, READ_TIMEOUT), Some(&body)).map(drop)
}
/// `RealmsClient.redeemInviteCode`: the invite-code service, with the token in
/// `x-mojangauth` as `frontendV2Request` sets it.
pub fn redeem_invite_code(&self, code: &str) -> Result<(), RealmsError> {
@@ -709,6 +881,26 @@ mod tests {
}
seen.extend_from_slice(&buf[..n]);
}
// The body the headers announce, which may come in a later read.
let head = seen
.windows(4)
.position(|w| w == b"\r\n\r\n")
.map_or(seen.len(), |i| i + 4);
let length = String::from_utf8_lossy(&seen[..head])
.lines()
.find_map(|l| {
let (k, v) = l.split_once(':')?;
k.eq_ignore_ascii_case("content-length")
.then(|| v.trim().parse().ok())?
})
.unwrap_or(0usize);
while seen.len() < head + length {
let n = stream.read(&mut buf).unwrap();
if n == 0 {
break;
}
seen.extend_from_slice(&buf[..n]);
}
stream.write_all(answer.as_bytes()).unwrap();
String::from_utf8_lossy(&seen).into_owned()
});
@@ -891,4 +1083,86 @@ mod tests {
);
assert_eq!(RealmsError::from_answer(404, "", false).code(), 404);
}
/// `RealmsClient.updateConfiguration`'s request as gson writes `RealmsConfigurationDto`:
/// the slot under `options`, the preference as its id and the region as its `nameId`.
#[test]
fn a_configuration_update_posts_the_vanilla_dto() {
let (base, server) =
serve_once("HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
let detail: RealmServer = serde_json::from_str(
r#"{"id":7,"activeSlot":1,"slots":[{"slotId":1,
"options":"{\"slotName\":\"Spawn\",\"worldTemplateId\":42}",
"settings":[{"name":"hardcore","value":"false"}]}]}"#,
)
.unwrap();
let preference = RegionSelectionPreferenceDto {
region_selection_preference: MANUAL,
preferred_region: Some("UKSouth".into()),
};
client(&base)
.update_configuration(7, "Base", "Hi", &preference, &detail.slots[&1])
.unwrap();
let request = server.join().unwrap();
assert!(
request.starts_with("POST /worlds/7/configuration HTTP/1.1"),
"{request}"
);
let body: serde_json::Value =
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
assert_eq!(body["options"]["slotId"], 1);
assert_eq!(body["options"]["slotName"], "Spawn");
assert_eq!(body["options"]["worldTemplateId"], 42);
assert_eq!(
body["settings"],
serde_json::json!([{"name": "hardcore", "value": "false"}])
);
assert_eq!(
body["regionSelectionPreference"],
serde_json::json!({"regionSelectionPreference": 2, "preferredRegion": "UKSouth"})
);
assert_eq!(
body["description"],
serde_json::json!({"name": "Base", "description": "Hi"})
);
}
/// `PreferredRegionsDto`: `regionDataList`, each region by `regionName` with its
/// `ServiceQuality` value; and a Realm's preference as its id.
#[test]
fn preferred_regions_and_the_realm_s_preference_read_as_vanilla_writes_them() {
let regions: PreferredRegionsResponse = serde_json::from_str(
r#"{"regionDataList":[{"regionName":"EastUs2","serviceQuality":1},
{"regionName":"WestEurope","serviceQuality":4}]}"#,
)
.unwrap();
assert_eq!(
regions.region_data,
vec![
RegionDataDto {
region: "EastUs2".into(),
service_quality: 1
},
RegionDataDto {
region: "WestEurope".into(),
service_quality: 4
},
]
);
assert_eq!(
region_translation_key("EastUs2"),
Some("realms.configuration.region.east_us_2")
);
let server: RealmServer = serde_json::from_str(
r#"{"id":7,"regionSelectionPreference":{"regionSelectionPreference":1,"preferredRegion":null}}"#,
)
.unwrap();
assert_eq!(
server.region_selection_preference,
Some(RegionSelectionPreferenceDto {
region_selection_preference: AUTOMATIC_OWNER,
preferred_region: None,
})
);
}
}
+6
View File
@@ -2985,6 +2985,7 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
let x = (w / 2.0).floor() - 155.0 + 80.0;
let y = 76.0 + (lines.len() as f32 + 1.0) * 9.0;
self.erase_check.set([x, y, width, BOX]);
crate::widgets::target([x, y, width, BOX]);
let hover = (x..x + width).contains(&cursor.0) && (y..y + BOX).contains(&cursor.1);
let sprite = match (*erase_cache, hover) {
(true, true) => "gui/sprites/widget/checkbox_selected_highlighted",
@@ -3155,6 +3156,7 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
let x = (w / 2.0).floor() - (width / 2.0).floor();
let y = top + 57.0 + lines.len() as f32 * 9.0;
self.warning_check.set([x, y, width, BOX]);
crate::widgets::target([x, y, width, BOX]);
let hover = (x..x + width).contains(&cursor.0) && (y..y + BOX).contains(&cursor.1);
crate::widgets::narratable(|| {
crate::widgets::Narratable::checkbox(
@@ -3219,6 +3221,10 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
}
for b in self.buttons(w, h) {
crate::widgets::record(&b.label, [b.x, b.y, b.w, 20.0], b.enabled);
// Realms' icon buttons have no label to record.
if b.enabled && b.label.trim().is_empty() {
crate::widgets::target([b.x, b.y, b.w, 20.0]);
}
if let Some(action) = b.action.filter(|_| !self.preparing) {
crate::widgets::declare_action(action, [b.x, b.y, b.w, 20.0], b.enabled);
}
@@ -175,3 +175,57 @@ fn client_packet_hands_a_mod_the_decoded_packet() {
[("voicechat:secret".to_string(), vec![1, 2, 3])]
);
}
#[test]
fn a_tank_hook_can_refuse_a_fluid() {
use horizon_client::mod_api::{
BlockEntityAcceptsFluidEvent, Flow, dispatch_block_entity_accepts_fluid,
};
struct TestHost;
impl Host for TestHost {}
struct WaterTank;
impl Hooks for WaterTank {
fn block_entity_accepts_fluid(
&self,
_host: &dyn Host,
event: &mut BlockEntityAcceptsFluidEvent<'_>,
) -> Flow {
if event.block == "mymod:water_tank" && event.fluid != "minecraft:water" {
Flow::Cancel
} else {
Flow::Continue
}
}
}
let mut cancel = false;
dispatch_block_entity_accepts_fluid(
&TestHost,
&[],
"mymod:water_tank",
0,
"create:chocolate",
&mut cancel,
);
assert!(!cancel, "every tank takes every fluid without a mod");
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(WaterTank)];
let mut cancel = false;
dispatch_block_entity_accepts_fluid(
&TestHost,
&mods,
"mymod:water_tank",
0,
"create:chocolate",
&mut cancel,
);
assert!(cancel, "the water tank refuses the mod's fluid");
let mut cancel = false;
dispatch_block_entity_accepts_fluid(
&TestHost,
&mods,
"mymod:water_tank",
0,
"minecraft:water",
&mut cancel,
);
assert!(!cancel, "the water tank takes water");
}
+195 -3
View File
@@ -1,7 +1,8 @@
//! What the game asks the service about its own account: whether it owns a cosmetic (`GET
//! /v1/me`: no other player's cosmetics show for one that owns none, unless the player asks), and
//! the settings it changes itself, "Show my cosmetics" (`PUT /v1/visible`) and "Show me as a
//! Horizon player" (`POST /v1/presence`, also sent every `PRESENCE_EVERY` while on).
//! Horizon player" (`POST /v1/presence`, also sent every `PRESENCE_EVERY` while on), and the
//! emote its player plays (`POST /v1/emote`, with a session kept for it: `Kept`).
//!
//! The game signs in the way an online-mode server checks a player: the service hands out a
//! one-time server id, the game tells Mojang's session server it joins it (`join`, with the
@@ -69,6 +70,19 @@ fn sign_in(
name: &str,
join: impl FnOnce(&str) -> Result<(), String>,
) -> Result<String, String> {
let mut join = Some(join);
sign_in_for(service, name, &mut |id: &str| {
join.take().ok_or("joined twice")?(id)
})
.map(|(session, _)| session)
}
/// `sign_in`, with how long the session lives (seconds; an hour if the service didn't say).
fn sign_in_for(
service: &impl Call,
name: &str,
join: &mut impl FnMut(&str) -> Result<(), String>,
) -> Result<(String, u64), String> {
let start = service.call("POST", "/v1/auth/start", None, &json!({}))?;
let server_id = start["server_id"].as_str().ok_or("no server id")?;
join(server_id)?;
@@ -78,7 +92,108 @@ fn sign_in(
None,
&json!({ "server_id": server_id, "name": name }),
)?;
Ok(finish["session"].as_str().ok_or("no session")?.to_owned())
let session = finish["session"].as_str().ok_or("no session")?.to_owned();
Ok((session, finish["expires_in"].as_u64().unwrap_or(3600)))
}
/// The service's session for an emote's start and stop, kept for the time the service gave less a
/// minute: emotes come often, Mojang's `join` shouldn't each time.
#[derive(Default)]
pub struct Kept {
session: Option<(String, std::time::Instant)>,
}
impl Kept {
/// `method` on `path` with `body` as the account `name`, signing in first if no session is
/// kept; a session the service no longer knows (401) is dropped and the call made once more
/// after a sign-in.
pub fn call(
&mut self,
service: &impl Call,
name: &str,
mut join: impl FnMut(&str) -> Result<(), String>,
method: &str,
path: &str,
body: &Value,
) -> Result<Value, String> {
for attempt in 0..2 {
let fresh = self
.session
.as_ref()
.filter(|(_, until)| std::time::Instant::now() < *until);
let session = match fresh {
Some((s, _)) => s.clone(),
None => {
let (session, seconds) = sign_in_for(service, name, &mut join)?;
let keep = std::time::Duration::from_secs(seconds.saturating_sub(60));
self.session = Some((session.clone(), std::time::Instant::now() + keep));
session
}
};
match service.call(method, path, Some(&session), body) {
Err(e) if e == "HTTP 401" && attempt == 0 => self.session = None,
answer => return answer,
}
}
Err("HTTP 401".into())
}
}
/// Tells the service the player plays the owned emote `emote` now (`None`: stopped); only while
/// they show themselves as a Horizon player (else the service answers 409).
pub fn play_emote(
kept: &mut Kept,
service: &impl Call,
name: &str,
join: impl FnMut(&str) -> Result<(), String>,
emote: Option<&str>,
) -> Result<(), String> {
kept.call(
service,
name,
join,
"POST",
"/v1/emote",
&json!({ "emote": emote }),
)
.map(|_| ())
}
/// An emote the account owns: its item and its names by locale (`en_us`, `fr_fr`...).
pub type OwnedEmote = (crate::Item, std::collections::BTreeMap<String, String>);
/// The emotes the account `name` owns (`GET /v1/me`'s `emotes`: id, file hash, names), their
/// files read with `get` (`/v1/items/<id>?hash=`). A file that fails to come or to parse, or
/// isn't the emote it says, is left out.
pub fn owned_emotes(
kept: &mut Kept,
service: &impl Call,
name: &str,
join: impl FnMut(&str) -> Result<(), String>,
get: impl Fn(&str) -> Result<Vec<u8>, String>,
) -> Result<Vec<OwnedEmote>, String> {
let me = kept.call(service, name, join, "GET", "/v1/me", &Value::Null)?;
let mut emotes = Vec::new();
for e in me["emotes"].as_array().into_iter().flatten() {
let (Some(id), Some(hash)) = (e["id"].as_str(), e["hash"].as_str()) else {
continue;
};
let item = get(&format!("/v1/items/{id}?hash={hash}"))
.ok()
.and_then(|bytes| crate::Item::parse(&bytes).ok())
.filter(|i| i.slot == crate::Slot::Emote && i.id == id);
let Some(item) = item else {
continue;
};
let names = e["names"]
.as_object()
.into_iter()
.flatten()
.filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_owned())))
.collect();
emotes.push((item, names));
}
Ok(emotes)
}
/// Whether the account `name` owns at least one cosmetic (signed in as `set_visible` does).
@@ -147,8 +262,16 @@ mod tests {
}
"/v1/auth/finish" => Err("HTTP 401".into()),
"/v1/visible" if s == Some("svc") => Ok(json!({"visible": body["visible"]})),
"/v1/me" if s == Some("svc") => Ok(json!({"owned": self.owned})),
"/v1/me" if s == Some("svc") => Ok(json!({
"owned": self.owned,
"emotes": [
{"id": "wave", "hash": "h1", "names": {"en_us": "Wave", "fr_fr": "Coucou"}},
{"id": "gone", "hash": "h1", "names": {}},
{"id": "hat", "hash": "h1", "names": {}},
],
})),
"/v1/presence" if s == Some("svc") => Ok(json!({"listed": body["listed"]})),
"/v1/emote" if s == Some("svc") => Ok(json!({"emote": body["emote"]})),
_ => Err("HTTP 401".into()),
}
}
@@ -212,6 +335,75 @@ mod tests {
assert!(set_visible(&service, "Alex", |_| Ok(()), false).is_err());
}
#[test]
fn emotes_sign_in_once_and_again_only_when_the_session_is_gone() {
let service = Service::default();
let joins = std::cell::Cell::new(0);
let join = |id: &str| {
joins.set(joins.get() + 1);
*service.joined.borrow_mut() = Some(id.to_owned());
Ok(())
};
let mut kept = Kept::default();
for emote in [Some("wave_hello"), None, Some("wave_hello")] {
play_emote(&mut kept, &service, "Alex", join, emote).unwrap();
}
assert_eq!(joins.get(), 1);
let sent = service.sent.borrow();
let emotes: Vec<(&str, Option<&str>, &Value)> = sent
.iter()
.filter(|c| c.1 == "/v1/emote")
.map(|c| (c.0.as_str(), c.2.as_deref(), &c.3))
.collect();
assert_eq!(emotes.len(), 3);
assert_eq!(
emotes[0],
("POST", Some("svc"), &json!({"emote": "wave_hello"}))
);
assert_eq!(emotes[1].2, &json!({"emote": null}));
drop(sent);
// The service forgot the session (a restart): one more sign-in, then the call.
kept.session = Some((
"stale".into(),
std::time::Instant::now() + std::time::Duration::from_secs(60),
));
play_emote(&mut kept, &service, "Alex", join, Some("wave_hello")).unwrap();
assert_eq!(joins.get(), 2);
}
#[test]
fn the_wheel_gets_the_owned_emotes_files_and_names() {
let service = Service::default();
let join = |id: &str| {
*service.joined.borrow_mut() = Some(id.to_owned());
Ok(())
};
let emote = r#"{"format": 1, "id": "wave", "slot": "emote", "parts": [],
"emote": {"length": 20, "tracks": [{"part": "right_arm", "keys": [{"t": 0}, {"t": 10, "rotation": [0, 0, 90]}]}]}}"#;
let asked = RefCell::new(Vec::new());
let get = |path: &str| {
asked.borrow_mut().push(path.to_owned());
match path {
"/v1/items/wave?hash=h1" => Ok(emote.as_bytes().to_vec()),
// A file that says it is the emote `wave`, asked for as `hat`.
"/v1/items/hat?hash=h1" => Ok(emote.as_bytes().to_vec()),
_ => Err("HTTP 404".to_owned()),
}
};
let mut kept = Kept::default();
let owned = owned_emotes(&mut kept, &service, "Alex", join, get).unwrap();
assert_eq!(owned.len(), 1);
assert_eq!(owned[0].0.id, "wave");
assert_eq!(owned[0].1["fr_fr"], "Coucou");
assert_eq!(asked.borrow().len(), 3);
let sent = service.sent.borrow();
assert_eq!(
sent.last()
.map(|c| (c.0.as_str(), c.1.as_str(), c.2.as_deref())),
Some(("GET", "/v1/me", Some("svc")))
);
}
#[test]
fn leaving_the_list_signs_in_the_same_way() {
let service = Service::default();
+723
View File
@@ -0,0 +1,723 @@
//! Emotes: an item of the `emote` slot whose `emote` block keyframes the player model's bones over
//! time (rotations in degrees, offsets in pixels), one-shot or looping, with optional props: the
//! item's own parts, boxes riding a bone like any item's, which the keyframes may move too.
//!
//! Time is in game ticks from the emote's start. Between two keys a channel eases as the first key
//! says (`smooth`: a Catmull-Rom curve through the neighbouring keys, the default; `linear`;
//! `in_out`; `step`); a loop wraps from its last key to its first at `length`. The emote fades in
//! over `blend` ticks from the pose the player had, and a one-shot fades out over its last `blend`.
use crate::item::{Bone, Error};
use crate::pose::Pose;
use glam::{EulerRot, Quat, Vec3};
use serde::Deserialize;
/// The longest emote (ticks): a one-shot's length, a loop's period.
pub const MAX_LENGTH: f32 = 200.0;
/// Tracks per emote: the six bones, the root, and props.
pub const MAX_TRACKS: usize = 16;
/// Keys per track, and in all.
pub const MAX_KEYS: usize = 32;
pub const MAX_ALL_KEYS: usize = 192;
/// How far a key may turn a bone or prop about one axis (degrees); the root may turn twice round.
pub const MAX_TURN: f32 = 360.0;
pub const MAX_ROOT_TURN: f32 = 720.0;
/// How far a key may move a bone or prop from its rest (pixels), and the whole body (a hop of a
/// block at most).
pub const MAX_MOVE: f32 = 8.0;
pub const MAX_ROOT_MOVE: f32 = 16.0;
/// The player model's bones an emote poses, in the order `Emote::pose_body` takes them.
pub const BODY: [Bone; 6] = [
Bone::Head,
Bone::Body,
Bone::RightArm,
Bone::LeftArm,
Bone::RightLeg,
Bone::LeftLeg,
];
/// The root's pivot: the feet, 24 pixels below the neck in the player model's space (y down).
const FEET: Vec3 = Vec3::new(0.0, 24.0, 0.0);
/// What a track moves.
#[derive(Clone, Debug, PartialEq)]
pub enum Target {
Bone(Bone),
/// The whole body, about the feet.
Root,
/// One of the item's own parts, on top of its rest pose.
Prop(String),
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Ease {
#[default]
Smooth,
Linear,
InOut,
Step,
}
/// A key: where its track is at `t` (rotation in radians).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Key {
pub t: f32,
pub offset: [f32; 3],
pub rotation: [f32; 3],
pub scale: f32,
pub ease: Ease,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Track {
pub target: Target,
pub keys: Vec<Key>,
}
/// A checked emote.
#[derive(Clone, Debug, PartialEq)]
pub struct Emote {
pub length: f32,
pub looping: bool,
pub blend: f32,
pub tracks: Vec<Track>,
}
/// Where a track is at a time: an offset (pixels), a rotation (radians), a scale.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Sample {
pub offset: [f32; 3],
pub rotation: [f32; 3],
pub scale: f32,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct EmoteFile {
length: f32,
#[serde(default, rename = "loop")]
looping: bool,
#[serde(default = "four")]
blend: f32,
tracks: Vec<TrackFile>,
}
fn four() -> f32 {
4.0
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct TrackFile {
part: String,
keys: Vec<KeyFile>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct KeyFile {
t: f32,
#[serde(default)]
offset: [f32; 3],
/// Degrees.
#[serde(default)]
rotation: [f32; 3],
#[serde(default)]
scale: Option<f32>,
#[serde(default)]
ease: Ease,
}
fn invalid(text: impl Into<String>) -> Error {
Error::Invalid(text.into())
}
fn bone_named(name: &str) -> Option<Bone> {
BODY.into_iter().find(|b| b.part() == name)
}
impl EmoteFile {
/// Checks the emote; `props` names the item's own parts.
pub(crate) fn check(self, props: &[String]) -> Result<Emote, Error> {
let numbers = [self.length, self.blend];
if !numbers.iter().all(|v| v.is_finite()) {
return Err(invalid("the emote's length is not a number"));
}
if !(4.0..=MAX_LENGTH).contains(&self.length) {
return Err(invalid(format!("an emote lasts 4 to {MAX_LENGTH} ticks")));
}
if !(0.0..=self.length / 2.0).contains(&self.blend) {
return Err(invalid("an emote's blend is at most half its length"));
}
if self.tracks.is_empty() || self.tracks.len() > MAX_TRACKS {
return Err(invalid(format!("an emote has 1 to {MAX_TRACKS} tracks")));
}
let all: usize = self.tracks.iter().map(|t| t.keys.len()).sum();
if all > MAX_ALL_KEYS {
return Err(invalid(format!("{all} keys, at most {MAX_ALL_KEYS}")));
}
let mut tracks: Vec<Track> = Vec::new();
for file in self.tracks {
let target = match file.part.as_str() {
"root" => Target::Root,
name => match bone_named(name) {
Some(bone) => Target::Bone(bone),
None if props.iter().any(|p| p == name) => Target::Prop(name.to_owned()),
None => return Err(invalid(format!("an emote moves no part {name:?}"))),
},
};
if tracks.iter().any(|t| t.target == target) {
return Err(invalid(format!("two tracks move {:?}", file.part)));
}
if file.keys.is_empty() || file.keys.len() > MAX_KEYS {
return Err(invalid(format!("a track has 1 to {MAX_KEYS} keys")));
}
let (turn, reach) = match target {
Target::Root => (MAX_ROOT_TURN, MAX_ROOT_MOVE),
_ => (MAX_TURN, MAX_MOVE),
};
let mut keys = Vec::with_capacity(file.keys.len());
for k in file.keys {
let scale = k.scale.unwrap_or(1.0);
let values = [k.t, scale].into_iter().chain(k.offset).chain(k.rotation);
if !values.into_iter().all(f32::is_finite) {
return Err(invalid("an emote's key is not a number"));
}
if keys.last().is_some_and(|last: &Key| k.t <= last.t)
|| !(0.0..=self.length).contains(&k.t)
{
return Err(invalid("keys go forward in time within the emote"));
}
if k.rotation.iter().any(|r| r.abs() > turn)
|| k.offset.iter().any(|o| o.abs() > reach)
{
return Err(invalid(format!(
"a key of {:?} turns or moves too far",
file.part
)));
}
// Only a prop grows or shrinks (it may pop in from nothing).
if k.scale.is_some() && !matches!(target, Target::Prop(_))
|| !(0.0..=2.0).contains(&scale)
{
return Err(invalid("only a prop's key scales, from 0 to 2"));
}
keys.push(Key {
t: k.t,
offset: k.offset,
rotation: k.rotation.map(f32::to_radians),
scale,
ease: k.ease,
});
}
tracks.push(Track { target, keys });
}
Ok(Emote {
length: self.length,
looping: self.looping,
blend: self.blend,
tracks,
})
}
}
/// Catmull-Rom through `p1` to `p2`, `p0` and `p3` their neighbours.
fn catmull(p0: f32, p1: f32, p2: f32, p3: f32, s: f32) -> f32 {
let s2 = s * s;
0.5 * (2.0 * p1
+ (p2 - p0) * s
+ (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * s2
+ (3.0 * p1 - p0 - 3.0 * p2 + p3) * s2 * s)
}
impl Track {
/// The track at `t` ticks into an emote of `length` (wrapped if `looping`).
pub fn sample(&self, t: f32, length: f32, looping: bool) -> Sample {
let keys = &self.keys;
let n = keys.len();
let at = |k: &Key| Sample {
offset: k.offset,
rotation: k.rotation,
scale: k.scale,
};
let t = if looping {
t.rem_euclid(length)
} else {
t.clamp(0.0, length)
};
// The segment from key i to the next: wrapping round in a loop, held at the ends else.
let i = match keys.iter().rposition(|k| k.t <= t) {
Some(i) => i,
None if looping => n - 1,
None => return at(&keys[0]),
};
if i == n - 1 && !looping {
return at(&keys[i]);
}
let next = (i + 1) % n;
let (t0, mut t1) = (keys[i].t, keys[next].t);
let mut now = t;
if next <= i {
t1 += length;
if now < t0 {
now += length;
}
}
let span = t1 - t0;
let s = if span > 0.0 {
((now - t0) / span).clamp(0.0, 1.0)
} else {
0.0
};
let (k0, k1) = (&keys[i], &keys[next]);
let neighbour = |j: isize| -> &Key {
if looping {
&keys[j.rem_euclid(n as isize) as usize]
} else {
&keys[j.clamp(0, n as isize - 1) as usize]
}
};
let (kp, kn) = (neighbour(i as isize - 1), neighbour(i as isize + 2));
let mix = |a: f32, b: f32, pa: f32, nb: f32| match k0.ease {
Ease::Step => a,
Ease::Linear => a + (b - a) * s,
Ease::InOut => a + (b - a) * (s * s * (3.0 - 2.0 * s)),
Ease::Smooth => catmull(pa, a, b, nb, s),
};
let three = |f: fn(&Key) -> [f32; 3]| -> [f32; 3] {
let (a, b, p, q) = (f(k0), f(k1), f(kp), f(kn));
std::array::from_fn(|c| mix(a[c], b[c], p[c], q[c]))
};
Sample {
offset: three(|k| k.offset),
rotation: three(|k| k.rotation),
scale: mix(k0.scale, k1.scale, kp.scale, kn.scale),
}
}
}
/// `smoothstep`, clamped.
fn smooth01(x: f32) -> f32 {
let x = x.clamp(0.0, 1.0);
x * x * (3.0 - 2.0 * x)
}
fn quat(r: [f32; 3]) -> Quat {
Quat::from_euler(EulerRot::ZYX, r[2], r[1], r[0])
}
impl Emote {
/// How much of the emote shows at `t` ticks in: fading in over `blend`, and out over the last
/// `blend` of a one-shot. Past a one-shot's end: 0.
pub fn weight(&self, t: f32) -> f32 {
if t < 0.0 || !self.looping && t >= self.length {
return 0.0;
}
if self.blend <= 0.0 {
return 1.0;
}
let fade_in = smooth01(t / self.blend);
if self.looping {
fade_in
} else {
fade_in.min(smooth01((self.length - t) / self.blend))
}
}
/// Poses the player model's bones (`BODY`'s order) `t` ticks in: `rest` is the model at
/// rest, `poses` what its animation made of it, blended toward the emote by `weight` (1: the
/// emote's own pose). A bone the emote doesn't key keeps its animation; the root moves all
/// six about the feet.
pub fn pose_body(&self, t: f32, weight: f32, rest: &[Pose; 6], poses: &mut [Pose; 6]) {
if weight <= 0.0 {
return;
}
let mut target = *poses;
for track in &self.tracks {
if let Target::Bone(bone) = track.target {
let i = BODY.iter().position(|b| *b == bone).unwrap_or(0);
let s = track.sample(t, self.length, self.looping);
let r = rest[i];
target[i] = [
r[0] + s.offset[0],
r[1] + s.offset[1],
r[2] + s.offset[2],
s.rotation[0],
s.rotation[1],
s.rotation[2],
r[6],
r[7],
r[8],
];
}
}
if let Some(root) = self.tracks.iter().find(|t| t.target == Target::Root) {
let s = root.sample(t, self.length, self.looping);
let turn = quat(s.rotation);
let shift = Vec3::from_array(s.offset);
for p in &mut target {
let at = FEET + turn * (Vec3::new(p[0], p[1], p[2]) - FEET) + shift;
let (z, y, x) = (turn * quat([p[3], p[4], p[5]])).to_euler(EulerRot::ZYX);
p[..6].copy_from_slice(&[at.x, at.y, at.z, x, y, z]);
}
}
// Fully in (most of an emote): the target as it is, no blending to pay for.
if weight >= 1.0 {
for (p, goal) in poses.iter_mut().zip(target) {
p[..6].copy_from_slice(&goal[..6]);
}
return;
}
let w = weight;
for (p, goal) in poses.iter_mut().zip(target) {
let at = Vec3::new(p[0], p[1], p[2]).lerp(Vec3::new(goal[0], goal[1], goal[2]), w);
let turned = quat([p[3], p[4], p[5]]).slerp(quat([goal[3], goal[4], goal[5]]), w);
let (z, y, x) = turned.to_euler(EulerRot::ZYX);
p[..6].copy_from_slice(&[at.x, at.y, at.z, x, y, z]);
}
}
/// The props' poses `t` ticks in, each from its rest pose (`rest`, by part name).
pub fn pose_props(
&self,
t: f32,
rest: impl Fn(&str) -> Option<Pose>,
mut set: impl FnMut(&str, Pose),
) {
for track in &self.tracks {
let Target::Prop(name) = &track.target else {
continue;
};
let Some(mut pose) = rest(name) else {
continue;
};
let s = track.sample(t, self.length, self.looping);
for c in 0..3 {
pose[c] += s.offset[c];
pose[3 + c] += s.rotation[c];
pose[6 + c] *= s.scale;
}
set(name, pose);
}
}
/// Times worth checking the props' reach at: every key, and 32 points over the length.
pub(crate) fn sample_times(&self) -> impl Iterator<Item = f32> + '_ {
let keys = self.tracks.iter().flat_map(|t| t.keys.iter().map(|k| k.t));
keys.chain((0..32).map(|k| self.length * k as f32 / 32.0))
}
}
/// An emote someone plays: the item, and when it started and stopped (ticks of the clock the
/// game samples it with, such as the player's age).
#[derive(Clone, Debug)]
pub struct Playing {
pub item: std::sync::Arc<crate::Item>,
pub start: f32,
/// Cancelled at this time: it fades out over its blend from there.
pub stop: Option<f32>,
}
impl Playing {
/// The emote's own time and weight at `now`, or `None` once it is over.
pub fn at(&self, now: f32) -> Option<(f32, f32)> {
let emote = self.item.emote.as_ref()?;
let t = now - self.start;
let mut weight = emote.weight(t);
if let Some(stop) = self.stop {
let fade = if emote.blend > 0.0 {
smooth01(1.0 - (now - stop) / emote.blend)
} else {
0.0
};
weight = weight.min(fade);
}
let over = !emote.looping && t >= emote.length || self.stop.is_some() && weight <= 0.0;
(!over).then_some((t, weight))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Item;
use crate::pose::tests::png;
use std::f32::consts::{FRAC_PI_2, PI};
/// A wave: the right arm up over 8 ticks, waved twice, down by 40; a balloon prop in the left
/// hand that pops in.
fn wave(edit: impl Fn(String) -> String) -> String {
let file = format!(
r#"{{"format": 1, "id": "wave", "slot": "emote",
"texture": {{"png": "{}"}},
"parts": [{{"name": "balloon", "bone": "left_arm", "pivot": [1, 10, 0],
"cubes": [{{"origin": [-2, -4, -2], "size": [4, 4, 4], "uv": [0, 0]}}]}}],
"emote": {{"length": 40, "blend": 4, "tracks": [
{{"part": "right_arm", "keys": [
{{"t": 0}},
{{"t": 8, "rotation": [0, 0, 150], "ease": "linear"}},
{{"t": 16, "rotation": [0, 0, 120], "ease": "step"}},
{{"t": 24, "rotation": [0, 0, 150]}},
{{"t": 40}}]}},
{{"part": "balloon", "keys": [
{{"t": 0, "scale": 0, "ease": "in_out"}}, {{"t": 10, "offset": [0, -4, 0]}}]}}]}}}}"#,
png(16, 16)
);
edit(file)
}
fn parse(file: &str) -> Result<Item, crate::Error> {
Item::parse(file.as_bytes())
}
fn emote(file: &str) -> Emote {
parse(file).unwrap().emote.unwrap()
}
fn near(a: f32, b: f32) {
assert!((a - b).abs() < 1e-4, "{a} != {b}");
}
#[test]
fn keys_are_hit_and_each_ease_runs_between_them() {
let e = emote(&wave(|f| f));
let arm = &e.tracks[0];
assert_eq!(arm.target, Target::Bone(crate::Bone::RightArm));
let z = |t: f32| arm.sample(t, e.length, e.looping).rotation[2].to_degrees();
// A smooth curve passes through its keys.
near(z(0.0), 0.0);
near(z(8.0), 150.0);
near(z(24.0), 150.0);
near(z(40.0), 0.0);
// Linear from 8 to 16: halfway is the mean; step from 16 holds until 24.
near(z(12.0), 135.0);
near(z(20.0), 120.0);
near(z(23.9), 120.0);
// Held after the end of a one-shot.
near(z(55.0), 0.0);
}
#[test]
fn smooth_keys_leave_no_corner() {
// Smooth through 0, 90, 0 at 0, 10, 20: the slope is the same on both sides of each inner
// key, and the curve tops out at the key (Catmull-Rom's tangent there is flat).
let file = wave(|f| {
f.replace(
r#"{"t": 8, "rotation": [0, 0, 150], "ease": "linear"},
{"t": 16, "rotation": [0, 0, 120], "ease": "step"},
{"t": 24, "rotation": [0, 0, 150]},"#,
r#"{"t": 10, "rotation": [0, 0, 90]}, {"t": 20}, {"t": 30, "rotation": [0, 0, -90]},"#,
)
});
let e = emote(&file);
let z = |t: f32| e.tracks[0].sample(t, e.length, false).rotation[2];
for key in [10.0, 20.0] {
let (h, k) = (1e-2, key);
let left = (z(k) - z(k - h)) / h;
let right = (z(k + h) - z(k)) / h;
assert!((left - right).abs() < 0.02, "{key}: {left} vs {right}");
}
assert!(z(9.5) < z(10.0) && z(10.5) < z(10.0));
}
#[test]
fn a_loop_wraps_from_its_last_key_to_its_first() {
let file = wave(|f| {
f.replace(
r#""length": 40, "blend": 4"#,
r#""length": 40, "loop": true"#,
)
.replace(
r#"{"t": 40}"#,
r#"{"t": 32, "rotation": [0, 0, 40], "ease": "linear"}"#,
)
});
let e = emote(&file);
let z = |t: f32| e.tracks[0].sample(t, e.length, true).rotation[2].to_degrees();
// From 40° at 32 back to 0° at 40 (= 0), linearly: 20° at 36.
near(z(36.0), 20.0);
// A period later, the same.
near(z(76.0), 20.0);
near(z(48.0), 150.0);
// A loop fades in, then stays.
near(e.weight(0.0), 0.0);
near(e.weight(2.0), 0.5);
near(e.weight(400.0), 1.0);
}
#[test]
fn a_one_shot_fades_in_and_out_and_a_cancel_fades_it_out() {
let item = std::sync::Arc::new(parse(&wave(|f| f)).unwrap());
let e = item.emote.as_ref().unwrap();
near(e.weight(2.0), 0.5);
near(e.weight(20.0), 1.0);
near(e.weight(38.0), 0.5);
assert_eq!(e.weight(40.0), 0.0);
let mut playing = Playing {
item: item.clone(),
start: 100.0,
stop: None,
};
assert_eq!(playing.at(120.0), Some((20.0, 1.0)));
assert_eq!(playing.at(140.0), None);
// Cancelled at 110: half gone 2 ticks later, over after the blend.
playing.stop = Some(110.0);
let (t, w) = playing.at(112.0).unwrap();
near(t, 12.0);
near(w, 0.5);
assert_eq!(playing.at(114.0), None);
}
/// The player model's bones at rest (`BODY`'s order) and as walking left them.
fn bodies() -> ([Pose; 6], [Pose; 6]) {
let at = |x: f32, y: f32| [x, y, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
let rest = [
at(0.0, 0.0),
at(0.0, 0.0),
at(-5.0, 2.0),
at(5.0, 2.0),
at(-1.9, 12.0),
at(1.9, 12.0),
];
let mut walking = rest;
walking[0][4] = 0.3; // the head looks aside
walking[2][3] = 0.5; // the right arm swings
(rest, walking)
}
#[test]
fn keyed_bones_take_the_emotes_pose_and_others_keep_theirs() {
let e = emote(&wave(|f| f));
let (rest, walking) = bodies();
let mut poses = walking;
e.pose_body(8.0, 1.0, &rest, &mut poses);
// The right arm: rest position, 150° about z, its swing gone.
near(poses[2][0], -5.0);
near(poses[2][3], 0.0);
near(poses[2][5], 150f32.to_radians());
// The head still looks aside; the legs don't move.
near(poses[0][4], 0.3);
assert_eq!(poses[4], walking[4]);
// Half blended: halfway between the swing and the emote's pose (one axis each).
let mut half = walking;
half[2][3] = 0.0;
e.pose_body(8.0, 0.5, &rest, &mut half);
near(half[2][5], 75f32.to_radians());
}
#[test]
fn the_root_turns_and_lifts_the_whole_body_about_the_feet() {
let file = wave(|f| {
f.replace(
r#"{"part": "balloon","#,
r#"{"part": "root", "keys": [{"t": 0, "offset": [0, -8, 0], "rotation": [0, 180, 0]}]},
{"part": "balloon","#,
)
});
let e = emote(&file);
let (rest, _) = bodies();
let mut poses = rest;
e.pose_body(0.0, 1.0, &rest, &mut poses);
// Half a turn about the vertical through the feet swaps the arms' sides, then 8 px up.
near(poses[3][0], -5.0);
near(poses[3][1], 2.0 - 8.0);
near(poses[4][0], 1.9);
near(poses[4][1], 12.0 - 8.0);
// Each bone is turned with it (180° about y, whatever Euler angles say so).
let q = quat([poses[0][3], poses[0][4], poses[0][5]]);
assert!(q.angle_between(Quat::from_rotation_y(PI)) < 1e-3);
// A quarter turn about x tips the head (24 px above the feet) forward along -z... or +z:
// (0, -24) from the feet turned 90° about x is (0, 0, -24) with y down, z forward.
let tip = wave(|f| {
f.replace(
r#"{"part": "balloon","#,
r#"{"part": "root", "keys": [{"t": 0, "rotation": [90, 0, 0]}]}, {"part": "balloon","#,
)
});
let mut tipped = rest;
emote(&tip).pose_body(0.0, 1.0, &rest, &mut tipped);
let head = Vec3::new(tipped[0][0], tipped[0][1], tipped[0][2]);
let expected = FEET + Quat::from_rotation_x(FRAC_PI_2) * (Vec3::ZERO - FEET);
assert!((head - expected).length() < 1e-3, "{head} {expected}");
}
#[test]
fn props_pop_in_and_move_from_their_rest() {
let item = parse(&wave(|f| f)).unwrap();
let e = item.emote.as_ref().unwrap();
let mut got = Vec::new();
e.pose_props(5.0, |n| item.rest(n), |n, p| got.push((n.to_owned(), p)));
let (name, pose) = &got[0];
assert_eq!(name, "balloon");
// In-out from scale 0 at 0 to 1 at 10: half at 5; moved half of 4 px up.
near(pose[6], 0.5);
near(pose[1], 10.0 - 2.0);
}
#[test]
fn refuses_emotes_past_the_limits() {
assert!(parse(&wave(|f| f)).is_ok());
let bad = [
(r#""length": 40"#, r#""length": 400"#),
(r#""length": 40"#, r#""length": 2"#),
(r#""blend": 4"#, r#""blend": 30"#),
(r#"{"t": 16,"#, r#"{"t": 4,"#),
(r#"{"t": 40}"#, r#"{"t": 41}"#),
(r#""part": "right_arm""#, r#""part": "tail""#),
(
r#""part": "balloon", "keys""#,
r#""part": "right_arm", "keys""#,
),
(
r#"[0, 0, 150], "ease": "linear""#,
r#"[0, 0, 400], "ease": "linear""#,
),
(r#"{"t": 0}"#, r#"{"t": 0, "scale": 2}"#),
(r#"{"t": 0}"#, r#"{"t": 0, "offset": [9, 0, 0]}"#),
(r#""ease": "step""#, r#""ease": "bounce""#),
];
for (from, to) in bad {
let file = wave(|f| f.replacen(from, to, 1));
assert_ne!(file, wave(|f| f), "{from}");
assert!(parse(&file).is_err(), "{to}");
}
// The balloon carried past the reach: 18 px down its arm, then 8 more and twice as big.
let far = wave(|f| {
f.replace("[1, 10, 0]", "[1, 18, 0]").replace(
r#""offset": [0, -4, 0]"#,
r#""offset": [0, 8, 0], "scale": 2"#,
)
});
assert!(matches!(parse(&far), Err(crate::Error::TooFar(_))));
let many: Vec<String> = (0..=MAX_KEYS).map(|k| format!(r#"{{"t": {k}}}"#)).collect();
let file = wave(|f| f.replacen(r#"{"t": 0}"#, &many.join(","), 1));
assert!(parse(&file).is_err());
}
#[test]
fn only_an_emote_has_one_and_an_emote_needs_a_texture_only_for_props() {
// A hat with an emote, an emote without one.
let hat = wave(|f| f.replace(r#""slot": "emote""#, r#""slot": "head""#));
assert!(parse(&hat).is_err());
let mut bare = wave(|f| f);
let emote_at = bare.find(r#""emote": {"#).unwrap();
let start = bare[..emote_at].rfind(',').unwrap();
bare.replace_range(start..bare.len() - 1, "");
assert!(parse(&bare).is_err());
// No props: no texture needed. Props: needed.
let no_props = r#"{"format": 1, "id": "nod", "slot": "emote", "parts": [],
"emote": {"length": 20, "tracks": [{"part": "head", "keys": [{"t": 0}, {"t": 10, "rotation": [30, 0, 0]}]}]}}"#;
let item = parse(no_props).unwrap();
assert_eq!((item.slot, item.vertices), (crate::Slot::Emote, 0));
let untextured = wave(|f| {
let (a, b) = (
f.find(r#""texture""#).unwrap(),
f.find(r#""parts""#).unwrap(),
);
format!("{}{}", &f[..a], &f[b..])
});
assert!(matches!(parse(&untextured), Err(crate::Error::Texture(_))));
}
}
+383
View File
@@ -0,0 +1,383 @@
//! Which other players play an emote, from the cosmetics service: the game names the Horizon
//! players in view each tick (`Feed::ask`, at most `BATCH`), a thread of its own asks
//! `POST /v1/emotes` about them at most once a `POLL`, and the frame reads the answer
//! (`Feed::playing`). Nobody in view, nothing is asked. A failure waits 15 s, doubling to 10
//! minutes, and ends the emotes shown.
//!
//! `POST /v1/emotes {"players": ["<uuid hex>", …]}` answers
//! `{"playing": {"<uuid hex>": {"id", "hash", "ms"}}}`: the emote each plays and how long ago it
//! started. The item files come from `GET /v1/items/<id>?hash=`, kept in memory.
use crate::{BATCH, Item, Slot, Transport};
use std::collections::HashMap;
use std::sync::{Arc, Condvar, Mutex, PoisonError};
use std::time::{Duration, Instant};
/// The shortest time between two requests.
pub const POLL: Duration = Duration::from_secs(1);
/// A list of players in view counts this long after the game last named it.
const FRESH: Duration = Duration::from_secs(2);
const BACKOFF: (Duration, Duration) = (Duration::from_secs(15), Duration::from_secs(600));
/// An emote another player plays: the item, and when it started on this machine's clock.
#[derive(Clone, Debug)]
pub struct Seen {
pub item: Arc<Item>,
pub started: Instant,
}
#[derive(Default)]
struct State {
want: Vec<u128>,
asked_at: Option<Instant>,
playing: HashMap<u128, Seen>,
/// By `id@hash`; `None`: unreadable or not an emote.
items: HashMap<String, Option<Arc<Item>>>,
next: Option<Instant>,
wait: Option<Duration>,
stop: bool,
}
struct Shared {
state: Mutex<State>,
wake: Condvar,
}
/// The game's handle; dropping it stops the thread.
pub struct Feed {
shared: Arc<Shared>,
}
impl Feed {
pub fn new(transport: impl Transport) -> Self {
let shared = Arc::new(Shared {
state: Mutex::default(),
wake: Condvar::new(),
});
let inner = shared.clone();
let _ = std::thread::Builder::new()
.name("Cosmetics emotes".into())
.spawn(move || {
loop {
let wait = {
let s = inner.state.lock().unwrap_or_else(PoisonError::into_inner);
if s.stop {
return;
}
due(&s, Instant::now())
};
match wait {
Some(Duration::ZERO) => round(&inner, &transport, Instant::now()),
// Waiting for the next poll, or for the game to name anyone.
Some(wait) => {
let s = inner.state.lock().unwrap_or_else(PoisonError::into_inner);
let (s, _) = inner
.wake
.wait_timeout_while(s, wait, |s| !s.stop)
.unwrap_or_else(PoisonError::into_inner);
drop(s);
}
None => {
let s = inner.state.lock().unwrap_or_else(PoisonError::into_inner);
let s = inner
.wake
.wait_while(s, |s| !s.stop && due(s, Instant::now()).is_none())
.unwrap_or_else(PoisonError::into_inner);
drop(s);
}
}
}
});
Self { shared }
}
/// The Horizon players in view now; only the first `BATCH` are asked about.
pub fn ask(&self, players: impl IntoIterator<Item = u128>) {
let mut want: Vec<u128> = players.into_iter().take(BATCH).collect();
want.sort_unstable();
want.dedup();
let mut s = self
.shared
.state
.lock()
.unwrap_or_else(PoisonError::into_inner);
let was_idle = s.want.is_empty() || s.asked_at.is_none_or(|a| a.elapsed() > FRESH);
s.asked_at = (!want.is_empty()).then(Instant::now);
s.want = want;
if was_idle {
self.shared.wake.notify_all();
}
}
/// The emote `uuid` plays, as last answered.
pub fn playing(&self, uuid: u128) -> Option<Seen> {
let s = self
.shared
.state
.lock()
.unwrap_or_else(PoisonError::into_inner);
s.playing.get(&uuid).cloned()
}
}
impl Drop for Feed {
fn drop(&mut self) {
self.shared
.state
.lock()
.unwrap_or_else(PoisonError::into_inner)
.stop = true;
self.shared.wake.notify_all();
}
}
/// How long until the next request: `ZERO` now, `None` while nobody is in view.
fn due(s: &State, now: Instant) -> Option<Duration> {
let fresh = s.asked_at.is_some_and(|a| now.duration_since(a) <= FRESH);
if s.want.is_empty() || !fresh {
return None;
}
Some(
s.next
.map_or(Duration::ZERO, |n| n.saturating_duration_since(now)),
)
}
fn hex(uuid: u128) -> String {
format!("{uuid:032x}")
}
/// One request about the players in view, then the item files it names.
fn round(shared: &Shared, transport: &dyn Transport, now: Instant) {
let want = {
let mut s = shared.state.lock().unwrap_or_else(PoisonError::into_inner);
s.next = Some(now + POLL);
s.want.clone()
};
let body = serde_json::json!({ "players": want.iter().map(|u| hex(*u)).collect::<Vec<_>>() });
let answer = transport
.post("/v1/emotes", body.to_string().as_bytes())
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok());
let Some(answer) = answer else {
let mut s = shared.state.lock().unwrap_or_else(PoisonError::into_inner);
let wait = s.wait.map_or(BACKOFF.0, |w| (w * 2).min(BACKOFF.1));
s.wait = Some(wait);
s.next = Some(now + wait);
s.playing.clear();
return;
};
let mut playing = HashMap::new();
for uuid in &want {
let Some(e) = answer["playing"].get(hex(*uuid)) else {
continue;
};
let (Some(id), Some(hash), Some(ms)) =
(e["id"].as_str(), e["hash"].as_str(), e["ms"].as_u64())
else {
continue;
};
let Some(item) = emote_item(shared, transport, id, hash) else {
continue;
};
// When it started here, keeping a start it already had (the answer's `ms` jitters with
// the request's own time).
let started = now.checked_sub(Duration::from_millis(ms)).unwrap_or(now);
playing.insert(*uuid, (item, started));
}
let mut s = shared.state.lock().unwrap_or_else(PoisonError::into_inner);
s.wait = None;
let old = std::mem::take(&mut s.playing);
for (uuid, (item, started)) in playing {
let started = match old.get(&uuid) {
Some(seen) if Arc::ptr_eq(&seen.item, &item) => seen.started,
_ => started,
};
s.playing.insert(uuid, Seen { item, started });
}
}
fn emote_item(
shared: &Shared,
transport: &dyn Transport,
id: &str,
hash: &str,
) -> Option<Arc<Item>> {
let safe = |t: &str| {
!t.is_empty() && t.len() <= 64 && t.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
};
if !safe(id) || !safe(hash) {
return None;
}
let key = format!("{id}@{hash}");
if let Some(known) = shared
.state
.lock()
.unwrap_or_else(PoisonError::into_inner)
.items
.get(&key)
{
return known.clone();
}
// A network failure is tried again at the next poll; a bad file never.
let bytes = transport.get(&format!("/v1/items/{id}?hash={hash}")).ok()?;
let item = Item::parse(&bytes)
.ok()
.filter(|i| i.id == id && i.slot == Slot::Emote)
.map(Arc::new);
shared
.state
.lock()
.unwrap_or_else(PoisonError::into_inner)
.items
.insert(key, item.clone());
item
}
#[cfg(test)]
mod tests {
use super::*;
const NOD: &str = r#"{"format": 1, "id": "nod", "slot": "emote", "parts": [],
"emote": {"length": 20, "tracks": [{"part": "head", "keys": [{"t": 10, "rotation": [20, 0, 0]}]}]}}"#;
/// The service: player 1 plays `nod` started 500 ms ago; logs each request.
#[derive(Clone, Default)]
struct Service {
log: Arc<Mutex<Vec<String>>>,
down: Arc<std::sync::atomic::AtomicBool>,
}
impl Transport for Service {
fn post(&self, path: &str, body: &[u8]) -> Result<Vec<u8>, String> {
self.log
.lock()
.unwrap()
.push(format!("POST {path} {}", String::from_utf8_lossy(body)));
if self.down.load(std::sync::atomic::Ordering::SeqCst) {
return Err("HTTP 503".into());
}
Ok(br#"{"playing": {"00000000000000000000000000000001": {"id": "nod", "hash": "h1", "ms": 500}}}"#.to_vec())
}
fn get(&self, path: &str) -> Result<Vec<u8>, String> {
self.log.lock().unwrap().push(format!("GET {path}"));
Ok(NOD.as_bytes().to_vec())
}
}
fn shared() -> Shared {
Shared {
state: Mutex::default(),
wake: Condvar::new(),
}
}
#[test]
fn asks_only_about_the_players_in_view_and_only_once_a_poll() {
let (service, shared) = (Service::default(), shared());
let t0 = Instant::now();
// Nobody named: nothing due.
assert_eq!(due(&shared.state.lock().unwrap(), t0), None);
{
let mut s = shared.state.lock().unwrap();
s.want = vec![1, 2];
s.asked_at = Some(t0);
}
assert_eq!(due(&shared.state.lock().unwrap(), t0), Some(Duration::ZERO));
round(&shared, &service, t0);
let log = service.log.lock().unwrap().clone();
assert_eq!(
log,
[
r#"POST /v1/emotes {"players":["00000000000000000000000000000001","00000000000000000000000000000002"]}"#,
"GET /v1/items/nod?hash=h1"
]
);
let s = shared.state.lock().unwrap();
let seen = s.playing.get(&1).unwrap();
assert_eq!(seen.item.id, "nod");
assert_eq!(t0.duration_since(seen.started), Duration::from_millis(500));
assert!(!s.playing.contains_key(&2));
// The next request waits a poll; a list the game stopped naming is not asked about.
assert_eq!(due(&s, t0 + POLL / 2), Some(POLL / 2));
assert_eq!(due(&s, t0 + FRESH + POLL), None);
}
#[test]
fn the_same_emote_keeps_its_start_the_file_is_fetched_once_and_failures_back_off() {
let (service, shared) = (Service::default(), shared());
let t0 = Instant::now();
{
let mut s = shared.state.lock().unwrap();
s.want = vec![1];
s.asked_at = Some(t0);
}
round(&shared, &service, t0);
round(&shared, &service, t0 + POLL);
let started = shared.state.lock().unwrap().playing[&1].started;
assert_eq!(t0.duration_since(started), Duration::from_millis(500));
let gets = service
.log
.lock()
.unwrap()
.iter()
.filter(|l| l.starts_with("GET"))
.count();
assert_eq!(gets, 1);
// Down: nothing shown, and the next try waits 15 s, then 30 s.
service
.down
.store(true, std::sync::atomic::Ordering::SeqCst);
round(&shared, &service, t0 + POLL * 2);
let s = shared.state.lock().unwrap();
assert!(s.playing.is_empty());
assert_eq!(s.next, Some(t0 + POLL * 2 + BACKOFF.0));
drop(s);
round(&shared, &service, t0 + POLL * 3);
assert_eq!(
shared.state.lock().unwrap().next,
Some(t0 + POLL * 3 + BACKOFF.0 * 2)
);
}
#[test]
fn a_file_that_is_not_an_emote_is_never_played() {
struct Hat;
impl Transport for Hat {
fn post(&self, _: &str, _: &[u8]) -> Result<Vec<u8>, String> {
Ok(br#"{"playing": {"00000000000000000000000000000001": {"id": "nod", "hash": "h1", "ms": 0}}}"#.to_vec())
}
fn get(&self, _: &str) -> Result<Vec<u8>, String> {
Ok(NOD
.replace(r#""slot": "emote""#, r#""slot": "head""#)
.into_bytes())
}
}
let shared = shared();
{
let mut s = shared.state.lock().unwrap();
s.want = vec![1];
s.asked_at = Some(Instant::now());
}
round(&shared, &Hat, Instant::now());
assert!(shared.state.lock().unwrap().playing.is_empty());
}
#[test]
fn the_thread_answers_the_game() {
let feed = Feed::new(Service::default());
let deadline = Instant::now() + Duration::from_secs(10);
loop {
feed.ask([1, 2]);
if feed.playing(1).is_some() || Instant::now() > deadline {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
assert_eq!(
feed.playing(1).map(|s| s.item.id.clone()).as_deref(),
Some("nod")
);
assert!(feed.playing(2).is_none());
}
}
+1
View File
@@ -573,6 +573,7 @@ impl Local {
for file in files {
if let Some(item) = std::fs::read(&file).ok().and_then(|b| Item::parse(&b).ok())
&& !slots.contains(&item.slot)
&& item.slot != crate::Slot::Emote
&& file.file_stem().is_some_and(|s| *s == *item.id)
{
slots.push(item.slot);
+47 -3
View File
@@ -33,6 +33,8 @@ pub enum Slot {
/// A name tag style: no model, a square mark drawn before the player's
/// name and a line under it (`Item::tag`).
NameTag,
/// An emote: not worn, played (`Item::emote`); its parts are props.
Emote,
}
/// The part of the player model an item follows.
@@ -162,6 +164,8 @@ pub struct Item {
/// pixels: its highest vertex with every bone at rest (`Bone::rest`), animations included.
/// The game lifts its wearer's name tag over it. 0 for a name tag style (no model).
pub top: f32,
/// What an emote does (`Slot::Emote` only).
pub emote: Option<crate::Emote>,
}
#[derive(Debug, PartialEq)]
@@ -204,12 +208,16 @@ struct ItemFile {
bone: Bone,
#[serde(default)]
attach: AttachFile,
texture: TextureFile,
/// Only an emote without props goes without.
#[serde(default)]
texture: Option<TextureFile>,
parts: Vec<PartFile>,
#[serde(default)]
animations: Vec<Animation>,
#[serde(default)]
tag: Option<TagFile>,
#[serde(default)]
emote: Option<crate::emote::EmoteFile>,
}
#[derive(Deserialize)]
@@ -315,7 +323,16 @@ impl Item {
{
return Err(invalid("the id is 1 to 64 of a-z, 0-9 and _"));
}
let texture = decode_png(&file.texture.png)?;
let texture = match (&file.texture, file.slot) {
(Some(t), _) => decode_png(&t.png)?,
// Nothing is drawn from it: no skin slot is taken.
(None, Slot::Emote) if file.parts.is_empty() => Texture {
width: 1,
height: 1,
rgba: vec![0; 4],
},
(None, _) => return Err(Error::Texture("no texture".into())),
};
let tag = match (file.slot, &file.tag) {
(Slot::NameTag, Some(t)) => {
if !file.parts.is_empty() || !file.animations.is_empty() {
@@ -376,6 +393,12 @@ impl Item {
if names.len() > MAX_PARTS {
return Err(Error::TooManyParts(names.len()));
}
let emote = match (file.slot, file.emote) {
(Slot::Emote, Some(e)) => Some(e.check(&names)?),
(Slot::Emote, None) => return Err(invalid("an emote needs its emote")),
(_, Some(_)) => return Err(invalid("only an emote has an emote")),
(_, None) => None,
};
let root = Part {
name: "cosmetic".into(),
pose,
@@ -417,6 +440,7 @@ impl Item {
limbs,
tag,
top: 0.0,
emote,
};
let (reach, top) = item.extent();
if reach > MAX_REACH {
@@ -431,12 +455,16 @@ impl Item {
}
/// The farthest any vertex gets from its bone's pivot, and the highest above the neck with
/// the bones at rest (pixels, `top`): at rest, and at 32 points of each animation's period.
/// the bones at rest (pixels, `top`): at rest, at 32 points of each animation's period, and
/// through an emote's props' keys.
fn extent(&self) -> (f32, f32) {
let mut times = vec![0.0];
for anim in &self.animations {
times.extend((0..32).map(|k| anim.period * k as f32 / 32.0));
}
if let Some(emote) = &self.emote {
times.extend(emote.sample_times());
}
let mut far = 0.0_f32;
let mut top = 0.0_f32;
let mut poses = std::collections::HashMap::new();
@@ -445,6 +473,15 @@ impl Item {
self.animate(ticks, |name, pose| {
poses.insert(name.to_owned(), pose);
});
if let Some(emote) = &self.emote {
let mut props = Vec::new();
emote.pose_props(
ticks,
|name| self.rest(name),
|name, pose| props.push((name.to_owned(), pose)),
);
poses.extend(props);
}
for (bone, root) in self.roots() {
let pivot = bone.rest();
walk(root, glam::Mat4::IDENTITY, &poses, &mut |p| {
@@ -457,6 +494,13 @@ impl Item {
(far, top)
}
/// The rest pose of the part named `name`.
pub fn rest(&self, name: &str) -> Option<Pose> {
self.roots()
.find_map(|(_, r)| find(r, name))
.map(|p| p.pose)
}
/// The animated parts' poses at `ticks` (game ticks, any start): each
/// is the part's rest pose with its animations added.
pub fn animate(&self, ticks: f32, mut set: impl FnMut(&str, Pose)) {
+9 -1
View File
@@ -1,5 +1,5 @@
//! Cosmetic items that Horizon clients draw on players: hats, back items,
//! shoulder pets, name tag styles. Only Horizon clients see them; the game protocol carries
//! shoulder pets, name tag styles, emotes. Only Horizon clients see them; the game protocol carries
//! nothing about them ([docs/cosmetics.md](../../../docs/cosmetics.md)).
//!
//! An item is a small model of boxes (Minecraft's box UV, as Blockbench's
@@ -9,13 +9,21 @@
//! reads and checks items and poses them; the game draws them.
pub mod account;
mod emote;
mod feed;
mod fetch;
mod item;
pub mod mark;
mod pose;
pub mod prefs;
pub mod preview;
pub mod viewer;
pub use emote::{
BODY, Ease, Emote, Key, MAX_ALL_KEYS, MAX_KEYS, MAX_LENGTH, MAX_MOVE, MAX_ROOT_MOVE,
MAX_ROOT_TURN, MAX_TRACKS, MAX_TURN, Playing, Sample, Target, Track,
};
pub use feed::{Feed, POLL, Seen};
pub use fetch::{BATCH, Clock, Cosmetics, Frame, Http, Local, Transport, Worn};
pub use item::{
Animation, Axis, Bone, Error, FORMAT, Face, Item, MAX_MARK, MAX_PARTS, MAX_REACH, MAX_TEXTURE,
+232 -39
View File
@@ -1,5 +1,7 @@
//! A still picture of a player wearing items, drawn on the CPU (the launcher's Wardrobe): the
//! player model at rest with its skin, each item on its bone, seen from the front at an angle.
//! A picture of a player wearing items, drawn on the CPU (the launcher's Wardrobe): the player
//! model with its skin, at rest or posed, each item on its bone, seen from any angle and distance
//! (`Camera`). The Wardrobe's live view draws it again only when something changes
//! (`crate::viewer`).
use crate::item::cube_faces;
use crate::{Item, Part, Texture, pose_matrix};
@@ -94,11 +96,93 @@ pub fn player_model(slim: bool) -> Part {
/// The matrix of the part named `name` under `parent`, at rest.
pub fn rest_matrix(part: &Part, parent: Mat4, name: &str) -> Option<Mat4> {
let m = parent * pose_matrix(part.pose);
posed_matrix(part, parent, name, &[])
}
/// The matrix of the part named `name` under `parent`, its parts posed by `poses` (name → pose;
/// the rest at rest).
pub fn posed_matrix(
part: &Part,
parent: Mat4,
name: &str,
poses: &[(String, crate::Pose)],
) -> Option<Mat4> {
let m = parent * pose_matrix(pose_of(part, poses));
if part.name == name {
return Some(m);
}
part.children.iter().find_map(|c| rest_matrix(c, m, name))
part.children
.iter()
.find_map(|c| posed_matrix(c, m, name, poses))
}
fn pose_of(part: &Part, poses: &[(String, crate::Pose)]) -> crate::Pose {
poses
.iter()
.find(|(n, _)| *n == part.name)
.map_or(part.pose, |(_, p)| *p)
}
/// Where the picture looks at the player from: turned `yaw` degrees (0 shows the front), from
/// `pitch` degrees above (below when negative), `zoom` times closer. At zoom 1 the whole player
/// fits, 3 blocks to the picture's height, with room above the head for tall hats and back items;
/// closer, the view centres on the head, wholly at `MAX_ZOOM`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Camera {
pub yaw: f32,
pub pitch: f32,
pub zoom: f32,
}
impl Camera {
/// Where the view starts and goes back to: a little turned, a little from above.
pub const HOME: Camera = Camera {
yaw: 30.0,
pitch: 10.0,
zoom: 1.0,
};
pub const MAX_ZOOM: f32 = 3.0;
/// How far up and down the view tilts, in degrees.
pub const MAX_PITCH: f32 = 60.0;
pub const MIN_PITCH: f32 = -30.0;
/// Degrees turned per point dragged.
pub const DRAG: f32 = 0.6;
/// How much closer one step of the scroll wheel comes.
pub const ZOOM_STEP: f32 = 1.15;
/// Dragged by `dx`, `dy` points, as if the pointer held the player: right turns the front
/// to the right, down tips the head toward the viewer.
pub fn dragged(self, dx: f32, dy: f32) -> Self {
Self {
yaw: (self.yaw - dx * Self::DRAG).rem_euclid(360.0),
pitch: (self.pitch + dy * Self::DRAG).clamp(Self::MIN_PITCH, Self::MAX_PITCH),
zoom: self.zoom,
}
}
/// `steps` of the scroll wheel closer (farther when negative), from 1 to `MAX_ZOOM`.
pub fn zoomed(self, steps: f32) -> Self {
Self {
zoom: (self.zoom * Self::ZOOM_STEP.powf(steps)).clamp(1.0, Self::MAX_ZOOM),
..self
}
}
/// Model space (blocks, y down, the front at -z, the body's pivot at 0) to the picture's
/// pixels, `width`×`height`, depth growing away from the viewer.
pub fn view(&self, width: u32, height: u32) -> Mat4 {
let scale = height as f32 / 3.0 * self.zoom;
// The head's top is 0.5 blocks above the body's pivot and the feet 1.5 below. At zoom 1
// the picture's middle is 0.4 below the pivot (a fifth of the picture above the head),
// at `MAX_ZOOM` the head's centre, 0.25 above.
let near = ((self.zoom - 1.0) / (Self::MAX_ZOOM - 1.0)).clamp(0.0, 1.0);
let focus = 0.4 + (-0.25 - 0.4) * near;
Mat4::from_translation(Vec3::new(width as f32 / 2.0, height as f32 / 2.0, 0.0))
* Mat4::from_scale(Vec3::splat(scale))
* Mat4::from_rotation_x(self.pitch.to_radians())
* Mat4::from_rotation_y(self.yaw.to_radians())
* Mat4::from_translation(Vec3::new(0.0, -focus, 0.0))
}
}
/// One model to draw: its root under `base` (model space, blocks), its parts posed by
@@ -111,16 +195,27 @@ pub struct Draw<'a> {
}
impl<'a> Draw<'a> {
/// `item` on the player model `body`, at `ticks` of its animations: one draw per bone it
/// rides (a shirt's sleeves are on the arms).
/// `item` on the player model `body` at rest, at `ticks` of its animations: one draw per
/// bone it rides (a shirt's sleeves are on the arms).
pub fn item(item: &'a Item, body: &Part, ticks: f32) -> Vec<Self> {
Self::item_posed(item, body, &[], ticks)
}
/// `item` on the player model `body` posed by `body_poses` (an emote), at `ticks` of its
/// animations: each draw follows its bone as posed.
pub fn item_posed(
item: &'a Item,
body: &Part,
body_poses: &[(String, crate::Pose)],
ticks: f32,
) -> Vec<Self> {
let mut poses = Vec::new();
item.animate(ticks, |name, pose| poses.push((name.to_owned(), pose)));
item.roots()
.filter_map(|(bone, root)| {
Some(Self {
root,
base: rest_matrix(body, Mat4::IDENTITY, bone.part())?,
base: posed_matrix(body, Mat4::IDENTITY, bone.part(), body_poses)?,
texture: &item.texture,
poses: poses.clone(),
})
@@ -129,30 +224,18 @@ impl<'a> Draw<'a> {
}
}
/// Draws `draws` into a `width`×`height` picture: turned `yaw` degrees (0 shows the front),
/// tilted `pitch` degrees down: 2.5 blocks to the height, the head's top a tenth of the way down
/// (room for a hat).
pub fn render(draws: &[Draw], width: u32, height: u32, yaw: f32, pitch: f32) -> Image {
/// Draws `draws` into a `width`×`height` picture seen from `camera`. At `Camera::HOME`'s zoom
/// the head's top is a fifth of the way down (room for a hat) and the feet 13% up.
pub fn render(draws: &[Draw], width: u32, height: u32, camera: &Camera) -> Image {
let (w, h) = (width as usize, height as usize);
let mut rgba = vec![0u8; w * h * 4];
let mut depth = vec![f32::INFINITY; w * h];
// Model space has y down and the front at -z: the camera looks along +z from the front.
let scale = height as f32 / 2.5;
let view = Mat4::from_translation(Vec3::new(width as f32 / 2.0, height as f32 * 0.1, 0.0))
* Mat4::from_scale(Vec3::new(scale, scale, scale))
* Mat4::from_rotation_x(-pitch.to_radians())
* Mat4::from_rotation_y(yaw.to_radians())
// Up the body's pivot: the head's top is 0.5 blocks above it.
* Mat4::from_translation(Vec3::new(0.0, 0.5, 0.0));
let view = camera.view(width, height);
for d in draws {
let mut stack = vec![(d.root, d.base)];
while let Some((part, parent)) = stack.pop() {
let pose = d
.poses
.iter()
.find(|(n, _)| *n == part.name)
.map_or(part.pose, |(_, p)| *p);
let m = parent * pose_matrix(pose);
let m = parent * pose_matrix(pose_of(part, &d.poses));
let to_screen = view * m;
let normal = glam::Mat3::from_mat4(view * m);
for face in &part.faces {
@@ -268,6 +351,18 @@ mod tests {
}
}
/// Straight at the front, level, the whole player.
const FRONT: Camera = Camera {
yaw: 0.0,
pitch: 0.0,
zoom: 1.0,
};
/// Where the model-space point `p` (blocks) lands in a 100×100 picture.
fn screen(camera: Camera, p: Vec3) -> Vec3 {
camera.view(100, 100).transform_point3(p)
}
fn at(img: &Image, x: u32, y: u32) -> [u8; 4] {
img.rgba[((y * img.width + x) * 4) as usize..][..4]
.try_into()
@@ -284,14 +379,14 @@ mod tests {
texture: &tex,
poses: Vec::new(),
};
let img = render(&[draw], 100, 100, 0.0, 0.0);
// 40 px a block: the head's top at row 10, its bottom (the pivot) 20 px lower; its
// middle at row 20, column 50.
let face = at(&img, 50, 20);
let img = render(&[draw], 100, 100, &FRONT);
// A third of 100 px a block: the head's top at row 20, its bottom (the pivot) at 36.7;
// its middle at row 28, column 50.
let face = at(&img, 50, 28);
assert_eq!(face[3], 255);
assert!(face[0] > 100 && face[1] == 0 && face[2] == 0, "{face:?}");
// Above the head, nothing (the transparent hat layer doesn't hide it).
assert_eq!(at(&img, 50, 5)[3], 0);
assert_eq!(at(&img, 50, 10)[3], 0);
// Turned round: the back of the head (green region), not the face.
let back = render(
&[Draw {
@@ -302,10 +397,12 @@ mod tests {
}],
100,
100,
180.0,
0.0,
&Camera {
yaw: 180.0,
..FRONT
},
);
let c = at(&back, 50, 20);
let c = at(&back, 50, 28);
assert!(c[1] > 100 && c[0] == 0, "{c:?}");
}
@@ -326,17 +423,113 @@ mod tests {
texture: &tex,
poses: Vec::new(),
}];
let bare = render(&draws, 100, 100, 0.0, 0.0);
let bare = render(&draws, 100, 100, &FRONT);
draws.extend(Draw::item(&item, &body, 0.0));
let worn = render(&draws, 100, 100, 0.0, 0.0);
// The cap spans 8 to 12 model pixels above the head's pivot: rows 0 to 10, over the
// head's top at row 10.
assert_eq!(at(&bare, 50, 5)[3], 0);
let cap = at(&worn, 50, 5);
let worn = render(&draws, 100, 100, &FRONT);
// The cap spans 8 to 12 model pixels above the head's pivot: rows 11.7 to 20, over the
// head's top at row 20.
assert_eq!(at(&bare, 50, 15)[3], 0);
let cap = at(&worn, 50, 15);
assert_eq!(cap[3], 255);
// Grey (200 lit), not the skin's colours.
assert!(cap[0] == cap[1] && cap[1] == cap[2], "{cap:?}");
// The face is still the face.
assert_eq!(at(&worn, 50, 20), at(&bare, 50, 20));
assert_eq!(at(&worn, 50, 28), at(&bare, 50, 28));
}
#[test]
fn dragging_turns_the_player_as_if_held() {
// The nose: the middle of the head's front face.
let nose = Vec3::new(0.0, -0.25, -0.25);
let ahead = screen(FRONT, nose);
assert!((ahead.x - 50.0).abs() < 1e-3, "{ahead}");
// Dragged right, the face turns right.
let right = screen(FRONT.dragged(30.0, 0.0), nose);
assert!(right.x > 52.0, "{right}");
let left = screen(FRONT.dragged(-30.0, 0.0), nose);
assert!(left.x < 48.0, "{left}");
// A full turn's drag comes back round, the yaw kept within one turn.
let round = FRONT.dragged(360.0 / Camera::DRAG, 0.0);
assert!(
round.yaw.abs() < 1e-3 || (round.yaw - 360.0).abs() < 1e-3,
"{round:?}"
);
// Dragged down, the top of the head turns toward the viewer (its normal points at -z,
// where the viewer is); up, away.
let top = |c: Camera| c.view(100, 100).transform_vector3(Vec3::NEG_Y).z;
assert!(top(FRONT.dragged(0.0, 20.0)) < 0.0);
assert!(top(FRONT.dragged(0.0, -20.0)) > 0.0);
assert!(top(Camera::HOME) < 0.0, "home looks from a little above");
// The tilt stops at its limits.
assert_eq!(FRONT.dragged(0.0, 1e4).pitch, Camera::MAX_PITCH);
assert_eq!(FRONT.dragged(0.0, -1e4).pitch, Camera::MIN_PITCH);
}
#[test]
fn zooming_closes_in_on_the_head() {
// The whole player at zoom 1: the head's top a fifth down, the feet above the bottom.
let head_top = screen(FRONT, Vec3::new(0.0, -0.5, 0.0));
let feet = screen(FRONT, Vec3::new(0.0, 1.5, 0.0));
assert!((head_top.y - 20.0).abs() < 1e-3, "{head_top}");
assert!(80.0 < feet.y && feet.y < 90.0, "{feet}");
assert_eq!(FRONT.zoomed(100.0).zoom, Camera::MAX_ZOOM);
assert_eq!(FRONT.zoomed(-100.0).zoom, 1.0);
assert!((FRONT.zoomed(2.0).zoomed(-2.0).zoom - 1.0).abs() < 1e-5);
// All the way in, the head's centre is the picture's and it is three times the size.
let near = FRONT.zoomed(100.0);
let head = screen(near, Vec3::new(0.0, -0.25, 0.0));
assert!(
(head.x - 50.0).abs() < 1e-3 && (head.y - 50.0).abs() < 1e-3,
"{head}"
);
let chin = screen(near, Vec3::new(0.0, 0.0, 0.0));
assert!((chin.y - head.y - 0.25 * 100.0).abs() < 1e-3, "{chin}");
// On the way, the picture's middle moves from the body's middle up to the head.
let middle = |c: Camera| {
c.view(100, 100)
.inverse()
.transform_point3(Vec3::new(50.0, 50.0, 0.0))
};
assert!((middle(FRONT).y - 0.4).abs() < 1e-4);
let half = middle(FRONT.zoomed(4.0)).y;
assert!(-0.25 < half && half < 0.4, "{half}");
}
#[test]
fn an_item_follows_its_bone_as_posed() {
let file = format!(
r#"{{"format": 1, "id": "band", "slot": "torso", "bone": "right_arm",
"texture": {{"png": "{}"}},
"parts": [{{"name": "b", "cubes": [{{"origin": [-3, 8, -2], "size": [4, 2, 4], "uv": [0, 0]}}]}}]}}"#,
crate::pose::tests::png(32, 32)
);
let item = Item::parse(file.as_bytes()).unwrap();
let body = player_model(false);
// Ten pixels down the arm, where the band is.
let wrist = |draws: &[Draw]| {
draws[0]
.base
.transform_point3(Vec3::new(0.0, 10.0 / 16.0, 0.0))
};
let rest = wrist(&Draw::item(&item, &body, 0.0));
// At rest the arm hangs: the wrist is below the shoulder.
assert!(rest.y > 0.5 && rest.z.abs() < 1e-4, "{rest}");
// Raised straight ahead (vanilla's -90° about x), the band comes forward with it.
let raised = [(
"right_arm".to_owned(),
[
-5.0,
2.0,
0.0,
-std::f32::consts::FRAC_PI_2,
0.0,
0.0,
1.0,
1.0,
1.0,
],
)];
let up = wrist(&Draw::item_posed(&item, &body, &raised, 0.0));
assert!(up.z < -0.5 && (up.y - 2.0 / 16.0).abs() < 1e-4, "{up}");
}
}
+368
View File
@@ -0,0 +1,368 @@
//! The Wardrobe's live view, without the drawing: where the camera is, the clock items animate
//! by, an emote playing, and when the picture has to be drawn again. The launcher draws with
//! `preview::render` only when `Stage::frame` says so: never while nothing moves, and nothing
//! moves while the window is in the background.
//!
//! - **Animations** run on the game's clock (20 ticks a second), paused while the window is in
//! the background or nothing animates, so they go on where they stopped.
//! - **At most 20 pictures a second** while something animates (`ANIMATION_FRAME`: one a game
//! tick, the unit animations are written in), 60 while the player drags or scrolls
//! (`MOVE_FRAME`); a change sooner than that waits for its turn.
//! - **Emotes** (`BodyMotion`): poses of the player model played once on the same clock; the
//! items follow their bones as posed (`preview::Draw::item_posed`).
use crate::Pose;
use crate::preview::Camera;
use std::sync::Arc;
use std::time::{Duration, Instant};
/// The game's ticks, which item animations count in.
pub const TICKS_PER_SECOND: f32 = 20.0;
/// The shortest gap between two pictures while something animates: 20 a second, one a tick.
pub const ANIMATION_FRAME: Duration = Duration::from_millis(50);
/// The shortest gap between two pictures for a drag or a scroll: 60 a second.
pub const MOVE_FRAME: Duration = Duration::from_millis(16);
/// A body animation played once on the player model (an emote): the poses of its parts
/// (`head`, `body`, `right_arm`…, as `preview::player_model` names them) `ticks` after it
/// started. Parts it leaves out stay at rest.
pub trait BodyMotion: Send + Sync {
/// How long it plays, in ticks.
fn length(&self) -> f32;
/// The parts' poses at `ticks` (0 to `length`).
fn pose(&self, ticks: f32, set: &mut dyn FnMut(&str, Pose));
}
/// What to draw: the items' animation clock and, during an emote, the player model's poses.
#[derive(Clone, Debug, PartialEq)]
pub struct Shot {
pub camera: Camera,
pub ticks: f32,
pub body: Vec<(String, Pose)>,
}
/// What `Stage::frame` asks of the view: draw `shot` now, and look again in `wake`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Frame {
pub shot: Option<Shot>,
pub wake: Option<Duration>,
}
/// Everything the picture depends on; a picture is drawn again only when it changed.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Key {
camera: Camera,
outfit: u64,
scale: f32,
ticks: f32,
emote: Option<usize>,
}
/// A clock that runs only while told to, keeping what it ran before.
#[derive(Clone, Copy, Debug, Default)]
struct Clock {
banked: Duration,
since: Option<Instant>,
}
impl Clock {
fn at(&self, now: Instant) -> Duration {
self.banked
+ self
.since
.map_or(Duration::ZERO, |s| now.saturating_duration_since(s))
}
fn run(&mut self, on: bool, now: Instant) {
match (on, self.since) {
(true, None) => self.since = Some(now),
(false, Some(_)) => {
self.banked = self.at(now);
self.since = None;
}
_ => {}
}
}
fn ticks(&self, now: Instant) -> f32 {
self.at(now).as_secs_f32() * TICKS_PER_SECOND
}
}
/// The live view's state between pictures.
#[derive(Default)]
pub struct Stage {
camera: Option<Camera>,
/// Bumped by every new outfit.
outfit: u64,
/// Whether anything worn animates (and animations are wanted).
animates: bool,
clock: Clock,
/// The emote playing, and the clock's ticks when it started.
emote: Option<(Arc<dyn BodyMotion>, f32)>,
drawn: Option<(Key, Instant)>,
}
impl Stage {
pub fn camera(&self) -> Camera {
self.camera.unwrap_or(Camera::HOME)
}
/// Whether the camera is away from `Camera::HOME` (the reset button does something).
pub fn moved(&self) -> bool {
self.camera() != Camera::HOME
}
pub fn set_camera(&mut self, camera: Camera) {
self.camera = Some(camera);
}
/// A new outfit to show; `animates` says whether any of it moves (false under reduced
/// motion).
pub fn dress(&mut self, animates: bool) {
self.outfit += 1;
self.animates = animates;
}
/// Plays `motion` once, from now.
pub fn play(&mut self, motion: Arc<dyn BodyMotion>, now: Instant) {
self.emote = Some((motion, self.clock.ticks(now)));
}
/// Whether an emote is playing.
pub fn playing(&self) -> bool {
self.emote.is_some()
}
/// Called each time the view is drawn, at `now`, `scale` device pixels a point; `active` is
/// whether the window is in front. Says whether to draw the picture again, and when to look
/// again (never, while nothing moves).
pub fn frame(&mut self, now: Instant, scale: f32, active: bool) -> Frame {
let ticks = self.clock.ticks(now);
if self
.emote
.as_ref()
.is_some_and(|(m, from)| ticks - from >= m.length())
{
self.emote = None;
}
let moving = active && (self.animates || self.emote.is_some());
self.clock.run(moving, now);
let key = Key {
camera: self.camera(),
outfit: self.outfit,
scale,
ticks,
emote: self
.emote
.as_ref()
.map(|(m, _)| Arc::as_ptr(m) as *const () as usize),
};
let gap = if moving { ANIMATION_FRAME } else { MOVE_FRAME };
match self.drawn {
Some((drawn, _)) if drawn == key => Frame {
shot: None,
wake: moving.then_some(ANIMATION_FRAME),
},
Some((_, at)) if now.saturating_duration_since(at) < gap => Frame {
shot: None,
wake: Some(gap - now.saturating_duration_since(at)),
},
_ => {
self.drawn = Some((key, now));
let mut body = Vec::new();
if let Some((m, from)) = &self.emote {
m.pose((ticks - from).clamp(0.0, m.length()), &mut |name, pose| {
body.push((name.to_owned(), pose))
});
}
Frame {
shot: Some(Shot {
camera: key.camera,
ticks,
body,
}),
wake: moving.then_some(ANIMATION_FRAME),
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const MS: Duration = Duration::from_millis(1);
/// Calls `frame` every `every` for `span` from `start`; the pictures drawn.
fn run(
stage: &mut Stage,
start: Instant,
span: Duration,
every: Duration,
active: bool,
) -> usize {
let mut drawn = 0;
let mut t = Duration::ZERO;
while t < span {
drawn += usize::from(stage.frame(start + t, 2.0, active).shot.is_some());
t += every;
}
drawn
}
#[test]
fn nothing_is_drawn_again_while_nothing_moves() {
let t0 = Instant::now();
let mut stage = Stage::default();
stage.dress(false);
let first = stage.frame(t0, 2.0, true);
assert!(first.shot.is_some());
assert_eq!(first.wake, None);
// A minute of frames (the page redrawn for other reasons, hovers…), focused or not:
// nothing drawn, nothing asked for.
for active in [true, false] {
for k in 1..600 {
let f = stage.frame(t0 + k * 100 * MS, 2.0, active);
assert_eq!(f, Frame::default());
}
}
}
#[test]
fn a_drag_draws_at_most_60_pictures_a_second() {
let t0 = Instant::now();
let mut stage = Stage::default();
stage.dress(false);
stage.frame(t0, 2.0, true);
// The pointer moves every 4 ms for a second, the view asked each time.
let mut drawn = 0;
let mut waits = Vec::new();
for k in 1..=250 {
let now = t0 + k * 4 * MS;
stage.set_camera(stage.camera().dragged(1.0, 0.0));
let f = stage.frame(now, 2.0, true);
drawn += usize::from(f.shot.is_some());
waits.extend(f.wake);
}
assert!((55..=63).contains(&drawn), "{drawn}");
// A change that waits says when its turn comes, never later than a frame.
assert!(waits.iter().all(|w| *w <= MOVE_FRAME), "{waits:?}");
// The drag's end is drawn when the wait is over, then nothing more.
let end = t0 + 1000 * MS + MOVE_FRAME;
assert!(stage.frame(end, 2.0, true).shot.is_some());
assert_eq!(stage.frame(end + 500 * MS, 2.0, true), Frame::default());
}
#[test]
fn animations_play_at_20_pictures_a_second_only_in_front() {
let t0 = Instant::now();
let mut stage = Stage::default();
stage.dress(true);
let first = stage.frame(t0, 2.0, true);
assert_eq!(first.wake, Some(ANIMATION_FRAME));
// Asked as often as it wants for a second: about 20 pictures.
let drawn = 1 + run(&mut stage, t0 + MS, Duration::from_secs(1), MS, true);
assert!((19..=21).contains(&drawn), "{drawn}");
// In the background: the frozen frame drawn at most once more, then nothing and no
// timer, however long.
let t1 = t0 + Duration::from_secs(1);
let last = run(&mut stage, t1, 100 * MS, 10 * MS, false);
assert!(last <= 1, "{last}");
assert_eq!(stage.frame(t1 + 100 * MS, 2.0, false), Frame::default());
assert_eq!(
run(&mut stage, t1, Duration::from_secs(60), 100 * MS, false),
0
);
// Back in front an hour later, the animations go on where they stopped: the clock ran
// one second (20 ticks), not an hour.
let back = t1 + Duration::from_secs(3600);
assert_eq!(stage.frame(back, 2.0, true).wake, Some(ANIMATION_FRAME));
let shot = stage
.frame(back + 60 * MS, 2.0, true)
.shot
.expect("drawn again in front");
assert!((shot.ticks - 21.2).abs() < 0.1, "{}", shot.ticks);
let later = stage.frame(back + 1060 * MS, 2.0, true).shot.unwrap();
assert!(
(later.ticks - shot.ticks - 20.0).abs() < 1e-3,
"{}",
later.ticks
);
}
#[test]
fn a_new_outfit_or_scale_is_drawn_once() {
let t0 = Instant::now();
let mut stage = Stage::default();
stage.dress(false);
stage.frame(t0, 2.0, true);
stage.dress(false);
assert!(stage.frame(t0 + 100 * MS, 2.0, true).shot.is_some());
assert!(stage.frame(t0 + 200 * MS, 1.0, true).shot.is_some());
assert_eq!(stage.frame(t0 + 300 * MS, 1.0, true), Frame::default());
}
/// Raises the right arm over 20 ticks.
struct Wave;
impl BodyMotion for Wave {
fn length(&self) -> f32 {
20.0
}
fn pose(&self, ticks: f32, set: &mut dyn FnMut(&str, Pose)) {
let x = -std::f32::consts::PI * ticks / 20.0;
set("right_arm", [-5.0, 2.0, 0.0, x, 0.0, 0.0, 1.0, 1.0, 1.0]);
}
}
#[test]
fn an_emote_plays_once_then_the_view_rests() {
let t0 = Instant::now();
let mut stage = Stage::default();
stage.dress(false);
stage.frame(t0, 2.0, true);
stage.play(Arc::new(Wave), t0);
let start = stage.frame(t0 + 50 * MS, 2.0, true);
let shot = start.shot.expect("drawn when it starts");
assert_eq!(shot.body.len(), 1);
assert_eq!(start.wake, Some(ANIMATION_FRAME));
// Half a second in (10 ticks), the arm is half way up.
let mid = stage.frame(t0 + 550 * MS, 2.0, true).shot.unwrap();
let x = mid.body[0].1[3];
assert!((x + std::f32::consts::FRAC_PI_2).abs() < 0.1, "{x}");
// Over after a second: drawn once more at rest, then nothing.
let end = stage.frame(t0 + 1100 * MS, 2.0, true);
assert!(end.shot.as_ref().is_some_and(|s| s.body.is_empty()));
assert_eq!(end.wake, None);
assert!(!stage.playing());
assert_eq!(stage.frame(t0 + 2000 * MS, 2.0, true), Frame::default());
}
#[test]
fn an_emote_waits_while_the_window_is_in_the_background() {
let t0 = Instant::now();
let mut stage = Stage::default();
stage.dress(false);
stage.play(Arc::new(Wave), t0);
stage.frame(t0, 2.0, true);
stage.frame(t0 + 250 * MS, 2.0, false);
assert_eq!(stage.frame(t0 + 10_000 * MS, 2.0, false), Frame::default());
assert!(stage.playing());
// Back in front, it goes on from five ticks in.
stage.frame(t0 + 20_000 * MS, 2.0, true);
let back = stage.frame(t0 + 20_060 * MS, 2.0, true).shot.unwrap();
assert!((back.ticks - 6.2).abs() < 0.1, "{}", back.ticks);
}
#[test]
fn the_camera_starts_home_and_says_when_it_moved() {
let mut stage = Stage::default();
assert_eq!(stage.camera(), Camera::HOME);
assert!(!stage.moved());
stage.set_camera(stage.camera().zoomed(1.0));
assert!(stage.moved());
stage.set_camera(Camera::HOME);
assert!(!stage.moved());
}
}
+18 -10
View File
@@ -94,6 +94,13 @@ Draw the screen's Done, Back, Cancel or Apply with `ui::action_button` and the
`widgets::Action` it performs, or call `widgets::declare_action` if the screen
draws its own buttons. That is all: nothing else names the screen.
Every control also needs a focus target, or the pad can't reach it. `ui::button` and its kin register
themselves, as does `settings_shell::foot_button`. `graphics_style::button` and `screen_draw::button` only
paint, so anything drawn with them, or by hand, needs its own
`widgets::record` or `widgets::target_as`. A focus screen with any target has no crosshair, so register all
of it. The `every_screen_has_targets` case of `tools/pad_menu_e2e.py` fails on a focus screen that
registers nothing, unless its allowlist names it.
| Action | Pad button | With the button disabled |
|---|---|---|
| Back, Cancel, Done (the first the screen has that can be pressed, in that order) | B | B falls back to Escape |
@@ -140,18 +147,19 @@ that player's options and bindings.
Both modes feed the screens the same pointer events a mouse sends: in focus
mode the (hidden) pointer sits on the focused widget. So every screen keeps
its mouse behaviour (drags, sliders, scrollbars, hover highlights) with no
controller code of its own, and both modes reach every screen. Moving the
real mouse hands control back at once.
controller code of its own, but focus only lands on what a screen registers,
and a focus screen with any target has no crosshair: a control with no target
on it is out of the pad's reach. Moving the real mouse hands control back at once.
### Focus navigation
Every interactive rectangle a screen draws registers itself as a focus
target with its kind (`widgets::target_as`): classic buttons, sliders, edit
boxes and list rows (worlds, servers, languages, superflat layers and presets, biomes), icon buttons (the title's
and pause menu's Friends button), inventory and container slots, creative and recipe-book
A screen registers what it draws as focus targets with their kind
(`widgets::target_as`): classic buttons, sliders, edit
boxes and list rows (worlds, servers, languages, superflat layers and presets, biomes), icon buttons (the title's,
the pause menu's and Realms'), inventory and container slots, creative and recipe-book
tabs, and the stations' own buttons (trades, loom patterns, beacon effects,
enchantments, stonecutter recipes, the recipe book's recipes, pages and
filter). A screen that registers nothing falls back to the pointer.
filter). Not every control registers yet (see "Adding a screen"). A screen that registers nothing falls back to the pointer.
The step itself is **vanilla's arrow-key navigation**
(`ContainerEventHandler.nextFocusPathInDirection`, 26.3), applied to every
@@ -307,10 +315,10 @@ Steam + X opens it at any time. "On-Screen Keyboard: Auto / Horizon / Steam"
### Screens covered
Both modes reach every screen through the pointer events. Focus mode
Both modes send the same pointer events, but neither reaches everything. Focus mode
registers targets on: the title menus (main, world list and its search, create
and edit world, delete, multiplayer list and its edit screens), every options
page (vanilla's and Horizon Settings with the pages it opens, key and
page (vanilla's and Horizon Settings with the pages it opens except Replay Studio, key and
controller binds, language list), pause and Play with Friends, death, chat, signs and books (their buttons), the
survival and creative inventories, every container and station (chests,
furnaces, brewing, crafter, crafting table with its recipe book, anvil,
@@ -318,7 +326,7 @@ grindstone, smithing, stonecutter, loom, cartography, enchanting, beacon,
villager and wandering-trader trades, mount inventories), and the LAN /
world options. Where a screen draws something clickable no target names
(the advancements map, the statistics lists, the pack screen's rows, the
book's page text), the pad drives the pointer instead.
book's page text), the pad drives the pointer instead, as it does on Replay Studio, which registers nothing yet.
## Glyphs
+159 -21
View File
@@ -41,8 +41,8 @@ y points down, as in vanilla's `ModelPart`s and Blockbench's "Modded Entity" mod
}
```
- `slot`: `head`, `back`, `torso`, `shoulder` or `name_tag`; a player wears at most one item per
slot. A `torso` item is hidden under a chestplate (not an elytra).
- `slot`: `head`, `back`, `torso`, `shoulder`, `name_tag` or `emote` (below); a player wears at
most one item per slot, and plays emotes. A `torso` item is hidden under a chestplate (not an elytra).
- `bone`: the player model part the item follows (`head`, `body`, `left_arm`, `right_arm`,
`left_leg`, `right_leg`; `head` if left out). The item sits at the bone's matrix times `attach` (offset in pixels,
rotation in degrees, a uniform scale in (0, 2]).
@@ -76,6 +76,103 @@ Limits, checked when an item is read (`Item::parse`); an item past one is refuse
Unknown fields are refused, so a file never carries anything the game would silently ignore.
## Emotes (`horizon_cosmetics::Emote`)
An emote is an item of the `emote` slot: a short animation of the player model, owned like any
item, never worn in a slot. Its `emote` block keyframes the bones; its `parts`, if any, are props
(boxes on a bone, as any item's, with the item's texture; an emote without props needs no
`texture`). Purely visual, nothing random, nothing sent to game servers.
```json
{
"format": 1, "id": "example_wave", "slot": "emote", "parts": [],
"emote": { "length": 40, "loop": false, "blend": 4, "tracks": [
{ "part": "right_arm", "keys": [
{ "t": 0 },
{ "t": 8, "rotation": [0, 0, 150] },
{ "t": 16, "rotation": [0, 0, 120], "ease": "linear" },
{ "t": 40 } ] },
{ "part": "root", "keys": [ { "t": 0 }, { "t": 6, "offset": [0, -2, 0] }, { "t": 12 } ] }
] }
}
```
- `length`: ticks; a one-shot ends there, a loop (`"loop": true`) repeats with that period and
wraps from its last key back to its first.
- `blend`: ticks over which the emote fades in from the pose the player had (and a one-shot out
at its end; a cancelled emote fades out from where it was), at most half the length; 4 if left out.
- `tracks`: one per thing moved. `part` is a bone (`head`, `body`, `right_arm`, `left_arm`,
`right_leg`, `left_leg`): the key's `rotation` (degrees) replaces the bone's own and its `offset`
(pixels) moves it from its rest; a bone no track names keeps vanilla's animation (the head
still looks around). `root` turns (`rotation`) and moves (`offset`) the whole body about the
feet. A prop's part name moves that part from its rest (in place of its own loop animations), and only a
prop's key may `scale` it (0 to 2: it may pop in from nothing). A missing value is the rest.
- `ease`, from a key to the next: `smooth` (Catmull-Rom through the neighbouring keys: no corner
at a key; the default), `linear`, `in_out` (smoothstep) or `step` (held).
Limits, checked by `Item::parse` with the item's own (vertices, parts, texture):
| Limit | Value |
|---|---|
| Length | 4 to 200 ticks (10 s) |
| Tracks | 16, one per part |
| Keys | 32 a track, 192 in all, going forward in time within the length |
| Turns | 360° about an axis (the root: 720°, two spins) |
| Moves | 8 pixels from the rest (the root: 16, a block's hop) |
| Props' reach | 24 pixels from their bone, through every key and 32 points of the length |
`Emote::pose_body` poses the six bones at a time and weight (the blend), `Emote::pose_props` the
props; `Playing` is an emote someone plays (its start, its cancel) and gives its time and weight.
### Playing an emote (`game/emotes.rs`, `game/emote_wheel.rs`)
- **The wheel**: *Emote Wheel* (`key.emote`, **B** by default: no vanilla key uses it; Controls
rebinds it, and so does the controller's page, with no button by default: the stick's cursor
picks a slice). Up to 8 emotes around the middle of the screen, the game running behind. Hold
the key, point at one and let go; or tap the key and click one, or press its number (1–8).
Escape or the key again closes it. The wheel lists the emotes the account owns: when it opens,
at most every 10 minutes, the game asks the service (`GET /v1/me`'s `emotes`: id, file hash
and names, signed in with the session `account::Kept` keeps) and reads the files it lacks
(`/v1/items`); `HORIZON_COSMETICS_LOCAL`: the folder's. An offline account has none.
- **Cancelled** by moving more than a hundredth of a block, attacking or mining, using an item,
being hurt, riding, sleeping, gliding, sneaking, swimming or dying: it fades out over its blend.
Only a standing player starts one. A loop plays until cancelled.
- **The camera**: from first person it turns to the front view (third person, facing you) for
the emote and eases out from close to its usual distance over a quarter second; when the emote
ends it goes back to first person, unless you changed the view meanwhile. From a third person
view it stays as it is.
- **Visual only**: it changes nothing of the game; the game server receives nothing and sees a
player standing still. `horizon-ctl emote [ID|stop]` plays one as the wheel does.
### Others' emotes (`horizon_cosmetics::Feed`)
Vanilla servers stay untouched: emotes travel through the cosmetics service only.
- **Mine**: while one plays, the game says so, signed in as for *Show me as Horizon*
(`POST /v1/emote {"emote": id}`, `{"emote": null}` when it stops early), with a service
session kept for the hour it lives (`account::Kept`: one Mojang `join` an hour, not one per
emote). A loop is said again every 30 s (the service keeps one a minute). Only while *Show me
as Horizon* is on: playing an emote tells anyone who asks that you play Horizon, so with it off
nothing is sent. An offline account sends nothing. The service takes at most 6 at once, then
one every 2 s per account, owned emotes only.
- **Theirs**: each tick the game names the Horizon players in view (the `horizon` list of the
`/v1/worn` answers, at most 64) to a thread of its own, which asks
`POST /v1/emotes {"players": [...]}` at most once a second, only while someone is named
(nobody in view who plays Horizon: no request). The answer gives each player's emote, its
file's hash and how long ago it started (`{"playing": {uuid: {"id", "hash", "ms"}}}`), and the
game plays it on that player's model from that point; the item file comes once
(`/v1/items`). A player who moves ends theirs on this screen at once. A failure waits 15 s,
doubling to 10 minutes, and ends the emotes shown.
- **Switches**: others' emotes follow *Other players' cosmetics*: shown only with *Shown*; with
*Name tags only* or *Hidden* nothing is asked and nothing is drawn. Vanilla clients see nothing.
- **Cost**: per player in view, one cache look a tick (the same lock as what they wear) and,
while one plays, the six bones' sampling (a few keys each) after the vanilla animation in the
pass that draws them; props are boxes in the item's budget, drawn as cosmetics are (the GPU
skin path). Nobody emoting: one `Option` check per player model. Measured (2026-11-05, an
i7-7500U laptop, `Playing::at` + `pose_body` + `pose_props` over each emote's length): 0.26 to
0.41 µs per emoting player and frame, about 1 µs for one that turns its root (Victory Hop,
Harvest Jig); 30 players emoting at once stay under 35 µs a frame.
## What other players wear (`horizon_cosmetics::Cosmetics`)
The game asks Horizon's cosmetics service (HTTPS, JSON) what the players in view wear, and draws
@@ -128,7 +225,8 @@ same cached `/v1/worn` answers as what they wear (no request of its own, nothing
everything off for one run (no thread, no request, no setting, no drawing), `=1` on;
`HORIZON_COSMETICS_API=<url>` points the game at another service, and
`HORIZON_COSMETICS_LOCAL=<folder>` reads items from a folder instead: every player and every
mannequin wears its first item of each slot (the art and the bench, without the network).
mannequin wears its first item of each slot, and every mannequin plays the folder's first
emote over and over (the art and the bench, without the network).
- **Settings** (Extras, under "Cosmetics", shown only with the flag on), the same three as the
launcher's Wardrobe (`horizon_cosmetics::prefs`, lines of `horizon-extras.txt`):
- *My cosmetics: Shown / Hidden* is the account's (`PUT /v1/visible`): hidden, every Horizon
@@ -176,19 +274,37 @@ same cached `/v1/worn` answers as what they wear (no request of its own, nothing
does no cosmetics work beyond that. The bench scene `stress_avatars` (300 mannequins, 20×15)
measures the cost with `HORIZON_COSMETICS=1 HORIZON_COSMETICS_LOCAL=…` against the same run off.
## A still picture (`horizon_cosmetics::preview`)
## The live view (`horizon_cosmetics::preview`, `horizon_cosmetics::viewer`)
The launcher shows items on the player's own skin without the game: `preview::render` draws the
player model at rest (vanilla's `PlayerModel` boxes on a 64×64 skin, wide or slim arms, the outer
layers included) and each item on its bone on the CPU, with a depth buffer and cutout alpha, seen
from any angle.
player model (vanilla's `PlayerModel` boxes on a 64×64 skin, wide or slim arms, the outer layers
included) and each item on its bone on the CPU, with a depth buffer and cutout alpha, as the game's
entity cutout draws them, seen from a `preview::Camera` (turn, tilt, zoom).
- **Camera**: dragging turns the player as if held (0.6° a point; the tilt stays within −30° to
60°), the wheel comes up to 3 times closer (×1.15 a step), closing in on the head; *Reset view*
goes back to `Camera::HOME` (30° turned, 10° from above, the whole player).
- **Animations** play on the game's clock (20 ticks a second, the same `Item::animate` as the
game), only while the window is in front and not under reduced motion (`HORIZON_LAUNCHER_MOTION`,
the system's setting); in the background the clock stops and they go on from there.
- **Cost**: `viewer::Stage` says when to draw again. Nothing changed (camera, outfit, scale, the
clock), nothing is drawn and no timer runs: an unchanged page, an idle view or a window in the
background does no work. At most 20 pictures a second while something animates (one a game
tick), 60 while dragging or zooming. A picture is drawn at the view's size in points (240×330,
each pixel a block of device pixels, as the launcher's pixel art), about 2 ms on the CPU on any
screen. The view is its own GPUI view: a new picture rebuilds it alone, the
page around it is drawn from the last frame.
- **Emotes**: `viewer::BodyMotion` is a body animation played once (the parts' poses over its
length, on the same clock); `preview::Draw::item_posed` keeps each item on its bone as posed. The
launcher's `Viewer::play` takes one: the emote format implements `BodyMotion` and the Wardrobe
calls it.
## The Wardrobe's account check (`launcher/src/wardrobe.rs`)
The launcher talks to the same service for the player's own items: the catalogue, what the account
owns, its purchase history, what each slot wears and whether other players see it. Buying and
claiming the founder set happen on the website's shop (https://playhorizon.dev/shop), never in the
launcher.
owns, its purchase history, what each slot wears and whether other players see it. Paying happens
on the website: the launcher's Shop tab starts the checkout and opens https://playhorizon.dev/shop/pay
in the browser; no card is ever typed into the launcher.
- **Who the player is**: the service hands out a one-time server id (`POST /v1/auth/start`), the
launcher tells Mojang's session server that the account joins it
@@ -222,17 +338,18 @@ its last visit, does (a claim, or a preview the service grants that account). Wi
page stays hidden.
- **A row per slot** (head, back, shirt, shoulder pet, name tag) with what it wears,
"Nothing equipped" or "None owned yet"; **your player** in the middle, on your own skin, wearing
it, with your name tag over the head as others see it (Horizon's mark unless the game's *Show me
as Horizon* is off, your style's badge after the name) (`horizon_cosmetics::preview`); **what you own for the
chosen slot**, "Nothing" first. Equipping shows at once.
- **The shop is on the website only** (Ludovic, 2026-11-05): the launcher has no prices, product
lists or checkout. One plain button by the page's title, *Get more cosmetics on playhorizon.dev*,
opens `https://playhorizon.dev/shop` in the browser (browsing, prices, the founder claim,
purchases through Paddle); no badge, reminder or pop-up points to it. An account that owns
nothing yet sees "You don't own any cosmetics yet." with that button instead. The one exception
is the founder set's free *Claim for free* where Paddle doesn't sell (`Claim::here`): the site
sends those players to the Wardrobe, since only the launcher can prove the account with Mojang. **Your purchases** lists the account's orders (date, item, price, state, order number).
"Nothing equipped" or "None owned yet"; **your player** in the middle, in 3D on your own skin,
wearing it, with your name tag over the head as others see it (Horizon's mark unless the game's
*Show me as Horizon* is off, your style's badge after the name): drag to turn, scroll to zoom,
*Reset view* (the live view above, `launcher/src/wardrobe_page/viewer.rs`); **what you own for
the chosen slot**, "Nothing" first. Hovering a card tries its item on the player (or, for
"Nothing", takes the slot's item off) until the pointer leaves; equipping shows at once.
- **One way to the Shop tab**: a plain button by the page's title, *Get more
cosmetics*, opens the launcher's Shop tab (below; paying happens on the website); no badge,
reminder or pop-up points to it. An account that owns nothing yet sees "You don't own any
cosmetics yet." with that button instead. The founder set's free *Claim for free* where Paddle
doesn't sell (`Claim::here`) stays here too: the site sends those players to the Wardrobe, since
only the launcher can prove the account with Mojang. **Your purchases** lists the account's orders (date, item, price, state, order number).
- **Three switches**: *Show my cosmetics* (the account's, on the service: off, everyone sees you
without them), *Hide other players' cosmetics* and *Show other players' auras and trails* (off by
default). The last two are lines of the game's `horizon-extras.txt` (`horizon_cosmetics::prefs`:
@@ -246,3 +363,24 @@ page stays hidden.
- `HORIZON_LAUNCHER_PAGE=wardrobe` in a development build shows the page for a made-up account
wearing `HORIZON_COSMETICS_LOCAL`'s items; `HORIZON_WARDROBE_STATE=offline|failed|empty`
shows that state.
## The Shop page (`launcher/src/wardrobe_page/shop.rs`, `launcher/src/shop.rs`)
A plain top-bar tab beside the Wardrobe, shown under the same rule. Ludovic (2026-11-05): not in your
face, people play without pop-ups. So the tab has no badge, dot, count or "New"; it never opens by
itself and is never the start page; nothing anywhere (launcher or game) points to it; Play never waits
for it. No countdowns, scarcity, buyer counts, "limited" wording or loot boxes.
- **Everything comes from `GET /v1/catalogue`** (the Wardrobe's own trip): `currencies`, each item's
`prices` (minor units per currency, only while on sale), `sets`, `collections` and `supporter`.
What the catalogue doesn't price isn't sold, so items the service puts on sale
(`COSMETICS_ON_SALE`) show up by themselves. The currency is guessed from the system locale
(francs in CH/LI, pounds in GB, euros in the euro area, else dollars) and can be switched.
- **Cards** per collection (sets first), the founder set while it can be claimed, the Supporter badge.
Choosing a card tries it on the account's own skin, over what it wears (`shop::try_on`), in the
Wardrobe's 3D view (the live view above, a camera of its own): drag to turn, scroll to zoom.
- **The founder set**: *Claim for free* where Paddle doesn't sell (`Claim::here`), else Paddle's free
checkout in the browser (`POST /v1/web/checkout` with `founder_set`).
- **A catalogue that doesn't load** shows "The shop didn't load." with *Retry*; nothing else changes.
- `HORIZON_LAUNCHER_PAGE=shop` in a development build shows it for the made-up account of the
Wardrobe's preview, the founder set tried on.
+1
View File
@@ -61,6 +61,7 @@ Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launch
| Command | MCP tool | What it does | Arguments |
| --- | --- | --- | --- |
| `emote [ID\|stop]` | `emote` | Play one of the player's emotes, stop it, or list them | `id` (string): an owned emote's id, or stop |
| `break X Y Z [--timeout S]` | `break` | Mine a block, holding the attack button until it breaks | `x` (integer, required): block X<br>`y` (integer, required): block Y<br>`z` (integer, required): block Z<br>`--timeout` (number): give up after this many seconds (default 30) |
| `place X Y Z [--item ITEM]` | `place` | Place the held item (or ITEM from the hotbar) at an empty position | `x` (integer, required): block X<br>`y` (integer, required): block Y<br>`z` (integer, required): block Z<br>`--item` (string): hotbar item to select first |
| `interact X Y Z [FACE]` | `interact` | Right-click a block: open a chest or crafting table, press a button, open a door | `x` (integer, required): block X<br>`y` (integer, required): block Y<br>`z` (integer, required): block Z<br>`face` (down, up, north, south, west, east): which face to click |
+20
View File
@@ -2964,6 +2964,26 @@ a potion's kinds, are ids of their own (`create:potion/healing`). The mod draws
cauldron takes only water and lava, and the world itself has only vanilla's fluids: no source or flowing blocks of a
mod's fluid yet, and no buckets of one.
A tank may refuse a fluid: `native_block_entity_accepts_fluid(block, tank, fluid, cancel)` (NeoForge's
`IFluidHandler.isFluidValid`) is asked on the server before `native_fluid_insert` fills a fillable tank, in both its
passes (same fluid first, then empty), and before `native_move_fluids` counts a tank's room. Set `cancel` to refuse the
fluid there, so a water tank takes no `create:chocolate` and a mod removed later leaves no fluid id the tank cannot use.
Every tank takes every fluid unless a mod says otherwise, and a cauldron, which holds only water and lava, never asks:
```rust,ignore
fn block_entity_accepts_fluid(
&self,
_host: &dyn Host,
event: &mut BlockEntityAcceptsFluidEvent<'_>,
) -> Flow {
if event.block == "mymod:water_tank" && event.fluid != "minecraft:water" {
Flow::Cancel
} else {
Flow::Continue
}
}
```
### Falling blocks
`crates/horizon-client/src/game/native_falling.rs`: a helper for server mods, for the level in place, on a server only. It
+24 -2
View File
@@ -6,8 +6,10 @@ off by default: a fresh install looks exactly like vanilla 26.3, and turning one
## Fair play
Nothing here tells the player more than vanilla does or plays for them, so the game stays fine on servers
that ban unfair clients. That rules out, on purpose: a reach display, entities on the minimap, freelook,
auto-clicking or any other automatic action, seeing through walls, and hitboxes beyond vanilla's F3+B.
that ban unfair clients. That rules out, on purpose: entities on the minimap, freelook, auto-clicking or any
other automatic action, seeing through walls, and hitboxes beyond vanilla's F3+B. The HUD widgets read only
what this client already has: they send no packet and ask the server nothing, so an anticheat sees nothing new.
The reach widget measures your own last hit and never changes reach.
Three options do see further than vanilla would at that moment, and some strict servers ban fog or light
changes: clear water and clear lava (below), and a fixed time of day, whose sky light brightens a night.
They are off by default; check a server's rules before turning them on there.
@@ -74,6 +76,26 @@ box's quads. With nothing changing, a frame allocates nothing.
as the damage's direct entity (`ClientboundDamageEventPacket`: melee and spear hits, not arrows or fire). A
report that names no attacker counts once, within a second of a swing at that entity. Back to 0 when you are hurt or
3 seconds pass without a hit. It counts what the game already shows (the target's hurt flash).
- **FPS** (`hudFps`, top left): the frames drawn in the last second, as F3's first line counts them.
- **Clicks per second** (`hudCps`, top left): your left and right presses in the last second, "CPS: 8 | 3".
Counted from the mouse buttons while playing (no screen open); nothing is sent for them.
- **Keystrokes** (`hudKeys`, bottom left): W A S D, both mouse buttons with their clicks per second, and jump,
lit while held. The letters follow your key bindings.
- **Clock** (`hudClock`, top right): your computer's local time, HH:MM.
- **Reach** (`hudReach`, top left): for 3 seconds after you hit an entity, how far from your eyes the hit
landed (the pick's distance to its box), "Reach: 2.87". It reads the pick the attack already used.
- **Speed** (`hudSpeed`, top left): your speed across the ground in blocks per second, over the last quarter
second; a teleport reads 0.
Each `hud…` option has a `hud…Corner` beside it. With every widget off, a frame costs one test of the options.
Style, for every widget: **Background opacity** (`hudOpacity`, 0 to 100, default 47, under the background
switch), **Text colour** (`hudTextColour`, default white; coloured lines such as durability keep their colour)
and **Text shadow** (`hudShadow`, default on).
Layout: a widget moved by hand stays where it was put, as `hudPlace:<widget>,<x>,<y>,<scale>` lines (`x` and `y`
are fractions of the room the screen leaves beside the widget, so it stays on screen after a resize; `corner` instead of them keeps the corner; scale in
percent, 50 to 200). Widgets without such a line keep their corner. `pvp_hud::Layout`, `pvp_hud::snap`.
## Enchantment levels as numbers (`numericEnchants`, default off)
+1
View File
@@ -2717,6 +2717,7 @@ dependencies = [
"libc",
"objc2-app-kit 0.3.2",
"oo7",
"png 0.18.1",
"proc-macro2",
"pulldown-cmark",
"quote",
+2
View File
@@ -70,6 +70,8 @@ getrandom = "0.3"
# Players' heads on the Servers page, from the default skins in the player's own game files (already
# in the lockfile through gpui).
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
# Screenshots read a row at a time and shrunk as they come (`pictures`): the version `image` uses.
png = "0.18"
# Native builds at low priority (rust_toolchain::low_priority); already in the lockfile.
[target.'cfg(unix)'.dependencies]
+34 -3
View File
@@ -955,7 +955,8 @@
"Head": "Hoof",
"Back": "Terug",
"Nothing": "Niks nie",
"Turn": "Draai",
"Reset view": "Herstel aansig",
"Drag to turn, scroll to zoom.": "Sleep om te draai, rol om te zoem.",
"Shoulder pet": "Skouer troeteldier",
"Name tag": "Naamplaatjie",
"Launcher theme": "Launcher tema",
@@ -1146,7 +1147,7 @@
"Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.": "Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.",
"Move…": "Move…",
"The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.": "The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.",
"Get more cosmetics on playhorizon.dev": "Kry meer kosmetiese items op playhorizon.dev",
"Get more cosmetics": "Kry meer kosmetiese items",
"You don't own any cosmetics yet.": "Jy besit nog geen skoonheidsmiddels nie.",
"Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.": "Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.",
"Game architecture": "Game architecture",
@@ -1182,5 +1183,35 @@
"Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}": "Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}",
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.",
"Tags": "Etikette",
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on."
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.",
"No Minecraft: Java Edition account has that name.": "Geen Minecraft: Java Edition-rekening het daardie naam nie.",
"That account already owns everything in the basket.": "Daardie rekening besit reeds alles in die mandjie.",
"The shop isn't selling right now. Nothing was charged.": "Die winkel verkoop nie nou nie. Niks is gehef nie.",
"Paddle couldn't start the checkout. Nothing was charged.": "Paddle kon nie die betaling begin nie. Niks is gehef nie.",
"Shop": "Winkel",
"Owned": "In besit",
"Claim the founder set": "Eis die stigter stel",
"Claiming…": "Eis tans op…",
"The shop didn't load.": "Die winkel het nie gelaai nie.",
"Things to wear for your player, seen by everyone who plays on Horizon.": "Goed om vir jou speler te dra, sigbaar vir almal wat op Horizon speel.",
"Free for founders": "Gratis vir stigters",
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Vir die spelers wat eerste hier was. Gratis om op te eis; sodra jy dit opgeëis het, is dit joune om te hou.",
"Finish the free checkout in your browser. The set lands here a moment later.": "Voltooi die gratis kassa in jou blaaier. Die stel verskyn 'n oomblik later hier.",
"{n} items": "{n} items",
"Nothing is on sale right now.": "Niks is nou te koop nie.",
"Your basket": "Jou mandjie",
"Your basket is empty.": "Jou mandjie is leeg.",
"Add to basket": "Voeg by mandjie",
"Total": "Totaal",
"This is a gift": "Dis 'n geskenk",
"Your friend's Minecraft name": "Jou vriend se Minecraft-naam",
"Find": "Soek",
"For {name}": "Vir {name}",
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Vra wie ook al betaal voordat jy koop. Terugbetalings binne 14 dae om enige rede.",
"Pay {price} in your browser": "Betaal {price} in jou blaaier",
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Paddle se betaling maak in jou blaaier oop. Niks word in die lanseerder gehef nie.",
"US sales tax is added where a state charges it.": "VS-verkoopsbelasting word bygevoeg waar 'n staat dit hef.",
"VAT included where it applies.": "BTW ingesluit waar dit geld.",
"In a set in your basket": "In 'n stel in jou mandjie",
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Voltooi die betaling in jou blaaier. Jou items kom 'n oomblik later in die Klerekas."
}
+34 -3
View File
@@ -957,7 +957,8 @@
"Head": "الرأس",
"Back": "الظهر",
"Nothing": "لا شيء",
"Turn": "تدوير",
"Reset view": "إعادة ضبط العرض",
"Drag to turn, scroll to zoom.": "اسحب للتدوير، ومرّر للتكبير.",
"Shoulder pet": "حيوان على الكتف",
"Name tag": "بطاقة الاسم",
"Launcher theme": "سمة المشغل",
@@ -1146,7 +1147,7 @@
"Move…": "نقل…",
"The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.": "يُغلق المشغل أثناء نسخ بياناتك والتحقق منها، ثم يُفتح مجددًا. يُحتفظ بالمجلدات القديمة وتظل تشغل مساحة على القرص حتى تتحقق من نجاح النقل وتحذفها بنفسك.",
"Could not open the data folder: {error}": "Could not open the data folder: {error}",
"Get more cosmetics on playhorizon.dev": "احصل على المزيد من مستحضرات التجميل على playhorizon.dev",
"Get more cosmetics": "احصل على المزيد من مستحضرات المظهر",
"You don't own any cosmetics yet.": "لا تملك أي مظاهر بعد.",
"Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.": "يتبع الخيار التلقائي بنية هذا المشغّل. على Windows ARM، اختر x64 أو ARM64. تستخدم الإصدارات التي لا تتوفر لها ARM64 نسخة x64 الموقّعة.",
"Game architecture": "بنية اللعبة",
@@ -1182,5 +1183,35 @@
"Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}": "Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}",
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.",
"Tags": "الوسوم",
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on."
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.",
"No Minecraft: Java Edition account has that name.": "لا يوجد حساب Minecraft: Java Edition بهذا الاسم.",
"That account already owns everything in the basket.": "هذا الحساب يملك بالفعل كل ما في السلة.",
"The shop isn't selling right now. Nothing was charged.": "المتجر لا يبيع الآن. لم يُخصم أي مبلغ.",
"Paddle couldn't start the checkout. Nothing was charged.": "تعذّر على Paddle بدء الدفع. لم يُخصم أي مبلغ.",
"Shop": "المتجر",
"Owned": "تملكه",
"Claim the founder set": "استلم طقم المؤسسين",
"Claiming…": "جارٍ الحصول عليه…",
"The shop didn't load.": "لم يتم تحميل المتجر.",
"Things to wear for your player, seen by everyone who plays on Horizon.": "أشياء يرتديها لاعبك، يراها كل من يلعب على Horizon.",
"Free for founders": "مجاني للمؤسسين",
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "للاعبين الذين كانوا هنا أولًا. مجاني؛ وبعد الحصول عليه يبقى لك.",
"Finish the free checkout in your browser. The set lands here a moment later.": "أكمل عملية الدفع المجانية في متصفحك. ستظهر المجموعة هنا بعد لحظات.",
"{n} items": "{n} عناصر",
"Nothing is on sale right now.": "لا شيء معروض للبيع الآن.",
"Your basket": "سلتك",
"Your basket is empty.": "سلتك فارغة.",
"Add to basket": "أضف إلى السلة",
"Total": "المجموع",
"This is a gift": "هذه هدية",
"Your friend's Minecraft name": "اسم صديقك في Minecraft",
"Find": "ابحث",
"For {name}": "إلى {name}",
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "اسأل من سيدفع قبل أن تشتري. استرداد خلال 14 يومًا لأي سبب.",
"Pay {price} in your browser": "ادفع {price} في متصفحك",
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "يُفتح دفع Paddle في متصفحك. لا يُخصم شيء في المشغّل.",
"US sales tax is added where a state charges it.": "تُضاف ضريبة المبيعات الأمريكية حيث تفرضها الولاية.",
"VAT included where it applies.": "ضريبة القيمة المضافة مشمولة حيث تنطبق.",
"In a set in your basket": "ضمن مجموعة في سلتك",
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "أكمل الدفع في متصفحك. تصل عناصرك إلى خزانة الملابس بعد لحظة."
}
+34 -3
View File
@@ -955,7 +955,8 @@
"Head": "Baş",
"Back": "Geri",
"Nothing": "heç nə",
"Turn": "Dön",
"Reset view": "Görünüşü sıfırla",
"Drag to turn, scroll to zoom.": "Döndürmək üçün sürükləyin, yaxınlaşdırmaq üçün sürüşdürün.",
"Shoulder pet": "Çiyin ev heyvanı",
"Name tag": "Ad etiketi",
"Launcher theme": "Başlatıcı mövzu",
@@ -1146,7 +1147,7 @@
"Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.": "Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.",
"Move…": "Move…",
"The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.": "The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.",
"Get more cosmetics on playhorizon.dev": "Daha çox kosmetik əşyanı playhorizon.dev saytında əldə edin",
"Get more cosmetics": "Daha çox kosmetik əşya əldə edin",
"You don't own any cosmetics yet.": "Hələ heç bir kosmetikanız yoxdur.",
"Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.": "Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.",
"Game architecture": "Game architecture",
@@ -1182,5 +1183,35 @@
"Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}": "Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}",
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.",
"Tags": "Teqlər",
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on."
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.",
"No Minecraft: Java Edition account has that name.": "Bu adda Minecraft: Java Edition hesabı yoxdur.",
"That account already owns everything in the basket.": "Bu hesab artıq səbətdəki hər şeyə sahibdir.",
"The shop isn't selling right now. Nothing was charged.": "Mağaza hazırda satış etmir. Heç nə tutulmayıb.",
"Paddle couldn't start the checkout. Nothing was charged.": "Paddle ödənişi başlada bilmədi. Heç nə tutulmayıb.",
"Shop": "Mağaza",
"Owned": "Sahibsiniz",
"Claim the founder set": "Təsisçi dəstini tələb edin",
"Claiming…": "Əldə edilir…",
"The shop didn't load.": "Mağaza yüklənmədi.",
"Things to wear for your player, seen by everyone who plays on Horizon.": "Oyunçunuz üçün geyimlər, Horizon-da oynayan hər kəsə görünür.",
"Free for founders": "Qurucular üçün pulsuz",
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Ən əvvəl burada olan oyunçular üçün. Pulsuz əldə etmək olar; əldə etdikdən sonra həmişəlik sizindir.",
"Finish the free checkout in your browser. The set lands here a moment later.": "Pulsuz ödənişi brauzerinizdə tamamlayın. Dəst bir az sonra burada görünəcək.",
"{n} items": "{n} əşya",
"Nothing is on sale right now.": "Hazırda heç nə satışda deyil.",
"Your basket": "Səbətiniz",
"Your basket is empty.": "Səbətiniz boşdur.",
"Add to basket": "Səbətə əlavə et",
"Total": "Cəmi",
"This is a gift": "Bu hədiyyədir",
"Your friend's Minecraft name": "Dostunuzun Minecraft adı",
"Find": "Tap",
"For {name}": "{name} üçün",
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Almazdan əvvəl ödəyəcək şəxsdən soruşun. İstənilən səbəbdən 14 gün ərzində geri ödəniş.",
"Pay {price} in your browser": "{price} məbləğini brauzerdə ödə",
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Paddle ödənişi brauzerinizdə açılır. Launcher-də heç nə tutulmur.",
"US sales tax is added where a state charges it.": "ABŞ satış vergisi ştatın tələb etdiyi yerdə əlavə olunur.",
"VAT included where it applies.": "ƏDV tətbiq olunduğu yerdə daxildir.",
"In a set in your basket": "Səbətinizdəki dəstdə",
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Ödənişi brauzerinizdə bitirin. Əşyalarınız bir azdan Qarderoba gələcək."
}
+34 -3
View File
@@ -955,7 +955,8 @@
"Head": "Баш .",
"Back": "Артҡа",
"Nothing": "Бер ни ҙә .",
"Turn": "боролош.",
"Reset view": "Күренеште ҡайтарыу",
"Drag to turn, scroll to zoom.": "Әйләндереү өсөн тартығыҙ, яҡынайтыу өсөн тәгәрәтегеҙ.",
"Shoulder pet": "Елкә йорт хайуаны",
"Name tag": "Исем билдәһе",
"Launcher theme": "Запуск темаһы",
@@ -1146,7 +1147,7 @@
"Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.": "Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.",
"Move…": "Move…",
"The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.": "The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.",
"Get more cosmetics on playhorizon.dev": "Күберәк косметиканы playhorizon.dev сайтында алығыҙ",
"Get more cosmetics": "Күберәк косметика алығыҙ",
"You don't own any cosmetics yet.": "Һеҙҙә әлегә бер косметика ла юҡ.",
"Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.": "Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.",
"Game architecture": "Game architecture",
@@ -1182,5 +1183,35 @@
"Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}": "Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}",
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.",
"Tags": "Тегтар",
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on."
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.",
"No Minecraft: Java Edition account has that name.": "Был исемле Minecraft: Java Edition иҫәп яҙмаһы юҡ.",
"That account already owns everything in the basket.": "Был иҫәп яҙмаһында кәрзиндәге бар нәмә лә бар инде.",
"The shop isn't selling right now. Nothing was charged.": "Магазин хәҙер һатмай. Бер нәмә лә алынманы.",
"Paddle couldn't start the checkout. Nothing was charged.": "Paddle түләүҙе башлай алманы. Бер нәмә лә алынманы.",
"Shop": "Магазин",
"Owned": "Һинеке",
"Claim the founder set": "Нигеҙ һалыусылар йыйылмаһына дәғүә итегеҙ",
"Claiming…": "Алына…",
"The shop didn't load.": "Магазин ыңғайланманы.",
"Things to wear for your player, seen by everyone who plays on Horizon.": "Уйынсыңа кейем, Horizon-да уйнаусы һәр кемгә күренә.",
"Free for founders": "Нигеҙләүселәр өсөн бушлай",
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Беренсе булып килгән уйынсылар өсөн. Бушлай алырға була; алғас, мәңгелеккә һинеке.",
"Finish the free checkout in your browser. The set lands here a moment later.": "Бушлай түләүҙе браузерҙа тамамлағыҙ. Йыйылма бер аҙҙан бында барлыҡҡа килә.",
"{n} items": "{n} әйбер",
"Nothing is on sale right now.": "Хәҙер бер нәмә лә һатылмай.",
"Your basket": "Кәрзинегеҙ",
"Your basket is empty.": "Кәрзинегеҙ буш.",
"Add to basket": "Кәрзингә өҫтәргә",
"Total": "Барыһы",
"This is a gift": "Был бүләк",
"Your friend's Minecraft name": "Дуҫығыҙҙың Minecraft исеме",
"Find": "Табырға",
"For {name}": "{name} өсөн",
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Һатып алыр алдынан кем түләйәсәген һорағыҙ. Теләһә ниндәй сәбәп менән 14 көн эсендә аҡса кире ҡайтарыла.",
"Pay {price} in your browser": "{price} браузерҙа түләргә",
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Paddle түләүе браузерығыҙҙа асыла. Лаунчерҙа бер нәмә лә алынмай.",
"US sales tax is added where a state charges it.": "АҠШ һатыу һалымы штат талап иткән урында өҫтәлә.",
"VAT included where it applies.": "ҠӨҺ ҡулланылған урында индерелгән.",
"In a set in your basket": "Кәрзинегеҙҙәге йыйылмала",
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Түләүҙе браузерҙа тамамлағыҙ. Әйберҙәрегеҙ бер аҙҙан Гардеробҡа килә."
}
+34 -3
View File
@@ -955,7 +955,8 @@
"Head": "Галава",
"Back": "Назад",
"Nothing": "нічога",
"Turn": "Паварот",
"Reset view": "Скінуць выгляд",
"Drag to turn, scroll to zoom.": "Цягніце, каб павярнуць, пракручвайце, каб наблізіць.",
"Shoulder pet": "Плечавы гадаванец",
"Name tag": "Бірка з імем",
"Launcher theme": "Тэма лаунчера",
@@ -1146,7 +1147,7 @@
"Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.": "Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.",
"Move…": "Move…",
"The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.": "The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.",
"Get more cosmetics on playhorizon.dev": "Больш касметыкі на playhorizon.dev",
"Get more cosmetics": "Больш касметыкі",
"You don't own any cosmetics yet.": "У вас пакуль няма касметыкі.",
"Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.": "Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.",
"Game architecture": "Game architecture",
@@ -1182,5 +1183,35 @@
"Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}": "Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}",
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.",
"Tags": "Тэгі",
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on."
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.",
"No Minecraft: Java Edition account has that name.": "Няма ўліковага запісу Minecraft: Java Edition з такім імем.",
"That account already owns everything in the basket.": "Гэты ўліковы запіс ужо мае ўсё, што ў кошыку.",
"The shop isn't selling right now. Nothing was charged.": "Крама зараз не прадае. Нічога не спісана.",
"Paddle couldn't start the checkout. Nothing was charged.": "Paddle не змог пачаць аплату. Нічога не спісана.",
"Shop": "Крама",
"Owned": "У вас",
"Claim the founder set": "Прэтэнзіі заснавальніка мноства",
"Claiming…": "Атрымліваем…",
"The shop didn't load.": "Краму не ўдалося загрузіць.",
"Things to wear for your player, seen by everyone who plays on Horizon.": "Рэчы для вашага персанажа, якія бачаць усе, хто гуляе ў Horizon.",
"Free for founders": "Бясплатна для заснавальнікаў",
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Для гульцоў, якія былі тут першымі. Можна атрымаць бясплатна; атрымаўшы, вы пакідаеце яго назаўсёды.",
"Finish the free checkout in your browser. The set lands here a moment later.": "Завяршыце бясплатную аплату ў браўзеры. Набор з’явіцца тут праз імгненне.",
"{n} items": "{n} прадметаў",
"Nothing is on sale right now.": "Зараз нічога не прадаецца.",
"Your basket": "Ваш кошык",
"Your basket is empty.": "Ваш кошык пусты.",
"Add to basket": "Дадаць у кошык",
"Total": "Разам",
"This is a gift": "Гэта падарунак",
"Your friend's Minecraft name": "Імя вашага сябра ў Minecraft",
"Find": "Знайсці",
"For {name}": "Для {name}",
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Спытайце таго, хто плаціць, перш чым купляць. Вяртанне грошай на працягу 14 дзён па любой прычыне.",
"Pay {price} in your browser": "Аплаціць {price} у браўзеры",
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Аплата Paddle адкрываецца ў браўзеры. У лаунчары нічога не спісваецца.",
"US sales tax is added where a state charges it.": "Падатак з продажаў ЗША дадаецца там, дзе штат яго бярэ.",
"VAT included where it applies.": "ПДВ уключаны там, дзе ён ужываецца.",
"In a set in your basket": "У наборы ў вашым кошыку",
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Завяршыце аплату ў браўзеры. Рэчы з'явяцца ў Гардэробе праз хвіліну."
}
+34 -3
View File
@@ -957,7 +957,8 @@
"Head": "Глава",
"Back": "Назад",
"Nothing": "нищо",
"Turn": "Завъртете",
"Reset view": "Нулиране на изгледа",
"Drag to turn, scroll to zoom.": "Плъзнете, за да завъртите, превъртете, за да увеличите.",
"Shoulder pet": "Раменен любимец",
"Name tag": "Етикет с име",
"Launcher theme": "Тема за стартиране",
@@ -1146,7 +1147,7 @@
"Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.": "Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.",
"Move…": "Move…",
"The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.": "The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.",
"Get more cosmetics on playhorizon.dev": "Още козметика на playhorizon.dev",
"Get more cosmetics": "Още козметика",
"You don't own any cosmetics yet.": "Все още нямате козметика.",
"Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.": "Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.",
"Game architecture": "Game architecture",
@@ -1182,5 +1183,35 @@
"Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}": "Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}",
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.",
"Tags": "Етикети",
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on."
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.",
"No Minecraft: Java Edition account has that name.": "Няма акаунт в Minecraft: Java Edition с това име.",
"That account already owns everything in the basket.": "Този акаунт вече притежава всичко в кошницата.",
"The shop isn't selling right now. Nothing was charged.": "Магазинът не продава в момента. Нищо не е таксувано.",
"Paddle couldn't start the checkout. Nothing was charged.": "Paddle не успя да започне плащането. Нищо не е таксувано.",
"Shop": "Магазин",
"Owned": "Притежаваш",
"Claim the founder set": "Заявете основателния комплект",
"Claiming…": "Взимане…",
"The shop didn't load.": "Магазинът не се зареди.",
"Things to wear for your player, seen by everyone who plays on Horizon.": "Неща за обличане на твоя играч, видими за всички, които играят на Horizon.",
"Free for founders": "Безплатно за основателите",
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "За играчите, които бяха тук първи. Безплатно; веднъж взето, остава твое.",
"Finish the free checkout in your browser. The set lands here a moment later.": "Завърши безплатното плащане в браузъра си. Комплектът се появява тук след малко.",
"{n} items": "{n} предмета",
"Nothing is on sale right now.": "В момента нищо не се продава.",
"Your basket": "Вашата кошница",
"Your basket is empty.": "Кошницата ви е празна.",
"Add to basket": "Добави в кошницата",
"Total": "Общо",
"This is a gift": "Това е подарък",
"Your friend's Minecraft name": "Името на приятеля ви в Minecraft",
"Find": "Намери",
"For {name}": "За {name}",
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Попитайте този, който плаща, преди да купите. Възстановяване на сумата до 14 дни по всякаква причина.",
"Pay {price} in your browser": "Плати {price} в браузъра",
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Плащането на Paddle се отваря в браузъра ви. В лаунчера нищо не се таксува.",
"US sales tax is added where a state charges it.": "Данък върху продажбите в САЩ се добавя, където щатът го налага.",
"VAT included where it applies.": "ДДС е включен, където се прилага.",
"In a set in your basket": "В комплект в кошницата ви",
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Завършете плащането в браузъра. Предметите ще се появят в Гардероба след миг."
}
+34 -3
View File
@@ -955,7 +955,8 @@
"Head": "Penn",
"Back": "En-dro",
"Nothing": "Netra",
"Turn": "Treiñ",
"Reset view": "Adderaouekaat ar sell",
"Drag to turn, scroll to zoom.": "Riklit evit treiñ, dibunit evit zoumañ.",
"Shoulder pet": "Loen-ti skoaz",
"Name tag": "Tikedenn anv",
"Launcher theme": "Tem al lañser",
@@ -1146,7 +1147,7 @@
"Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.": "Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.",
"Move…": "Move…",
"The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.": "The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.",
"Get more cosmetics on playhorizon.dev": "Muioc'h a gosmetikoù war playhorizon.dev",
"Get more cosmetics": "Muioc'h a gosmetikoù",
"You don't own any cosmetics yet.": "N'ho peus kosmetik ebet c'hoazh.",
"Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.": "Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.",
"Game architecture": "Game architecture",
@@ -1182,5 +1183,35 @@
"Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}": "Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}",
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.",
"Tags": "Tikedennoù",
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on."
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.",
"No Minecraft: Java Edition account has that name.": "N'eus kont Minecraft: Java Edition ebet gant an anv-se.",
"That account already owns everything in the basket.": "Ar gont-se he deus dija pep tra a zo er boutailh.",
"The shop isn't selling right now. Nothing was charged.": "Ne werzh ket ar stal bremañ. N'eus bet paet netra.",
"Paddle couldn't start the checkout. Nothing was charged.": "Ne c'hallas ket Paddle kregiñ gant ar paeamant. N'eus bet paet netra.",
"Shop": "Stal",
"Owned": "Ho pezet",
"Claim the founder set": "Goulenn ar strollad diazezerien",
"Claiming…": "O c’houlenn…",
"The shop didn't load.": "N’eo ket bet karget ar stal.",
"Things to wear for your player, seen by everyone who plays on Horizon.": "Traoù da wiskañ evit ho c’hoarier, gwelet gant an holl a c’hoari war Horizon.",
"Free for founders": "Digoust evit ar grouerien",
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Evit ar re a oa amañ da gentañ. Digoust da c’houlenn; ur wech goulennet eo ho re da virout.",
"Finish the free checkout in your browser. The set lands here a moment later.": "Echuit ar paeamant digoust en ho merdeer. Ar set a zeuio amañ un nebeut goude.",
"{n} items": "{n} elfenn",
"Nothing is on sale right now.": "Netra n'eo war werzh bremañ.",
"Your basket": "Ho poutailh",
"Your basket is empty.": "Goullo eo ho poutailh.",
"Add to basket": "Ouzhpennañ er boutailh",
"Total": "Hollad",
"This is a gift": "Ur prof eo",
"Your friend's Minecraft name": "Anv Minecraft ho mignon",
"Find": "Kavout",
"For {name}": "Evit {name}",
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Goulennit gant an hini a bae a-raok prenañ. Distaol a-benn 14 devezh evit forzh peseurt abeg.",
"Pay {price} in your browser": "Paeañ {price} en ho merdeer",
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Digeriñ a ra paeamant Paddle en ho merdeer. Netra ne vez paet el lañser.",
"US sales tax is added where a state charges it.": "Ouzhpennet e vez tailh gwerzhañ SUA el lec'h ma vez goulennet gant ur stad.",
"VAT included where it applies.": "TVA enlakaet el lec'h ma talvez.",
"In a set in your basket": "En ur strollad en ho poutailh",
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Echuit ar paeamant en ho merdeer. Erruout a raio hoc'h elfennoù er Wiskamant a-benn ur pennadig."
}
+34 -3
View File
@@ -955,7 +955,8 @@
"Head": "Glava",
"Back": "Nazad",
"Nothing": "Ništa",
"Turn": "Okreni se",
"Reset view": "Vrati prikaz",
"Drag to turn, scroll to zoom.": "Povucite za okretanje, skrolajte za zumiranje.",
"Shoulder pet": "Rameni ljubimac",
"Name tag": "Imenska oznaka",
"Launcher theme": "Tema pokretača",
@@ -1146,7 +1147,7 @@
"Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.": "Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.",
"Move…": "Move…",
"The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.": "The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.",
"Get more cosmetics on playhorizon.dev": "Više kozmetike na playhorizon.dev",
"Get more cosmetics": "Više kozmetike",
"You don't own any cosmetics yet.": "Još nemate nijednu kozmetiku.",
"Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.": "Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.",
"Game architecture": "Game architecture",
@@ -1182,5 +1183,35 @@
"Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}": "Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}",
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.",
"Tags": "Oznake",
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on."
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.",
"No Minecraft: Java Edition account has that name.": "Nijedan Minecraft: Java Edition račun nema to ime.",
"That account already owns everything in the basket.": "Taj račun već posjeduje sve u korpi.",
"The shop isn't selling right now. Nothing was charged.": "Prodavnica trenutno ne prodaje. Ništa nije naplaćeno.",
"Paddle couldn't start the checkout. Nothing was charged.": "Paddle nije mogao pokrenuti plaćanje. Ništa nije naplaćeno.",
"Shop": "Prodavnica",
"Owned": "U posjedu",
"Claim the founder set": "Zatražite osnivački skup",
"Claiming…": "Preuzimanje…",
"The shop didn't load.": "Prodavnica se nije učitala.",
"Things to wear for your player, seen by everyone who plays on Horizon.": "Stvari za nošenje za vašeg igrača, vidljive svima koji igraju na Horizonu.",
"Free for founders": "Besplatno za osnivače",
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Za igrače koji su bili prvi ovdje. Besplatno za preuzimanje; kad ga preuzmete, vaš je zauvijek.",
"Finish the free checkout in your browser. The set lands here a moment later.": "Završite besplatno plaćanje u pregledniku. Set će se ovdje pojaviti za trenutak.",
"{n} items": "{n} predmeta",
"Nothing is on sale right now.": "Trenutno se ništa ne prodaje.",
"Your basket": "Vaša korpa",
"Your basket is empty.": "Vaša korpa je prazna.",
"Add to basket": "Dodaj u korpu",
"Total": "Ukupno",
"This is a gift": "Ovo je poklon",
"Your friend's Minecraft name": "Minecraft ime vašeg prijatelja",
"Find": "Pronađi",
"For {name}": "Za {name}",
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Pitajte onoga ko plaća prije kupovine. Povrat novca u roku od 14 dana iz bilo kojeg razloga.",
"Pay {price} in your browser": "Plati {price} u pregledniku",
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Paddle plaćanje se otvara u vašem pregledniku. U pokretaču se ništa ne naplaćuje.",
"US sales tax is added where a state charges it.": "Američki porez na promet dodaje se gdje ga država naplaćuje.",
"VAT included where it applies.": "PDV uključen gdje se primjenjuje.",
"In a set in your basket": "U kompletu u vašoj korpi",
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Završite plaćanje u pregledniku. Predmeti stižu u Garderobu za trenutak."
}
+34 -3
View File
@@ -955,7 +955,8 @@
"Head": "Cap",
"Back": "Enrere",
"Nothing": "Res",
"Turn": "Gira",
"Reset view": "Restableix la vista",
"Drag to turn, scroll to zoom.": "Arrossega per girar, desplaça per fer zoom.",
"Shoulder pet": "Mascota d'espatlla",
"Name tag": "Etiqueta de nom",
"Launcher theme": "Tema del llançador",
@@ -1146,7 +1147,7 @@
"Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.": "Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.",
"Move…": "Move…",
"The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.": "The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.",
"Get more cosmetics on playhorizon.dev": "Aconsegueix més cosmètics a playhorizon.dev",
"Get more cosmetics": "Aconsegueix més cosmètics",
"You don't own any cosmetics yet.": "Encara no tens cap cosmètic.",
"Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.": "Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.",
"Game architecture": "Game architecture",
@@ -1182,5 +1183,35 @@
"Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}": "Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}",
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.",
"Tags": "Etiquetes",
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on."
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.",
"No Minecraft: Java Edition account has that name.": "Cap compte de Minecraft: Java Edition no té aquest nom.",
"That account already owns everything in the basket.": "Aquest compte ja té tot el que hi ha a la cistella.",
"The shop isn't selling right now. Nothing was charged.": "La botiga no ven ara mateix. No s'ha cobrat res.",
"Paddle couldn't start the checkout. Nothing was charged.": "Paddle no ha pogut començar el pagament. No s'ha cobrat res.",
"Shop": "Botiga",
"Owned": "Tens",
"Claim the founder set": "Reclama el conjunt del fundador",
"Claiming…": "Reclamant…",
"The shop didn't load.": "La botiga no s’ha carregat.",
"Things to wear for your player, seen by everyone who plays on Horizon.": "Coses per portar al vostre jugador, que veu tothom qui juga a Horizon.",
"Free for founders": "Gratuït per als fundadors",
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Per als jugadors que hi eren des del principi. Es pot reclamar gratis; un cop reclamat, és teu per sempre.",
"Finish the free checkout in your browser. The set lands here a moment later.": "Acabeu el pagament gratuït al navegador. El set arribarà aquí d'aquí a un moment.",
"{n} items": "{n} objectes",
"Nothing is on sale right now.": "Ara mateix no hi ha res a la venda.",
"Your basket": "La teva cistella",
"Your basket is empty.": "La teva cistella és buida.",
"Add to basket": "Afegeix a la cistella",
"Total": "Total",
"This is a gift": "És un regal",
"Your friend's Minecraft name": "El nom de Minecraft del teu amic",
"Find": "Cerca",
"For {name}": "Per a {name}",
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Pregunta a qui paga abans de comprar. Reemborsaments en 14 dies per qualsevol motiu.",
"Pay {price} in your browser": "Paga {price} al navegador",
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "El pagament de Paddle s'obre al navegador. No es cobra res al llançador.",
"US sales tax is added where a state charges it.": "S'afegeix l'impost de vendes dels EUA on un estat el cobra.",
"VAT included where it applies.": "IVA inclòs on s'aplica.",
"In a set in your basket": "En un conjunt de la teva cistella",
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Acaba el pagament al navegador. Els objectes arriben a l'Armari un moment després."
}
+34 -3
View File
@@ -957,7 +957,8 @@
"Head": "Hlava",
"Back": "Zpět",
"Nothing": "Nic",
"Turn": "Otočte se",
"Reset view": "Obnovit pohled",
"Drag to turn, scroll to zoom.": "Tažením otočíte, kolečkem přiblížíte.",
"Shoulder pet": "Ramenní mazlíček",
"Name tag": "Jmenovka",
"Launcher theme": "Téma spouštěče",
@@ -1146,7 +1147,7 @@
"Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.": "Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.",
"Move…": "Move…",
"The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.": "The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.",
"Get more cosmetics on playhorizon.dev": "Další kosmetika na playhorizon.dev",
"Get more cosmetics": "Další kosmetika",
"You don't own any cosmetics yet.": "Zatím nevlastníte žádnou kosmetiku.",
"Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.": "Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.",
"Game architecture": "Game architecture",
@@ -1182,5 +1183,35 @@
"Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}": "Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}",
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.",
"Tags": "Štítky",
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on."
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.",
"No Minecraft: Java Edition account has that name.": "Žádný účet Minecraft: Java Edition nemá toto jméno.",
"That account already owns everything in the basket.": "Tento účet už vlastní vše v košíku.",
"The shop isn't selling right now. Nothing was charged.": "Obchod teď neprodává. Nic nebylo účtováno.",
"Paddle couldn't start the checkout. Nothing was charged.": "Paddle nemohl spustit platbu. Nic nebylo účtováno.",
"Shop": "Obchod",
"Owned": "Vlastněno",
"Claim the founder set": "Nárokujte zakladatelskou sadu",
"Claiming…": "Získávání…",
"The shop didn't load.": "Obchod se nenačetl.",
"Things to wear for your player, seen by everyone who plays on Horizon.": "Věci k nošení pro vašeho hráče, viditelné pro všechny, kdo hrají na Horizonu.",
"Free for founders": "Zdarma pro zakladatele",
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Pro hráče, kteří tu byli první. Zdarma k získání; jakmile si ho vezmete, je váš navždy.",
"Finish the free checkout in your browser. The set lands here a moment later.": "Dokončete bezplatnou platbu v prohlížeči. Sada se tu za chvíli objeví.",
"{n} items": "{n} předmětů",
"Nothing is on sale right now.": "Teď se nic neprodává.",
"Your basket": "Váš košík",
"Your basket is empty.": "Košík je prázdný.",
"Add to basket": "Přidat do košíku",
"Total": "Celkem",
"This is a gift": "Je to dárek",
"Your friend's Minecraft name": "Minecraft jméno vašeho přítele",
"Find": "Najít",
"For {name}": "Pro {name}",
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Než nakoupíte, zeptejte se toho, kdo platí. Vrácení peněz do 14 dnů z jakéhokoli důvodu.",
"Pay {price} in your browser": "Zaplatit {price} v prohlížeči",
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Platba Paddle se otevře v prohlížeči. V launcheru se nic neúčtuje.",
"US sales tax is added where a state charges it.": "Americká daň z prodeje se přičítá tam, kde ji stát vybírá.",
"VAT included where it applies.": "DPH zahrnuta tam, kde se uplatňuje.",
"In a set in your basket": "V sadě ve vašem košíku",
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Dokončete platbu v prohlížeči. Předměty dorazí do Šatníku za chvíli."
}
+34 -3
View File
@@ -955,7 +955,8 @@
"Head": "Пуҫ",
"Back": "Каялла",
"Nothing": "Нимӗн те",
"Turn": "Ҫаврӑнӑр",
"Reset view": "Курăнăва каялла тавăр",
"Drag to turn, scroll to zoom.": "Ҫавăрма туртăр, ҫывхартма кустăрмана ҫавăрăр.",
"Shoulder pet": "Хулпуҫҫи чӗрчунӗ",
"Name tag": "Ят палли",
"Launcher theme": "Лаунчер теми",
@@ -1146,7 +1147,7 @@
"Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.": "Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.",
"Move…": "Move…",
"The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.": "The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.",
"Get more cosmetics on playhorizon.dev": "Ытларах косметика playhorizon.dev сайтра",
"Get more cosmetics": "Ытларах косметика",
"You don't own any cosmetics yet.": "Сирӗн хальлӗхе пӗр косметика та ҫук.",
"Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.": "Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.",
"Game architecture": "Game architecture",
@@ -1182,5 +1183,35 @@
"Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}": "Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}",
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.",
"Tags": "Тегсем",
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on."
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.",
"No Minecraft: Java Edition account has that name.": "Ку ятлă Minecraft: Java Edition аккаунт çук.",
"That account already owns everything in the basket.": "Ку аккаунтăн корзинкăри пĕтĕм япала пур ĕнтĕ.",
"The shop isn't selling right now. Nothing was charged.": "Лавкка халĕ сутмасть. Нимĕн те илмен.",
"Paddle couldn't start the checkout. Nothing was charged.": "Paddle тӳлеве пуçлайман. Нимĕн те илмен.",
"Shop": "Лавкка",
"Owned": "Санӑн",
"Claim the founder set": "Учредительсен пуххине илӗр",
"Claiming…": "Илет…",
"The shop didn't load.": "Лавкка тиеннӗ мар.",
"Things to wear for your player, seen by everyone who plays on Horizon.": "Вылякана тăхӑнмалли япаласем, Horizon çинче вылякан пурне те курӑнаççӗ.",
"Free for founders": "Никӗслекенсем валли тӳлевсӗр",
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Пирвайхи килнӗ вылякансем валли. Тӳлевсӗр илме пулать; илсен яланлӑхах санӑн.",
"Finish the free checkout in your browser. The set lands here a moment later.": "Тӳлевсӗр тӳлевне браузерта вӗçлесе хурӑр. Пухӑм кунта кӑшт та иртсен курӑнать.",
"{n} items": "{n} япала",
"Nothing is on sale right now.": "Халĕ нимĕн те сутăнмасть.",
"Your basket": "Сирĕн корзинка",
"Your basket is empty.": "Корзинка пушă.",
"Add to basket": "Корзинкăна хуш",
"Total": "Пурĕ",
"This is a gift": "Ку парне",
"Your friend's Minecraft name": "Тусăрăн Minecraft ячĕ",
"Find": "Туп",
"For {name}": "{name} валли",
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Туянас умĕн кам тӳлессине ыйтăр. Кирек мĕнле сăлтавпа та 14 кун хушшинче укçа тавăрса параççĕ.",
"Pay {price} in your browser": "{price} браузерта тӳле",
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Paddle тӳлевĕ браузерта уçăлать. Лаунчерта нимĕн те илмеççĕ.",
"US sales tax is added where a state charges it.": "АПШ сутă налукне штат ыйтнă çĕрте хушаççĕ.",
"VAT included where it applies.": "НДС кĕрет, вăл ĕçленĕ çĕрте.",
"In a set in your basket": "Корзинкăри комплектра",
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Тӳлеве браузерта вĕçлĕр. Япаласем Гардероба часах килĕç."
}
+34 -3
View File
@@ -955,7 +955,8 @@
"Head": "Pen",
"Back": "Yn ol",
"Nothing": "Dim byd",
"Turn": "Trowch",
"Reset view": "Ailosod yr olwg",
"Drag to turn, scroll to zoom.": "Llusgwch i droi, sgroliwch i chwyddo.",
"Shoulder pet": "Ysgwydd anifail anwes",
"Name tag": "Tag enw",
"Launcher theme": "Thema lansiwr",
@@ -1146,7 +1147,7 @@
"Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.": "Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.",
"Move…": "Move…",
"The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.": "The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.",
"Get more cosmetics on playhorizon.dev": "Mwy o gosmetigau ar playhorizon.dev",
"Get more cosmetics": "Mwy o gosmetigau",
"You don't own any cosmetics yet.": "Does gennych chi ddim colur eto.",
"Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.": "Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.",
"Game architecture": "Game architecture",
@@ -1182,5 +1183,35 @@
"Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}": "Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}",
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.",
"Tags": "Tagiau",
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on."
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.",
"No Minecraft: Java Edition account has that name.": "Does dim cyfrif Minecraft: Java Edition gyda'r enw hwnnw.",
"That account already owns everything in the basket.": "Mae'r cyfrif hwnnw eisoes yn berchen ar bopeth yn y fasged.",
"The shop isn't selling right now. Nothing was charged.": "Dydy'r siop ddim yn gwerthu ar hyn o bryd. Ni chodwyd dim.",
"Paddle couldn't start the checkout. Nothing was charged.": "Methodd Paddle â dechrau'r talu. Ni chodwyd dim.",
"Shop": "Siop",
"Owned": "Yn eich meddiant",
"Claim the founder set": "Hawliwch y set sylfaenydd",
"Claiming…": "Yn hawlio…",
"The shop didn't load.": "Wnaeth y siop ddim llwytho.",
"Things to wear for your player, seen by everyone who plays on Horizon.": "Pethau i’w gwisgo ar eich chwaraewr, i’w gweld gan bawb sy’n chwarae ar Horizon.",
"Free for founders": "Am ddim i’r sylfaenwyr",
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "I’r chwaraewyr oedd yma gyntaf. Am ddim i’w hawlio; unwaith y byddwch wedi’i hawlio, mae’n eiddo i chi am byth.",
"Finish the free checkout in your browser. The set lands here a moment later.": "Gorffennwch y ddesg dalu am ddim yn eich porwr. Bydd y set yn glanio yma ymhen ychydig.",
"{n} items": "{n} eitem",
"Nothing is on sale right now.": "Does dim ar werth ar hyn o bryd.",
"Your basket": "Eich basged",
"Your basket is empty.": "Mae eich basged yn wag.",
"Add to basket": "Ychwanegu i'r fasged",
"Total": "Cyfanswm",
"This is a gift": "Rhodd yw hon",
"Your friend's Minecraft name": "Enw Minecraft eich ffrind",
"Find": "Chwilio",
"For {name}": "I {name}",
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Gofynnwch i bwy bynnag sy'n talu cyn prynu. Ad-daliad o fewn 14 diwrnod am unrhyw reswm.",
"Pay {price} in your browser": "Talu {price} yn eich porwr",
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Mae taliad Paddle yn agor yn eich porwr. Does dim yn cael ei godi yn y lansiwr.",
"US sales tax is added where a state charges it.": "Ychwanegir treth gwerthiant UDA lle mae talaith yn ei chodi.",
"VAT included where it applies.": "TAW wedi'i chynnwys lle mae'n berthnasol.",
"In a set in your basket": "Mewn set yn eich basged",
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Gorffennwch dalu yn eich porwr. Bydd eich eitemau'n cyrraedd y Cwpwrdd Dillad mewn eiliad."
}
+34 -3
View File
@@ -957,7 +957,8 @@
"Head": "Hoved",
"Back": "Tilbage",
"Nothing": "Intet",
"Turn": "Drej",
"Reset view": "Nulstil visning",
"Drag to turn, scroll to zoom.": "Træk for at dreje, rul for at zoome.",
"Shoulder pet": "Skulder kæledyr",
"Name tag": "Navneskilt",
"Launcher theme": "Launcher tema",
@@ -1146,7 +1147,7 @@
"Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.": "Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.",
"Move…": "Move…",
"The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.": "The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.",
"Get more cosmetics on playhorizon.dev": "Få flere kosmetiske genstande på playhorizon.dev",
"Get more cosmetics": "Få flere kosmetiske genstande",
"You don't own any cosmetics yet.": "Du ejer ingen kosmetik endnu.",
"Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.": "Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.",
"Game architecture": "Game architecture",
@@ -1182,5 +1183,35 @@
"Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}": "Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}",
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.",
"Tags": "Tags",
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on."
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.",
"No Minecraft: Java Edition account has that name.": "Ingen Minecraft: Java Edition-konto har det navn.",
"That account already owns everything in the basket.": "Den konto ejer allerede alt i kurven.",
"The shop isn't selling right now. Nothing was charged.": "Butikken sælger ikke lige nu. Intet blev trukket.",
"Paddle couldn't start the checkout. Nothing was charged.": "Paddle kunne ikke starte betalingen. Intet blev trukket.",
"Shop": "Butik",
"Owned": "Ejet",
"Claim the founder set": "Gør krav på grundlæggersættet",
"Claiming…": "Henter…",
"The shop didn't load.": "Butikken blev ikke indlæst.",
"Things to wear for your player, seen by everyone who plays on Horizon.": "Ting din spiller kan bære, som alle, der spiller på Horizon, kan se.",
"Free for founders": "Gratis for grundlæggere",
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Til spillerne, der var her først. Gratis at hente; når det er hentet, er det dit.",
"Finish the free checkout in your browser. The set lands here a moment later.": "Gennemfør det gratis checkout i din browser. Sættet lander her et øjeblik senere.",
"{n} items": "{n} ting",
"Nothing is on sale right now.": "Intet er til salg lige nu.",
"Your basket": "Din kurv",
"Your basket is empty.": "Din kurv er tom.",
"Add to basket": "Læg i kurven",
"Total": "I alt",
"This is a gift": "Det er en gave",
"Your friend's Minecraft name": "Din vens Minecraft-navn",
"Find": "Find",
"For {name}": "Til {name}",
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Spørg den, der betaler, før du køber. Refusion inden for 14 dage uanset årsag.",
"Pay {price} in your browser": "Betal {price} i din browser",
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Paddles betaling åbner i din browser. Intet trækkes i launcheren.",
"US sales tax is added where a state charges it.": "Amerikansk salgsmoms lægges til, hvor en stat opkræver den.",
"VAT included where it applies.": "Moms inkluderet, hvor den gælder.",
"In a set in your basket": "I et sæt i din kurv",
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Gør betalingen færdig i din browser. Dine ting kommer i Garderoben et øjeblik efter."
}
+34 -3
View File
@@ -957,7 +957,8 @@
"Head": "Kopf",
"Back": "Rücken",
"Nothing": "Nichts",
"Turn": "Drehen",
"Reset view": "Ansicht zurücksetzen",
"Drag to turn, scroll to zoom.": "Ziehen zum Drehen, scrollen zum Zoomen.",
"Shoulder pet": "Schultertier",
"Name tag": "Namensschild",
"Launcher theme": "Launcher-Design",
@@ -1146,7 +1147,7 @@
"Move…": "Verschieben…",
"Move and restart": "Verschieben und neu starten",
"Could not open the data folder: {error}": "Could not open the data folder: {error}",
"Get more cosmetics on playhorizon.dev": "Mehr Kosmetik auf playhorizon.dev",
"Get more cosmetics": "Mehr Kosmetik",
"You don't own any cosmetics yet.": "Du besitzt noch keine Kosmetik.",
"Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.": "Automatisch übernimmt die Architektur dieses Launchers. Unter Windows ARM können Sie x64 oder ARM64 wählen. Versionen ohne ARM64 verwenden den signierten x64-Build.",
"Game architecture": "Spielarchitektur",
@@ -1182,5 +1183,35 @@
"Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}": "Für Horizon {pin} gemacht, du spielst {yours}: der Launcher baut dein Spiel auf {pin}",
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.",
"Tags": "Tags",
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Die intelligente App-Steuerung (Smart App Control) von Windows blockiert den Build (os error 4551). Sie lässt nur signierte Programme laufen, und ein nativer Build führt Programme aus, die niemand signiert hat: die, die er auf diesem Computer kompiliert, und einige des Compilers selbst. Um native Mods zu bauen, schalte sie unter Windows-Sicherheit > App- & Browsersteuerung > Einstellungen für intelligente App-Steuerung aus und baue dann erneut. Auf manchen Windows-Versionen lässt sie sich ohne Neuinstallation von Windows nicht wieder einschalten. Inhalts-Mods (.hmod) brauchen keinen Build und funktionieren auch mit ihr."
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Die intelligente App-Steuerung (Smart App Control) von Windows blockiert den Build (os error 4551). Sie lässt nur signierte Programme laufen, und ein nativer Build führt Programme aus, die niemand signiert hat: die, die er auf diesem Computer kompiliert, und einige des Compilers selbst. Um native Mods zu bauen, schalte sie unter Windows-Sicherheit > App- & Browsersteuerung > Einstellungen für intelligente App-Steuerung aus und baue dann erneut. Auf manchen Windows-Versionen lässt sie sich ohne Neuinstallation von Windows nicht wieder einschalten. Inhalts-Mods (.hmod) brauchen keinen Build und funktionieren auch mit ihr.",
"No Minecraft: Java Edition account has that name.": "Kein Minecraft: Java Edition-Konto hat diesen Namen.",
"That account already owns everything in the basket.": "Dieses Konto besitzt schon alles im Warenkorb.",
"The shop isn't selling right now. Nothing was charged.": "Der Shop verkauft gerade nicht. Es wurde nichts abgebucht.",
"Paddle couldn't start the checkout. Nothing was charged.": "Paddle konnte die Kasse nicht starten. Es wurde nichts abgebucht.",
"Shop": "Shop",
"Owned": "Im Besitz",
"Claim the founder set": "Gründer-Set beanspruchen",
"Claiming…": "Wird geholt…",
"The shop didn't load.": "Der Shop wurde nicht geladen.",
"Things to wear for your player, seen by everyone who plays on Horizon.": "Dinge für deinen Spieler, die alle sehen, die auf Horizon spielen.",
"Free for founders": "Gratis für Gründer",
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Für die Spieler, die von Anfang an dabei waren. Gratis; einmal geholt, gehört es dir.",
"Finish the free checkout in your browser. The set lands here a moment later.": "Schließe den kostenlosen Checkout in deinem Browser ab. Das Set erscheint kurz danach hier.",
"{n} items": "{n} Gegenstände",
"Nothing is on sale right now.": "Gerade ist nichts im Angebot.",
"Your basket": "Dein Warenkorb",
"Your basket is empty.": "Dein Warenkorb ist leer.",
"Add to basket": "In den Warenkorb",
"Total": "Gesamt",
"This is a gift": "Das ist ein Geschenk",
"Your friend's Minecraft name": "Minecraft-Name deines Freundes",
"Find": "Suchen",
"For {name}": "Für {name}",
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Frag vor dem Kauf, wer bezahlt. Erstattung innerhalb von 14 Tagen aus jedem Grund.",
"Pay {price} in your browser": "{price} im Browser bezahlen",
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Die Kasse von Paddle öffnet sich in deinem Browser. Im Launcher wird nichts abgebucht.",
"US sales tax is added where a state charges it.": "US-Umsatzsteuer kommt hinzu, wo ein Bundesstaat sie erhebt.",
"VAT included where it applies.": "MwSt. inbegriffen, wo sie anfällt.",
"In a set in your basket": "In einem Set in deinem Warenkorb",
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Schließe die Zahlung im Browser ab. Deine Gegenstände erscheinen kurz danach in der Garderobe."
}
+34 -3
View File
@@ -957,7 +957,8 @@
"Head": "Κεφάλι",
"Back": "Πίσω",
"Nothing": "Τίποτα",
"Turn": "Γυρίστε",
"Reset view": "Επαναφορά προβολής",
"Drag to turn, scroll to zoom.": "Σύρετε για περιστροφή, κυλήστε για μεγέθυνση.",
"Shoulder pet": "Ζώο ώμου",
"Name tag": "Ετικέτα ονόματος",
"Launcher theme": "Θέμα εκκίνησης",
@@ -1146,7 +1147,7 @@
"Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.": "Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.",
"Move…": "Move…",
"The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.": "The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.",
"Get more cosmetics on playhorizon.dev": "Περισσότερα καλλυντικά στο playhorizon.dev",
"Get more cosmetics": "Περισσότερα διακοσμητικά",
"You don't own any cosmetics yet.": "Δεν έχετε ακόμα καλλυντικά.",
"Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.": "Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.",
"Game architecture": "Game architecture",
@@ -1182,5 +1183,35 @@
"Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}": "Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}",
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.",
"Tags": "Ετικέτες",
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on."
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.",
"No Minecraft: Java Edition account has that name.": "Κανένας λογαριασμός Minecraft: Java Edition δεν έχει αυτό το όνομα.",
"That account already owns everything in the basket.": "Αυτός ο λογαριασμός έχει ήδη όλα όσα είναι στο καλάθι.",
"The shop isn't selling right now. Nothing was charged.": "Το κατάστημα δεν πουλάει αυτή τη στιγμή. Δεν χρεώθηκε τίποτα.",
"Paddle couldn't start the checkout. Nothing was charged.": "Το Paddle δεν μπόρεσε να ξεκινήσει την πληρωμή. Δεν χρεώθηκε τίποτα.",
"Shop": "Κατάστημα",
"Owned": "Στην κατοχή σας",
"Claim the founder set": "Διεκδικήστε το ιδρυτικό σύνολο",
"Claiming…": "Διεκδίκηση…",
"The shop didn't load.": "Το κατάστημα δεν φόρτωσε.",
"Things to wear for your player, seen by everyone who plays on Horizon.": "Πράγματα για να φορέσει ο παίκτης σας, ορατά σε όλους όσοι παίζουν στο Horizon.",
"Free for founders": "Δωρεάν για ιδρυτές",
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Για τους παίκτες που ήταν εδώ πρώτοι. Δωρεάν διεκδίκηση· μόλις το διεκδικήσετε, είναι δικό σας για πάντα.",
"Finish the free checkout in your browser. The set lands here a moment later.": "Ολοκληρώστε τη δωρεάν πληρωμή στο πρόγραμμα περιήγησής σας. Το σετ εμφανίζεται εδώ λίγο αργότερα.",
"{n} items": "{n} αντικείμενα",
"Nothing is on sale right now.": "Τίποτα δεν πωλείται αυτή τη στιγμή.",
"Your basket": "Το καλάθι σας",
"Your basket is empty.": "Το καλάθι σας είναι άδειο.",
"Add to basket": "Προσθήκη στο καλάθι",
"Total": "Σύνολο",
"This is a gift": "Είναι δώρο",
"Your friend's Minecraft name": "Το όνομα Minecraft του φίλου σας",
"Find": "Εύρεση",
"For {name}": "Για {name}",
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Ρωτήστε όποιον πληρώνει πριν αγοράσετε. Επιστροφή χρημάτων εντός 14 ημερών για οποιονδήποτε λόγο.",
"Pay {price} in your browser": "Πληρωμή {price} στο πρόγραμμα περιήγησης",
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Η πληρωμή του Paddle ανοίγει στο πρόγραμμα περιήγησης. Τίποτα δεν χρεώνεται στον εκκινητή.",
"US sales tax is added where a state charges it.": "Ο φόρος πωλήσεων ΗΠΑ προστίθεται όπου τον επιβάλλει η πολιτεία.",
"VAT included where it applies.": "Ο ΦΠΑ περιλαμβάνεται όπου ισχύει.",
"In a set in your basket": "Σε σετ στο καλάθι σας",
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Ολοκληρώστε την πληρωμή στο πρόγραμμα περιήγησης. Τα αντικείμενα φτάνουν στη Γκαρνταρόμπα σε λίγο."
}
+34 -3
View File
@@ -955,7 +955,8 @@
"Head": "Kapo",
"Back": "Reen",
"Nothing": "Nenio",
"Turn": "Turnu vin",
"Reset view": "Restarigi vidon",
"Drag to turn, scroll to zoom.": "Trenu por turni, rulumu por zomi.",
"Shoulder pet": "Ŝultra dorlotbesto",
"Name tag": "Nometikedo",
"Launcher theme": "Lanĉila temo",
@@ -1146,7 +1147,7 @@
"Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.": "Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.",
"Move…": "Move…",
"The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.": "The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.",
"Get more cosmetics on playhorizon.dev": "Akiru pliajn kosmetikaĵojn ĉe playhorizon.dev",
"Get more cosmetics": "Akiru pliajn kosmetikaĵojn",
"You don't own any cosmetics yet.": "Vi ankoraŭ ne posedas kosmetikaĵojn.",
"Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.": "Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.",
"Game architecture": "Game architecture",
@@ -1182,5 +1183,35 @@
"Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}": "Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}",
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.",
"Tags": "Etikedoj",
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on."
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.",
"No Minecraft: Java Edition account has that name.": "Neniu konto de Minecraft: Java Edition havas tiun nomon.",
"That account already owns everything in the basket.": "Tiu konto jam posedas ĉion en la korbo.",
"The shop isn't selling right now. Nothing was charged.": "La butiko nun ne vendas. Nenio estis pagigita.",
"Paddle couldn't start the checkout. Nothing was charged.": "Paddle ne povis komenci la pagon. Nenio estis pagigita.",
"Shop": "Vendejo",
"Owned": "Posedata",
"Claim the founder set": "Postulu la fondinton",
"Claiming…": "Postulante…",
"The shop didn't load.": "La vendejo ne ŝargiĝis.",
"Things to wear for your player, seen by everyone who plays on Horizon.": "Aĵoj por porti sur via ludanto, videblaj al ĉiuj, kiuj ludas en Horizon.",
"Free for founders": "Senpaga por fondintoj",
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Por la ludantoj, kiuj estis ĉi tie unue. Senpage postuleblas; post la postulo ĝi estas via por ĉiam.",
"Finish the free checkout in your browser. The set lands here a moment later.": "Finu la senpagan pagon en via retumilo. La aro aperos ĉi tie post momento.",
"{n} items": "{n} aĵoj",
"Nothing is on sale right now.": "Nenio estas vendata nun.",
"Your basket": "Via korbo",
"Your basket is empty.": "Via korbo estas malplena.",
"Add to basket": "Aldoni al korbo",
"Total": "Sumo",
"This is a gift": "Ĉi tio estas donaco",
"Your friend's Minecraft name": "La Minecraft-nomo de via amiko",
"Find": "Trovi",
"For {name}": "Por {name}",
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Demandu kiu pagas antaŭ ol aĉeti. Repago ene de 14 tagoj pro ajna kialo.",
"Pay {price} in your browser": "Pagi {price} en via retumilo",
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "La pago de Paddle malfermiĝas en via retumilo. Nenio estas pagigita en la lanĉilo.",
"US sales tax is added where a state charges it.": "Usona vendimposto aldoniĝas kie ŝtato postulas ĝin.",
"VAT included where it applies.": "AVI inkluzivita kie ĝi validas.",
"In a set in your basket": "En aro en via korbo",
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Finu la pagon en via retumilo. Viaj aĵoj alvenos en la Vestoŝrankon post momento."
}
+34 -3
View File
@@ -957,7 +957,8 @@
"Head": "Cabeza",
"Back": "Espalda",
"Nothing": "Nada",
"Turn": "Girar",
"Reset view": "Restablecer vista",
"Drag to turn, scroll to zoom.": "Arrastra para girar, desplaza para hacer zoom.",
"Shoulder pet": "Mascota de hombro",
"Name tag": "Etiqueta de nombre",
"Launcher theme": "Tema del launcher",
@@ -1146,7 +1147,7 @@
"Move…": "Mover…",
"Move and restart": "Mover y reiniciar",
"Could not open the data folder: {error}": "Could not open the data folder: {error}",
"Get more cosmetics on playhorizon.dev": "Consigue más cosméticos en playhorizon.dev",
"Get more cosmetics": "Consigue más cosméticos",
"You don't own any cosmetics yet.": "Aún no tienes ningún cosmético.",
"Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.": "Automático usa la arquitectura de este lanzador. En Windows ARM, elige x64 o ARM64. Las versiones sin ARM64 usan la compilación x64 firmada.",
"Game architecture": "Arquitectura del juego",
@@ -1182,5 +1183,35 @@
"Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}": "Hecho para Horizon {pin}, juegas {yours}: el launcher compila tu juego en {pin}",
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.",
"Tags": "Etiquetas",
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "El Control inteligente de aplicaciones (Smart App Control) de Windows está bloqueando la compilación (os error 4551). Solo deja ejecutar programas firmados, y una compilación nativa ejecuta programas que nadie ha firmado: los que compila en este equipo y algunos del propio compilador. Para compilar mods nativos, desactívalo en Seguridad de Windows > Control de aplicaciones y navegador > Configuración de Control inteligente de aplicaciones y vuelve a compilar. En algunas versiones de Windows no se puede volver a activar sin reinstalar Windows. Los mods de contenido (.hmod) no necesitan compilación y funcionan con él activado."
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "El Control inteligente de aplicaciones (Smart App Control) de Windows está bloqueando la compilación (os error 4551). Solo deja ejecutar programas firmados, y una compilación nativa ejecuta programas que nadie ha firmado: los que compila en este equipo y algunos del propio compilador. Para compilar mods nativos, desactívalo en Seguridad de Windows > Control de aplicaciones y navegador > Configuración de Control inteligente de aplicaciones y vuelve a compilar. En algunas versiones de Windows no se puede volver a activar sin reinstalar Windows. Los mods de contenido (.hmod) no necesitan compilación y funcionan con él activado.",
"No Minecraft: Java Edition account has that name.": "Ninguna cuenta de Minecraft: Java Edition tiene ese nombre.",
"That account already owns everything in the basket.": "Esa cuenta ya tiene todo lo que hay en la cesta.",
"The shop isn't selling right now. Nothing was charged.": "La tienda no está vendiendo ahora. No se ha cobrado nada.",
"Paddle couldn't start the checkout. Nothing was charged.": "Paddle no pudo iniciar el pago. No se ha cobrado nada.",
"Shop": "Tienda",
"Owned": "Tuyo",
"Claim the founder set": "Reclamar el set fundador",
"Claiming…": "Consiguiendo…",
"The shop didn't load.": "La tienda no se cargó.",
"Things to wear for your player, seen by everyone who plays on Horizon.": "Cosas que lleva tu jugador, visibles para todos los que juegan en Horizon.",
"Free for founders": "Gratis para fundadores",
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Para quienes estuvieron aquí primero. Gratis; una vez conseguido, es tuyo para siempre.",
"Finish the free checkout in your browser. The set lands here a moment later.": "Termina el pago gratuito en tu navegador. El set llegará aquí un momento después.",
"{n} items": "{n} objetos",
"Nothing is on sale right now.": "Ahora mismo no hay nada a la venta.",
"Your basket": "Tu cesta",
"Your basket is empty.": "Tu cesta está vacía.",
"Add to basket": "Añadir a la cesta",
"Total": "Total",
"This is a gift": "Es un regalo",
"Your friend's Minecraft name": "El nombre de Minecraft de tu amigo",
"Find": "Buscar",
"For {name}": "Para {name}",
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Pregunta a quien paga antes de comprar. Reembolsos en 14 días por cualquier motivo.",
"Pay {price} in your browser": "Pagar {price} en el navegador",
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "El pago de Paddle se abre en tu navegador. No se cobra nada en el launcher.",
"US sales tax is added where a state charges it.": "Se añade el impuesto sobre las ventas de EE. UU. donde un estado lo cobra.",
"VAT included where it applies.": "IVA incluido donde corresponde.",
"In a set in your basket": "En un conjunto de tu cesta",
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Termina el pago en tu navegador. Tus objetos llegan al Armario un momento después."
}
+34 -3
View File
@@ -955,7 +955,8 @@
"Head": "Pea",
"Back": "Tagasi",
"Nothing": "Mitte midagi",
"Turn": "Pöörake",
"Reset view": "Lähtesta vaade",
"Drag to turn, scroll to zoom.": "Lohista pööramiseks, keri suumimiseks.",
"Shoulder pet": "Õla lemmikloom",
"Name tag": "Nimesilt",
"Launcher theme": "Käivitusprogrammi teema",
@@ -1146,7 +1147,7 @@
"Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.": "Moves all launcher, game and build data, including settings, worlds, instances and installed versions. Choose an empty folder with enough free space.",
"Move…": "Move…",
"The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.": "The launcher closes while your data is copied and verified, then reopens. Old folders are kept and still use disk space until you verify the move and remove them yourself.",
"Get more cosmetics on playhorizon.dev": "Rohkem kosmeetikat saidil playhorizon.dev",
"Get more cosmetics": "Rohkem kosmeetikat",
"You don't own any cosmetics yet.": "Sul pole veel ühtegi kosmeetikaeset.",
"Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.": "Automatic follows this launcher's architecture. On Windows ARM, choose x64 or ARM64. Releases without ARM64 use the signed x64 build.",
"Game architecture": "Game architecture",
@@ -1182,5 +1183,35 @@
"Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}": "Made for Horizon {pin}, you play {yours}: the launcher builds your game on {pin}",
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.",
"Tags": "Sildid",
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on."
"Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.": "Windows' Smart App Control is blocking the build (os error 4551). It only lets signed programs run, and a native build runs programs no one has signed: the ones it compiles on this computer and some of the compiler's own. To build native mods, turn it off in Windows Security > App & browser control > Smart App Control settings, then build again. On some Windows versions it can't be turned back on without reinstalling Windows. Content mods (.hmod) need no build and work with it on.",
"No Minecraft: Java Edition account has that name.": "Sellise nimega Minecraft: Java Editioni kontot pole.",
"That account already owns everything in the basket.": "Sellel kontol on juba kõik korvis olev olemas.",
"The shop isn't selling right now. Nothing was charged.": "Pood praegu ei müü. Midagi ei võetud.",
"Paddle couldn't start the checkout. Nothing was charged.": "Paddle ei saanud makset alustada. Midagi ei võetud.",
"Shop": "Pood",
"Owned": "Omatud",
"Claim the founder set": "Nõudke asutajakomplekti",
"Claiming…": "Välja võtmine…",
"The shop didn't load.": "Poodi ei õnnestunud laadida.",
"Things to wear for your player, seen by everyone who plays on Horizon.": "Asjad mängija jaoks, mida näevad kõik, kes Horizonis mängivad.",
"Free for founders": "Tasuta asutajatele",
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Mängijatele, kes olid siin esimesed. Tasuta välja võtta; kui olete selle välja võtnud, jääb see teile igaveseks.",
"Finish the free checkout in your browser. The set lands here a moment later.": "Lõpetage tasuta makse oma brauseris. Komplekt ilmub siia hetke pärast.",
"{n} items": "{n} eset",
"Nothing is on sale right now.": "Praegu pole midagi müügil.",
"Your basket": "Sinu ostukorv",
"Your basket is empty.": "Sinu ostukorv on tühi.",
"Add to basket": "Lisa ostukorvi",
"Total": "Kokku",
"This is a gift": "See on kingitus",
"Your friend's Minecraft name": "Sinu sõbra Minecrafti nimi",
"Find": "Otsi",
"For {name}": "Saajale {name}",
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Enne ostmist küsi maksjalt. Raha tagastatakse 14 päeva jooksul mis tahes põhjusel.",
"Pay {price} in your browser": "Maksa {price} brauseris",
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Paddle'i makse avaneb brauseris. Käivitis ei võeta midagi.",
"US sales tax is added where a state charges it.": "USA müügimaks lisandub seal, kus osariik seda nõuab.",
"VAT included where it applies.": "Käibemaks sisaldub seal, kus see kehtib.",
"In a set in your basket": "Komplektis sinu ostukorvis",
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Lõpeta makse brauseris. Esemed jõuavad Garderoobi hetke pärast."
}
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff. Show more