mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5de5b6fd91 | ||
|
|
23c3faedc1 | ||
|
|
ba3c3feebf | ||
|
|
ec298576f6 | ||
|
|
97c9d1a14f | ||
|
|
9099175f8a | ||
|
|
e1e39361db | ||
|
|
ef19179033 | ||
|
|
b189c72fcc | ||
|
|
3a229da6b3 | ||
|
|
e22ccc17d2 | ||
|
|
493dd75acf | ||
|
|
f4481233b0 | ||
|
|
63cbec701e | ||
|
|
a597387a66 | ||
|
|
2d306dc52c | ||
|
|
4f6be12c2e | ||
|
|
8c93f81143 | ||
|
|
a20e9ce2d0 | ||
|
|
aba1a90e6f | ||
|
|
73a3c0348d | ||
|
|
c95f24bc32 | ||
|
|
ca0b945857 | ||
|
|
33be2a611a | ||
|
|
1199c117f2 | ||
|
|
2e749948f4 | ||
|
|
f5f96b1be4 | ||
|
|
522634ca7f | ||
|
|
f7543ccffa | ||
|
|
1fd2f02081 | ||
|
|
8d8c250f48 | ||
|
|
d21bc19b84 | ||
|
|
3afc51e740 | ||
|
|
17778b9722 | ||
|
|
d1c1684fb5 | ||
|
|
fdc87aecdb | ||
|
|
52343380b3 | ||
|
|
e2680a5450 | ||
|
|
5e7d248148 | ||
|
|
67f34e0fdd | ||
|
|
17a68d7b97 | ||
|
|
90b30ee17b | ||
|
|
5578753b58 | ||
|
|
6faf3ef3f3 | ||
|
|
ab44611814 | ||
|
|
8c612bc687 | ||
|
|
9debdf26d4 | ||
|
|
27a2eb565e | ||
|
|
d7ae75b441 | ||
|
|
bc017eec9b | ||
|
|
693178dc36 | ||
|
|
b19634003c | ||
|
|
8711aabd75 | ||
|
|
76a5bd8a22 | ||
|
|
453d8b89ce | ||
|
|
845132a56b | ||
|
|
051e3dd546 | ||
|
|
ab5dfae985 | ||
|
|
19a6e9b81b | ||
|
|
de118feb4e | ||
|
|
e508e7919f | ||
|
|
f222616f7e | ||
|
|
0f89fd7e00 | ||
|
|
6889d9366b | ||
|
|
0804e2219a | ||
|
|
1c6a22db1a | ||
|
|
8a9a6d5e3f | ||
|
|
d17e8fa343 | ||
|
|
6f374959b7 | ||
|
|
15a8b2a77c | ||
|
|
1206818cde | ||
|
|
12157a2d67 | ||
|
|
b029b9d04f | ||
|
|
9b89ff00ee | ||
|
|
2e13076a93 | ||
|
|
9974322832 | ||
|
|
b21f0c92f2 | ||
|
|
31545199d4 | ||
|
|
9a8781468e | ||
|
|
1117b98d15 | ||
|
|
8fe0e40d68 | ||
|
|
f68c335eb2 | ||
|
|
12dad67f8c | ||
|
|
922f00f52e | ||
|
|
1fe21fb861 | ||
|
|
68b03b5184 | ||
|
|
200615a30a | ||
|
|
1ba0db3c3c | ||
|
|
0ce0e67282 | ||
|
|
00d1e1a46e | ||
|
|
1ee069b6ba | ||
|
|
c30dfd55e5 | ||
|
|
0f8cf58043 | ||
|
|
dd93b0164a | ||
|
|
7622c02706 | ||
|
|
ac02477732 | ||
|
|
0748416e9e | ||
|
|
0fe1d0e15d | ||
|
|
3f38b2d0bd | ||
|
|
e08c7a3ee0 |
No files matched your search
@@ -152,23 +152,23 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `fn tick`, `fn tick_world`, `fn render`; G: `window_handler.rs` (`impl ApplicationHandler for Game`: `window_event`, `about_to_wait`); L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs`, `screen_backgrounds.rs` (panorama, menu blur, border wall) |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `world_entry.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn tick_mining`, `fn break_block`); G: `item_actions.rs` (`fn use_item`), `melee.rs` (`fn attack`), `hand.rs`, `combat.rs`, `consume.rs`, `using_item.rs` (an item in use, tick by tick), `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs`, `inventory_input.rs` (keys and clicks over a container), `inventory_slots.rs` (slot layout and drawing) |
|
||||
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
|
||||
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
|
||||
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
|
||||
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server`, `horizon-anticheat` (L: `lan/anticheat.rs`) |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities), `world_session.rs` (opening and closing a world); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
@@ -191,7 +191,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
|
||||
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
|
||||
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
|
||||
(`pack`), both in game.rs.
|
||||
(`pack`), both in game.rs, their methods in `game/resource_tables.rs`.
|
||||
|
||||
## Single player runs on a server thread
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ another project's source may also be read and used, **only when that project's l
|
||||
"source available" licenses, all rights reserved (OptiFine), and any project with no license or an unclear one.
|
||||
Don't read, copy, translate or paraphrase their source, and don't send code an AI tool wrote from it. Being able
|
||||
to download a mod or a pack does not give you the right to read its files.
|
||||
- **Java's own library is GPL too.** OpenJDK's source (`java.lang.Math`, `StrictMath`, `FdLibm`, `HashMap`, the
|
||||
sorts, `Double.toString`, HotSpot's intrinsics...) is GPL-2.0, so it is off limits like the rest. Reproduce Java's
|
||||
behaviour from its API documentation (the Javadoc) and from what a JVM returns, or from a permissive original such
|
||||
as Sun's fdlibm C sources (see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)); see #6741.
|
||||
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
|
||||
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one at
|
||||
your commit counts. In doubt, don't: ask in #contributors first.
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
# Third-party notices
|
||||
|
||||
## Code removed for its license
|
||||
|
||||
Horizon once shipped three pieces of code under MIT that came from projects whose licenses don't allow that. All
|
||||
three are gone from main. They are listed here for anyone who holds an older commit, tag or release: in those
|
||||
versions, that code is not covered by Horizon's MIT license. It stays under its authors' copyright and its original
|
||||
license.
|
||||
|
||||
- **Shader packs written from Iris's source.** `crates/horizon-shaderpack` and the render stages that used it were
|
||||
written from the source of [Iris](https://github.com/IrisShaders/Iris) (LGPL-3.0, copyright the Iris contributors).
|
||||
They were added from 15 October 2026 (`b22583d057`), shipped in the game releases `v2026.10.16.02` to `v0.2.46`,
|
||||
and were removed on 24 October 2026 ([#1561](https://github.com/horizon-rust/horizon/pull/1561)) after a developer
|
||||
of Iris showed us. Shader packs came back in `crates/horizon-shaders`, first written from a description of the
|
||||
pack format.
|
||||
- **Math functions translated from OpenJDK.** In `crates/horizon-core/src/jmath.rs`, `fdlibm::{log, log10, cbrt,
|
||||
hypot}` were translated from OpenJDK's `java/lang/FdLibm.java` and `round_f32` from its `java/lang/Math.java`
|
||||
(GPL-2.0 with the Classpath Exception), and `aarch64::{sin, cos}` from HotSpot's `macroAssembler_aarch64_trig.cpp`
|
||||
(GPL-2.0); copyright Oracle and/or its affiliates, and Cavium for that file. The `fdlibm` and `aarch64` functions
|
||||
were added on 13 October 2026 (`558d140fca`, first released in `v2026.10.13`). `round_f32` was added on 14 October
|
||||
2026 as `java_round` in `crates/horizon-client/src/mipmap.rs` (`95db0fce9d`) and moved into `jmath.rs` on 3
|
||||
November ([#7770](https://github.com/horizon-rust/horizon/pull/7770)). All of them were replaced in
|
||||
[#11944](https://github.com/horizon-rust/horizon/pull/11944) by code written without OpenJDK's sources, from Sun's
|
||||
fdlibm C sources, the Java SE API documentation and a description of the behaviour, and checked against a JVM
|
||||
([#6741](https://github.com/horizon-rust/horizon/issues/6741)).
|
||||
- **Java's `HashMap` translated from OpenJDK.** `crates/horizon-world/src/java_hash_map.rs`, which reproduces the
|
||||
iteration order of `java.util.HashMap`, was translated from OpenJDK's `java/util/HashMap.java` (GPL-2.0 with the
|
||||
Classpath Exception; copyright Oracle and/or its affiliates). It was added on 7 October 2026 (`118e93ea51` and
|
||||
`194db0dbb4`, first released in `v2026.10.12`). It was replaced in
|
||||
[#12043](https://github.com/horizon-rust/horizon/pull/12043) by code written without OpenJDK's sources, from a
|
||||
description of the behaviour, the Java SE API documentation and the textbook red-black tree, and checked against a
|
||||
JVM ([#6741](https://github.com/horizon-rust/horizon/issues/6741)).
|
||||
|
||||
## Catnip / Ponder
|
||||
|
||||
`crates/horizon-client-render/src/fluid_render.rs` translates the still-fluid face tiling and
|
||||
@@ -256,3 +287,22 @@ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## fdlibm
|
||||
|
||||
`crates/horizon-core/src/jmath/fdlibm.rs` and the `fdlibm_asin`, `fdlibm_atan` and
|
||||
`fdlibm_atan2` functions in `crates/horizon-core/src/geometry/trig.rs` are Rust ports of
|
||||
[fdlibm](https://www.netlib.org/fdlibm/) 5.3, Sun's freely distributable math library, whose
|
||||
results the Java SE API specifies for `StrictMath`. `fdlibm.rs` ports `e_log.c`, `e_log10.c`,
|
||||
`s_cbrt.c` and `e_hypot.c`, and, with fused multiply-adds, `s_sin.c`, `s_cos.c`, `k_sin.c`,
|
||||
`k_cos.c` and `e_rem_pio2.c`; `trig.rs` ports `e_asin.c`, `s_atan.c` and `e_atan2.c`. This
|
||||
notice also heads both files:
|
||||
|
||||
```
|
||||
Copyright (C) 1993 by Sun Microsystems, Inc. All rights reserved.
|
||||
|
||||
Developed at SunSoft, a Sun Microsystems, Inc. business.
|
||||
Permission to use, copy, modify, and distribute this
|
||||
software is freely granted, provided that this notice
|
||||
is preserved.
|
||||
```
|
||||
+49
-6984
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,97 @@
|
||||
//! The world's save on a timer (`MinecraftServer.computeNextAutosaveInterval`,
|
||||
//! `onTickRateChanged`), the players' autosaves, and the world's metadata (level.dat).
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn save_game(&mut self) -> Result<()> {
|
||||
self.save_game_with(true)
|
||||
}
|
||||
/// `MinecraftServer.computeNextAutosaveInterval`: three hundred seconds
|
||||
/// of ticks at the rate the server runs at now - its measured rate while
|
||||
/// a `/tick sprint` runs - never fewer than a hundred.
|
||||
pub(super) fn next_autosave_interval(&self) -> i64 {
|
||||
let rate = if self.clock.sprinting() {
|
||||
1.0e9 / (self.clock.average_nanos() + 1) as f32
|
||||
} else {
|
||||
self.clock.rate()
|
||||
};
|
||||
autosave_interval(rate)
|
||||
}
|
||||
/// `MinecraftServer.onTickRateChanged`: a rate that asks for a shorter
|
||||
/// wait than the one already running shortens it.
|
||||
pub(super) fn on_tick_rate_changed(&mut self) {
|
||||
let interval = self.next_autosave_interval();
|
||||
if interval < self.ticks_until_autosave {
|
||||
self.ticks_until_autosave = interval;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn finish_player_autosaves(&mut self, wait: bool) -> Result<bool> {
|
||||
let saved = self.player_autosaves.completed(wait);
|
||||
if !saved.is_empty() {
|
||||
let directory = horizon_client::lan::playerdata::directory(&self.world_root);
|
||||
for player in saved {
|
||||
self.native_player_save_to_file(player.uuid, &player.name, &directory);
|
||||
}
|
||||
}
|
||||
self.player_autosaves.writes_finished()
|
||||
}
|
||||
pub(super) fn save_game_metadata(&mut self) -> Result<()> {
|
||||
self.save_maps();
|
||||
self.save_random_sequences();
|
||||
self.save_fight();
|
||||
self.save_wandering_trader();
|
||||
self.save_portal_arrivals();
|
||||
self.save_stats();
|
||||
self.save_advancements();
|
||||
self.save_scoreboard();
|
||||
self.save_bossbars();
|
||||
self.save_schedule();
|
||||
self.save_warden_tracker();
|
||||
self.sync_level();
|
||||
// Level::write can synchronously replace the integrated owner file.
|
||||
let _ = self.finish_player_autosaves(true);
|
||||
let stored_owner = self.store_owner();
|
||||
write_level_for_role(
|
||||
&mut self.level,
|
||||
&self.world_root,
|
||||
self.options.execution_role,
|
||||
)?;
|
||||
// PlayerEvent.SaveToFile for the owner, once level.dat is written.
|
||||
if let Some((uuid, name)) = stored_owner {
|
||||
let directory = horizon_client::lan::playerdata::directory(&self.world_root);
|
||||
self.native_player_save_to_file(uuid, &name, &directory);
|
||||
}
|
||||
// PlayerList.saveAll: every placed player saves with the server, to
|
||||
// their own file (`PlayerDataStorage.save`); the owner is also in the
|
||||
// file level.dat names, which is the one it loads from
|
||||
// (`playerdata::load_owner`).
|
||||
if self.options.dedicated.is_some()
|
||||
&& !self.options.execution_role.is_client()
|
||||
&& let Some(host) = self.lan.take()
|
||||
{
|
||||
// The vehicle tree goes into a player's file when they leave
|
||||
// (`take_parent_vehicle`); here it stays in the chunk saves.
|
||||
let mut host = host;
|
||||
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
|
||||
// Read-only fixture input, correlated with the real save receipt.
|
||||
// Keep this identical on the untouched/fixed selector builds.
|
||||
if std::env::var_os("HORIZON_DEV_ADVANCEMENT_SELECTOR_AUDIT").is_some() {
|
||||
eprintln!(
|
||||
"advancement selector model raw: {}",
|
||||
serde_json::json!({"conn":g.conn,"name":g.name,
|
||||
"uuid":horizon_protocol::crypto::uuid_string(g.uuid),
|
||||
"game_time":self.world.game_time,
|
||||
"progress":g.advancements.to_json(&self.adv.tree, horizon_world::DATA_VERSION)})
|
||||
);
|
||||
}
|
||||
self.guest_attachments_into(g);
|
||||
self.player_autosaves.enqueue(g, &self.world_root);
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
self.save_world_state();
|
||||
self.message = Some(("Saved the game".into(), Instant::now()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Block particles: the sprite and tint of a block's faces and particle material, honey slides,
|
||||
//! and the burst a destroyed block leaves (`ClientLevel.addDestroyBlockEffect`).
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The block's first up face (or any face) and its fixed tint, for block
|
||||
/// models drawn as entities (falling blocks, carried blocks).
|
||||
pub(super) fn block_face_sprite(&self, state: BlockStateId) -> Option<(String, u32)> {
|
||||
let pack = &self.pack;
|
||||
let models = pack
|
||||
.render
|
||||
.model_catalog
|
||||
.collect(state, horizon_core::position::BlockPos::new(0, 0, 0))
|
||||
.ok()?;
|
||||
let face = models
|
||||
.iter()
|
||||
.flat_map(|(_, m)| m.faces.iter())
|
||||
.find(|f| f.quad.facing == horizon_assets::model::Direction::Up)
|
||||
.or_else(|| models.iter().flat_map(|(_, m)| m.faces.iter()).next())?;
|
||||
let name = pack.layer_names.get(&face.layer)?.clone();
|
||||
let tint = if face.quad.tint_index >= 0 {
|
||||
let n = &self.res.facts().get(state).name;
|
||||
if n.contains("leaves") || n.contains("vine") {
|
||||
0x48B518
|
||||
} else {
|
||||
0x91BD59
|
||||
}
|
||||
} else {
|
||||
0xFFFFFF
|
||||
};
|
||||
Some((name, tint))
|
||||
}
|
||||
/// `TerrainParticle` at `pos`: the model's particle sprite and tint source
|
||||
/// 0's `colorAsTerrainParticle` (white when the block has none).
|
||||
/// `HoneyBlock.showSlideParticles` (entity event 53): five honey block
|
||||
/// particles at the entity.
|
||||
pub(super) fn honey_slide_particles(&mut self, at: [f64; 3]) {
|
||||
self.honey_entity_particles(at, 53);
|
||||
}
|
||||
/// `HoneyBlock.showParticles`: the crumb count of entity event 53 or 54.
|
||||
pub(super) fn honey_entity_particles(&mut self, at: [f64; 3], event: i8) {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
let Some(count) = horizon_client::landing::honey_particle_count(event) else {
|
||||
return;
|
||||
};
|
||||
let Some(honey) = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:honey_block")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let block = [
|
||||
at[0].floor() as i32,
|
||||
at[1].floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
];
|
||||
if let Some((sprite, tint)) = self.particle_sprite(honey, block) {
|
||||
for _ in 0..count {
|
||||
self.particles.add(
|
||||
&ParticleOptions::block(ParticleType::Block, sprite.clone(), tint),
|
||||
at,
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `BlockStateModelSet.getParticleMaterial`: the sprite of the model's
|
||||
/// `particle` texture, else its first face (entity-rendered blocks may
|
||||
/// have a particle texture without faces, and faces without one).
|
||||
pub(super) fn block_particle_sprite(
|
||||
pack: &PackResources,
|
||||
state: BlockStateId,
|
||||
) -> Option<String> {
|
||||
let catalog = &pack.render.model_catalog;
|
||||
let layer = catalog.particle(state).or_else(|| {
|
||||
let models = catalog
|
||||
.collect(state, horizon_core::position::BlockPos::new(0, 0, 0))
|
||||
.ok()?;
|
||||
models
|
||||
.iter()
|
||||
.flat_map(|(_, m)| m.faces.iter())
|
||||
.next()
|
||||
.map(|f| f.layer)
|
||||
})?;
|
||||
pack.layer_names.get(&layer).cloned()
|
||||
}
|
||||
pub(super) fn particle_shape(&self, pos: [i32; 3], state: BlockStateId) -> Vec<[f64; 6]> {
|
||||
let f = self.res.facts().get(state);
|
||||
let offset = if f.outline_offset {
|
||||
self.pack
|
||||
.context
|
||||
.tables
|
||||
.offset(state, pos[0], pos[2])
|
||||
.unwrap_or([0.0; 3])
|
||||
} else {
|
||||
[0.0; 3]
|
||||
};
|
||||
f.outline
|
||||
.iter()
|
||||
.map(|b| std::array::from_fn(|i| b[i] + offset[i % 3]))
|
||||
.collect()
|
||||
}
|
||||
/// ClientLevel.addDestroyBlockEffect: a burst per outline box.
|
||||
pub(super) fn destroy_particles(&mut self, pos: [i32; 3], state: BlockStateId) {
|
||||
if self.settings.extras.hide_block_particles {
|
||||
return;
|
||||
}
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, pos) else {
|
||||
return;
|
||||
};
|
||||
let boxes = self.particle_shape(pos, state);
|
||||
horizon_client::particles::destroy_positions(pos, &boxes, |p, v| {
|
||||
let particle = Particle::terrain(self.particles.random(), p, v, sprite.clone(), tint);
|
||||
self.particles.push(particle);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Using an item on a cauldron (`CauldronInteraction`): what the click does, and doing it.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// What the cauldron at `pos` answers to `held` on a client, which only
|
||||
/// asks (`CauldronInteractions` acts `if (!level.isClientSide())`). A
|
||||
/// sneak with something in hand skips `useItemOn`
|
||||
/// (`Player.isSecondaryUseActive`).
|
||||
pub(super) fn cauldron_outcome(
|
||||
&self,
|
||||
pos: [i32; 3],
|
||||
held: Option<&ItemStack>,
|
||||
) -> horizon_client::cauldron::Outcome {
|
||||
use horizon_client::cauldron::{self, Outcome};
|
||||
let Some(held) = held else {
|
||||
return Outcome::Pass;
|
||||
};
|
||||
if self.keys.held(Mapping::Sneak) {
|
||||
return Outcome::Pass;
|
||||
}
|
||||
let Some((state, under_water)) = self.cauldron_at(pos) else {
|
||||
return Outcome::Pass;
|
||||
};
|
||||
let removes_dye = cauldron::removes_dye(self.items(), &held.item);
|
||||
cauldron::outcome(&state, under_water, removes_dye, held)
|
||||
}
|
||||
/// `CauldronInteractions` for the held item aimed at a cauldron.
|
||||
pub(super) fn use_on_cauldron(&mut self, pos: [i32; 3]) -> Result<bool> {
|
||||
use horizon_client::cauldron::{self, Outcome};
|
||||
struct Hand<'g, 'a>(&'g mut Game<'a>);
|
||||
impl cauldron::Player for Hand<'_, '_> {
|
||||
fn creative(&self) -> bool {
|
||||
self.0.creative
|
||||
}
|
||||
fn hand(&mut self) -> &mut Option<ItemStack> {
|
||||
self.0.held_mut()
|
||||
}
|
||||
fn add(&mut self, stack: &mut ItemStack) -> bool {
|
||||
let before = stack.count;
|
||||
match self.0.give(stack.clone()) {
|
||||
None => stack.count = 0,
|
||||
Some(rest) => stack.count = rest.count,
|
||||
}
|
||||
stack.count < before
|
||||
}
|
||||
fn contains(&self, stack: &ItemStack) -> bool {
|
||||
self.0
|
||||
.inventory
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|have| have.same_item_same_components(stack))
|
||||
}
|
||||
fn drop_stack(&mut self, stack: ItemStack) {
|
||||
if let Some(eye) = self.0.eye_position() {
|
||||
// ItemUtils.createFilledResult: `player.drop(stack, false)`.
|
||||
self.0.throw_from_eyes(stack, eye, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some((state, under_water)) = self.cauldron_at(pos) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(held) = self.held() else {
|
||||
return Ok(false);
|
||||
};
|
||||
// Player.isSecondaryUseActive with an item in hand skips useItemOn.
|
||||
if self.keys.held(Mapping::Sneak) {
|
||||
return Ok(false);
|
||||
}
|
||||
let removes_dye = cauldron::removes_dye(self.items(), &held.item);
|
||||
let result = cauldron::interact(&state, under_water, removes_dye, &mut Hand(self));
|
||||
if result.outcome == Outcome::Pass {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(stat) = result.stat {
|
||||
self.award_custom_stat(stat, 1);
|
||||
}
|
||||
if let Some(block) = result.block
|
||||
&& let Ok(new) = registry.resolve(&block)
|
||||
{
|
||||
self.level_set(pos, new)?;
|
||||
if result.lower_fill_level {
|
||||
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
|
||||
}
|
||||
}
|
||||
if let Some(sound) = result.sound {
|
||||
self.audio.play(
|
||||
sound,
|
||||
Some([
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
//! Unit tests that lived in game.rs's inline `tests` module.
|
||||
use super::*;
|
||||
use horizon_client::container_screen::{Button, Clicks, Release, Target};
|
||||
use horizon_client::keymapping::{KeyAction, SNEAK, SPRINT, ToggleKeys};
|
||||
|
||||
/// `ClipContext.Block.OUTLINE` for the SOURCE_ONLY ray (#8180): a ray
|
||||
/// through the empty part of a torch's cell is not stopped by it.
|
||||
#[test]
|
||||
fn the_fluid_ray_passes_through_the_hollow_of_a_torch() {
|
||||
// A torch: a 2x10x2 pillar in the middle of its cell, at (0, 0, 0).
|
||||
let torch = || vec![[0.4375, 0.0, 0.4375, 0.5625, 0.625, 0.5625]];
|
||||
let dir = [0.0, 0.0, 1.0];
|
||||
// Beside the pillar: no hit, the ray goes on to the water behind.
|
||||
assert!(outline_stop([0.1, 0.3, -1.0], dir, 5.0, [0, 0, 0], [0, 0, -1], torch).is_none());
|
||||
// Above the pillar (a torch is 10/16 tall): no hit either.
|
||||
assert!(outline_stop([0.5, 0.8, -1.0], dir, 5.0, [0, 0, 0], [0, 0, -1], torch).is_none());
|
||||
// Through the pillar: stopped on its front face, not at the cell's edge.
|
||||
let hit = outline_stop([0.5, 0.3, -1.0], dir, 5.0, [0, 0, 0], [0, 0, -1], torch)
|
||||
.expect("the pillar stops the ray");
|
||||
assert_eq!(hit.face, [0, 0, -1]);
|
||||
assert!((hit.point[2] - 0.4375).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_death_drops_the_off_hand_before_the_armour() {
|
||||
let stack = |item: &str| Some(ItemStack::new(item, 1));
|
||||
let mut inventory = vec![stack("minecraft:diamond"), None, stack("minecraft:stick")];
|
||||
let mut offhand = stack("minecraft:shield");
|
||||
let mut armor = [
|
||||
stack("minecraft:iron_helmet"),
|
||||
stack("minecraft:iron_chestplate"),
|
||||
stack("minecraft:iron_leggings"),
|
||||
stack("minecraft:iron_boots"),
|
||||
];
|
||||
let dropped: Vec<String> = death_drops(&mut inventory, &mut offhand, &mut armor)
|
||||
.into_iter()
|
||||
.map(|s| s.item.to_string())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
dropped,
|
||||
[
|
||||
"minecraft:diamond",
|
||||
"minecraft:stick",
|
||||
"minecraft:shield",
|
||||
"minecraft:iron_boots",
|
||||
"minecraft:iron_leggings",
|
||||
"minecraft:iron_chestplate",
|
||||
"minecraft:iron_helmet",
|
||||
]
|
||||
);
|
||||
assert!(inventory.iter().all(Option::is_none));
|
||||
assert!(offhand.is_none() && armor.iter().all(Option::is_none));
|
||||
}
|
||||
|
||||
/// `MinecraftServer.computeNextAutosaveInterval`: three hundred seconds of
|
||||
/// ticks at the rate the server runs at, never fewer than a hundred.
|
||||
#[test]
|
||||
fn the_autosave_waits_for_three_hundred_seconds_of_ticks() {
|
||||
// 20 ticks a second is the six thousand ticks a world autosaved on
|
||||
// before the rate could be changed (`/tick rate`).
|
||||
assert_eq!(autosave_interval(20.0), 6000);
|
||||
assert_eq!(autosave_interval(10.0), 3000);
|
||||
assert_eq!(autosave_interval(40.0), 12000);
|
||||
assert_eq!(autosave_interval(1000.0), 300_000);
|
||||
// `Math.max(100, ...)`: a very low rate still autosaves every hundred
|
||||
// ticks, and a frozen clock (0) does too.
|
||||
assert_eq!(autosave_interval(0.5), 150);
|
||||
assert_eq!(autosave_interval(0.0), 100);
|
||||
}
|
||||
|
||||
/// `Minecraft.<init>`: only an explicit `startedCleanly: false` is an
|
||||
/// unclean start, and it leaves fullscreen; the packs stay selected.
|
||||
#[test]
|
||||
fn an_unclean_start_leaves_fullscreen_and_keeps_the_packs() {
|
||||
let fullscreen = || Settings {
|
||||
fullscreen: true,
|
||||
resource_packs: vec!["pack".into()],
|
||||
..Default::default()
|
||||
};
|
||||
// The key absent (a first boot) is a clean start.
|
||||
let mut settings = fullscreen();
|
||||
assert!(!unclean_start_recovery(&mut settings));
|
||||
assert!(settings.fullscreen);
|
||||
let mut settings = fullscreen();
|
||||
settings.file.fullscreen_resolution = Some("1920x1080@60:24".into());
|
||||
settings.file.set(
|
||||
"startedCleanly",
|
||||
horizon_client::options_file::V::Bool(false),
|
||||
);
|
||||
assert!(unclean_start_recovery(&mut settings));
|
||||
assert!(!settings.fullscreen);
|
||||
assert_eq!(settings.file.fullscreen_resolution, None);
|
||||
assert_eq!(settings.resource_packs, ["pack"]);
|
||||
let mut settings = fullscreen();
|
||||
settings.file.set(
|
||||
"startedCleanly",
|
||||
horizon_client::options_file::V::Bool(true),
|
||||
);
|
||||
assert!(!unclean_start_recovery(&mut settings));
|
||||
assert!(settings.fullscreen);
|
||||
}
|
||||
|
||||
/// `NativeScreenView.id` (the `screen_render_post` hook) is a mod's
|
||||
/// contract: every `Screen` has its own id, and it does not change.
|
||||
/// `Screen::id`'s match makes a new variant give itself one; this list
|
||||
/// makes the test cover it too.
|
||||
#[test]
|
||||
fn screen_ids_are_distinct_and_stable() {
|
||||
use std::collections::BTreeSet;
|
||||
let all = [
|
||||
Screen::Playing,
|
||||
Screen::GameModeSwitcher,
|
||||
Screen::EmoteWheel,
|
||||
Screen::Paused,
|
||||
Screen::Options,
|
||||
Screen::Chat,
|
||||
Screen::Creative,
|
||||
Screen::Inventory,
|
||||
Screen::Dead,
|
||||
Screen::Stats,
|
||||
Screen::Advancements,
|
||||
Screen::WorldOptions,
|
||||
Screen::Friends,
|
||||
Screen::Social,
|
||||
Screen::CommandBlock,
|
||||
Screen::BlockEditor,
|
||||
Screen::ServerLinks,
|
||||
Screen::ConfirmLink,
|
||||
Screen::DialogWarning,
|
||||
Screen::Win,
|
||||
Screen::DebugOptions,
|
||||
];
|
||||
let ids: Vec<&'static str> = all.iter().map(|screen| screen.id()).collect();
|
||||
let unique: BTreeSet<&'static str> = ids.iter().copied().collect();
|
||||
assert_eq!(unique.len(), all.len(), "two screens share an id");
|
||||
for id in &ids {
|
||||
assert!(!id.is_empty(), "an empty screen id");
|
||||
assert!(
|
||||
id.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
|
||||
"{id} is not a snake_case id"
|
||||
);
|
||||
}
|
||||
// The ones a mod can match on in the hook's docs.
|
||||
assert_eq!(Screen::Playing.id(), "playing");
|
||||
assert_eq!(Screen::Inventory.id(), "inventory");
|
||||
assert_eq!(Screen::Win.id(), "win");
|
||||
}
|
||||
|
||||
/// The `modifiers` a native mod's input hooks hand over are GLFW's bits
|
||||
/// (`GLFW_MOD_SHIFT` 1, `GLFW_MOD_CONTROL` 2, `GLFW_MOD_ALT` 4,
|
||||
/// `GLFW_MOD_SUPER` 8), each on its own and all together.
|
||||
#[test]
|
||||
fn glfw_modifier_bits_are_glfw_modifier_bits() {
|
||||
use winit::keyboard::ModifiersState;
|
||||
for (state, bits) in [
|
||||
(ModifiersState::empty(), 0),
|
||||
(ModifiersState::SHIFT, 1),
|
||||
(ModifiersState::CONTROL, 2),
|
||||
(ModifiersState::ALT, 4),
|
||||
(ModifiersState::SUPER, 8),
|
||||
(ModifiersState::SHIFT | ModifiersState::ALT, 5),
|
||||
(ModifiersState::CONTROL | ModifiersState::SUPER, 10),
|
||||
(ModifiersState::SHIFT | ModifiersState::CONTROL, 3),
|
||||
(
|
||||
ModifiersState::SHIFT
|
||||
| ModifiersState::CONTROL
|
||||
| ModifiersState::ALT
|
||||
| ModifiersState::SUPER,
|
||||
15,
|
||||
),
|
||||
] {
|
||||
assert_eq!(glfw_modifiers(state), bits, "{state:?}");
|
||||
}
|
||||
// Every modifier GLFW has a bit for, and no other.
|
||||
assert_eq!(glfw_modifiers(ModifiersState::all()), 15);
|
||||
}
|
||||
|
||||
/// The `button` a native mod's mouse hooks hand over is GLFW's index (0
|
||||
/// left, 1 right, 2 middle, 3 back, 4 forward, else the button's number),
|
||||
/// the one `keybinds::mouse_name` names (`key.mouse.<index + 1>` from the
|
||||
/// back button on).
|
||||
#[test]
|
||||
fn glfw_button_indices_are_glfw_indices() {
|
||||
for (button, index, name) in [
|
||||
(MouseButton::Left, 0, "key.mouse.left"),
|
||||
(MouseButton::Right, 1, "key.mouse.right"),
|
||||
(MouseButton::Middle, 2, "key.mouse.middle"),
|
||||
(MouseButton::Back, 3, "key.mouse.4"),
|
||||
(MouseButton::Forward, 4, "key.mouse.5"),
|
||||
] {
|
||||
assert_eq!(glfw_button(button), index);
|
||||
assert_eq!(horizon_client::keybinds::mouse_name(button), name);
|
||||
}
|
||||
for n in 5..12u16 {
|
||||
assert_eq!(glfw_button(MouseButton::Other(n)), i32::from(n));
|
||||
assert_eq!(
|
||||
horizon_client::keybinds::mouse_name(MouseButton::Other(n)),
|
||||
format!("key.mouse.{}", n + 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// F3 left on when leaving a world kept the axes on the title screen.
|
||||
#[test]
|
||||
fn the_debug_crosshair_is_gone_once_the_world_is() {
|
||||
assert!(debug_crosshair_drawn(true, true, false, false));
|
||||
assert!(!debug_crosshair_drawn(true, true, false, true));
|
||||
}
|
||||
|
||||
/// Snuffing a candle or a candle cake draws one smoke per
|
||||
/// `getParticleOffsets`: by `candles` for a candle, a block up for a cake.
|
||||
#[test]
|
||||
fn a_snuffed_candle_and_candle_cake_smoke_at_vanillas_offsets() {
|
||||
assert_eq!(
|
||||
Game::candle_offsets("minecraft:candle", Some(1)),
|
||||
[[8u8, 8, 8]].as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
Game::candle_offsets("minecraft:red_candle", Some(3)),
|
||||
[[8u8, 5, 10], [6, 7, 8], [9, 8, 7]].as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
Game::candle_offsets("minecraft:candle_cake", None),
|
||||
[[8u8, 16, 8]].as_slice()
|
||||
);
|
||||
}
|
||||
|
||||
/// Held items apply their main-hand and off-hand modifiers to the player (#4118, #4777).
|
||||
#[test]
|
||||
fn equipment_modifiers_follow_their_slot_group() {
|
||||
assert!(equipment_modifier_applies("mainhand", "mainhand"));
|
||||
assert!(equipment_modifier_applies("hand", "mainhand"));
|
||||
assert!(equipment_modifier_applies("any", "mainhand"));
|
||||
assert!(!equipment_modifier_applies("armor", "mainhand"));
|
||||
assert!(!equipment_modifier_applies("mainhand", "head"));
|
||||
assert!(equipment_modifier_applies("armor", "chest"));
|
||||
assert!(!equipment_modifier_applies("hand", "feet"));
|
||||
assert!(equipment_modifier_applies("offhand", "offhand"));
|
||||
assert!(equipment_modifier_applies("hand", "offhand"));
|
||||
assert!(equipment_modifier_applies("any", "offhand"));
|
||||
assert!(!equipment_modifier_applies("armor", "offhand"));
|
||||
assert!(!equipment_modifier_applies("mainhand", "offhand"));
|
||||
assert!(!equipment_modifier_applies("offhand", "mainhand"));
|
||||
}
|
||||
|
||||
/// A guest's interact packet names its entity and the server keeps it:
|
||||
/// `ServerGamePacketListenerImpl.handleInteract` resolves
|
||||
/// `packet.entityId()` once and `Player.interactOn` clicks that entity,
|
||||
/// while a fresh pick would take the nearest one. Two cows stand one
|
||||
/// behind the other; the packet names the far one.
|
||||
#[test]
|
||||
fn an_interact_packet_clicks_the_entity_it_names_not_the_nearest() {
|
||||
// (server id, distance from the guest) in the level's own order.
|
||||
let cows = [(17u32, 1.5f64), (23, 2.5)];
|
||||
assert_eq!(Game::named_entity(&cows, 23, |c| c.0), Some(1));
|
||||
// The near cow is reachable too, when that is the one named.
|
||||
assert_eq!(Game::named_entity(&cows, 17, |c| c.0), Some(0));
|
||||
// An id the level does not hold clicks nothing (Level.getEntity).
|
||||
assert_eq!(Game::named_entity(&cows, 31, |c| c.0), None);
|
||||
}
|
||||
|
||||
/// The id path keeps the crosshair pick's filters, so a packet cannot name
|
||||
/// a mob the pick would refuse: one in its death animation, the one the
|
||||
/// player rides, or the camera entity. Without them a guest could shear,
|
||||
/// feed or leash a dying mob that `mob_hit` never returns.
|
||||
#[test]
|
||||
fn an_interact_packet_cannot_name_a_mob_the_pick_would_refuse() {
|
||||
use super::creatures::named_mob_pickable;
|
||||
assert!(named_mob_pickable(true, 17, None, None));
|
||||
// Dead: `Mob.alive()` is health > 0.
|
||||
assert!(!named_mob_pickable(false, 17, None, None));
|
||||
// Ridden, and the camera entity (`remote_camera_id`).
|
||||
assert!(!named_mob_pickable(true, 17, Some(17), None));
|
||||
assert!(!named_mob_pickable(true, 17, None, Some(17)));
|
||||
// Another mob being ridden or watched does not block this one.
|
||||
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
|
||||
/// screen the stock layout answers exactly as before, which is what keeps
|
||||
/// this invisible to players who run no native mods.
|
||||
#[test]
|
||||
fn a_mod_screen_on_a_machine_menu_takes_the_stock_layout_out() {
|
||||
let stock = Some((8.0, 109.0));
|
||||
// A mod screen is open: the stock grid does not answer.
|
||||
assert_eq!(Game::stock_machine_slot(stock, true), None);
|
||||
// No mod screen: the hopper and dispenser layouts are untouched.
|
||||
assert_eq!(Game::stock_machine_slot(stock, false), stock);
|
||||
// Other menus have no stock layout to stand back from.
|
||||
assert_eq!(Game::stock_machine_slot(None, true), None);
|
||||
assert_eq!(Game::stock_machine_slot(None, false), None);
|
||||
}
|
||||
|
||||
/// A key bound to key.attack keeps attacking while it is held: each
|
||||
/// repeat clicks like the press (#3243).
|
||||
#[test]
|
||||
fn a_held_key_bound_to_a_button_repeats_its_clicks() {
|
||||
assert_eq!(keyboard_button(true, KeyAction::Press), Some(true));
|
||||
assert_eq!(keyboard_button(true, KeyAction::Repeat), Some(true));
|
||||
assert_eq!(keyboard_button(true, KeyAction::Release), Some(false));
|
||||
for action in [KeyAction::Press, KeyAction::Repeat, KeyAction::Release] {
|
||||
assert_eq!(keyboard_button(false, action), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// A server dialog, a book, a sign editor or a prompt over the world is a
|
||||
/// screen: held keys and the mouse buttons are released
|
||||
/// (`KeyMapping.releaseAll`), and toggled sneak comes back when it closes
|
||||
/// (#4974, #9729).
|
||||
#[test]
|
||||
fn a_dialog_releases_held_keys_like_a_screen() {
|
||||
assert!(gameplay_has_keyboard(Screen::Playing, false, false));
|
||||
assert!(!gameplay_has_keyboard(Screen::Playing, false, true));
|
||||
assert!(!gameplay_has_keyboard(Screen::Playing, true, false));
|
||||
assert!(!gameplay_has_keyboard(Screen::Chat, false, false));
|
||||
|
||||
let mut keys = horizon_client::held::HeldKeys::from_iter([KeyCode::KeyW]);
|
||||
let mut left = true;
|
||||
let mut right = true;
|
||||
drop_held_behind_dialog(false, &mut keys, &mut left, &mut right);
|
||||
assert!(keys.raw(&KeyCode::KeyW) && left && right);
|
||||
drop_held_behind_dialog(true, &mut keys, &mut left, &mut right);
|
||||
assert!(keys.is_empty() && !left && !right);
|
||||
|
||||
let options = [true, false, false, false];
|
||||
let mut toggles = ToggleKeys::default();
|
||||
toggles.key(SNEAK, KeyAction::Press, false, true);
|
||||
toggles.key(SNEAK, KeyAction::Release, false, true);
|
||||
assert!(toggles.is_down(SNEAK));
|
||||
toggles.screen_opened(options);
|
||||
assert!(!toggles.is_down(SNEAK));
|
||||
toggles.screen_closed(options);
|
||||
assert!(toggles.is_down(SNEAK));
|
||||
}
|
||||
/// A key bound to key.pickItem clones over a container screen, as the
|
||||
/// middle button does; a key bound to attack or use does nothing there (#5378).
|
||||
#[test]
|
||||
fn a_key_bound_to_pick_block_clones_behind_a_container_screen() {
|
||||
assert!(container_key_clones(MouseButton::Middle, KeyAction::Press));
|
||||
assert!(container_key_clones(MouseButton::Middle, KeyAction::Repeat));
|
||||
assert!(!container_key_clones(
|
||||
MouseButton::Middle,
|
||||
KeyAction::Release
|
||||
));
|
||||
for button in [MouseButton::Left, MouseButton::Right] {
|
||||
assert!(!container_key_clones(button, KeyAction::Press));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn sleep_fade_lasts_ten_ticks_after_waking() {
|
||||
let alpha = |timer| match hud_overlays::sleep_overlay(320, 240, timer).first() {
|
||||
Some(hud_layout::GuiDraw::Fill { color, .. }) => color >> 24,
|
||||
None => 0,
|
||||
_ => panic!("sleep overlay must be a full-screen fill"),
|
||||
};
|
||||
assert_eq!(tick_sleep_timer(0, false), 0);
|
||||
let mut timer = 0;
|
||||
for _ in 0..50 {
|
||||
timer = tick_sleep_timer(timer, true);
|
||||
}
|
||||
assert_eq!(alpha(timer as i32), 110);
|
||||
for _ in 0..100 {
|
||||
timer = tick_sleep_timer(timer, true);
|
||||
}
|
||||
assert_eq!(timer, 100);
|
||||
assert_eq!(alpha(timer as i32), 220);
|
||||
for _ in 0..5 {
|
||||
timer = tick_sleep_timer(timer, false);
|
||||
}
|
||||
assert_eq!(alpha(timer as i32), 110);
|
||||
for _ in 0..5 {
|
||||
timer = tick_sleep_timer(timer, false);
|
||||
}
|
||||
assert_eq!(timer, 0);
|
||||
assert!(hud_overlays::sleep_overlay(320, 240, timer as i32).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_rest_poll_does_not_wait_for_a_blocked_worker() {
|
||||
let (release, wait) = std::sync::mpsc::channel();
|
||||
let loader = WorldRestLoader {
|
||||
progress: Progress::new(),
|
||||
handle: std::thread::spawn(move || {
|
||||
wait.recv().unwrap();
|
||||
Ok(())
|
||||
}),
|
||||
};
|
||||
assert!(!loader.ready().unwrap());
|
||||
release.send(()).unwrap();
|
||||
loader.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_rest_poll_waits_for_a_slow_worker() {
|
||||
let (release, wait) = std::sync::mpsc::channel();
|
||||
let loader = WorldRestLoader {
|
||||
progress: Progress::new(),
|
||||
handle: std::thread::spawn(move || {
|
||||
wait.recv().unwrap();
|
||||
Ok(())
|
||||
}),
|
||||
};
|
||||
// However long it takes, a worker still loading is not an error.
|
||||
assert!(!loader.ready().unwrap());
|
||||
release.send(()).unwrap();
|
||||
while !loader.ready().unwrap() {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
loader.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_rest_worker_failure_reaches_the_client() {
|
||||
let loader = WorldRestLoader {
|
||||
progress: Progress::new(),
|
||||
handle: std::thread::spawn(|| Err("missing world table".to_owned())),
|
||||
};
|
||||
assert_eq!(
|
||||
loader.finish().unwrap_err().to_string(),
|
||||
"missing world table"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_rest_worker_panic_reaches_the_client() {
|
||||
let loader = WorldRestLoader {
|
||||
progress: Progress::new(),
|
||||
handle: std::thread::spawn(|| panic!("broken loader")),
|
||||
};
|
||||
assert_eq!(
|
||||
loader.finish().unwrap_err().to_string(),
|
||||
"world resource loader panicked"
|
||||
);
|
||||
}
|
||||
|
||||
/// An integrated server thread shares the loaded resources with the client.
|
||||
#[test]
|
||||
fn shared_resources_can_cross_threads() {
|
||||
fn shared<T: Send + Sync>() {}
|
||||
shared::<Resources>();
|
||||
shared::<PackResources>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_creative_tabs_does_not_drop_the_carried_stack_on_release() {
|
||||
let mut creative = Creative::from_json(&serde_json::json!({
|
||||
"schema": 2,
|
||||
"tabs": [
|
||||
{"type": "CATEGORY", "items": ["{count:1,id:\"minecraft:stone\"}"]},
|
||||
{"type": "INVENTORY"}
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
for button in [Button::Left, Button::Right] {
|
||||
let mut clicks = Clicks::default();
|
||||
for tab in [1, 1, 0, 1] {
|
||||
select_creative_tab(&mut creative, &mut clicks, tab);
|
||||
assert_eq!(creative.selected, tab);
|
||||
assert_eq!(creative.tab().inventory, tab == 1);
|
||||
assert_eq!(
|
||||
clicks.release(
|
||||
&Target::<usize>::Outside,
|
||||
button,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
),
|
||||
Release::Nothing
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eyes under water: the ray hits the source the eyes are in, at the eyes
|
||||
/// (#3787), whichever way it looks.
|
||||
#[test]
|
||||
fn a_ray_starting_inside_a_source_hits_it_at_the_start() {
|
||||
let eye = [0.5, 65.62, 0.5];
|
||||
let down = [0.0, -1.0, 0.0];
|
||||
assert_eq!(
|
||||
clip_start_source(eye, down, 4.5, [0, 65, 0], true),
|
||||
Some(([0, 1, 0], eye))
|
||||
);
|
||||
// Without water above, 0.62 is still below the surface (8/9).
|
||||
assert_eq!(
|
||||
clip_start_source(eye, [0.0, 1.0, 0.0], 4.5, [0, 65, 0], false),
|
||||
Some(([0, -1, 0], eye))
|
||||
);
|
||||
// Looking ahead, slightly down: SOUTH's dot product is largest.
|
||||
let ahead = [0.0, -0.2f64, 0.98f64.sqrt()];
|
||||
assert_eq!(
|
||||
clip_start_source(eye, ahead, 4.5, [0, 65, 0], true),
|
||||
Some(([0, 0, -1], eye))
|
||||
);
|
||||
}
|
||||
|
||||
/// Eyes above a surface source's 8/9: a ray going down enters it through
|
||||
/// the top inside the block; level or rising rays miss it.
|
||||
#[test]
|
||||
fn a_ray_starting_above_a_source_surface_enters_through_the_top() {
|
||||
let eye = [0.5, 65.95, 0.5];
|
||||
let hit = clip_start_source(eye, [0.0, -1.0, 0.0], 4.5, [0, 65, 0], false);
|
||||
let (face, point) = hit.unwrap();
|
||||
assert_eq!(face, [0, 1, 0]);
|
||||
assert!((point[1] - (65.0 + f64::from(8.0f32 / 9.0f32))).abs() < 1e-12);
|
||||
assert_eq!(
|
||||
clip_start_source(eye, [1.0, 0.0, 0.0], 4.5, [0, 65, 0], false),
|
||||
None
|
||||
);
|
||||
// Leaves the block sideways before it comes down to the surface.
|
||||
let shallow = [0.995f64.sqrt(), -0.0707, 0.0];
|
||||
assert_eq!(
|
||||
clip_start_source(eye, shallow, 4.5, [0, 65, 0], false),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_drop_requires_ctrl_even_with_sprint_latched() {
|
||||
for sprint in [false, true] {
|
||||
let mut toggles = ToggleKeys::default();
|
||||
if sprint {
|
||||
toggles.key(SPRINT, KeyAction::Press, false, true);
|
||||
toggles.key(SPRINT, KeyAction::Release, false, true);
|
||||
}
|
||||
assert_eq!(toggles.is_down(SPRINT), sprint);
|
||||
|
||||
for held in 0..4 {
|
||||
let mut modifiers = winit::keyboard::ModifiersState::empty();
|
||||
let mut physical_keys = HashSet::from([KeyCode::ShiftLeft, KeyCode::KeyQ]);
|
||||
let sides = [KeyCode::ControlLeft, KeyCode::ControlRight];
|
||||
for (bit, key) in sides.into_iter().enumerate() {
|
||||
if held & (1 << bit) != 0 {
|
||||
horizon_client_input::modifiers::set_key(
|
||||
&mut modifiers,
|
||||
&mut physical_keys,
|
||||
key,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(drop_whole_stack(modifiers), held != 0);
|
||||
for key in sides {
|
||||
horizon_client_input::modifiers::set_key(
|
||||
&mut modifiers,
|
||||
&mut physical_keys,
|
||||
key,
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert!(!drop_whole_stack(modifiers));
|
||||
assert_eq!(toggles.is_down(SPRINT), sprint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
//! The creative inventory screen (`CreativeModeInventoryScreen`): its tabs, pages, tooltips,
|
||||
//! trash slot, search box and clicks.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `CreativeModeInventoryScreen.hasPermissions` (`canUseGameMasterBlocks`
|
||||
/// and the Operator Items Tab option) given to `tryRefreshInvalidatedTabs`,
|
||||
/// as the screen's constructor and `containerTick` do.
|
||||
pub(super) fn refresh_creative_tabs(&mut self) {
|
||||
let permissions = self.can_use_game_master_blocks() && self.settings.operator_items_tab;
|
||||
self.creative_screen
|
||||
.set_permissions(permissions, &self.pack.names);
|
||||
}
|
||||
pub(super) fn creative_inventory_open(&self) -> bool {
|
||||
self.screen == Screen::Creative && self.creative_screen.tab().inventory
|
||||
}
|
||||
/// An item tab of the creative screen (its ItemPickerMenu).
|
||||
pub(super) fn creative_tab_open(&self) -> bool {
|
||||
self.screen == Screen::Creative && !self.creative_screen.tab().inventory
|
||||
}
|
||||
|
||||
pub(super) fn creative_origin(batch: &HudBatch) -> (f32, f32) {
|
||||
batch.centered_origin(Creative::WIDTH, Creative::HEIGHT)
|
||||
}
|
||||
/// A creative tab's title: `itemGroup.*` for vanilla's; a mod tab's
|
||||
/// `itemGroup.<mod>.<name>`, else its `title` as a lang key (a mod's own
|
||||
/// `itemGroup.<mod>`), else `title` as English text.
|
||||
pub(super) fn creative_tab_title(&self, tab: &horizon_client::creative::Tab) -> String {
|
||||
match horizon_client::creative::item_group_key(&tab.id) {
|
||||
Some(key) => self.translate(key, &tab.title),
|
||||
None => {
|
||||
let own = self.translate(&tab.title, &tab.title);
|
||||
self.translate(&format!("itemGroup.{}", tab.id.replace(':', ".")), &own)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Vanilla tooltip: dark box with a purple gradient border, laid out and
|
||||
/// drawn like an item tooltip (4 px of padding on every side).
|
||||
pub(super) fn tooltip(&self, batch: &mut HudBatch, text: &str, x: f32, y: f32) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let lines = vec![(horizon_client::text::Text::literal(text), u32::MAX)];
|
||||
let (x, y, w, h) =
|
||||
Self::tooltip_geometry(atlas, &lines, (x, y), (batch.width, batch.height), [0.0; 2]);
|
||||
self.tooltip_box(batch, None, x, y, w, h);
|
||||
Self::tooltip_text(batch, atlas, &lines, x, y);
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.getTooltipFromContainerItem`: the lines
|
||||
/// that follow the item's name, none for a category tab's own grid slots
|
||||
/// (`grid`): the names of the tabs containing the stack, in blue, then on
|
||||
/// the search tab's grid the tags a `#` search shows, in dark purple.
|
||||
pub(super) fn creative_tooltip_tabs(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
grid: bool,
|
||||
) -> Vec<(horizon_client::text::Text, u32)> {
|
||||
let screen = &self.creative_screen;
|
||||
let tab = screen.tab();
|
||||
let category = !tab.search && !tab.inventory;
|
||||
if !horizon_client::creative::tooltip_lists_tabs(category, grid) {
|
||||
return Vec::new();
|
||||
}
|
||||
let blue = rgba(85, 85, 255, 255);
|
||||
let dark_purple = rgba(170, 0, 170, 255);
|
||||
let mut lines: Vec<(horizon_client::text::Text, u32)> = screen
|
||||
.tabs_containing(stack)
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
let title = self.creative_tab_title(&screen.tabs[i]);
|
||||
(horizon_client::text::Text::literal(title), blue)
|
||||
})
|
||||
.collect();
|
||||
if tab.search && grid {
|
||||
for tag in screen.search_tag_lines(stack) {
|
||||
lines.push((horizon_client::text::Text::literal(tag), dark_purple));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.extractRenderState`: item slots share the
|
||||
/// container screen's hover target for their highlight and tooltip.
|
||||
pub(super) fn draw_creative(&mut self, batch: &mut HudBatch) {
|
||||
// CreativeModeInventoryScreen: `CommonComponents.EMPTY` for a title.
|
||||
screen_info::begin_container("creative");
|
||||
let (ox, oy) = Self::creative_origin(batch);
|
||||
let selected = self.creative_screen.selected;
|
||||
// Only what is drawn of the tabs: their stacks stay where they are.
|
||||
let tabs = self.creative_screen.tab_headers();
|
||||
let tab = &tabs[selected.min(tabs.len() - 1)];
|
||||
let tab_sprite = |t: &horizon_client::creative::Tab, on: bool| {
|
||||
format!(
|
||||
"gui/sprites/container/creative_inventory/tab_{}_{}_{}",
|
||||
if t.top { "top" } else { "bottom" },
|
||||
if on { "selected" } else { "unselected" },
|
||||
t.column + 1
|
||||
)
|
||||
};
|
||||
self.render_transparent_background("creative", batch);
|
||||
// Unselected tabs behind the panel, then the panel, then the selected tab.
|
||||
for (i, t) in tabs.iter().enumerate() {
|
||||
if i != selected && self.creative_screen.on_page(t) {
|
||||
let (tx, ty) = Creative::tab_position(t);
|
||||
batch.sprite(&self.gui.atlas, &tab_sprite(t, false), ox + tx, oy + ty);
|
||||
let mouse = (self.cursor.0 - ox, self.cursor.1 - oy);
|
||||
if Creative::tab_wants_hand(t, false, mouse) {
|
||||
horizon_client::widgets::request_cursor(
|
||||
horizon_client::widgets::Cursor::PointingHand,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let background = if tab.search {
|
||||
"gui/container/creative_inventory/tab_item_search"
|
||||
} else if tab.inventory {
|
||||
"gui/container/creative_inventory/tab_inventory"
|
||||
} else {
|
||||
"gui/container/creative_inventory/tab_items"
|
||||
};
|
||||
if let Some(mut r) = self.gui.atlas.sprites.get(background).copied() {
|
||||
r.u1 = r.u0 + (r.u1 - r.u0) * Creative::WIDTH / r.w as f32;
|
||||
r.v1 = r.v0 + (r.v1 - r.v0) * Creative::HEIGHT / r.h as f32;
|
||||
batch.rect_uv(ox, oy, Creative::WIDTH, Creative::HEIGHT, r, u32::MAX);
|
||||
}
|
||||
let (tx, ty) = Creative::tab_position(tab);
|
||||
batch.sprite(&self.gui.atlas, &tab_sprite(tab, true), ox + tx, oy + ty);
|
||||
for t in tabs.iter().filter(|t| self.creative_screen.on_page(t)) {
|
||||
let (tx, ty) = Creative::tab_position(t);
|
||||
let stack = ItemStack {
|
||||
item: Arc::from(t.icon.as_str()),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
self.draw_item(
|
||||
batch,
|
||||
&stack,
|
||||
ox + tx + 5.0,
|
||||
oy + ty + if t.top { 9.0 } else { 7.0 },
|
||||
);
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
if !tab.inventory {
|
||||
let title = self.creative_tab_title(tab);
|
||||
batch.text(
|
||||
atlas,
|
||||
&title,
|
||||
ox + 8.0,
|
||||
oy + 6.0,
|
||||
rgba(64, 64, 64, 255),
|
||||
false,
|
||||
);
|
||||
}
|
||||
if tab.search {
|
||||
// CreativeModeInventoryScreen: an unbordered EditBox of 50 at (82, 6).
|
||||
let search = &mut self.creative_screen.search;
|
||||
search.x = ox + 82.0;
|
||||
search.y = oy + 6.0;
|
||||
search.width = 80.0;
|
||||
search.bordered = false;
|
||||
search.focused = true;
|
||||
search.text_color = u32::MAX;
|
||||
search.max_length = clipboard::CREATIVE_SEARCH_CHARS;
|
||||
if horizon_client::widgets::narrating() {
|
||||
search.message = screen_info::tr("itemGroup.search", &[]);
|
||||
}
|
||||
search.draw(batch, atlas, None);
|
||||
}
|
||||
// The hovered stack, and whether it is a grid slot (`CustomCreativeSlot`).
|
||||
let mut hovered: Option<(ItemStack, bool)> = None;
|
||||
// Use the same padded slot bounds as clicks and the other menus, so
|
||||
// highlights and tooltips remain visible between adjacent items.
|
||||
let hovered_slot = self.slot_at(batch, self.cursor.0, self.cursor.1);
|
||||
if tab.inventory {
|
||||
self.draw_player_preview(
|
||||
batch,
|
||||
ox + 73.0,
|
||||
oy + 6.0,
|
||||
ox + 105.0,
|
||||
oy + 49.0,
|
||||
20.0,
|
||||
0.0625,
|
||||
);
|
||||
let trash = Self::creative_trash_hovered(batch, self.cursor);
|
||||
let (tx, ty) = Creative::TRASH_POSITION;
|
||||
if trash {
|
||||
self.draw_slot_highlight(batch, false, ox + tx, oy + ty);
|
||||
}
|
||||
self.draw_hovered_slot_highlight(batch, false, ox, oy);
|
||||
self.draw_inventory_slots(batch, ox, oy);
|
||||
if trash {
|
||||
self.draw_slot_highlight(batch, true, ox + tx, oy + ty);
|
||||
}
|
||||
self.draw_hovered_slot_highlight(batch, true, ox, oy);
|
||||
} else {
|
||||
let items = self.creative_screen.items(&self.pack.names);
|
||||
let first = self.creative_screen.first_row(&self.pack.names);
|
||||
for r in 0..5 {
|
||||
for c in 0..9 {
|
||||
let Some(stack) = items.get((first + r) * 9 + c).filter(|s| s.count > 0) else {
|
||||
continue;
|
||||
};
|
||||
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 18.0 + r as f32 * 18.0);
|
||||
let over = hovered_slot == Some(Creative::DISPLAY + r * 9 + c);
|
||||
if over {
|
||||
self.draw_slot_highlight(batch, false, x, y);
|
||||
}
|
||||
self.draw_item(batch, stack, x, y);
|
||||
if over {
|
||||
self.draw_slot_highlight(batch, true, x, y);
|
||||
hovered = Some((stack.clone(), true));
|
||||
}
|
||||
}
|
||||
}
|
||||
let drag = self.drag_view();
|
||||
for c in 0..9 {
|
||||
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0);
|
||||
let over = hovered_slot == Some(c);
|
||||
if over {
|
||||
self.draw_slot_highlight(batch, false, x, y);
|
||||
}
|
||||
self.draw_slot_item(batch, drag.as_ref(), c, &self.inventory[c], x, y);
|
||||
if over {
|
||||
self.draw_slot_highlight(batch, true, x, y);
|
||||
hovered = self.inventory[c].clone().map(|s| (s, false));
|
||||
}
|
||||
}
|
||||
let scrollable = self.creative_screen.rows(&self.pack.names) > 5;
|
||||
let scroller = if scrollable {
|
||||
"gui/sprites/container/creative_inventory/scroller"
|
||||
} else {
|
||||
"gui/sprites/container/creative_inventory/scroller_disabled"
|
||||
};
|
||||
batch.sprite(
|
||||
&self.gui.atlas,
|
||||
scroller,
|
||||
ox + Creative::SCROLL_X,
|
||||
oy + Creative::SCROLL_Y + self.creative_screen.scroller_offset(),
|
||||
);
|
||||
}
|
||||
let effect_tip = self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32);
|
||||
self.draw_creative_pages(batch, ox, oy);
|
||||
for t in tabs.iter().filter(|t| self.creative_screen.on_page(t)) {
|
||||
let (tx, ty) = Creative::tab_position(t);
|
||||
if self.cursor.0 >= ox + tx
|
||||
&& self.cursor.0 < ox + tx + 26.0
|
||||
&& self.cursor.1 >= oy + ty
|
||||
&& self.cursor.1 < oy + ty + 32.0
|
||||
{
|
||||
self.tooltip(
|
||||
batch,
|
||||
&self.creative_tab_title(t),
|
||||
self.cursor.0,
|
||||
self.cursor.1,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.draw_carried(batch);
|
||||
if tab.inventory {
|
||||
if Self::creative_trash_hovered(batch, self.cursor) {
|
||||
// `CreativeModeInventoryScreen`: the destroy slot's tooltip is
|
||||
// `inventory.binSlot`, not an English literal.
|
||||
let destroy = self.translate("inventory.binSlot", "Destroy Item");
|
||||
self.tooltip(batch, &destroy, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1)
|
||||
&& let Some(stack) = self.slot_view(slot)
|
||||
&& self.tooltip_with_carried(&stack)
|
||||
{
|
||||
let tabs = self.creative_tooltip_tabs(&stack, false);
|
||||
self.draw_stack_tooltip_with(
|
||||
batch,
|
||||
&stack,
|
||||
false,
|
||||
self.cursor.0,
|
||||
self.cursor.1,
|
||||
tabs,
|
||||
);
|
||||
}
|
||||
} else if let Some((item, grid)) = hovered
|
||||
&& self.tooltip_with_carried(&item)
|
||||
{
|
||||
let tabs = self.creative_tooltip_tabs(&item, grid);
|
||||
// A `CustomCreativeSlot`: TooltipFlag.asCreative.
|
||||
self.draw_stack_tooltip_with(batch, &item, grid, self.cursor.0, self.cursor.1, tabs);
|
||||
}
|
||||
if let Some(lines) = effect_tip {
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
}
|
||||
pub(super) fn creative_trash_hovered(batch: &HudBatch, cursor: (f32, f32)) -> bool {
|
||||
let (ox, oy) = Self::creative_origin(batch);
|
||||
let (x, y) = Creative::TRASH_POSITION;
|
||||
cursor.0 >= ox + x
|
||||
&& cursor.0 < ox + x + 16.0
|
||||
&& cursor.1 >= oy + y
|
||||
&& cursor.1 < oy + y + 16.0
|
||||
}
|
||||
/// NeoForge's creative page buttons, 20 square, 50 above the screen at
|
||||
/// its left and right edges: -1 or 1 for the one at `cursor`, when there
|
||||
/// is more than one page.
|
||||
pub(super) fn creative_page_button(
|
||||
ox: f32,
|
||||
oy: f32,
|
||||
cursor: (f32, f32),
|
||||
pages: u32,
|
||||
) -> Option<i64> {
|
||||
if pages <= 1 {
|
||||
return None;
|
||||
}
|
||||
let y = oy - 50.0;
|
||||
let inside =
|
||||
|x: f32| cursor.0 >= x && cursor.0 < x + 20.0 && cursor.1 >= y && cursor.1 < y + 20.0;
|
||||
if inside(ox) {
|
||||
Some(-1)
|
||||
} else if inside(ox + Creative::WIDTH - 20.0) {
|
||||
Some(1)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// The page buttons and "page / pages" between them.
|
||||
pub(super) fn draw_creative_pages(&self, batch: &mut HudBatch, ox: f32, oy: f32) {
|
||||
let pages = self.creative_screen.pages();
|
||||
if pages <= 1 {
|
||||
return;
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
let y = oy - 50.0;
|
||||
for (x, label, step) in [(ox, "<", -1), (ox + Creative::WIDTH - 20.0, ">", 1)] {
|
||||
let hover = Self::creative_page_button(ox, oy, self.cursor, pages) == Some(step);
|
||||
let page = self.creative_screen.page as i64 + step;
|
||||
let active = (0..pages as i64).contains(&page);
|
||||
let sprite = match (active, hover) {
|
||||
(false, _) => "gui/sprites/widget/button_disabled",
|
||||
(true, true) => "gui/sprites/widget/button_highlighted",
|
||||
(true, false) => "gui/sprites/widget/button",
|
||||
};
|
||||
horizon_client::ui::blit_sprite(batch, atlas, sprite, x, y, 20.0, 20.0, u32::MAX);
|
||||
let color = if active {
|
||||
u32::MAX
|
||||
} else {
|
||||
rgba(160, 160, 160, 255)
|
||||
};
|
||||
let w = HudBatch::text_width(atlas, label);
|
||||
batch.text(atlas, label, x + (20.0 - w) / 2.0, y + 6.0, color, true);
|
||||
}
|
||||
let text = format!("{} / {}", self.creative_screen.page + 1, pages);
|
||||
let w = HudBatch::text_width(atlas, &text);
|
||||
batch.text(
|
||||
atlas,
|
||||
&text,
|
||||
ox + (Creative::WIDTH - w) / 2.0,
|
||||
oy - 44.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
pub(super) fn click_creative(&mut self, right: bool) {
|
||||
if self.mod_browser_press(right) {
|
||||
return;
|
||||
}
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| [r.config.width, r.config.height])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let batch = HudBatch::new(size);
|
||||
let (ox, oy) = Self::creative_origin(&batch);
|
||||
let (cx, cy) = self.cursor;
|
||||
// NeoForge's page buttons, when mods add tabs.
|
||||
if let Some(step) =
|
||||
Self::creative_page_button(ox, oy, (cx, cy), self.creative_screen.pages())
|
||||
{
|
||||
// Page buttons are outside the inventory panel; their release must
|
||||
// not throw a carried stack, including when the button is disabled.
|
||||
self.inv_clicks.skip_release();
|
||||
let page = self.creative_screen.page as i64 + step;
|
||||
if (0..self.creative_screen.pages() as i64).contains(&page) {
|
||||
self.audio.play_button_click();
|
||||
self.creative_screen.set_page(page as u32);
|
||||
self.refresh_saved_hotbars();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let tabs = self.creative_screen.tabs.clone();
|
||||
for (i, t) in tabs.iter().enumerate() {
|
||||
if !self.creative_screen.on_page(t) {
|
||||
continue;
|
||||
}
|
||||
let (tx, ty) = Creative::tab_position(t);
|
||||
if cx >= ox + tx && cx < ox + tx + 26.0 && cy >= oy + ty && cy < oy + ty + 32.0 {
|
||||
select_creative_tab(&mut self.creative_screen, &mut self.inv_clicks, i);
|
||||
self.refresh_saved_hotbars();
|
||||
self.inv_drag = None;
|
||||
self.last_inv_click = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self.creative_inventory_open() {
|
||||
if Self::creative_trash_hovered(&batch, self.cursor) {
|
||||
if self.has_shift_down() {
|
||||
let before = self.player_equipment();
|
||||
self.inventory.fill(None);
|
||||
self.armor.fill(None);
|
||||
self.offhand = None;
|
||||
self.craft_grid[..4].fill(None);
|
||||
self.player_equipment_changed(&before);
|
||||
self.apply_equipment();
|
||||
} else {
|
||||
self.carried = None;
|
||||
}
|
||||
self.inv_drag = None;
|
||||
self.inv_clicks = Default::default();
|
||||
} else if self.slot_at(&batch, cx, cy).is_some() || self.clicked_outside(&batch) {
|
||||
self.inventory_press(right);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !right && self.creative_search_click(ox, oy) {
|
||||
return;
|
||||
}
|
||||
// Holding the scrollbar scrolls as the mouse moves (mouseDragged).
|
||||
if !right
|
||||
&& self
|
||||
.creative_screen
|
||||
.press_scrollbar(cx - ox, cy - oy, &self.pack.names)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.slot_at(&batch, cx, cy).is_some() || self.clicked_outside(&batch) {
|
||||
self.inventory_press(right);
|
||||
}
|
||||
}
|
||||
/// A click on the search tab's box (`EditBox.onClick`).
|
||||
pub(super) fn creative_search_click(&mut self, ox: f32, oy: f32) -> bool {
|
||||
if !self.creative_screen.tab().search {
|
||||
return false;
|
||||
}
|
||||
let (x, y, w, h) = (ox + 82.0, oy + 6.0, 80.0, 9.0);
|
||||
let (mx, my) = self.cursor;
|
||||
if mx < x || my < y || mx >= x + w || my >= y + h {
|
||||
return false;
|
||||
}
|
||||
let shift = self.has_shift_down();
|
||||
let search = &mut self.creative_screen.search;
|
||||
search.x = x;
|
||||
search.y = y;
|
||||
search.width = w;
|
||||
search.click(&self.gui.atlas, mx, shift, false);
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! `Entity.applyEffectsFromBlocks` runs in the entity's own tick, and
|
||||
//! `ServerLevel.tick` ticks only the entities in entity-ticking chunks: a mob
|
||||
//! past the simulation distance presses no plate, however long it stands on it.
|
||||
|
||||
use super::Game;
|
||||
use super::native_machines::tests::server_game;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_client::mobs::Mob;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_mob_presses_a_plate_only_inside_the_simulation_distance() {
|
||||
let mut game = server_game();
|
||||
game.settings.simulation_distance = 4;
|
||||
game.settings.render_distance = 12;
|
||||
let registry = game.pack.assets.registry;
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
let plate = registry
|
||||
.default_state("minecraft:stone_pressure_plate")
|
||||
.unwrap();
|
||||
let zombie = game
|
||||
.res
|
||||
.mob_types()
|
||||
.get("minecraft:zombie")
|
||||
.unwrap()
|
||||
.clone();
|
||||
// The player in chunk (0, 0); a zombie on a plate in chunk (2, 0), inside
|
||||
// the simulation distance, and another in chunk (8, 0): loaded and in
|
||||
// view, but past it.
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 65.0, 8.5)).unwrap(),
|
||||
);
|
||||
for (cx, x, id) in [(2, 40, 1), (8, 136, 2)] {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((cx, 0), Arc::new(Chunk::empty(cx, 0)));
|
||||
game.world.set(x, 63, 8, stone).unwrap();
|
||||
game.world.set(x, 64, 8, plate).unwrap();
|
||||
game.mobs.push(Mob::new(
|
||||
id,
|
||||
zombie.clone(),
|
||||
Vec3::new(f64::from(x) + 0.5, 64.0, 8.5),
|
||||
0.0,
|
||||
));
|
||||
}
|
||||
game.update_chunk_tickets();
|
||||
let ticking = game.entity_ticking_area();
|
||||
assert!(ticking(40, 8), "chunk (2, 0) ticks entities");
|
||||
assert!(
|
||||
!ticking(136, 8),
|
||||
"chunk (8, 0) is past the simulation distance"
|
||||
);
|
||||
for _ in 0..3 {
|
||||
game.tick_blocks().unwrap();
|
||||
}
|
||||
let powered = |game: &Game<'_>, x: i32| {
|
||||
let state = game.world.block(x, 64, 8);
|
||||
registry
|
||||
.state(state)
|
||||
.unwrap()
|
||||
.properties
|
||||
.get("powered")
|
||||
.cloned()
|
||||
};
|
||||
assert_eq!(powered(&game, 40).as_deref(), Some("true"));
|
||||
assert_eq!(powered(&game, 136).as_deref(), Some("false"));
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
//! Keys and mouse buttons over an open container screen: hotbar and pick keys, modifier keys,
|
||||
//! drags, throws and the press/release of a slot click.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `CreativeModeInventoryScreen.keyPressed` on the search tab: a hotbar
|
||||
/// key over a slot swaps before the search box sees it.
|
||||
pub(super) fn search_tab_hotbar_key(&mut self, code: KeyCode) -> bool {
|
||||
use horizon_client::creative::{Hover, search_key_swaps};
|
||||
let numeric = HOTBAR_DIGITS.contains(&code) || NUMERIC_KEYS.contains(&code);
|
||||
if !numeric {
|
||||
return false;
|
||||
}
|
||||
let Some(key) = self.settings.keys.container_hotbar_key(code) else {
|
||||
return false;
|
||||
};
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| [r.config.width, r.config.height])
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let batch = HudBatch::new(size);
|
||||
let hover = match self.slot_at(&batch, self.cursor.0, self.cursor.1) {
|
||||
None => Hover::Nothing,
|
||||
Some(slot) if slot >= Creative::DISPLAY => {
|
||||
let first = self.creative_screen.first_row(&self.pack.names);
|
||||
Hover::Display {
|
||||
filled: self
|
||||
.creative_screen
|
||||
.shown(&self.pack.names, first * 9 + slot - Creative::DISPLAY)
|
||||
.is_some(),
|
||||
}
|
||||
}
|
||||
Some(_) => Hover::Slot,
|
||||
};
|
||||
search_key_swaps(numeric, true, hover, self.carried.is_some()) && self.inventory_hotkey(key)
|
||||
}
|
||||
/// Number keys, F and Q over a slot of the open menu: SWAP with a hotbar
|
||||
/// slot or the off hand, THROW one or the whole stack.
|
||||
pub(super) fn inventory_hotkey(&mut self, key: KeyCode) -> bool {
|
||||
use horizon_client::menu::Input;
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| [r.config.width, r.config.height])
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let batch = HudBatch::new(size);
|
||||
let Some(slot) = self.slot_at(&batch, self.cursor.0, self.cursor.1) else {
|
||||
return false;
|
||||
};
|
||||
let digit = HOTBAR_DIGITS.iter().position(|k| *k == key);
|
||||
let done = match (digit, key) {
|
||||
(Some(n), _) => self.menu_click(Some(slot), n as i32, Input::Swap),
|
||||
(None, KeyCode::KeyF) => self.menu_click(Some(slot), 40, Input::Swap),
|
||||
(None, KeyCode::KeyQ) => {
|
||||
let all = drop_whole_stack(self.modifiers);
|
||||
self.menu_click(Some(slot), i32::from(all), Input::Throw)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if done && let Some(key) = self.container {
|
||||
self.container_changed(key);
|
||||
}
|
||||
done
|
||||
}
|
||||
/// The pick block key bound to a keyboard key, over a slot of the open
|
||||
/// menu (`AbstractContainerScreen.keyPressed`): `CLONE` with button 0. The
|
||||
/// search tab's box takes the key instead.
|
||||
pub(super) fn inventory_pick_key(&mut self) {
|
||||
let menu = (self.screen == Screen::Inventory
|
||||
|| (self.screen == Screen::Creative && !self.creative_screen.tab().search))
|
||||
&& !self.dialog_open();
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| [r.config.width, r.config.height])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let batch = HudBatch::new(size);
|
||||
let Some(slot) = self
|
||||
.slot_at(&batch, self.cursor.0, self.cursor.1)
|
||||
.filter(|_| menu)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.menu_click(Some(slot), 0, horizon_client::menu::Input::Clone)
|
||||
&& let Some(key) = self.container
|
||||
{
|
||||
self.container_changed(key);
|
||||
}
|
||||
}
|
||||
/// The plain inventory mouse press: starts a quick craft drag when a
|
||||
/// stack is held, gathers on a double click, else the normal click.
|
||||
pub(super) fn inventory_press(&mut self, right: bool) {
|
||||
use horizon_client::container_screen::Target;
|
||||
use horizon_client::menu::Input;
|
||||
if self.mod_browser_press(right) {
|
||||
return;
|
||||
}
|
||||
if !right && self.sort_press() {
|
||||
return;
|
||||
}
|
||||
if self.native_screen_press(right) {
|
||||
self.inv_clicks.skip_release();
|
||||
return;
|
||||
}
|
||||
// AbstractRecipeBookScreen lets the book handle its buttons before
|
||||
// AbstractContainerScreen starts a drag or chooses an outside slot.
|
||||
// Toggling it can move the inventory under the pointer before release.
|
||||
if self.recipe_book_click(right) {
|
||||
self.inv_clicks.skip_release();
|
||||
return;
|
||||
}
|
||||
let (slot, outside) = self.inventory_target();
|
||||
let shift = self.has_shift_down();
|
||||
let target = Target::of(slot, outside);
|
||||
let input = self.inv_clicks.press(
|
||||
&target,
|
||||
Self::screen_button(right),
|
||||
self.carried.is_none(),
|
||||
shift,
|
||||
false,
|
||||
);
|
||||
if outside {
|
||||
// slotId -999: an empty hand throws nothing; a carried stack
|
||||
// starts a drag whose release drops it.
|
||||
match input {
|
||||
Some(input) => self.throw_click(right, input),
|
||||
None if self.inv_clicks.quick_crafting().is_some() => {
|
||||
self.inv_drag = Some((i32::from(right), Vec::new()));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.tweak_press(slot, right, shift) {
|
||||
return;
|
||||
}
|
||||
if let Some(slot) = slot
|
||||
&& shift
|
||||
&& slot != 200
|
||||
{
|
||||
let now = std::time::Instant::now();
|
||||
let double = self.last_inv_click.is_some_and(|(at, last, was_right)| {
|
||||
last == slot && was_right == right && now.duration_since(at).as_millis() < 250
|
||||
});
|
||||
self.last_inv_click = Some((now, slot, right));
|
||||
// mouseClicked's QUICK_MOVE (and Horizon's at-press QUICK_MOVE
|
||||
// of a carried stack, mouseReleased's in vanilla) remember the
|
||||
// stack it moves.
|
||||
if input == Some(Input::QuickMove) || (self.carried.is_some() && !double) {
|
||||
self.last_quick_moved = self.slot_view(slot);
|
||||
}
|
||||
if double && !right {
|
||||
// mouseReleased: every stack of lastQuickMoved's item in the
|
||||
// clicked slot's container is quick-moved.
|
||||
if let Some(input) = input
|
||||
&& !self.menu_click(Some(slot), 0, input)
|
||||
{
|
||||
// A screen the menu engine doesn't model: its own click.
|
||||
self.click_inventory(right);
|
||||
return;
|
||||
}
|
||||
if let Some(moved) = self.last_quick_moved.clone() {
|
||||
// The creative screen clicks its own menu, on a server too.
|
||||
if self.remote.is_some() && self.screen != Screen::Creative {
|
||||
self.remote_quick_move_all(slot, &moved);
|
||||
} else {
|
||||
self.menu_session(|menu, env, to_menu| {
|
||||
Some(menu.quick_move_all(env, to_menu(slot)?, &moved).is_ok())
|
||||
});
|
||||
if self.remote.is_some() {
|
||||
self.send_creative_slots();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_inv_click = None;
|
||||
self.inv_clicks.skip_release();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(slot) = slot
|
||||
&& slot != 200
|
||||
{
|
||||
// An item tab's display slots take no drags or PICKUP_ALL.
|
||||
let display = slot >= Creative::DISPLAY;
|
||||
let now = std::time::Instant::now();
|
||||
let double = !shift
|
||||
&& !right
|
||||
&& !display
|
||||
&& self.carried.is_some()
|
||||
&& self.last_inv_click.is_some_and(|(at, last, was_right)| {
|
||||
last == slot && !was_right && now.duration_since(at).as_millis() < 250
|
||||
});
|
||||
self.last_inv_click = Some((now, slot, right));
|
||||
if double && self.menu_click(Some(slot), 0, Input::PickupAll) {
|
||||
self.last_inv_click = None;
|
||||
// The double click's own release (`mouseReleased`'s PICKUP_ALL).
|
||||
self.inv_clicks.skip_release();
|
||||
return;
|
||||
}
|
||||
if self.carried.is_some() {
|
||||
let ty = i32::from(right);
|
||||
// `mouseClicked` starts a quick craft drag whenever the hand holds a stack,
|
||||
// Shift or not: `mouseDragged` fills the slots the cursor crosses, so a
|
||||
// Shift held over the drag spreads the same way (#4166). The drag starts
|
||||
// empty on a Shift press: a release with nothing spread is `mouseReleased`'s
|
||||
// click on the hovered slot (Pickup, or QuickMove under Shift).
|
||||
if !shift && !display && self.quick_craft_accepts(slot, ty, 0) {
|
||||
self.inv_drag = Some((ty, vec![slot]));
|
||||
} else {
|
||||
self.inv_drag = Some((ty, Vec::new()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self.creative_tab_open() {
|
||||
// CreativeModeInventoryScreen goes through the same mouseClicked:
|
||||
// a click now, or a drag that its release ends.
|
||||
match input {
|
||||
Some(input) => {
|
||||
self.menu_click(slot, i32::from(right), input);
|
||||
}
|
||||
None if self.inv_clicks.quick_crafting().is_some() => {
|
||||
self.inv_drag = Some((i32::from(right), Vec::new()));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// mouseClicked: an empty hand's PICKUP is the menu's click at once
|
||||
// (a right click on a bundle takes its first stack out).
|
||||
if let (Some(input), Some(_)) = (input, slot)
|
||||
&& self.remote.is_none()
|
||||
&& self.menu_click(slot, i32::from(right), input)
|
||||
{
|
||||
if let Some(key) = self.container {
|
||||
self.container_changed(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.click_inventory(right);
|
||||
}
|
||||
/// Mouse movement while a quick craft drag is running.
|
||||
pub(super) fn inventory_drag_move(&mut self) {
|
||||
if self.tweaks_on() && self.tweak_drag_move(self.inventory_target().0) {
|
||||
return;
|
||||
}
|
||||
let Some((ty, slots)) = self.inv_drag.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(r) = self.renderer.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let batch = HudBatch::new([r.config.width, r.config.height]);
|
||||
if let Some(slot) = self.slot_at(&batch, self.cursor.0, self.cursor.1)
|
||||
&& slot != 200
|
||||
&& slot < Creative::DISPLAY
|
||||
&& !slots.contains(&slot)
|
||||
&& self.quick_craft_accepts(slot, ty, slots.len())
|
||||
&& let Some((_, slots)) = self.inv_drag.as_mut()
|
||||
{
|
||||
slots.push(slot);
|
||||
}
|
||||
}
|
||||
/// The hovered slot and whether the cursor is outside the window.
|
||||
pub(super) fn inventory_target(&self) -> (Option<usize>, bool) {
|
||||
let Some(r) = self.renderer.as_ref() else {
|
||||
return (None, false);
|
||||
};
|
||||
let batch = HudBatch::new([r.config.width, r.config.height]);
|
||||
(
|
||||
self.slot_at(&batch, self.cursor.0, self.cursor.1),
|
||||
self.clicked_outside(&batch),
|
||||
)
|
||||
}
|
||||
pub(super) fn screen_button(right: bool) -> horizon_client::container_screen::Button {
|
||||
use horizon_client::container_screen::Button;
|
||||
if right { Button::Right } else { Button::Left }
|
||||
}
|
||||
/// `Screen.hasShiftDown` / `InputWithModifiers.hasShiftDown`: 26.3 keeps
|
||||
/// no key cache — every event answers with its own modifiers bit
|
||||
/// (`InputWithModifiers.java:54-56`, `keys = 3`), and the pointer and
|
||||
/// keyboard events carry the OS's live word, refreshed at the focus. The
|
||||
/// window's `ModifiersChanged` is that word here; key releases can die
|
||||
/// on the way from the OS (a stuck Shift then kept every screen's Shift
|
||||
/// and a sneak the key no longer held: #4166's in-game report), so a
|
||||
/// cached key set is not vanilla's answer.
|
||||
pub(super) fn has_shift_down(&self) -> bool {
|
||||
self.modifiers.shift_key()
|
||||
}
|
||||
/// The same live word for Control (`InputWithModifiers.hasControlDown`).
|
||||
pub(super) fn has_control_down(&self) -> bool {
|
||||
self.modifiers.control_key()
|
||||
}
|
||||
/// The same live word for Alt (`InputWithModifiers.hasAltDown`).
|
||||
pub(super) fn has_alt_down(&self) -> bool {
|
||||
self.modifiers.alt_key()
|
||||
}
|
||||
/// Real events word the modifiers by their own `ModifiersChanged`; a
|
||||
/// control socket's `key` is synthesized, so it words the bit directly
|
||||
/// and every reader can ask the one live state.
|
||||
pub(super) fn press_modifier_key(&mut self, key: KeyCode, down: bool) {
|
||||
horizon_client_input::modifiers::set_key(
|
||||
&mut self.modifiers,
|
||||
&mut self.physical_keys,
|
||||
key,
|
||||
down,
|
||||
);
|
||||
}
|
||||
/// `hasClickedOutside` of the open screen at the cursor.
|
||||
pub(super) fn clicked_outside(&self, batch: &HudBatch) -> bool {
|
||||
use horizon_client::container_screen::{Book, clicked_outside};
|
||||
let image = self.container_image(batch);
|
||||
let book = self
|
||||
.recipe_book_area(batch)
|
||||
.map(|(visible, tab_hovered)| Book {
|
||||
visible,
|
||||
selected_tab_hovered: tab_hovered,
|
||||
});
|
||||
clicked_outside(
|
||||
f64::from(self.cursor.0),
|
||||
f64::from(self.cursor.1),
|
||||
image,
|
||||
book,
|
||||
)
|
||||
}
|
||||
/// The open container screen's window: its place and size.
|
||||
pub(super) fn container_image(
|
||||
&self,
|
||||
batch: &HudBatch,
|
||||
) -> horizon_client::container_screen::Image {
|
||||
let (ox, oy) = self.inventory_origin(batch);
|
||||
let (shift, width, height) = if self.screen == Screen::Creative {
|
||||
(0.0, Creative::WIDTH as i32, Creative::HEIGHT as i32)
|
||||
} else {
|
||||
let (shift, width) = match self.station.as_ref().map(|s| s.kind) {
|
||||
Some(stations::Kind::Merchant) => (-50.0, 276),
|
||||
Some(stations::Kind::Beacon) => (beacon::SHIFT, 230),
|
||||
_ => (0.0, self.native_screen_width().map_or(176, |w| w as i32)),
|
||||
};
|
||||
(shift, width, self.screen_height() as i32)
|
||||
};
|
||||
horizon_client::container_screen::Image {
|
||||
left: (ox + shift) as i32,
|
||||
top: oy as i32,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
/// A click outside the window (slot id -999): `THROW` does nothing; a
|
||||
/// `PICKUP` drops the carried stack (left) or one of it (right), thrown
|
||||
/// from the hand (`AbstractContainerMenu.doClick`, `LivingEntity.drop`).
|
||||
pub(super) fn throw_click(&mut self, right: bool, input: horizon_client::menu::Input) {
|
||||
use horizon_client::menu::Input;
|
||||
let button = i32::from(right);
|
||||
if self.remote.is_some() {
|
||||
if self.screen == Screen::Creative {
|
||||
self.remote_creative_drop(right);
|
||||
} else {
|
||||
self.remote_throw(right, input);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let before = self.carried.clone();
|
||||
if !self.menu_click(None, button, input) && input == Input::Pickup {
|
||||
// A screen the menu engine doesn't model: the same doClick.
|
||||
if let Some(c) = self.carried.as_mut() {
|
||||
let thrown = if right {
|
||||
let one = ItemStack {
|
||||
count: 1,
|
||||
..c.clone()
|
||||
};
|
||||
c.count -= 1;
|
||||
if c.count == 0 {
|
||||
self.carried = None;
|
||||
}
|
||||
one
|
||||
} else {
|
||||
self.carried.take().expect("some")
|
||||
};
|
||||
self.player_drop(thrown);
|
||||
}
|
||||
}
|
||||
if self.carried != before {
|
||||
self.swing_arm();
|
||||
}
|
||||
if let Some(key) = self.container {
|
||||
self.container_changed(key);
|
||||
}
|
||||
}
|
||||
/// Mouse release over an inventory screen: `mouseReleased` ends or
|
||||
/// cancels the drag, drops a stack released outside, or clicks.
|
||||
pub(super) fn inventory_mouse_release(&mut self, right: bool) {
|
||||
use horizon_client::container_screen::{Release, Target};
|
||||
self.tweak_release();
|
||||
let (slot, outside) = self.inventory_target();
|
||||
let dragged = self.inv_drag.as_ref().is_some_and(|(_, s)| !s.is_empty());
|
||||
let release = self.inv_clicks.release(
|
||||
&Target::of(slot, outside),
|
||||
Self::screen_button(right),
|
||||
self.carried.is_none(),
|
||||
dragged,
|
||||
self.has_shift_down(),
|
||||
false,
|
||||
);
|
||||
match (release, Target::of(slot, outside)) {
|
||||
(Release::QuickCraft, _) => self.inventory_release(),
|
||||
(Release::Click(input), Target::Outside) => {
|
||||
self.inv_drag = None;
|
||||
self.throw_click(right, input);
|
||||
}
|
||||
(Release::Click(input), Target::Slot(slot)) if self.inv_drag.is_some() => {
|
||||
// The drag never reached a slot: a plain click where it ends.
|
||||
self.inv_drag = None;
|
||||
if !self.menu_click(Some(slot), i32::from(right), input) {
|
||||
self.click_inventory(right);
|
||||
}
|
||||
if let Some(key) = self.container {
|
||||
self.container_changed(key);
|
||||
}
|
||||
}
|
||||
_ => self.inv_drag = None,
|
||||
}
|
||||
}
|
||||
/// A press of the pick block button (`keyPickItem`, the middle button by
|
||||
/// default) over an inventory screen: `mouseClicked` with `cloning`
|
||||
/// (infinite materials). An empty hand clones the hovered stack at its
|
||||
/// full size (`CLONE`), a carried stack starts a quick craft drag of type
|
||||
/// 2 that fills every slot it crosses; without infinite materials the
|
||||
/// press does nothing.
|
||||
pub(super) fn inventory_pick_press(&mut self) {
|
||||
use horizon_client::container_screen::{Button, Target};
|
||||
let (slot, outside) = self.inventory_target();
|
||||
let was_crafting = self.inv_clicks.quick_crafting().is_some();
|
||||
let input = self.inv_clicks.press(
|
||||
&Target::of(slot, outside),
|
||||
Button::Middle,
|
||||
self.carried.is_none(),
|
||||
self.has_shift_down(),
|
||||
self.creative,
|
||||
);
|
||||
match (input, slot) {
|
||||
// Slot -999 (outside the window) clones nothing.
|
||||
(Some(input), Some(slot)) if !outside => {
|
||||
self.menu_click(Some(slot), 2, input);
|
||||
}
|
||||
(None, _) if !was_crafting && self.inv_clicks.quick_crafting().is_some() => {
|
||||
// Item tab display slots take no drags.
|
||||
let first = slot.filter(|&s| {
|
||||
s != 200 && s < Creative::DISPLAY && self.quick_craft_accepts(s, 2, 0)
|
||||
});
|
||||
self.inv_drag = Some((2, first.into_iter().collect()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// The pick block button's release (`mouseReleased`): ends its quick
|
||||
/// craft drag, cancels another button's, or clones onto the slot under a
|
||||
/// carried stack (`CLONE`, which only an item tab's display slot takes).
|
||||
pub(super) fn inventory_pick_release(&mut self) {
|
||||
use horizon_client::container_screen::{Button, Release, Target};
|
||||
let (slot, outside) = self.inventory_target();
|
||||
let dragged = self.inv_drag.as_ref().is_some_and(|(_, s)| !s.is_empty());
|
||||
let release = self.inv_clicks.release(
|
||||
&Target::of(slot, outside),
|
||||
Button::Middle,
|
||||
self.carried.is_none(),
|
||||
dragged,
|
||||
self.has_shift_down(),
|
||||
true,
|
||||
);
|
||||
match (release, slot) {
|
||||
(Release::QuickCraft, _) => self.inventory_release(),
|
||||
(Release::Click(input), Some(slot)) if !outside => {
|
||||
self.inv_drag = None;
|
||||
self.menu_click(Some(slot), 2, input);
|
||||
}
|
||||
_ => self.inv_drag = None,
|
||||
}
|
||||
}
|
||||
/// Mouse release: finishes a quick craft drag (one slot is a plain click).
|
||||
pub(super) fn inventory_release(&mut self) {
|
||||
use horizon_client::menu::Input;
|
||||
let Some((ty, slots)) = self.inv_drag.take() else {
|
||||
return;
|
||||
};
|
||||
if slots.len() == 1 {
|
||||
if !self.menu_click(Some(slots[0]), ty, Input::Pickup) {
|
||||
self.click_inventory(ty == 1);
|
||||
}
|
||||
} else {
|
||||
let mask = |header: i32| header | (ty << 2);
|
||||
let mut ops = vec![(None, mask(0), Input::QuickCraft)];
|
||||
ops.extend(
|
||||
slots
|
||||
.into_iter()
|
||||
.map(|slot| (Some(slot), mask(1), Input::QuickCraft)),
|
||||
);
|
||||
ops.push((None, mask(2), Input::QuickCraft));
|
||||
if !self.menu_ops(&ops) {
|
||||
// Not a menu the engine models: the drag is a plain click.
|
||||
self.click_inventory(ty == 1);
|
||||
}
|
||||
}
|
||||
if let Some(key) = self.container {
|
||||
self.container_changed(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Container screen slots: item and decoration drawing, the carried stack and quick craft drag,
|
||||
//! slot positions and hit tests, highlights, and the crafting result slot.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn draw_item(&self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) {
|
||||
// `GuiGraphics.renderItem`: the resolved item model (tints, foil and
|
||||
// special renderers included), then the decorations.
|
||||
self.draw_item_model(batch, stack, x, y);
|
||||
self.draw_item_decorations(batch, stack, x, y, None);
|
||||
}
|
||||
/// `extractSlot` of slot `slot` holding `stack` at (x, y): during a quick
|
||||
/// craft drag a dragged slot shows what it would hold over a white
|
||||
/// highlight (its count yellow when the slot caps it), and hides while
|
||||
/// it is the drag's only slot.
|
||||
pub(super) fn draw_slot_item(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
drag: Option<&DragView>,
|
||||
slot: usize,
|
||||
stack: &Option<ItemStack>,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
let carried;
|
||||
let (stack, capped) = match drag.and_then(|d| d.slots.get(&slot)) {
|
||||
Some(None) => return,
|
||||
Some(Some((count, capped))) => {
|
||||
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
|
||||
carried = self
|
||||
.carried
|
||||
.clone()
|
||||
.map(|c| ItemStack { count: *count, ..c });
|
||||
(carried.as_ref(), *capped)
|
||||
}
|
||||
None => (stack.as_ref(), false),
|
||||
};
|
||||
if let Some(stack) = stack {
|
||||
self.draw_item_model(batch, stack, x, y);
|
||||
let text = capped.then(|| format!("\u{a7}e{}", stack.count));
|
||||
self.draw_item_decorations(batch, stack, x, y, text);
|
||||
}
|
||||
}
|
||||
/// `extractCarriedItem`: the carried stack at the cursor, showing what a
|
||||
/// quick craft drag over several slots leaves in hand.
|
||||
pub(super) fn draw_carried(&mut self, batch: &mut HudBatch) {
|
||||
let Some(mut stack) = self.carried.clone() else {
|
||||
return;
|
||||
};
|
||||
if let Some(left) = self.drag_view().and_then(|d| d.left) {
|
||||
if left == 0 {
|
||||
return;
|
||||
}
|
||||
stack.count = left;
|
||||
}
|
||||
self.draw_item(batch, &stack, self.cursor.0 - 8.0, self.cursor.1 - 8.0);
|
||||
}
|
||||
/// The quick craft drag in progress, as the menu engine previews it.
|
||||
pub(super) fn drag_view(&mut self) -> Option<DragView> {
|
||||
let (ty, slots) = self.inv_drag.clone()?;
|
||||
let mut view = None;
|
||||
self.menu_session(|menu, env, to_menu| {
|
||||
let menu_slots: Option<Vec<usize>> = slots.iter().map(|&g| to_menu(g)).collect();
|
||||
let shown = match menu.quick_craft_preview(env, ty, &menu_slots?) {
|
||||
Some((shown, left)) => {
|
||||
let slots = slots.iter().copied().zip(shown).collect();
|
||||
DragView {
|
||||
slots,
|
||||
left: Some(left),
|
||||
}
|
||||
}
|
||||
// A single dragged slot is hidden.
|
||||
None => DragView {
|
||||
slots: slots.iter().map(|&g| (g, None)).collect(),
|
||||
left: None,
|
||||
},
|
||||
};
|
||||
view = Some(shown);
|
||||
Some(false)
|
||||
});
|
||||
view
|
||||
}
|
||||
/// Whether a quick craft drag takes `slot`
|
||||
/// (`shouldAddSlotToQuickCraft`); screens the menu engine doesn't
|
||||
/// model take every slot.
|
||||
pub(super) fn quick_craft_accepts(&mut self, slot: usize, ty: i32, dragged: usize) -> bool {
|
||||
let mut accepts = true;
|
||||
self.menu_session(|menu, env, to_menu| {
|
||||
accepts = to_menu(slot).is_some_and(|s| menu.quick_craft_accepts(env, s, ty, dragged));
|
||||
Some(false)
|
||||
});
|
||||
accepts
|
||||
}
|
||||
/// `GuiGraphicsExtractor.itemDecorations`: the count (or `count_text`)
|
||||
/// and damage bar.
|
||||
pub(super) fn draw_item_decorations(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
stack: &ItemStack,
|
||||
x: f32,
|
||||
y: f32,
|
||||
count_text: Option<String>,
|
||||
) {
|
||||
// GuiGraphicsExtractor.itemDecorations, laid out at the origin and
|
||||
// moved to (x, y): the damage bar, the cooldown shade and the count.
|
||||
let info = self.items().get(&stack.item);
|
||||
// ItemStack.isBarVisible: isDamaged, which an unbreakable stack never is.
|
||||
let max_damage = if horizon_client::stations::is_damageable(self.items(), stack) {
|
||||
info.map_or(0, |info| info.max_damage as i32)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let cooldown =
|
||||
horizon_client::cooldowns::group(stack, info.and_then(|i| i.use_cooldown.as_ref()));
|
||||
let partial = self.clock.fraction() as f32;
|
||||
// BundleItem overrides the bar with its fullness.
|
||||
let mut bar = horizon_client::bundle::Contents::of(self.items(), stack)
|
||||
.map(|c| c.bar())
|
||||
.unwrap_or_else(|| hud_overlays::damage_bar(stack.damage as i32, max_damage));
|
||||
// Client-only: a mod may replace or hide the bar vanilla just chose.
|
||||
self.native_item_bar(stack, &mut bar);
|
||||
let decorations = hud_overlays::ItemDecorations {
|
||||
count: stack.count,
|
||||
count_text,
|
||||
bar,
|
||||
cooldown: self.cooldowns.percent(&cooldown, partial),
|
||||
};
|
||||
let mut draws = vec![hud_layout::GuiDraw::Push(vec![
|
||||
hud_layout::PoseOp::Translate(x, y),
|
||||
])];
|
||||
draws.extend(hud_overlays::item_decorations(
|
||||
0,
|
||||
0,
|
||||
&decorations,
|
||||
&self.gui.atlas.font,
|
||||
));
|
||||
draws.push(hud_layout::GuiDraw::Pop);
|
||||
hud_layout::paint(batch, &self.gui.atlas, &draws);
|
||||
self.native_item_decorations(stack, x, y, batch);
|
||||
}
|
||||
|
||||
pub(super) fn inventory_origin(&self, batch: &HudBatch) -> (f32, f32) {
|
||||
if self.screen == Screen::Creative {
|
||||
return Self::creative_origin(batch);
|
||||
}
|
||||
let width = self.native_screen_width().unwrap_or(176.0);
|
||||
let (x, y) = batch.centered_origin(width, self.screen_height());
|
||||
(x + self.recipe_book_shift(batch.width), y)
|
||||
}
|
||||
|
||||
/// InventoryMenu / CraftingMenu slot layouts.
|
||||
pub(super) fn slot_position(&self, i: usize) -> (f32, f32) {
|
||||
if self.creative_inventory_open()
|
||||
&& let Some(position) = Creative::inventory_slot_position(i)
|
||||
{
|
||||
return position;
|
||||
}
|
||||
if self.creative_tab_open()
|
||||
&& let Some(position) = Creative::tab_slot_position(i)
|
||||
{
|
||||
return position;
|
||||
}
|
||||
if let Some(p) = self
|
||||
.station_slot_position(i)
|
||||
.or_else(|| {
|
||||
Self::stock_machine_slot(self.machine_slot_position(i), self.native_screen_shown())
|
||||
})
|
||||
.or_else(|| self.crafter_slot_position(i))
|
||||
.or_else(|| self.mount_slot_position(i))
|
||||
{
|
||||
return p;
|
||||
}
|
||||
let table = self.craft_size == 3;
|
||||
// ContainerScreen (3 rows) shifts the player inventory down by one pixel.
|
||||
// ChestMenu: the inventory sits `18 * (rows - 3) + 1` lower than the
|
||||
// 3-row layout of the other menus.
|
||||
let shift = match (self.chest_rows(), self.native_screen_inventory_y()) {
|
||||
(_, Some(y)) => y - 84.0,
|
||||
(Some(rows), None) => 1.0 + (rows as f32 - 3.0) * 18.0,
|
||||
(None, None) => 0.0,
|
||||
};
|
||||
let inventory_x = self.native_screen_inventory_x().unwrap_or(8.0);
|
||||
match i {
|
||||
0..9 => (inventory_x + i as f32 * 18.0, 142.0 + shift),
|
||||
9..36 => (
|
||||
inventory_x + ((i - 9) % 9) as f32 * 18.0,
|
||||
84.0 + shift + ((i - 9) / 9) as f32 * 18.0,
|
||||
),
|
||||
500..504 => (8.0, 8.0 + (i - 500) as f32 * 18.0),
|
||||
// InventoryMenu: the shield slot.
|
||||
504 => (77.0, 62.0),
|
||||
300..500 if let Some(p) = self.native_screen_slot(i - 300) => p,
|
||||
300..500 => match self.container_kind() {
|
||||
Some(BlockEntity::Furnace(_)) => {
|
||||
[(56.0, 17.0), (56.0, 53.0), (116.0, 35.0)][(i - 300).min(2)]
|
||||
}
|
||||
_ => (
|
||||
8.0 + ((i - 300) % 9) as f32 * 18.0,
|
||||
18.0 + ((i - 300) / 9) as f32 * 18.0,
|
||||
),
|
||||
},
|
||||
200 => {
|
||||
if table {
|
||||
(124.0, 35.0)
|
||||
} else {
|
||||
(154.0, 28.0)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let g = i - 100;
|
||||
let (r, c) = (g / self.craft_size, g % self.craft_size);
|
||||
if table {
|
||||
(30.0 + c as f32 * 18.0, 17.0 + r as f32 * 18.0)
|
||||
} else {
|
||||
(98.0 + c as f32 * 18.0, 18.0 + r as f32 * 18.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The stock hopper/dispenser slot layout, unless a mod screen is shown for
|
||||
/// that menu: a mod screen places its own container slots
|
||||
/// (`native_screen_slot`) and the player's inventory (`native_screen_inventory_y`),
|
||||
/// and this layout answers first in `slot_position`, which kept a mod's screen
|
||||
/// on a hopper or a dispenser on the stock grid (#5141). The rest of those
|
||||
/// menus is already the mod's: `draw_native_screen` owns the background and
|
||||
/// `screen_height` its height.
|
||||
pub(super) fn stock_machine_slot(
|
||||
stock: Option<(f32, f32)>,
|
||||
mod_screen_shown: bool,
|
||||
) -> Option<(f32, f32)> {
|
||||
if mod_screen_shown { None } else { stock }
|
||||
}
|
||||
|
||||
pub(super) fn slot_at(&self, batch: &HudBatch, x: f32, y: f32) -> Option<usize> {
|
||||
if self.book_covers_menu(batch.width) {
|
||||
return None;
|
||||
}
|
||||
let (ox, oy) = self.inventory_origin(batch);
|
||||
self.slots().into_iter().find(|&i| {
|
||||
let (sx, sy) = self.slot_position(i);
|
||||
// AbstractContainerScreen.isHovering: the 16x16 slot grows by one
|
||||
// pixel on every side, so the gaps between slots still hover one.
|
||||
x >= ox + sx - 1.0 && x < ox + sx + 17.0 && y >= oy + sy - 1.0 && y < oy + sy + 17.0
|
||||
})
|
||||
}
|
||||
/// `AbstractContainerScreen.extractSlotHighlightBack` and
|
||||
/// `AbstractContainerScreen.extractSlotHighlightFront`: the 24 x 24
|
||||
/// `container/slot_highlight_back` sprite behind the slot at `x`, `y` (its
|
||||
/// 16 x 16 item box), the `_front` one over its item.
|
||||
pub(super) fn draw_slot_highlight(&self, batch: &mut HudBatch, front: bool, x: f32, y: f32) {
|
||||
let sprite = if front {
|
||||
"gui/sprites/container/slot_highlight_front"
|
||||
} else {
|
||||
"gui/sprites/container/slot_highlight_back"
|
||||
};
|
||||
horizon_client::ui::blit_sprite(
|
||||
batch,
|
||||
&self.gui.atlas,
|
||||
sprite,
|
||||
x - 4.0,
|
||||
y - 4.0,
|
||||
24.0,
|
||||
24.0,
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
/// The highlight of the slot under the cursor, for the screens whose
|
||||
/// slots all sit in `slot_at`'s grid. `Slot.isHighlightable` is false
|
||||
/// for the crafter's result (`NonInteractiveResultSlot`).
|
||||
pub(super) fn draw_hovered_slot_highlight(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
front: bool,
|
||||
ox: f32,
|
||||
oy: f32,
|
||||
) {
|
||||
let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) else {
|
||||
return;
|
||||
};
|
||||
if slot == 309 && matches!(self.container_kind(), Some(BlockEntity::Crafter(_))) {
|
||||
return;
|
||||
}
|
||||
let (sx, sy) = self.slot_position(slot);
|
||||
self.draw_slot_highlight(batch, front, ox + sx, oy + sy);
|
||||
}
|
||||
pub(super) fn slot_mut(&mut self, i: usize) -> &mut Option<ItemStack> {
|
||||
if i >= mount_menu::SADDLE {
|
||||
return self.mount_slot_mut(i);
|
||||
}
|
||||
if i >= stations::INPUT {
|
||||
return self.station_slot_mut(i);
|
||||
}
|
||||
if (500..504).contains(&i) {
|
||||
return &mut self.armor[i - 500];
|
||||
}
|
||||
if i == 504 {
|
||||
return &mut self.offhand;
|
||||
}
|
||||
if i >= 300 {
|
||||
return self.container_slot_mut(i - 300);
|
||||
}
|
||||
if (100..109).contains(&i) {
|
||||
&mut self.craft_grid[i - 100]
|
||||
} else {
|
||||
&mut self.inventory[i]
|
||||
}
|
||||
}
|
||||
/// What a slot shows: the crafting result slots are computed.
|
||||
pub(super) fn slot_view(&mut self, i: usize) -> Option<ItemStack> {
|
||||
match i {
|
||||
200 => self.craft_result(),
|
||||
309 if matches!(self.container_kind(), Some(BlockEntity::Crafter(_))) => {
|
||||
self.crafter_result()
|
||||
}
|
||||
_ => self.slot_mut(i).clone(),
|
||||
}
|
||||
}
|
||||
pub(super) fn craft_result(&self) -> Option<ItemStack> {
|
||||
// A server's result slot is the server's (`CraftingMenu.slotChangedCraftingGrid`).
|
||||
if let Some(r) = &self.remote {
|
||||
return r.craft_result.clone();
|
||||
}
|
||||
self.craft_recipe().map(|(_, result)| result)
|
||||
}
|
||||
/// The recipe id and result of the crafting grid.
|
||||
/// `RecipeCraftingHolder.awardUsedRecipes`: a crafted recipe becomes known
|
||||
/// (special ones never do).
|
||||
pub(super) fn award_crafted(&mut self, crafts: &[(String, Vec<Option<ItemStack>>)]) {
|
||||
for (recipe, ingredients) in crafts {
|
||||
// The whole id: the advancements' `recipe_crafted` criteria name
|
||||
// `minecraft:x`, and the crafting copy names the recipe bare.
|
||||
let recipe = horizon_client::crafting::qualified(recipe);
|
||||
self.trigger_recipe_crafted(&recipe, ingredients);
|
||||
}
|
||||
let recipes: Vec<String> = crafts
|
||||
.iter()
|
||||
.map(|(r, _)| horizon_client::crafting::qualified(r))
|
||||
.collect();
|
||||
let index = &world_stations!(self).recipe_index;
|
||||
let (_, unlocked) = self.level.recipe_book.add_recipes(index, &recipes);
|
||||
self.recipe_unlocked(&unlocked);
|
||||
}
|
||||
pub(super) fn craft_recipe(&self) -> Option<(String, ItemStack)> {
|
||||
let n = self.craft_size;
|
||||
let grid: Vec<Option<ItemStack>> = (0..n * n).map(|i| self.craft_grid[i].clone()).collect();
|
||||
let scale = |id: i32| self.maps.data.get(&id).map(|m| m.scale);
|
||||
// The world's table, the one taking the result (`menu_click`) crafts from.
|
||||
self.recipes()
|
||||
.find_with(&grid, n, &scale)
|
||||
.map(|(name, result)| (name.to_owned(), result))
|
||||
}
|
||||
pub(super) fn draw_inventory_slots(&mut self, batch: &mut HudBatch, ox: f32, oy: f32) {
|
||||
let drag = self.drag_view();
|
||||
for i in self.slots() {
|
||||
let (sx, sy) = self.slot_position(i);
|
||||
if i == 504 && self.offhand.is_none() {
|
||||
batch.sprite(
|
||||
&self.gui.atlas,
|
||||
"gui/sprites/container/slot/shield",
|
||||
ox + sx,
|
||||
oy + sy,
|
||||
);
|
||||
}
|
||||
if (500..504).contains(&i) && self.armor[i - 500].is_none() {
|
||||
let icon = ["helmet", "chestplate", "leggings", "boots"][i - 500];
|
||||
batch.sprite(
|
||||
&self.gui.atlas,
|
||||
&format!("gui/sprites/container/slot/{icon}"),
|
||||
ox + sx,
|
||||
oy + sy,
|
||||
);
|
||||
}
|
||||
let stack = self.slot_view(i);
|
||||
self.draw_slot_item(batch, drag.as_ref(), i, &stack, ox + sx, oy + sy);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! A click that uses the held item at nothing or at an entity (`Minecraft.startUseItem`,
|
||||
//! `Player.interactOn`), the context a block's use sees, and the mod hooks that cancel a placement
|
||||
//! or an emptied bucket.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The hand whose use ended the click, if any.
|
||||
pub(super) fn use_item(&mut self) -> Result<Option<Hand>> {
|
||||
self.with_player_actor(|game| game.use_item_as_player())
|
||||
}
|
||||
/// `Minecraft.startUseItem`: the main hand, then the off hand.
|
||||
pub(super) fn use_item_as_player(&mut self) -> Result<Option<Hand>> {
|
||||
// Spectators only look (MultiPlayerGameMode.useItemOn/useItem pass),
|
||||
// but on a server a spectator's use on a block opens the block's
|
||||
// menu (ServerPlayerGameMode.useItemOn).
|
||||
if self.spectator() {
|
||||
if matches!(self.guest_use, Some(GuestUse::OnBlock(_)))
|
||||
&& let Some(hit) = self.pick()
|
||||
{
|
||||
self.spectator_open_menu(hit.pos);
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
// A carrot or warped fungus on a stick boosts the ridden pig or strider.
|
||||
// A guest's use packets name one hand and touch no entity: the client
|
||||
// sends one per hand and part of its click, and Interact for entities.
|
||||
let guest = self.guest_use;
|
||||
if let Some(GuestUse::Entity(hand, target)) = guest {
|
||||
return Ok(self
|
||||
.with_hand(hand, |g| g.interact_entity(Some(target)))?
|
||||
.then_some(hand));
|
||||
}
|
||||
let on_block = matches!(guest, Some(GuestUse::OnBlock(_)));
|
||||
if !on_block
|
||||
&& self.riding.is_some()
|
||||
&& let Some(item) = self.held().map(|s| s.item.clone())
|
||||
&& self.use_steering_stick(&item)
|
||||
{
|
||||
return Ok(Some(Hand::Main));
|
||||
}
|
||||
if guest.is_none() && self.interact_entity(None)? {
|
||||
return Ok(Some(Hand::Main));
|
||||
}
|
||||
// An EntityHitResult may fall through to Item.use, never to useItemOn.
|
||||
let block_hit = match guest {
|
||||
Some(GuestUse::OnBlock(_)) => self.pick(),
|
||||
Some(GuestUse::Item(_)) => None,
|
||||
_ if self.entity_targeted() => None,
|
||||
_ => self.pick(),
|
||||
};
|
||||
let hands = match guest {
|
||||
Some(GuestUse::OnBlock(hand) | GuestUse::Item(hand) | GuestUse::Entity(hand, _)) => {
|
||||
vec![hand]
|
||||
}
|
||||
None => Hand::BOTH.to_vec(),
|
||||
};
|
||||
let entity = guest.is_none() && self.entity_targeted();
|
||||
for hand in hands {
|
||||
// The off hand's turn starts on the entity too (Minecraft.startUseItem).
|
||||
if entity && hand == Hand::Off && self.with_hand(hand, |g| g.interact_entity(None))? {
|
||||
return Ok(Some(hand));
|
||||
}
|
||||
// A click on the fence block: `LeadItem.useOn`. A client whose crosshair
|
||||
// met the knot first sends Interact with the knot's id instead.
|
||||
if on_block && self.with_hand(hand, Game::interact_knot) {
|
||||
return Ok(Some(hand));
|
||||
}
|
||||
if self.with_hand(hand, |game| game.use_hand(block_hit.as_ref()))? {
|
||||
return Ok(Some(hand));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
/// The index in `list` of the entity an interact packet's `target` names,
|
||||
/// by its server id (`Level.getEntity(id)`), or None for an id this level
|
||||
/// does not hold: `handleInteract` finds nothing then either.
|
||||
pub(super) fn named_entity<T>(
|
||||
list: &[T],
|
||||
target: i32,
|
||||
id: impl Fn(&T) -> u32,
|
||||
) -> Option<usize> {
|
||||
list.iter().position(|e| id(e) as i32 == target)
|
||||
}
|
||||
/// `Player.interactOn` with the acting hand: whether it took the click. A
|
||||
/// guest's interact packet names its entity (`target`), which the helpers
|
||||
/// use as it is; the local player picks from the crosshair instead, the
|
||||
/// way `Minecraft.hitResult` does. `native_entity_interact_specific` and
|
||||
/// `native_interact_entity` hear it first; a claim swings the hand and
|
||||
/// ends the click.
|
||||
pub(super) fn interact_entity(&mut self, target: Option<InteractTarget>) -> Result<bool> {
|
||||
// Native mods, where Player.interactOn starts: before the entity's
|
||||
// own interact and the item's interactLivingEntity. The entity is the
|
||||
// one the packet named, or the one the crosshair picked.
|
||||
let (named, distance) = match target {
|
||||
Some(t) => (Some(t.id), None),
|
||||
None => match self.pick_entity_hit_at() {
|
||||
Some((distance, id)) => (id, Some(distance)),
|
||||
None => (None, None),
|
||||
},
|
||||
};
|
||||
if let Some(id) = named
|
||||
&& let Some(entity) = self.level_entity(id)
|
||||
{
|
||||
// The point clicked, measured from the entity's position: a
|
||||
// guest's packet carries it, and the local player's is where the
|
||||
// crosshair's ray met the entity (`EntityHitResult.getLocation()`).
|
||||
let location = match target {
|
||||
Some(t) => Some(t.location),
|
||||
None => distance
|
||||
.zip(self.aim())
|
||||
.zip(self.target_position(entity))
|
||||
.map(|((d, (eye, dir)), at)| {
|
||||
std::array::from_fn(|k| eye[k] + dir[k] * d - at[k])
|
||||
}),
|
||||
};
|
||||
let kind = self.target_type(entity);
|
||||
let stack = self.held().cloned();
|
||||
let mut claimed = false;
|
||||
if let Some(location) = location {
|
||||
self.native_entity_interact_specific(
|
||||
id,
|
||||
&kind,
|
||||
stack.as_ref(),
|
||||
location,
|
||||
&mut claimed,
|
||||
);
|
||||
}
|
||||
if !claimed {
|
||||
self.native_interact_entity(id, &kind, stack.as_ref(), &mut claimed);
|
||||
self.native_interact_entity_v2(id, &kind, self.hand, stack.as_ref(), &mut claimed);
|
||||
}
|
||||
if claimed {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(self.interact_frame(target)
|
||||
|| self.interact_armor_stand(target)
|
||||
|| self.interact_cushion(target)
|
||||
|| self.interact_boat(target)
|
||||
|| self.interact_cart(target)
|
||||
|| self.interact_mob(target)?
|
||||
// A guest's Interact naming a knot's id goes to `use_knot` before this.
|
||||
|| target.is_none() && self.interact_knot()
|
||||
|| self.item_interact_mob(target))
|
||||
}
|
||||
|
||||
/// Asks the mods whether the acting guest may place `stack` where and as
|
||||
/// `BlockItem.place` would for `ctx` (`native_block_place`): true when
|
||||
/// one canceled it. A game's own player is not asked, nor a placement
|
||||
/// vanilla refuses.
|
||||
pub(super) fn block_place_canceled(&mut self, stack: &ItemStack, ctx: &PlaceContext) -> bool {
|
||||
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
|
||||
return false;
|
||||
};
|
||||
let Some((pos, state)) =
|
||||
self.with_level(|b, level, ticks| b.placement_state(level, ticks, ctx))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let mut cancel = false;
|
||||
self.native_block_place(
|
||||
uuid,
|
||||
&name,
|
||||
stack,
|
||||
[pos.x, pos.y, pos.z],
|
||||
state,
|
||||
&mut cancel,
|
||||
);
|
||||
cancel
|
||||
}
|
||||
/// Asks the mods whether the acting guest may empty `stack` at `pos`
|
||||
/// (`native_bucket_empty`): true when one cancelled it. A game's own
|
||||
/// player is not asked.
|
||||
pub(super) fn bucket_empty_cancelled(&mut self, stack: &ItemStack, pos: [i32; 3]) -> bool {
|
||||
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
|
||||
return false;
|
||||
};
|
||||
let fluid = match &*stack.item {
|
||||
"minecraft:lava_bucket" => "minecraft:lava",
|
||||
"minecraft:powder_snow_bucket" => "minecraft:powder_snow",
|
||||
_ => "minecraft:water",
|
||||
};
|
||||
let mut cancel = false;
|
||||
self.native_bucket_empty(uuid, &name, pos, fluid, &mut cancel);
|
||||
cancel
|
||||
}
|
||||
/// The player as a block's `useItemOn` / `useWithoutItem` sees them.
|
||||
pub(super) fn use_context(&self) -> PlayerContext {
|
||||
PlayerContext {
|
||||
yaw: self.yaw,
|
||||
pitch: self.pitch,
|
||||
may_build: self.may_build(),
|
||||
can_eat: self.creative
|
||||
|| self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.health.food.food < 20),
|
||||
game_master: self.can_use_game_master_blocks(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Key and button codes as GLFW and vanilla's `InputConstants` number them, and the small rules
|
||||
//! that decide which device or screen a key press goes to.
|
||||
use super::*;
|
||||
|
||||
pub(super) fn follow_named_keymap(
|
||||
logical_key: &winit::keyboard::Key,
|
||||
physical_key: &mut PhysicalKey,
|
||||
) {
|
||||
use winit::keyboard::{Key, NamedKey};
|
||||
if let Key::Named(named) = logical_key {
|
||||
let code = match named {
|
||||
NamedKey::Escape => KeyCode::Escape,
|
||||
NamedKey::CapsLock => KeyCode::CapsLock,
|
||||
NamedKey::F1 => KeyCode::F1,
|
||||
NamedKey::F2 => KeyCode::F2,
|
||||
NamedKey::F3 => KeyCode::F3,
|
||||
NamedKey::F4 => KeyCode::F4,
|
||||
NamedKey::F5 => KeyCode::F5,
|
||||
NamedKey::F6 => KeyCode::F6,
|
||||
NamedKey::F7 => KeyCode::F7,
|
||||
NamedKey::F8 => KeyCode::F8,
|
||||
NamedKey::F9 => KeyCode::F9,
|
||||
NamedKey::F10 => KeyCode::F10,
|
||||
NamedKey::F11 => KeyCode::F11,
|
||||
NamedKey::F12 => KeyCode::F12,
|
||||
NamedKey::F13 => KeyCode::F13,
|
||||
NamedKey::F14 => KeyCode::F14,
|
||||
NamedKey::F15 => KeyCode::F15,
|
||||
NamedKey::F16 => KeyCode::F16,
|
||||
NamedKey::F17 => KeyCode::F17,
|
||||
NamedKey::F18 => KeyCode::F18,
|
||||
NamedKey::F19 => KeyCode::F19,
|
||||
NamedKey::F20 => KeyCode::F20,
|
||||
NamedKey::F21 => KeyCode::F21,
|
||||
NamedKey::F22 => KeyCode::F22,
|
||||
NamedKey::F23 => KeyCode::F23,
|
||||
NamedKey::F24 => KeyCode::F24,
|
||||
NamedKey::F25 => KeyCode::F25,
|
||||
NamedKey::F26 => KeyCode::F26,
|
||||
NamedKey::F27 => KeyCode::F27,
|
||||
NamedKey::F28 => KeyCode::F28,
|
||||
NamedKey::F29 => KeyCode::F29,
|
||||
NamedKey::F30 => KeyCode::F30,
|
||||
NamedKey::F31 => KeyCode::F31,
|
||||
NamedKey::F32 => KeyCode::F32,
|
||||
NamedKey::F33 => KeyCode::F33,
|
||||
NamedKey::F34 => KeyCode::F34,
|
||||
NamedKey::F35 => KeyCode::F35,
|
||||
_ => return,
|
||||
};
|
||||
*physical_key = PhysicalKey::Code(code);
|
||||
}
|
||||
}
|
||||
|
||||
/// The modifier `modifiers` stands for, as GLFW words it in the native mods'
|
||||
/// input hooks: `GLFW_MOD_SHIFT` 1, `GLFW_MOD_CONTROL` 2, `GLFW_MOD_ALT` 4,
|
||||
/// `GLFW_MOD_SUPER` 8 — the word a NeoForge listener reads off its event
|
||||
/// (`getModifiers`). The lock keys are not modifiers there.
|
||||
pub(super) fn glfw_modifiers(modifiers: winit::keyboard::ModifiersState) -> u32 {
|
||||
let mut bits = 0;
|
||||
if modifiers.shift_key() {
|
||||
bits |= 1;
|
||||
}
|
||||
if modifiers.control_key() {
|
||||
bits |= 2;
|
||||
}
|
||||
if modifiers.alt_key() {
|
||||
bits |= 4;
|
||||
}
|
||||
if modifiers.super_key() {
|
||||
bits |= 8;
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
/// The GLFW mouse button index `button` is, as the native mods' mouse hooks
|
||||
/// hand it over: 0 left, 1 right, 2 middle, 3 back, 4 forward. `Other(n)` is
|
||||
/// winit's n, the platform's own number (X11 or evdev on Linux), so a button
|
||||
/// past 4 is not GLFW's index everywhere. Buttons 0–4 are the numbering
|
||||
/// `keybinds::mouse_name` names (`key.mouse.<index + 1>`) and vanilla's
|
||||
/// `InputConstants` reads.
|
||||
pub(super) fn glfw_button(button: MouseButton) -> i32 {
|
||||
match button {
|
||||
MouseButton::Left => 0,
|
||||
MouseButton::Right => 1,
|
||||
MouseButton::Middle => 2,
|
||||
MouseButton::Back => 3,
|
||||
MouseButton::Forward => 4,
|
||||
MouseButton::Other(n) => i32::from(n),
|
||||
}
|
||||
}
|
||||
|
||||
/// `KeyboardHandler.keyPress` for a key bound to a mouse mapping (key.attack,
|
||||
/// key.use, key.pickItem): in game, a press and every repeat of the held key
|
||||
/// reach `KeyMapping.set(key, true)` and `KeyMapping.click` alike, so a held
|
||||
/// key keeps attacking; a release lets go. `None` behind a screen, which takes
|
||||
/// the key.
|
||||
pub(super) fn keyboard_button(playing: bool, action: KeyAction) -> Option<bool> {
|
||||
playing.then_some(action != KeyAction::Release)
|
||||
}
|
||||
|
||||
/// Gameplay owns the keyboard: `Screen::Playing`, no title, and nothing open
|
||||
/// over the world (`screen_over_playing`: a server dialog, a book or lectern,
|
||||
/// a sign editor, a prompt). Each is a `Screen` in vanilla, so `Gui.setScreen`
|
||||
/// runs `KeyMapping.releaseAll` and `Minecraft.tick` skips `handleKeybinds`
|
||||
/// while it is up: a mouse button held into it is let go of (#9729).
|
||||
pub(super) fn gameplay_has_keyboard(screen: Screen, title: bool, screen_over: bool) -> bool {
|
||||
screen == Screen::Playing && !title && !screen_over
|
||||
}
|
||||
|
||||
/// A dialog drops held keys and mouse buttons it does not see (#4974).
|
||||
pub(super) fn drop_held_behind_dialog(
|
||||
dialog: bool,
|
||||
keys: &mut horizon_client::held::HeldKeys,
|
||||
left: &mut bool,
|
||||
right: &mut bool,
|
||||
) {
|
||||
if dialog {
|
||||
keys.clear();
|
||||
*left = false;
|
||||
*right = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractContainerScreen.keyPressed` for a key bound to a mouse mapping: a press or repeat of
|
||||
/// key.pickItem's key clones the hovered slot; keys bound to key.attack and key.use do nothing
|
||||
/// there (#5378).
|
||||
pub(super) fn container_key_clones(button: MouseButton, action: KeyAction) -> bool {
|
||||
button == MouseButton::Middle && action != KeyAction::Release
|
||||
}
|
||||
@@ -496,12 +496,16 @@ impl Game<'_> {
|
||||
let running = (!paused && !self.agent.hold_server).then(|| self.clock.world_runs());
|
||||
let server = self.serving.is_some() || self.options.dedicated.is_some();
|
||||
// A config edited while the server is paused (empty) still reloads.
|
||||
// The parts outside every level get spans of their own, so that a slow
|
||||
// tick's line and `horizon-report` say when a mod's tick hook took it.
|
||||
if server {
|
||||
let _span = horizon_client::spans::guard("mod_configs_poll");
|
||||
self.native_configs_poll();
|
||||
}
|
||||
if let Some(running) = running
|
||||
&& server
|
||||
{
|
||||
let _span = horizon_client::spans::guard("mods_server_tick_start");
|
||||
self.native_lifecycle_server_tick_start(running);
|
||||
}
|
||||
let observe_level =
|
||||
@@ -578,13 +582,20 @@ impl Game<'_> {
|
||||
self.native_lifecycle.cancel_server_tick();
|
||||
}
|
||||
result?;
|
||||
if let Err(error) = self.close_idle_levels() {
|
||||
let closed = {
|
||||
let _span = horizon_client::spans::guard("close_idle_levels");
|
||||
self.close_idle_levels()
|
||||
};
|
||||
if let Err(error) = closed {
|
||||
self.native_lifecycle.cancel_server_tick();
|
||||
return Err(error);
|
||||
}
|
||||
// `ServerTickEnd`: an errored tick or an idle-close failure (at
|
||||
// the `?` above) never reaches this site.
|
||||
self.native_lifecycle_server_tick_end(paused);
|
||||
{
|
||||
let _span = horizon_client::spans::guard("mods_server_tick_end");
|
||||
self.native_lifecycle_server_tick_end(paused);
|
||||
}
|
||||
// The packets counted for `native_server_packets` (only while a mod counts them).
|
||||
if server {
|
||||
self.diagnostics_packets_tick();
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
//! The loading screen while the resources load (`Loading`), the window's app before the game
|
||||
//! takes over (`App`), and the checks a start makes first: missing or damaged prepared files,
|
||||
//! an unclean last exit, a fullscreen the window system could not show.
|
||||
use super::*;
|
||||
|
||||
/// The window system ended the event loop with a failure (on Linux, a compositor dropping the
|
||||
/// connection after a protocol error) while fullscreen was on: the next start opens windowed, so a
|
||||
/// fullscreen the system can't show doesn't crash every launch (#2904).
|
||||
pub(super) fn leave_fullscreen_after(
|
||||
result: &std::result::Result<(), EventLoopError>,
|
||||
settings: &mut Settings,
|
||||
) {
|
||||
if matches!(result, Err(EventLoopError::ExitFailure(_))) && settings.fullscreen {
|
||||
settings.fullscreen = false;
|
||||
settings.save(&Game::options_path());
|
||||
log::warn!(
|
||||
"the window system closed the game in fullscreen; the next start opens windowed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run_event_loop(mut game: Game<'_>) -> Result<()> {
|
||||
if game.options.headless {
|
||||
// No display to open winit's loop on: the game's own loop.
|
||||
game.run_headless();
|
||||
// narrator::start ran for it too (options.txt's narrator setting).
|
||||
narrator::stop();
|
||||
write_report(&game)?;
|
||||
return game.error.take().map_or(Ok(()), Err);
|
||||
}
|
||||
let mut split = split::Split::new(game);
|
||||
let result = EventLoop::new()?.run_app(&mut split);
|
||||
game = split.host;
|
||||
narrator::stop();
|
||||
leave_fullscreen_after(&result, &mut game.settings);
|
||||
result?;
|
||||
#[cfg(feature = "discord")]
|
||||
horizon_client::discord_presence::stop();
|
||||
write_report(&game)?;
|
||||
if let Some(e) = game.error.take() {
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The loading screen's state while the worker loads the resources.
|
||||
pub(super) struct Loading {
|
||||
pub(super) window: Option<Arc<Window>>,
|
||||
pub(super) renderer: Option<Renderer>,
|
||||
/// The boot screen's atlas, until the pack's GUI atlas replaces it.
|
||||
pub(super) boot: horizon_client::hud::HudAtlas,
|
||||
pub(super) gui: Option<UiResources>,
|
||||
pub(super) settings: Settings,
|
||||
pub(super) fullscreen: horizon_client::window::FullscreenState,
|
||||
pub(super) video_modes: horizon_client::window::VideoModeList,
|
||||
pub(super) options: Option<Options>,
|
||||
pub(super) vanilla_root: PathBuf,
|
||||
pub(super) pack_error: Option<String>,
|
||||
pub(super) started: Instant,
|
||||
pub(super) panorama_rotation: horizon_client::gfx::panorama::Rotation,
|
||||
pub(super) progress: Progress,
|
||||
pub(super) receiver: Receiver<LoaderMsg>,
|
||||
pub(super) loaded: Option<std::result::Result<Loaded, String>>,
|
||||
/// The loader's `Done` arrived: its channel closing afterwards is expected.
|
||||
pub(super) delivered: bool,
|
||||
pub(super) handle: Option<JoinHandle<()>>,
|
||||
pub(super) shown: bool,
|
||||
/// When the menu bundle arrived (`set_ui`), and whether a title frame with
|
||||
/// it was presented since: the world renderer waits for that frame
|
||||
/// (`ready_for_handoff`).
|
||||
pub(super) ui_at: Option<Instant>,
|
||||
pub(super) title_drawn: bool,
|
||||
pub(super) menu: startup_menu::StartupMenu,
|
||||
pub(super) error: Option<Box<dyn std::error::Error>>,
|
||||
}
|
||||
/// How long the handoff waits for the first title frame before building the
|
||||
/// world renderer anyway (a hidden window may never be asked to redraw).
|
||||
pub(super) const TITLE_FRAME_WAIT: Duration = Duration::from_secs(1);
|
||||
impl Loading {
|
||||
/// The world renderer is built on the main thread, so building it before
|
||||
/// the title is on the screen holds the title back by that build. Wait for
|
||||
/// one title frame first, unless a world is waiting to open.
|
||||
pub(super) fn ready_for_handoff(&self) -> bool {
|
||||
self.title_drawn
|
||||
|| self.menu.pending.is_some()
|
||||
|| self.ui_at.is_none_or(|at| at.elapsed() >= TITLE_FRAME_WAIT)
|
||||
}
|
||||
pub(super) fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
// The Window constructor: created fullscreen when options.txt or --fullscreen says so.
|
||||
let fullscreen =
|
||||
self.settings.fullscreen || self.options.as_ref().is_some_and(|o| o.fullscreen);
|
||||
let size = self
|
||||
.options
|
||||
.as_ref()
|
||||
.map_or([1280, 720], |o| o.window_size)
|
||||
.map(f64::from);
|
||||
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
|
||||
let attributes = horizon_client::window::with_horizon_icon(
|
||||
Window::default_attributes().with_title("Horizon"),
|
||||
);
|
||||
// Wayland names the window by its app id, which the horizon.desktop entry matches.
|
||||
#[cfg(target_os = "linux")]
|
||||
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
|
||||
attributes, "horizon", "horizon",
|
||||
);
|
||||
// `--offscreen` renders into a texture: its window would only show white.
|
||||
let window_step = startup::step("window.create");
|
||||
let window = match event_loop.create_window(
|
||||
attributes
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1]))
|
||||
.with_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None)))
|
||||
.with_visible(!self.options.as_ref().is_some_and(|o| o.offscreen)),
|
||||
) {
|
||||
Ok(w) => Arc::new(w),
|
||||
Err(e) => {
|
||||
self.error = Some(e.into());
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
};
|
||||
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
|
||||
drop(window_step);
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
// The renderer takes seconds to build and winit's window sits white
|
||||
// until the first frame presents, so paint the boot screen on it and
|
||||
// keep repainting it as the console's lines arrive; the wgpu frames
|
||||
// redraw the same design when they start. GDI is unsafe:
|
||||
// horizon-threads holds it (this crate forbids unsafe code).
|
||||
#[cfg(target_os = "windows")]
|
||||
let boot_paint_stop = hwnd(&window).map(|hwnd| {
|
||||
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let repaint_stop = stop.clone();
|
||||
std::thread::spawn(move || {
|
||||
while !repaint_stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
horizon_threads::boot_paint::paint(
|
||||
hwnd,
|
||||
horizon_theme::fonts::GEIST_SEMIBOLD,
|
||||
horizon_theme::fonts::GEIST_MONO,
|
||||
&horizon_client::logging::recent_lines(),
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
});
|
||||
stop
|
||||
});
|
||||
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
|
||||
let boot_step = startup::step("renderer.boot");
|
||||
match loading_renderer(
|
||||
window.clone(),
|
||||
offscreen,
|
||||
Some(&self.boot),
|
||||
None,
|
||||
None,
|
||||
self.settings.horizon.api,
|
||||
) {
|
||||
Ok(r) => self.renderer = Some(r),
|
||||
Err(e) => {
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(stop) = &boot_paint_stop {
|
||||
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
self.error = Some(e);
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
drop(boot_step);
|
||||
// The wgpu frames take the window over: stop the boot paint thread
|
||||
// before the first one presents.
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(stop) = &boot_paint_stop {
|
||||
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
horizon_client::hud::GUI_SCALE.store(self.settings.gui_scale, Ordering::Relaxed);
|
||||
self.window = Some(window);
|
||||
}
|
||||
pub(super) fn window_event(
|
||||
&mut self,
|
||||
event_loop: &ActiveEventLoop,
|
||||
id: WindowId,
|
||||
mut event: WindowEvent,
|
||||
) {
|
||||
if self.window.as_ref().is_none_or(|w| w.id() != id) {
|
||||
return;
|
||||
}
|
||||
follow_keymap(&mut event);
|
||||
if let Err(error) = self.menu_event(event_loop, event) {
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
/// The menu bundle arrived: rebuild the loading screen with the real atlas
|
||||
/// and title panorama.
|
||||
pub(super) fn set_ui(&mut self, gui: UiResources, root: PathBuf) {
|
||||
log::info!(
|
||||
"Horizon: loading the title artwork after {} ms",
|
||||
self.started.elapsed().as_millis()
|
||||
);
|
||||
self.menu.set_root(root, &self.settings);
|
||||
self.gui = Some(gui);
|
||||
self.title_drawn = false;
|
||||
horizon_client::ui_theme::reset_atlas();
|
||||
self.apply_menu_settings();
|
||||
let Some(window) = self.window.clone() else {
|
||||
return;
|
||||
};
|
||||
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
renderer.release_surface();
|
||||
}
|
||||
let bundle = self.gui.as_ref().expect("the menu bundle was just set");
|
||||
let _step = startup::step("renderer.title_artwork");
|
||||
match loading_renderer(
|
||||
window,
|
||||
offscreen,
|
||||
Some(&bundle.atlas),
|
||||
Some(&bundle.panorama),
|
||||
self.renderer.as_ref(),
|
||||
self.settings.horizon.api,
|
||||
) {
|
||||
Ok(r) => {
|
||||
horizon_client::hud::GUI_SCALE.store(self.settings.gui_scale, Ordering::Relaxed);
|
||||
self.renderer = Some(r);
|
||||
}
|
||||
Err(e) => self.error = Some(e),
|
||||
}
|
||||
// The title frame's wait starts once its renderer is built.
|
||||
self.ui_at = Some(Instant::now());
|
||||
}
|
||||
pub(super) fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
// The loop may run once more after exit(): keep the first error, the
|
||||
// one that ended loading.
|
||||
if self.error.is_some() {
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
if horizon_threads::stop_requests() > 0 {
|
||||
// No world is open yet: nothing to save.
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
if let Err(error) = self.poll_menu_resources() {
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
self.poll_menu_realms(event_loop);
|
||||
if let Some(w) = &self.window {
|
||||
if self.options.as_ref().is_some_and(|o| o.offscreen) {
|
||||
self.draw();
|
||||
} else {
|
||||
w.request_redraw();
|
||||
}
|
||||
}
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
}
|
||||
pub(super) fn draw(&mut self) {
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| [r.config.width, r.config.height])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut batch = HudBatch::new(size);
|
||||
self.draw_menu(&mut batch);
|
||||
self.menu.frame_drawn((batch.width, batch.height));
|
||||
let mut frame = loading_frame(&batch);
|
||||
frame.yaw = self.panorama_rotation.update(
|
||||
self.started.elapsed().as_secs_f32(),
|
||||
self.settings.file.double("panoramaScrollSpeed") as f32,
|
||||
);
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
match renderer.render(&frame) {
|
||||
Ok(true) if self.gui.is_some() && self.menu.pending.is_none() => {
|
||||
self.title_drawn = true;
|
||||
self.menu.perf.title_shown()
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("Horizon: loading screen frame failed: {e}"),
|
||||
}
|
||||
}
|
||||
if !self.shown {
|
||||
self.shown = true;
|
||||
startup::mark("loading_screen.shown");
|
||||
log::info!(
|
||||
"Horizon: loading screen shown after {} ms",
|
||||
self.started.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
}
|
||||
pub(super) fn finish(&mut self, loaded: Loaded) -> Result<Option<split::Split<'static>>> {
|
||||
let handoff_at = Instant::now();
|
||||
let mut options = self.options.take().ok_or("loading already finished")?;
|
||||
options.resources = loaded.root.clone();
|
||||
// StartupMenu owns world selection until it can hand it to Game.
|
||||
options.world = None;
|
||||
options.server = None;
|
||||
let gui = self.gui.take().ok_or("missing menu bundle")?;
|
||||
let window = self.window.clone().ok_or("window closed")?;
|
||||
// Keep the loader's device and swap in the real textures in one step.
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
renderer.release_surface();
|
||||
}
|
||||
let renderer_at = Instant::now();
|
||||
let renderer = startup::time("renderer.world", || {
|
||||
build_renderer(
|
||||
window.clone(),
|
||||
options.offscreen,
|
||||
&loaded.pack,
|
||||
&gui,
|
||||
self.renderer.as_ref(),
|
||||
loaded.renderer_cpu,
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.horizon.wide_frame(),
|
||||
shader_packs::picked_pack_shape(&self.settings),
|
||||
(
|
||||
self.settings.file.int("textureFiltering"),
|
||||
self.settings.file.int("maxAnisotropyBit"),
|
||||
),
|
||||
)
|
||||
})?;
|
||||
log::info!(
|
||||
"Horizon: world renderer ready after {} ms",
|
||||
self.started.elapsed().as_millis()
|
||||
);
|
||||
let renderer_ms = renderer_at.elapsed().as_millis();
|
||||
// Audio is created on the main thread, after the core is ready.
|
||||
let audio = if !options.execution_role.is_client() {
|
||||
Audio::silent(&options.resources)
|
||||
} else {
|
||||
Audio::with_device(&options.resources, &self.settings.sound_device)
|
||||
};
|
||||
let pack_error = loaded.pack_error.or_else(|| self.pack_error.take());
|
||||
// The game borrows its shared resources for the process's lifetime,
|
||||
// like the single copy an integrated server would share.
|
||||
let res: &'static Arc<Resources> = Box::leak(Box::new(loaded.resources));
|
||||
let game_at = Instant::now();
|
||||
let game_step = startup::step("handoff.start_game");
|
||||
let Some(mut game) = start_game(
|
||||
res,
|
||||
loaded.pack,
|
||||
gui,
|
||||
options,
|
||||
pack_error,
|
||||
audio,
|
||||
self.vanilla_root.clone(),
|
||||
self.started,
|
||||
loaded.rest_loader,
|
||||
Some(self.settings.clone()),
|
||||
loaded.game_cpu,
|
||||
loaded.entity_models,
|
||||
)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
drop(game_step);
|
||||
let game_ms = game_at.elapsed().as_millis();
|
||||
game.window = Some(window);
|
||||
game.renderer = Some(renderer);
|
||||
game.world_renderer_ready = true;
|
||||
game.fullscreen = self.fullscreen;
|
||||
game.panorama_rotation = self.panorama_rotation;
|
||||
self.menu.handoff(&mut game);
|
||||
game.apply_settings();
|
||||
game.previous = Instant::now();
|
||||
// Minecraft.onGameLoadFinished: a start that made it this far is clean.
|
||||
game.settings.file.set(
|
||||
"startedCleanly",
|
||||
horizon_client::options_file::V::Bool(true),
|
||||
);
|
||||
game.settings.save(&Game::options_path());
|
||||
log::info!(
|
||||
"Horizon: startup handoff {} ms (renderer {} ms, start_game {} ms)",
|
||||
handoff_at.elapsed().as_millis(),
|
||||
renderer_ms,
|
||||
game_ms
|
||||
);
|
||||
Ok(Some(split::Split::new(game)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The application: the loading screen until the resources are ready, then
|
||||
/// the game itself, on the same event loop and window.
|
||||
pub(super) enum App {
|
||||
Loading(Box<Loading>),
|
||||
Ready(Box<split::Split<'static>>),
|
||||
}
|
||||
impl ApplicationHandler for App {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
match self {
|
||||
App::Loading(loading) => loading.resumed(event_loop),
|
||||
App::Ready(game) => game.resumed(event_loop),
|
||||
}
|
||||
}
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
|
||||
match self {
|
||||
App::Loading(loading) => loading.window_event(event_loop, id, event),
|
||||
App::Ready(game) => game.window_event(event_loop, id, event),
|
||||
}
|
||||
}
|
||||
fn device_event(&mut self, event_loop: &ActiveEventLoop, id: DeviceId, event: DeviceEvent) {
|
||||
if let App::Ready(game) = self {
|
||||
game.device_event(event_loop, id, event);
|
||||
}
|
||||
}
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
enum Step {
|
||||
Waiting,
|
||||
Ready(Box<split::Split<'static>>),
|
||||
Done,
|
||||
Failed(Box<dyn std::error::Error>),
|
||||
}
|
||||
let step = match self {
|
||||
App::Ready(game) => {
|
||||
game.about_to_wait(event_loop);
|
||||
return;
|
||||
}
|
||||
App::Loading(loading) => {
|
||||
loading.about_to_wait(event_loop);
|
||||
if loading.error.is_some() {
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
match if loading.menu.reload_packs || !loading.ready_for_handoff() {
|
||||
None
|
||||
} else {
|
||||
loading.loaded.take()
|
||||
} {
|
||||
None => Step::Waiting,
|
||||
Some(Err(e)) => Step::Failed(e.into()),
|
||||
Some(Ok(loaded)) => match loading.finish(loaded) {
|
||||
Ok(Some(game)) => Step::Ready(Box::new(game)),
|
||||
Ok(None) => Step::Done,
|
||||
Err(e) => Step::Failed(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
match step {
|
||||
Step::Waiting => {}
|
||||
Step::Ready(game) => *self = App::Ready(game),
|
||||
Step::Done => event_loop.exit(),
|
||||
Step::Failed(e) => {
|
||||
if let App::Loading(loading) = self {
|
||||
loading.error = Some(e);
|
||||
}
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn exiting(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if let App::Ready(game) = self {
|
||||
game.exiting(event_loop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every launch reads the prepared game files (`horizon-prepare`'s output, whose
|
||||
/// manifest is `horizon-resources.json`): without them, say where they were
|
||||
/// looked for and how to get them, before a window or a loader starts.
|
||||
pub(super) fn missing_resources(root: &std::path::Path) -> Option<String> {
|
||||
let shown = std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
if !root.join("horizon-resources.json").is_file() {
|
||||
return Some(format!(
|
||||
"Horizon's prepared game files are missing: no horizon-resources.json in {}\n\
|
||||
Horizon is started from its launcher: download it at {} (Download). It needs your \
|
||||
own Minecraft: Java Edition; the launcher signs you in and prepares the game files.\n\
|
||||
Developers: horizon-prepare --prepare, or pass --resources <folder>",
|
||||
shown.display(),
|
||||
horizon_client::no_resources::DOWNLOAD_URL
|
||||
));
|
||||
}
|
||||
damaged_prepared(root).map(|problem| {
|
||||
format!(
|
||||
"Horizon's prepared game files are damaged: {problem} in {}\n\
|
||||
Prepare them again with the launcher, or: horizon-prepare --prepare",
|
||||
shown.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Which top-level prepared file a killed preparation or an interrupted copy
|
||||
/// left unusable. The loader parses these before it can start; naming one
|
||||
/// beats serde's bare "expected value at line 1 column 1".
|
||||
pub(super) fn damaged_prepared(root: &std::path::Path) -> Option<String> {
|
||||
let manifest = root.join("horizon-resources.json");
|
||||
let bytes = std::fs::read(&manifest).ok()?;
|
||||
if bytes.iter().all(u8::is_ascii_whitespace) {
|
||||
return Some("horizon-resources.json is empty".into());
|
||||
}
|
||||
if serde_json::from_slice::<serde_json::Value>(&bytes).is_err() {
|
||||
return Some("horizon-resources.json is not valid JSON".into());
|
||||
}
|
||||
for entry in std::fs::read_dir(root).ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension() != Some(std::ffi::OsStr::new("json")) {
|
||||
continue;
|
||||
}
|
||||
if std::fs::metadata(&path).is_ok_and(|meta| meta.len() == 0) {
|
||||
return Some(format!("{} is empty", entry.file_name().to_string_lossy()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `Minecraft.<init>`: `startedCleanly` left false means the previous start
|
||||
/// died before `onGameLoadFinished`. Vanilla then leaves fullscreen
|
||||
/// ("resetting fullscreen mode") so a start that died on the display mode can
|
||||
/// come back; the resource packs are rolled back only when their load fails
|
||||
/// (`rollbackResourcePacks`), not here. True when the last start was unclean.
|
||||
pub(super) fn unclean_start_recovery(settings: &mut Settings) -> bool {
|
||||
// Vanilla's field defaults true: only an explicit `false` is an unclean
|
||||
// start; an options file that never had the key is not one.
|
||||
let unclean = matches!(
|
||||
settings.file.get("startedCleanly"),
|
||||
Some(horizon_client::options_file::V::Bool(false))
|
||||
);
|
||||
if unclean {
|
||||
settings.fullscreen = false;
|
||||
settings.file.fullscreen_resolution = None;
|
||||
}
|
||||
unclean
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
//! The window's first renderer and frames: the renderer built from the prepared textures, the
|
||||
//! boot screen's frame, and `start_game`, which turns the loaded resources into a `Game`.
|
||||
use super::*;
|
||||
|
||||
/// CPU mip chains for the block sprites and world border.
|
||||
pub(super) fn prepare_renderer_textures(
|
||||
pack: &PackResources,
|
||||
) -> horizon_client::gfx::PreparedTextures {
|
||||
let assets = &pack.render;
|
||||
horizon_client::gfx::PreparedTextures::new(
|
||||
&assets.pixels.get(),
|
||||
assets.sprite_size,
|
||||
assets.texture_layers,
|
||||
&assets.sprite_mips,
|
||||
(&pack.forcefield.2, pack.forcefield.0),
|
||||
)
|
||||
.with_pbr(
|
||||
&assets.pbr_normals,
|
||||
&assets.pbr_specular,
|
||||
assets.pbr_size,
|
||||
assets.texture_layers,
|
||||
)
|
||||
}
|
||||
|
||||
/// Upload pack textures and create the renderer on the window's device.
|
||||
pub(super) fn build_renderer(
|
||||
window: Arc<Window>,
|
||||
offscreen: bool,
|
||||
pack: &PackResources,
|
||||
gui: &UiResources,
|
||||
previous: Option<&Renderer>,
|
||||
prepared: Option<horizon_client::gfx::PreparedTextures>,
|
||||
api: horizon_client::graphics::GraphicsApi,
|
||||
translucent_batching: bool,
|
||||
// HDR with Smooth Gradients (`Graphics::wide_frame`): a 16-bit frame where HDR output is
|
||||
// offered (`Renderer::new`).
|
||||
wide_frame: bool,
|
||||
shader_pack: shader_packs::PickedPack,
|
||||
filtering: (i32, i32),
|
||||
) -> Result<Renderer> {
|
||||
let assets = &pack.render;
|
||||
let atlas = &gui.atlas;
|
||||
// The new renderer's atlas texture starts from `HudAtlas.pixels`, which
|
||||
// holds the textures but never a glyph rasterized at run time. The font's
|
||||
// cache still answers with the rects it drew into the previous renderer's
|
||||
// texture, so forget them: the glyphs that are drawn again are placed into
|
||||
// this texture (as `ui_theme` does when its atlas changes). Without this,
|
||||
// Chinese and Japanese menu labels go blank when the loading screen hands
|
||||
// over to the game.
|
||||
atlas.font.reset_cache();
|
||||
// Made here when the worker did not: the renderer's own fallback has no LabPBR maps.
|
||||
let prepared = Some(prepared.unwrap_or_else(|| prepare_renderer_textures(pack)));
|
||||
let atlas_pixels = atlas.pixels();
|
||||
let mut renderer = pollster::block_on(Renderer::new(
|
||||
window,
|
||||
offscreen,
|
||||
&assets.pixels.get(),
|
||||
assets.sprite_size,
|
||||
assets.sprite_minimum_size,
|
||||
assets.texture_layers,
|
||||
&assets.sprite_mips,
|
||||
&assets.animations,
|
||||
(&atlas_pixels, atlas.width, atlas.height),
|
||||
(&atlas.glint.0, atlas.glint.1, atlas.glint.2),
|
||||
(
|
||||
&atlas.glint_armor.0,
|
||||
atlas.glint_armor.1,
|
||||
atlas.glint_armor.2,
|
||||
),
|
||||
(&pack.forcefield.2, pack.forcefield.0),
|
||||
gui.panorama.clone(),
|
||||
prepared,
|
||||
previous,
|
||||
api,
|
||||
translucent_batching,
|
||||
wide_frame,
|
||||
shader_pack.draws_flat,
|
||||
shader_pack.linear,
|
||||
shader_pack.hdr_float,
|
||||
))?;
|
||||
renderer.hud.sprite_animations = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
.map(|(rect, animation)| (*rect, animation.clone(), (usize::MAX, 0, 0)))
|
||||
.collect();
|
||||
renderer
|
||||
.hud
|
||||
.set_text_shader(&renderer.device, atlas.text_shader.as_deref());
|
||||
pack.item_renderer
|
||||
.prepare_glint_atlas(renderer.device.limits().max_texture_dimension_2d, filtering)?;
|
||||
renderer.set_lines_shader(atlas.lines_shader.as_deref());
|
||||
renderer.set_custom_sky(&pack.custom_sky);
|
||||
let cit_glints = pack.item_renderer.cit.as_ref().map(|c| &c.glint_textures);
|
||||
renderer
|
||||
.hud
|
||||
.set_cit_glints(&renderer.device, &renderer.queue, cit_glints);
|
||||
Ok(renderer)
|
||||
}
|
||||
|
||||
/// The loading screen's renderer: one atlas (the boot screen's until the
|
||||
/// pack's GUI atlas is ready) over one opaque white block-sprite layer, an
|
||||
/// empty border texture and the title panorama once the menu bundle is ready.
|
||||
pub(super) fn loading_renderer(
|
||||
window: Arc<Window>,
|
||||
offscreen: bool,
|
||||
atlas: Option<&horizon_client::hud::HudAtlas>,
|
||||
panorama: Option<&Arc<horizon_client::gfx::panorama::Faces>>,
|
||||
previous: Option<&Renderer>,
|
||||
api: horizon_client::graphics::GraphicsApi,
|
||||
) -> Result<Renderer> {
|
||||
static FORCEFIELD: [u8; 4] = [0; 4];
|
||||
static EMPTY_HUD: [u8; 4] = [0, 0, 0, 255];
|
||||
static EMPTY_GLINT: [u8; 4] = [0; 4];
|
||||
let panorama = panorama
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Arc::new(horizon_client::gfx::panorama::Faces::colour([0; 4])));
|
||||
let (pixels, width, height) = atlas.map_or((EMPTY_HUD.as_slice(), 1, 1), |a| {
|
||||
(a.pixels.as_slice(), a.width, a.height)
|
||||
});
|
||||
let (glint_pixels, glint_width, glint_height) = atlas
|
||||
.map_or((EMPTY_GLINT.as_slice(), 1, 1), |a| {
|
||||
(a.glint.0.as_slice(), a.glint.1, a.glint.2)
|
||||
});
|
||||
// A glyph rasterized into an earlier texture is not in these pixels; the
|
||||
// new texture gets it again when the character is drawn (see
|
||||
// `build_renderer`).
|
||||
if let Some(atlas) = atlas {
|
||||
atlas.font.reset_cache();
|
||||
}
|
||||
let mut renderer = pollster::block_on(Renderer::new(
|
||||
window,
|
||||
offscreen,
|
||||
&[255, 255, 255, 255],
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
&[],
|
||||
&[],
|
||||
(pixels, width, height),
|
||||
(glint_pixels, glint_width, glint_height),
|
||||
(glint_pixels, glint_width, glint_height),
|
||||
(&FORCEFIELD[..], 1),
|
||||
panorama,
|
||||
None,
|
||||
previous,
|
||||
api,
|
||||
// It draws no terrain.
|
||||
false,
|
||||
// An 8-bit frame, which HDR output takes as well.
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
if let Some(atlas) = atlas {
|
||||
renderer.hud.sprite_animations = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
.map(|(rect, animation)| (*rect, animation.clone(), (usize::MAX, 0, 0)))
|
||||
.collect();
|
||||
}
|
||||
Ok(renderer)
|
||||
}
|
||||
|
||||
pub(super) fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
Frame {
|
||||
weather: &[],
|
||||
weather_time: horizon_client::weather::Time::new(0, 0.0),
|
||||
camera: glam::DVec3::new(0.5, 100.0, 0.5),
|
||||
yaw: 0.0,
|
||||
pitch: 10.0,
|
||||
fov_degrees: 85.0,
|
||||
eye: glam::DVec3::new(0.5, 100.0, 0.5),
|
||||
eye_rotation: [0.0, 10.0],
|
||||
first_person: true,
|
||||
bob: glam::Mat4::IDENTITY,
|
||||
spin: glam::Mat4::IDENTITY,
|
||||
sky: horizon_client::environment::Sky::default(),
|
||||
shadow_scene_revision: (0, 0),
|
||||
shadow_cache: false,
|
||||
distance: 12.0,
|
||||
detail_distance: 12.0,
|
||||
depth_far: 1000.0,
|
||||
underwater: false,
|
||||
water_plane: None,
|
||||
cave_culling: false,
|
||||
title: true,
|
||||
sky_blocked: false,
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
boss_world_fog: false,
|
||||
environment_fog: None,
|
||||
water_fog: [0.0; 3],
|
||||
fog: None,
|
||||
dark_disc: false,
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
hud,
|
||||
glint_alpha: 0.75,
|
||||
glint_ticks: 0.0,
|
||||
nether_lighting: false,
|
||||
entities: &[],
|
||||
static_block_entities: &[],
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: Default::default(),
|
||||
entity_motion: &[],
|
||||
entity_motion_posed: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
colored_lights: &[],
|
||||
emissive_layers: &[],
|
||||
clouds: None,
|
||||
lightning: Default::default(),
|
||||
additive: &[],
|
||||
see_through: &[],
|
||||
world_primitives: Default::default(),
|
||||
shadow_only: &[],
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
border: None,
|
||||
game_time: 0.0,
|
||||
shader_game_time: 0.0,
|
||||
end_portal_sprites: [[0.0; 4]; 2],
|
||||
crumbling_sprite: [0.0; 4],
|
||||
ticks: 0,
|
||||
darkness: (0.0, 0.0),
|
||||
night_vision: 0.0,
|
||||
block_flicker: 0.0,
|
||||
boss_darken: 0.0,
|
||||
chunk_fade: 0.0,
|
||||
post_effects: horizon_client::post_chain::Request::none(),
|
||||
menu_blur: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The post-load setup once the resources are ready: `Game::new` and the
|
||||
/// options, packs, mods, control socket, benchmark and world selection the
|
||||
/// old `run` did after loading. `None` means a harness or dedicated run
|
||||
/// finished here instead.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn start_game<'a>(
|
||||
res: &'a Arc<Resources>,
|
||||
pack: PackResources,
|
||||
gui: UiResources,
|
||||
options: Options,
|
||||
pack_error: Option<String>,
|
||||
audio: Audio,
|
||||
vanilla_root: PathBuf,
|
||||
started: Instant,
|
||||
rest_loader: Option<WorldRestLoader>,
|
||||
settings: Option<Settings>,
|
||||
prepared: Option<GameCpuResources>,
|
||||
entity_models: Option<Arc<horizon_client::mobs::EntityModelDocument>>,
|
||||
) -> Result<Option<Game<'a>>> {
|
||||
let handoff = prepared.is_some();
|
||||
let mut settings = match settings {
|
||||
Some(settings) => settings,
|
||||
None => Settings::load(&Game::options_path()),
|
||||
};
|
||||
if let Some(d) = options.distance_override {
|
||||
settings.render_distance = d;
|
||||
}
|
||||
// A synchronous launch has no handoff worker: build its CPU resources
|
||||
// here, from the same entity-model document the rest worker read.
|
||||
let prepared = match prepared {
|
||||
Some(prepared) => Some(prepared),
|
||||
None => Some(GameCpuResources::load(
|
||||
&options.resources,
|
||||
entity_models.as_deref(),
|
||||
)?),
|
||||
};
|
||||
let mut game = Game::new(res, pack, gui, options, settings, audio, prepared)?;
|
||||
game.rest_loader = rest_loader;
|
||||
game.ui.vanilla_root = vanilla_root;
|
||||
if let Some(error) = pack_error {
|
||||
// Minecraft.clearResourcePacksOnError: the selection is cleared and
|
||||
// saved, so the next launch starts without the packs.
|
||||
game.settings.resource_packs.clear();
|
||||
game.settings.save(&Game::options_path());
|
||||
game.open_options_at(true, horizon_client::options_ui::Page::Packs);
|
||||
game.ui.options.resource_error = Some(error);
|
||||
}
|
||||
if game.options.benchmark.is_some() {
|
||||
// The same conditions on every machine: no VSync, no frame cap, no
|
||||
// pause or idle limit without focus. In memory only, never saved.
|
||||
game.settings.vsync = false;
|
||||
game.settings.max_fps = 260;
|
||||
game.settings.inactivity_fps_limit = "minimized".into();
|
||||
game.options.no_pause = true;
|
||||
}
|
||||
game.apply_settings();
|
||||
// Benchmarks measure the game alone.
|
||||
if game.options.benchmark.is_none() {
|
||||
game.mods.dev = game.options.dev_mods.clone();
|
||||
game.start_mods();
|
||||
}
|
||||
// After the mods: their templates are among the ids it lists.
|
||||
game.preload_registries();
|
||||
game.start_agent_control()?;
|
||||
if let Some(scenario) = game.options.benchmark.clone() {
|
||||
let control = game
|
||||
.control
|
||||
.get_or_insert_with(horizon_client::control::Server::local);
|
||||
horizon_client::benchmark::start(
|
||||
control.sender(),
|
||||
horizon_client::benchmark::Setup {
|
||||
scenario,
|
||||
started,
|
||||
app: horizon_bench::App {
|
||||
name: "horizon-client".into(),
|
||||
version: horizon_client::crash::version(),
|
||||
commit: option_env!("HORIZON_COMMIT").map(str::to_owned),
|
||||
mods: Vec::new(),
|
||||
},
|
||||
simulation_distance: game.settings.simulation_distance as u32,
|
||||
vsync: false,
|
||||
fps_cap: None,
|
||||
offscreen: game.options.offscreen,
|
||||
game_dir: std::env::current_dir()?,
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some(directory) = std::env::var_os("HORIZON_NATIVE_CLIENT_PARTICLES_FIXTURE") {
|
||||
game.run_native_client_particles_fixture(std::path::Path::new(&directory))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(directory) = std::env::var_os("HORIZON_BOAT_RIDER_FIXTURE") {
|
||||
game.run_boat_rider_fixture(std::path::Path::new(&directory))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(directory) = std::env::var_os("HORIZON_BOAT_AUTHORITY_FIXTURE") {
|
||||
game.run_boat_authority_fixture(std::path::Path::new(&directory))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if let Ok(spec) = std::env::var("HORIZON_DISPENSE_FIXTURE") {
|
||||
game.run_component_harness(integrated::ComponentHarness::Dispenser(spec))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if let Ok(spec) = std::env::var("HORIZON_SCULK_FIXTURE") {
|
||||
game.run_component_harness(integrated::ComponentHarness::Sculk(spec))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if game.options.execution_role == execution_role::ExecutionRole::DedicatedServer {
|
||||
game.run_dedicated()?;
|
||||
return Ok(None);
|
||||
}
|
||||
match game.options.world.clone() {
|
||||
_ if game.options.server.is_some() => {
|
||||
let (address, name) = (
|
||||
game.options.server.clone().unwrap_or_default(),
|
||||
game.options.player_name(),
|
||||
);
|
||||
game.connect_server(&address, &name)?;
|
||||
if let Some(report) = std::env::var_os("HORIZON_NET_REPORT") {
|
||||
game.run_net_harness(report.as_ref())?;
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Some(folder) => {
|
||||
let (seed, creative) = (game.options.seed, game.options.creative);
|
||||
if std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some() {
|
||||
// The loaded-command fixture pins the server's valid minimum.
|
||||
// Client options reject simulationDistance2 (their UI starts at5),
|
||||
// so an options.txt value cannot establish this prerequisite.
|
||||
if std::env::var_os("HORIZON_HARNESS_LOADED_FIXTURE").is_some() {
|
||||
game.settings.simulation_distance = 2;
|
||||
}
|
||||
// Headless, but with the same server and memory connection as play.
|
||||
game.open_integrated(&folder, None, Some(seed), creative, None)?;
|
||||
} else {
|
||||
// Start after the window is handed over, so --world uses the
|
||||
// same responsive initialization path as the title screen.
|
||||
game.title = Some(
|
||||
Title::new(
|
||||
std::env::current_dir()?.join("saves"),
|
||||
&game.options.resources,
|
||||
)
|
||||
.with_user(|| game.options.player_name()),
|
||||
);
|
||||
game.pending_entry = Some(PendingWorldEntry::Local {
|
||||
folder,
|
||||
name: None,
|
||||
seed: Some(seed),
|
||||
creative,
|
||||
create: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
None if handoff => {}
|
||||
None => {
|
||||
let mut title = Title::new(
|
||||
std::env::current_dir()?.join("saves"),
|
||||
&game.options.resources,
|
||||
)
|
||||
.with_user(|| game.options.player_name());
|
||||
title.last_server = game.settings.file.string("lastServer");
|
||||
title.skip_multiplayer_warning = game.settings.file.bool("skipMultiplayerWarning");
|
||||
// Gui.addInitialScreens: the accessibility onboarding comes first.
|
||||
if game.settings.file.bool("onboardAccessibility") {
|
||||
title.start_onboarding();
|
||||
}
|
||||
game.title = Some(title);
|
||||
}
|
||||
}
|
||||
if std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some()
|
||||
&& std::env::var_os("HORIZON_COMMAND_OUTPUT").is_some()
|
||||
{
|
||||
game.wait_command_harness()?;
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(game))
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! A player's melee attack (`Player.attack`): the attack strength, critical and sweep attacks, and
|
||||
//! what a hit on a target that is not a mob does.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Player.getAttackStrengthScale(0.5)`.
|
||||
pub(super) fn attack_strength_scale(&self) -> f32 {
|
||||
// 1.8.9 had no attack cooldown.
|
||||
if self.legacy_rules() {
|
||||
return 1.0;
|
||||
}
|
||||
let delay = horizon_client::attack_strength::delay(self.main_hand_attack_speed());
|
||||
((self.attack_ticks as f32 + 0.5) / delay).clamp(0.0, 1.0)
|
||||
}
|
||||
/// `Player.isSweepAttack`: a charged, grounded sword hit below the movement limit.
|
||||
pub(super) fn sweep_attack_position(&mut self, scale: f32, crit: bool) -> Option<[f64; 3]> {
|
||||
// The sweep came with 1.9.
|
||||
if self.legacy_rules() {
|
||||
return None;
|
||||
}
|
||||
// Player.getSpeed: the movement speed attribute as it is now (the
|
||||
// field is only refreshed by the local movement step, never for a
|
||||
// server's player).
|
||||
let p = self.player.as_mut()?;
|
||||
if let Ok(speed) = p.attribute("minecraft:movement_speed") {
|
||||
p.speed = speed as f32;
|
||||
}
|
||||
let p = self.player.as_ref()?;
|
||||
let sword = self.held().is_some_and(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:swords")
|
||||
.is_some_and(|tag| tag.contains(&*s.item))
|
||||
});
|
||||
if !p.is_sweep_attack(scale, crit, sword) {
|
||||
return None;
|
||||
}
|
||||
// Mth.sin / Mth.cos of yRot * (PI / 180), from its sine table.
|
||||
use horizon_core::geometry::trig;
|
||||
let yaw = f64::from(self.yaw * trig::DEG_TO_RAD);
|
||||
Some([
|
||||
p.position.x - f64::from(trig::sin(yaw)),
|
||||
p.position.y + f64::from(p.dimensions.height) * 0.5,
|
||||
p.position.z + f64::from(trig::cos(yaw)),
|
||||
])
|
||||
}
|
||||
/// `Player.doSweepAttack` after a successful hit, in vanilla's order: the
|
||||
/// sweep sound, the victims around `target` (its box, and the mob that
|
||||
/// was hit, when there is one) and then the arc.
|
||||
pub(super) fn sweep_attack(
|
||||
&mut self,
|
||||
at: [f64; 3],
|
||||
from: Option<[f64; 3]>,
|
||||
target: Option<(Aabb, mace::Spared)>,
|
||||
scale: f32,
|
||||
) {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0);
|
||||
if let Some((area, mob)) = target {
|
||||
self.sweep_damage(area, mob, scale);
|
||||
}
|
||||
let arc = horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::SweepAttack,
|
||||
);
|
||||
self.particles.add(&arc, at, [0.0; 3]);
|
||||
// Player.sweepAttack's `sendParticles(SWEEP_ATTACK, ..., 0, dx, 0, dz, 0)`:
|
||||
// the offset is the one from the player to the particle.
|
||||
let origin = from.unwrap_or(at);
|
||||
let spread = [at[0] - origin[0], 0.0, at[2] - origin[2]];
|
||||
self.lan_send_particles(lan_particles::QueuedParticle {
|
||||
dimension: self.dim.id(),
|
||||
options: arc,
|
||||
state: None,
|
||||
at,
|
||||
spread,
|
||||
speed: [0.0; 3],
|
||||
count: 0,
|
||||
randomization: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// `Player.attack` after a target that is not a mob took the hit:
|
||||
/// the sprint knockback sound, a living target's critical hit (`living`:
|
||||
/// its entity id; `crit`: whether `melee_damage` decided the hit critical,
|
||||
/// mods' `critical_hit` included), the sweep around the target's box `area`
|
||||
/// and the strong or weak sound.
|
||||
pub(super) fn attack_effects(
|
||||
&mut self,
|
||||
area: Aabb,
|
||||
living: Option<i32>,
|
||||
crit: bool,
|
||||
spared: mace::Spared,
|
||||
magic: Option<i32>,
|
||||
) {
|
||||
let scale = self.attack_strength_scale();
|
||||
let full = scale > 0.9;
|
||||
let player = self.player.as_ref();
|
||||
let from = player.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
if full && player.is_some_and(|p| p.sprinting) {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.knockback", from, 1.0, 1.0);
|
||||
}
|
||||
let crit = living.is_some() && crit;
|
||||
let sweep = self.sweep_attack_position(scale, crit);
|
||||
if let Some(at) = sweep {
|
||||
self.sweep_attack(at, from, Some((area, spared)), scale);
|
||||
}
|
||||
if crit {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.crit", from, 1.0, 1.0);
|
||||
if let Some(id) = living {
|
||||
self.area_critical_hit(id, area, false);
|
||||
}
|
||||
} else if sweep.is_none() {
|
||||
let sound = if full {
|
||||
"minecraft:entity.player.attack.strong"
|
||||
} else {
|
||||
"minecraft:entity.player.attack.weak"
|
||||
};
|
||||
self.audio.play(sound, from, 1.0, 1.0);
|
||||
}
|
||||
// Player.magicCrit: the enchantments added damage.
|
||||
if let Some(id) = magic {
|
||||
self.area_critical_hit(id, area, true);
|
||||
}
|
||||
}
|
||||
/// `critical_hit` around the box `area` of entity `id` (`magic`: the
|
||||
/// enchanted hit instead of the crit).
|
||||
pub(super) fn area_critical_hit(&mut self, id: i32, area: Aabb, magic: bool) {
|
||||
let (feet, size) = (area.bottom_center(), area.size());
|
||||
self.critical_hit(
|
||||
self.lan_acting,
|
||||
id,
|
||||
[feet.x, feet.y, feet.z],
|
||||
size.x,
|
||||
size.y,
|
||||
magic,
|
||||
);
|
||||
}
|
||||
/// `Player.canCriticalAttack` for a living target at attack strength
|
||||
/// `scale`: falling, off the ground, not in water, not on a ladder, not blind (mobility
|
||||
/// restricted), not riding, not sprinting.
|
||||
pub(super) fn critical_attack(&self, scale: f32) -> bool {
|
||||
let player = self.player.as_ref();
|
||||
let falling = player.is_some_and(|p| {
|
||||
!p.ground.on_ground
|
||||
&& p.fall_distance > 0.0
|
||||
&& !p.fluids.was_touching_water
|
||||
&& !p.on_climbable
|
||||
&& !p.sprinting
|
||||
});
|
||||
let restricted = self.potions.effects.get("blindness").is_some() || self.riding.is_some();
|
||||
scale > 0.9 && falling && !restricted
|
||||
}
|
||||
/// Player.attack with the attack-strength charge, crits and knockback.
|
||||
pub(super) fn attack(&mut self) -> bool {
|
||||
if self.spectator() {
|
||||
return false;
|
||||
}
|
||||
// A painting nearer than the targeted block and mob breaks instead.
|
||||
if let Some((i, d)) = self.painting_hit()
|
||||
&& self.mob_hit().is_none_or(|m| d < m.1)
|
||||
&& self.before_block(d)
|
||||
{
|
||||
self.break_painting(i, true);
|
||||
return true;
|
||||
}
|
||||
// A ghast's fireball or a wind charge in front: turned back.
|
||||
if self.attack_redirectable() {
|
||||
return true;
|
||||
}
|
||||
if self.attack_frame()
|
||||
|| self.attack_armor_stand()
|
||||
|| self.attack_cushion()
|
||||
|| self.attack_boat()
|
||||
|| self.attack_cart()
|
||||
|| self.attack_dragon()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let Some((index, distance, _)) = self.mob_hit() else {
|
||||
return false;
|
||||
};
|
||||
if !self.before_block(distance) || !self.held_range_allows(distance) {
|
||||
return false;
|
||||
}
|
||||
self.attack_mob(index);
|
||||
// Minecraft.startAttack swings after the attack.
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ impl Game<'_> {
|
||||
/// or the server stops, not saved: keep what you hold (a level
|
||||
/// attachment) and hold it again in `native_level_load`. True when the
|
||||
/// set of owners changed; false for an owner without a namespace, a chunk
|
||||
/// past the world's edge (as `/forceload` refuses it) and off a server. API @API:chunk_tickets@.
|
||||
/// past the world's edge (as `/forceload` refuses it) and off a server. API 412.
|
||||
pub(super) fn native_force_chunk(&mut self, owner: &str, chunk: [i32; 2], hold: bool) -> bool {
|
||||
self.native_chunk_ticket(self.dim, owner, chunk, hold)
|
||||
}
|
||||
@@ -28,7 +28,7 @@ impl Game<'_> {
|
||||
/// level held this way opens at the next server tick and ticks from then
|
||||
/// on, so a mod can generate or run the Nether and the End from any
|
||||
/// server hook. False also for a dimension other than the Overworld, the
|
||||
/// Nether and the End. API @API:chunk_tickets_in_level@.
|
||||
/// Nether and the End. API 413.
|
||||
pub(super) fn native_force_chunk_in(
|
||||
&mut self,
|
||||
owner: &str,
|
||||
@@ -103,7 +103,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// The chunks of the level in place `owner` holds (`native_force_chunk`),
|
||||
/// sorted. API @API:chunk_tickets@.
|
||||
/// sorted. API 412.
|
||||
pub(super) fn native_forced_chunks(&self, owner: &str) -> Vec<[i32; 2]> {
|
||||
let mut held: Vec<[i32; 2]> = self
|
||||
.mod_chunk_tickets
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
//! Horizon's own HUD extras do, and keep a mod's settings where the
|
||||
//! WebAssembly mod of the same id kept them.
|
||||
//!
|
||||
//! API 413 adds chunk tickets in a level nobody is in (`native_force_chunk_in`).
|
||||
//!
|
||||
//! API 412 adds chunks a mod keeps loaded and ticking (`native_force_chunk`, `native_forced_chunks`).
|
||||
//!
|
||||
//! API 411 adds client Host services, GUI painting, model-head snapshots and client lifecycle notifications.
|
||||
//!
|
||||
//! API 410 adds a final foreground GUI overlay (`native_gui_render_post`) and the current frame's camera during GUI rendering.
|
||||
@@ -1167,7 +1171,7 @@ pub(super) use horizon_world::level::flags;
|
||||
|
||||
/// The hook points' API version. The launcher reads this line from the
|
||||
/// source it builds; keep its form.
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 411;
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 413;
|
||||
|
||||
impl GameLevel<'_> {
|
||||
/// A hopper at `pos` is about to pick up `count` of `item`. Set `cancel` to leave the dropped stack in the world. Server-side. 232.
|
||||
|
||||
@@ -654,9 +654,21 @@ impl Game<'_> {
|
||||
before.clear();
|
||||
}
|
||||
let mut ids: Vec<(u32, bool)> = Vec::new();
|
||||
// ServerLevel.tick ticks only the entities in entity-ticking chunks,
|
||||
// and handlePortal runs in their tick: past the simulation distance an
|
||||
// entity's portal time and cooldown wait, kept as they are.
|
||||
let entity_ticking = self.entity_ticking_area();
|
||||
let mut waiting: Vec<u32> = Vec::new();
|
||||
let tracked = &self.portals.entities;
|
||||
for_each_entity!(entity_lists_of!(self), entity => {
|
||||
if entity.portal_size().is_some() && entity.is_vehicle() == vehicles {
|
||||
let at = entity.position();
|
||||
if !entity_ticking(at.x.floor() as i32, at.z.floor() as i32) {
|
||||
if tracked.contains_key(&entity.id()) {
|
||||
waiting.push(entity.id());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if entity.portal_from().is_none() {
|
||||
before.insert(entity.id(), entity.position());
|
||||
}
|
||||
@@ -666,7 +678,11 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
self.portals.before = before;
|
||||
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
|
||||
let present: std::collections::HashSet<u32> = ids
|
||||
.iter()
|
||||
.map(|(id, _)| *id)
|
||||
.chain(waiting.iter().copied())
|
||||
.collect();
|
||||
// Each pass keeps the other group's states.
|
||||
let (boats, carts) = (&self.boats, &self.carts);
|
||||
self.portals.entities.retain(|id, _| {
|
||||
@@ -840,16 +856,22 @@ impl Game<'_> {
|
||||
}
|
||||
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
|
||||
let mut portal_sections = std::mem::take(&mut self.portals.sections);
|
||||
// Only the entities that tick (in entity-ticking chunks) move, so only
|
||||
// they can have swept into a portal.
|
||||
let entity_ticking = self.entity_ticking_area();
|
||||
// The first portal block each entity's box swept through since the
|
||||
// start of its tick (a stuck arrow that did not move enters none).
|
||||
for_each_entity!(entity_lists_of!(self), entity => {
|
||||
let Some((width, height)) = entity.portal_size() else {
|
||||
continue;
|
||||
};
|
||||
let (id, to) = (entity.id(), entity.position());
|
||||
if !entity_ticking(to.x.floor() as i32, to.z.floor() as i32) {
|
||||
continue;
|
||||
}
|
||||
if !entity.portal_sweeps() || !entity.can_use_portal(self) {
|
||||
continue;
|
||||
}
|
||||
let (id, to) = (entity.id(), entity.position());
|
||||
let swept_later = match only {
|
||||
Some(ids) => !ids.contains(&id),
|
||||
None => ridden.contains(&id),
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
//! Key and mouse mappings in play: toggled sneak and sprint, keys after rebinding, keys held
|
||||
//! across screens, the hotbar's wheel and bumper steps, and mouse buttons while playing.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Keep the held-key state of toggled sneak and sprint in step with their mapping.
|
||||
/// `KeyMapping.resetToggleKeys`: every toggle mapping up, and the latched sneak and sprint
|
||||
/// keys with it.
|
||||
pub(super) fn reset_toggle_keys(&mut self) {
|
||||
for key in &mut self.toggles.0 {
|
||||
key.reset();
|
||||
}
|
||||
self.sync_toggle(0);
|
||||
self.sync_toggle(1);
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
}
|
||||
/// key.smoothCamera from any device toggles the cinematic camera in play.
|
||||
pub(super) fn smooth_camera_action(&mut self) {
|
||||
if self.screen == Screen::Playing {
|
||||
self.smooth_camera = !self.smooth_camera;
|
||||
}
|
||||
}
|
||||
pub(super) fn sync_toggle(&mut self, slot: usize) {
|
||||
let key = [KeyCode::ShiftLeft, KeyCode::ControlLeft][slot];
|
||||
if self.toggles.is_down(slot) {
|
||||
self.keys.insert(key);
|
||||
} else {
|
||||
self.keys.remove(&key);
|
||||
}
|
||||
}
|
||||
/// Let a held sneak, sprint or modifier key go when the keyboard reports
|
||||
/// its modifier up. A release does not always reach the mapping: a screen
|
||||
/// reads presses only, and an unfocused window is given no release at all,
|
||||
/// so the latch would keep the player sneaking or sprinting until the key
|
||||
/// is tapped again. The modifier state is what the keyboard itself
|
||||
/// asserts, so it settles the question. A mapping that toggles, or one a
|
||||
/// pad latched, keeps its state: `set_down(false, needs_toggle)` leaves
|
||||
/// those alone, and a toggle the player asked for is meant to outlive the
|
||||
/// key. A mapping rebound away from its modifier is left alone too: the
|
||||
/// modifier says nothing about it, and so is one a controller holds.
|
||||
///
|
||||
/// Only `physical_keys` is cleared directly here; the logical `self.keys`
|
||||
/// entries for sneak and sprint are left to the toggle loop below, because
|
||||
/// they stand for the mapping and not the physical Shift or Control.
|
||||
/// `Screen.hasShiftDown` and `hasControlDown` read the physical state
|
||||
/// (vanilla's `InputConstants.isKeyDown`), so a Shift no one is holding
|
||||
/// makes every inventory click a shift-click and every throw a whole-stack
|
||||
/// drop. Those do not care what sneak and sprint are bound to. A modifier
|
||||
/// says nothing about which side is down, so one being up means both
|
||||
/// Shifts, or both Controls, are.
|
||||
pub(super) fn heal_hold_latches(&mut self) {
|
||||
let (shift, control) = (self.modifiers.shift_key(), self.modifiers.control_key());
|
||||
if !shift {
|
||||
for key in [KeyCode::ShiftLeft, KeyCode::ShiftRight] {
|
||||
self.physical_keys.remove(&key);
|
||||
}
|
||||
}
|
||||
if !control {
|
||||
for key in [KeyCode::ControlLeft, KeyCode::ControlRight] {
|
||||
self.physical_keys.remove(&key);
|
||||
}
|
||||
}
|
||||
for (slot, mapping, needs_toggle) in [
|
||||
(0, "key.sneak", self.settings.toggle_crouch),
|
||||
(1, "key.sprint", self.settings.toggle_sprint),
|
||||
] {
|
||||
let held = match horizon_client::keybinds::key_code(self.settings.keys.get(mapping)) {
|
||||
Some(KeyCode::ShiftLeft | KeyCode::ShiftRight) => shift,
|
||||
Some(KeyCode::ControlLeft | KeyCode::ControlRight) => control,
|
||||
_ => continue,
|
||||
};
|
||||
// A controller's stick click acts as the key without setting the modifier: it is held
|
||||
// while the pad holds it (or its release waits for a tick), not when the keyboard says.
|
||||
let pad = self.pad.holds_action(&self.settings.controller, mapping);
|
||||
if !held && !pad {
|
||||
self.toggles.0[slot].set_down(false, needs_toggle);
|
||||
self.sync_toggle(slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether the other device still holds the sneak or sprint mapping `source` released.
|
||||
pub(super) fn toggle_held_elsewhere(&self, slot: usize, key: KeyCode, source: Source) -> bool {
|
||||
match source {
|
||||
// A pad's toggle-sneak isn't holding it: it has no release that would end it.
|
||||
Source::Keyboard => {
|
||||
!(slot == SNEAK && self.settings.controller.toggle_sneak)
|
||||
&& self
|
||||
.pad
|
||||
.holds_action(&self.settings.controller, ["key.sneak", "key.sprint"][slot])
|
||||
}
|
||||
Source::Pad => self.physical_keys.iter().any(|physical| {
|
||||
self.settings
|
||||
.keys
|
||||
.translate_all(*physical)
|
||||
.contains(&Ok(key))
|
||||
}),
|
||||
Source::Agent => false,
|
||||
}
|
||||
}
|
||||
/// Apply a physical key to every mapping bound to it.
|
||||
pub(super) fn mapped_key_event(
|
||||
&mut self,
|
||||
key: KeyCode,
|
||||
pressed: bool,
|
||||
repeat: bool,
|
||||
) -> Result<()> {
|
||||
for mapped in self.settings.keys.translate_all(key) {
|
||||
match mapped {
|
||||
Ok(k) => {
|
||||
self.mapped_key(k, pressed, repeat, key, Source::Keyboard)?;
|
||||
}
|
||||
Err(button) => {
|
||||
let action = match (pressed, repeat) {
|
||||
(false, _) => KeyAction::Release,
|
||||
(_, false) => KeyAction::Press,
|
||||
(_, true) => KeyAction::Repeat,
|
||||
};
|
||||
if let Some(pressed) = keyboard_button(
|
||||
gameplay_has_keyboard(
|
||||
self.screen,
|
||||
self.title.is_some(),
|
||||
self.screen_over_playing(),
|
||||
),
|
||||
action,
|
||||
) {
|
||||
if self.shared_default_acts(button, horizon_client::keybinds::key_name(key))
|
||||
{
|
||||
self.mouse_mapping(button, pressed, Source::Keyboard)?;
|
||||
}
|
||||
} else if container_key_clones(button, action) {
|
||||
self.inventory_pick_key();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// A key event after rebinding, as the default key of its mapping (the
|
||||
/// keyboard and controllers both end here). `raw` is the physical key
|
||||
/// the event started from (`key` where the caller started from no window
|
||||
/// key): the screens act in that keyspace (`Screen.keyPressed`), where
|
||||
/// `key`, the vanilla default the mapping translated to, may itself be
|
||||
/// another mapping's rebound key. `source` is the device the event came
|
||||
/// from: a key stays held while any device holds it.
|
||||
pub(super) fn mapped_key(
|
||||
&mut self,
|
||||
key: KeyCode,
|
||||
pressed: bool,
|
||||
repeat: bool,
|
||||
raw: KeyCode,
|
||||
source: Source,
|
||||
) -> Result<()> {
|
||||
// Sneak and sprint are ToggleKeyMappings: a press (or a key
|
||||
// repeat) flips them when toggling; only releases reach them
|
||||
// behind a screen (KeyboardHandler.keyPress).
|
||||
let toggle = match key {
|
||||
KeyCode::ShiftLeft => Some((0, self.settings.toggle_crouch)),
|
||||
KeyCode::ControlLeft => Some((1, self.settings.toggle_sprint)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((slot, option)) = toggle {
|
||||
let action = match (pressed, repeat) {
|
||||
(false, _) => KeyAction::Release,
|
||||
(_, false) => KeyAction::Press,
|
||||
(_, true) => KeyAction::Repeat,
|
||||
};
|
||||
// One device letting go doesn't let go for the other still holding it.
|
||||
if !pressed && self.toggle_held_elsewhere(slot, key, source) {
|
||||
return Ok(());
|
||||
}
|
||||
let screen_open = !gameplay_has_keyboard(
|
||||
self.screen,
|
||||
self.title.is_some(),
|
||||
self.screen_over_playing(),
|
||||
);
|
||||
self.toggles.key(slot, action, screen_open, option);
|
||||
self.sync_toggle(slot);
|
||||
if !pressed {
|
||||
return Ok(());
|
||||
}
|
||||
if self.keys.raw(&key) {
|
||||
self.keys.press(key, source);
|
||||
}
|
||||
} else if !pressed {
|
||||
self.keys.release(&key, source);
|
||||
return Ok(());
|
||||
} else {
|
||||
self.keys.press(key, source);
|
||||
}
|
||||
// KeyboardHandler.keyPress: a repeat (InputConstants.REPEAT) reaches
|
||||
// Screen.keyPressed and KeyMapping.click like a press, so holding
|
||||
// key.drop keeps throwing, in game and over a menu slot.
|
||||
if repeat && key != KeyCode::KeyQ {
|
||||
return Ok(());
|
||||
}
|
||||
self.key_pressed(key, raw)
|
||||
}
|
||||
/// `KeyMapping.releaseAll` when a screen opens and
|
||||
/// `restoreToggleStatesOnScreenClosed` when it closes.
|
||||
pub(super) fn track_toggle_screens(&mut self) {
|
||||
let dialog = self.dialog_open();
|
||||
drop_held_behind_dialog(dialog, &mut self.keys, &mut self.left, &mut self.right);
|
||||
let playing = gameplay_has_keyboard(
|
||||
self.screen,
|
||||
self.title.is_some(),
|
||||
self.screen_over_playing(),
|
||||
);
|
||||
if playing == self.was_playing {
|
||||
return;
|
||||
}
|
||||
self.was_playing = playing;
|
||||
let options = [
|
||||
self.settings.toggle_crouch,
|
||||
self.settings.toggle_sprint,
|
||||
self.settings.toggle_attack,
|
||||
self.settings.toggle_use,
|
||||
];
|
||||
if playing {
|
||||
self.toggles.screen_closed(options);
|
||||
} else {
|
||||
self.toggles.screen_opened(options);
|
||||
}
|
||||
self.sync_toggle(0);
|
||||
self.sync_toggle(1);
|
||||
self.left = self.toggles.is_down(2);
|
||||
self.right = self.toggles.is_down(3);
|
||||
}
|
||||
|
||||
/// `MouseHandler.onScroll`: the hotbar (or the spectator menu) takes the wheel only when no
|
||||
/// screen is open; a book, lectern, sign or prompt drawn over gameplay is one.
|
||||
pub(super) fn hotbar_takes_wheel(&self) -> bool {
|
||||
self.screen == Screen::Playing
|
||||
&& !self.screen_over_playing()
|
||||
&& self.sleeping.is_none()
|
||||
&& self.player.is_some()
|
||||
}
|
||||
/// A mod's `MouseScrolling` cancels the step about to turn the hotbar.
|
||||
pub(super) fn hotbar_scroll_canceled(&mut self, scroll: [f64; 2], steps: [i32; 2]) -> bool {
|
||||
let mut canceled = false;
|
||||
self.native_mouse_scrolling(
|
||||
scroll,
|
||||
steps,
|
||||
[
|
||||
self.physical_buttons.contains(&MouseButton::Left),
|
||||
self.physical_buttons.contains(&MouseButton::Middle),
|
||||
self.physical_buttons.contains(&MouseButton::Right),
|
||||
],
|
||||
[self.cursor.0, self.cursor.1],
|
||||
glfw_modifiers(self.modifiers),
|
||||
&mut canceled,
|
||||
);
|
||||
canceled
|
||||
}
|
||||
/// What one whole wheel step does in play (`MouseHandler.onScroll`): a spectator's menu or
|
||||
/// flying speed, else the selected hotbar slot.
|
||||
pub(super) fn hotbar_wheel_step(&mut self, wheel: i32, vertical_steps: i32) {
|
||||
if self.spectator() && self.spectator_gui.is_menu_active() {
|
||||
self.spectator_scroll(-wheel);
|
||||
} else if self.spectator() {
|
||||
if let Some(player) = &mut self.player {
|
||||
player.flying_speed = horizon_client::mouse::spectator_flying_speed(
|
||||
player.flying_speed,
|
||||
vertical_steps,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.selected = horizon_client::mouse::ScrollWheelHandler::next_selection(
|
||||
f64::from(wheel),
|
||||
self.selected as i32,
|
||||
9,
|
||||
) as usize;
|
||||
}
|
||||
}
|
||||
/// A step of the hotbar from a controller's bumper: one wheel notch, down for the next slot,
|
||||
/// through everything the wheel goes through.
|
||||
pub(super) fn hotbar_step(&mut self, next: bool) {
|
||||
let wheel = if next { -1 } else { 1 };
|
||||
if self.hotbar_takes_wheel()
|
||||
&& !self.native_wheel_event(wheel as f32)
|
||||
&& !self.hotbar_scroll_canceled([0.0, f64::from(wheel)], [0, wheel])
|
||||
{
|
||||
self.hotbar_wheel_step(wheel, wheel);
|
||||
}
|
||||
}
|
||||
/// A mouse mapping pressed or let go by `source`. A release waits while another device holds
|
||||
/// the mapping, so letting go of one can't end what the other still presses, as for keys
|
||||
/// (`HeldKeys`).
|
||||
pub(super) fn mouse_mapping(
|
||||
&mut self,
|
||||
button: MouseButton,
|
||||
pressed: bool,
|
||||
source: Source,
|
||||
) -> Result<()> {
|
||||
if !pressed && self.mouse_mapping_held_elsewhere(button, source) {
|
||||
return Ok(());
|
||||
}
|
||||
self.play_mouse(button, pressed)
|
||||
}
|
||||
/// Whether a device other than `source` holds what `button` stands for. Asks each device's own
|
||||
/// live state rather than keeping a count: a screen takes a mouse release without telling the
|
||||
/// mappings, and a count would then hold the attack for good. A script keeps no such state, so
|
||||
/// nothing waits for a script's mouse hold.
|
||||
pub(super) fn mouse_mapping_held_elsewhere(&self, button: MouseButton, source: Source) -> bool {
|
||||
let mapping = match button {
|
||||
MouseButton::Left => "key.attack",
|
||||
MouseButton::Right => "key.use",
|
||||
_ => return false,
|
||||
};
|
||||
match source {
|
||||
Source::Keyboard | Source::Agent => {
|
||||
self.pad.holds_action(&self.settings.controller, mapping)
|
||||
}
|
||||
Source::Pad => {
|
||||
let keys = &self.settings.keys;
|
||||
self.physical_buttons
|
||||
.iter()
|
||||
.any(|held| keys.translate_mouse_all(*held).contains(&Err(button)))
|
||||
|| self
|
||||
.physical_keys
|
||||
.iter()
|
||||
.any(|held| keys.translate_all(*held).contains(&Err(button)))
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Two mappings with one default button are separate mappings all the same
|
||||
/// (`Minecraft.handleKeybinds`): whether the middle-button action a physical `input` translated
|
||||
/// to is one of those it is bound to, for this player now.
|
||||
pub(super) fn shared_default_acts(&self, button: MouseButton, input: Option<&str>) -> bool {
|
||||
button != MouseButton::Middle
|
||||
|| input.is_none_or(|input| self.settings.keys.middle_acts(input, self.spectator()))
|
||||
}
|
||||
/// A mouse button while playing (window or control socket).
|
||||
pub(super) fn play_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
|
||||
if pressed {
|
||||
self.pvp_click(button);
|
||||
}
|
||||
if self.sleeping.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
// key.spectatorHotbar (the middle button) opens the spectator menu
|
||||
// and uses its selected item.
|
||||
if button == MouseButton::Middle && self.spectator() {
|
||||
if pressed {
|
||||
self.spectator_action_key();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
return self.remote_mouse(button, pressed);
|
||||
}
|
||||
match button {
|
||||
MouseButton::Left => {
|
||||
// KeyMapping.click: Minecraft.tick starts the attack.
|
||||
if pressed {
|
||||
self.attack_clicks += 1;
|
||||
}
|
||||
self.toggles
|
||||
.button(2, pressed, false, self.settings.toggle_attack);
|
||||
self.left = self.toggles.is_down(2);
|
||||
}
|
||||
MouseButton::Right => {
|
||||
self.toggles
|
||||
.button(3, pressed, false, self.settings.toggle_use);
|
||||
self.right = self.toggles.is_down(3);
|
||||
// handleKeybinds consumes use clicks without acting while an
|
||||
// item is in use. Minecraft.tick runs them after the attack
|
||||
// clicks, so a place and a break in one tick replace the block.
|
||||
if pressed && self.using.is_none() {
|
||||
self.use_clicks += 1;
|
||||
}
|
||||
}
|
||||
MouseButton::Middle if pressed => {
|
||||
// handleKeybinds: the number keys select before key.pickItem.
|
||||
self.select_queued_hotbar();
|
||||
// Minecraft.pickBlockOrEntity -> the server's handlePickItemFromBlock:
|
||||
// the block's clone item, with its block entity's data on Ctrl in
|
||||
// creative, picked from the inventory (`pick_item`).
|
||||
if let Some(entity) = self.pick_entity_hit() {
|
||||
// handlePickItemFromEntity: the entity's pick result.
|
||||
if let Some(stack) = entity.and_then(|id| self.entity_pick_result(id)) {
|
||||
let infinite = self.creative;
|
||||
horizon_client::pick_item::pick(
|
||||
&mut self.inventory[..36],
|
||||
&mut self.selected,
|
||||
&stack,
|
||||
infinite,
|
||||
);
|
||||
}
|
||||
} else if let Some(hit) = self.pick() {
|
||||
let include_data = self.creative && self.has_control_down();
|
||||
if let Some(stack) = self.picked_stack(hit.pos, include_data) {
|
||||
let infinite = self.creative;
|
||||
horizon_client::pick_item::pick(
|
||||
&mut self.inventory[..36],
|
||||
&mut self.selected,
|
||||
&stack,
|
||||
infinite,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
//! A key press while a world is open (`KeyboardHandler.keyPress` and the gameplay mappings).
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// A key press while a world is open (keyboard or control socket). `key`
|
||||
/// is the vanilla default the pressed mapping translates to, `raw` the
|
||||
/// physical key as pressed (the same where the caller had no window key):
|
||||
/// `Screen.keyPressed` hears the raw one, and only a raw key settles what
|
||||
/// the container's hotbar keys are now (`checkHotbarKeyPressed`).
|
||||
pub(super) fn key_pressed(&mut self, key: KeyCode, raw: KeyCode) -> Result<()> {
|
||||
// `CreativeModeInventoryScreen.keyPressed` clears `ignoreTextInput` on
|
||||
// every key, before it decides anything.
|
||||
self.creative_ignore_text = false;
|
||||
if self.sleeping.is_some()
|
||||
&& self.screen == Screen::Playing
|
||||
&& !matches!(
|
||||
key,
|
||||
KeyCode::F1 | KeyCode::F2 | KeyCode::F11 | KeyCode::KeyT | KeyCode::Slash
|
||||
)
|
||||
{
|
||||
if matches!(key, KeyCode::Escape | KeyCode::ShiftLeft) {
|
||||
self.wake_up();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.sort_key_press(key)
|
||||
|| self.hdr_key_press(key)
|
||||
|| horizon_client::keybinds::key_name(key)
|
||||
.is_some_and(|name| self.hud_editor_key_press(name))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if key == KeyCode::Escape && self.mod_browser_back() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.remote.is_some() && self.remote_key(key)? {
|
||||
return Ok(());
|
||||
}
|
||||
match key {
|
||||
KeyCode::Escape => match self.screen {
|
||||
Screen::Options => self.options_key(KeyCode::Escape, None)?,
|
||||
Screen::Playing => {
|
||||
if self.studio_replay.is_some() && self.captured {
|
||||
self.capture(false);
|
||||
return Ok(());
|
||||
}
|
||||
self.pause_focus = None;
|
||||
self.screen = Screen::Paused;
|
||||
self.pause_menu = true;
|
||||
self.capture(false);
|
||||
}
|
||||
Screen::Creative => {
|
||||
self.pad_clear_focus();
|
||||
self.mod_browser_closed();
|
||||
self.carried = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
Screen::Paused | Screen::Inventory => {
|
||||
self.close_container();
|
||||
self.pause_focus = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.pause_menu = true;
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
Screen::Stats | Screen::Friends => self.screen = Screen::Paused,
|
||||
Screen::Social => self.close_social(),
|
||||
Screen::ServerLinks => self.screen = Screen::Paused,
|
||||
// ConfirmScreen.keyPressed: Escape answers no; both return to
|
||||
// the Server Links screen they came from.
|
||||
Screen::ConfirmLink => self.screen = self.confirm_link_back(),
|
||||
Screen::DialogWarning => self.screen = Screen::ServerLinks,
|
||||
Screen::Advancements => self.close_advancements(),
|
||||
Screen::WorldOptions => self.world_options_escape(),
|
||||
Screen::CommandBlock => self.close_command_block(),
|
||||
Screen::BlockEditor => self.close_block_editor(),
|
||||
Screen::Chat => self.close_chat(chat::ChatExit::Intentional),
|
||||
Screen::Win => self.close_win_screen()?,
|
||||
Screen::DebugOptions => self.close_debug_options(),
|
||||
Screen::Mod => self.close_mod_screen(),
|
||||
Screen::HudEditor => self.close_hud_editor(),
|
||||
},
|
||||
// `checkHotbarKeyPressed`: the keys the controls bind to the off
|
||||
// hand and the hotbar slots, so a rebound key swaps instead of
|
||||
// reaching the screen. The list below keeps Q and the defaults.
|
||||
// The raw key asks what the bindings are now: the translated
|
||||
// `key` is a vanilla default and may itself be another mapping's
|
||||
// rebound key (the off hand on Left Alt while hotbar 7 is on F
|
||||
// would find that F a second time and swap with slot 7).
|
||||
_ if (self.screen == Screen::Inventory
|
||||
|| (self.screen == Screen::Creative && !self.creative_screen.tab().search))
|
||||
&& self
|
||||
.settings
|
||||
.keys
|
||||
.container_hotbar_key(raw)
|
||||
.is_some_and(|key| self.inventory_hotkey(key)) => {}
|
||||
KeyCode::Digit1
|
||||
| KeyCode::Digit2
|
||||
| KeyCode::Digit3
|
||||
| KeyCode::Digit4
|
||||
| KeyCode::Digit5
|
||||
| KeyCode::Digit6
|
||||
| KeyCode::Digit7
|
||||
| KeyCode::Digit8
|
||||
| KeyCode::Digit9
|
||||
| KeyCode::KeyF
|
||||
| KeyCode::KeyQ
|
||||
if (self.screen == Screen::Inventory
|
||||
|| (self.screen == Screen::Creative && !self.creative_screen.tab().search))
|
||||
&& self.inventory_hotkey(key) => {}
|
||||
// The search tab's number keys (scripted presses; the window's
|
||||
// keys reach search_tab_hotbar_key before the search box).
|
||||
_ if self.screen == Screen::Creative
|
||||
&& self.creative_screen.tab().search
|
||||
&& self.search_tab_hotbar_key(key) => {}
|
||||
KeyCode::KeyT if self.screen == Screen::Inventory => self.recipe_focus_search(),
|
||||
KeyCode::KeyT if self.screen == Screen::Playing => self.open_chat_method(""),
|
||||
// CreativeModeInventoryScreen.keyPressed: the chat key opens the
|
||||
// search tab from another tab, and `ignoreTextInput` drops the
|
||||
// letter it would have typed.
|
||||
KeyCode::KeyT
|
||||
if self.screen == Screen::Creative && !self.creative_screen.tab().search =>
|
||||
{
|
||||
if let Some(tab) = self.creative_screen.search_tab() {
|
||||
select_creative_tab(&mut self.creative_screen, &mut self.inv_clicks, tab);
|
||||
self.creative_ignore_text = true;
|
||||
}
|
||||
}
|
||||
// key.advancements.
|
||||
KeyCode::KeyL if self.screen == Screen::Playing => {
|
||||
self.open_advancements(false);
|
||||
// Opened from the keybinds, like the inventory: vanilla releases
|
||||
// an active use in the same tick.
|
||||
self.release_use_for_keybind_screen();
|
||||
}
|
||||
// key.socialInteractions.
|
||||
KeyCode::KeyP if self.screen == Screen::Playing => {
|
||||
self.open_social(false);
|
||||
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() {
|
||||
self.open_quick_actions();
|
||||
}
|
||||
}
|
||||
KeyCode::Slash if self.screen == Screen::Playing => self.open_chat_method("/"),
|
||||
// key.toggleGui is a KeyMapping: it clicks only with no screen open.
|
||||
KeyCode::F1 if self.screen == Screen::Playing => self.toggle_hide_gui(),
|
||||
KeyCode::F11 => self.toggle_fullscreen(),
|
||||
KeyCode::F5 if self.screen == Screen::Playing && self.studio_replay.is_none() => {
|
||||
self.cycle_camera()
|
||||
}
|
||||
KeyCode::KeyE
|
||||
if self.screen == Screen::Creative && self.creative_screen.tab().search => {}
|
||||
KeyCode::KeyE => match self.screen {
|
||||
Screen::Playing if self.spectator() || self.in_portal_transition() => {}
|
||||
Screen::Playing => {
|
||||
// handleKeybinds: the number keys select before key.inventory.
|
||||
self.select_queued_hotbar();
|
||||
// key.inventory while riding a tamed mount opens its inventory.
|
||||
if !self.open_ridden_mount_menu() {
|
||||
if self.tutorial.open_inventory() {
|
||||
self.persist_tutorial_step();
|
||||
}
|
||||
if self.creative {
|
||||
self.screen = Screen::Creative;
|
||||
self.refresh_creative_tabs();
|
||||
self.creative_screen.reopen();
|
||||
self.refresh_saved_hotbars();
|
||||
} else {
|
||||
self.screen = Screen::Inventory;
|
||||
}
|
||||
self.capture(false);
|
||||
}
|
||||
}
|
||||
Screen::Creative => {
|
||||
self.pad_clear_focus();
|
||||
if let Some(c) = self.carried.take() {
|
||||
let _ = self.give(c);
|
||||
}
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
Screen::Inventory => {
|
||||
self.close_container();
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
// key.swapOffhand: on a server, ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND
|
||||
// (the server swaps the hands and sends both slots back; spectators can't).
|
||||
KeyCode::KeyF if self.screen == Screen::Playing && self.remote.is_some() => {
|
||||
if !self.spectator()
|
||||
&& let Some(r) = self.remote.as_mut()
|
||||
{
|
||||
r.pending_keys.push(key);
|
||||
}
|
||||
}
|
||||
KeyCode::KeyF if self.screen == Screen::Playing => {
|
||||
// handleKeybinds: the number keys select before key.swapOffhand.
|
||||
self.select_queued_hotbar();
|
||||
// handlePlayerAction SWAP_ITEM_WITH_OFFHAND: setItemInHand for
|
||||
// the off hand, then the main hand.
|
||||
std::mem::swap(&mut self.offhand, &mut self.inventory[self.selected]);
|
||||
self.hand_stack_replaced(self.selected);
|
||||
self.hand_stack_replaced(shield::OFFHAND_SLOT);
|
||||
let (offhand, main) = (self.offhand.clone(), self.inventory[self.selected].clone());
|
||||
self.player_on_equip("offhand", main.as_ref(), offhand.as_ref());
|
||||
self.player_on_equip("mainhand", offhand.as_ref(), main.as_ref());
|
||||
self.using = None;
|
||||
}
|
||||
KeyCode::KeyQ if self.screen == Screen::Playing => {
|
||||
// handleKeybinds: the number keys select before key.drop.
|
||||
self.select_queued_hotbar();
|
||||
let all =
|
||||
drop_whole_stack(self.modifiers) || std::mem::take(&mut self.pad.drop_stack);
|
||||
self.drop_held(all);
|
||||
}
|
||||
KeyCode::Digit1
|
||||
| KeyCode::Digit2
|
||||
| KeyCode::Digit3
|
||||
| KeyCode::Digit4
|
||||
| KeyCode::Digit5
|
||||
| KeyCode::Digit6
|
||||
| KeyCode::Digit7
|
||||
| KeyCode::Digit8
|
||||
| KeyCode::Digit9
|
||||
if self.screen == Screen::Playing =>
|
||||
{
|
||||
let slot = match key {
|
||||
KeyCode::Digit1 => 0,
|
||||
KeyCode::Digit2 => 1,
|
||||
KeyCode::Digit3 => 2,
|
||||
KeyCode::Digit4 => 3,
|
||||
KeyCode::Digit5 => 4,
|
||||
KeyCode::Digit6 => 5,
|
||||
KeyCode::Digit7 => 6,
|
||||
KeyCode::Digit8 => 7,
|
||||
_ => 8,
|
||||
};
|
||||
// Minecraft.handleKeybinds: a spectator's number keys drive
|
||||
// the spectator menu, not the hotbar; a creative player
|
||||
// holding the load or save toolbar key loads or saves a
|
||||
// toolbar instead (load first when both are held).
|
||||
let load = self.mapped_key_down("key.loadToolbarActivator");
|
||||
if self.spectator() {
|
||||
self.spectator_hotbar_key(slot as i32);
|
||||
} else if self.creative
|
||||
&& (load || self.mapped_key_down("key.saveToolbarActivator"))
|
||||
{
|
||||
self.hotbar_load_or_save(slot, load);
|
||||
} else {
|
||||
self.hotbar_presses.push(slot);
|
||||
}
|
||||
}
|
||||
_ if self.screen == Screen::Paused && self.pause_menu => {
|
||||
self.pause_key(key)?;
|
||||
}
|
||||
_ if self.screen == Screen::Dead => {
|
||||
self.death_key(key)?;
|
||||
}
|
||||
_ if self.screen == Screen::Stats => self.stats_key(raw),
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Placing the local player in a world it opened: at its saved position once the chunks around it
|
||||
//! are lit, or at the world spawn, then capturing the mouse.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn spawn_player(&mut self) -> Result<()> {
|
||||
if self.player.is_some() || self.remote.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let saved = self.level.position.map(|p| Vec3::new(p[0], p[1], p[2]));
|
||||
if self.spawn.is_none() {
|
||||
self.spawn = self
|
||||
.level
|
||||
.spawn
|
||||
.map(|s| Vec3::new(s[0] + 0.5, s[1], s[2] + 0.5));
|
||||
}
|
||||
let feet = if let Some(p) = saved {
|
||||
let (cx, cz) = ((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4);
|
||||
self.streaming.center = (cx, cz);
|
||||
if !(-1..=1).all(|dz| (-1..=1).all(|dx| self.streaming.is_lit(cx + dx, cz + dz))) {
|
||||
return Ok(());
|
||||
}
|
||||
self.level.position = None;
|
||||
p
|
||||
} else if self.spawn.is_none() || self.player_spawn.is_some() {
|
||||
// A new world: its spawn, then the first player around it.
|
||||
if self.spawn.is_none() {
|
||||
let Some([x, y, z]) = self.find_world_spawn()? else {
|
||||
return Ok(());
|
||||
};
|
||||
self.spawn = Some(Vec3::new(
|
||||
f64::from(x) + 0.5,
|
||||
f64::from(y),
|
||||
f64::from(z) + 0.5,
|
||||
));
|
||||
self.level.spawn = Some([f64::from(x), f64::from(y), f64::from(z)]);
|
||||
// The world's spawn from the start, not only once saved: a bed set
|
||||
// before the first save must not become it.
|
||||
self.level.world_spawn.get_or_insert([x, y, z]);
|
||||
// The "Bonus Chest" create-world option, placed once with the spawn.
|
||||
if horizon_client::save::WorldGen::read(&self.world_root)
|
||||
.is_some_and(|g| g.bonus_chest)
|
||||
{
|
||||
self.place_bonus_chest([x, y, z])?;
|
||||
}
|
||||
}
|
||||
let s = self
|
||||
.level
|
||||
.spawn
|
||||
.unwrap_or_default()
|
||||
.map(|v| v.floor() as i32);
|
||||
let Some(feet) = self.find_player_spawn(s)? else {
|
||||
return Ok(());
|
||||
};
|
||||
feet
|
||||
} else {
|
||||
let s = self.spawn.unwrap_or_default();
|
||||
let (cx, cz) = ((s.x.floor() as i32) >> 4, (s.z.floor() as i32) >> 4);
|
||||
self.streaming.center = (cx, cz);
|
||||
if !(-1..=1).all(|dz| (-1..=1).all(|dx| self.streaming.is_lit(cx + dx, cz + dz))) {
|
||||
return Ok(());
|
||||
}
|
||||
let column = [s.x.floor() as i32, s.y.floor() as i32, s.z.floor() as i32];
|
||||
if self.level.world_spawn == Some(column) {
|
||||
// TeleportTransition.missingRespawnBlock: PlayerSpawnFinder
|
||||
// around the world spawn, not its exact column; its candidates'
|
||||
// chunks load first.
|
||||
let Some(feet) = self.find_player_spawn(column)? else {
|
||||
return Ok(());
|
||||
};
|
||||
feet
|
||||
} else {
|
||||
// A spawnpoint: the stored column, lifted onto its current surface.
|
||||
let y = self
|
||||
.land_column(column[0], column[2])
|
||||
.map_or(s.y, f64::from);
|
||||
Vec3::new(s.x, y.max(s.y), s.z)
|
||||
}
|
||||
};
|
||||
let mut player = PlayerMotion::new(self.motion, feet)?;
|
||||
player.yaw = self.yaw;
|
||||
let entering = self.restore_player_state;
|
||||
player.load_saved_state_once(&mut self.restore_player_state, &self.level)?;
|
||||
let spectator = self.mode == horizon_client::command::args::GameMode::Spectator;
|
||||
player.may_fly = self.creative || spectator;
|
||||
player.invulnerable = self.creative || spectator;
|
||||
player.spectator = spectator;
|
||||
if spectator {
|
||||
player.flying = true;
|
||||
} else if !player.may_fly {
|
||||
player.flying = false;
|
||||
}
|
||||
player.respiration = self.armor[0]
|
||||
.as_ref()
|
||||
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
|
||||
player.pitch = self.pitch;
|
||||
player.glider = self.glider_worn();
|
||||
// A saved death already dropped inventory/XP and counted the death.
|
||||
if player.health.dead_or_dying() {
|
||||
self.screen = Screen::Dead;
|
||||
self.death_focus = None;
|
||||
self.ui.died_at = Some(Instant::now());
|
||||
self.ui.death_text = None;
|
||||
self.ui.death_confirm = None;
|
||||
self.capture(false);
|
||||
}
|
||||
self.previous_position = feet;
|
||||
if entering {
|
||||
// The world's first player: a server's client gets it with its
|
||||
// level (Minecraft.setLevel), which stops every sound.
|
||||
self.stop_sounds_for_new_level();
|
||||
}
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
self.sway = Default::default();
|
||||
self.apply_equipment();
|
||||
self.eye = 1.62;
|
||||
self.previous_eye = 1.62;
|
||||
self.clock.reset();
|
||||
self.capture_loaded_world();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
//! The mouse in play: the look it turns, the pointer grab (`MouseHandler.grabMouse` /
|
||||
//! `releaseMouse`), the cursor icon, and pausing a world whose window loses focus.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The options `MouseHandler.turnPlayer` reads.
|
||||
pub(super) fn look_options(&self) -> LookOptions {
|
||||
LookOptions {
|
||||
sensitivity: f64::from(self.settings.sensitivity),
|
||||
smooth_camera: self.smooth_camera,
|
||||
scoping: self.scoping(),
|
||||
invert_x: self.settings.invert_x,
|
||||
invert_y: self.settings.invert_y,
|
||||
}
|
||||
}
|
||||
/// `MouseHandler.handleAccumulatedMovement` and `Entity.turn`, once per frame.
|
||||
/// An agent moving the mouse (`mouse-move`) counts as a grabbed pointer in
|
||||
/// an active window, as it cannot grab the real one.
|
||||
pub(super) fn apply_mouse_look(&mut self) {
|
||||
let look = self.look_options();
|
||||
let time = self.started.elapsed().as_secs_f64();
|
||||
let pointer = self.captured || self.agent_pointer;
|
||||
let grabbed = pointer && self.screen == Screen::Playing && self.player.is_some();
|
||||
let active = self.focused || self.agent_pointer;
|
||||
if let Some((xo, yo)) = self
|
||||
.mouse
|
||||
.handle_accumulated_movement(time, active, grabbed, look)
|
||||
{
|
||||
self.look_turn(xo, yo);
|
||||
}
|
||||
}
|
||||
/// A look from any device, in `Entity.turn` units: the tutorial's "look
|
||||
/// around" step hears it and Horizon's zoom slows it, for the mouse and a
|
||||
/// controller alike.
|
||||
pub(super) fn look_turn(&mut self, x: f64, y: f64) {
|
||||
self.tutorial.mouse(x, y);
|
||||
let zoom = self.zoom_turn_scale();
|
||||
self.turn_player(x * zoom, y * zoom);
|
||||
}
|
||||
/// `Minecraft.pause`: a single-player game with a pausing screen open.
|
||||
pub(super) fn pauses_game(&self) -> bool {
|
||||
self.has_singleplayer_server()
|
||||
&& !self.is_published()
|
||||
&& !self.has_split_players()
|
||||
&& self.title.is_none()
|
||||
&& (matches!(
|
||||
self.screen,
|
||||
Screen::Paused
|
||||
| Screen::Options
|
||||
| Screen::Stats
|
||||
| Screen::Advancements
|
||||
| Screen::WorldOptions
|
||||
| Screen::Friends
|
||||
| Screen::Social
|
||||
| Screen::ServerLinks
|
||||
| Screen::ConfirmLink
|
||||
| Screen::DialogWarning
|
||||
| Screen::CommandBlock
|
||||
| Screen::DebugOptions
|
||||
| Screen::HudEditor
|
||||
) || self.book.is_some()
|
||||
|| self.win_screen_pauses())
|
||||
}
|
||||
/// A screen open over gameplay while `screen` stays `Playing`: a book or
|
||||
/// lectern, a sign, a server dialog, the code of conduct, a link or
|
||||
/// server pack prompt (vanilla's `Minecraft.screen` is set for each).
|
||||
pub(super) fn screen_over_playing(&self) -> bool {
|
||||
self.book.is_some() || self.sign_edit.is_some() || self.prompt_open()
|
||||
}
|
||||
/// A prompt is up: over the world or over another open screen, it takes the input and is
|
||||
/// answered first, in the order of `handle_window_event`'s arms and of what draws on top.
|
||||
pub(super) fn prompt_open(&self) -> bool {
|
||||
self.dialog_open()
|
||||
|| self.code_of_conduct.is_some()
|
||||
|| self.link_prompt.is_some()
|
||||
|| self.command_prompt.is_some()
|
||||
|| self.server_packs.prompt.is_some()
|
||||
}
|
||||
/// `Minecraft.pauseIfInactive`: a window without focus for more than 500 ms
|
||||
/// pauses the game. An open screen (books, signs, the pack prompt, a server
|
||||
/// dialog) stays instead of pausing (`Gui.setPauseScreen`).
|
||||
pub(super) fn pause_if_inactive(&mut self) {
|
||||
if self
|
||||
.focus_lost
|
||||
.is_some_and(|t| t.elapsed() > Duration::from_millis(500))
|
||||
&& self.control.is_none()
|
||||
&& self.screen == Screen::Playing
|
||||
&& self.player.is_some()
|
||||
&& !self.options.no_pause
|
||||
&& self.settings.file.bool("pauseOnLostFocus")
|
||||
&& !self.pad_keeps_playing_unfocused()
|
||||
&& self.book.is_none()
|
||||
&& self.sign_edit.is_none()
|
||||
&& self.server_packs.prompt.is_none()
|
||||
&& !self.dialog_open()
|
||||
{
|
||||
self.pause_focus = None;
|
||||
self.screen = Screen::Paused;
|
||||
}
|
||||
}
|
||||
/// `KeyMapping.setAll`: every key physically held is held again, as the
|
||||
/// mapping it is bound to, so a key pressed or kept down behind a screen
|
||||
/// counts once the game has the keyboard back. A sneak or sprint that
|
||||
/// toggles keeps its toggle state (`shouldSetOnIngameFocus`).
|
||||
pub(super) fn restore_held_keys(&mut self) {
|
||||
let held: Vec<KeyCode> = self.physical_keys.iter().copied().collect();
|
||||
let toggles = [self.settings.toggle_crouch, self.settings.toggle_sprint];
|
||||
for key in held {
|
||||
for mapped in self.settings.keys.translate_all(key) {
|
||||
match mapped {
|
||||
Ok(slot_key @ (KeyCode::ShiftLeft | KeyCode::ControlLeft)) => {
|
||||
let slot = usize::from(slot_key == KeyCode::ControlLeft);
|
||||
if !toggles[slot] {
|
||||
self.toggles.0[slot].set_down(true, false);
|
||||
self.sync_toggle(slot);
|
||||
}
|
||||
}
|
||||
Ok(other) => {
|
||||
self.keys.insert(other);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `MouseHandler.grabMouse` / `releaseMouse`. Both put the pointer at
|
||||
/// the window's centre, so a screen opened from gameplay (inventory,
|
||||
/// chest, pause, chat, sign, book, death) starts with the cursor in the
|
||||
/// middle instead of wherever the system last left it. Releasing when
|
||||
/// already released does nothing, so moving from screen to screen keeps
|
||||
/// the cursor where it is. Like GLFW's `glfwSetCursorPos`, nothing warps
|
||||
/// while the window is unfocused (losing focus releases the pointer).
|
||||
pub(super) fn capture(&mut self, capture: bool) {
|
||||
// A guest's action (`as_guest`) opens and closes screens on the
|
||||
// guest's own client: the window's pointer stays the host's (#687).
|
||||
if self.lan_acting.is_some() {
|
||||
return;
|
||||
}
|
||||
// MouseHandler.grabMouse never captures an inactive window, and vanilla
|
||||
// grabs only on `setScreen(null)`: the pack prompt and server dialogs
|
||||
// are screens over the world that keep the pointer.
|
||||
if capture
|
||||
&& (!self.focused
|
||||
|| self.server_packs.prompt.is_some()
|
||||
|| self.dialog_open()
|
||||
|| self.command_prompt.is_some())
|
||||
{
|
||||
return;
|
||||
}
|
||||
// The container screen's press state belongs to the screen: a screen
|
||||
// that takes the pointer over starts with its own state, as
|
||||
// `AbstractContainerScreen` does, so a press whose release never
|
||||
// arrived (the pointer left the window) cannot wedge the next
|
||||
// screen's quick move and clicks.
|
||||
self.reset_inventory_clicks();
|
||||
if !capture {
|
||||
self.stop_mining();
|
||||
}
|
||||
// The real pointer takes over from an agent's.
|
||||
self.agent_pointer = false;
|
||||
// Splitscreen: another player has the pointer; this one's menus leave it alone.
|
||||
if !self.keyboard_mouse {
|
||||
self.captured = false;
|
||||
self.capture_mode = None;
|
||||
return;
|
||||
}
|
||||
if capture && !self.captured && !cfg!(target_os = "macos") {
|
||||
// MouseHandler.grabMouse: KeyMapping.setAll unless
|
||||
// InputQuirks.RESTORE_KEY_STATE_AFTER_MOUSE_GRAB is off (macOS).
|
||||
self.restore_held_keys();
|
||||
}
|
||||
let Some(window) = &self.window else { return };
|
||||
let size = self.renderer.as_ref().map_or_else(
|
||||
|| window.inner_size().into(),
|
||||
|r| [r.config.width, r.config.height],
|
||||
);
|
||||
let [vx, vy] = self.view_at.map(f64::from);
|
||||
let centre = winit::dpi::PhysicalPosition::new(
|
||||
vx + f64::from(size[0]) / 2.0,
|
||||
vy + f64::from(size[1]) / 2.0,
|
||||
);
|
||||
let warp = |window: &Window| {
|
||||
let _ = window.set_cursor_position(centre);
|
||||
};
|
||||
let scale = HudBatch::new(size).scale;
|
||||
let was_grabbed = self.captured;
|
||||
if self.options.offscreen && !self.split_window {
|
||||
// A genuinely hidden `--offscreen` run leaves the system pointer where
|
||||
// the person at the computer has it. Its grab is internal (an
|
||||
// agent turns the view with `mouse-move`).
|
||||
if capture {
|
||||
self.mouse.set_ignore_first_move();
|
||||
}
|
||||
self.captured = capture;
|
||||
self.capture_mode = None;
|
||||
} else if capture {
|
||||
self.mouse.set_ignore_first_move();
|
||||
// macOS warps by re-associating the mouse with the pointer,
|
||||
// which undoes a Locked grab and leaves the pointer free: warp
|
||||
// first there. Wayland moves a pointer only while it is locked,
|
||||
// so everywhere else warp after grabbing (Windows does both).
|
||||
#[cfg(target_os = "macos")]
|
||||
warp(window);
|
||||
// Windows clips a Locked pointer to a 1x1 rectangle where it is, and `SetCursorPos`
|
||||
// stays inside that clip: warp first, or the pointer stays frozen wherever it was
|
||||
// (over another window, on another monitor) instead of the window's centre (#6076).
|
||||
#[cfg(windows)]
|
||||
if self.focused {
|
||||
warp(window);
|
||||
}
|
||||
self.capture_mode = if window.set_cursor_grab(CursorGrabMode::Locked).is_ok() {
|
||||
Some(CursorGrabMode::Locked)
|
||||
} else if window.set_cursor_grab(CursorGrabMode::Confined).is_ok() {
|
||||
Some(CursorGrabMode::Confined)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.captured = self.capture_mode.is_some();
|
||||
// Windows already warped above, inside the clip's own position.
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
if self.captured && self.focused {
|
||||
warp(window);
|
||||
}
|
||||
} else {
|
||||
if self.captured {
|
||||
// Wayland moves a pointer only as the locked pointer's
|
||||
// position hint, which the compositor applies as the lock
|
||||
// goes (sway and Hyprland do): set it before ungrabbing.
|
||||
// X11, Windows and macOS warp, so warp again once free
|
||||
// (Wayland refuses that one).
|
||||
if self.focused {
|
||||
warp(window);
|
||||
}
|
||||
let _ = window.set_cursor_grab(CursorGrabMode::None);
|
||||
if self.focused {
|
||||
warp(window);
|
||||
}
|
||||
} else {
|
||||
let _ = window.set_cursor_grab(CursorGrabMode::None);
|
||||
}
|
||||
self.captured = false;
|
||||
self.capture_mode = None;
|
||||
}
|
||||
// MouseHandler.grabMouse and releaseMouse: the mouse position is
|
||||
// the window's centre whenever the pointer's grab state changes, so
|
||||
// a screen opened from gameplay starts with the cursor in the middle
|
||||
// and the game starts with it away from the screen's last spot.
|
||||
if let Some(pos) = horizon_client::mouse::mouse_position_after_grab_change(
|
||||
capture,
|
||||
was_grabbed,
|
||||
size,
|
||||
scale,
|
||||
) {
|
||||
self.cursor = pos;
|
||||
}
|
||||
if !self.options.offscreen || self.split_window {
|
||||
window.set_cursor_visible(!self.captured);
|
||||
}
|
||||
}
|
||||
/// A Linux window manager can release the native pointer grab while the
|
||||
/// window remains focused. Reapply the recorded mode on the next gameplay
|
||||
/// click or wheel event, without warping the pointer or consuming input.
|
||||
pub(super) fn reassert_pointer_capture(&mut self) {
|
||||
#[cfg(target_os = "linux")]
|
||||
if self.screen == Screen::Playing
|
||||
&& self.focused
|
||||
&& self.captured
|
||||
&& !self.options.offscreen
|
||||
&& let (Some(window), Some(mode)) = (&self.window, self.capture_mode)
|
||||
{
|
||||
let other = match mode {
|
||||
CursorGrabMode::Locked => CursorGrabMode::Confined,
|
||||
CursorGrabMode::Confined => CursorGrabMode::Locked,
|
||||
CursorGrabMode::None => return,
|
||||
};
|
||||
self.capture_mode = if window.set_cursor_grab(mode).is_ok() {
|
||||
Some(mode)
|
||||
} else if window.set_cursor_grab(other).is_ok() {
|
||||
Some(other)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.captured = self.capture_mode.is_some();
|
||||
window.set_cursor_visible(!self.captured);
|
||||
}
|
||||
}
|
||||
/// `GuiGraphicsExtractor.applyCursor` -> `Window.selectCursor` at the end
|
||||
/// of a frame: the cursor the frame's widgets asked for, set on the window
|
||||
/// only when it changes.
|
||||
pub(super) fn apply_cursor_icon(&mut self) {
|
||||
use horizon_client::widgets::Cursor;
|
||||
let wanted = horizon_client::widgets::take_cursor();
|
||||
// The widgets under the keyboard's hidden pointer aren't under the
|
||||
// mouse (`handleCursor` asks only for a hovered widget).
|
||||
let effective = if self.settings.allow_cursor_changes && !self.key_focus_driving() {
|
||||
wanted
|
||||
} else {
|
||||
Cursor::Arrow
|
||||
};
|
||||
if effective == self.cursor_icon || !self.keyboard_mouse {
|
||||
return;
|
||||
}
|
||||
if let Some(window) = &self.window {
|
||||
window.set_cursor(match effective {
|
||||
Cursor::Arrow => winit::window::CursorIcon::Default,
|
||||
Cursor::PointingHand => winit::window::CursorIcon::Pointer,
|
||||
Cursor::NotAllowed => winit::window::CursorIcon::NotAllowed,
|
||||
});
|
||||
}
|
||||
self.cursor_icon = effective;
|
||||
}
|
||||
/// LevelLoadingScreen.onClose -> Gui.setScreen(null) -> grabMouse.
|
||||
pub(super) fn capture_loaded_world(&mut self) {
|
||||
// Loading may finish after focus was lost or another screen opened.
|
||||
if self.title.is_none() && self.screen == Screen::Playing {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
/// The window came back into focus after holding the pointer: the system
|
||||
/// dropped the grab with the focus, and vanilla's grab outlives it
|
||||
/// (`MouseHandler.mouseGrabbed` stays true while GLFW keeps the cursor
|
||||
/// mode), so the game takes the pointer again without waiting for a
|
||||
/// click. A screen that is open keeps it free, as releasing the pointer
|
||||
/// for that screen did: it is the screen, not the focus, that shows it.
|
||||
pub(super) fn regrab_on_refocus(&mut self) {
|
||||
if std::mem::take(&mut self.regrab_on_refocus) && self.screen == Screen::Playing {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! `Entity.handlePortal` runs in the entity's own tick, and `ServerLevel.tick`
|
||||
//! ticks only the entities in entity-ticking chunks: past the simulation
|
||||
//! distance a mob in a nether portal doesn't cross, and a mob cooling down
|
||||
//! from one keeps its cooldown until it ticks again.
|
||||
|
||||
use super::native_machines::tests::server_game;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_client::mobs::Mob;
|
||||
use horizon_client::nether_portal::ENTITY_DELAY;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn mobs_past_the_simulation_distance_neither_enter_portals_nor_cool_down() {
|
||||
let mut game = server_game();
|
||||
game.settings.simulation_distance = 4;
|
||||
game.settings.render_distance = 12;
|
||||
let registry = game.pack.assets.registry;
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
let portal = registry.default_state("minecraft:nether_portal").unwrap();
|
||||
let zombie = game
|
||||
.res
|
||||
.mob_types()
|
||||
.get("minecraft:zombie")
|
||||
.unwrap()
|
||||
.clone();
|
||||
// The player in chunk (0, 0); chunk (2, 0) is inside the simulation
|
||||
// distance, chunk (8, 0) loaded and in view but past it. In each, one
|
||||
// zombie stands in a portal (z 8) and one on stone, cooling down (z 12).
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 65.0, 8.5)).unwrap(),
|
||||
);
|
||||
for (cx, x, in_portal, cooling) in [(2, 40, 1, 3), (8, 136, 2, 4)] {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((cx, 0), Arc::new(Chunk::empty(cx, 0)));
|
||||
for z in [8, 12] {
|
||||
game.world.set(x, 63, z, stone).unwrap();
|
||||
}
|
||||
game.world.set(x, 64, 8, portal).unwrap();
|
||||
game.world.set(x, 65, 8, portal).unwrap();
|
||||
let at = |z: f64| Vec3::new(f64::from(x) + 0.5, 64.0, z);
|
||||
game.mobs
|
||||
.push(Mob::new(in_portal, zombie.clone(), at(8.5), 0.0));
|
||||
game.mobs
|
||||
.push(Mob::new(cooling, zombie.clone(), at(12.5), 0.0));
|
||||
game.set_portal_cooldown(cooling);
|
||||
}
|
||||
game.update_chunk_tickets();
|
||||
let ticking = game.entity_ticking_area();
|
||||
assert!(ticking(40, 8), "chunk (2, 0) ticks entities");
|
||||
assert!(
|
||||
!ticking(136, 8),
|
||||
"chunk (8, 0) is past the simulation distance"
|
||||
);
|
||||
for _ in 0..3 {
|
||||
game.handle_entity_portals();
|
||||
game.find_entity_portals();
|
||||
}
|
||||
let state = |id| game.portals.entities.get(&id);
|
||||
let here = |id| game.mobs.iter().any(|m| m.id == id);
|
||||
// A mob crosses a nether portal at once: the ticking zombie has gone.
|
||||
assert!(!here(1), "the ticking zombie went through its portal");
|
||||
assert!(here(2), "the far zombie is still here");
|
||||
assert!(
|
||||
state(2).is_none(),
|
||||
"the far zombie never entered its portal"
|
||||
);
|
||||
assert_eq!(state(3).map(|s| s.cooldown), Some(ENTITY_DELAY - 3));
|
||||
assert_eq!(state(4).map(|s| s.cooldown), Some(ENTITY_DELAY));
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! The renderer and its settings: building it for the window, applying the video settings and a
|
||||
//! shader pack's translucent layers, and the far terrain (LOD) distances.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn renderer_for(
|
||||
&self,
|
||||
window: Arc<Window>,
|
||||
pack: &PackResources,
|
||||
gui: &UiResources,
|
||||
) -> Result<Renderer> {
|
||||
build_renderer(
|
||||
window,
|
||||
self.options.offscreen,
|
||||
pack,
|
||||
gui,
|
||||
self.renderer.as_ref(),
|
||||
None,
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.horizon.wide_frame(),
|
||||
shader_packs::picked_pack_shape(&self.settings),
|
||||
(
|
||||
self.settings.file.int("textureFiltering"),
|
||||
self.settings.file.int("maxAnisotropyBit"),
|
||||
),
|
||||
)
|
||||
}
|
||||
pub(super) fn apply_settings(&mut self) {
|
||||
// LocalPlayer.refreshChatAbilities: the chat shows what the Chat option allows.
|
||||
let abilities = self.chat_abilities();
|
||||
self.ui.chat.set_abilities(&self.gui.atlas.font, abilities);
|
||||
let render_distance_changed = self.streaming.radius != self.settings.render_distance;
|
||||
self.streaming.radius = self.settings.render_distance;
|
||||
// Options.renderDistance, biomeBlendRadius, ambientOcclusion,
|
||||
// cutoutLeaves and the leaf-culling mode: LevelExtractor.allChanged
|
||||
// when they change.
|
||||
let leaf_culling = self.effective_leaf_culling();
|
||||
let (s, st) = (&self.settings, &self.streaming);
|
||||
let reload = render_distance_changed
|
||||
|| (
|
||||
s.biome_blend,
|
||||
s.smooth_lighting,
|
||||
s.cutout_leaves,
|
||||
leaf_culling,
|
||||
) != (
|
||||
st.biome_blend,
|
||||
st.smooth_lighting,
|
||||
st.cutout_leaves,
|
||||
st.leaf_culling,
|
||||
);
|
||||
self.streaming.biome_blend = self.settings.biome_blend;
|
||||
self.streaming.smooth_lighting = self.settings.smooth_lighting;
|
||||
self.streaming.cutout_leaves = self.settings.cutout_leaves;
|
||||
self.streaming.leaf_culling = leaf_culling;
|
||||
if reload {
|
||||
self.all_sections_changed();
|
||||
}
|
||||
self.audio.master = self.settings.master_volume;
|
||||
if self.settings.sound_device != self.audio.device() {
|
||||
let _ = self.audio.set_device(&self.settings.sound_device);
|
||||
}
|
||||
horizon_client::hud::GUI_SCALE.store(
|
||||
self.settings.gui_scale,
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
self.apply_ui_settings();
|
||||
}
|
||||
/// The sprite layers a shader pack draws in the translucent pass: every section is meshed
|
||||
/// again when they change.
|
||||
pub(super) fn set_translucent_layers(&mut self, layers: Vec<bool>) {
|
||||
if *layers != *self.streaming.translucent_layers {
|
||||
self.streaming.translucent_layers = layers.into();
|
||||
self.all_sections_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// LOD Distance and Far Detail Reduced as the player chose them, the
|
||||
/// distance no farther than a server sampling far terrain for us allows.
|
||||
pub(super) fn far_terrain_choice(&self) -> (u32, bool) {
|
||||
let g = &self.settings.horizon;
|
||||
let reduced = g.far_detail == horizon_client::graphics::FarDetail::Reduced;
|
||||
let distance = match self.server_far_terrain() {
|
||||
Some(offer) => g.lod_distance.min(offer.distance),
|
||||
None => g.lod_distance,
|
||||
};
|
||||
(distance, reduced)
|
||||
}
|
||||
/// The far terrain another machine's server samples for us
|
||||
/// (`lod_net`): a client of a dedicated server or of a LAN world, not of
|
||||
/// its own server thread, which hands its generator over directly.
|
||||
pub(super) fn server_far_terrain(&self) -> Option<horizon_client::lod_net::Offer> {
|
||||
if self.integrated.is_some() {
|
||||
return None;
|
||||
}
|
||||
self.remote.as_ref()?.far_terrain
|
||||
}
|
||||
/// LOD Distance as far terrain uses it (`graphics::lod_distance_capped`).
|
||||
pub(super) fn lod_distance(&self) -> u32 {
|
||||
let (distance, _) = self.far_terrain_choice();
|
||||
match &self.renderer {
|
||||
Some(renderer) => {
|
||||
horizon_client::graphics::lod_distance_capped(distance, &renderer.info)
|
||||
}
|
||||
None => distance.min(horizon_client::lod::MAX_CHUNKS),
|
||||
}
|
||||
}
|
||||
/// Only enabled LOD worlds extend the renderer's range, never simulation.
|
||||
pub(super) fn terrain_distance(&self) -> i32 {
|
||||
if self.lod.is_some() {
|
||||
self.streaming.radius.max(self.lod_distance() as i32)
|
||||
} else {
|
||||
self.streaming.radius
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
//! The startup's resource load: the stages the loading screen shows, the
|
||||
//! scoped threads each step runs on, and the core, gameplay and pack tables
|
||||
//! read from the prepared resources.
|
||||
use super::*;
|
||||
|
||||
pub(super) const STAGE_LINES: [&str; 10] = [
|
||||
"Loading menu",
|
||||
"Loading assets",
|
||||
"Loading block facts",
|
||||
"Loading biomes",
|
||||
"Loading pack tables",
|
||||
"Loading collision",
|
||||
"Loading items",
|
||||
"Loading recipes",
|
||||
"Loading world resources",
|
||||
"World resources ready",
|
||||
];
|
||||
|
||||
/// The background loader's checkpoint, read by the loading screen each frame.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Progress {
|
||||
pub(super) value: Arc<AtomicU32>,
|
||||
pub(super) lines: &'static [&'static str],
|
||||
}
|
||||
impl Progress {
|
||||
pub(super) fn new() -> Self {
|
||||
Self::with(&STAGE_LINES)
|
||||
}
|
||||
pub(super) fn with(lines: &'static [&'static str]) -> Self {
|
||||
Self {
|
||||
value: Arc::new(AtomicU32::new(0)),
|
||||
lines,
|
||||
}
|
||||
}
|
||||
pub(super) fn set(&self, stage: u32) {
|
||||
self.value.store(stage, Ordering::Relaxed);
|
||||
}
|
||||
pub(super) fn fraction(&self) -> f32 {
|
||||
self.value.load(Ordering::Relaxed) as f32 / (self.lines.len() - 1) as f32
|
||||
}
|
||||
pub(super) fn line(&self) -> &'static str {
|
||||
self.lines[(self.value.load(Ordering::Relaxed) as usize).min(self.lines.len() - 1)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Harness and dedicated runs keep the fully-synchronous startup so their
|
||||
/// output stays byte-identical; only an ordinary launch shows the window first.
|
||||
pub(super) fn synchronous_startup(options: &Options) -> bool {
|
||||
// A headless game has no window for the loading screen.
|
||||
options.headless
|
||||
|| options.frames.is_some()
|
||||
|| options.world_frames.is_some()
|
||||
|| options.benchmark.is_some()
|
||||
|| options.control.is_some()
|
||||
|| !options.execution_role.is_client()
|
||||
|| std::env::var_os("HORIZON_DISPENSE_FIXTURE").is_some()
|
||||
|| std::env::var_os("HORIZON_SCULK_FIXTURE").is_some()
|
||||
|| std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some()
|
||||
|| std::env::var_os("HORIZON_NET_REPORT").is_some()
|
||||
}
|
||||
|
||||
/// The local world a client's launch opens at once (`--world`,
|
||||
/// `--quickPlaySingleplayer`), whose generator data can load with the game's.
|
||||
pub(super) fn launch_world(options: &Options) -> Option<&str> {
|
||||
(options.execution_role.is_client() && options.server.is_none() && !options.test_terrain)
|
||||
.then_some(options.world.as_deref())
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// A scoped thread with a name, for one step of the resource load.
|
||||
///
|
||||
/// Named because the heap census and the startup trace both fall back to the
|
||||
/// OS thread name: unnamed, every loader's retained memory and every span it
|
||||
/// ran landed in one `unnamed thread` bucket. That bucket was 330 MiB of a
|
||||
/// playing game's resident set -- the largest of all of them -- with nothing
|
||||
/// to say which loader held it, and 13 of the trace's 17 lanes were
|
||||
/// indistinguishable.
|
||||
pub(super) fn named_scope<'scope, T: Send + 'scope>(
|
||||
scope: &'scope std::thread::Scope<'scope, '_>,
|
||||
name: &str,
|
||||
f: impl FnOnce() -> T + Send + 'scope,
|
||||
) -> std::thread::ScopedJoinHandle<'scope, T> {
|
||||
std::thread::Builder::new()
|
||||
// Linux keeps 15 bytes of a thread name: a short prefix keeps them apart
|
||||
// ("horizon-load-core" and "horizon-load-collision" would both read
|
||||
// "horizon-load-co").
|
||||
.name(format!("load-{name}"))
|
||||
.spawn_scoped(scope, f)
|
||||
// `Scope::spawn`, which this replaces, panics when the OS refuses a
|
||||
// thread. Keeping that: a resource load cannot go on without it.
|
||||
.expect("resource loader thread")
|
||||
}
|
||||
|
||||
/// A step of the resource load: on its own scoped thread, or, when the
|
||||
/// settings allow a single worker, run where it is joined.
|
||||
pub(super) enum Step<'scope, T> {
|
||||
Thread(std::thread::ScopedJoinHandle<'scope, T>),
|
||||
Later(Box<dyn FnOnce() -> T + Send + 'scope>),
|
||||
}
|
||||
impl<'scope, T: Send + 'scope> Step<'scope, T> {
|
||||
/// `name` names the thread when one is spawned ([`named_scope`]).
|
||||
pub(super) fn spawn(
|
||||
scope: &'scope std::thread::Scope<'scope, '_>,
|
||||
parallel: bool,
|
||||
name: &str,
|
||||
f: impl FnOnce() -> T + Send + 'scope,
|
||||
) -> Self {
|
||||
if parallel {
|
||||
Self::Thread(named_scope(scope, name, f))
|
||||
} else {
|
||||
Self::Later(Box::new(f))
|
||||
}
|
||||
}
|
||||
pub(super) fn join(self) -> T {
|
||||
match self {
|
||||
Self::Thread(handle) => handle.join().unwrap(),
|
||||
Self::Later(f) => f(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The world bundle's core tables from one resource root, before
|
||||
/// `load_resources` binds them into the pack tables and `WorldCore`.
|
||||
pub(super) struct CoreTables {
|
||||
pub(super) assets: Assets,
|
||||
pub(super) item_defaults: Arc<horizon_world::item_defaults::Defaults>,
|
||||
pub(super) render: RenderAssets,
|
||||
pub(super) item_renderer: horizon_client::item_render::ItemRenderer,
|
||||
pub(super) visibility: horizon_world::block_render::FaceVisibility,
|
||||
pub(super) gameplay: GameplayTables,
|
||||
pub(super) biomes: Arc<Biomes>,
|
||||
pub(super) environment: Environment,
|
||||
pub(super) collision: Arc<BlockCollisionRegistry<'static>>,
|
||||
}
|
||||
/// The block facts and the tables read from them.
|
||||
pub(super) struct GameplayTables {
|
||||
pub(super) behaviour_json: serde_json::Value,
|
||||
pub(super) facts: BlockFacts,
|
||||
pub(super) gameplay_projection: Vec<horizon_client::blocks::GameplayProjection>,
|
||||
pub(super) light: LightTables,
|
||||
pub(super) motion: MotionResources,
|
||||
}
|
||||
|
||||
/// [`CoreTables`] at `root`. Every table reads the block registry; past it,
|
||||
/// the block models and sprites (then the item models, which share their
|
||||
/// parsed models), the face visibility, the collisions, the biomes and the
|
||||
/// environment need no block facts and load beside them. The outer error
|
||||
/// fails the load; the inner one is the packs' (the registry or the block
|
||||
/// models), which `load_resources` rolls back from.
|
||||
pub(super) fn load_core(
|
||||
root: &std::path::Path,
|
||||
headless: bool,
|
||||
parallel: bool,
|
||||
progress: &Progress,
|
||||
launch_world: Option<&str>,
|
||||
) -> std::result::Result<std::result::Result<CoreTables, String>, String> {
|
||||
// The model library reads every file under `assets/` and needs no
|
||||
// block registry, so it is parsed beside `Assets::load` rather than
|
||||
// after it. It used to sit inside `reload` behind both the registry
|
||||
// read and the block facts; on a cold cache that read was most of a
|
||||
// second on the critical path, and warm it is still ~30 ms.
|
||||
// A server draws nothing and parses no models, so it skips this.
|
||||
let (assets, parsed_models) = std::thread::scope(|scope| {
|
||||
let models = (parallel && !headless).then(|| {
|
||||
scope.spawn(|| {
|
||||
let _step = startup::step("core.models_parse");
|
||||
horizon_client::model_library::ParsedModels::load(root)
|
||||
})
|
||||
});
|
||||
let assets = startup::time("core.assets", || Assets::load(root));
|
||||
let models = models.map(|m| m.join().unwrap_or_else(|e| std::panic::resume_unwind(e)));
|
||||
(assets, models)
|
||||
});
|
||||
let assets = match assets {
|
||||
Ok(assets) => assets,
|
||||
Err(e) => return Ok(Err(e.to_string())),
|
||||
};
|
||||
progress.set(1);
|
||||
let registry = assets.registry;
|
||||
let (render, gameplay, visibility, biomes, environment, collision) =
|
||||
std::thread::scope(|scope| {
|
||||
let assets = &assets;
|
||||
let render = Step::spawn(scope, parallel, "render", move || {
|
||||
// A server reads no block sprites' pixels, which only the
|
||||
// renderer draws (32 MB).
|
||||
let parsed_models = parsed_models;
|
||||
let render = startup::time("core.block_models_sprites", || {
|
||||
if headless {
|
||||
assets.reload_for_server(root)
|
||||
} else if let Some(parsed) = parsed_models {
|
||||
assets.reload_with_models(root, parsed)
|
||||
} else {
|
||||
assets.reload(root)
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let library = render.model_library.clone();
|
||||
let item_defaults = startup::time("core.item_defaults", || {
|
||||
horizon_world::item_defaults::shared(root)
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let item_renderer = startup::time("core.item_models", || {
|
||||
if headless {
|
||||
horizon_client::item_render::ItemRenderer::load_for_server(root, library)
|
||||
} else {
|
||||
horizon_client::item_render::ItemRenderer::load_with_library(root, library)
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string());
|
||||
Ok((render, item_renderer, item_defaults))
|
||||
});
|
||||
// Nor the face visibility the mesher and the block entity
|
||||
// renderer cull with (19 MB).
|
||||
let visibility = Step::spawn(scope, parallel, "visibility", || {
|
||||
if headless {
|
||||
return Ok(horizon_world::block_render::FaceVisibility::empty());
|
||||
}
|
||||
startup::time("core.visibility", || {
|
||||
models::load_visibility(root, registry)
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
let biomes = Step::spawn(scope, parallel, "biomes", || {
|
||||
let biomes = Arc::new(
|
||||
startup::time("core.biomes", || Biomes::load(root))
|
||||
.map_err(|e| e.to_string())?,
|
||||
);
|
||||
// A launch that names a world: its data preload needs only the
|
||||
// block registry and the biomes, so it starts here rather than
|
||||
// after the block models and facts (~250 ms later).
|
||||
if let Some(folder) = launch_world {
|
||||
world_loading::preload_launch_world(folder, root, registry, biomes.clone());
|
||||
}
|
||||
Ok::<_, String>(biomes)
|
||||
});
|
||||
let environment = Step::spawn(scope, parallel, "environment", || {
|
||||
startup::time("core.environment", || Environment::load(root))
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
let collision = Step::spawn(scope, parallel, "collision", || {
|
||||
startup::time("core.collision", || collisions::load(root, registry))
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
let gameplay = gameplay_tables(root, assets, parallel, progress);
|
||||
(
|
||||
render.join(),
|
||||
gameplay,
|
||||
visibility.join(),
|
||||
biomes.join(),
|
||||
environment.join(),
|
||||
collision.join(),
|
||||
)
|
||||
});
|
||||
// The packs' failure first: it rolls back whatever else failed.
|
||||
let (render, item_renderer, item_defaults) = match render {
|
||||
Ok(render) => render,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let gameplay = gameplay?;
|
||||
progress.set(3);
|
||||
Ok(Ok(CoreTables {
|
||||
item_defaults,
|
||||
gameplay,
|
||||
biomes: biomes?,
|
||||
environment: environment?,
|
||||
visibility: visibility?,
|
||||
item_renderer: item_renderer?,
|
||||
collision: Arc::new(collision?),
|
||||
assets,
|
||||
render,
|
||||
}))
|
||||
}
|
||||
/// The block facts, then the light tables beside the motion tables, which
|
||||
/// both read them.
|
||||
pub(super) fn gameplay_tables(
|
||||
root: &std::path::Path,
|
||||
assets: &Assets,
|
||||
parallel: bool,
|
||||
progress: &Progress,
|
||||
) -> std::result::Result<GameplayTables, String> {
|
||||
let facts_step = startup::step("core.block_facts");
|
||||
// Read whole, then parsed from the buffer: `serde_json::from_reader`
|
||||
// pulls through a `BufReader` and is measurably slower on a file this
|
||||
// size than one read plus `from_slice`, which is how `BlockFacts` next
|
||||
// door already reads its own. Same value either way.
|
||||
let behaviour_json: serde_json::Value =
|
||||
horizon_world::json_file::read(&root.join("block-behaviours.json"))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (facts, gameplay_projection) =
|
||||
BlockFacts::load_with_projection(root, assets.registry, &behaviour_json, parallel)
|
||||
.map_err(|e| e.to_string())?;
|
||||
horizon_client::block_contact::set_mod_blocks(
|
||||
facts
|
||||
.states
|
||||
.iter()
|
||||
.any(|s| !s.name.starts_with("minecraft:")),
|
||||
);
|
||||
drop(facts_step);
|
||||
progress.set(2);
|
||||
let (light, motion) = std::thread::scope(|scope| {
|
||||
let light = Step::spawn(scope, parallel, "light", || {
|
||||
startup::time("core.light", || {
|
||||
LightTables::load(root, assets.registry, &facts)
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
let motion = startup::time("core.motion", || {
|
||||
MotionResources::load_with_facts(root, assets, &facts)
|
||||
})
|
||||
.map_err(|e| e.to_string());
|
||||
(light.join(), motion)
|
||||
});
|
||||
Ok(GameplayTables {
|
||||
light: light?,
|
||||
motion: motion?,
|
||||
behaviour_json,
|
||||
facts,
|
||||
gameplay_projection,
|
||||
})
|
||||
}
|
||||
|
||||
/// The synchronous launch loads both phases; an ordinary launch returns the
|
||||
/// core bundle while the rest worker keeps loading. `launch_world`: the world
|
||||
/// the launch opens, whose data is preloaded meanwhile.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn load_resources(
|
||||
mut root: PathBuf,
|
||||
launch_world: Option<&str>,
|
||||
vanilla_root: &std::path::Path,
|
||||
settings: &Settings,
|
||||
started: Instant,
|
||||
menu: bool,
|
||||
headless: bool,
|
||||
progress: &Progress,
|
||||
publish: &mut dyn FnMut(UiResources, &std::path::Path),
|
||||
) -> Result<Loaded> {
|
||||
let threads = settings.horizon.worker_threads();
|
||||
// One pool of workers for every level's streaming, the client's and the
|
||||
// server thread's (horizon_threads::jobs), sized before any world opens.
|
||||
horizon_threads::jobs::configure(threads);
|
||||
horizon_client::sprites::set_texture_budget(settings.horizon.texture_budget_mib);
|
||||
horizon_client::sprites::set_labpbr_maps(shader_packs::picks_material_pack(settings));
|
||||
// Stage A: the menu bundle (atlas and panorama). With a loading screen it is
|
||||
// published at once so the title screen can appear while the world bundle
|
||||
// loads; the synchronous launch instead carries it in `Loaded`.
|
||||
let mut pack_error = None;
|
||||
// A dedicated server builds only the font (UiResources::server), as the
|
||||
// integrated server's game does: the full atlas and panorama are 144 MB.
|
||||
let load_ui = |root: &std::path::Path| {
|
||||
let _step = startup::step("ui.load");
|
||||
if headless {
|
||||
UiResources::server(root)
|
||||
} else {
|
||||
UiResources::load(root, vanilla_root)
|
||||
}
|
||||
};
|
||||
// The menu bundle loads on this thread while workers build the world
|
||||
// bundle's core tables from the same root (`load_core`). A pack failure
|
||||
// in either starts both again with the game's own resources
|
||||
// (Minecraft.rollbackResourcePacks).
|
||||
let parallel = threads > 1;
|
||||
let mut offer = |ui: UiResources, root: &std::path::Path| {
|
||||
if menu {
|
||||
log::info!(
|
||||
"Horizon: UI bundle ready after {} ms",
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
publish(ui, root);
|
||||
None
|
||||
} else {
|
||||
Some(ui)
|
||||
}
|
||||
};
|
||||
let (core, loaded_gui, entity_models) = loop {
|
||||
let (ui, core, entity_models) = std::thread::scope(|scope| {
|
||||
let core = Step::spawn(scope, parallel, "core", || {
|
||||
// A failed pack starts core again from the game's own
|
||||
// resources, which the world's preload must read: with packs
|
||||
// it waits for the core that loaded.
|
||||
let early = launch_world.filter(|_| root == vanilla_root);
|
||||
startup::time("core", || {
|
||||
load_core(&root, headless, parallel, progress, early)
|
||||
})
|
||||
});
|
||||
// The handoff's entity models, the rest worker's mob models and
|
||||
// its transformers all read this one document. A server draws
|
||||
// none of them.
|
||||
let entity_models = (!headless).then(|| {
|
||||
Step::spawn(scope, parallel, "entity_models", || {
|
||||
startup::time("core.entity_models", || {
|
||||
horizon_client::mobs::EntityModelDocument::load(&root).ok()
|
||||
})
|
||||
})
|
||||
});
|
||||
let ui = load_ui(&root).map(|ui| offer(ui, &root));
|
||||
(ui, core.join(), entity_models.and_then(|step| step.join()))
|
||||
});
|
||||
let packs = root != vanilla_root;
|
||||
let failure = match (ui, core) {
|
||||
(Ok(gui), Ok(Ok(core))) => break (core, gui, entity_models),
|
||||
(Err(e), _) if packs => e.to_string(),
|
||||
(_, Ok(Err(e))) if packs => e,
|
||||
(_, Ok(Err(e))) => {
|
||||
return Err(format!(
|
||||
"{e}\nPrepare resources with the launcher, or: horizon-prepare --prepare"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
(_, Err(e)) => return Err(e.into()),
|
||||
(Err(e), Ok(Ok(_))) => return Err(e),
|
||||
};
|
||||
log::warn!(
|
||||
"Horizon: resource packs failed to load ({failure}); using the default resources"
|
||||
);
|
||||
pack_error = Some(failure);
|
||||
root = vanilla_root.to_path_buf();
|
||||
};
|
||||
log::info!("load core tables: {:.2}s", started.elapsed().as_secs_f32());
|
||||
let entity_models = entity_models.map(Arc::new);
|
||||
let CoreTables {
|
||||
assets,
|
||||
item_defaults,
|
||||
render,
|
||||
item_renderer,
|
||||
visibility,
|
||||
gameplay:
|
||||
GameplayTables {
|
||||
behaviour_json,
|
||||
facts,
|
||||
gameplay_projection,
|
||||
light,
|
||||
motion,
|
||||
},
|
||||
biomes,
|
||||
environment,
|
||||
collision,
|
||||
} = core;
|
||||
let assets = Arc::new(assets);
|
||||
let render = Arc::new(render);
|
||||
if let Some(folder) = launch_world {
|
||||
world_loading::preload_launch_world(folder, &root, assets.registry, biomes.clone());
|
||||
}
|
||||
let tables = Arc::new(startup::time("pack.mesh_tables", || {
|
||||
MeshTables::new(&assets, &render, &facts, visibility)
|
||||
})?);
|
||||
let pack_step = startup::step("pack.assemble");
|
||||
let pack = PackResources::assemble(
|
||||
&root,
|
||||
assets.clone(),
|
||||
render,
|
||||
tables,
|
||||
item_renderer,
|
||||
biomes.clone(),
|
||||
Arc::new(light),
|
||||
);
|
||||
drop(pack_step);
|
||||
log::info!("load pack tables: {:.2}s", started.elapsed().as_secs_f32());
|
||||
progress.set(4);
|
||||
if !menu {
|
||||
horizon_client::ui::load_metadata(&root);
|
||||
horizon_client::lang::set(horizon_client::lang::Language::load(
|
||||
&[&root],
|
||||
&settings.language,
|
||||
));
|
||||
}
|
||||
progress.set(5);
|
||||
let suffocation =
|
||||
horizon_client::mobs::suffocation::bind(collision.clone(), &motion.causes_suffocation)?;
|
||||
let resources = Resources {
|
||||
core: WorldCore {
|
||||
facts,
|
||||
biomes: biomes.clone(),
|
||||
environment,
|
||||
collision,
|
||||
motion,
|
||||
suffocation,
|
||||
threads,
|
||||
},
|
||||
rest: std::sync::OnceLock::new(),
|
||||
};
|
||||
let resources = Arc::new(resources);
|
||||
if menu {
|
||||
horizon_client::telemetry::mark_core_ready();
|
||||
log::info!(
|
||||
"Horizon: core-ready after {} ms",
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
// The block entity renderer's face visibility: the meshes' own, whose
|
||||
// shapes already hold their faces, not a second load of the same tables.
|
||||
let visibility = pack.context.tables.visibility.clone();
|
||||
let rest_loader = if menu {
|
||||
let resources = resources.clone();
|
||||
let root = root.clone();
|
||||
let vanilla_root = vanilla_root.to_owned();
|
||||
let entity_models = entity_models.clone();
|
||||
let rest_progress = progress.clone();
|
||||
let progress = progress.clone();
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("world-rest-loader".into())
|
||||
.spawn(move || {
|
||||
let _item_defaults = item_defaults;
|
||||
let _step = startup::step("rest");
|
||||
let rest = load_world_rest(
|
||||
&root,
|
||||
&resources,
|
||||
&assets,
|
||||
&behaviour_json,
|
||||
&gameplay_projection,
|
||||
entity_models.as_deref(),
|
||||
visibility,
|
||||
&progress,
|
||||
started,
|
||||
true,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
resources
|
||||
.rest
|
||||
.set(rest)
|
||||
.map_err(|_| "world resources already loaded".to_owned())?;
|
||||
progress.set(9);
|
||||
horizon_client::telemetry::mark_rest_ready();
|
||||
log::info!(
|
||||
"Horizon: rest-ready after {} ms",
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
write_prepared_files(vanilla_root);
|
||||
Ok(())
|
||||
})?;
|
||||
Some(WorldRestLoader {
|
||||
progress: rest_progress,
|
||||
handle,
|
||||
})
|
||||
} else {
|
||||
let rest = load_world_rest(
|
||||
&root,
|
||||
&resources,
|
||||
&assets,
|
||||
&behaviour_json,
|
||||
&gameplay_projection,
|
||||
entity_models.as_deref(),
|
||||
visibility,
|
||||
progress,
|
||||
started,
|
||||
!headless,
|
||||
)?;
|
||||
resources
|
||||
.rest
|
||||
.set(rest)
|
||||
.map_err(|_| "world resources already loaded")?;
|
||||
None
|
||||
};
|
||||
// The world tables load (above) while the handoff's CPU work is done: the
|
||||
// world renderer's textures beside the game's own tables. A failure here
|
||||
// waits for the tail, so no tail worker outlives its pack-loader generation.
|
||||
let prepared_at = Instant::now();
|
||||
let (game_cpu, renderer_cpu) = if menu {
|
||||
let (game_cpu, renderer_cpu) = std::thread::scope(|scope| {
|
||||
let textures = Step::spawn(scope, parallel, "textures", || {
|
||||
startup::time("handoff.renderer_textures", || {
|
||||
prepare_renderer_textures(&pack)
|
||||
})
|
||||
});
|
||||
let game_cpu = startup::time("handoff.game_cpu", || {
|
||||
GameCpuResources::load(&root, entity_models.as_deref())
|
||||
});
|
||||
(game_cpu, textures.join())
|
||||
});
|
||||
let game_cpu = match game_cpu {
|
||||
Ok(game_cpu) => game_cpu,
|
||||
Err(error) => {
|
||||
if let Some(tail) = rest_loader {
|
||||
let _ = tail.handle.join();
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
log::info!(
|
||||
"Horizon: handoff CPU resources prepared on worker in {} ms",
|
||||
prepared_at.elapsed().as_millis()
|
||||
);
|
||||
(Some(game_cpu), Some(renderer_cpu))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
Ok(Loaded {
|
||||
game_cpu,
|
||||
entity_models,
|
||||
renderer_cpu,
|
||||
root,
|
||||
pack_error,
|
||||
resources,
|
||||
rest_loader,
|
||||
pack,
|
||||
gui: loaded_gui,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write the prepared folder's `horizon-data.bin` when it has none the game
|
||||
/// can use: on a thread of its own, at idle priority, once the start is done.
|
||||
/// This start has just read the folder's files (they are in the disk cache);
|
||||
/// the next starts read them from one file (#1961).
|
||||
pub(super) fn write_prepared_files(root: PathBuf) {
|
||||
use horizon_core::prepared_files;
|
||||
if prepared_files::open(&root).is_some() || !root.join("horizon-resources.json").is_file() {
|
||||
return;
|
||||
}
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Prepared data file".into())
|
||||
.spawn(move || {
|
||||
horizon_threads::idle_current();
|
||||
let started = Instant::now();
|
||||
match prepared_files::write(&root) {
|
||||
Ok(()) => log::info!(
|
||||
"Horizon: wrote {} in {} ms",
|
||||
prepared_files::FILE_NAME,
|
||||
started.elapsed().as_millis()
|
||||
),
|
||||
Err(e) => log::info!("Horizon: {} not written: {e}", prepared_files::FILE_NAME),
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::info!(
|
||||
"Horizon: {} not written: {e}",
|
||||
horizon_core::prepared_files::FILE_NAME
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! The game's shared read-only tables: `Resources` (blocks, items, recipes, mob types, biomes),
|
||||
//! `UiResources` and `PackResources`.
|
||||
use super::*;
|
||||
|
||||
impl Resources {
|
||||
pub(super) fn rest(&self) -> &WorldRest {
|
||||
self.rest
|
||||
.get()
|
||||
.expect("world resources joined before entry")
|
||||
}
|
||||
pub(super) fn facts(&self) -> &BlockFacts {
|
||||
&self.core.facts
|
||||
}
|
||||
pub(super) fn items(&self) -> &Items {
|
||||
&self.rest().items
|
||||
}
|
||||
pub(super) fn recipes(&self) -> &Recipes {
|
||||
&self.rest().recipes
|
||||
}
|
||||
pub(super) fn transformers(&self) -> &horizon_client::block_transform::Transformers {
|
||||
&self.rest().transformers
|
||||
}
|
||||
pub(super) fn biomes(&self) -> &Arc<Biomes> {
|
||||
&self.core.biomes
|
||||
}
|
||||
pub(super) fn environment(&self) -> &Environment {
|
||||
&self.core.environment
|
||||
}
|
||||
pub(super) fn mob_types(&self) -> &HashMap<String, Arc<MobType>> {
|
||||
&self.rest().mob_types
|
||||
}
|
||||
pub(super) fn mob_data(&self) -> &horizon_client::mobs::MobData {
|
||||
&self.rest().mob_data
|
||||
}
|
||||
pub(super) fn providers(&self) -> &Providers {
|
||||
&self.rest().providers
|
||||
}
|
||||
pub(super) fn block_entity_render(&self) -> &BlockEntityRenderer {
|
||||
&self.rest().block_entity_render
|
||||
}
|
||||
pub(super) fn animate(&self) -> &horizon_client::animate_tick::AnimateTables {
|
||||
&self.rest().animate
|
||||
}
|
||||
pub(super) fn fog(&self) -> &horizon_client::fog::FogTables {
|
||||
&self.rest().fog
|
||||
}
|
||||
pub(super) fn music(&self) -> &horizon_client::music::MusicTables {
|
||||
&self.rest().music
|
||||
}
|
||||
pub(super) fn ambient(&self) -> &horizon_client::ambience::AmbientTables {
|
||||
&self.rest().ambient
|
||||
}
|
||||
pub(super) fn nether_environment(&self) -> &Environment {
|
||||
&self.rest().nether_environment
|
||||
}
|
||||
pub(super) fn end_environment(&self) -> &Environment {
|
||||
&self.rest().end_environment
|
||||
}
|
||||
pub(super) fn behaviours(&self) -> &Behaviours {
|
||||
&self.rest().behaviours
|
||||
}
|
||||
pub(super) fn collision(&self) -> &BlockCollisionRegistry<'static> {
|
||||
&self.core.collision
|
||||
}
|
||||
pub(super) fn suffocation(&self) -> &horizon_world::suffocation::SuffocationRegistry<'static> {
|
||||
&self.core.suffocation
|
||||
}
|
||||
pub(super) fn motion(&self) -> &MotionResources {
|
||||
&self.core.motion
|
||||
}
|
||||
pub(super) fn threads(&self) -> usize {
|
||||
self.core.threads
|
||||
}
|
||||
pub(super) fn player_arm(&self, slim: bool, left: bool) -> &Option<horizon_client::mobs::Part> {
|
||||
let arms = if slim {
|
||||
&self.rest().player_arm_slim
|
||||
} else {
|
||||
&self.rest().player_arm
|
||||
};
|
||||
&arms[usize::from(left)]
|
||||
}
|
||||
pub(super) fn player_type(&self) -> &Option<Arc<horizon_client::mobs::MobType>> {
|
||||
&self.rest().player_type
|
||||
}
|
||||
pub(super) fn spin_attack(&self) -> &Option<horizon_client::mobs::Part> {
|
||||
&self.rest().spin_attack
|
||||
}
|
||||
pub(super) fn stations(&self) -> &horizon_client::stations::StationData {
|
||||
&self.rest().stations
|
||||
}
|
||||
}
|
||||
|
||||
impl UiResources {
|
||||
/// From a resource root layering packs over `defaults`, the game's own
|
||||
/// resources (`root` itself when no pack is on).
|
||||
pub fn load(root: &std::path::Path, defaults: &std::path::Path) -> Result<Self> {
|
||||
// The title screen waits for both: the panorama's six faces decode
|
||||
// beside the atlas.
|
||||
std::thread::scope(|scope| {
|
||||
let panorama = named_scope(scope, "panorama", || {
|
||||
startup::time("ui.panorama", || {
|
||||
horizon_client::gfx::panorama::Faces::load(root, defaults)
|
||||
})
|
||||
});
|
||||
let mod_image = crate::game::mods::built_with_native_mods();
|
||||
let atlas = startup::time("ui.atlas", || {
|
||||
HudAtlas::build_over_with_image(root, defaults, mod_image)
|
||||
});
|
||||
let panorama = panorama
|
||||
.join()
|
||||
.unwrap_or_else(|panic| std::panic::resume_unwind(panic));
|
||||
Ok(Self {
|
||||
atlas: atlas?,
|
||||
panorama: Arc::new(panorama),
|
||||
})
|
||||
})
|
||||
}
|
||||
/// What a server's game holds, since it draws nothing: the font, which books
|
||||
/// and chat measure with, and a one-pixel panorama.
|
||||
pub fn server(root: &std::path::Path) -> Result<Self> {
|
||||
Ok(Self {
|
||||
atlas: HudAtlas::fonts(root)?,
|
||||
panorama: Arc::new(horizon_client::gfx::panorama::Faces::colour([0; 4])),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl PackResources {
|
||||
/// Everything a pack overlay at `root` provides, over `assets` and `render`
|
||||
/// loaded from it.
|
||||
pub(super) fn load(
|
||||
root: &std::path::Path,
|
||||
assets: Arc<Assets>,
|
||||
render: Arc<RenderAssets>,
|
||||
facts: &BlockFacts,
|
||||
biomes: Arc<Biomes>,
|
||||
light: Arc<LightTables>,
|
||||
) -> Result<Self> {
|
||||
let visibility = models::load_visibility(root, assets.registry)?;
|
||||
let tables = Arc::new(MeshTables::new(&assets, &render, facts, visibility)?);
|
||||
let item_renderer = horizon_client::item_render::ItemRenderer::load_with_library(
|
||||
root,
|
||||
render.model_library.clone(),
|
||||
)?;
|
||||
Ok(Self::assemble(
|
||||
root,
|
||||
assets,
|
||||
render,
|
||||
tables,
|
||||
item_renderer,
|
||||
biomes,
|
||||
light,
|
||||
))
|
||||
}
|
||||
/// The workers' context over `biomes`: this pack's own when `biomes` is
|
||||
/// the table it was built with, else the same parts with that table.
|
||||
pub(super) fn context_with(&self, biomes: Arc<Biomes>) -> Arc<WorkerContext> {
|
||||
let old = &self.context;
|
||||
if Arc::ptr_eq(&old.biomes, &biomes) {
|
||||
return old.clone();
|
||||
}
|
||||
Arc::new(WorkerContext {
|
||||
assets: old.assets.clone(),
|
||||
render: old.render.clone(),
|
||||
tables: old.tables.clone(),
|
||||
biomes,
|
||||
light: old.light.clone(),
|
||||
})
|
||||
}
|
||||
/// The pack tables over their parts, which `load_resources` builds on
|
||||
/// separate workers.
|
||||
pub(super) fn assemble(
|
||||
root: &std::path::Path,
|
||||
assets: Arc<Assets>,
|
||||
render: Arc<RenderAssets>,
|
||||
tables: Arc<MeshTables>,
|
||||
item_renderer: horizon_client::item_render::ItemRenderer,
|
||||
biomes: Arc<Biomes>,
|
||||
light: Arc<LightTables>,
|
||||
) -> Self {
|
||||
Self {
|
||||
layer_names: Arc::new(
|
||||
render
|
||||
.sprite_layers
|
||||
.iter()
|
||||
.map(|(name, layer)| (*layer, name.trim_start_matches("minecraft:").to_owned()))
|
||||
.collect(),
|
||||
),
|
||||
context: Arc::new(WorkerContext {
|
||||
assets: assets.clone(),
|
||||
render: render.clone(),
|
||||
tables,
|
||||
biomes,
|
||||
light,
|
||||
}),
|
||||
assets,
|
||||
render,
|
||||
item_renderer: Arc::new(item_renderer),
|
||||
names: Arc::new(Names::load(root)),
|
||||
clouds: Arc::new(load_clouds(root)),
|
||||
forcefield: Arc::new(load_forcefield(root)),
|
||||
custom_sky: Arc::new(horizon_client::custom_sky::CustomSky::load(root)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! What a frame draws behind the screens: the title panorama, the level loading background, the
|
||||
//! menu blur and transparent background, the world border wall, and the screens' render events.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `native_screen_render_post` (`ScreenEvent.Render.Post`) for the open
|
||||
/// screen, once a frame; the Win and Options screens call this on their
|
||||
/// own path. Reads `self.screen`, the one `hud_layers` drew. Not while
|
||||
/// playing with no screen, nor while the server pack prompt hides the
|
||||
/// open screen (`hud_layers` draws `Playing` then). Client only,
|
||||
/// read-only: what a mod draws is its own.
|
||||
pub(super) fn fire_screen_render_post(&self, batch: &mut HudBatch) {
|
||||
let screen = self.screen;
|
||||
if screen == Screen::Playing || self.server_packs.prompt.is_some() || self.book.is_some() {
|
||||
return;
|
||||
}
|
||||
// slot_count mirrors slots() without allocating; keep them in step.
|
||||
debug_assert_eq!(self.slot_count(), self.slots().len());
|
||||
let view = self.screen_view(batch, screen);
|
||||
self.native_screen_render_post(batch, &view);
|
||||
}
|
||||
/// `native_screen_render_post` (`ScreenEvent.Render.Post`) for the title's
|
||||
/// own screen (`title`, `world_select`, `world_create`, `multiplayer`…),
|
||||
/// once a frame, after it has drawn and before the toasts. Not when a
|
||||
/// screen of the game's own is over the title (Options draws and fires
|
||||
/// its own), nor while a dialog, a prompt or a connect or load hides it.
|
||||
/// Client only, read-only: what a mod draws is its own.
|
||||
pub(super) fn fire_title_screen_render_post(&self, batch: &mut HudBatch) {
|
||||
if self.screen != Screen::Playing || self.screen_over_playing() || self.remote_loading() {
|
||||
return;
|
||||
}
|
||||
let Some(view) = self.title_screen_view(batch) else {
|
||||
return;
|
||||
};
|
||||
self.native_screen_render_post(batch, &view);
|
||||
}
|
||||
|
||||
/// The frame shows the title's panorama, not a level: the title screen, or a
|
||||
/// LevelLoadingScreen (Reason.OTHER), which draws it too.
|
||||
pub(super) fn shows_panorama(&self) -> bool {
|
||||
// The two cases that always had one: the title screen and waiting on
|
||||
// a server. Left exactly as they were.
|
||||
self.title.is_some() || self.remote_loading() || self.screen_wants_panorama()
|
||||
}
|
||||
/// Whether the screen on top asked for the panorama through
|
||||
/// [`horizon_client::screen_draw::Backdrop`].
|
||||
///
|
||||
/// This is the opt-in path for Horizon's own screens: a screen sets its
|
||||
/// `backdrop` and the renderer draws the panorama behind it, instead of
|
||||
/// every such screen needing its own branch in `shows_panorama`. Vanilla
|
||||
/// screens leave the backdrop at `None` and so never reach this.
|
||||
///
|
||||
/// Only in a menu: in a world the panorama would replace the world the
|
||||
/// player is standing in, which is not what any of these screens want.
|
||||
pub(super) fn screen_wants_panorama(&self) -> bool {
|
||||
use horizon_client::screen_draw::Backdrop;
|
||||
if self.player.is_some() {
|
||||
return false;
|
||||
}
|
||||
self.ui
|
||||
.options
|
||||
.pack_screen
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.backdrop == Backdrop::Panorama)
|
||||
}
|
||||
/// `LevelLoadingScreen.extractBackground`.
|
||||
pub(super) fn draw_level_loading_background(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
reason: horizon_client::level_loading::Reason,
|
||||
) {
|
||||
use horizon_client::level_loading::Reason;
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
match reason {
|
||||
// The panorama and its blur are drawn by the renderer.
|
||||
Reason::Other => {
|
||||
horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false)
|
||||
}
|
||||
Reason::NetherPortal => {
|
||||
let overlay = hud_overlays::portal_overlay(w as i32, h as i32, 1.0);
|
||||
hud_layout::paint(batch, atlas, &[overlay]);
|
||||
}
|
||||
Reason::EndPortal => batch.fill(atlas, 0.0, 0.0, w, h, rgba(20, 20, 20, 255)),
|
||||
}
|
||||
}
|
||||
/// `GameRenderer.update`'s `requestedPostEffects`: `end_of_frame` (a resource
|
||||
/// pack's own chain), then the server's `LocalPlayer.postEffects`. Only the
|
||||
/// world is drawn through them.
|
||||
pub(super) fn requested_post_effects(&self) -> Vec<String> {
|
||||
if self.title.is_some() || self.player.is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
std::iter::once("minecraft:end_of_frame".to_owned())
|
||||
.chain(self.post_effects.iter().cloned())
|
||||
.chain(self.spectated_post_effect.map(str::to_owned))
|
||||
.collect()
|
||||
}
|
||||
/// `Screen.extractBlurredBackground` over the panorama or the world and
|
||||
/// HUD, when `menuBackgroundBlurriness` is at least 1.
|
||||
pub(super) fn menu_blur(&self) -> Option<f32> {
|
||||
let blurred = self.remote_loading()
|
||||
|| self.title.as_ref().is_some_and(|t| {
|
||||
matches!(self.screen, Screen::Options | Screen::Win)
|
||||
|| !matches!(t.mode, horizon_client::title::Mode::Main)
|
||||
})
|
||||
|| (self.title.is_none()
|
||||
&& self.player.is_some()
|
||||
&& (self.screen.blurs_background(self.pause_menu)
|
||||
|| self.dialog_open()
|
||||
|| (self.screen == Screen::Win && self.win_screen_pauses())))
|
||||
|| self.screen_blur_requested.get();
|
||||
let radius = self.settings.menu_blur as f32;
|
||||
let mut radius = (blurred && radius >= 1.0).then_some(radius);
|
||||
self.native_menu_background(&mut radius);
|
||||
radius.filter(|r| r.is_finite() && (1.0..=64.0).contains(r))
|
||||
}
|
||||
/// `Screen.renderTransparentBackground`: draws the translucent dark background
|
||||
/// behind an in-game screen (inventory, containers, creative, books, signs, editors),
|
||||
/// giving native mods a chance to suppress it (`Flow::Cancel`) or apply menu blur / tiled background.
|
||||
pub(super) fn render_transparent_background(&self, screen: &str, batch: &mut HudBatch) {
|
||||
let mut canceled = false;
|
||||
self.native_screen_transparent_background(screen, batch, &mut canceled);
|
||||
if !canceled {
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.fill(
|
||||
atlas,
|
||||
0.0,
|
||||
0.0,
|
||||
batch.width,
|
||||
batch.height,
|
||||
rgba(16, 16, 16, 160),
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `LevelExtractor` / `LevelRenderer`: the world border wall this frame.
|
||||
pub(super) fn border_draw(
|
||||
&mut self,
|
||||
camera: [f64; 3],
|
||||
partial: f32,
|
||||
) -> Option<horizon_client::gfx::BorderDraw> {
|
||||
use horizon_client::border_render::{self, BorderRenderer};
|
||||
let distance = f64::from(self.streaming.radius * 16);
|
||||
border_render::extract(
|
||||
&self.border,
|
||||
partial,
|
||||
camera,
|
||||
distance,
|
||||
&mut self.border_state,
|
||||
);
|
||||
// `LevelRenderer.repositionCamera`: the wall is rebuilt when the camera
|
||||
// enters another `SectionPos` (it only holds the stretch in range).
|
||||
let section = camera.map(|c| (c.floor() as i32) >> 4);
|
||||
if self.border_section != Some(section) {
|
||||
self.border_renderer.invalidate();
|
||||
self.border_section = Some(section);
|
||||
}
|
||||
// Camera.depthFar, as the renderer's far plane.
|
||||
let depth_far =
|
||||
horizon_client::camera::depth_far(self.terrain_distance(), self.settings.cloud_range);
|
||||
if !self
|
||||
.border_renderer
|
||||
.prepare(&self.border_state, camera, distance, depth_far)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let millis = self.visual_millis();
|
||||
Some(horizon_client::gfx::BorderDraw {
|
||||
vertices: self.border_renderer.vertices,
|
||||
rebuilt: self.border_renderer.take_rebuilt(),
|
||||
transforms: self
|
||||
.border_renderer
|
||||
.transforms(&self.border_state, camera, millis),
|
||||
sides: BorderRenderer::sides(&self.border_state, camera, distance),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
//! An item in use, tick by tick (`LivingEntity.startUsingItem`, `updatingUsingItem`,
|
||||
//! `releaseUsingItem`, `stopUsingItem`): eating and drinking, goat horns, bundles, and the
|
||||
//! vibrations a use makes.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Consumable.startConsuming` for a held consumable: food needs
|
||||
/// `Player.canEat` (`Consumable.canConsume`), anything else is always
|
||||
/// consumed. The right click is held for `consume_seconds`.
|
||||
pub(super) fn start_eating(&mut self, info: &horizon_client::items::ItemInfo) -> bool {
|
||||
let Some(stack) = self.held().cloned() else {
|
||||
return false;
|
||||
};
|
||||
if info.consumable.is_none()
|
||||
|| (info.food.is_some()
|
||||
&& (self.player.is_none() || !self.can_eat(&stack, info.can_always_eat)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// as Consumable.startConsuming does, no time is eaten at once, without a use
|
||||
// (the server's eating reaches a client through the inventory).
|
||||
if let Some(consumable) = info.consumable.as_ref()
|
||||
&& item_use::consumes_at_once(consumable.ticks())
|
||||
{
|
||||
if self.remote.is_none() {
|
||||
// the cooldown of ItemStack.use's applyAfterUseComponentSideEffects.
|
||||
self.apply_use_cooldown(&stack);
|
||||
self.finish_consuming(info, consumable, &stack);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
self.start_using_item();
|
||||
true
|
||||
}
|
||||
/// `InstrumentItem.use` for a goat horn: the player holds it up for the
|
||||
/// instrument's `use_duration`, its sound plays at the player
|
||||
/// (`SoundSource.RECORDS`, volume `range / 16`), `INSTRUMENT_PLAY` is
|
||||
/// posted, the used statistic counts and the horn is on cooldown as long.
|
||||
/// A horn whose instrument is unknown fails, which still ends the click.
|
||||
pub(super) fn use_instrument(&mut self, stack: &ItemStack) {
|
||||
let Some(instrument) = self.res.items().instrument(stack) else {
|
||||
return;
|
||||
};
|
||||
self.start_using_item();
|
||||
self.play_player_bound_sound(&instrument.sound, 2, instrument.range / 16.0, 1.0, true);
|
||||
self.post_entity_event(
|
||||
"minecraft:instrument_play",
|
||||
horizon_client::mobs::Who::Player,
|
||||
);
|
||||
if instrument.use_duration > 0.0 {
|
||||
let group = horizon_client::cooldowns::group(stack, None);
|
||||
self.add_cooldown(group, instrument.use_ticks());
|
||||
}
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
}
|
||||
|
||||
pub(super) fn consume_held(&mut self) {
|
||||
if self.creative {
|
||||
return;
|
||||
}
|
||||
let cell = self.held_mut();
|
||||
if let Some(s) = cell.as_mut() {
|
||||
s.count -= 1;
|
||||
if s.count == 0 {
|
||||
*cell = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn tick_using(&mut self) {
|
||||
let Some(hand) = self.used_hand() else {
|
||||
return;
|
||||
};
|
||||
self.with_hand(hand, Self::tick_used_item);
|
||||
}
|
||||
/// `LivingEntity.updatingUsingItem` for the item in use, its hand acting.
|
||||
pub(super) fn tick_used_item(&mut self) {
|
||||
let Some((slot, ticks)) = self.using else {
|
||||
return;
|
||||
};
|
||||
let Some(stack) = self.stack_in(slot).cloned() else {
|
||||
self.using = None;
|
||||
return;
|
||||
};
|
||||
// Switching hotbar slots only ends a main-hand use.
|
||||
let hand_changed = slot != self.selected && slot != shield::OFFHAND_SLOT;
|
||||
if !self.right || hand_changed || self.screen != Screen::Playing {
|
||||
self.using = None;
|
||||
self.release_used_item(&stack, ticks, hand_changed);
|
||||
return;
|
||||
}
|
||||
// ServerPlayer.updateUsingItem: USING_ITEM every tick of the use.
|
||||
self.trigger_using_item(&stack);
|
||||
let Some(info) = self.items().get(&stack.item).cloned() else {
|
||||
self.using = None;
|
||||
return;
|
||||
};
|
||||
// a mod's tick ran the use out: finished now, or frozen until let go
|
||||
// for an item used on release (CrossbowItem.useOnRelease).
|
||||
if !self.use_item_ticked(&stack, ticks) {
|
||||
if &*stack.item != "minecraft:crossbow" {
|
||||
self.complete_using_item(slot, &stack, &info);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.blocks_attacks(&stack).is_some() {
|
||||
// ShieldItem: used for 72000 ticks, i.e. until released.
|
||||
self.item_on_use_tick(&stack, ticks);
|
||||
self.using = Some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
if self.tick_drinking(slot, ticks, &stack) {
|
||||
if self.using.is_none() {
|
||||
self.use_item_finished(&stack);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if &*stack.item == "minecraft:bow" {
|
||||
self.using = Some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
if &*stack.item == "minecraft:crossbow" {
|
||||
self.tick_crossbow(slot, ticks, &stack);
|
||||
return;
|
||||
}
|
||||
// TridentItem and EnderEyeItem: used until released (an eye's
|
||||
// getUseDuration 0 never counts down to finishUsingItem).
|
||||
if matches!(&*stack.item, "minecraft:trident" | "minecraft:ender_eye") {
|
||||
self.using = Some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
// ItemStack.onUseTick: a spear stabs what its speed allows, until released.
|
||||
if horizon_client::spear::Kinetic::of(&stack.item).is_some() {
|
||||
self.spear_tick(&stack, ticks);
|
||||
self.using = Some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
if &*stack.item == "minecraft:spyglass" {
|
||||
// SpyglassItem.getUseDuration (1200, as a mod left it), then
|
||||
// completeUsingItem: finishUsingItem lowers it, and the finish hook.
|
||||
match item_use::count_down(ticks, self.use_duration) {
|
||||
Some(ticks) => self.using = Some((slot, ticks)),
|
||||
None => self.complete_using_item(slot, &stack, &info),
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(duration) = self
|
||||
.res
|
||||
.items()
|
||||
.instrument(&stack)
|
||||
.map(|i| i.use_ticks().max(0) as u32)
|
||||
{
|
||||
// InstrumentItem.getUseDuration: held up that long. When it runs
|
||||
// out, LivingEntity.stopUsingItem ends the use and causes the
|
||||
// interaction vibration; letting go of the button already does.
|
||||
if ticks + 1 < duration {
|
||||
self.using = Some((slot, ticks + 1));
|
||||
} else {
|
||||
self.stop_using_item(&stack);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if &*stack.item == "minecraft:brush" {
|
||||
self.tick_brush(&stack, slot, ticks);
|
||||
return;
|
||||
}
|
||||
if info.bundle {
|
||||
self.tick_bundle(slot, ticks);
|
||||
return;
|
||||
}
|
||||
if self.mod_use(&stack) {
|
||||
self.tick_mod_use(slot, ticks, &stack);
|
||||
return;
|
||||
}
|
||||
let Some(consumable) = info.consumable.as_ref() else {
|
||||
self.using = None;
|
||||
return;
|
||||
};
|
||||
// ItemStack.onUseTick: Consumable.shouldEmitParticlesAndSounds with
|
||||
// the remaining ticks before this tick counts them down.
|
||||
// the duration is getUseDuration's when the use started, as a mod's
|
||||
// Start and Tick left it.
|
||||
let remaining = self.use_duration - ticks as i32;
|
||||
if consumable.should_emit(remaining) {
|
||||
self.emit_consume(consumable, &stack, 5);
|
||||
}
|
||||
self.item_on_use_tick(&stack, ticks);
|
||||
match item_use::count_down(ticks, self.use_duration) {
|
||||
Some(ticks) => self.using = Some((slot, ticks)),
|
||||
None => self.complete_using_item(slot, &stack, &info),
|
||||
}
|
||||
}
|
||||
/// `BundleItem.onUseTick` and `getUseDuration` (200): the first tick and
|
||||
/// then every second tick from the 11th on drop one stack out of the
|
||||
/// bundle in `slot`; the use ends after 200 ticks.
|
||||
pub(super) fn tick_bundle(&mut self, slot: usize, ticks: u32) {
|
||||
const USE_DURATION: u32 = 200;
|
||||
let remaining = USE_DURATION - ticks.min(USE_DURATION);
|
||||
if ticks == 0 || (remaining < USE_DURATION - 10 && remaining.is_multiple_of(2)) {
|
||||
self.drop_bundle_content(slot);
|
||||
}
|
||||
self.using = (ticks + 1 < USE_DURATION).then_some((slot, ticks + 1));
|
||||
}
|
||||
|
||||
/// `LivingEntity.releaseUsingItem` (`Item.releaseUsing`) for `stack`,
|
||||
/// used for `ticks`, its hand acting: a bow or a trident lets go (not
|
||||
/// when its hand changed), a spyglass stops scoping, and a mod's own
|
||||
/// release follows. A mod's `Stop` may skip the release. `stopUsingItem`
|
||||
/// ends the use after it.
|
||||
pub(super) fn release_used_item(&mut self, stack: &ItemStack, ticks: u32, hand_changed: bool) {
|
||||
let canceled = !hand_changed && !self.use_item_stopped(stack, ticks);
|
||||
let (release, spyglass) = item_use::release_parts(canceled, hand_changed);
|
||||
match &*stack.item {
|
||||
"minecraft:bow" if release => self.release_bow(stack, self.ticks_held(stack, ticks)),
|
||||
"minecraft:trident" if release => self.release_trident(self.ticks_held(stack, ticks)),
|
||||
"minecraft:spyglass" if spyglass => self.stop_spyglass(),
|
||||
_ => {}
|
||||
}
|
||||
if release {
|
||||
self.mod_release_using(stack, ticks);
|
||||
}
|
||||
self.stop_using_item(stack);
|
||||
}
|
||||
/// `LivingEntity.stopUsingItem`: the use of `stack` is over (`useItem` and
|
||||
/// its remaining ticks are cleared), and when the use effects of the stack
|
||||
/// ask for it, `ItemStack.causeUseVibration` sends an `ITEM_INTERACT_FINISH`
|
||||
/// game event from the player, at its feet.
|
||||
pub(super) fn stop_using_item(&mut self, stack: &ItemStack) {
|
||||
self.using = None;
|
||||
self.use_vibration(stack, "minecraft:item_interact_finish");
|
||||
}
|
||||
/// `ItemStack.causeUseVibration` for `stack` used by the player: the
|
||||
/// `event` game event (`ITEM_INTERACT_START` when the use begins,
|
||||
/// `ITEM_INTERACT_FINISH` when it ends) from the player, at its feet, when
|
||||
/// the stack's use effects ask for interaction vibrations (the default).
|
||||
pub(super) fn use_vibration(&mut self, stack: &ItemStack, event: &'static str) {
|
||||
let default = self.items().get(&stack.item).map(|i| i.interact_vibrations);
|
||||
if !item_use::interact_vibrations(stack, default) {
|
||||
return;
|
||||
}
|
||||
let Some(p) = self.player.as_ref().map(|p| p.position) else {
|
||||
return;
|
||||
};
|
||||
let ctx = self.player_context(None);
|
||||
self.post_game_event(event, [p.x, p.y, p.z], ctx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! winit's `ApplicationHandler` for the game window: window and device events, shutting down,
|
||||
//! and the idle step (`about_to_wait`).
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// winit's `window_event`. The control socket and the pad menu call it too, with the
|
||||
/// input they make up.
|
||||
pub(super) fn handle_window_event(
|
||||
&mut self,
|
||||
event_loop: &dyn EventLoopCtl,
|
||||
id: WindowId,
|
||||
mut event: WindowEvent,
|
||||
origin: input_trace::InputOrigin,
|
||||
) {
|
||||
// A headless game has no window: its events (the frame, the control socket's input)
|
||||
// carry `WindowId::dummy()`.
|
||||
let ours = match &self.window {
|
||||
Some(w) => w.id() == id,
|
||||
None => self.options.headless,
|
||||
};
|
||||
if !ours {
|
||||
return;
|
||||
}
|
||||
follow_keymap(&mut event);
|
||||
// MacosUtil.setCtrlClickEmulatesRightClick: Control + left click is a right click.
|
||||
if let WindowEvent::MouseInput { state, button, .. } = &mut event
|
||||
&& *button == MouseButton::Left
|
||||
&& self.right_click_emulation.is_right_click(
|
||||
*state == ElementState::Pressed,
|
||||
self.modifiers.control_key(),
|
||||
cfg!(target_os = "macos") && self.settings.ctrl_click_emulates_right_click,
|
||||
)
|
||||
{
|
||||
*button = MouseButton::Right;
|
||||
}
|
||||
// Who caused it, before the event is handled: the trace wants the
|
||||
// window's own keys, never the ones the socket made up.
|
||||
self.trace_window_event(origin, &event);
|
||||
if let WindowEvent::KeyboardInput { event, .. } = &event
|
||||
&& let PhysicalKey::Code(key) = event.physical_key
|
||||
{
|
||||
if event.state == ElementState::Pressed {
|
||||
self.physical_keys.insert(key);
|
||||
horizon_client::keybinds::learn_layout(event);
|
||||
} else {
|
||||
self.physical_keys.remove(&key);
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
&event,
|
||||
WindowEvent::MouseInput {
|
||||
state: ElementState::Pressed,
|
||||
..
|
||||
}
|
||||
) && !self.pad.menu.synthetic
|
||||
{
|
||||
self.pad.keyboard_took_over = true;
|
||||
}
|
||||
if let WindowEvent::MouseInput { state, button, .. } = &event {
|
||||
if *state == ElementState::Pressed {
|
||||
self.physical_buttons.insert(*button);
|
||||
} else {
|
||||
self.physical_buttons.remove(button);
|
||||
}
|
||||
}
|
||||
let mouse = if !self.pad_synthetic_input()
|
||||
&& let WindowEvent::MouseInput { state, button, .. } = &event
|
||||
{
|
||||
Some((
|
||||
glfw_button(*button),
|
||||
*state == ElementState::Pressed,
|
||||
glfw_modifiers(self.modifiers),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some((button, pressed, modifiers)) = mouse {
|
||||
let mut canceled = false;
|
||||
self.native_mouse_button_pre(button, pressed, modifiers, &mut canceled);
|
||||
if canceled {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.handle_window_event_inner(event_loop, event);
|
||||
if let Some((button, pressed, modifiers)) = mouse {
|
||||
self.native_mouse_button_post(button, pressed, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
/// The loop ended: close the pads' output, save the world, write the perf report.
|
||||
pub(super) fn shut_down(&mut self) {
|
||||
self.end_screenshot_sequence(Err("cancelled: the game is closing".into()));
|
||||
self.pad_close_output();
|
||||
if let Err(e) = self.save_for_exit() {
|
||||
log::warn!("Horizon: failed to save the world on exit: {e}");
|
||||
}
|
||||
self.finish_perf();
|
||||
}
|
||||
/// winit's `about_to_wait`: what happens between frames (stop requests, the closing
|
||||
/// save, world entry, the control socket, pads), then the next frame.
|
||||
pub(super) fn idle(&mut self, event_loop: &dyn EventLoopCtl) {
|
||||
// A splitscreen player's game has nothing to save: P1's shuts the process down.
|
||||
if horizon_threads::stop_requests() > 0
|
||||
&& !self.options.split_guest
|
||||
&& self.pending_save.as_ref().is_none_or(|save| !save.quit)
|
||||
{
|
||||
// SIGTERM, SIGINT or SIGHUP: the shutdown hook's server halt, as a window close.
|
||||
if let Err(error) = self.begin_closing_save(true) {
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self.pending_save.is_some() {
|
||||
match self.poll_closing_save() {
|
||||
Ok(true) => event_loop.exit(),
|
||||
Ok(false) => {
|
||||
if let Some(window) = &self.window {
|
||||
window.request_redraw();
|
||||
}
|
||||
// Yield between polls even when disk I/O is slow.
|
||||
event_loop.set_control_flow(ControlFlow::WaitUntil(
|
||||
Instant::now() + Duration::from_millis(4),
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
self.error = Some(error);
|
||||
self.exit_on_error(event_loop);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Err(error) = horizon_client::spans::time("world_entry", || self.resume_world_entry())
|
||||
{
|
||||
self.error = Some(error);
|
||||
self.exit_on_error(event_loop);
|
||||
return;
|
||||
}
|
||||
// The world a backup made before its upgrade opens (EditWorldScreen's callback).
|
||||
if let Some(folder) = self.title.as_mut().and_then(|t| t.take_open_request())
|
||||
&& let Err(error) = self.open_world(&folder, None, None, false, None)
|
||||
{
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
// A Realm's address came back (GetServerDetailsTask hands over to ConnectScreen).
|
||||
if let Some(action) = self.title.as_mut().and_then(|t| t.take_connect_request())
|
||||
&& let Err(error) = self.title_action(event_loop, action)
|
||||
{
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
horizon_client::spans::time("server_packs", || self.tick_server_packs());
|
||||
horizon_client::spans::time("control", || self.poll_control(event_loop));
|
||||
if self.pending_save.is_some() {
|
||||
return;
|
||||
}
|
||||
horizon_client::spans::time("pads", || self.poll_pads(event_loop));
|
||||
self.pause_if_inactive();
|
||||
if self.options.frames.is_some() && self.started.elapsed() > self.options.timeout {
|
||||
self.error = Some("frame-limited run timed out".into());
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
if let Some(w) = &self.window {
|
||||
if self.options.offscreen {
|
||||
// Offscreen frames don't wait for the window to be drawn: an
|
||||
// occluded window (a locked macOS session) gets no redraws.
|
||||
let id = w.id();
|
||||
self.handle_window_event(
|
||||
event_loop,
|
||||
id,
|
||||
WindowEvent::RedrawRequested,
|
||||
input_trace::InputOrigin::Game,
|
||||
);
|
||||
} else {
|
||||
w.request_redraw();
|
||||
}
|
||||
} else if self.options.headless {
|
||||
// The headless loop paces the frames itself (`run_headless`).
|
||||
self.handle_window_event(
|
||||
event_loop,
|
||||
WindowId::dummy(),
|
||||
WindowEvent::RedrawRequested,
|
||||
input_trace::InputOrigin::Game,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Consume the last frame's result: a hidden compositor may stop sending
|
||||
// redraws altogether, leaving a successful result stale indefinitely.
|
||||
event_loop.set_control_flow(event_loop::redraw_flow(
|
||||
&mut self.frame_drawn,
|
||||
self.options.offscreen,
|
||||
Instant::now(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHandler for Game<'_> {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
debug_keys::warm_debug_cpu();
|
||||
// The Window constructor: created fullscreen when options.txt or --fullscreen says so.
|
||||
let fullscreen = self.settings.fullscreen || self.options.fullscreen;
|
||||
let size = self.options.window_size.map(f64::from);
|
||||
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
|
||||
let attributes = horizon_client::window::with_horizon_icon(
|
||||
Window::default_attributes().with_title("Horizon"),
|
||||
);
|
||||
// Wayland names the window by its app id, which the horizon.desktop entry matches.
|
||||
#[cfg(target_os = "linux")]
|
||||
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
|
||||
attributes, "horizon", "horizon",
|
||||
);
|
||||
// `--offscreen` renders into a texture: its window would only show white.
|
||||
let window_step = startup::step("window.create");
|
||||
let window = match event_loop.create_window(
|
||||
attributes
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1]))
|
||||
.with_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None)))
|
||||
.with_visible(!self.options.offscreen),
|
||||
) {
|
||||
Ok(w) => Arc::new(w),
|
||||
Err(e) => {
|
||||
self.error = Some(e.into());
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
};
|
||||
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
|
||||
drop(window_step);
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
match self.new_renderer(window.clone()) {
|
||||
Ok(r) => {
|
||||
self.renderer = Some(r);
|
||||
self.world_renderer_ready = true;
|
||||
}
|
||||
Err(e) => {
|
||||
self.error = Some(e);
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.window = Some(window);
|
||||
self.ime_allowed = false;
|
||||
self.ime_area = None;
|
||||
self.apply_settings();
|
||||
self.previous = Instant::now();
|
||||
}
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
|
||||
let key = if self.window.as_ref().is_some_and(|w| w.id() == id)
|
||||
&& let WindowEvent::KeyboardInput {
|
||||
event,
|
||||
is_synthetic: false,
|
||||
..
|
||||
} = &event
|
||||
{
|
||||
Some((event.clone(), glfw_modifiers(self.modifiers)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.handle_window_event(event_loop, id, event, input_trace::InputOrigin::User);
|
||||
if let Some((event, modifiers)) = key {
|
||||
self.native_input_key(&event, modifiers);
|
||||
}
|
||||
}
|
||||
fn device_event(&mut self, event_loop: &ActiveEventLoop, _: DeviceId, event: DeviceEvent) {
|
||||
// A composited view renders offscreen but its keyboard/mouse owner still
|
||||
// controls a visible native window. A headless offscreen run never does.
|
||||
if self.pending_save.is_none()
|
||||
&& self.captured
|
||||
&& self.focused
|
||||
&& self.keyboard_mouse
|
||||
&& (!self.options.offscreen || self.split_window)
|
||||
&& self.screen == Screen::Playing
|
||||
&& let DeviceEvent::MouseMotion { delta: (x, y) } = event
|
||||
{
|
||||
if x != 0.0 || y != 0.0 {
|
||||
self.pad_yield_input(event_loop);
|
||||
}
|
||||
// MouseHandler.onMove with the pointer grabbed; applied once per frame.
|
||||
self.note_input(
|
||||
input_trace::InputOrigin::User,
|
||||
input_trace::TraceKind::Motion { x, y },
|
||||
);
|
||||
self.mouse.on_move(0.0, 0.0, x, y, true, true);
|
||||
self.last_input = Instant::now();
|
||||
}
|
||||
}
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
self.idle(event_loop);
|
||||
}
|
||||
/// The application is ending. On macOS, Quit (Cmd+Q) ends it through
|
||||
/// applicationWillTerminate without a CloseRequested; vanilla's GLFW
|
||||
/// window turns it into a window close, so Minecraft.stop saves the world.
|
||||
fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
|
||||
self.shut_down();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
//! Entering a world without blocking the window: the deferred entry, world creation, the world
|
||||
//! resources' background load, and the world's own content tables.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn defer_world_entry(&mut self, entry: PendingWorldEntry) -> Result<bool> {
|
||||
self.poll_world_rest()?;
|
||||
if self.rest_loader.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
self.perf.world_opening();
|
||||
if self.title.is_none() {
|
||||
self.title = Some(
|
||||
Title::new(
|
||||
std::env::current_dir()?.join("saves"),
|
||||
&self.options.resources,
|
||||
)
|
||||
.with_user(|| self.options.player_name()),
|
||||
);
|
||||
}
|
||||
self.pending_entry = Some(entry);
|
||||
log::info!("Horizon: world entry waiting for world resources");
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(super) fn resume_world_entry(&mut self) -> Result<()> {
|
||||
self.poll_integrated_start()?;
|
||||
self.poll_world_rest()?;
|
||||
self.poll_world_creation();
|
||||
if self.rest_loader.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let on_world_screens = self.title.as_ref().is_some_and(|title| {
|
||||
!matches!(
|
||||
title.mode,
|
||||
horizon_client::title::Mode::Main | horizon_client::title::Mode::Multiplayer(_)
|
||||
)
|
||||
});
|
||||
world_loading::preload_world_data(
|
||||
on_world_screens,
|
||||
&self.options.resources,
|
||||
self.pack.assets.registry,
|
||||
);
|
||||
if std::mem::take(&mut self.pending_pack_reload) {
|
||||
self.reload_packs();
|
||||
}
|
||||
match self.pending_entry.take() {
|
||||
Some(PendingWorldEntry::Local {
|
||||
folder,
|
||||
name,
|
||||
seed,
|
||||
creative,
|
||||
create,
|
||||
}) => {
|
||||
self.open_world(&folder, name.as_deref(), seed, creative, create.as_deref())?;
|
||||
}
|
||||
Some(PendingWorldEntry::Server {
|
||||
address,
|
||||
name,
|
||||
cookies,
|
||||
lan,
|
||||
}) => {
|
||||
self.connect_server_with_options(&address, &name, cookies, lan)?;
|
||||
}
|
||||
Some(PendingWorldEntry::StudioReplay(path)) => {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
if let Err(e) = self.open_studio_replay(&path_str) {
|
||||
log::warn!("Horizon Studio: could not open {path_str}: {e}");
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `CreateWorldScreen.openCreateWorldScreen`: read the world generation
|
||||
/// data the create screen describes, on a worker, then open the screen
|
||||
/// (`title.preparing`).
|
||||
pub(super) fn poll_world_creation(&mut self) {
|
||||
let Some(title) = self.title.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if !title.preparing() {
|
||||
return;
|
||||
}
|
||||
if self.creation_job.is_none() {
|
||||
let dir = self.options.resources.join("worldgen");
|
||||
let registry = self.pack.assets.registry;
|
||||
// The chosen packs' worldgen over the game's own:
|
||||
// `applyNewPackConfig` reloads with the new pack config.
|
||||
let overrides = title
|
||||
.creation_packs()
|
||||
.map(|(dir, packs)| {
|
||||
world_loading::creation_worldgen_overrides(
|
||||
dir.as_deref(),
|
||||
&self.options.resources,
|
||||
&packs,
|
||||
self.res.biomes(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
self.creation_job = std::thread::Builder::new()
|
||||
.name("world-creation".into())
|
||||
.spawn(move || {
|
||||
horizon_client::vanillagen::shared_data_with(&dir, &overrides, registry)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
if !self
|
||||
.creation_job
|
||||
.as_ref()
|
||||
.is_none_or(std::thread::JoinHandle::is_finished)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let outcome = self.creation_job.take().and_then(|job| job.join().ok());
|
||||
if let Some(title) = self.title.as_mut() {
|
||||
match outcome {
|
||||
Some(Ok(())) => title.finish_preparing(),
|
||||
// `applyNewPackConfig`'s failure: back out of the read with
|
||||
// the message, the Create screen's options intact.
|
||||
outcome => {
|
||||
let e = outcome
|
||||
.and_then(|r| r.err())
|
||||
.unwrap_or_else(|| "the read never ran".to_owned());
|
||||
log::warn!("Horizon: preparing world creation failed: {e}");
|
||||
// Clear the flag first: `failed` sets the error screen
|
||||
// with Create as its back target, which
|
||||
// `finish_preparing` would replace with a fresh world.
|
||||
title.finish_preparing();
|
||||
title.failed(format!(
|
||||
"{}: {e}",
|
||||
horizon_client::lang::get_or(
|
||||
"createWorld.preparing",
|
||||
"Preparing for world creation..."
|
||||
)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn poll_world_rest(&mut self) -> Result<()> {
|
||||
if let Some(loader) = &self.rest_loader
|
||||
&& loader.ready()?
|
||||
{
|
||||
self.rest_loader
|
||||
.take()
|
||||
.ok_or("missing world loader")?
|
||||
.finish()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- the world's data packs' content tables ---------------------------
|
||||
|
||||
/// The content tables the world plays with: its data packs' over the
|
||||
/// game's own when it has packs, `Resources.rest`'s own otherwise.
|
||||
pub(super) fn items(&self) -> &Items {
|
||||
match &self.world_tables {
|
||||
Some(t) => &t.items,
|
||||
None => self.res.items(),
|
||||
}
|
||||
}
|
||||
pub(super) fn mob_suffocation(
|
||||
&self,
|
||||
) -> &horizon_world::suffocation::SuffocationRegistry<'static> {
|
||||
self.world_tables
|
||||
.as_ref()
|
||||
.map_or_else(|| self.res.suffocation(), |t| &t.suffocation)
|
||||
}
|
||||
pub(super) fn recipes(&self) -> &Recipes {
|
||||
match &self.world_tables {
|
||||
Some(t) => &t.recipes,
|
||||
None => self.synced_recipes().unwrap_or_else(|| self.res.recipes()),
|
||||
}
|
||||
}
|
||||
pub(super) fn stations(&self) -> &horizon_client::stations::StationData {
|
||||
match &self.world_tables {
|
||||
Some(t) => &t.stations,
|
||||
None => self.res.stations(),
|
||||
}
|
||||
}
|
||||
/// The enchantments item names and tooltips read: a server's, with the
|
||||
/// entries its data packs add, else the world's.
|
||||
pub(super) fn enchantments(&self) -> &horizon_client::enchant::Enchantments {
|
||||
match self.remote.as_ref().and_then(|r| r.enchantments.as_ref()) {
|
||||
Some(e) => e,
|
||||
None => &self.stations().enchantments,
|
||||
}
|
||||
}
|
||||
/// The world's packs' content files, none without packs.
|
||||
/// The biome table the world plays with, `None` before a world opens.
|
||||
pub(super) fn packed_biomes(&self) -> Option<Arc<Biomes>> {
|
||||
let table = world_loading::packed_biomes(
|
||||
&self.world_root,
|
||||
&self.options.resources,
|
||||
self.res.biomes(),
|
||||
&self.level.enabled_features,
|
||||
);
|
||||
(!Arc::ptr_eq(&table, self.res.biomes())).then_some(table)
|
||||
}
|
||||
/// What the chunk and far-terrain workers mesh with: the pack's context,
|
||||
/// over the world's biomes when it has its own.
|
||||
pub(super) fn worker_context(&self) -> Arc<WorkerContext> {
|
||||
match &self.world_biomes {
|
||||
Some(table) => self.pack.context_with(table.clone()),
|
||||
None => self.pack.context.clone(),
|
||||
}
|
||||
}
|
||||
pub(super) fn pack_files(&self) -> horizon_world::loot::PackOverlay<'_> {
|
||||
pack_files(&self.world_tables)
|
||||
}
|
||||
/// Load the world's data packs' content tables (`None`: the world has no
|
||||
/// packs, and `Resources.rest` stays in use). This re-reads the tables,
|
||||
/// so it runs once per world open and on `/reload`.
|
||||
pub(super) fn build_world_tables(
|
||||
&self,
|
||||
reload: bool,
|
||||
enabled_features: &[String],
|
||||
) -> Result<(Option<WorldTables>, horizon_client::health::HealthResources)> {
|
||||
// The ask of this load, both here and on `/reload`: a native mod
|
||||
// recomputes the pack files it contributes every time.
|
||||
let mut contributed = Vec::new();
|
||||
self.native_data_pack_files(&mut contributed);
|
||||
let files = functions::overlay_files(
|
||||
&self.world_root,
|
||||
&self.options.resources,
|
||||
self.cmds.vanilla_disabled,
|
||||
enabled_features,
|
||||
contributed,
|
||||
);
|
||||
let overlay = horizon_world::loot::PackOverlay { files: &files };
|
||||
let root = &self.options.resources;
|
||||
let base = if reload {
|
||||
&self.health_resources
|
||||
} else {
|
||||
&self.motion.health
|
||||
};
|
||||
let health = base.with_packs(root, &overlay, reload)?;
|
||||
// With no pack, `res` holds the tables, unless content mods switched
|
||||
// live since it was loaded: their data is in the new root.
|
||||
if files.is_empty() && !self.mods.data_switched {
|
||||
return Ok((None, health));
|
||||
}
|
||||
let mut items = Items::load(root, self.pack.assets.registry, self.res.facts(), &overlay)?;
|
||||
// Item damage predicates use the same frozen registry as player hurt.
|
||||
Arc::make_mut(&mut items.enchantments).bind_damage_catalog(&health.damage);
|
||||
// The recipe tables, the stations and the recipe book read one folder:
|
||||
// hold its files so it is read once.
|
||||
let _recipe_files = horizon_client::json_folder::read(&root.join("data/minecraft/recipe"));
|
||||
let recipes = Recipes::load(root, &items, &overlay)?;
|
||||
let stations = horizon_client::stations::StationData::load(root, &items, &overlay);
|
||||
let mut data_map_types = Vec::new();
|
||||
self.native_data_map_types(&mut data_map_types);
|
||||
let data_maps = native_data_maps::DataMaps::load(
|
||||
&data_map_types,
|
||||
&files,
|
||||
&items.item_tags,
|
||||
&items.block_tags,
|
||||
);
|
||||
let causes_suffocation = items
|
||||
.block_tags
|
||||
.get("minecraft:causes_suffocation")
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.cloned()
|
||||
.collect();
|
||||
let suffocation = horizon_client::mobs::suffocation::bind(
|
||||
self.res.core.collision.clone(),
|
||||
&causes_suffocation,
|
||||
)?;
|
||||
// A registry whose pack tags don't load keeps the game's own tags
|
||||
// (`in_tag` falls back to them), rather than matching no tag at all.
|
||||
let pack_tags = WorldTables::PACK_TAG_REGISTRIES
|
||||
.into_iter()
|
||||
.filter_map(|registry| {
|
||||
match horizon_client::items::load_tags(root, registry, &overlay) {
|
||||
Ok(tags) => Some((registry, tags)),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: the world's {registry} tags are not applied: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok((
|
||||
Some(WorldTables {
|
||||
suffocation,
|
||||
files,
|
||||
items,
|
||||
recipes,
|
||||
stations,
|
||||
data_maps,
|
||||
pack_tags,
|
||||
}),
|
||||
health,
|
||||
))
|
||||
}
|
||||
/// Reload the content tables with the world's packs, as
|
||||
/// `/reload` reloads a server's (`ReloadableServerResources`).
|
||||
pub(super) fn reload_world_tables(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
match self.build_world_tables(true, &self.level.enabled_features) {
|
||||
Ok((tables, health)) => {
|
||||
self.world_tables = tables;
|
||||
self.health_resources = health;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: the world's data packs are not applied: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.damage_tag_generation = self.damage_tag_generation.wrapping_add(1);
|
||||
self.recipe_tables.categories = None;
|
||||
horizon_client::structure_blocks::forget_loot_data();
|
||||
// Before the players are sent the reloaded data below.
|
||||
self.sync_recipe_content();
|
||||
// PlayerList.reloadResources: the tags and recipes go to the players
|
||||
// and their advancements reload. The synchronized registries (the
|
||||
// dialogs, and the commands' ids of them) stay the hub's.
|
||||
let kept = self.lan.as_ref().map(|host| host.hub.lan.shared.sync());
|
||||
match kept {
|
||||
Some(kept) => {
|
||||
match lan::reload_sync_data(&self.options.resources, &self.pack_files(), &kept) {
|
||||
Ok(sync) => {
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.hub.reload_resources(sync);
|
||||
}
|
||||
self.run_native_advancement_grants();
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: the players are not sent the reloaded data: {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Err(e) =
|
||||
lan::reload_cached_sync_data(&self.options.resources, &self.pack_files())
|
||||
{
|
||||
log::warn!("Horizon: the cached world data is not reloaded: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let overlay = pack_files(&self.world_tables);
|
||||
self.adv.tree = Arc::new(horizon_client::advancements::Tree::load_with(
|
||||
&self.options.resources.join("data"),
|
||||
&overlay,
|
||||
));
|
||||
self.adv.reset_tree();
|
||||
self.native_reload_listeners();
|
||||
self.native_server_data_loaded(true);
|
||||
}
|
||||
|
||||
pub(super) fn join_world_rest(&mut self) -> Result<()> {
|
||||
if self.rest_loader.is_some() {
|
||||
let waited = Instant::now();
|
||||
log::info!("Horizon: waiting for world resources");
|
||||
while self.rest_loader.is_some() {
|
||||
self.poll_world_rest()?;
|
||||
if self.rest_loader.is_some() {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
"Horizon: world resources joined after {} ms",
|
||||
waited.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
if self.res.rest.get().is_none() {
|
||||
return Err("world resources are unavailable".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! Opening and closing a local world from the client: the title screen's actions, entering a
|
||||
//! world on the server thread, and saving and leaving it on exit.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `onAbort`: the confirm screen's Back or Escape reopens the Data
|
||||
/// Packs screen with the selection it had.
|
||||
pub(super) fn reopen_data_packs(&mut self, event_loop: &dyn EventLoopCtl) -> Result<()> {
|
||||
let action = self
|
||||
.title
|
||||
.as_mut()
|
||||
.and_then(|t| t.reopen_data_packs_action());
|
||||
if let Some(action) = action {
|
||||
self.title_action(event_loop, action)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// What the title screen asked for, from a click or Enter.
|
||||
pub(super) fn title_action(
|
||||
&mut self,
|
||||
event_loop: &dyn EventLoopCtl,
|
||||
action: Action,
|
||||
) -> Result<()> {
|
||||
match action {
|
||||
// A splitscreen player's title never ends the process (P1's does).
|
||||
Action::Quit if self.options.split_guest => {}
|
||||
Action::Quit => event_loop.exit(),
|
||||
Action::Options => self.open_options(true),
|
||||
Action::Accessibility => {
|
||||
self.open_options_at(true, horizon_client::options_ui::Page::Accessibility)
|
||||
}
|
||||
Action::Language => {
|
||||
self.open_options_at(true, horizon_client::options_ui::Page::Language)
|
||||
}
|
||||
Action::Sounds => self.open_options_at(true, horizon_client::options_ui::Page::Sound),
|
||||
Action::Credits => {
|
||||
self.open_options_at(true, horizon_client::options_ui::Page::Credits)
|
||||
}
|
||||
Action::OpenUri(uri) => packs::open_uri(&uri),
|
||||
Action::CopyText(text) => {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&text);
|
||||
}
|
||||
Action::Play { folder } => self.open_world(&folder, None, None, false, None)?,
|
||||
Action::Create {
|
||||
folder,
|
||||
name,
|
||||
seed,
|
||||
creative,
|
||||
options,
|
||||
} => self.open_world(&folder, Some(&name), seed, creative, Some(&options))?,
|
||||
Action::Connect { address, lan } => {
|
||||
self.connect_server_with_options(&address, &self.options.player_name(), None, lan)?
|
||||
}
|
||||
Action::DataPacks { dir, selected } => {
|
||||
self.keys.clear();
|
||||
self.screen = Screen::Options;
|
||||
self.options_from_title = true;
|
||||
let resources = self.vanilla_root();
|
||||
self.ui.options.open_data_packs(dir, selected, resources);
|
||||
}
|
||||
Action::None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Enter a local world from the client: it runs on the client's server thread.
|
||||
pub(super) fn open_world(
|
||||
&mut self,
|
||||
folder: &str,
|
||||
name: Option<&str>,
|
||||
seed: Option<i64>,
|
||||
creative: bool,
|
||||
create: Option<&horizon_client::title::WorldOptions>,
|
||||
) -> Result<()> {
|
||||
// openWorldLoadLevelData: an existing world whose level data does not read opens
|
||||
// RecoverWorldDataScreen instead.
|
||||
if create.is_none()
|
||||
&& let Some(title) = self.title.as_mut()
|
||||
&& title.open_recover(folder)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
world_loading::world_data_used();
|
||||
if self.defer_world_entry(PendingWorldEntry::Local {
|
||||
folder: folder.to_owned(),
|
||||
name: name.map(str::to_owned),
|
||||
seed,
|
||||
creative,
|
||||
create: create.cloned().map(Box::new),
|
||||
})? {
|
||||
return Ok(());
|
||||
}
|
||||
self.join_world_rest()?;
|
||||
// Create World's data packs, in the world before it opens.
|
||||
if let Some(dir) = create.and_then(|o| o.pack_dir.as_deref()) {
|
||||
let world = std::env::current_dir()?.join("saves").join(folder);
|
||||
let packs = create.map_or(&[][..], |o| &o.data_packs[..]);
|
||||
if let Err(e) = horizon_client::datapack::install(dir, &world, packs) {
|
||||
log::warn!("Horizon: data packs for {folder}: {e}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
// The world's pack selection (`DataPackConfig`), for the pack stack
|
||||
// to read: the picked packs on, the feature packs left out off.
|
||||
// Only when `install` did not run (no Data Packs screen opened): it
|
||||
// writes the file itself, and its `disabled` must not be lost.
|
||||
if let Some(o) = create.filter(|o| o.pack_dir.is_none()) {
|
||||
let world = std::env::current_dir()?.join("saves").join(folder);
|
||||
if let Err(e) = std::fs::create_dir_all(&world) {
|
||||
log::warn!("Horizon: data packs for {folder}: {}: {e}", world.display());
|
||||
} else {
|
||||
let disabled: Vec<String> =
|
||||
horizon_client::datapack::feature_packs(&self.vanilla_root())
|
||||
.into_iter()
|
||||
.map(|p| p.id)
|
||||
.filter(|id| !o.data_packs.contains(id))
|
||||
.collect();
|
||||
let json = serde_json::json!({"enabled": o.data_packs, "disabled": disabled});
|
||||
let file = world.join("datapacks.json");
|
||||
if let Err(e) = std::fs::write(&file, json.to_string()) {
|
||||
log::warn!("Horizon: data packs for {folder}: {}: {e}", file.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
// IntegratedServer.initServer's line, which other launchers read in `latest.log` to
|
||||
// offer the world again.
|
||||
horizon_client::logging::write_as(
|
||||
"Server thread",
|
||||
log::Level::Info,
|
||||
&format!(
|
||||
"Starting integrated minecraft server version {}",
|
||||
horizon_core::VANILLA_VERSION
|
||||
),
|
||||
);
|
||||
self.open_integrated(folder, name, seed, creative, create)
|
||||
}
|
||||
|
||||
/// Load authoritative world state synchronously, without client entry or
|
||||
/// integrated-server selection. Server startup must use this entry point.
|
||||
pub(super) fn load_world(&mut self, request: world_loading::WorldRequest) -> Result<()> {
|
||||
self.join_world_rest()?;
|
||||
self.perf.world_opening();
|
||||
let prepared = request.prepare(
|
||||
&self.res,
|
||||
&self.pack,
|
||||
&self.options,
|
||||
&self.settings,
|
||||
&horizon_world::datafix::progress::UpgradeProgress::new(),
|
||||
)?;
|
||||
self.finish_open_world(prepared)
|
||||
}
|
||||
|
||||
/// Leaving the game with a world open (window closed, Quit, `quit`):
|
||||
/// menu items go back (Player.closeContainer), then the world is saved,
|
||||
/// once.
|
||||
/// Keeps this play session's performance summary for the launcher to send, if the player
|
||||
/// allows performance statistics (`horizon_client::telemetry`), and starts the next one.
|
||||
pub(super) fn finish_perf(&mut self) {
|
||||
// One session per computer: P1's (a splitscreen player's would count it again).
|
||||
if self.options.split_guest {
|
||||
return;
|
||||
}
|
||||
let s = &self.settings;
|
||||
let settings = horizon_telemetry::payload::Settings {
|
||||
render_distance: s.render_distance.clamp(0, 255) as u8,
|
||||
simulation_distance: s.simulation_distance.clamp(0, 255) as u8,
|
||||
// 260 is the slider's "unlimited".
|
||||
max_fps: if s.max_fps >= 260 {
|
||||
0
|
||||
} else {
|
||||
s.max_fps.min(u32::from(u16::MAX)) as u16
|
||||
},
|
||||
vsync: s.vsync,
|
||||
fullscreen: s.fullscreen,
|
||||
graphics: s.graphics_preset.clone(),
|
||||
};
|
||||
// Again now: on Wayland the window is on a screen only once it has been shown.
|
||||
if let Some(window) = &self.window {
|
||||
horizon_client::telemetry::note_display(window);
|
||||
}
|
||||
self.perf
|
||||
.finish(&horizon_client::logging::game_home(), settings);
|
||||
}
|
||||
|
||||
pub(super) fn save_for_exit(&mut self) -> Result<()> {
|
||||
if self.native_window() && !self.native_client_stopped {
|
||||
self.native_client_stopped = true;
|
||||
self.native_client_stopping();
|
||||
}
|
||||
// Native application termination can bypass CloseRequested. Finish any
|
||||
// save screen synchronously here, before the process can go away.
|
||||
while self.pending_save.is_some() {
|
||||
self.poll_closing_save()?;
|
||||
if self.pending_save.is_some() {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
// A repro recording is finished and bundled before the process may go away.
|
||||
self.repro_on_exit();
|
||||
if self.title.is_some() || self.saved_for_exit {
|
||||
return Ok(());
|
||||
}
|
||||
// Native application termination can bypass the close request, so make the same
|
||||
// client-side worker abort explicit before the game-owned streaming state is dropped.
|
||||
self.streaming.abort_generation();
|
||||
if let Some(server) = self.integrated.take() {
|
||||
// The server thread saves the world.
|
||||
server.stop()?;
|
||||
self.saved_for_exit = true;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(mut remote) = self.remote.take() {
|
||||
// Minecraft.disconnect closes the connection, so the host sees a clean leave.
|
||||
remote.session.disconnect();
|
||||
self.saved_for_exit = true;
|
||||
return Ok(());
|
||||
}
|
||||
self.close_container();
|
||||
if self.remote.is_none() {
|
||||
self.award_custom_stat("leave_game", 1);
|
||||
}
|
||||
self.lan_close(false);
|
||||
self.free_player_beds()?;
|
||||
self.save_game()?;
|
||||
self.saved_for_exit = true;
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn close_world(&mut self) -> Result<()> {
|
||||
// Pause-menu and death-screen exits use the normal world-close action.
|
||||
// A standalone replay owns a synthetic remote session, so it must go
|
||||
// through replay cleanup instead of being treated like a server.
|
||||
if self.studio_replay.is_some() {
|
||||
self.close_studio_replay();
|
||||
return Ok(());
|
||||
}
|
||||
// A recording cannot cross into another world, and a replay feeding the
|
||||
// title screen is nonsense.
|
||||
self.repro_world_closed();
|
||||
self.clear_waypoints();
|
||||
self.post_effects.clear();
|
||||
self.spectated_post_effect = None;
|
||||
if self.external_server() {
|
||||
return self.leave_server(None);
|
||||
}
|
||||
self.begin_closing_save(false)
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,16 @@ impl Mth for f32 {
|
||||
}
|
||||
}
|
||||
|
||||
// Copyright (C) 1993 by Sun Microsystems, Inc. All rights reserved.
|
||||
//
|
||||
// Developed at SunSoft, a Sun Microsystems, Inc. business.
|
||||
// Permission to use, copy, modify, and distribute this
|
||||
// software is freely granted, provided that this notice
|
||||
// is preserved.
|
||||
//
|
||||
// The notice above covers `fdlibm_asin`, `fdlibm_atan` and `fdlibm_atan2` below, Rust ports
|
||||
// of fdlibm 5.3's e_asin.c, s_atan.c and e_atan2.c (https://www.netlib.org/fdlibm/).
|
||||
|
||||
/// fdlibm `__ieee754_asin`, as `StrictMath.asin` (and so `Math.asin`) computes it.
|
||||
#[allow(clippy::excessive_precision, clippy::approx_constant, clippy::eq_op)] // fdlibm's constants and NaN, verbatim.
|
||||
pub fn fdlibm_asin(x: f64) -> f64 {
|
||||
|
||||
@@ -13,28 +13,31 @@
|
||||
//! rounds `sin` and `cos` differently for about 3% of arguments, so there
|
||||
//! they are correctly rounded instead ([`exact`]), as Intel's stubs are for
|
||||
//! all but about 0.1%.
|
||||
//! - On AArch64 only `sin` and `cos` are intrinsics: fdlibm's kernels
|
||||
//! evaluated with fused multiply-adds (`macroAssembler_aarch64_trig.cpp`).
|
||||
//! Everything else is `StrictMath`.
|
||||
//! - On AArch64 only `sin` and `cos` are intrinsics, which compute fdlibm's
|
||||
//! `sin` and `cos` with fused multiply-adds ([`aarch64`]). Everything else
|
||||
//! is `StrictMath`, which the Java SE API defines as fdlibm 5.3's results
|
||||
//! ([`fdlibm`]).
|
||||
//!
|
||||
//! So these functions return what vanilla returns on the same machine, on
|
||||
//! Linux and on an Apple silicon Mac alike.
|
||||
|
||||
/// `Math.round(float)`, bit for bit: exact (no `x + 0.5` in `f32`), ties toward
|
||||
/// positive infinity, saturating to the `int` range, NaN to 0.
|
||||
pub mod fdlibm;
|
||||
|
||||
/// `Math.sin` and `Math.cos` as HotSpot computes them on AArch64: fdlibm's
|
||||
/// algorithm with fused multiply-adds, on every platform.
|
||||
pub mod aarch64 {
|
||||
pub use super::fdlibm::{cos_fma as cos, sin_fma as sin};
|
||||
}
|
||||
|
||||
/// `Math.round(float)`: the `int` closest to `a`, ties toward positive
|
||||
/// infinity (so `-2.5` is `-2`); `NaN` is 0, and values beyond the `int`
|
||||
/// range give `i32::MIN` or `i32::MAX`.
|
||||
pub fn round_f32(a: f32) -> i32 {
|
||||
let bits = a.to_bits() as i32;
|
||||
let biased_exp = (bits & 0x7F80_0000) >> 23;
|
||||
let shift = (22 + 127) - biased_exp;
|
||||
if shift & -32 == 0 {
|
||||
let mut r = bits & 0x007F_FFFF | 0x0080_0000;
|
||||
if bits < 0 {
|
||||
r = -r;
|
||||
}
|
||||
((r >> shift) + 1) >> 1
|
||||
} else {
|
||||
a as i32
|
||||
}
|
||||
// floor(a + 1/2). The sum is exact in an f64 for 2^-30 <= |a| < 2^31.
|
||||
// Below that it rounds to within 2^-30 of 1/2, whose floor is 0 as the
|
||||
// exact sum's is; above it the cast saturates either way. The cast also
|
||||
// maps NaN to 0.
|
||||
(f64::from(a) + 0.5).floor() as i32
|
||||
}
|
||||
|
||||
/// `Math.sin`.
|
||||
@@ -145,416 +148,6 @@ pub fn round(x: f64) -> i64 {
|
||||
(if x - f >= 0.5 { f + 1.0 } else { f }) as i64
|
||||
}
|
||||
|
||||
fn hi(x: f64) -> i32 {
|
||||
(x.to_bits() >> 32) as i32
|
||||
}
|
||||
|
||||
fn with_hi(x: f64, high: i32) -> f64 {
|
||||
f64::from_bits((x.to_bits() & 0xffff_ffff) | (u64::from(high as u32) << 32))
|
||||
}
|
||||
|
||||
/// `java.lang.FdLibm` ports (`StrictMath`) of what the `libm` crate computes
|
||||
/// differently.
|
||||
// fdlibm's constants and NaN, verbatim.
|
||||
#[allow(clippy::excessive_precision, clippy::approx_constant, clippy::eq_op)]
|
||||
pub mod fdlibm {
|
||||
use super::{hi, with_hi};
|
||||
|
||||
const TWO54: f64 = 1.80143985094819840000e+16;
|
||||
|
||||
/// `FdLibm.Log`.
|
||||
pub fn log(mut x: f64) -> f64 {
|
||||
const LN2_HI: f64 = 6.93147180369123816490e-01;
|
||||
const LN2_LO: f64 = 1.90821492927058770002e-10;
|
||||
const LG1: f64 = 6.666666666666735130e-01;
|
||||
const LG2: f64 = 3.999999999940941908e-01;
|
||||
const LG3: f64 = 2.857142874366239149e-01;
|
||||
const LG4: f64 = 2.222219843214978396e-01;
|
||||
const LG5: f64 = 1.818357216161805012e-01;
|
||||
const LG6: f64 = 1.531383769920937332e-01;
|
||||
const LG7: f64 = 1.479819860511658591e-01;
|
||||
let mut hx = hi(x);
|
||||
let lx = x.to_bits() as u32;
|
||||
let mut k = 0;
|
||||
if hx < 0x0010_0000 {
|
||||
if ((hx & 0x7fff_ffff) as u32 | lx) == 0 {
|
||||
return -TWO54 / 0.0;
|
||||
}
|
||||
if hx < 0 {
|
||||
return (x - x) / 0.0;
|
||||
}
|
||||
k -= 54;
|
||||
x *= TWO54;
|
||||
hx = hi(x);
|
||||
}
|
||||
if hx >= 0x7ff0_0000 {
|
||||
return x + x;
|
||||
}
|
||||
k += (hx >> 20) - 1023;
|
||||
hx &= 0x000f_ffff;
|
||||
let i = (hx + 0x9_5f64) & 0x10_0000;
|
||||
x = with_hi(x, hx | (i ^ 0x3ff0_0000));
|
||||
k += i >> 20;
|
||||
let f = x - 1.0;
|
||||
if (0x000f_ffff & (2 + hx)) < 3 {
|
||||
if f == 0.0 {
|
||||
if k == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let dk = f64::from(k);
|
||||
return dk * LN2_HI + dk * LN2_LO;
|
||||
}
|
||||
let r = f * f * (0.5 - 0.33333333333333333 * f);
|
||||
if k == 0 {
|
||||
return f - r;
|
||||
}
|
||||
let dk = f64::from(k);
|
||||
return dk * LN2_HI - ((r - dk * LN2_LO) - f);
|
||||
}
|
||||
let s = f / (2.0 + f);
|
||||
let dk = f64::from(k);
|
||||
let z = s * s;
|
||||
let mut i = hx - 0x6_147a;
|
||||
let w = z * z;
|
||||
let j = 0x6b851 - hx;
|
||||
let t1 = w * (LG2 + w * (LG4 + w * LG6));
|
||||
let t2 = z * (LG1 + w * (LG3 + w * (LG5 + w * LG7)));
|
||||
i |= j;
|
||||
let r = t2 + t1;
|
||||
if i > 0 {
|
||||
let hfsq = 0.5 * f * f;
|
||||
if k == 0 {
|
||||
f - (hfsq - s * (hfsq + r))
|
||||
} else {
|
||||
dk * LN2_HI - ((hfsq - (s * (hfsq + r) + dk * LN2_LO)) - f)
|
||||
}
|
||||
} else if k == 0 {
|
||||
f - s * (f - r)
|
||||
} else {
|
||||
dk * LN2_HI - ((s * (f - r) - dk * LN2_LO) - f)
|
||||
}
|
||||
}
|
||||
|
||||
/// `FdLibm.Log10`.
|
||||
pub fn log10(mut x: f64) -> f64 {
|
||||
const IVLN10: f64 = 4.34294481903251816668e-01;
|
||||
const LOG10_2HI: f64 = 3.01029995663611771306e-01;
|
||||
const LOG10_2LO: f64 = 3.69423907715893078616e-13;
|
||||
let mut hx = hi(x);
|
||||
let lx = x.to_bits() as u32;
|
||||
let mut k = 0;
|
||||
if hx < 0x0010_0000 {
|
||||
if ((hx & 0x7fff_ffff) as u32 | lx) == 0 {
|
||||
return -TWO54 / 0.0;
|
||||
}
|
||||
if hx < 0 {
|
||||
return (x - x) / 0.0;
|
||||
}
|
||||
k -= 54;
|
||||
x *= TWO54;
|
||||
hx = hi(x);
|
||||
}
|
||||
if hx >= 0x7ff0_0000 {
|
||||
return x + x;
|
||||
}
|
||||
k += (hx >> 20) - 1023;
|
||||
let i = ((k as u32) >> 31) as i32;
|
||||
hx = (hx & 0x000f_ffff) | ((0x3ff - i) << 20);
|
||||
let y = f64::from(k + i);
|
||||
x = with_hi(x, hx);
|
||||
let z = y * LOG10_2LO + IVLN10 * log(x);
|
||||
z + y * LOG10_2HI
|
||||
}
|
||||
|
||||
/// `FdLibm.Cbrt`.
|
||||
pub fn cbrt(mut x: f64) -> f64 {
|
||||
const B1: i32 = 715094163;
|
||||
const B2: i32 = 696219795;
|
||||
const C: f64 = 5.42857142857142815906e-01;
|
||||
const D: f64 = -7.05306122448979611050e-01;
|
||||
const E: f64 = 1.41428571428571436819e+00;
|
||||
const F: f64 = 1.60714285714285720630e+00;
|
||||
const G: f64 = 3.57142857142857150787e-01;
|
||||
if x == 0.0 || !x.is_finite() {
|
||||
return x;
|
||||
}
|
||||
let sign = if x < 0.0 { -1.0 } else { 1.0 };
|
||||
x = x.abs();
|
||||
let mut t = 0.0f64;
|
||||
if x < f64::MIN_POSITIVE {
|
||||
t = 18014398509481984.0; // 2^54
|
||||
t *= x;
|
||||
t = with_hi(t, hi(t) / 3 + B2);
|
||||
} else {
|
||||
t = with_hi(t, hi(x) / 3 + B1);
|
||||
}
|
||||
let mut r = t * t / x;
|
||||
let mut s = C + r * t;
|
||||
t *= G + F / (s + E + D / s);
|
||||
t = f64::from_bits(t.to_bits() & 0xffff_ffff_0000_0000);
|
||||
t = with_hi(t, hi(t) + 1);
|
||||
s = t * t;
|
||||
r = x / s;
|
||||
let w = t + t;
|
||||
r = (r - t) / (w + r);
|
||||
t += t * r;
|
||||
sign * t
|
||||
}
|
||||
|
||||
/// `FdLibm.Hypot`.
|
||||
pub fn hypot(x: f64, y: f64) -> f64 {
|
||||
let mut a = x.abs();
|
||||
let mut b = y.abs();
|
||||
if !a.is_finite() || !b.is_finite() {
|
||||
if a == f64::INFINITY || b == f64::INFINITY {
|
||||
return f64::INFINITY;
|
||||
}
|
||||
return a + b;
|
||||
}
|
||||
if b > a {
|
||||
std::mem::swap(&mut a, &mut b);
|
||||
}
|
||||
let mut ha = hi(a);
|
||||
let mut hb = hi(b);
|
||||
if ha - hb > 0x3c0_0000 {
|
||||
return a + b;
|
||||
}
|
||||
let mut k = 0;
|
||||
if a > f64::from_bits(0x5f30_0000_ffff_ffff) {
|
||||
ha -= 0x2580_0000;
|
||||
hb -= 0x2580_0000;
|
||||
a *= f64::from_bits(0x1a70_0000_0000_0000); // 2^-600
|
||||
b *= f64::from_bits(0x1a70_0000_0000_0000);
|
||||
k += 600;
|
||||
}
|
||||
if b < f64::from_bits(0x20b0_0000_0000_0000) {
|
||||
// 2^-500
|
||||
if b < f64::MIN_POSITIVE {
|
||||
if b == 0.0 {
|
||||
return a;
|
||||
}
|
||||
let t1 = f64::from_bits(0x7fd0_0000_0000_0000); // 2^1022
|
||||
b *= t1;
|
||||
a *= t1;
|
||||
k -= 1022;
|
||||
} else {
|
||||
ha += 0x2580_0000;
|
||||
hb += 0x2580_0000;
|
||||
a *= f64::from_bits(0x6570_0000_0000_0000); // 2^600
|
||||
b *= f64::from_bits(0x6570_0000_0000_0000);
|
||||
k -= 600;
|
||||
}
|
||||
}
|
||||
let mut w = a - b;
|
||||
if w > b {
|
||||
let t1 = with_hi(0.0, ha);
|
||||
let t2 = a - t1;
|
||||
w = (t1 * t1 - (b * (-b) - t2 * (a + t1))).sqrt();
|
||||
} else {
|
||||
a += a;
|
||||
let y1 = with_hi(0.0, hb);
|
||||
let y2 = b - y1;
|
||||
let t1 = with_hi(0.0, ha + 0x0010_0000);
|
||||
let t2 = a - t1;
|
||||
w = (t1 * y1 - (w * (-w) - (t1 * y2 + t2 * b))).sqrt();
|
||||
}
|
||||
if k != 0 {
|
||||
f64::from_bits(((1023 + k) as u64) << 52) * w
|
||||
} else {
|
||||
w
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HotSpot's AArch64 `Math.sin` / `Math.cos` stubs
|
||||
/// (`MacroAssembler::generate_dsin_dcos`): fdlibm with fused multiply-adds.
|
||||
/// Exposed on every architecture so they can be tested anywhere.
|
||||
// HotSpot's tables, verbatim.
|
||||
#[allow(
|
||||
clippy::excessive_precision,
|
||||
clippy::approx_constant,
|
||||
clippy::unreadable_literal
|
||||
)]
|
||||
pub mod aarch64 {
|
||||
use super::hi;
|
||||
|
||||
const S: [f64; 6] = [
|
||||
-1.66666666666666324348e-01,
|
||||
8.33333333332248946124e-03,
|
||||
-1.98412698298579493134e-04,
|
||||
2.75573137070700676789e-06,
|
||||
-2.50507602534068634195e-08,
|
||||
1.58969099521155010221e-10,
|
||||
];
|
||||
const C: [f64; 6] = [
|
||||
4.16666666666666019037e-02,
|
||||
-1.38888888888741095749e-03,
|
||||
2.48015872894767294178e-05,
|
||||
-2.75573143513906633035e-07,
|
||||
2.08757232129817482790e-09,
|
||||
-1.13596475577881948265e-11,
|
||||
];
|
||||
const INVPIO2: f64 = 6.36619772367581382433e-01;
|
||||
const PIO2_1: f64 = 1.57079632673412561417e+00;
|
||||
const PIO2_1T: f64 = 6.07710050650619224932e-11;
|
||||
const PIO2_2: f64 = 6.07710050630396597660e-11;
|
||||
const PIO2_2T: f64 = 2.02226624879595063154e-21;
|
||||
const PIO2_3: f64 = 2.02226624871116645580e-21;
|
||||
const PIO2_3T: f64 = 8.47842766036889956997e-32;
|
||||
const NPIO2_HW: [i32; 32] = [
|
||||
0x3FF921FB, 0x400921FB, 0x4012D97C, 0x401921FB, 0x401F6A7A, 0x4022D97C, 0x4025FDBB,
|
||||
0x402921FB, 0x402C463A, 0x402F6A7A, 0x4031475C, 0x4032D97C, 0x40346B9C, 0x4035FDBB,
|
||||
0x40378FDB, 0x403921FB, 0x403AB41B, 0x403C463A, 0x403DD85A, 0x403F6A7A, 0x40407E4C,
|
||||
0x4041475C, 0x4042106C, 0x4042D97C, 0x4043A28C, 0x40446B9C, 0x404534AC, 0x4045FDBB,
|
||||
0x4046C6CB, 0x40478FDB, 0x404858EB, 0x404921FB,
|
||||
];
|
||||
|
||||
/// `generate_kernel_sin`.
|
||||
fn kernel_sin(x: f64, y: f64, iy: bool) -> f64 {
|
||||
let z = x * x;
|
||||
let v = z * x;
|
||||
let mut r = z.mul_add(S[5], S[4]);
|
||||
r = z.mul_add(r, S[3]);
|
||||
r = z.mul_add(r, S[2]);
|
||||
r = z.mul_add(r, S[1]);
|
||||
if !iy {
|
||||
v.mul_add(z.mul_add(r, S[0]), x)
|
||||
} else {
|
||||
let t = (-v).mul_add(r, 0.5 * y);
|
||||
let t = (-z).mul_add(t, y);
|
||||
x + v.mul_add(S[0], t)
|
||||
}
|
||||
}
|
||||
|
||||
/// `generate_kernel_cos`. `ix` is the high word of the *original*
|
||||
/// argument: the stub reuses the register, so a reduced argument always
|
||||
/// takes the `qx = 0.28125` branch.
|
||||
fn kernel_cos(x: f64, y: f64, ix: i32) -> f64 {
|
||||
let z = x * x;
|
||||
let mut r = z.mul_add(C[5], C[4]);
|
||||
r = z.mul_add(r, C[3]);
|
||||
let xy = x * y;
|
||||
r = z.mul_add(r, C[2]);
|
||||
r = z.mul_add(r, C[1]);
|
||||
let zz = z * z;
|
||||
r = z.mul_add(r, C[0]);
|
||||
if ix <= 0x3FD33333 {
|
||||
let v = (-zz).mul_add(r, xy);
|
||||
1.0 - 0.5f64.mul_add(z, v)
|
||||
} else {
|
||||
let qx = if ix > 0x3fe90000 {
|
||||
0.28125
|
||||
} else {
|
||||
f64::from_bits(u64::from((ix - 0x0020_0000) as u32) << 32)
|
||||
};
|
||||
let zr_xy = zz.mul_add(r, -xy);
|
||||
let h = 0.5f64.mul_add(z, -qx);
|
||||
let a = 1.0 - qx;
|
||||
a - (h - zr_xy)
|
||||
}
|
||||
}
|
||||
|
||||
/// `generate__ieee754_rem_pio2` for |x| <= 2^19·π/2; `None` beyond.
|
||||
fn rem_pio2(x: f64, ix: i32) -> Option<(i32, f64, f64)> {
|
||||
if ix < 0x4002d97c {
|
||||
return Some(if x.to_bits() as i64 >= 0 {
|
||||
let z = x - PIO2_1;
|
||||
if ix != 0x3ff921fb {
|
||||
let y0 = z - PIO2_1T;
|
||||
(1, y0, (z - y0) - PIO2_1T)
|
||||
} else {
|
||||
let z = z - PIO2_2;
|
||||
let y0 = z - PIO2_2T;
|
||||
(1, y0, (z - y0) - PIO2_2T)
|
||||
}
|
||||
} else {
|
||||
let z = x + PIO2_1;
|
||||
if ix != 0x3ff921fb {
|
||||
let y0 = z + PIO2_1T;
|
||||
(-1, y0, (z - y0) + PIO2_1T)
|
||||
} else {
|
||||
let z = z + PIO2_2;
|
||||
let y0 = z + PIO2_2T;
|
||||
(-1, y0, (z - y0) + PIO2_2T)
|
||||
}
|
||||
});
|
||||
}
|
||||
if ix > 0x413921fb {
|
||||
return None;
|
||||
}
|
||||
let t = x.abs();
|
||||
let fnv = t.mul_add(INVPIO2, 0.5).trunc();
|
||||
let n = fnv as i32;
|
||||
let mut r = (-fnv).mul_add(PIO2_1, t);
|
||||
let mut w = fnv * PIO2_1T;
|
||||
let mut y0 = r - w;
|
||||
if n > 32 || ix == NPIO2_HW[(n - 1) as usize] {
|
||||
let j = ix >> 20;
|
||||
let exponent = |y: f64| ((y.to_bits() << 1) >> 53) as i32;
|
||||
if j - exponent(y0) > 16 {
|
||||
let t = r;
|
||||
let w2 = fnv * PIO2_2;
|
||||
r = t - w2;
|
||||
w = fnv.mul_add(PIO2_2T, w2 - (t - r));
|
||||
y0 = r - w;
|
||||
if j - exponent(y0) > 49 {
|
||||
let t = r;
|
||||
let w3 = fnv * PIO2_3;
|
||||
r = t - w3;
|
||||
w = fnv.mul_add(PIO2_3T, w3 - (t - r));
|
||||
y0 = r - w;
|
||||
}
|
||||
}
|
||||
}
|
||||
let y1 = (r - y0) - w;
|
||||
Some(if x > 0.0 { (n, y0, y1) } else { (-n, -y0, -y1) })
|
||||
}
|
||||
|
||||
fn sin_cos(x: f64, is_cos: bool) -> f64 {
|
||||
let ix = hi(x) & 0x7fff_ffff;
|
||||
if ix < 0x3e400000 {
|
||||
return if is_cos { 1.0 } else { x };
|
||||
}
|
||||
if ix <= 0x3fe921fb {
|
||||
return if is_cos {
|
||||
kernel_cos(x, 0.0, ix)
|
||||
} else {
|
||||
kernel_sin(x, 0.0, false)
|
||||
};
|
||||
}
|
||||
if ix >= 0x7ff00000 {
|
||||
return f64::from_bits(0x7ff0_0000_0000_0001);
|
||||
}
|
||||
// Beyond 2^19·π/2 the stub's Payne–Hanek reduction isn't ported:
|
||||
// StrictMath's is used instead.
|
||||
let Some((n, y0, y1)) = rem_pio2(x, ix) else {
|
||||
return if is_cos { libm::cos(x) } else { libm::sin(x) };
|
||||
};
|
||||
let (value, negate) = if (n & 1 == 1) != is_cos {
|
||||
(
|
||||
kernel_cos(y0, y1, ix),
|
||||
if is_cos { (n ^ (n >> 1)) & 1 } else { n & 2 },
|
||||
)
|
||||
} else {
|
||||
(
|
||||
kernel_sin(y0, y1, true),
|
||||
if is_cos { (n ^ (n >> 1)) & 1 } else { n & 2 },
|
||||
)
|
||||
};
|
||||
if negate != 0 { -value } else { value }
|
||||
}
|
||||
|
||||
/// `Math.sin` on AArch64 HotSpot.
|
||||
pub fn sin(x: f64) -> f64 {
|
||||
sin_cos(x, false)
|
||||
}
|
||||
|
||||
/// `Math.cos` on AArch64 HotSpot.
|
||||
pub fn cos(x: f64) -> f64 {
|
||||
sin_cos(x, true)
|
||||
}
|
||||
}
|
||||
|
||||
/// `sin` and `cos` correctly rounded, for Windows, whose libm rounds them
|
||||
/// unlike Java's x86-64 intrinsics. Double-double arithmetic (two `f64`s per
|
||||
/// value): `x` reduced by a four-part pi/2, a short series checked against an
|
||||
@@ -813,6 +406,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_f32_at_the_edges_of_the_int_range() {
|
||||
for (x, expected) in [
|
||||
(2_147_483_520.0, 2_147_483_520), // the largest float below 2^31
|
||||
(2_147_483_648.0, i32::MAX),
|
||||
(f32::INFINITY, i32::MAX),
|
||||
(-2_147_483_648.0, i32::MIN),
|
||||
(-2_147_483_904.0, i32::MIN),
|
||||
(f32::NEG_INFINITY, i32::MIN),
|
||||
(8_388_607.5, 8_388_608), // the largest floats with a fraction
|
||||
(-8_388_607.5, -8_388_607),
|
||||
(-f32::from_bits(1), 0),
|
||||
] {
|
||||
assert_eq!(round_f32(x), expected, "{x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_ties_toward_positive_infinity() {
|
||||
for (x, want) in [
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
// Copyright (C) 1993 by Sun Microsystems, Inc. All rights reserved.
|
||||
//
|
||||
// Developed at SunSoft, a Sun Microsystems, Inc. business.
|
||||
// Permission to use, copy, modify, and distribute this
|
||||
// software is freely granted, provided that this notice
|
||||
// is preserved.
|
||||
//
|
||||
// Rust port of fdlibm 5.3's e_log.c, e_log10.c, s_cbrt.c, e_hypot.c, s_sin.c, s_cos.c, k_sin.c, k_cos.c and e_rem_pio2.c.
|
||||
|
||||
//! fdlibm 5.3, Sun's freely distributable math library
|
||||
//! (<https://www.netlib.org/fdlibm/>), in Rust.
|
||||
//!
|
||||
//! - [`log`], [`log10`], [`cbrt`] and [`hypot`] are the C functions with the
|
||||
//! same double operations in the same order, so they round exactly as
|
||||
//! fdlibm does. The Java SE API defines `StrictMath`'s versions as fdlibm
|
||||
//! 5.3's results.
|
||||
//! - [`sin_fma`] and [`cos_fma`] are fdlibm's `sin` and `cos` as HotSpot's
|
||||
//! AArch64 intrinsics compute `Math.sin` and `Math.cos`. They differ from
|
||||
//! the C in these ways, checked against JDK 25 on Apple silicon:
|
||||
//! - `|x| < 2^-27` returns `x` (sin) or 1 (cos) first; the kernels have no
|
||||
//! shortcut of their own for a tiny argument.
|
||||
//! - The kernels' polynomials and corrections use fused multiply-adds, and
|
||||
//! the cosine kernel's `z * r` is `(z * z) * P`.
|
||||
//! - The cosine kernel picks its branch from the caller's argument, before
|
||||
//! reduction, and its first branch includes the bound `0x3fd33333`.
|
||||
//! - The reduction fuses its quotient and its second and third tails, and
|
||||
//! for n = 32 takes the extra steps only when the high word is 16 pi's,
|
||||
//! as for n < 32.
|
||||
//! - Beyond |x| = 2^19 * pi/2 the reduction is not modelled: the `libm`
|
||||
//! crate's `sin` and `cos` answer there, and may differ in the last bit.
|
||||
|
||||
/// High 32 bits of `x`, as a signed int (fdlibm's `__HI(x)`).
|
||||
fn high_word(x: f64) -> i32 {
|
||||
(x.to_bits() >> 32) as i32
|
||||
}
|
||||
|
||||
/// Low 32 bits of `x` (fdlibm's `__LO(x)`).
|
||||
fn low_word(x: f64) -> u32 {
|
||||
x.to_bits() as u32
|
||||
}
|
||||
|
||||
/// `x` with its high 32 bits replaced by `hw` (fdlibm's `__HI(x) = hw`).
|
||||
fn set_high_word(x: f64, hw: i32) -> f64 {
|
||||
f64::from_bits((u64::from(hw as u32) << 32) | u64::from(low_word(x)))
|
||||
}
|
||||
|
||||
/// The double whose high 32 bits are `hw` and whose low 32 bits are 0.
|
||||
fn from_high_word(hw: i32) -> f64 {
|
||||
f64::from_bits(u64::from(hw as u32) << 32)
|
||||
}
|
||||
|
||||
/// 2^54, to bring subnormal arguments into the normal range.
|
||||
const TWO54: f64 = f64::from_bits(0x4350_0000_0000_0000);
|
||||
|
||||
// e_log.c: ln 2 split so that k * LN2_HI is exact for |k| < 2000, and the
|
||||
// polynomial for log(1 + f) in s = f / (2 + f).
|
||||
const LN2_HI: f64 = f64::from_bits(0x3fe6_2e42_fee0_0000); // 6.93147180369123816490e-01
|
||||
const LN2_LO: f64 = f64::from_bits(0x3dea_39ef_3579_3c76); // 1.90821492927058770002e-10
|
||||
const LG1: f64 = f64::from_bits(0x3fe5_5555_5555_5593); // 6.666666666666735130e-01
|
||||
const LG2: f64 = f64::from_bits(0x3fd9_9999_9997_fa04); // 3.999999999940941908e-01
|
||||
const LG3: f64 = f64::from_bits(0x3fd2_4924_9422_9359); // 2.857142874366239149e-01
|
||||
const LG4: f64 = f64::from_bits(0x3fcc_71c5_1d8e_78af); // 2.222219843214978396e-01
|
||||
const LG5: f64 = f64::from_bits(0x3fc7_4664_96cb_03de); // 1.818357216161805012e-01
|
||||
const LG6: f64 = f64::from_bits(0x3fc3_9a09_d078_c69f); // 1.531383769920937332e-01
|
||||
const LG7: f64 = f64::from_bits(0x3fc2_f112_df3e_5244); // 1.479819860511658591e-01
|
||||
/// The C literal `0.33333333333333333`, rounded to a double.
|
||||
const ONE_THIRD: f64 = f64::from_bits(0x3fd5_5555_5555_5555);
|
||||
|
||||
/// Natural logarithm, fdlibm's `__ieee754_log`.
|
||||
///
|
||||
/// Writes `x = 2^k * (1 + f)` with `sqrt(2)/2 < 1 + f < sqrt(2)`, then
|
||||
/// returns `k * ln 2 + log(1 + f)`, with `log(1 + f)` from a degree-14
|
||||
/// polynomial in `s = f / (2 + f)` (or a short series when |f| < 2^-20).
|
||||
/// `log(±0)` is -inf, negative arguments give NaN, `log(+inf)` is +inf.
|
||||
pub fn log(x: f64) -> f64 {
|
||||
let mut x = x;
|
||||
let mut hx = high_word(x);
|
||||
let mut k = 0i32;
|
||||
if hx < 0x0010_0000 {
|
||||
// Zero, negative, or subnormal.
|
||||
if ((hx & 0x7fff_ffff) as u32 | low_word(x)) == 0 {
|
||||
return f64::NEG_INFINITY;
|
||||
}
|
||||
if hx < 0 {
|
||||
return f64::NAN;
|
||||
}
|
||||
k -= 54;
|
||||
x *= TWO54;
|
||||
hx = high_word(x);
|
||||
}
|
||||
if hx >= 0x7ff0_0000 {
|
||||
return x + x; // +inf or NaN
|
||||
}
|
||||
k += (hx >> 20) - 1023;
|
||||
hx &= 0x000f_ffff;
|
||||
// Give 1 + f the exponent of 1, or of 1/2 when the mantissa is above
|
||||
// sqrt(2), and move that factor of 2 into k.
|
||||
let i = (hx + 0x95f64) & 0x0010_0000;
|
||||
x = set_high_word(x, hx | (i ^ 0x3ff0_0000));
|
||||
k += i >> 20;
|
||||
let f = x - 1.0;
|
||||
let dk = f64::from(k);
|
||||
if (0x000f_ffff & (2 + hx)) < 3 {
|
||||
// |f| < 2^-20: log(1 + f) = f - f^2/2 + f^3/3.
|
||||
if f == 0.0 {
|
||||
return if k == 0 {
|
||||
0.0
|
||||
} else {
|
||||
dk * LN2_HI + dk * LN2_LO
|
||||
};
|
||||
}
|
||||
let r = f * f * (0.5 - ONE_THIRD * f);
|
||||
return if k == 0 {
|
||||
f - r
|
||||
} else {
|
||||
dk * LN2_HI - ((r - dk * LN2_LO) - f)
|
||||
};
|
||||
}
|
||||
let s = f / (2.0 + f);
|
||||
let z = s * s;
|
||||
let w = z * z;
|
||||
let t1 = w * (LG2 + w * (LG4 + w * LG6));
|
||||
let t2 = z * (LG1 + w * (LG3 + w * (LG5 + w * LG7)));
|
||||
let r = t2 + t1;
|
||||
// Mantissas between 0x6147a and 0x6b851 (in the top 20 bits) keep
|
||||
// f^2/2 apart for accuracy.
|
||||
if ((hx - 0x6147a) | (0x6b851 - hx)) > 0 {
|
||||
let hfsq = 0.5 * f * f;
|
||||
if k == 0 {
|
||||
f - (hfsq - s * (hfsq + r))
|
||||
} else {
|
||||
dk * LN2_HI - ((hfsq - (s * (hfsq + r) + dk * LN2_LO)) - f)
|
||||
}
|
||||
} else if k == 0 {
|
||||
f - s * (f - r)
|
||||
} else {
|
||||
dk * LN2_HI - ((s * (f - r) - dk * LN2_LO) - f)
|
||||
}
|
||||
}
|
||||
|
||||
// e_log10.c: 1/ln 10, and log10(2) split so that n * LOG10_2HI is exact.
|
||||
const IVLN10: f64 = f64::from_bits(0x3fdb_cb7b_1526_e50e); // 4.34294481903251816668e-01
|
||||
const LOG10_2HI: f64 = f64::from_bits(0x3fd3_4413_509f_6000); // 3.01029995663611771306e-01
|
||||
const LOG10_2LO: f64 = f64::from_bits(0x3d59_fef3_11f1_2b36); // 3.69423907715893078616e-13
|
||||
|
||||
/// Base-10 logarithm, fdlibm's `__ieee754_log10`.
|
||||
///
|
||||
/// Writes `x = 2^n * m`, with `m` in [1, 2), or in [1/2, 1) and `n` one
|
||||
/// higher when the exponent is negative, and returns
|
||||
/// `n * log10_2hi + (n * log10_2lo + log(m) / ln 10)`, with [`log`] for
|
||||
/// `log(m)`. Special cases as [`log`].
|
||||
pub fn log10(x: f64) -> f64 {
|
||||
let mut x = x;
|
||||
let mut hx = high_word(x);
|
||||
let mut k = 0i32;
|
||||
if hx < 0x0010_0000 {
|
||||
// Zero, negative, or subnormal.
|
||||
if ((hx & 0x7fff_ffff) as u32 | low_word(x)) == 0 {
|
||||
return f64::NEG_INFINITY;
|
||||
}
|
||||
if hx < 0 {
|
||||
return f64::NAN;
|
||||
}
|
||||
k -= 54;
|
||||
x *= TWO54;
|
||||
hx = high_word(x);
|
||||
}
|
||||
if hx >= 0x7ff0_0000 {
|
||||
return x + x; // +inf or NaN
|
||||
}
|
||||
k += (hx >> 20) - 1023;
|
||||
let i = i32::from(k < 0);
|
||||
hx = (hx & 0x000f_ffff) | ((0x3ff - i) << 20);
|
||||
let y = f64::from(k + i);
|
||||
x = set_high_word(x, hx);
|
||||
let z = y * LOG10_2LO + IVLN10 * log(x);
|
||||
z + y * LOG10_2HI
|
||||
}
|
||||
|
||||
// s_cbrt.c: exponent biases for the first estimate, (682 - 0.03306235651) *
|
||||
// 2^20 and (664 - 0.03306235651) * 2^20, and the rational correction's
|
||||
// coefficients.
|
||||
const B1: i32 = 715_094_163;
|
||||
const B2: i32 = 696_219_795;
|
||||
const CBRT_C: f64 = f64::from_bits(0x3fe1_5f15_f15f_15f1); // 19/35
|
||||
const CBRT_D: f64 = f64::from_bits(0xbfe6_91de_2532_c834); // -864/1225
|
||||
const CBRT_E: f64 = f64::from_bits(0x3ff6_a0ea_0ea0_ea0f); // 99/70
|
||||
const CBRT_F: f64 = f64::from_bits(0x3ff9_b6db_6db6_db6e); // 45/28
|
||||
const CBRT_G: f64 = f64::from_bits(0x3fd6_db6d_b6db_6db7); // 5/14
|
||||
|
||||
/// Cube root, fdlibm's `cbrt`.
|
||||
///
|
||||
/// A first estimate from the high word divided by 3 (5 bits), a rational
|
||||
/// correction (23 bits), the estimate chopped to 20 bits and nudged up, then
|
||||
/// one Newton step. `cbrt(±0)`, `cbrt(±inf)` and `cbrt(NaN)` return the
|
||||
/// argument.
|
||||
pub fn cbrt(x: f64) -> f64 {
|
||||
let hx = high_word(x);
|
||||
let sign = hx & i32::MIN;
|
||||
let hx = hx ^ sign;
|
||||
if hx >= 0x7ff0_0000 {
|
||||
return x + x; // NaN or ±inf
|
||||
}
|
||||
if (hx as u32 | low_word(x)) == 0 {
|
||||
return x; // ±0
|
||||
}
|
||||
let x = set_high_word(x, hx); // |x|
|
||||
let mut t = if hx < 0x0010_0000 {
|
||||
// Subnormal: estimate from 2^54 * x, keeping its low word.
|
||||
let t = TWO54 * x;
|
||||
set_high_word(t, high_word(t) / 3 + B2)
|
||||
} else {
|
||||
from_high_word(hx / 3 + B1)
|
||||
};
|
||||
let r = t * t / x;
|
||||
let s = CBRT_C + r * t;
|
||||
t *= CBRT_G + CBRT_F / (s + CBRT_E + CBRT_D / s);
|
||||
// Keep the top 20 bits of the mantissa and add one in the last of them,
|
||||
// so that t is above cbrt(x) and t * t is exact.
|
||||
t = from_high_word(high_word(t) + 1);
|
||||
let s = t * t;
|
||||
let r = x / s;
|
||||
let w = t + t;
|
||||
let r = (r - t) / (w + r);
|
||||
t += t * r;
|
||||
set_high_word(t, high_word(t) | sign)
|
||||
}
|
||||
|
||||
/// `sqrt(x^2 + y^2)` without undue overflow or underflow, fdlibm's
|
||||
/// `__ieee754_hypot`.
|
||||
///
|
||||
/// With `a >= b` the magnitudes, compared by high word: `a + b` when `a` is
|
||||
/// more than 2^60 times `b`; otherwise both scaled by a power of two when
|
||||
/// large or tiny, `a^2 + b^2` summed from parts with the low 32 bits split
|
||||
/// off, and the square root scaled back. An infinite argument gives +inf,
|
||||
/// even with a NaN; otherwise a NaN gives NaN.
|
||||
pub fn hypot(x: f64, y: f64) -> f64 {
|
||||
let mut ha = high_word(x) & 0x7fff_ffff;
|
||||
let mut hb = high_word(y) & 0x7fff_ffff;
|
||||
let (mut a, mut b) = if hb > ha {
|
||||
std::mem::swap(&mut ha, &mut hb);
|
||||
(y, x)
|
||||
} else {
|
||||
(x, y)
|
||||
};
|
||||
a = set_high_word(a, ha);
|
||||
b = set_high_word(b, hb);
|
||||
if ha - hb > 0x03c0_0000 {
|
||||
return a + b; // a / b > 2^60
|
||||
}
|
||||
let mut k = 0i32;
|
||||
if ha > 0x5f30_0000 {
|
||||
// a > 2^500
|
||||
if ha >= 0x7ff0_0000 {
|
||||
// inf or NaN: inf wins over NaN.
|
||||
let mut w = a + b;
|
||||
if ((ha & 0x000f_ffff) as u32 | low_word(a)) == 0 {
|
||||
w = a;
|
||||
}
|
||||
if ((hb ^ 0x7ff0_0000) as u32 | low_word(b)) == 0 {
|
||||
w = b;
|
||||
}
|
||||
return w;
|
||||
}
|
||||
// Scale both by 2^-600.
|
||||
ha -= 0x2580_0000;
|
||||
hb -= 0x2580_0000;
|
||||
k += 600;
|
||||
a = set_high_word(a, ha);
|
||||
b = set_high_word(b, hb);
|
||||
}
|
||||
if hb < 0x20b0_0000 {
|
||||
// b < 2^-500
|
||||
if hb <= 0x000f_ffff {
|
||||
// b subnormal or zero
|
||||
if (hb as u32 | low_word(b)) == 0 {
|
||||
return a;
|
||||
}
|
||||
// Scale both by 2^1022. As in the C, `ha` and `hb` keep the
|
||||
// high words from before this scaling.
|
||||
let t1 = from_high_word(0x7fd0_0000);
|
||||
b *= t1;
|
||||
a *= t1;
|
||||
k -= 1022;
|
||||
} else {
|
||||
// Scale both by 2^600.
|
||||
ha += 0x2580_0000;
|
||||
hb += 0x2580_0000;
|
||||
k -= 600;
|
||||
a = set_high_word(a, ha);
|
||||
b = set_high_word(b, hb);
|
||||
}
|
||||
}
|
||||
let mut w = a - b;
|
||||
if w > b {
|
||||
// a > 2b: a^2 + b^2 = t1^2 + (b^2 + t2 * (a + t1)), t1 = a with
|
||||
// its low word cleared.
|
||||
let t1 = from_high_word(ha);
|
||||
let t2 = a - t1;
|
||||
w = (t1 * t1 - (b * (-b) - t2 * (a + t1))).sqrt();
|
||||
} else {
|
||||
// a <= 2b: a^2 + b^2 = 2a * b + (a - b)^2, 2a and b split in two.
|
||||
a += a;
|
||||
let y1 = from_high_word(hb);
|
||||
let y2 = b - y1;
|
||||
let t1 = from_high_word(ha + 0x0010_0000);
|
||||
let t2 = a - t1;
|
||||
w = (t1 * y1 - (w * (-w) - (t1 * y2 + t2 * b))).sqrt();
|
||||
}
|
||||
if k == 0 {
|
||||
w
|
||||
} else {
|
||||
// 2^k * w
|
||||
from_high_word(0x3ff0_0000 + (k << 20)) * w
|
||||
}
|
||||
}
|
||||
|
||||
// k_sin.c's coefficients: sin x ~ x + S1 x^3 + ... + S6 x^13 on [-pi/4, pi/4].
|
||||
const S1: f64 = f64::from_bits(0xbfc5_5555_5555_5549); // -1.66666666666666324348e-01
|
||||
const S2: f64 = f64::from_bits(0x3f81_1111_1110_f8a6); // 8.33333333332248946124e-03
|
||||
const S3: f64 = f64::from_bits(0xbf2a_01a0_19c1_61d5); // -1.98412698298579493134e-04
|
||||
const S4: f64 = f64::from_bits(0x3ec7_1de3_57b1_fe7d); // 2.75573137070700676789e-06
|
||||
const S5: f64 = f64::from_bits(0xbe5a_e5e6_8a2b_9ceb); // -2.50507602534068634195e-08
|
||||
const S6: f64 = f64::from_bits(0x3de5_d93a_5acf_d57c); // 1.58969099521155010221e-10
|
||||
|
||||
// k_cos.c's coefficients: cos x ~ 1 - x^2/2 + C1 x^4 + ... + C6 x^14.
|
||||
const C1: f64 = f64::from_bits(0x3fa5_5555_5555_554c); // 4.16666666666666019037e-02
|
||||
const C2: f64 = f64::from_bits(0xbf56_c16c_16c1_5177); // -1.38888888888741095749e-03
|
||||
const C3: f64 = f64::from_bits(0x3efa_01a0_19cb_1590); // 2.48015872894767294178e-05
|
||||
const C4: f64 = f64::from_bits(0xbe92_7e4f_809c_52ad); // -2.75573143513906633035e-07
|
||||
const C5: f64 = f64::from_bits(0x3e21_ee9e_bdb4_b1c4); // 2.08757232129817482790e-09
|
||||
const C6: f64 = f64::from_bits(0xbda8_fae9_be88_38d4); // -1.13596475577881948265e-11
|
||||
|
||||
// e_rem_pio2.c: 2/pi, and pi/2 cut into 33-bit pieces (so that n times a piece
|
||||
// is exact for n < 2^20) and the remainders after each.
|
||||
const INVPIO2: f64 = f64::from_bits(0x3fe4_5f30_6dc9_c883); // 6.36619772367581382433e-01
|
||||
const PIO2_1: f64 = f64::from_bits(0x3ff9_21fb_5440_0000); // 1.57079632673412561417e+00
|
||||
const PIO2_1T: f64 = f64::from_bits(0x3dd0_b461_1a62_6331); // 6.07710050650619224932e-11
|
||||
const PIO2_2: f64 = f64::from_bits(0x3dd0_b461_1a60_0000); // 6.07710050630396597660e-11
|
||||
const PIO2_2T: f64 = f64::from_bits(0x3ba3_198a_2e03_7073); // 2.02226624879595063154e-21
|
||||
const PIO2_3: f64 = f64::from_bits(0x3ba3_198a_2e00_0000); // 2.02226624871116645580e-21
|
||||
const PIO2_3T: f64 = f64::from_bits(0x397b_839a_2520_49c1); // 8.47842766036889956997e-32
|
||||
/// High words of n * pi/2 for n = 1..=32.
|
||||
const NPIO2_HW: [i32; 32] = [
|
||||
0x3ff9_21fb,
|
||||
0x4009_21fb,
|
||||
0x4012_d97c,
|
||||
0x4019_21fb,
|
||||
0x401f_6a7a,
|
||||
0x4022_d97c,
|
||||
0x4025_fdbb,
|
||||
0x4029_21fb,
|
||||
0x402c_463a,
|
||||
0x402f_6a7a,
|
||||
0x4031_475c,
|
||||
0x4032_d97c,
|
||||
0x4034_6b9c,
|
||||
0x4035_fdbb,
|
||||
0x4037_8fdb,
|
||||
0x4039_21fb,
|
||||
0x403a_b41b,
|
||||
0x403c_463a,
|
||||
0x403d_d85a,
|
||||
0x403f_6a7a,
|
||||
0x4040_7e4c,
|
||||
0x4041_475c,
|
||||
0x4042_106c,
|
||||
0x4042_d97c,
|
||||
0x4043_a28c,
|
||||
0x4044_6b9c,
|
||||
0x4045_34ac,
|
||||
0x4045_fdbb,
|
||||
0x4046_c6cb,
|
||||
0x4047_8fdb,
|
||||
0x4048_58eb,
|
||||
0x4049_21fb,
|
||||
];
|
||||
|
||||
/// The biased exponent of `v`, 11 bits.
|
||||
fn biased_exponent(v: f64) -> i32 {
|
||||
(high_word(v) >> 20) & 0x7ff
|
||||
}
|
||||
|
||||
/// fdlibm's `__kernel_sin(x, y, iy)` with fused multiply-adds: sin(x + y)
|
||||
/// for |x| <= pi/4 + a little, `y` the tail of a reduced argument
|
||||
/// (`reduced == false`: y is 0). Unlike the C, a tiny `x` goes through the
|
||||
/// polynomial too.
|
||||
fn sin_kernel_fma(x: f64, y: f64, reduced: bool) -> f64 {
|
||||
let z = x * x;
|
||||
let v = z * x;
|
||||
// S2 + z (S3 + z (S4 + z (S5 + z S6)))
|
||||
let r = z.mul_add(z.mul_add(z.mul_add(z.mul_add(S6, S5), S4), S3), S2);
|
||||
if !reduced {
|
||||
// x + v (S1 + z r)
|
||||
return v.mul_add(z.mul_add(r, S1), x);
|
||||
}
|
||||
// x - ((z (y/2 - v r) - y) - v S1)
|
||||
let t = (-v).mul_add(r, 0.5 * y);
|
||||
let u = (-z).mul_add(t, y);
|
||||
x + v.mul_add(S1, u)
|
||||
}
|
||||
|
||||
/// fdlibm's `__kernel_cos(x, y)` with fused multiply-adds: cos(x + y) for
|
||||
/// |x| <= pi/4 + a little. `hw` is the high word (sign cleared) of the
|
||||
/// caller's argument before any reduction, and it alone picks the branch:
|
||||
/// so a reduced argument always takes the second one with `qx = 0.28125`.
|
||||
/// The first branch includes `hw == 0x3fd33333`, and a tiny `x` goes
|
||||
/// through the polynomial too.
|
||||
fn cos_kernel_fma(x: f64, y: f64, hw: i32) -> f64 {
|
||||
let z = x * x;
|
||||
// C1 + z (C2 + z (C3 + z (C4 + z (C5 + z C6)))); the C's z * r is zz * p.
|
||||
let p = z.mul_add(
|
||||
z.mul_add(z.mul_add(z.mul_add(z.mul_add(C6, C5), C4), C3), C2),
|
||||
C1,
|
||||
);
|
||||
let xy = x * y;
|
||||
let zz = z * z;
|
||||
if hw <= 0x3fd3_3333 {
|
||||
// 1 - (z/2 - (z r - x y))
|
||||
return 1.0 - 0.5f64.mul_add(z, (-zz).mul_add(p, xy));
|
||||
}
|
||||
// (1 - qx) - ((z/2 - qx) - (z r - x y)), with qx = |x|/4 cut to its
|
||||
// high word, or 0.28125 above 0.78125.
|
||||
let qx = if hw > 0x3fe9_0000 {
|
||||
0.28125
|
||||
} else {
|
||||
from_high_word(hw - 0x0020_0000)
|
||||
};
|
||||
let hz = 0.5f64.mul_add(z, -qx);
|
||||
let a = 1.0 - qx;
|
||||
a - (hz - zz.mul_add(p, -xy))
|
||||
}
|
||||
|
||||
/// fdlibm's `__ieee754_rem_pio2(x)` for 2^-27 <= |x| <= 2^19 * pi/2, with
|
||||
/// fused multiply-adds: `(n, y0, y1)` with `x - n * pi/2 = y0 + y1`.
|
||||
fn rem_pio2_fma(x: f64) -> (i32, f64, f64) {
|
||||
let hx = high_word(x);
|
||||
let ix = hx & 0x7fff_ffff;
|
||||
if ix < 0x4002_d97c {
|
||||
// |x| < 3pi/4: n = ±1, as in the C. Next to pi/2 itself (high word
|
||||
// 0x3ff921fb), subtract the second 33 bits of pi/2 too.
|
||||
let (z, tail) = match (hx > 0, ix == 0x3ff9_21fb) {
|
||||
(true, false) => (x - PIO2_1, -PIO2_1T),
|
||||
(true, true) => (x - PIO2_1 - PIO2_2, -PIO2_2T),
|
||||
(false, false) => (x + PIO2_1, PIO2_1T),
|
||||
(false, true) => (x + PIO2_1 + PIO2_2, PIO2_2T),
|
||||
};
|
||||
let y0 = z + tail;
|
||||
let n = if hx > 0 { 1 } else { -1 };
|
||||
return (n, y0, (z - y0) + tail);
|
||||
}
|
||||
let t = x.abs();
|
||||
let fnum = t.mul_add(INVPIO2, 0.5).trunc();
|
||||
let n = fnum as i32;
|
||||
// t - n * pi/2 with 33 + 53 bits of pi/2.
|
||||
let mut r = (-fnum).mul_add(PIO2_1, t);
|
||||
let mut w = fnum * PIO2_1T;
|
||||
let mut y0 = r - w;
|
||||
if n > 32 || ix == NPIO2_HW[(n - 1) as usize] {
|
||||
// Past n = 32, or with the high word of n * pi/2 itself, y0 may have
|
||||
// lost bits to cancellation: past 16 bits take the next 33 bits of
|
||||
// pi/2, and past 49 the next 33.
|
||||
let j = ix >> 20;
|
||||
if j - biased_exponent(y0) > 16 {
|
||||
let t = r;
|
||||
let w2 = fnum * PIO2_2;
|
||||
r = t - w2;
|
||||
w = fnum.mul_add(PIO2_2T, w2 - (t - r));
|
||||
y0 = r - w;
|
||||
if j - biased_exponent(y0) > 49 {
|
||||
let t = r;
|
||||
let w3 = fnum * PIO2_3;
|
||||
r = t - w3;
|
||||
w = fnum.mul_add(PIO2_3T, w3 - (t - r));
|
||||
y0 = r - w;
|
||||
}
|
||||
}
|
||||
}
|
||||
let y1 = (r - y0) - w;
|
||||
if hx < 0 { (-n, -y0, -y1) } else { (n, y0, y1) }
|
||||
}
|
||||
|
||||
/// `sin(x)`: fdlibm's `sin` (s_sin.c) with fused multiply-adds; see the
|
||||
/// module documentation for how it departs from the C.
|
||||
pub fn sin_fma(x: f64) -> f64 {
|
||||
let ix = high_word(x) & 0x7fff_ffff;
|
||||
if ix < 0x3e40_0000 {
|
||||
return x;
|
||||
}
|
||||
if ix <= 0x3fe9_21fb {
|
||||
return sin_kernel_fma(x, 0.0, false);
|
||||
}
|
||||
if ix >= 0x7ff0_0000 {
|
||||
return f64::NAN;
|
||||
}
|
||||
if ix > 0x4139_21fb {
|
||||
return libm::sin(x);
|
||||
}
|
||||
let (n, y0, y1) = rem_pio2_fma(x);
|
||||
match n & 3 {
|
||||
0 => sin_kernel_fma(y0, y1, true),
|
||||
1 => cos_kernel_fma(y0, y1, ix),
|
||||
2 => -sin_kernel_fma(y0, y1, true),
|
||||
_ => -cos_kernel_fma(y0, y1, ix),
|
||||
}
|
||||
}
|
||||
|
||||
/// `cos(x)`: fdlibm's `cos` (s_cos.c) with fused multiply-adds; see the
|
||||
/// module documentation for how it departs from the C.
|
||||
pub fn cos_fma(x: f64) -> f64 {
|
||||
let ix = high_word(x) & 0x7fff_ffff;
|
||||
if ix < 0x3e40_0000 {
|
||||
return 1.0;
|
||||
}
|
||||
if ix <= 0x3fe9_21fb {
|
||||
return cos_kernel_fma(x, 0.0, ix);
|
||||
}
|
||||
if ix >= 0x7ff0_0000 {
|
||||
return f64::NAN;
|
||||
}
|
||||
if ix > 0x4139_21fb {
|
||||
return libm::cos(x);
|
||||
}
|
||||
let (n, y0, y1) = rem_pio2_fma(x);
|
||||
match n & 3 {
|
||||
0 => cos_kernel_fma(y0, y1, ix),
|
||||
1 => -sin_kernel_fma(y0, y1, true),
|
||||
2 => -cos_kernel_fma(y0, y1, ix),
|
||||
_ => sin_kernel_fma(y0, y1, true),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{cbrt, cos_fma, hypot, log, log10, sin_fma};
|
||||
|
||||
fn check1(f: fn(f64) -> f64, cases: &[(u64, u64)]) {
|
||||
for &(x, want) in cases {
|
||||
assert_eq!(f(f64::from_bits(x)).to_bits(), want, "{x:016x}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `StrictMath` on JDK 25 (x86-64), on the paths the plain vectors in
|
||||
/// `jmath::tests` don't reach: subnormal arguments, |f| < 2^-20 in `log`,
|
||||
/// and each of `hypot`'s scalings.
|
||||
#[test]
|
||||
fn matches_strictmath_on_every_path() {
|
||||
check1(log, &[(0x0006204cc2b51c13, 0xc0862ad9fbcaf99b)]);
|
||||
check1(log, &[(0x3feffffffffffd15, 0xbd37580000000110)]);
|
||||
check1(log10, &[(0x0003397f14b3dd31, 0xc0734592a35d544b)]);
|
||||
check1(cbrt, &[(0x8007a2937cf342e8, 0xaa9f817dc09448aa)]);
|
||||
for (x, y, want) in [
|
||||
// a > 2^500: scaled by 2^-600.
|
||||
(0xe08d3dde0d47e8aa, 0xdf642854234b0418, 0x608d3dde0d4857d4),
|
||||
// b < 2^-500: scaled by 2^600.
|
||||
(0x9656cdb86ae83b21, 0x96303a08c1241dab, 0x1657295fd0495647),
|
||||
// b subnormal: scaled by 2^1022, the split still using the high
|
||||
// words from before the scaling (updating them changes these).
|
||||
(0x00d66c302c6465c5, 0x000ffffff731f247, 0x00d66c303219c78c),
|
||||
(0x007cf84ccb90d050, 0x000fffffd32f892e, 0x007cf8937ce3c936),
|
||||
// inf beats NaN.
|
||||
(0x7ff0000000000000, 0x7ff8000000000000, 0x7ff0000000000000),
|
||||
] {
|
||||
let got = hypot(f64::from_bits(x), f64::from_bits(y)).to_bits();
|
||||
assert_eq!(got, want, "{x:016x} {y:016x}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `Math.sin` / `Math.cos` on JDK 25, Apple M4: one argument for each way
|
||||
/// the fused versions differ from fdlibm's C, where the C's way gives
|
||||
/// another result.
|
||||
#[test]
|
||||
fn matches_hotspot_aarch64_where_it_departs_from_fdlibm() {
|
||||
// n = 32 away from 16 pi's high word: one reduction step only.
|
||||
check1(sin_fma, &[(0x404921fcfcc2b2f1, 0x3f0a87e85d5af49d)]);
|
||||
// High word 0x3fd33333: the cosine kernel's first branch.
|
||||
check1(cos_fma, &[(0x3fd33333d22b3d20, 0x3fee921dbcb1c922)]);
|
||||
// The cosine kernel's branch follows the caller's argument.
|
||||
check1(sin_fma, &[(0xc1262ac2ad35bfa3, 0xbfefbacd8d55fb04)]);
|
||||
// A tiny reduced argument still goes through the sine polynomial.
|
||||
check1(cos_fma, &[(0x412136d51041526e, 0x3e3dc3d8bd377c4d)]);
|
||||
// Fused polynomial, tail correction and z * r.
|
||||
check1(sin_fma, &[(0xbfe1b3f7fec43873, 0xbfe0d04fc1a57b1d)]);
|
||||
check1(cos_fma, &[(0x401e6ba3a2f24963, 0x3fcf86e6398c69d5)]);
|
||||
check1(cos_fma, &[(0x400f271ce0981ec5, 0xbfe75bfc80508df7)]);
|
||||
}
|
||||
}
|
||||
@@ -320,11 +320,65 @@ pub fn trail_part(item: &Item) -> Option<Part> {
|
||||
/// 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 {
|
||||
render_view(draws, width, height, camera.view(width, height))
|
||||
}
|
||||
|
||||
/// Each corner of every face `draws` holds, in model space (blocks).
|
||||
fn corners(draws: &[Draw], mut each: impl FnMut(Vec3)) {
|
||||
for d in draws {
|
||||
let mut stack = vec![(&*d.root, d.base)];
|
||||
while let Some((part, parent)) = stack.pop() {
|
||||
let m = parent * pose_matrix(pose_of(part, &d.poses));
|
||||
for face in &part.faces {
|
||||
for v in &face.vertices {
|
||||
each(m.transform_point3(Vec3::new(v[0], v[1], v[2]) / 16.0));
|
||||
}
|
||||
}
|
||||
for child in &part.children {
|
||||
stack.push((child, m));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A view of `draws` alone, turned `yaw` degrees and seen from `pitch` degrees above as
|
||||
/// `Camera` turns, as large as fits a `width`×`height` picture with `margin` of its smaller side
|
||||
/// left free on each side: a shop's picture of one item, framed on it. None when there is
|
||||
/// nothing to see.
|
||||
pub fn fitted_view(
|
||||
draws: &[Draw],
|
||||
(width, height): (u32, u32),
|
||||
(yaw, pitch): (f32, f32),
|
||||
margin: f32,
|
||||
) -> Option<Mat4> {
|
||||
let turn = Mat4::from_rotation_x(pitch.to_radians()) * Mat4::from_rotation_y(yaw.to_radians());
|
||||
let (mut lo, mut hi) = (Vec3::splat(f32::INFINITY), Vec3::splat(f32::NEG_INFINITY));
|
||||
corners(draws, |p| {
|
||||
let p = turn.transform_point3(p);
|
||||
(lo, hi) = (lo.min(p), hi.max(p));
|
||||
});
|
||||
let size = hi - lo;
|
||||
if !size.is_finite() || size.x <= 0.0 && size.y <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let free = margin * width.min(height) as f32 * 2.0;
|
||||
let (w, h) = (width as f32 - free, height as f32 - free);
|
||||
let scale = (w / size.x.max(1e-3)).min(h / size.y.max(1e-3));
|
||||
let centre = (lo + hi) / 2.0;
|
||||
Some(
|
||||
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_translation(-centre)
|
||||
* turn,
|
||||
)
|
||||
}
|
||||
|
||||
/// Draws `draws` into a `width`×`height` picture through `view` (model space to the picture's
|
||||
/// pixels, depth growing away from the viewer: `Camera::view`, `fitted_view`).
|
||||
pub fn render_view(draws: &[Draw], width: u32, height: u32, view: Mat4) -> 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 view = camera.view(width, height);
|
||||
// Solid first; see-through over them, unsorted (an aura is one thin layer).
|
||||
let solid = draws.iter().filter(|d| d.alpha >= 1.0);
|
||||
for d in solid.chain(draws.iter().filter(|d| d.alpha < 1.0)) {
|
||||
@@ -440,6 +494,57 @@ fn raster(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The drawn pixels' bounds: (left, top, right, bottom), inclusive.
|
||||
fn drawn(image: &Image) -> Option<(u32, u32, u32, u32)> {
|
||||
let mut out: Option<(u32, u32, u32, u32)> = None;
|
||||
for y in 0..image.height {
|
||||
for x in 0..image.width {
|
||||
if image.rgba[((y * image.width + x) * 4 + 3) as usize] > 0 {
|
||||
let (l, t, r, b) = out.unwrap_or((x, y, x, y));
|
||||
out = Some((l.min(x), t.min(y), r.max(x), b.max(y)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fitted_view_fills_the_picture_with_the_head_alone() {
|
||||
let (body, skin) = (player_model(false), skin());
|
||||
let head = body.children.iter().find(|p| p.name == "head").unwrap();
|
||||
let draws = [Draw::model(head, &skin)];
|
||||
// Straight at the front: the head is a square, centred, a tenth of the height free
|
||||
// around its (see-through, half a pixel larger) hat layer: 48 of 60 pixels for 9 of the
|
||||
// skin's, so 42.7 for the head's 8.
|
||||
let view = fitted_view(&draws, (100, 60), (0.0, 0.0), 0.1).unwrap();
|
||||
let image = render_view(&draws, 100, 60, view);
|
||||
assert_eq!(drawn(&image), Some((29, 9, 70, 50)));
|
||||
// Its front is the red face, lit as a face toward the viewer is (three quarters).
|
||||
assert_eq!(
|
||||
&image.rgba[((30 * 100 + 50) * 4) as usize..][..3],
|
||||
&[150, 0, 0]
|
||||
);
|
||||
// Turned, it is wider than tall, and still within the margins.
|
||||
let view = fitted_view(&draws, (100, 100), (45.0, 20.0), 0.1).unwrap();
|
||||
let (l, t, r, b) = drawn(&render_view(&draws, 100, 100, view)).unwrap();
|
||||
assert!(l >= 9 && t >= 9 && r <= 90 && b <= 90, "{l} {t} {r} {b}");
|
||||
assert!(r - l >= 68 || b - t >= 68, "fills one way: {l} {t} {r} {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_see_has_no_fitted_view() {
|
||||
let (skin, empty) = (
|
||||
skin(),
|
||||
Part {
|
||||
name: "empty".into(),
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
faces: Vec::new(),
|
||||
children: Vec::new(),
|
||||
},
|
||||
);
|
||||
assert!(fitted_view(&[Draw::model(&empty, &skin)], (64, 64), (30.0, 15.0), 0.1).is_none());
|
||||
}
|
||||
|
||||
/// A 64×64 skin in one colour per region: what lands where shows which face was drawn.
|
||||
fn skin() -> Texture {
|
||||
let mut rgba = vec![0u8; 64 * 64 * 4];
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -27,7 +27,7 @@ command above to inspect the selection rather than relying on historical counts.
|
||||
|
||||
| # | Area (commits) | Files | Checks |
|
||||
|---|---|---|---|
|
||||
| 1 | Game loop, tick order, frame pacing (554) | `L/game.rs` (`fn tick`, `fn tick_world`, `fn render`, `about_to_wait`), `L/clock.rs`, `N/window.rs`, `R/spans.rs` | game_loop, frame_limiter, interaction_input, tick |
|
||||
| 1 | Game loop, tick order, frame pacing (554) | `L/game.rs` (`fn tick`, `fn tick_world`, `fn render`), `G/window_handler.rs` (`about_to_wait`), `L/clock.rs`, `N/window.rs`, `R/spans.rs` | game_loop, frame_limiter, interaction_input, tick |
|
||||
| 2 | Mobs in the game loop, spawning (166) | `G/creatures.rs`, `G/natural_spawning.rs`, `L/mobs/spawning.rs`, `L/mobs/spawn_rules.rs`, `L/mobs/spawn_selection.rs`, `L/mobs/finalize.rs`, `G/spawner.rs`, `G/trial_spawner.rs` | natural_spawn, spawn_rules, finalize_spawn, spawner, trial_spawner, mob_ai |
|
||||
| 3 | World save and load (163) | `L/save.rs`, `G/saving.rs`, `G/persist.rs` (entities), `L/entity_nbt.rs`, `K/block_entity_nbt.rs`, `W/region.rs`, `W/nbt.rs` | save_roundtrip, world_reopen, world, datafix |
|
||||
| 4 | World generation (153) | `W/worldgen.rs`, `W/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `placement/`), `L/vanillagen.rs` | chunk_generation, worldgen, server_world_gen, world_spawn, feature_graph, feature_placement, decoration and the other worldgen harnesses |
|
||||
@@ -41,21 +41,21 @@ command above to inspect the selection rather than relying on historical counts.
|
||||
| 12 | Guests' actions on a server (98) | `G/lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `G/lan_{attributes,pvp,chat,crafting,stations,trading,mount_menu,waypoints}.rs` | server_player_combat, server_player_damage, server_player_targets, lan_e2e (all) |
|
||||
| 13 | Modpack porting, mod content (97) | `crates/horizon-port-pack/`, `L/mod_content.rs`, `tools/port_*.py` | `cargo test -p horizon-port-pack`; `python3 tools/mod_port_harness.py` |
|
||||
| 14 | Mob AI (96) | `L/mobs/ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `control.rs` | mob_ai (probe `examples/mob_ai_probe.rs`; known failure on main: drowned_*, #290) |
|
||||
| 15 | Items and item use (96) | `I/items.rs`, `I/item_data.rs`, `G/hand.rs`, `G/consume.rs`, `G/potions.rs`, `R/effects.rs`; game.rs `fn use_hand`, `fn use_item` | use_item_on, item_use, item_use_lifecycle, consumable, consume_effect, status_effect |
|
||||
| 15 | Items and item use (96) | `I/items.rs`, `I/item_data.rs`, `G/hand.rs`, `G/consume.rs`, `G/using_item.rs`, `G/potions.rs`, `R/effects.rs`, `G/item_actions.rs` (`fn use_item`); game.rs `fn use_hand` | use_item_on, item_use, item_use_lifecycle, consumable, consume_effect, status_effect |
|
||||
| 16 | Options and settings screens (93) | `R/options_ui.rs`, `G/options.rs`, `R/settings.rs`, `R/options_file.rs`, `R/graphics_ui.rs`, `R/graphics.rs` | options, toggle_keys |
|
||||
| 17 | HUD, chat, tab list (93) | `R/hud.rs`, `R/hud_layout.rs`, `R/hud_overlays.rs`, `R/chat.rs`, `G/chat.rs`, `R/tab_list.rs`, `L/locator_bar.rs`, `G/locator_bar.rs` | hud, tab_list, locator_bar, chat_screen, secure_chat |
|
||||
| 18 | Command execution (88) | `G/commands.rs`, `G/commands/*.rs`, `L/scoreboard_cmd.rs`, `L/data_cmd.rs`, `K/scoreboard.rs`, `G/functions.rs` | command_exec (corpus `tools/command_exec_corpus.py`), scoreboard, server_ops_feedback, lan_e2e commands |
|
||||
| 19 | Chunk streaming, light, tickets (85) | `R/streaming.rs`, `R/world.rs`, `R/chunk.rs`, `R/chunk_map.rs`, `R/light.rs`, `L/ticket_levels.rs`, `L/tick_chunk_membership.rs`, `G/world_loading.rs` | light, chunk_status, random_tick_sections; `cargo test -p horizon-client --test streaming_meshes` |
|
||||
| 19 | Chunk streaming, light, tickets (85) | `R/streaming.rs`, `R/world.rs`, `R/chunk.rs`, `R/chunk_map.rs`, `R/light.rs`, `L/ticket_levels.rs`, `L/tick_chunk_membership.rs`, `G/world_loading.rs`, `G/world_entry.rs` | light, chunk_status, random_tick_sections; `cargo test -p horizon-client --test streaming_meshes` |
|
||||
| 20 | Mob models and animation (78) | `L/mobs/render.rs`, `L/mobs/anim.rs`, `L/mobs/body_anim.rs`, `L/mobs/keyframes.rs`, `R/entity.wgsl` | entity_model |
|
||||
| 21 | Block behaviour, server side (78) | `W/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `data.rs`, `redstone.rs`, `place.rs`), `G/block_entity_ticks.rs`, `G/moving_pistons.rs` | block_update, redstone, random_tick, simple_block, piston_movement, block_entity_tickers |
|
||||
| 22 | Player movement and collision (75) | `L/motion.rs` + `L/motion/`, `R/collisions.rs`, `W/entity_move.rs`, `W/player_travel.rs`, `W/collision.rs` | collision, entity_move, player_travel, living_motion, climbing, swimming; `python3 tools/test_native_scene_motion.py` |
|
||||
| 23 | Resource packs and models (65) | `R/packs.rs` + `R/packs/`, `R/pack_screen.rs`, `R/atlas_sources.rs`, `R/sprites.rs`, `R/model_library.rs`, `G/packs.rs`, `L/mobs/cem.rs`, `L/mobs/cem_anim.rs`, `R/block_entity_cem.rs`, `crates/horizon-assets/`, `crates/horizon-optifine/` | resource_pack, pack_selection, pack_screen, model_resolution, item_model, mipmap |
|
||||
| 24 | Container menus and inventory (61) | `L/menu.rs`, `L/container_screen.rs`, `R/containers.rs`, `L/creative.rs`; game.rs `fn menu_ops`, `fn click_inventory` | menu_click, container_screen, creative, lan_e2e containers clicks |
|
||||
| 24 | Container menus and inventory (61) | `L/menu.rs`, `L/container_screen.rs`, `R/containers.rs`, `L/creative.rs`; game.rs `fn menu_ops`, `fn click_inventory`; `G/inventory_input.rs`, `G/inventory_slots.rs` | menu_click, container_screen, creative, lan_e2e containers clicks |
|
||||
| 25 | Workstations (60) | `L/stations.rs`, `G/stations.rs`, `I/enchant.rs`, `R/brewing.rs` | enchanting, screen_layout, lan_e2e brewing loom stonecutter grindstone anvil smithing |
|
||||
| 26 | Crafting and the recipe book (59) | `L/crafting.rs`, `L/recipe_book.rs`, `L/recipe_book_screen.rs`, `G/recipe_book.rs`, `G/machines.rs`, `G/machines/crafter.rs` | recipe, recipe_book, recipe_book_view, place_recipe, ghost_recipe, crafter |
|
||||
| 27 | Vehicles and mounts (54) | `G/boats.rs`, `G/boat_passengers.rs`, `G/minecarts.rs`, `G/mounts.rs`, `G/mount_menu.rs`, `G/llamas.rs`, `G/passengers.rs` | server_entities, lan_e2e boats minecarts riding mounts |
|
||||
| 28 | Villagers, trading, raids (50) | `L/mobs/villager*.rs`, `L/merchant.rs`, `G/trading.rs`, `G/villagers.rs`, `L/raid.rs`, `G/raids.rs`, `L/gossip.rs` | trading, raid, mob_ai |
|
||||
| 29 | Title, worlds, startup (49) | `L/title.rs` + `L/title/`, `G/startup_menu.rs`, `R/title_edition.rs` | world_options; `cargo test -p horizon-client --lib title` |
|
||||
| 29 | Title, worlds, startup (49) | `L/title.rs` + `L/title/`, `G/startup_menu.rs`, `R/title_edition.rs`, `G/loading_window.rs` (the first renderer, `start_game`), `G/resource_load.rs` (the startup resource load) | world_options; `cargo test -p horizon-client --lib title` |
|
||||
| 30 | Dedicated server, horizon-server (48) | `G/dedicated.rs`, `G/dedicated/status.rs`, `crates/horizon-server/`, `crates/horizon-server-files/` | dedicated_server, server_files, user_cache, online_mode, server_ops_feedback |
|
||||
| 31 | Particles and client effects (46) | `R/particles.rs`, `G/render_tick.rs`, `G/level_particles.rs`, `L/animate_tick.rs` | particle_providers, block_particles, entity_particles, emitter_particles, marker_particles |
|
||||
| 32 | Bosses and the End (45) | `G/dragon.rs`, `G/wither.rs`, `G/end.rs` | server_dragon, forced_end, end_podium, end_gateway |
|
||||
|
||||
+21
-4
@@ -457,11 +457,28 @@ for it. No countdowns, scarcity, buyer counts, "limited" wording or loot boxes.
|
||||
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.
|
||||
- **Laid out like a game's item shop** (Ludovic, 2026-11-07: "you just don't see the items"): the
|
||||
first collection's `pack` (else its largest set) across the top, big, with its name, item count,
|
||||
price and *Buy*; then *Looks* (the other sets), each collection's items, and the founder set and
|
||||
the Supporter badge. A tile is the thing itself drawn, its name and its price, nothing else; it
|
||||
lifts under the pointer. Very little text anywhere.
|
||||
- **Pictures** (`shop/thumbs.rs`): drawn by the Wardrobe's renderer (`preview::fitted_view` frames
|
||||
an item on itself) at the screen's pixels, twice as fine then halved, on background threads, kept
|
||||
by the item files' hashes (and the skin). An item alone at an angle (a back item from behind, an
|
||||
aura from above, a trail's particles grown), a shirt on a plain figure, an emote on the player at
|
||||
its fullest move, a look on the player, the pack as its looks side by side. Name tags show as the
|
||||
game draws them. Without a skin the launcher can read (signed out, offline, an unreadable one),
|
||||
the player is Horizon's stand-in, the website shop's own original art (`stand_in` in
|
||||
`wardrobe_page.rs`, `standInSkin` on the site), never Mojang's Steve or Alex.
|
||||
- **A tile opens it big**: the account's own player in 3D wearing it over what it wears
|
||||
(`shop::try_on`; drag to turn, scroll to zoom; an emote plays, again each time it ends), its name,
|
||||
price and line, a look's or the pack's items, *Buy* (not for what the account owns) and *Gift*
|
||||
(the friend's Minecraft name, then *Pay … in your browser*, priced without what they own). Each
|
||||
is one checkout of that offer (`POST /v1/web/checkout`); "Ask whoever pays before you buy.
|
||||
Refunds within 14 days for any reason." sits under the buttons.
|
||||
- **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.
|
||||
Wardrobe's preview; `HORIZON_COSMETICS_CATALOGUE=<file>` gives it a saved `GET /v1/catalogue`
|
||||
(prices, sets), `HORIZON_COSMETICS_SKIN=<png>` a skin, `HORIZON_SHOP_OPEN=<id>` an offer open.
|
||||
@@ -30,8 +30,8 @@ Not every line is read by a person. Instead, every change is checked against the
|
||||
|
||||
## What it doesn't contain
|
||||
|
||||
- **No Minecraft files.** Horizon contains no Minecraft code, assets or data, and never downloads them. The launcher builds the game files on your computer from your own copy of Minecraft: Java Edition 26.3. A check that runs on every pull request (`tools/check_public_tree.py`) keeps game files and decompiled code out of the repository and its history.
|
||||
- **Other projects' code only when their license allows it.** Since 24 October 2026, Horizon's code is written from Minecraft 26.3 itself and public documentation. Since 30 October 2026 it may also use another project's source, but only under one of the licenses listed there (MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense, the CC0-1.0 public-domain dedication), credited in the pull request and in THIRD_PARTY_NOTICES.md; GPL, LGPL, MPL, unlicensed and closed projects stay off limits, even to read ([the rule](../CONTRIBUTING.md#only-original-code-or-code-whose-license-allows-it)). On 24 October shader packs were removed ([#1561](https://github.com/horizon-rust/horizon/pull/1561)), after a developer of Iris showed that our shader pack code had been written from Iris's source. They are being written again from a description of the pack format only.
|
||||
- **No Minecraft files.** Horizon contains no Minecraft code or assets. The launcher builds the game files on your computer from your own copy of Minecraft: Java Edition 26.3: an install you already have, or the game downloaded from Mojang's servers for your signed-in account, which must own it. Horizon's code does hold some of the game's data that compatibility needs: the tables that convert the block and item ids of pre-1.13 worlds (`crates/horizon-world/src/datafix/`), taken from vanilla's code. A check that runs on every pull request (`tools/check_public_tree.py`) keeps game files and decompiled code out of the repository and its history.
|
||||
- **Other projects' code only when their license allows it.** Since 24 October 2026, Horizon's code is written from Minecraft 26.3 itself and public documentation. Since 30 October 2026 it may also use another project's source, but only under one of the licenses listed there (MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense, the CC0-1.0 public-domain dedication), credited in the pull request and in THIRD_PARTY_NOTICES.md; GPL, LGPL, MPL, unlicensed and closed projects stay off limits, even to read ([the rule](../CONTRIBUTING.md#only-original-code-or-code-whose-license-allows-it)). On 24 October shader packs were removed ([#1561](https://github.com/horizon-rust/horizon/pull/1561)), after a developer of Iris showed that our shader pack code had been written from Iris's source. They came back in `crates/horizon-shaders`, first written from a description of the pack format. In November, code translated from OpenJDK (GPL) was found in `horizon-core`'s math functions and in `horizon-world`'s copy of Java's `HashMap` ([#6741](https://github.com/horizon-rust/horizon/issues/6741)). Both were written again without OpenJDK's sources: the math from Sun's fdlibm C sources, the `HashMap` from a description of its behaviour. [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md#code-removed-for-its-license) lists all three, for anyone holding an older version.
|
||||
|
||||
## Check it yourself
|
||||
|
||||
|
||||
@@ -47,6 +47,13 @@ All requests use HTTPS and JSON except the two OAuth calls (form-encoded):
|
||||
1. **Device code**: `POST https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode`
|
||||
(`client_id`, `scope=XboxLive.signin offline_access`). The launcher shows the `user_code` and
|
||||
`verification_uri` (https://www.microsoft.com/link) with a copy button and opens the browser.
|
||||
The device code page takes whatever Microsoft account the browser is signed in to, and the flow has
|
||||
no option for an account picker (no `prompt`, no `login_hint`). To add a different account without any
|
||||
change to the Azure app (#3786), the sign-in panel offers **Open in private window**: the default
|
||||
browser's private window (`launcher/src/private_window.rs`: the `https` ProgId on Windows, LaunchServices
|
||||
on macOS, `xdg-settings` on Linux; `--incognito`, `--inprivate`, `--private`, `--private-window`) has no
|
||||
Microsoft session. Safari and unknown browsers get **Copy link** and a hint instead. **Use a different
|
||||
Microsoft account** opens `login.live.com/logout.srf` and then the code page.
|
||||
2. **Poll** `POST …/consumers/oauth2/v2.0/token` (`grant_type=urn:ietf:params:oauth:grant-type:device_code`)
|
||||
every `interval` seconds; `authorization_pending` waits, `slow_down` adds 5 s, `authorization_declined`,
|
||||
`expired_token` and `bad_verification_code` end the sign-in with a message. Result: the Microsoft
|
||||
|
||||
+5
-1
@@ -1787,6 +1787,10 @@ API 410 adds `native_gui_render_post(batch, gui_hidden, focused)` draws after th
|
||||
|
||||
API 411 adds Client-only Host snapshots of the window, connection, entities, interpolated model heads and profile skins; visual rays and block occlusion facts; atlas GUI painting, nine-slice widgets and tooltips; persisted mod key changes and a local action bar. client_start runs before connecting (a window need not exist yet), client_disconnecting before a real connection is discarded (not dimension travel), client_stopping once before final save/exit. menu_background can change a normal menu blur request (None disables it; finite 1..64 only). Bundles need "hooks": 411.
|
||||
|
||||
API 412 adds `native_force_chunk(owner, chunk, hold)` holds a chunk of the level in place loaded and ticking for `owner`, as NeoForge's `TicketController.forceChunk`; `native_forced_chunks(owner)` lists what it holds. Tickets aren't saved. Bundles need "hooks": 412.
|
||||
|
||||
API 413 adds `native_force_chunk_in(owner, dimension, chunk, hold)`, `native_force_chunk` for a named level, which opens and ticks a level not open yet, so a mod can pre-generate the Nether and the End with nobody there. Bundles need "hooks": 413.
|
||||
|
||||
## What the launcher does
|
||||
|
||||
**Developing a mod?** Run `horizon-launcher settings native-profile dev` once, or pick "Dev build" in the Mods page's
|
||||
@@ -2649,7 +2653,7 @@ server only.
|
||||
level nobody is in, or one not open yet, opens at the next server tick and ticks while a ticket holds it, so a
|
||||
pre-generator walks the Nether and the End from any server hook (a console command, `native_level_tick_pre` of the
|
||||
Overworld) with nobody there. False for a dimension other than the Overworld, the Nether and the End. API
|
||||
@API:chunk_tickets_in_level@.
|
||||
413.
|
||||
- **`native_world_border(dimension)`** (`native_world_border.rs`): the level's world border as the server enforces it,
|
||||
`[min_x, min_z, max_x, max_z]` in blocks (the previous size while it moves), for a web map's border line or a
|
||||
pre-generator's `worldborder` selection. Vanilla 26.3 keeps a border per level (`ServerLevel.getWorldBorder`, each
|
||||
|
||||
Generated
+1
@@ -2696,6 +2696,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"futures-lite",
|
||||
"getrandom 0.3.4",
|
||||
"glam",
|
||||
"gpui-fast",
|
||||
"gpui-fast-platform",
|
||||
"horizon-bench",
|
||||
|
||||
@@ -42,6 +42,7 @@ clap_complete = "4"
|
||||
horizon-theme = { path = "../crates/horizon-theme" }
|
||||
horizon-core = { path = "../crates/horizon-core" }
|
||||
horizon-cosmetics = { path = "../crates/horizon-cosmetics" }
|
||||
glam = "0.30"
|
||||
# `horizon-launcher mcp`: the Model Context Protocol, shared with horizon-ctl and horizon-server.
|
||||
horizon-mcp = { path = "../crates/horizon-mcp" }
|
||||
horizon-prepare = { path = "../crates/horizon-prepare" }
|
||||
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.",
|
||||
"{name} was left out: made for another Horizon version": "{name} is weggelaat: gemaak vir 'n ander Horizon-weergawe",
|
||||
"{name} was left out: clashes with {other}": "{name} is weggelaat: bots met {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Maak oop in privaat venster",
|
||||
"Open this link in a private window to pick another account.": "Maak hierdie skakel in 'n privaat venster oop om 'n ander rekening te kies.",
|
||||
"Use a different Microsoft account": "Gebruik 'n ander Microsoft-rekening",
|
||||
"Buy": "Koop",
|
||||
"Gift": "Gee as geskenk",
|
||||
"Looks": "Voorkomste"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.": "أكمل الدفع في متصفحك. تصل عناصرك إلى خزانة الملابس بعد لحظة.",
|
||||
"{name} was left out: made for another Horizon version": "تم استبعاد {name}: صُنع لإصدار آخر من Horizon",
|
||||
"{name} was left out: clashes with {other}": "تم استبعاد {name}: يتعارض مع {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "فتح في نافذة خاصة",
|
||||
"Open this link in a private window to pick another account.": "افتح هذا الرابط في نافذة خاصة لاختيار حساب آخر.",
|
||||
"Use a different Microsoft account": "استخدام حساب Microsoft مختلف",
|
||||
"Buy": "شراء",
|
||||
"Gift": "إهداء",
|
||||
"Looks": "إطلالات"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.",
|
||||
"{name} was left out: made for another Horizon version": "{name} kənarda qaldı: başqa Horizon versiyası üçün hazırlanıb",
|
||||
"{name} was left out: clashes with {other}": "{name} kənarda qaldı: {other} ilə toqquşur",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Gizli pəncərədə açın",
|
||||
"Open this link in a private window to pick another account.": "Başqa hesab seçmək üçün bu linki gizli pəncərədə açın.",
|
||||
"Use a different Microsoft account": "Başqa Microsoft hesabından istifadə edin",
|
||||
"Buy": "Al",
|
||||
"Gift": "Hədiyyə et",
|
||||
"Looks": "Görünüşlər"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.": "Түләүҙе браузерҙа тамамлағыҙ. Әйберҙәрегеҙ бер аҙҙан Гардеробҡа килә.",
|
||||
"{name} was left out: made for another Horizon version": "{name} төшөрөп ҡалдырылды: Horizon-дың башҡа версияһы өсөн эшләнгән",
|
||||
"{name} was left out: clashes with {other}": "{name} төшөрөп ҡалдырылды: {other} менән бәрелешә",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Шәхси тәҙрәлә асыу",
|
||||
"Open this link in a private window to pick another account.": "Башҡа иҫәп яҙмаһын һайлау өсөн был һылтанманы шәхси тәҙрәлә асығыҙ.",
|
||||
"Use a different Microsoft account": "Башҡа Microsoft иҫәп яҙмаһын ҡулланыу",
|
||||
"Buy": "Һатып алырға",
|
||||
"Gift": "Бүләк итергә",
|
||||
"Looks": "Образдар"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.": "Завяршыце аплату ў браўзеры. Рэчы з'явяцца ў Гардэробе праз хвіліну.",
|
||||
"{name} was left out: made for another Horizon version": "{name} прапушчаны: зроблены для іншай версіі Horizon",
|
||||
"{name} was left out: clashes with {other}": "{name} прапушчаны: канфліктуе з {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Адкрыць у прыватным акне",
|
||||
"Open this link in a private window to pick another account.": "Адкрыйце гэту спасылку ў прыватным акне, каб выбраць іншы ўліковы запіс.",
|
||||
"Use a different Microsoft account": "Выкарыстаць іншы ўліковы запіс Microsoft",
|
||||
"Buy": "Купіць",
|
||||
"Gift": "Падараваць",
|
||||
"Looks": "Вобразы"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.": "Завършете плащането в браузъра. Предметите ще се появят в Гардероба след миг.",
|
||||
"{name} was left out: made for another Horizon version": "{name} е пропуснат: направен за друга версия на Horizon",
|
||||
"{name} was left out: clashes with {other}": "{name} е пропуснат: конфликтира с {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Отваряне в поверителен прозорец",
|
||||
"Open this link in a private window to pick another account.": "Отворете тази връзка в поверителен прозорец, за да изберете друг акаунт.",
|
||||
"Use a different Microsoft account": "Използвайте друг акаунт в Microsoft",
|
||||
"Buy": "Купи",
|
||||
"Gift": "Подари",
|
||||
"Looks": "Визии"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.",
|
||||
"{name} was left out: made for another Horizon version": "{name} a zo bet lezet a-gostez: graet evit ur stumm all eus Horizon",
|
||||
"{name} was left out: clashes with {other}": "{name} a zo bet lezet a-gostez: en em stok a ra gant {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Digeriñ en ur prenestr prevez",
|
||||
"Open this link in a private window to pick another account.": "Digorit al liamm-mañ en ur prenestr prevez evit dibab ur gont all.",
|
||||
"Use a different Microsoft account": "Implijout ur gont Microsoft all",
|
||||
"Buy": "Prenañ",
|
||||
"Gift": "Reiñ e profad",
|
||||
"Looks": "Neuzioù"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.",
|
||||
"{name} was left out: made for another Horizon version": "{name} je izostavljen: napravljen za drugu verziju Horizona",
|
||||
"{name} was left out: clashes with {other}": "{name} je izostavljen: sukobljava se s {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Otvori u privatnom prozoru",
|
||||
"Open this link in a private window to pick another account.": "Otvori ovaj link u privatnom prozoru da odabereš drugi nalog.",
|
||||
"Use a different Microsoft account": "Koristi drugi Microsoft nalog",
|
||||
"Buy": "Kupi",
|
||||
"Gift": "Pokloni",
|
||||
"Looks": "Izgledi"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.",
|
||||
"{name} was left out: made for another Horizon version": "S'ha deixat fora {name}: fet per a una altra versió d'Horizon",
|
||||
"{name} was left out: clashes with {other}": "S'ha deixat fora {name}: entra en conflicte amb {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Obre en una finestra privada",
|
||||
"Open this link in a private window to pick another account.": "Obriu aquest enllaç en una finestra privada per triar un altre compte.",
|
||||
"Use a different Microsoft account": "Utilitza un altre compte de Microsoft",
|
||||
"Buy": "Compra",
|
||||
"Gift": "Regala",
|
||||
"Looks": "Estils"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.",
|
||||
"{name} was left out: made for another Horizon version": "{name} byl vynechán: vytvořen pro jinou verzi Horizonu",
|
||||
"{name} was left out: clashes with {other}": "{name} byl vynechán: koliduje s {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Otevřít v anonymním okně",
|
||||
"Open this link in a private window to pick another account.": "Otevřete tento odkaz v anonymním okně a vyberte jiný účet.",
|
||||
"Use a different Microsoft account": "Použít jiný účet Microsoft",
|
||||
"Buy": "Koupit",
|
||||
"Gift": "Darovat",
|
||||
"Looks": "Vzhledy"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.": "Тӳлеве браузерта вĕçлĕр. Япаласем Гардероба часах килĕç.",
|
||||
"{name} was left out: made for another Horizon version": "{name} кӗртмен: Horizon-ӑн урӑх версийӗ валли тунӑ",
|
||||
"{name} was left out: clashes with {other}": "{name} кӗртмен: {other} мод-па килӗшмест",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Вӑрттӑн чӳречере уҫӑр",
|
||||
"Open this link in a private window to pick another account.": "Урӑх аккаунт суйлама ку ссылкӑна вӑрттӑн чӳречере уҫӑр.",
|
||||
"Use a different Microsoft account": "Урӑх Microsoft аккаунтне усӑ курӑр",
|
||||
"Buy": "Туянса ил",
|
||||
"Gift": "Парне пар",
|
||||
"Looks": "Сăнарсем"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.",
|
||||
"{name} was left out: made for another Horizon version": "Gadawyd {name} allan: wedi'i wneud ar gyfer fersiwn arall o Horizon",
|
||||
"{name} was left out: clashes with {other}": "Gadawyd {name} allan: mae'n gwrthdaro â {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Agor mewn ffenestr breifat",
|
||||
"Open this link in a private window to pick another account.": "Agorwch y ddolen hon mewn ffenestr breifat i ddewis cyfrif arall.",
|
||||
"Use a different Microsoft account": "Defnyddio cyfrif Microsoft gwahanol",
|
||||
"Buy": "Prynu",
|
||||
"Gift": "Rhoi fel anrheg",
|
||||
"Looks": "Gwisgoedd"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.",
|
||||
"{name} was left out: made for another Horizon version": "{name} blev udeladt: lavet til en anden Horizon-version",
|
||||
"{name} was left out: clashes with {other}": "{name} blev udeladt: kolliderer med {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Åbn i privat vindue",
|
||||
"Open this link in a private window to pick another account.": "Åbn dette link i et privat vindue for at vælge en anden konto.",
|
||||
"Use a different Microsoft account": "Brug en anden Microsoft-konto",
|
||||
"Buy": "Køb",
|
||||
"Gift": "Giv i gave",
|
||||
"Looks": "Looks"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.",
|
||||
"{name} was left out: made for another Horizon version": "{name} wurde weggelassen: für eine andere Horizon-Version gemacht",
|
||||
"{name} was left out: clashes with {other}": "{name} wurde weggelassen: kollidiert mit {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "In privatem Fenster öffnen",
|
||||
"Open this link in a private window to pick another account.": "Öffne diesen Link in einem privaten Fenster, um ein anderes Konto auszuwählen.",
|
||||
"Use a different Microsoft account": "Anderes Microsoft-Konto verwenden",
|
||||
"Buy": "Kaufen",
|
||||
"Gift": "Verschenken",
|
||||
"Looks": "Looks"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.": "Ολοκληρώστε την πληρωμή στο πρόγραμμα περιήγησης. Τα αντικείμενα φτάνουν στη Γκαρνταρόμπα σε λίγο.",
|
||||
"{name} was left out: made for another Horizon version": "Το {name} παραλείφθηκε: φτιάχτηκε για άλλη έκδοση του Horizon",
|
||||
"{name} was left out: clashes with {other}": "Το {name} παραλείφθηκε: συγκρούεται με το {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Άνοιγμα σε ιδιωτικό παράθυρο",
|
||||
"Open this link in a private window to pick another account.": "Ανοίξτε αυτόν τον σύνδεσμο σε ιδιωτικό παράθυρο για να επιλέξετε άλλον λογαριασμό.",
|
||||
"Use a different Microsoft account": "Χρήση διαφορετικού λογαριασμού Microsoft",
|
||||
"Buy": "Αγορά",
|
||||
"Gift": "Δώρο",
|
||||
"Looks": "Εμφανίσεις"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.",
|
||||
"{name} was left out: made for another Horizon version": "{name} estis preterlasita: farita por alia versio de Horizon",
|
||||
"{name} was left out: clashes with {other}": "{name} estis preterlasita: konfliktas kun {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Malfermi en privata fenestro",
|
||||
"Open this link in a private window to pick another account.": "Malfermu ĉi tiun ligilon en privata fenestro por elekti alian konton.",
|
||||
"Use a different Microsoft account": "Uzi alian Microsoft-konton",
|
||||
"Buy": "Aĉeti",
|
||||
"Gift": "Donaci",
|
||||
"Looks": "Aspektoj"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.",
|
||||
"{name} was left out: made for another Horizon version": "Se dejó fuera {name}: hecho para otra versión de Horizon",
|
||||
"{name} was left out: clashes with {other}": "Se dejó fuera {name}: choca con {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Abrir en ventana privada",
|
||||
"Open this link in a private window to pick another account.": "Abre este enlace en una ventana privada para elegir otra cuenta.",
|
||||
"Use a different Microsoft account": "Usar otra cuenta de Microsoft",
|
||||
"Buy": "Comprar",
|
||||
"Gift": "Regalar",
|
||||
"Looks": "Looks"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.",
|
||||
"{name} was left out: made for another Horizon version": "{name} jäeti välja: tehtud teise Horizoni versiooni jaoks",
|
||||
"{name} was left out: clashes with {other}": "{name} jäeti välja: on vastuolus modiga {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Ava privaatses aknas",
|
||||
"Open this link in a private window to pick another account.": "Ava see link privaatses aknas, et valida teine konto.",
|
||||
"Use a different Microsoft account": "Kasuta teist Microsofti kontot",
|
||||
"Buy": "Osta",
|
||||
"Gift": "Kingi",
|
||||
"Looks": "Välimused"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Erreklamatu sortzaile multzoa",
|
||||
"Claiming…": "Eskatzen…",
|
||||
"The shop didn't load.": "Dendak ez du kargatu.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Zure jokalariak jantzitako gauzak, Horizon-en jokatzen duen guztiek ikusiak.",
|
||||
"Free for founders": "Sortzaileentzat doakoa",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Lehenengo hemen egon ziren jokalarientzat. Doan eska daiteke; behin eskatuta, zurea da betiko.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Amaitu doako ordainketa zure nabigatzailean. Multzoa pixka bat geroago iritsiko da hona.",
|
||||
"{n} items": "{n} elementu",
|
||||
"Nothing is on sale right now.": "Orain ez dago ezer salgai.",
|
||||
"Your basket": "Zure saskia",
|
||||
"Your basket is empty.": "Zure saskia hutsik dago.",
|
||||
"Add to basket": "Gehitu saskira",
|
||||
"Total": "Guztira",
|
||||
"This is a gift": "Opari bat da",
|
||||
"Your friend's Minecraft name": "Zure lagunaren Minecraft izena",
|
||||
"Find": "Bilatu",
|
||||
"For {name}": "{name}rentzat",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Galdetu ordainduko duenari erosi aurretik. Itzulketak 14 egunen barruan edozein arrazoirengatik.",
|
||||
"Pay {price} in your browser": "Ordaindu {price} nabigatzailean",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Paddle-ren ordainketa nabigatzailean irekitzen da. Abiarazlean ez da ezer kobratzen.",
|
||||
"US sales tax is added where a state charges it.": "AEBetako salmenta-zerga gehitzen da estatu batek kobratzen duen lekuan.",
|
||||
"VAT included where it applies.": "BEZ barne, aplikatzen den lekuan.",
|
||||
"In a set in your basket": "Zure saskiko multzo batean",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Amaitu ordainketa nabigatzailean. Zure elementuak Armairura iritsiko dira une batean.",
|
||||
"{name} was left out: made for another Horizon version": "{name} kanpoan utzi da: Horizonen beste bertsio baterako egina",
|
||||
"{name} was left out: clashes with {other}": "{name} kanpoan utzi da: {other}(r)ekin talka egiten du",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Ireki leiho pribatuan",
|
||||
"Open this link in a private window to pick another account.": "Ireki esteka hau leiho pribatu batean beste kontu bat aukeratzeko.",
|
||||
"Use a different Microsoft account": "Erabili beste Microsoft kontu bat",
|
||||
"Buy": "Erosi",
|
||||
"Gift": "Oparitu",
|
||||
"Looks": "Itxurak"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.": "پیش از خرید از کسی که پول میدهد بپرسید. بازپرداخت تا ۱۴ روز به هر دلیلی.",
|
||||
"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.": "پرداخت را در مرورگر تمام کنید. آیتمهایتان کمی بعد به کمد لباس میرسند.",
|
||||
"{name} was left out: made for another Horizon version": "{name} کنار گذاشته شد: برای نسخهٔ دیگری از Horizon ساخته شده",
|
||||
"{name} was left out: clashes with {other}": "{name} کنار گذاشته شد: با {other} تداخل دارد",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "باز کردن در پنجره ناشناس",
|
||||
"Open this link in a private window to pick another account.": "برای انتخاب حساب دیگر، این پیوند را در یک پنجره ناشناس باز کنید.",
|
||||
"Use a different Microsoft account": "استفاده از حساب مایکروسافت دیگر",
|
||||
"Buy": "خرید",
|
||||
"Gift": "هدیه دادن",
|
||||
"Looks": "ظاهرها"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Lunasta perustajasarja",
|
||||
"Claiming…": "Lunastetaan…",
|
||||
"The shop didn't load.": "Kauppa ei latautunut.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Puettavaa pelaajallesi, kaikkien Horizonissa pelaavien nähtäväksi.",
|
||||
"Free for founders": "Ilmainen perustajille",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Pelaajille, jotka olivat täällä ensin. Ilmainen; lunastettuna se on pysyvästi sinun.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Viimeistele ilmainen kassa selaimessasi. Setti ilmestyy tänne hetken kuluttua.",
|
||||
"{n} items": "{n} esinettä",
|
||||
"Nothing is on sale right now.": "Mitään ei ole nyt myynnissä.",
|
||||
"Your basket": "Ostoskorisi",
|
||||
"Your basket is empty.": "Ostoskorisi on tyhjä.",
|
||||
"Add to basket": "Lisää koriin",
|
||||
"Total": "Yhteensä",
|
||||
"This is a gift": "Tämä on lahja",
|
||||
"Your friend's Minecraft name": "Ystäväsi Minecraft-nimi",
|
||||
"Find": "Etsi",
|
||||
"For {name}": "Saajalle {name}",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Kysy maksajalta ennen ostamista. Hyvitys 14 päivän sisällä mistä tahansa syystä.",
|
||||
"Pay {price} in your browser": "Maksa {price} selaimessa",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Paddlen maksu avautuu selaimeesi. Käynnistimessä ei veloiteta mitään.",
|
||||
"US sales tax is added where a state charges it.": "Yhdysvaltain myyntivero lisätään, jos osavaltio perii sen.",
|
||||
"VAT included where it applies.": "ALV sisältyy, missä sitä sovelletaan.",
|
||||
"In a set in your basket": "Korisi setissä",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Viimeistele maksu selaimessa. Esineet saapuvat Vaatekaappiin hetken kuluttua.",
|
||||
"{name} was left out: made for another Horizon version": "{name} jätettiin pois: tehty toiselle Horizon-versiolle",
|
||||
"{name} was left out: clashes with {other}": "{name} jätettiin pois: törmää modin {other} kanssa",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Avaa yksityisessä ikkunassa",
|
||||
"Open this link in a private window to pick another account.": "Avaa tämä linkki yksityisessä ikkunassa valitaksesi toisen tilin.",
|
||||
"Use a different Microsoft account": "Käytä toista Microsoft-tiliä",
|
||||
"Buy": "Osta",
|
||||
"Gift": "Lahjoita",
|
||||
"Looks": "Asut"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "I-claim ang founder set",
|
||||
"Claiming…": "Kina-claim…",
|
||||
"The shop didn't load.": "Hindi nag-load ang shop.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Mga isusuot para sa iyong player, nakikita ng lahat ng naglalaro sa Horizon.",
|
||||
"Free for founders": "Libre para sa mga founder",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Para sa mga manlalarong nauna rito. Libreng i-claim; kapag na-claim mo na, sa iyo na ito habambuhay.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Tapusin ang libreng checkout sa iyong browser. Lalabas dito ang set pagkalipas ng ilang sandali.",
|
||||
"{n} items": "{n} item",
|
||||
"Nothing is on sale right now.": "Walang ibinebenta ngayon.",
|
||||
"Your basket": "Ang basket mo",
|
||||
"Your basket is empty.": "Walang laman ang basket mo.",
|
||||
"Add to basket": "Idagdag sa basket",
|
||||
"Total": "Kabuuan",
|
||||
"This is a gift": "Regalo ito",
|
||||
"Your friend's Minecraft name": "Pangalan sa Minecraft ng kaibigan mo",
|
||||
"Find": "Hanapin",
|
||||
"For {name}": "Para kay {name}",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Tanungin ang magbabayad bago ka bumili. Refund sa loob ng 14 na araw sa anumang dahilan.",
|
||||
"Pay {price} in your browser": "Bayaran ang {price} sa browser mo",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Bubukas sa browser mo ang bayaran ng Paddle. Walang sisingilin sa launcher.",
|
||||
"US sales tax is added where a state charges it.": "Idinadagdag ang US sales tax kung saan sinisingil ito ng estado.",
|
||||
"VAT included where it applies.": "Kasama ang VAT kung saan ito ipinapataw.",
|
||||
"In a set in your basket": "Nasa isang set sa basket mo",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Tapusin ang pagbabayad sa browser mo. Darating ang mga item mo sa Wardrobe pagkaraan ng ilang sandali.",
|
||||
"{name} was left out: made for another Horizon version": "Hindi isinama ang {name}: ginawa para sa ibang bersyon ng Horizon",
|
||||
"{name} was left out: clashes with {other}": "Hindi isinama ang {name}: sumasalungat sa {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Buksan sa private window",
|
||||
"Open this link in a private window to pick another account.": "Buksan ang link na ito sa private window para pumili ng ibang account.",
|
||||
"Use a different Microsoft account": "Gumamit ng ibang Microsoft account",
|
||||
"Buy": "Bilhin",
|
||||
"Gift": "Iregalo",
|
||||
"Looks": "Mga look"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Krev stovnarasettið",
|
||||
"Claiming…": "Krevur…",
|
||||
"The shop didn't load.": "Handilin legði ikki.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Hlutir at bera á spælaranum hjá tær, sjónligir fyri øll sum spæla á Horizon.",
|
||||
"Free for founders": "Ókeypis til stovnarar",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Til spælararnar sum vóru her fyrstir. Ókeypis at krevja; tá tú hevur kravt tað, er tað títt fyri altíð.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Enda tann ókeypis útrokningina í tínum kaga. Settið kemur higar eina løtu seinni.",
|
||||
"{n} items": "{n} lutir",
|
||||
"Nothing is on sale right now.": "Einki er til sølu júst nú.",
|
||||
"Your basket": "Tín kurvur",
|
||||
"Your basket is empty.": "Tín kurvur er tómur.",
|
||||
"Add to basket": "Legg í kurvin",
|
||||
"Total": "Tilsamans",
|
||||
"This is a gift": "Hetta er ein gáva",
|
||||
"Your friend's Minecraft name": "Minecraft-navnið á vini tínum",
|
||||
"Find": "Finn",
|
||||
"For {name}": "Til {name}",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Spyr tann, ið rindar, áðrenn tú keypir. Endurgjald innan 14 dagar uttan mun til orsøk.",
|
||||
"Pay {price} in your browser": "Rinda {price} í kaganum",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Gjaldingin hjá Paddle opnast í kaganum. Einki verður kravt í byrjaranum.",
|
||||
"US sales tax is added where a state charges it.": "Amerikanskur søluskattur verður lagdur afturat, har ein statur krevur hann.",
|
||||
"VAT included where it applies.": "MVG íroknað, har tað er galdandi.",
|
||||
"In a set in your basket": "Í einum setti í tínum kurvi",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Ger gjaldingina lidna í kaganum. Lutirnir koma í Klæðaskápið eina løtu seinni.",
|
||||
"{name} was left out: made for another Horizon version": "{name} varð útihildin: gjørd til eina aðra útgávu av Horizon",
|
||||
"{name} was left out: clashes with {other}": "{name} varð útihildin: stríðist við {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Opna í privatum glugga",
|
||||
"Open this link in a private window to pick another account.": "Opna hesa leinkju í privatum glugga fyri at velja eina aðra kontu.",
|
||||
"Use a different Microsoft account": "Brúka eina aðra Microsoft kontu",
|
||||
"Buy": "Keyp",
|
||||
"Gift": "Gev í gávu",
|
||||
"Looks": "Útsjónir"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Réclamer le set fondateur",
|
||||
"Claiming…": "Récupération…",
|
||||
"The shop didn't load.": "La boutique n'a pas chargé.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "De quoi habiller ton joueur, visible par tous ceux qui jouent sur Horizon.",
|
||||
"Free for founders": "Gratuit pour les fondateurs",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Pour les joueurs qui étaient là en premier. Gratuit ; une fois récupéré, il est à toi pour de bon.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Termine le paiement gratuit dans ton navigateur. Le set arrive ici un instant plus tard.",
|
||||
"{n} items": "{n} objets",
|
||||
"Nothing is on sale right now.": "Rien n'est en vente pour le moment.",
|
||||
"Your basket": "Votre panier",
|
||||
"Your basket is empty.": "Votre panier est vide.",
|
||||
"Add to basket": "Ajouter au panier",
|
||||
"Total": "Total",
|
||||
"This is a gift": "C'est un cadeau",
|
||||
"Your friend's Minecraft name": "Le nom Minecraft de votre ami",
|
||||
"Find": "Chercher",
|
||||
"For {name}": "Pour {name}",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Demandez à la personne qui paie avant d'acheter. Remboursement sous 14 jours, quelle qu'en soit la raison.",
|
||||
"Pay {price} in your browser": "Payer {price} dans le navigateur",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Le paiement de Paddle s'ouvre dans votre navigateur. Rien n'est débité dans le launcher.",
|
||||
"US sales tax is added where a state charges it.": "La taxe de vente américaine s'ajoute là où un État la perçoit.",
|
||||
"VAT included where it applies.": "TVA incluse là où elle s'applique.",
|
||||
"In a set in your basket": "Dans un set de votre panier",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Terminez le paiement dans votre navigateur. Vos objets arrivent dans la Garde-robe un instant plus tard.",
|
||||
"{name} was left out: made for another Horizon version": "{name} a été laissé de côté : fait pour une autre version de Horizon",
|
||||
"{name} was left out: clashes with {other}": "{name} a été laissé de côté : en conflit avec {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Ouvrir dans une fenêtre de navigation privée",
|
||||
"Open this link in a private window to pick another account.": "Ouvrez ce lien dans une fenêtre de navigation privée pour choisir un autre compte.",
|
||||
"Use a different Microsoft account": "Utiliser un autre compte Microsoft",
|
||||
"Buy": "Acheter",
|
||||
"Gift": "Offrir",
|
||||
"Looks": "Tenues"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Rivendicâ il grup dai fondadôrs",
|
||||
"Claiming…": "Daûr a reclamâ…",
|
||||
"The shop didn't load.": "La buteghe no si à cjamât.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Robis di meti al to zuiadôr, che a vedin ducj chei che a zuin su Horizon.",
|
||||
"Free for founders": "Gratis pai fondadôrs",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Pai zuiadôrs che a jerin ca par prins. Gratis di reclamâ; une volte reclamât, al è to par simpri.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Finìs il paiament gratuit tal to navigadôr. Il set al rive culì un moment dopo.",
|
||||
"{n} items": "{n} ogjets",
|
||||
"Nothing is on sale right now.": "Nuie al è in vendite cumò.",
|
||||
"Your basket": "La tô çeste",
|
||||
"Your basket is empty.": "La tô çeste e je vueide.",
|
||||
"Add to basket": "Zonte te çeste",
|
||||
"Total": "Totâl",
|
||||
"This is a gift": "Al è un regâl",
|
||||
"Your friend's Minecraft name": "Il non di Minecraft dal tô amì",
|
||||
"Find": "Cjate",
|
||||
"For {name}": "Par {name}",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Domande a cui che al paie prime di comprâ. Rimbors dentri di 14 dîs par cualsisei reson.",
|
||||
"Pay {price} in your browser": "Paie {price} tal navigadôr",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Il paiament di Paddle si vierç tal tô navigadôr. Nuie al ven cjapât tal inviadôr.",
|
||||
"US sales tax is added where a state charges it.": "La tasse su lis venditis dai USA e ven zontade dulà che un stât le domande.",
|
||||
"VAT included where it applies.": "IVA includude dulà che si aplche.",
|
||||
"In a set in your basket": "Intun set te tô çeste",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Finìs il paiament tal navigadôr. I tiei ogjets a rivin tal Vistiari dopo un moment.",
|
||||
"{name} was left out: made for another Horizon version": "{name} al è stât lassât fûr: fat par une altre version di Horizon",
|
||||
"{name} was left out: clashes with {other}": "{name} al è stât lassât fûr: al va in conflit cun {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Vierç intun barcon privât",
|
||||
"Open this link in a private window to pick another account.": "Vierç chest leam intun barcon privât par sielzi un altri conto.",
|
||||
"Use a different Microsoft account": "Dopre un altri conto Microsoft",
|
||||
"Buy": "Compre",
|
||||
"Gift": "Regale",
|
||||
"Looks": "Stîi"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Claim de oprjochter set",
|
||||
"Claiming…": "Opeaskje…",
|
||||
"The shop didn't load.": "De winkel is net laden.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Dingen om oan te lûken foar dyn spiler, sichtber foar elkenien dy’t op Horizon spilet.",
|
||||
"Free for founders": "Fergees foar oprjochters",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Foar de spilers dy’t hjir earst wiene. Fergees op te easkjen; ienris opeaske is it foar altyd fan dy.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Meitsje de fergese checkout ôf yn dyn browser. De set komt efkes letter hjir telâne.",
|
||||
"{n} items": "{n} items",
|
||||
"Nothing is on sale right now.": "Der is no neat te keap.",
|
||||
"Your basket": "Dyn koerke",
|
||||
"Your basket is empty.": "Dyn koerke is leech.",
|
||||
"Add to basket": "Oan koerke tafoegje",
|
||||
"Total": "Totaal",
|
||||
"This is a gift": "Dit is in kado",
|
||||
"Your friend's Minecraft name": "De Minecraft-namme fan dyn freon",
|
||||
"Find": "Sykje",
|
||||
"For {name}": "Foar {name}",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Freegje wa't betellet foardatst keapest. Jild werom binnen 14 dagen om hokker reden ek.",
|
||||
"Pay {price} in your browser": "{price} betelje yn dyn browser",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "De betelling fan Paddle iepenet yn dyn browser. Yn de launcher wurdt neat yn rekken brocht.",
|
||||
"US sales tax is added where a state charges it.": "Amerikaanske ferkeapbelesting komt derby dêr't in steat dy hellet.",
|
||||
"VAT included where it applies.": "Btw ynbegrepen dêr't dy jildt.",
|
||||
"In a set in your basket": "Yn in set yn dyn koerke",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Meitsje de betelling ôf yn dyn browser. Dyn items komme efkes letter yn de Klaaikast.",
|
||||
"{name} was left out: made for another Horizon version": "{name} is weilitten: makke foar in oare Horizon-ferzje",
|
||||
"{name} was left out: clashes with {other}": "{name} is weilitten: botst mei {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Iepenje yn in priveefinster",
|
||||
"Open this link in a private window to pick another account.": "Iepenje dizze keppeling yn in priveefinster om in oar akkount te kiezen.",
|
||||
"Use a different Microsoft account": "Brûk in oar Microsoft akkount",
|
||||
"Buy": "Keapje",
|
||||
"Gift": "Kado jaan",
|
||||
"Looks": "Looks"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Éileamh an tacar bunaitheoir",
|
||||
"Claiming…": "Ag éileamh…",
|
||||
"The shop didn't load.": "Níor luchtaigh an siopa.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Rudaí le caitheamh ar d’imreoir, le feiceáil ag gach duine a imríonn ar Horizon.",
|
||||
"Free for founders": "Saor in aisce do bhunaitheoirí",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Do na himreoirí a bhí anseo ar dtús. Saor in aisce le héileamh; nuair a éilíonn tú é, is leatsa go deo é.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Críochnaigh an tseiceáil amach shaor in aisce i do bhrabhsálaí. Tiocfaidh an tsraith anseo nóiméad ina dhiaidh sin.",
|
||||
"{n} items": "{n} míreanna",
|
||||
"Nothing is on sale right now.": "Níl aon rud ar díol faoi láthair.",
|
||||
"Your basket": "Do chiseán",
|
||||
"Your basket is empty.": "Tá do chiseán folamh.",
|
||||
"Add to basket": "Cuir sa chiseán",
|
||||
"Total": "Iomlán",
|
||||
"This is a gift": "Is bronntanas é seo",
|
||||
"Your friend's Minecraft name": "Ainm Minecraft do chara",
|
||||
"Find": "Aimsigh",
|
||||
"For {name}": "Do {name}",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Fiafraigh de cibé duine a íocann sula gceannaíonn tú. Aisíocaíocht laistigh de 14 lá ar chúis ar bith.",
|
||||
"Pay {price} in your browser": "Íoc {price} i do bhrabhsálaí",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Osclaítear íocaíocht Paddle i do bhrabhsálaí. Ní ghearrtar aon rud sa lainseálaí.",
|
||||
"US sales tax is added where a state charges it.": "Cuirtear cáin díolacháin SAM leis nuair a ghearrann stát í.",
|
||||
"VAT included where it applies.": "CBL san áireamh nuair is infheidhme.",
|
||||
"In a set in your basket": "I sraith i do chiseán",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Críochnaigh an íocaíocht i do bhrabhsálaí. Tiocfaidh do mhíreanna chuig an Vardrús i gceann nóiméid.",
|
||||
"{name} was left out: made for another Horizon version": "Fágadh {name} amach: déanta do leagan eile de Horizon",
|
||||
"{name} was left out: clashes with {other}": "Fágadh {name} amach: tagann sé salach ar {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Oscail i bhfuinneog phríobháideach",
|
||||
"Open this link in a private window to pick another account.": "Oscail an nasc seo i bhfuinneog phríobháideach chun cuntas eile a roghnú.",
|
||||
"Use a different Microsoft account": "Úsáid cuntas Microsoft eile",
|
||||
"Buy": "Ceannaigh",
|
||||
"Gift": "Tabhair mar bhronntanas",
|
||||
"Looks": "Stíleanna"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Dèan tagradh air an t-seata stèidheadair",
|
||||
"Claiming…": "A' tagradh…",
|
||||
"The shop didn't load.": "Cha do luchdaich a' bhùth.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Rudan airson do chluicheadair a chaitheamh, a chì a h-uile duine a chluicheas air Horizon.",
|
||||
"Free for founders": "An-asgaidh dha luchd-stèidheachaidh",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Dha na cluicheadairean a bha an seo an toiseach. An-asgaidh airson tagradh; nuair a thagras tu e, bidh e agad gu bràth.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Cuir crìoch air a' phàigheadh an-asgaidh sa bhrobhsair agad. Nochdaidh an seata an seo greis às deidh sin.",
|
||||
"{n} items": "{n} nithean",
|
||||
"Nothing is on sale right now.": "Chan eil dad ri reic an-dràsta.",
|
||||
"Your basket": "Do bhasgaid",
|
||||
"Your basket is empty.": "Tha do bhasgaid falamh.",
|
||||
"Add to basket": "Cuir ris a' bhasgaid",
|
||||
"Total": "Iomlan",
|
||||
"This is a gift": "'S e tiodhlac a th' ann",
|
||||
"Your friend's Minecraft name": "Ainm Minecraft do charaid",
|
||||
"Find": "Lorg",
|
||||
"For {name}": "Do {name}",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Faighnich dhen neach a phàigheas mus ceannaich thu. Ais-dhìoladh taobh a-staigh 14 làithean air adhbhar sam bith.",
|
||||
"Pay {price} in your browser": "Pàigh {price} sa bhrabhsair",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Fosglaidh pàigheadh Paddle sa bhrabhsair agad. Cha tèid dad a thogail san lòinsear.",
|
||||
"US sales tax is added where a state charges it.": "Thèid cìs reic nan SA a chur ris far an tog stàit i.",
|
||||
"VAT included where it applies.": "VAT na bhroinn far a bheil i iomchaidh.",
|
||||
"In a set in your basket": "Ann an seata sa bhasgaid agad",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Crìochnaich am pàigheadh sa bhrabhsair. Ruigidh na nithean agad am Preas-aodaich an ceann greis.",
|
||||
"{name} was left out: made for another Horizon version": "Chaidh {name} fhàgail às: air a dhèanamh airson tionndadh eile de Horizon",
|
||||
"{name} was left out: clashes with {other}": "Chaidh {name} fhàgail às: tha e a' bualadh air {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Fosgail ann an uinneag phrìobhaideach",
|
||||
"Open this link in a private window to pick another account.": "Fosgail an ceangal seo ann an uinneag phrìobhaideach gus cunntas eile a thaghadh.",
|
||||
"Use a different Microsoft account": "Cleachd cunntas Microsoft eile",
|
||||
"Buy": "Ceannaich",
|
||||
"Gift": "Thoir mar thiodhlac",
|
||||
"Looks": "Coltasan"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Reclama o conxunto do fundador",
|
||||
"Claiming…": "Reclamando…",
|
||||
"The shop didn't load.": "A tenda non se cargou.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Cousas para levar co teu xogador, que ve todo o mundo que xoga 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 os xogadores que estiveron aquí primeiro. Gratis para reclamar; unha vez reclamado, é teu para sempre.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Remata o pagamento gratuíto no navegador. O set chegará aquí un momento despois.",
|
||||
"{n} items": "{n} obxectos",
|
||||
"Nothing is on sale right now.": "Agora mesmo non hai nada á venda.",
|
||||
"Your basket": "A túa cesta",
|
||||
"Your basket is empty.": "A túa cesta está baleira.",
|
||||
"Add to basket": "Engadir á cesta",
|
||||
"Total": "Total",
|
||||
"This is a gift": "É un agasallo",
|
||||
"Your friend's Minecraft name": "O nome de Minecraft do teu amigo",
|
||||
"Find": "Buscar",
|
||||
"For {name}": "Para {name}",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Pregunta a quen paga antes de mercar. Reembolsos en 14 días por calquera motivo.",
|
||||
"Pay {price} in your browser": "Pagar {price} no navegador",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "O pagamento de Paddle ábrese no teu navegador. Non se cobra nada no lanzador.",
|
||||
"US sales tax is added where a state charges it.": "Engádese o imposto sobre vendas dos EUA onde un estado o cobra.",
|
||||
"VAT included where it applies.": "IVE incluído onde se aplica.",
|
||||
"In a set in your basket": "Nun conxunto da túa cesta",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Remata o pagamento no navegador. Os teus obxectos chegan ao Armario un momento despois.",
|
||||
"{name} was left out: made for another Horizon version": "Deixouse fóra {name}: feito para outra versión de Horizon",
|
||||
"{name} was left out: clashes with {other}": "Deixouse fóra {name}: choca con {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Abrir nunha xanela privada",
|
||||
"Open this link in a private window to pick another account.": "Abre esta ligazón nunha xanela privada para escoller outra conta.",
|
||||
"Use a different Microsoft account": "Usar outra conta de Microsoft",
|
||||
"Buy": "Mercar",
|
||||
"Gift": "Agasallar",
|
||||
"Looks": "Estilos"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "E koi i ka mea hoʻokumu",
|
||||
"Claiming…": "E loaʻa ana…",
|
||||
"The shop didn't load.": "ʻAʻole i hoʻouka ʻia ka hale kūʻai.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Nā mea e ʻaʻahu ai no kāu mea pāʻani, ʻike ʻia e ka poʻe a pau e pāʻani ana ma Horizon.",
|
||||
"Free for founders": "Manuahi no ka poʻe hoʻokumu",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "No nā mea pāʻani i hiki mua mai. Manuahi e loaʻa; i ka wā i loaʻa ai, nou mau loa.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "E hoʻopau i ka uku manuahi ma kāu polokalamu kele pūnaewele. E ʻike ʻia ana ka pūʻulu ma ʻaneʻi ma hope koke iho.",
|
||||
"{n} items": "{n} mau mea",
|
||||
"Nothing is on sale right now.": "ʻAʻohe mea e kūʻai ʻia ana i kēia manawa.",
|
||||
"Your basket": "Kāu hīnaʻi",
|
||||
"Your basket is empty.": "Ua hakahaka kāu hīnaʻi.",
|
||||
"Add to basket": "Hoʻokomo i ka hīnaʻi",
|
||||
"Total": "Huina",
|
||||
"This is a gift": "He makana kēia",
|
||||
"Your friend's Minecraft name": "Ka inoa Minecraft o kou hoaaloha",
|
||||
"Find": "Huli",
|
||||
"For {name}": "No {name}",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "E nīnau i ka mea e uku ana ma mua o kou kūʻai ʻana. Hoʻihoʻi kālā i loko o 14 lā no kekahi kumu.",
|
||||
"Pay {price} in your browser": "Uku i {price} ma kāu polokalamu pūnaewele",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Wehe ʻia ka uku Paddle ma kāu polokalamu pūnaewele. ʻAʻohe mea i uku ʻia ma ka launcher.",
|
||||
"US sales tax is added where a state charges it.": "Hoʻohui ʻia ka ʻauhau kūʻai US ma kahi e ʻohi ai ka mokuʻāina.",
|
||||
"VAT included where it applies.": "Komo pū ka VAT ma kahi e pili ai.",
|
||||
"In a set in your basket": "I loko o kahi pūʻulu ma kāu hīnaʻi",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "E hoʻopau i ka uku ma kāu polokalamu pūnaewele. E hiki kāu mau mea i ka Lole ma hope iki.",
|
||||
"{name} was left out: made for another Horizon version": "Ua waiho ʻia ʻo {name}: hana ʻia no kekahi mana ʻē aʻe o Horizon",
|
||||
"{name} was left out: clashes with {other}": "Ua waiho ʻia ʻo {name}: kūʻē ia me {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "E wehe i loko o ka puka makani pilikino",
|
||||
"Open this link in a private window to pick another account.": "E wehe i kēia loulou ma kahi puka makani pilikino e koho ai i kekahi moʻokāki ʻē aʻe.",
|
||||
"Use a different Microsoft account": "E hoʻohana i kahi moʻokāki Microsoft ʻē aʻe",
|
||||
"Buy": "Kūʻai",
|
||||
"Gift": "Hāʻawi makana",
|
||||
"Looks": "Nā helehelena"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.": "סיימו לשלם בדפדפן. הפריטים יגיעו לארון הבגדים בעוד רגע.",
|
||||
"{name} was left out: made for another Horizon version": "{name} הושמט: נוצר לגרסה אחרת של Horizon",
|
||||
"{name} was left out: clashes with {other}": "{name} הושמט: מתנגש עם {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "פתח בחלון פרטי",
|
||||
"Open this link in a private window to pick another account.": "פתח את הקישור הזה בחלון פרטי כדי לבחור חשבון אחר.",
|
||||
"Use a different Microsoft account": "השתמש בחשבון Microsoft אחר",
|
||||
"Buy": "קנייה",
|
||||
"Gift": "מתנה",
|
||||
"Looks": "מראות"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.": "जहाँ लागू हो, वहाँ VAT शामिल है।",
|
||||
"In a set in your basket": "आपकी टोकरी के एक सेट में",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "ब्राउज़र में भुगतान पूरा करें। आपकी चीज़ें थोड़ी देर में वार्डरोब में आ जाएँगी।",
|
||||
"{name} was left out: made for another Horizon version": "{name} छोड़ दिया गया: Horizon के किसी दूसरे संस्करण के लिए बना है",
|
||||
"{name} was left out: clashes with {other}": "{name} छोड़ दिया गया: {other} से टकराता है",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "निजी विंडो में खोलें",
|
||||
"Open this link in a private window to pick another account.": "दूसरा खाता चुनने के लिए इस लिंक को निजी विंडो में खोलें।",
|
||||
"Use a different Microsoft account": "दूसरे Microsoft खाते का उपयोग करें",
|
||||
"Buy": "खरीदें",
|
||||
"Gift": "उपहार दें",
|
||||
"Looks": "लुक"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Preuzmi osnivački set",
|
||||
"Claiming…": "Preuzimanje…",
|
||||
"The shop didn't load.": "Trgovina se nije učitala.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Stvari koje tvoj igrač nosi, 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 ovdje prvi. Besplatno; kad ga preuzmeš, tvoj je zauvijek.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Završi besplatno plaćanje u pregledniku. Set će se ovdje pojaviti za trenutak.",
|
||||
"{n} items": "{n} predmeta",
|
||||
"Nothing is on sale right now.": "Trenutačno se ništa ne prodaje.",
|
||||
"Your basket": "Vaša košarica",
|
||||
"Your basket is empty.": "Vaša košarica je prazna.",
|
||||
"Add to basket": "Dodaj u košaricu",
|
||||
"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 tko plaća prije kupnje. 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.": "Paddleovo plaćanje otvara se 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 savezna 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 košarici",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Dovršite plaćanje u pregledniku. Predmeti stižu u Garderobu za trenutak.",
|
||||
"{name} was left out: made for another Horizon version": "{name} je izostavljen: napravljen za drugu verziju Horizona",
|
||||
"{name} was left out: clashes with {other}": "{name} je izostavljen: sukobljava se s {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Otvori u privatnom prozoru",
|
||||
"Open this link in a private window to pick another account.": "Otvorite ovu poveznicu u privatnom prozoru da biste odabrali drugi račun.",
|
||||
"Use a different Microsoft account": "Koristi drugi Microsoft račun",
|
||||
"Buy": "Kupi",
|
||||
"Gift": "Pokloni",
|
||||
"Looks": "Izgledi"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Igényelje az alapítókészletet",
|
||||
"Claiming…": "Beváltás…",
|
||||
"The shop didn't load.": "A bolt nem töltődött be.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Viselnivaló a játékosodnak, amit mindenki lát, aki Horizonon játszik.",
|
||||
"Free for founders": "Ingyenes az alapítóknak",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Azoknak, akik elsőként voltak itt. Ingyenes; ha beváltod, végleg a tiéd.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Fejezd be az ingyenes fizetést a böngészőben. A szett egy pillanattal később megjelenik itt.",
|
||||
"{n} items": "{n} tárgy",
|
||||
"Nothing is on sale right now.": "Most semmi sincs eladó.",
|
||||
"Your basket": "Kosarad",
|
||||
"Your basket is empty.": "A kosarad üres.",
|
||||
"Add to basket": "Kosárba",
|
||||
"Total": "Összesen",
|
||||
"This is a gift": "Ez ajándék",
|
||||
"Your friend's Minecraft name": "A barátod Minecraft-neve",
|
||||
"Find": "Keresés",
|
||||
"For {name}": "{name} részére",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Vásárlás előtt kérdezd meg, aki fizet. Visszatérítés 14 napon belül, bármilyen okból.",
|
||||
"Pay {price} in your browser": "{price} fizetése a böngészőben",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "A Paddle fizetése a böngésződben nyílik meg. A launcherben semmit sem terhelünk.",
|
||||
"US sales tax is added where a state charges it.": "Az amerikai forgalmi adó ott adódik hozzá, ahol egy állam felszámítja.",
|
||||
"VAT included where it applies.": "Az áfa benne van, ahol érvényes.",
|
||||
"In a set in your basket": "Egy készletben a kosaradban",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Fejezd be a fizetést a böngészőben. A tárgyaid kicsit később megjelennek a Ruhatárban.",
|
||||
"{name} was left out: made for another Horizon version": "{name} kimaradt: a Horizon egy másik verziójához készült",
|
||||
"{name} was left out: clashes with {other}": "{name} kimaradt: ütközik ezzel: {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Megnyitás privát ablakban",
|
||||
"Open this link in a private window to pick another account.": "Nyisd meg ezt a linket privát ablakban, hogy másik fiókot válassz.",
|
||||
"Use a different Microsoft account": "Másik Microsoft-fiók használata",
|
||||
"Buy": "Vásárlás",
|
||||
"Gift": "Ajándékozás",
|
||||
"Looks": "Szettek"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.": "Ավարտեք վճարումը դիտարկիչում։ Իրերը Զգեստապահարան կհասնեն մի պահ անց։",
|
||||
"{name} was left out: made for another Horizon version": "{name}-ը բաց թողնվեց. ստեղծված է Horizon-ի այլ տարբերակի համար",
|
||||
"{name} was left out: clashes with {other}": "{name}-ը բաց թողնվեց. բախվում է {other}-ի հետ",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Բացել մասնավոր պատուհանում",
|
||||
"Open this link in a private window to pick another account.": "Բացեք այս հղումը մասնավոր պատուհանում՝ մեկ այլ հաշիվ ընտրելու համար:",
|
||||
"Use a different Microsoft account": "Օգտագործել այլ Microsoft-ի հաշիվ",
|
||||
"Buy": "Գնել",
|
||||
"Gift": "Նվիրել",
|
||||
"Looks": "Կերպարներ"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Klaim set pendiri",
|
||||
"Claiming…": "Mengklaim…",
|
||||
"The shop didn't load.": "Toko gagal dimuat.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Barang yang dipakai pemainmu, terlihat oleh semua yang bermain di Horizon.",
|
||||
"Free for founders": "Gratis untuk pendiri",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Untuk pemain yang datang lebih dulu. Gratis; setelah diklaim, jadi milikmu.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Selesaikan checkout gratis di browser-mu. Set akan muncul di sini sesaat kemudian.",
|
||||
"{n} items": "{n} item",
|
||||
"Nothing is on sale right now.": "Saat ini tidak ada yang dijual.",
|
||||
"Your basket": "Keranjangmu",
|
||||
"Your basket is empty.": "Keranjangmu kosong.",
|
||||
"Add to basket": "Tambahkan ke keranjang",
|
||||
"Total": "Total",
|
||||
"This is a gift": "Ini hadiah",
|
||||
"Your friend's Minecraft name": "Nama Minecraft temanmu",
|
||||
"Find": "Cari",
|
||||
"For {name}": "Untuk {name}",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Tanyakan kepada yang membayar sebelum membeli. Pengembalian dana dalam 14 hari dengan alasan apa pun.",
|
||||
"Pay {price} in your browser": "Bayar {price} di browser",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Pembayaran Paddle terbuka di browsermu. Tidak ada yang ditagih di launcher.",
|
||||
"US sales tax is added where a state charges it.": "Pajak penjualan AS ditambahkan jika negara bagian memungutnya.",
|
||||
"VAT included where it applies.": "PPN sudah termasuk jika berlaku.",
|
||||
"In a set in your basket": "Dalam set di keranjangmu",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Selesaikan pembayaran di browser. Itemmu masuk ke Lemari sebentar lagi.",
|
||||
"{name} was left out: made for another Horizon version": "{name} tidak disertakan: dibuat untuk versi Horizon lain",
|
||||
"{name} was left out: clashes with {other}": "{name} tidak disertakan: bentrok dengan {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Buka di jendela pribadi",
|
||||
"Open this link in a private window to pick another account.": "Buka tautan ini di jendela pribadi untuk memilih akun lain.",
|
||||
"Use a different Microsoft account": "Gunakan akun Microsoft lain",
|
||||
"Buy": "Beli",
|
||||
"Gift": "Hadiahkan",
|
||||
"Looks": "Tampilan"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Chọọ ntọala ntọala",
|
||||
"Claiming…": "Na-anata…",
|
||||
"The shop didn't load.": "Ụlọ ahịa ahụ ebubataghị.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Ihe ị ga-eyi maka onye egwuregwu gị, nke onye ọ bụla na-egwu Horizon na-ahụ.",
|
||||
"Free for founders": "N'efu maka ndị malitere",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Maka ndị egwuregwu nọ ebe a mbụ. Nweta ya n'efu; ozugbo i nwetara ya, ọ bụ nke gị mgbe niile.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Mechaa ịkwụ ụgwọ n'efu na browser gị. Ngwugwu ahụ ga-abịa ebe a obere oge mgbe ahụ gasịrị.",
|
||||
"{n} items": "Ihe {n}",
|
||||
"Nothing is on sale right now.": "Ọ dịghị ihe a na-ere ugbu a.",
|
||||
"Your basket": "Nkata gị",
|
||||
"Your basket is empty.": "Nkata gị tọgbọrọ chakoo.",
|
||||
"Add to basket": "Tinye na nkata",
|
||||
"Total": "Ngụkọta",
|
||||
"This is a gift": "Nke a bụ onyinye",
|
||||
"Your friend's Minecraft name": "Aha Minecraft enyi gị",
|
||||
"Find": "Chọta",
|
||||
"For {name}": "Maka {name}",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Jụọ onye na-akwụ ụgwọ tupu ị zụta. Nkwụghachi n'ime ụbọchị 14 maka ihe ọ bụla kpatara ya.",
|
||||
"Pay {price} in your browser": "Kwụọ {price} na ihe nchọgharị gị",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Ịkwụ ụgwọ Paddle na-emepe na ihe nchọgharị gị. Ọ dịghị ihe a na-ana na launcher.",
|
||||
"US sales tax is added where a state charges it.": "A na-agbakwunye ụtụ ahịa US ebe steeti na-ana ya.",
|
||||
"VAT included where it applies.": "VAT gụnyere ebe ọ dị.",
|
||||
"In a set in your basket": "N'otu set dị na nkata gị",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Mechaa ịkwụ ụgwọ na ihe nchọgharị gị. Ihe gị ga-abata na Wardrobe n'oge na-adịghị anya.",
|
||||
"{name} was left out: made for another Horizon version": "A hapụrụ {name}: e mere ya maka ụdị Horizon ọzọ",
|
||||
"{name} was left out: clashes with {other}": "A hapụrụ {name}: ọ na-emegide {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Mepee na windo nzuzo",
|
||||
"Open this link in a private window to pick another account.": "Mepee njikọ a na windo nzuzo ka ịhọrọ akaụntụ ọzọ.",
|
||||
"Use a different Microsoft account": "Jiri akaụntụ Microsoft ọzọ",
|
||||
"Buy": "Zụta",
|
||||
"Gift": "Nye onyinye",
|
||||
"Looks": "Ọdịdị"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Gerðu tilkall til stofnendasettsins",
|
||||
"Claiming…": "Sæki…",
|
||||
"The shop didn't load.": "Verslunin hlóðst ekki.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Hlutir til að klæðast fyrir spilarann þinn, sýnilegir öllum sem spila á Horizon.",
|
||||
"Free for founders": "Ókeypis fyrir stofnendur",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Fyrir spilarana sem voru hér fyrst. Ókeypis að sækja; þegar þú hefur sótt það er það þitt til frambúðar.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Ljúktu ókeypis greiðslunni í vafranum þínum. Settið birtist hér augnabliki síðar.",
|
||||
"{n} items": "{n} hlutir",
|
||||
"Nothing is on sale right now.": "Ekkert er til sölu núna.",
|
||||
"Your basket": "Karfan þín",
|
||||
"Your basket is empty.": "Karfan þín er tóm.",
|
||||
"Add to basket": "Setja í körfu",
|
||||
"Total": "Samtals",
|
||||
"This is a gift": "Þetta er gjöf",
|
||||
"Your friend's Minecraft name": "Minecraft-nafn vinar þíns",
|
||||
"Find": "Finna",
|
||||
"For {name}": "Fyrir {name}",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Spyrðu þann sem borgar áður en þú kaupir. Endurgreiðsla innan 14 daga af hvaða ástæðu sem er.",
|
||||
"Pay {price} in your browser": "Greiða {price} í vafranum",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Greiðsla Paddle opnast í vafranum þínum. Ekkert er rukkað í ræsinum.",
|
||||
"US sales tax is added where a state charges it.": "Bandarískur söluskattur bætist við þar sem ríki innheimtir hann.",
|
||||
"VAT included where it applies.": "VSK innifalinn þar sem hann á við.",
|
||||
"In a set in your basket": "Í setti í körfunni þinni",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Ljúktu greiðslunni í vafranum. Hlutirnir koma í Fataskápinn augnabliki síðar.",
|
||||
"{name} was left out: made for another Horizon version": "{name} var sleppt: gert fyrir aðra útgáfu af Horizon",
|
||||
"{name} was left out: clashes with {other}": "{name} var sleppt: rekst á {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Opna í huliðsglugga",
|
||||
"Open this link in a private window to pick another account.": "Opnaðu þennan tengil í huliðsglugga til að velja annan reikning.",
|
||||
"Use a different Microsoft account": "Nota annan Microsoft reikning",
|
||||
"Buy": "Kaupa",
|
||||
"Gift": "Gefa",
|
||||
"Looks": "Útlit"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Rivendica il set fondatore",
|
||||
"Claiming…": "Riscatto in corso…",
|
||||
"The shop didn't load.": "Il negozio non si è caricato.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Cose da indossare per il tuo giocatore, viste da tutti quelli che giocano su Horizon.",
|
||||
"Free for founders": "Gratis per i fondatori",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Per i giocatori che c'erano fin dall'inizio. Gratis da riscattare; una volta riscattato, è tuo per sempre.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Completa il pagamento gratuito nel browser. Il set arriva qui un attimo dopo.",
|
||||
"{n} items": "{n} oggetti",
|
||||
"Nothing is on sale right now.": "Al momento non c'è niente in vendita.",
|
||||
"Your basket": "Il tuo carrello",
|
||||
"Your basket is empty.": "Il tuo carrello è vuoto.",
|
||||
"Add to basket": "Aggiungi al carrello",
|
||||
"Total": "Totale",
|
||||
"This is a gift": "È un regalo",
|
||||
"Your friend's Minecraft name": "Il nome Minecraft del tuo amico",
|
||||
"Find": "Cerca",
|
||||
"For {name}": "Per {name}",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Chiedi a chi paga prima di comprare. Rimborsi entro 14 giorni per qualsiasi motivo.",
|
||||
"Pay {price} in your browser": "Paga {price} nel browser",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Il pagamento di Paddle si apre nel tuo browser. Nel launcher non viene addebitato nulla.",
|
||||
"US sales tax is added where a state charges it.": "L'imposta sulle vendite USA si aggiunge dove uno stato la applica.",
|
||||
"VAT included where it applies.": "IVA inclusa dove si applica.",
|
||||
"In a set in your basket": "In un set nel tuo carrello",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Completa il pagamento nel browser. I tuoi oggetti arrivano nel Guardaroba poco dopo.",
|
||||
"{name} was left out: made for another Horizon version": "{name} è stato escluso: fatto per un'altra versione di Horizon",
|
||||
"{name} was left out: clashes with {other}": "{name} è stato escluso: va in conflitto con {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Apri in una finestra anonima",
|
||||
"Open this link in a private window to pick another account.": "Apri questo collegamento in una finestra anonima per scegliere un altro account.",
|
||||
"Use a different Microsoft account": "Usa un altro account Microsoft",
|
||||
"Buy": "Acquista",
|
||||
"Gift": "Regala",
|
||||
"Looks": "Look"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.": "該当する場合は VAT 込みです。",
|
||||
"In a set in your basket": "カート内のセットに含まれています",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "ブラウザで支払いを済ませてください。アイテムは少しあとにワードローブに届きます。",
|
||||
"{name} was left out: made for another Horizon version": "{name} は除外されました: 別のバージョンの Horizon 向けです",
|
||||
"{name} was left out: clashes with {other}": "{name} は除外されました: {other} と競合します",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "プライベートウィンドウで開く",
|
||||
"Open this link in a private window to pick another account.": "別のアカウントを選ぶには、このリンクをプライベートウィンドウで開いてください。",
|
||||
"Use a different Microsoft account": "別の Microsoft アカウントを使用",
|
||||
"Buy": "購入",
|
||||
"Gift": "ギフト",
|
||||
"Looks": "コーデ"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.": "დაასრულეთ გადახდა ბრაუზერში. ნივთები გარდერობში ცოტა ხანში მოვა.",
|
||||
"{name} was left out: made for another Horizon version": "{name} გამოტოვებულია: შექმნილია Horizon-ის სხვა ვერსიისთვის",
|
||||
"{name} was left out: clashes with {other}": "{name} გამოტოვებულია: კონფლიქტშია {other}-თან",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "გახსნა პირად ფანჯარაში",
|
||||
"Open this link in a private window to pick another account.": "სხვა ანგარიშის ასარჩევად გახსენით ეს ბმული პირად ფანჯარაში.",
|
||||
"Use a different Microsoft account": "გამოიყენეთ სხვა Microsoft-ის ანგარიში",
|
||||
"Buy": "ყიდვა",
|
||||
"Gift": "ჩუქება",
|
||||
"Looks": "იერსახეები"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.": "Төлемді браузерде аяқтаңыз. Заттарыңыз Гардеробқа сәлден соң келеді.",
|
||||
"{name} was left out: made for another Horizon version": "{name} алынып тасталды: Horizon-ның басқа нұсқасына жасалған",
|
||||
"{name} was left out: clashes with {other}": "{name} алынып тасталды: {other} модымен қайшылықта",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Жеке терезеде ашу",
|
||||
"Open this link in a private window to pick another account.": "Басқа тіркелгіні таңдау үшін бұл сілтемені жеке терезеде ашыңыз.",
|
||||
"Use a different Microsoft account": "Басқа Microsoft тіркелгісін пайдалану",
|
||||
"Buy": "Сатып алу",
|
||||
"Gift": "Сыйлау",
|
||||
"Looks": "Бейнелер"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.": "ಅನ್ವಯಿಸುವಲ್ಲಿ VAT ಸೇರಿದೆ.",
|
||||
"In a set in your basket": "ನಿಮ್ಮ ಬುಟ್ಟಿಯ ಸೆಟ್ನಲ್ಲಿ",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "ಬ್ರೌಸರ್ನಲ್ಲಿ ಪಾವತಿ ಮುಗಿಸಿ. ನಿಮ್ಮ ವಸ್ತುಗಳು ಸ್ವಲ್ಪ ಹೊತ್ತಿನಲ್ಲಿ ವಾರ್ಡ್ರೋಬ್ಗೆ ಬರುತ್ತವೆ.",
|
||||
"{name} was left out: made for another Horizon version": "{name} ಅನ್ನು ಬಿಡಲಾಗಿದೆ: Horizon ನ ಬೇರೆ ಆವೃತ್ತಿಗಾಗಿ ಮಾಡಲಾಗಿದೆ",
|
||||
"{name} was left out: clashes with {other}": "{name} ಅನ್ನು ಬಿಡಲಾಗಿದೆ: {other} ಜೊತೆ ಘರ್ಷಿಸುತ್ತದೆ",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "ಖಾಸಗಿ ವಿಂಡೋದಲ್ಲಿ ತೆರೆಯಿರಿ",
|
||||
"Open this link in a private window to pick another account.": "ಬೇರೆ ಖಾತೆಯನ್ನು ಆಯ್ಕೆ ಮಾಡಲು ಈ ಲಿಂಕ್ ಅನ್ನು ಖಾಸಗಿ ವಿಂಡೋದಲ್ಲಿ ತೆರೆಯಿರಿ.",
|
||||
"Use a different Microsoft account": "ಬೇರೆ Microsoft ಖಾತೆಯನ್ನು ಬಳಸಿ",
|
||||
"Buy": "ಖರೀದಿಸಿ",
|
||||
"Gift": "ಉಡುಗೊರೆ ನೀಡಿ",
|
||||
"Looks": "ಲುಕ್ಗಳು"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.": "해당하는 경우 VAT가 포함됩니다.",
|
||||
"In a set in your basket": "장바구니의 세트에 포함됨",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "브라우저에서 결제를 마치세요. 아이템은 잠시 후 옷장에 들어옵니다.",
|
||||
"{name} was left out: made for another Horizon version": "{name}이(가) 제외되었습니다: 다른 Horizon 버전용입니다",
|
||||
"{name} was left out: clashes with {other}": "{name}이(가) 제외되었습니다: {other}와(과) 충돌합니다",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "시크릿 창에서 열기",
|
||||
"Open this link in a private window to pick another account.": "다른 계정을 선택하려면 이 링크를 시크릿 창에서 여세요.",
|
||||
"Use a different Microsoft account": "다른 Microsoft 계정 사용",
|
||||
"Buy": "구매",
|
||||
"Gift": "선물하기",
|
||||
"Looks": "룩"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.": "Төлөмдү браузерде бүтүрүңүз. Буюмдарыңыз Гардеробго бир аздан кийин келет.",
|
||||
"{name} was left out: made for another Horizon version": "{name} чыгарылып калды: Horizon'дун башка версиясы үчүн жасалган",
|
||||
"{name} was left out: clashes with {other}": "{name} чыгарылып калды: {other} менен кагылышат",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Купуя терезеде ачуу",
|
||||
"Open this link in a private window to pick another account.": "Башка эсепти тандоо үчүн бул шилтемени купуя терезеде ачыңыз.",
|
||||
"Use a different Microsoft account": "Башка Microsoft эсебин колдонуу",
|
||||
"Buy": "Сатып алуу",
|
||||
"Gift": "Белек кылуу",
|
||||
"Looks": "Образдар"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Set dici auctor",
|
||||
"Claiming…": "Vindicatur…",
|
||||
"The shop didn't load.": "Taberna non est onerata.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Res quas lusor tuus gerat, ab omnibus visae qui in Horizon ludunt.",
|
||||
"Free for founders": "Gratis conditoribus",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Lusoribus qui primi hic fuerunt. Gratis vindicare; semel vindicatum, tuum semper est.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Solutionem gratuitam in navigatro tuo perfice. Paulo post copia hic apparebit.",
|
||||
"{n} items": "{n} res",
|
||||
"Nothing is on sale right now.": "Nihil nunc venit.",
|
||||
"Your basket": "Corbis tua",
|
||||
"Your basket is empty.": "Corbis tua vacua est.",
|
||||
"Add to basket": "In corbem adde",
|
||||
"Total": "Summa",
|
||||
"This is a gift": "Donum est",
|
||||
"Your friend's Minecraft name": "Nomen Minecraft amici tui",
|
||||
"Find": "Quaere",
|
||||
"For {name}": "Pro {name}",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Roga eum qui solvit antequam emas. Pecunia redditur intra 14 dies quavis de causa.",
|
||||
"Pay {price} in your browser": "Solve {price} in navigatro",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Solutio Paddle in navigatro tuo aperitur. Nihil in deductore exigitur.",
|
||||
"US sales tax is added where a state charges it.": "Vectigal venditionis Americanum additur ubi civitas id exigit.",
|
||||
"VAT included where it applies.": "VAT inclusum ubi valet.",
|
||||
"In a set in your basket": "In collectione in corbe tua",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Solutionem in navigatro perfice. Res tuae paulo post in Vestiarium venient.",
|
||||
"{name} was left out: made for another Horizon version": "{name} omissus est: factus ad aliam versionem Horizontis",
|
||||
"{name} was left out: clashes with {other}": "{name} omissus est: cum {other} confligit",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Aperi in fenestra privata",
|
||||
"Open this link in a private window to pick another account.": "Aperi hoc link in fenestra privata ut aliam rationem eligas.",
|
||||
"Use a different Microsoft account": "Aliam Microsoft rationem adhibe",
|
||||
"Buy": "Eme",
|
||||
"Gift": "Dona",
|
||||
"Looks": "Habitus"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Fuerderen de Grënner Set",
|
||||
"Claiming…": "Gëtt ofgeholl…",
|
||||
"The shop didn't load.": "D'Buttek huet sech net gelueden.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Saache fir Äre Spiller unzedoen, gesi vu jidderengem, deen op Horizon spillt.",
|
||||
"Free for founders": "Gratis fir Grënner",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Fir d'Spiller, déi als éischt hei waren. Gratis ofzehuelen; wann et ofgeholl ass, ass et Är fir ëmmer.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Schléisst de gratis Checkout an Ärem Browser of. De Set kënnt e Moment méi spéit hei un.",
|
||||
"{n} items": "{n} Saachen",
|
||||
"Nothing is on sale right now.": "Grad ass näischt ze kafen.",
|
||||
"Your basket": "Däi Kuerf",
|
||||
"Your basket is empty.": "Däi Kuerf ass eidel.",
|
||||
"Add to basket": "An de Kuerf",
|
||||
"Total": "Total",
|
||||
"This is a gift": "Dat ass e Kaddo",
|
||||
"Your friend's Minecraft name": "De Minecraft-Numm vun dengem Frënd",
|
||||
"Find": "Sichen",
|
||||
"For {name}": "Fir {name}",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Fro dee, dee bezilt, ier s de kafs. Remboursement bannent 14 Deeg aus all Grond.",
|
||||
"Pay {price} in your browser": "{price} am Browser bezuelen",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "D'Bezuelung vu Paddle mécht sech an dengem Browser op. Am Launcher gëtt näischt ofgebucht.",
|
||||
"US sales tax is added where a state charges it.": "US-Verkafssteier kënnt dobäi, wou e Bundesstaat se erhieft.",
|
||||
"VAT included where it applies.": "TVA abegraff, wou se gëllt.",
|
||||
"In a set in your basket": "An engem Set an dengem Kuerf",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Maach d'Bezuelung am Browser fäerdeg. Deng Saachen kommen e Moment duerno an d'Garderob.",
|
||||
"{name} was left out: made for another Horizon version": "{name} gouf ewechgelooss: fir eng aner Horizon-Versioun gemaach",
|
||||
"{name} was left out: clashes with {other}": "{name} gouf ewechgelooss: kollidéiert mat {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "An engem privaten Fënster opmaachen",
|
||||
"Open this link in a private window to pick another account.": "Maach dëse Link an engem privaten Fënster op, fir en anere Kont ze wielen.",
|
||||
"Use a different Microsoft account": "Aneren Microsoft Kont notzen",
|
||||
"Buy": "Kafen",
|
||||
"Gift": "Verschenken",
|
||||
"Looks": "Looks"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Claim de opriechterset",
|
||||
"Claiming…": "Aan 't opeise…",
|
||||
"The shop didn't load.": "De winkel is nit gelaje.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Dinger um aan te trekke veur dien speuler, gezeen door iederein die op Horizon speult.",
|
||||
"Free for founders": "Gratis veur oprichters",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Veur de speulers die der es eerste waore. Gratis op te eise; es 't opgeëis is, is 't veur altied van dich.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Rond de gratis checkout aaf in dien browser. De set kömp ei momentje later hie aan.",
|
||||
"{n} items": "{n} items",
|
||||
"Nothing is on sale right now.": "D'r is noe niks te koup.",
|
||||
"Your basket": "Diech mendje",
|
||||
"Your basket is empty.": "Diech mendje is laeg.",
|
||||
"Add to basket": "In 't mendje",
|
||||
"Total": "Totaal",
|
||||
"This is a gift": "Dit is e cadeau",
|
||||
"Your friend's Minecraft name": "De Minecraft-naam van diene vrund",
|
||||
"Find": "Zeuke",
|
||||
"For {name}": "Veur {name}",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Vraog wae betaalt veurdetse kóps. Geld trök binne 14 daag oet welke raeje auch.",
|
||||
"Pay {price} in your browser": "{price} betaole in diene browser",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "De betaling van Paddle geit ope in diene browser. In de launcher wuurt niks aafgeschreve.",
|
||||
"US sales tax is added where a state charges it.": "Amerikaanse verkoupbelasting kump drbie wo 'ne staot die hèf.",
|
||||
"VAT included where it applies.": "Btw inbegrepe wo die geldj.",
|
||||
"In a set in your basket": "In e set in diech mendje",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Maak de betaling aaf in diene browser. Dien items koume get later in de Kleijerkas.",
|
||||
"{name} was left out: made for another Horizon version": "{name} is weggelaote: gemaak veur 'n anger Horizon-versie",
|
||||
"{name} was left out: clashes with {other}": "{name} is weggelaote: bots mit {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Open in e privévinster",
|
||||
"Open this link in a private window to pick another account.": "Open dees link in e privévinster om un angere account te kieze.",
|
||||
"Use a different Microsoft account": "Gebruuk un angere Microsoft account",
|
||||
"Buy": "Koupe",
|
||||
"Gift": "Cadeau geve",
|
||||
"Looks": "Looks"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Rivendicà l’insema di fundatur",
|
||||
"Claiming…": "Ritiro in cors…",
|
||||
"The shop didn't load.": "El negozi l'ha minga cargà.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Roba da métes sü per el tò giugadór, vedüda da tücc quei che gioghen su Horizon.",
|
||||
"Free for founders": "Gratis per i fondador",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Per i giugadór che gh'eren per primm. Gratis da ritirà; ona volta ritirà, l'è tò per semper.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Finìss el pagament gratis in del to browser. El set el riva chì un moment dopo.",
|
||||
"{n} items": "{n} oggett",
|
||||
"Nothing is on sale right now.": "Adess gh'è nagott in vendita.",
|
||||
"Your basket": "El tò cavagn",
|
||||
"Your basket is empty.": "El tò cavagn l'è vœuj.",
|
||||
"Add to basket": "Giontà al cavagn",
|
||||
"Total": "Total",
|
||||
"This is a gift": "L'è on regal",
|
||||
"Your friend's Minecraft name": "El nom Minecraft del tò amis",
|
||||
"Find": "Cercà",
|
||||
"For {name}": "Per {name}",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Domanda a chi paga prima de crompà. Rimbors in 14 dì per qualsessia rason.",
|
||||
"Pay {price} in your browser": "Pagà {price} in del browser",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "El pagament de Paddle el se derva in del tò browser. In del launcher se paga nagott.",
|
||||
"US sales tax is added where a state charges it.": "La tassa sui vendit di USA la se gionta indove on stat la ciama.",
|
||||
"VAT included where it applies.": "IVA inclusa indove la se aplica.",
|
||||
"In a set in your basket": "In d'on set in del tò cavagn",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Finiss el pagament in del browser. I tò oggett riven in del Guardaroba on moment dopo.",
|
||||
"{name} was left out: made for another Horizon version": "{name} l'è staa lassaa foeura: faa per un'altra version de Horizon",
|
||||
"{name} was left out: clashes with {other}": "{name} l'è staa lassaa foeura: el va in conflit con {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Vèrt in d'una finestra privada",
|
||||
"Open this link in a private window to pick another account.": "Vèrt chèsto link in d'una finestra privada per sceglier un alter cunt.",
|
||||
"Use a different Microsoft account": "Dopera un alter cunt Microsoft",
|
||||
"Buy": "Crompà",
|
||||
"Gift": "Regalà",
|
||||
"Looks": "Stil"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.": "ລວມ VAT ບ່ອນທີ່ໃຊ້ໄດ້.",
|
||||
"In a set in your basket": "ຢູ່ໃນຊຸດໃນກະຕ່າຂອງທ່ານ",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "ຈ່າຍເງິນໃຫ້ແລ້ວໃນບຣາວເຊີ. ສິ່ງຂອງຂອງທ່ານຈະມາຮອດຕູ້ເສື້ອຜ້າໃນອີກບໍ່ດົນ.",
|
||||
"{name} was left out: made for another Horizon version": "{name} ຖືກປະໄວ້: ສ້າງມາສຳລັບ Horizon ລຸ້ນອື່ນ",
|
||||
"{name} was left out: clashes with {other}": "{name} ຖືກປະໄວ້: ຂັດກັບ {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "ເປີດໃນໜ້າຕ່າງສ່ວນຕົວ",
|
||||
"Open this link in a private window to pick another account.": "ເປີດລິ້ງນີ້ໃນໜ້າຕ່າງສ່ວນຕົວເພື່ອເລືອກບັນຊີອື່ນ.",
|
||||
"Use a different Microsoft account": "ໃຊ້ບັນຊີ Microsoft ອື່ນ",
|
||||
"Buy": "ຊື້",
|
||||
"Gift": "ມອບເປັນຂອງຂວັນ",
|
||||
"Looks": "ລຸກ"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Pretenduokite į steigėjų rinkinį",
|
||||
"Claiming…": "Atsiimama…",
|
||||
"The shop didn't load.": "Parduotuvė neįsikėlė.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Daiktai tavo žaidėjui, kuriuos mato visi, žaidžiantys Horizon.",
|
||||
"Free for founders": "Nemokama įkūrėjams",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Žaidėjams, kurie čia buvo pirmieji. Nemokamai; atsiėmus lieka tau.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Užbaik nemokamą atsiskaitymą naršyklėje. Rinkinys čia pasirodys po akimirkos.",
|
||||
"{n} items": "{n} daiktai",
|
||||
"Nothing is on sale right now.": "Šiuo metu niekas neparduodama.",
|
||||
"Your basket": "Jūsų krepšelis",
|
||||
"Your basket is empty.": "Jūsų krepšelis tuščias.",
|
||||
"Add to basket": "Į krepšelį",
|
||||
"Total": "Iš viso",
|
||||
"This is a gift": "Tai dovana",
|
||||
"Your friend's Minecraft name": "Jūsų draugo Minecraft vardas",
|
||||
"Find": "Rasti",
|
||||
"For {name}": "Gavėjui {name}",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Prieš pirkdami paklauskite to, kas moka. Pinigai grąžinami per 14 dienų dėl bet kokios priežasties.",
|
||||
"Pay {price} in your browser": "Mokėti {price} naršyklėje",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Paddle mokėjimas atsidaro jūsų naršyklėje. Paleidyklėje nieko nenuskaičiuojama.",
|
||||
"US sales tax is added where a state charges it.": "JAV pardavimo mokestis pridedamas ten, kur jį renka valstija.",
|
||||
"VAT included where it applies.": "PVM įskaičiuotas ten, kur taikomas.",
|
||||
"In a set in your basket": "Rinkinyje jūsų krepšelyje",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Užbaikite mokėjimą naršyklėje. Daiktai atkeliaus į Drabužinę po akimirkos.",
|
||||
"{name} was left out: made for another Horizon version": "{name} praleistas: sukurtas kitai Horizon versijai",
|
||||
"{name} was left out: clashes with {other}": "{name} praleistas: konfliktuoja su {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Atidaryti privačiame lange",
|
||||
"Open this link in a private window to pick another account.": "Atidarykite šią nuorodą privačiame lange, kad pasirinktumėte kitą paskyrą.",
|
||||
"Use a different Microsoft account": "Naudoti kitą „Microsoft“ paskyrą",
|
||||
"Buy": "Pirkti",
|
||||
"Gift": "Dovanoti",
|
||||
"Looks": "Įvaizdžiai"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"Claim the founder set": "Pieprasiet dibinātāja komplektu",
|
||||
"Claiming…": "Saņem…",
|
||||
"The shop didn't load.": "Veikals neielādējās.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Lietas, ko valkāt jūsu spēlētājam, kuras redz ikviens, kas spēlē Horizon.",
|
||||
"Free for founders": "Bezmaksas dibinātājiem",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Spēlētājiem, kas bija šeit pirmie. Saņemams bez maksas; kad esat to saņēmuši, tas ir jūsu uz visiem laikiem.",
|
||||
"Finish the free checkout in your browser. The set lands here a moment later.": "Pabeidziet bezmaksas norēķinu pārlūkprogrammā. Komplekts šeit parādīsies pēc brīža.",
|
||||
"{n} items": "{n} priekšmeti",
|
||||
"Nothing is on sale right now.": "Pašlaik nekas netiek pārdots.",
|
||||
"Your basket": "Jūsu grozs",
|
||||
"Your basket is empty.": "Jūsu grozs ir tukšs.",
|
||||
"Add to basket": "Pievienot grozam",
|
||||
"Total": "Kopā",
|
||||
"This is a gift": "Tā ir dāvana",
|
||||
"Your friend's Minecraft name": "Jūsu drauga Minecraft vārds",
|
||||
"Find": "Atrast",
|
||||
"For {name}": "Saņēmējam {name}",
|
||||
"Ask whoever pays before you buy. Refunds within 14 days for any reason.": "Pirms pērkat, pajautājiet tam, kurš maksā. Naudas atmaksa 14 dienu laikā jebkāda iemesla dēļ.",
|
||||
"Pay {price} in your browser": "Maksāt {price} pārlūkā",
|
||||
"Paddle's checkout opens in your browser. Nothing is charged in the launcher.": "Paddle maksājums atveras jūsu pārlūkā. Palaidējā nekas netiek iekasēts.",
|
||||
"US sales tax is added where a state charges it.": "ASV tirdzniecības nodoklis tiek pievienots, kur to iekasē štats.",
|
||||
"VAT included where it applies.": "PVN iekļauts, kur tas attiecas.",
|
||||
"In a set in your basket": "Komplektā jūsu grozā",
|
||||
"Finish paying in your browser. Your items reach the Wardrobe a moment later.": "Pabeidziet maksājumu pārlūkā. Jūsu priekšmeti pēc brīža nonāks Garderobē.",
|
||||
"{name} was left out: made for another Horizon version": "{name} tika izlaists: veidots citai Horizon versijai",
|
||||
"{name} was left out: clashes with {other}": "{name} tika izlaists: konfliktē ar {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Atvērt privātajā logā",
|
||||
"Open this link in a private window to pick another account.": "Atveriet šo saiti privātajā logā, lai izvēlētos citu kontu.",
|
||||
"Use a different Microsoft account": "Izmantot citu Microsoft kontu",
|
||||
"Buy": "Pirkt",
|
||||
"Gift": "Dāvināt",
|
||||
"Looks": "Tēli"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.": "Завршете го плаќањето во прелистувачот. Предметите стигнуваат во Гардеробата за момент.",
|
||||
"{name} was left out: made for another Horizon version": "{name} е изоставен: направен за друга верзија на Horizon",
|
||||
"{name} was left out: clashes with {other}": "{name} е изоставен: се судира со {other}",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Отвори во приватен прозорец",
|
||||
"Open this link in a private window to pick another account.": "Отворете ја оваа врска во приватен прозорец за да изберете друга сметка.",
|
||||
"Use a different Microsoft account": "Користи друга сметка на Microsoft",
|
||||
"Buy": "Купи",
|
||||
"Gift": "Подари",
|
||||
"Looks": "Изгледи"
|
||||
}
|
||||
+7
-10
@@ -1194,26 +1194,17 @@
|
||||
"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.": "Төлбөрөө хөтөч дээр дуусгаарай. Таны зүйлс хувцасны шүүгээнд удахгүй ирнэ.",
|
||||
"{name} was left out: made for another Horizon version": "{name}-г хассан: Horizon-ын өөр хувилбарт зориулагдсан",
|
||||
"{name} was left out: clashes with {other}": "{name}-г хассан: {other}-тэй зөрчилддөг",
|
||||
@@ -1253,5 +1244,11 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Open in private window": "Хувийн цонхонд нээх",
|
||||
"Open this link in a private window to pick another account.": "Өөр данс сонгохын тулд энэ холбоосыг хувийн цонхонд нээнэ үү.",
|
||||
"Use a different Microsoft account": "Өөр Microsoft данс ашиглах",
|
||||
"Buy": "Худалдаж авах",
|
||||
"Gift": "Бэлэглэх",
|
||||
"Looks": "Төрхүүд"
|
||||
}
|
||||
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user