Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6d002a723 | ||
|
|
85ca681ff1 | ||
|
|
22287141a5 | ||
|
|
6aa8930d3f | ||
|
|
f4262df3a2 | ||
|
|
09d0f1e1d8 | ||
|
|
d40955d303 | ||
|
|
e02a4bf0d3 | ||
|
|
c6292de19c | ||
|
|
802a858cc1 | ||
|
|
753594d0a0 | ||
|
|
341675a110 | ||
|
|
5f86c3c505 | ||
|
|
7df108a7be | ||
|
|
ec1b4198fe | ||
|
|
5da4b13265 | ||
|
|
81403ce81c | ||
|
|
3f87ea235e | ||
|
|
b65ebc85e2 | ||
|
|
0d7046557d | ||
|
|
2c32f22313 | ||
|
|
2620806a43 | ||
|
|
d956b41093 | ||
|
|
9253f573d8 | ||
|
|
4a53264476 | ||
|
|
64b7852431 | ||
|
|
cc4abd50ea | ||
|
|
e676b097eb | ||
|
|
1432581c19 | ||
|
|
adf39cbeb2 | ||
|
|
c67f07bbdb | ||
|
|
8295846dfd | ||
|
|
4d6f818c3a | ||
|
|
b1d131e6e3 | ||
|
|
582dc76fbd | ||
|
|
45ffc8628d | ||
|
|
889f3eddfe | ||
|
|
7372b5842e | ||
|
|
8455585a36 | ||
|
|
98b205d5de | ||
|
|
f2d12697f6 | ||
|
|
11b31e9c09 | ||
|
|
af902896d4 | ||
|
|
4d855f9ac0 | ||
|
|
106eec903f | ||
|
|
43f632aec8 | ||
|
|
b0cf285a76 | ||
|
|
bba6a462e0 | ||
|
|
90d6546eca | ||
|
|
480c65877f | ||
|
|
3498cc6165 | ||
|
|
5096ebc1dd | ||
|
|
93a3d53b3e | ||
|
|
82ccc27493 | ||
|
|
5adf98a327 | ||
|
|
77f967a80e | ||
|
|
5146537ec6 | ||
|
|
4489732c79 | ||
|
|
c57bf04727 | ||
|
|
38afa7e7d4 | ||
|
|
d7ff6beecf | ||
|
|
c99bd1a44d | ||
|
|
e81ac01453 | ||
|
|
31fe010646 | ||
|
|
adc19a8a07 | ||
|
|
ba43b0c40f | ||
|
|
26ff2b5e34 | ||
|
|
3639259761 | ||
|
|
c5d0a1c935 | ||
|
|
26415620ea | ||
|
|
ff4b01cd07 | ||
|
|
559baffb0c | ||
|
|
e52114437e | ||
|
|
e0c325ca5a | ||
|
|
93f178d4dd | ||
|
|
4552a50849 | ||
|
|
15583061c1 | ||
|
|
149e80cff5 | ||
|
|
e81f9ad646 | ||
|
|
1645a251ec | ||
|
|
b15db5c782 | ||
|
|
e1d257e6f1 | ||
|
|
12c5dc6169 | ||
|
|
3ef68721a8 | ||
|
|
ae487a0658 | ||
|
|
d1e15acd9b | ||
|
|
9f8a8ac0e6 | ||
|
|
ef1bbc2c19 | ||
|
|
a65582a876 | ||
|
|
6dbd14d78e | ||
|
|
b7fe922e7c | ||
|
|
61ea2cbca0 | ||
|
|
473bd8eb0e | ||
|
|
9d911fbe3e | ||
|
|
5f7cadfff0 | ||
|
|
74dd0ed4c3 | ||
|
|
7ab41043fa | ||
|
|
074be79a6a | ||
|
|
b080361402 | ||
|
|
99bd965511 | ||
|
|
1f9719f9b2 | ||
|
|
42cfe492f3 | ||
|
|
2e2dd80a9b | ||
|
|
ab1f82cf2b | ||
|
|
e6bc883aac | ||
|
|
d8a25fcb16 | ||
|
|
228fbb7d99 | ||
|
|
75b5ac8ec1 | ||
|
|
48683b1757 | ||
|
|
f720c33d24 | ||
|
|
41d5e253ca | ||
|
|
3bb3899088 | ||
|
|
b5249678a2 | ||
|
|
7b8eb4e6f4 | ||
|
|
2ffc0b65ad | ||
|
|
0ae809ff1a | ||
|
|
3a5555a05e | ||
|
|
b51c4fd973 | ||
|
|
2a33624c1e | ||
|
|
9bbcb50aed | ||
|
|
4cc2d109bb | ||
|
|
e7052b5cf9 | ||
|
|
0395b7220e | ||
|
|
dd6d2d09a2 | ||
|
|
dd0a1e8c39 | ||
|
|
84f8a89fce | ||
|
|
bab9bd9eec | ||
|
|
f511bc7488 | ||
|
|
43f69216d3 | ||
|
|
9bde691abc | ||
|
|
425652fce4 | ||
|
|
6651840801 | ||
|
|
2d75ab2e8f | ||
|
|
953512487b | ||
|
|
a9307ea8b7 | ||
|
|
6bf6b1e2c8 | ||
|
|
33f1f52d62 | ||
|
|
5ad154d184 | ||
|
|
243066eeca | ||
|
|
08528c929d | ||
|
|
2863d031b9 | ||
|
|
f780545a63 | ||
|
|
adba2e3fd0 | ||
|
|
39f398091b | ||
|
|
e2c5f32ad4 | ||
|
|
6c6aa33d33 | ||
|
|
4aeaf7d69e | ||
|
|
3ad431c6a3 | ||
|
|
dd2aa4c808 | ||
|
|
cd225f801e | ||
|
|
7344d153b0 | ||
|
|
88b8be3eea | ||
|
|
3417c795ec | ||
|
|
2d708acee1 | ||
|
|
38a989a235 | ||
|
|
4803ac509a | ||
|
|
5919b2ee37 | ||
|
|
a3ea52c7ae | ||
|
|
f606cb820c | ||
|
|
76ef5f41ee | ||
|
|
645e685dc3 | ||
|
|
b5fd86ea95 | ||
|
|
5b01bbb736 | ||
|
|
d86f9b8ae1 | ||
|
|
5c90dcf106 | ||
|
|
6a9479bd4e | ||
|
|
f41546e06e | ||
|
|
d543e34821 | ||
|
|
bdc89c7247 | ||
|
|
652bb95f02 | ||
|
|
785b64e2ea | ||
|
|
e905e18569 | ||
|
|
56777de07b | ||
|
|
12da8060d9 | ||
|
|
42eecc5c6b | ||
|
|
1fb2b9d0e7 | ||
|
|
02756b92c4 | ||
|
|
3ae016d664 | ||
|
|
789b488061 | ||
|
|
d491259de9 | ||
|
|
22f64bbd46 | ||
|
|
81c62c63a3 | ||
|
|
de76c6cfc6 | ||
|
|
16d1d18ef1 | ||
|
|
36a9541fb7 | ||
|
|
e3d4a12cf4 | ||
|
|
edefee0edb | ||
|
|
1a7b54a71b | ||
|
|
af30750fe9 | ||
|
|
fdaf42a7ae | ||
|
|
b40792ba62 | ||
|
|
24e1f1b376 | ||
|
|
61aa03d1ab | ||
|
|
d0da18c651 | ||
|
|
5717940f26 | ||
|
|
a8a83423be | ||
|
|
75d834b5f0 | ||
|
|
c98a51a664 | ||
|
|
9d55121379 | ||
|
|
3a90aecb12 | ||
|
|
524543dfa8 | ||
|
|
53a993c972 | ||
|
|
36c8983d68 | ||
|
|
39c43b82bd | ||
|
|
0b0aded17c | ||
|
|
d978845087 | ||
|
|
957eb14a5e | ||
|
|
601772b4fa | ||
|
|
6a61fdbe28 | ||
|
|
be8b04774f | ||
|
|
0698e8d46d | ||
|
|
9afeda6be3 | ||
|
|
89bb9243ab | ||
|
|
981b36ef36 | ||
|
|
c3f2a9928e | ||
|
|
5a8e4ea92c | ||
|
|
3427559f65 | ||
|
|
ecf510cfc2 | ||
|
|
8201422dc0 | ||
|
|
dec609bb7c | ||
|
|
d67da2cf27 | ||
|
|
4aa63389e5 | ||
|
|
92be6a71c2 | ||
|
|
f1d2c12e29 | ||
|
|
4e5296d180 | ||
|
|
d6bce553cc | ||
|
|
085e9f6064 | ||
|
|
2d5d714788 | ||
|
|
8b55208237 | ||
|
|
a4e0fc8a75 | ||
|
|
854ab1726c | ||
|
|
3254251593 | ||
|
|
e6c80be2c4 | ||
|
|
fc633db30b | ||
|
|
2bb5bb2965 | ||
|
|
5088717918 | ||
|
|
711dad1923 | ||
|
|
7bdd8c9ae1 | ||
|
|
39e6679121 | ||
|
|
c01554de38 | ||
|
|
2f86a8c15f | ||
|
|
31f22dbe88 | ||
|
|
67d42261fc | ||
|
|
07c9753b76 | ||
|
|
5d97cf0158 | ||
|
|
b79fd522cb | ||
|
|
5f5b78fb47 | ||
|
|
7983faa6ce | ||
|
|
36f20dd0aa | ||
|
|
de5617664b | ||
|
|
e9d179e183 | ||
|
|
e790fdeead | ||
|
|
7e78e51558 | ||
|
|
ee2e77db35 | ||
|
|
c0e2fb0c99 | ||
|
|
4442aaed80 | ||
|
|
d2c801b26f | ||
|
|
cca1842d3c | ||
|
|
2e237b9aa1 | ||
|
|
2bac359308 | ||
|
|
c2aaa3d39f | ||
|
|
ef65ffc544 | ||
|
|
c7739935c3 | ||
|
|
a33d9e37c8 | ||
|
|
f496fff3af | ||
|
|
a36ab31067 | ||
|
|
6c68f82884 | ||
|
|
def33e3892 | ||
|
|
01ecc1c7b5 | ||
|
|
facc7f17ea | ||
|
|
0aebe90bbc | ||
|
|
c73b892e42 | ||
|
|
4273e513c0 | ||
|
|
c288428168 | ||
|
|
fa99fc98e6 | ||
|
|
5cc05d62f4 | ||
|
|
0043afcd0c | ||
|
|
5883c0a485 | ||
|
|
66ff309502 | ||
|
|
cfeab5a7e9 | ||
|
|
1c1ad8ec32 | ||
|
|
1241a139e9 | ||
|
|
b5aa2303fb | ||
|
|
616ba9f131 | ||
|
|
32715a15da | ||
|
|
8480ab53d5 | ||
|
|
3fa4cbd9a8 | ||
|
|
f2719daa05 | ||
|
|
e9aa68c3ed | ||
|
|
8b4a95b099 | ||
|
|
a978363647 | ||
|
|
c853168fb7 | ||
|
|
861ac2b28d | ||
|
|
4dd40471f8 | ||
|
|
dcc73b1491 | ||
|
|
518adb688e | ||
|
|
a407637998 | ||
|
|
56b95a98b9 | ||
|
|
837f0410ea | ||
|
|
b5dff95be8 | ||
|
|
a75f3169eb | ||
|
|
753477f5b4 | ||
|
|
3dcb5cba92 | ||
|
|
37db8a337e | ||
|
|
5547077993 | ||
|
|
79fe52def4 | ||
|
|
bb9e6d6ebf | ||
|
|
2aa319becc | ||
|
|
5c591b13a4 | ||
|
|
137486c83a | ||
|
|
d2cb5a272b |
No files matched your search
@@ -25,7 +25,8 @@
|
||||
|
||||
<!-- "none", or for each project you read or copied from: its link at the commit, its license (only MIT,
|
||||
BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense or CC0-1.0), and what you took.
|
||||
CONTRIBUTING.md "Only original code". -->
|
||||
CONTRIBUTING.md "Only original code". Shader packs: also the sections of docs/cleanroom/shader-packs-spec.md,
|
||||
the documentation pages and the packs you used. -->
|
||||
|
||||
## Self-check
|
||||
|
||||
|
||||
@@ -14,3 +14,4 @@
|
||||
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
|
||||
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
|
||||
87478350 Qwerner88880 # added by Ludovic, 2026-10-31 (Discord vladislav_hagen; native-mod hooks)
|
||||
169198624 itarqos5 # added by Ludovic, 2026-11-08 (Discord Literal; Horizon Team)
|
||||
@@ -291,6 +291,11 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
# Windows x64: Microsoft's DXC, pinned and checksummed, which DirectX 12 uses on integrated GPUs
|
||||
# (gfx/dx12_compiler.rs), with its licence. ARM64 keeps FXC (THIRD_PARTY_NOTICES.md says why).
|
||||
if [[ "${{ matrix.name }}" == windows ]]; then
|
||||
python tools/ci/fetch_dxc.py "dist/${{ matrix.dir }}"
|
||||
fi
|
||||
cd dist
|
||||
rm -f "${{ matrix.asset }}"
|
||||
case "${{ matrix.asset }}" in
|
||||
|
||||
@@ -162,7 +162,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
| 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`, `hud_kit/` (Horizon overlays' `Room` and `Plate`, [docs/ui-kit.md](docs/ui-kit.md)), `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` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `settings_shell.rs` (Horizon's settings screens on it: `extras_ui.rs`, `graphics_ui.rs`, `controller_ui.rs`), `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `hud_kit/` (Horizon overlays' `Room` and `Plate`, [docs/ui-kit.md](docs/ui-kit.md)), `pad_glyph.rs` (controller glyphs, by what the button does), `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` |
|
||||
|
||||
@@ -76,7 +76,9 @@ another project's source may also be read and used, **only when that project's l
|
||||
|
||||
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever their
|
||||
license. Shader packs are a clean-room rewrite of the pack format, produced from a written description of it (see
|
||||
#1561); they carry no foreign source, and the rules above still apply to anything you read while working on them.
|
||||
#1561): the [specification and its provenance log](docs/cleanroom/README.md) are public. They carry no foreign
|
||||
source, and the rules above still apply to anything you read while working on them. A shader-pack pull request
|
||||
names its sources: the sections of that specification, the documentation pages and the packs it ran.
|
||||
|
||||
## Before asking for review
|
||||
|
||||
|
||||
@@ -63,6 +63,32 @@ variable's array specifier does not change later variables.
|
||||
It also reads a struct member outside a constant expression through a pointer to the member,
|
||||
not out of a loaded copy of the whole struct, so a member an atomic function uses can be read.
|
||||
|
||||
## DirectX Shader Compiler
|
||||
|
||||
The Windows x64 game folder holds `dxcompiler.dll`, Microsoft's DirectX Shader Compiler
|
||||
[v1.9.2609](https://github.com/microsoft/DirectXShaderCompiler/releases/tag/v1.9.2609), unmodified: the
|
||||
`bin/x64/dxcompiler.dll` of `dxc_2026_09_29.zip` (zip SHA-256
|
||||
`ad31b1fc8443175d204f77a611fdb3ef2ec42759bdc2f1167368de24a4a7e7f1`, DLL SHA-256
|
||||
`0689e2fee0a1c9df8662a59680469f24e2a3ad7ce6bbbcc9284f7a83172bfc59`, 22,274,872 bytes). The release build downloads
|
||||
it and checks both checksums (`tools/ci/fetch_dxc.py`); it is not in this repository. DirectX 12 loads it only on
|
||||
integrated GPUs (`crates/horizon-client-render/src/gfx/dx12_compiler.rs`,
|
||||
[#8513](https://github.com/horizon-rust/horizon/issues/8513)); every other GPU compiles with FXC, part of Windows.
|
||||
|
||||
Copyright (c) Microsoft Corporation. It is not covered by Horizon's MIT license. It is distributed under the
|
||||
"Microsoft Software License Terms, Microsoft DirectX Shader Compiler" (`LICENSE-MS.txt` of that release). Its
|
||||
Distributable Code terms are the Windows SDK's: end users get terms that protect the code and Microsoft at least as
|
||||
much, and the distributor indemnifies Microsoft. The project accepted them on 2026-11-08. Its open-source part is also under the University of Illinois/NCSA license (`LICENSE-LLVM.txt`). Both
|
||||
files ship beside the DLL as `DXC-LICENSE-MS.txt` and `DXC-LICENSE-LLVM.txt`, with `DXC-NOTICE.txt`
|
||||
(`packaging/windows/`), which tells players those terms apply and that deleting the DLL brings back FXC.
|
||||
|
||||
- Only x64: the Windows SDK's list of distributable files
|
||||
([REDIST.TXT lists](https://learn.microsoft.com/en-us/legal/windows-sdk/redist), updated 2024-10-21) names
|
||||
`Redist\D3D\x64\dxcompiler.dll` and `dxil.dll` and their x86 copies, for classic Windows applications (Horizon is
|
||||
one), and no ARM64 copy. The release zip's own `LICENSE-MS.txt` refers to a list of distributable files that the zip
|
||||
doesn't contain, so the SDK's list is the one followed. The Windows ARM64 build ships no DXC and compiles with FXC.
|
||||
- No `dxil.dll`: since DXC 1.8.2505 the compiler signs shaders with its own validator and no longer loads it, and
|
||||
wgpu never does.
|
||||
|
||||
## libmimalloc-sys and mimalloc
|
||||
|
||||
`third_party/libmimalloc-sys` is [libmimalloc-sys](https://github.com/purpleprotocol/mimalloc_rust)
|
||||
|
||||
@@ -14,6 +14,8 @@ pub enum PistonPart {
|
||||
Entity,
|
||||
State,
|
||||
Properties,
|
||||
/// Any other block entity's whole compound (`BlockEntity.saveWithFullMetadata`).
|
||||
BlockEntity,
|
||||
}
|
||||
|
||||
struct Pretty {
|
||||
@@ -255,6 +257,20 @@ fn piston_hash_order(
|
||||
entries.sort_by_key(|(k, _)| (hash_bucket(c.len(), k), std::cmp::Reverse(*k)));
|
||||
}
|
||||
PistonPart::State => return java_hash_order(c),
|
||||
PistonPart::BlockEntity => {
|
||||
// saveAdditional's fields, then `components`, then saveMetadata's
|
||||
// id and position; the fields keep the default order among them.
|
||||
const METADATA: [&str; 5] = ["components", "id", "x", "y", "z"];
|
||||
let mut entries = java_hash_order(c);
|
||||
entries.sort_by_key(|(k, _)| {
|
||||
let key = string_of(k);
|
||||
(
|
||||
hash_bucket(c.len(), k),
|
||||
METADATA.iter().position(|m| *m == key).map_or(0, |i| i + 1),
|
||||
)
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
@@ -313,6 +329,14 @@ pub fn java_hash_order(
|
||||
"NoBasePlate",
|
||||
"Marker",
|
||||
"Pose",
|
||||
// Mannequin.addAdditionalSaveData.
|
||||
"profile",
|
||||
"hidden_layers",
|
||||
"main_hand",
|
||||
"pose",
|
||||
"immovable",
|
||||
"description",
|
||||
"hide_description",
|
||||
];
|
||||
// BlockEntity.saveMetadata writes the position after the entity's data.
|
||||
const AFTER: &[&str] = &["x", "y", "z"];
|
||||
@@ -397,6 +421,21 @@ mod tests {
|
||||
assert_eq!(pretty(&tag).plain(), ordinary);
|
||||
}
|
||||
#[test]
|
||||
fn mannequin_compound_orders_pose_before_immovable_in_shared_bucket() {
|
||||
let tag = parse_snbt(
|
||||
r#"{AbsorptionAmount:0.0f,Air:300s,Brain:{memories:{}},DeathTime:0s,FallFlying:0b,Fire:0s,Health:20.0f,HurtTime:0s,Invulnerable:0b,Motion:[0.0d,0.0d,0.0d],OnGround:0b,PortalCooldown:0,Pos:[2.5d,100.0d,2.5d],Rotation:[0.0f,0.0f],UUID:[I;369,0,0,1],attributes:[],current_impulse_context_reset_grace_time:0,description:{text:"Guard"},hide_description:1b,immovable:1b,pose:"crouching",profile:{name:"alex"}}"#,
|
||||
)
|
||||
.unwrap()
|
||||
.0;
|
||||
let rendered = pretty(&tag).plain();
|
||||
let pose_idx = rendered.find("pose: \"crouching\"").unwrap();
|
||||
let immovable_idx = rendered.find("immovable: 1b").unwrap();
|
||||
assert!(
|
||||
pose_idx < immovable_idx,
|
||||
"expected `pose` before `immovable` in bucket 5, got: {rendered}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn compact_form_matches_vanilla() {
|
||||
assert_eq!(
|
||||
rt("{b:1b,a:\"x\",c:[1,2],d:1.5f,e:2d,f:3L,g:4s}"),
|
||||
@@ -441,4 +480,18 @@ mod tests {
|
||||
let long = parse_snbt("{l:[{a:1},{b:2}]}").unwrap().0;
|
||||
assert_eq!(pretty(&long).plain(), "{l: [{a: 1}, {b: 2}]}");
|
||||
}
|
||||
#[test]
|
||||
fn block_entity_metadata_follows_its_fields() {
|
||||
// A conduit's `/data get block`, as vanilla 26.3 printed it: `Target`
|
||||
// and `components` share a bucket, and saveAdditional wrote `Target` first.
|
||||
let tag = parse_snbt(
|
||||
r#"{id:"minecraft:conduit",x:4098,y:300,z:4098,components:{},Target:[I;1,2,3,4]}"#,
|
||||
)
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(
|
||||
pretty_piston(&tag, PistonPart::BlockEntity).plain(),
|
||||
r#"{Target: [I; 1, 2, 3, 4], components: {}, x: 4098, y: 300, z: 4098, id: "minecraft:conduit"}"#,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -82,49 +82,6 @@ impl Button {
|
||||
fn bit(self) -> u32 {
|
||||
1 << self as u32
|
||||
}
|
||||
/// What is printed on the button for a pad family (`Auto` reads as Xbox).
|
||||
pub fn label(self, style: GlyphStyle) -> &'static str {
|
||||
let i = self as usize;
|
||||
match style {
|
||||
GlyphStyle::PlayStation => [
|
||||
"×", "○", "□", "△", "L1", "R1", "L2", "R2", "Create", "Options", "L3", "R3", "↑",
|
||||
"↓", "←", "→",
|
||||
][i],
|
||||
GlyphStyle::Nintendo => [
|
||||
"B", "A", "Y", "X", "L", "R", "ZL", "ZR", "−", "+", "LS", "RS", "↑", "↓", "←", "→",
|
||||
][i],
|
||||
GlyphStyle::Xbox | GlyphStyle::Auto => [
|
||||
"A", "B", "X", "Y", "LB", "RB", "LT", "RT", "View", "Menu", "LS", "RS", "↑", "↓",
|
||||
"←", "→",
|
||||
][i],
|
||||
}
|
||||
}
|
||||
/// The glyph's colour (RGB): face buttons in their family's colours,
|
||||
/// the rest white.
|
||||
pub fn color(self, style: GlyphStyle) -> [u8; 3] {
|
||||
let face = match self {
|
||||
Button::South => 0,
|
||||
Button::East => 1,
|
||||
Button::West => 2,
|
||||
Button::North => 3,
|
||||
_ => return [255, 255, 255],
|
||||
};
|
||||
match style {
|
||||
GlyphStyle::PlayStation => [
|
||||
[124, 178, 232],
|
||||
[235, 110, 110],
|
||||
[222, 145, 205],
|
||||
[92, 205, 170],
|
||||
][face],
|
||||
GlyphStyle::Nintendo => [255, 255, 255],
|
||||
GlyphStyle::Xbox | GlyphStyle::Auto => [
|
||||
[110, 200, 90],
|
||||
[235, 90, 80],
|
||||
[80, 150, 240],
|
||||
[240, 200, 60],
|
||||
][face],
|
||||
}
|
||||
}
|
||||
/// The face button that plays this one's part on a Nintendo pad going by its letters
|
||||
/// (`PadSettings::nintendo_letters`): the A it prints on the right is South, which confirms
|
||||
/// and jumps as A does on a Switch; B (bottom) is East, X (top) West, Y (left) North. It is
|
||||
@@ -427,6 +384,101 @@ impl HeldDrop {
|
||||
}
|
||||
}
|
||||
|
||||
/// A job a menu's fixed buttons do. Menus aren't rebindable: this is the one map from a job to
|
||||
/// its button, and a glyph for a menu asks for the job (`GlyphOf::Menu`), never the button.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MenuJob {
|
||||
/// A: press, take, type.
|
||||
Confirm,
|
||||
/// B: back, close.
|
||||
Back,
|
||||
/// X: the screen's second job (Apply, Options, half a stack, delete a letter).
|
||||
Alternate,
|
||||
/// Y: the screen's third job (the exit, a quick move, a space).
|
||||
Extra,
|
||||
/// LB: the page or tab before.
|
||||
PagePrev,
|
||||
/// RB: the page or tab after.
|
||||
PageNext,
|
||||
/// LT: the group before; held, the modifier (shift, direct craft).
|
||||
GroupPrev,
|
||||
/// RT: the group after.
|
||||
GroupNext,
|
||||
/// View: throw.
|
||||
View,
|
||||
/// Menu (Start): done, and cancelling a binding.
|
||||
Start,
|
||||
/// The left stick pressed in.
|
||||
LeftStick,
|
||||
}
|
||||
impl MenuJob {
|
||||
pub const ALL: [MenuJob; 11] = [
|
||||
MenuJob::Confirm,
|
||||
MenuJob::Back,
|
||||
MenuJob::Alternate,
|
||||
MenuJob::Extra,
|
||||
MenuJob::PagePrev,
|
||||
MenuJob::PageNext,
|
||||
MenuJob::GroupPrev,
|
||||
MenuJob::GroupNext,
|
||||
MenuJob::View,
|
||||
MenuJob::Start,
|
||||
MenuJob::LeftStick,
|
||||
];
|
||||
/// The part that does the job (by place; a pad going by its letters is read so the part
|
||||
/// still lands: `PadState::by_letter`).
|
||||
pub const fn button(self) -> Button {
|
||||
match self {
|
||||
MenuJob::Confirm => Button::South,
|
||||
MenuJob::Back => Button::East,
|
||||
MenuJob::Alternate => Button::West,
|
||||
MenuJob::Extra => Button::North,
|
||||
MenuJob::PagePrev => Button::LeftBumper,
|
||||
MenuJob::PageNext => Button::RightBumper,
|
||||
MenuJob::GroupPrev => Button::LeftTrigger,
|
||||
MenuJob::GroupNext => Button::RightTrigger,
|
||||
MenuJob::View => Button::Select,
|
||||
MenuJob::Start => Button::Start,
|
||||
MenuJob::LeftStick => Button::LeftStick,
|
||||
}
|
||||
}
|
||||
/// The job a part does in menus.
|
||||
pub fn of(button: Button) -> Option<MenuJob> {
|
||||
Self::ALL.into_iter().find(|job| job.button() == button)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a glyph stands for: what the button does, never the button. `pad_glyph::Glyph` is built
|
||||
/// from one, so a rebinding or a pad's letters reach every glyph.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GlyphOf {
|
||||
/// A gameplay action (`key.jump`...), on the player's binding.
|
||||
Bound(&'static str),
|
||||
/// A menu's fixed job.
|
||||
Menu(MenuJob),
|
||||
}
|
||||
impl From<MenuJob> for GlyphOf {
|
||||
fn from(job: MenuJob) -> Self {
|
||||
GlyphOf::Menu(job)
|
||||
}
|
||||
}
|
||||
impl GlyphOf {
|
||||
/// The part that does it under these settings; none while the action is unbound.
|
||||
pub fn button(self, settings: &PadSettings) -> Option<Button> {
|
||||
match self {
|
||||
GlyphOf::Bound(action) => {
|
||||
// A misspelt action would quietly have no glyph.
|
||||
debug_assert!(
|
||||
ACTIONS.iter().any(|a| a.0 == action),
|
||||
"no pad action {action}"
|
||||
);
|
||||
settings.binding(action)
|
||||
}
|
||||
GlyphOf::Menu(job) => Some(job.button()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which button pictures prompts use.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum GlyphStyle {
|
||||
@@ -635,6 +687,10 @@ fn bool_text(v: bool) -> &'static str {
|
||||
if v { "true" } else { "false" }
|
||||
}
|
||||
impl PadSettings {
|
||||
/// A pad of `family` goes by its letters: only a Nintendo pad has letters to go by.
|
||||
pub fn by_letter(&self, family: GlyphStyle) -> bool {
|
||||
self.nintendo_letters && family == GlyphStyle::Nintendo
|
||||
}
|
||||
pub fn load(dir: &Path) -> Self {
|
||||
fs::read_to_string(dir.join(FILE))
|
||||
.map(|t| Self::parse(&t))
|
||||
@@ -1921,11 +1977,6 @@ bind_key.hotbar.2:north
|
||||
assert!(parts.held(Button::LeftBumper));
|
||||
assert_eq!(parts.left, [0.5, -0.25]);
|
||||
assert_eq!(parts.by_letter(), pad);
|
||||
// A part's glyph is the letter of the button playing it.
|
||||
assert_eq!(Button::South.by_letter().label(GlyphStyle::Nintendo), "A");
|
||||
assert_eq!(Button::East.by_letter().label(GlyphStyle::Nintendo), "B");
|
||||
assert_eq!(Button::West.by_letter().label(GlyphStyle::Nintendo), "X");
|
||||
assert_eq!(Button::North.by_letter().label(GlyphStyle::Nintendo), "Y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2353,12 +2404,7 @@ bind_key.hotbar.2:north
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_and_conflicts() {
|
||||
assert_eq!(Button::South.label(GlyphStyle::Xbox), "A");
|
||||
assert_eq!(Button::South.label(GlyphStyle::PlayStation), "×");
|
||||
assert_eq!(Button::South.label(GlyphStyle::Nintendo), "B");
|
||||
assert_eq!(Button::RightTrigger.label(GlyphStyle::PlayStation), "R2");
|
||||
assert_eq!(Button::DPadLeft.label(GlyphStyle::Xbox), "←");
|
||||
fn styles_resolve_and_conflicts() {
|
||||
assert_eq!(
|
||||
GlyphStyle::Auto.resolve(GlyphStyle::PlayStation),
|
||||
GlyphStyle::PlayStation
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! and of an inventory it drives (Horizon's; vanilla has no gamepad input). The game reads the facts
|
||||
//! from its own hit results and capabilities (`game/pad/guide.rs`); this turns them into rows, and
|
||||
//! places the rows around what was drawn.
|
||||
use crate::gamepad::{Button, GuideDetail, PadSettings, Rect};
|
||||
use crate::gamepad::{Button, GlyphOf, GuideDetail, MenuJob, PadSettings, Rect};
|
||||
|
||||
/// What a use does, by what takes it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -100,26 +100,27 @@ pub struct Facts {
|
||||
pub off_hand_item: bool,
|
||||
}
|
||||
|
||||
/// One button's line: the button and the translation key of what it does.
|
||||
/// One button's line: what the button does (its glyph follows the binding and the pad's letters)
|
||||
/// and the translation key of the caption.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Row {
|
||||
/// Shown before `button`: held with it, or its other half (LB and RB for pages).
|
||||
pub with: Option<Button>,
|
||||
pub button: Button,
|
||||
/// Shown before `of`: held with it, or its other half (LB and RB for pages).
|
||||
pub with: Option<GlyphOf>,
|
||||
pub of: GlyphOf,
|
||||
pub caption: &'static str,
|
||||
}
|
||||
impl Row {
|
||||
fn new(button: Button, caption: &'static str) -> Self {
|
||||
fn new(of: impl Into<GlyphOf>, caption: &'static str) -> Self {
|
||||
Row {
|
||||
with: None,
|
||||
button,
|
||||
of: of.into(),
|
||||
caption,
|
||||
}
|
||||
}
|
||||
fn pair(with: Button, button: Button, caption: &'static str) -> Self {
|
||||
fn pair(with: MenuJob, job: MenuJob, caption: &'static str) -> Self {
|
||||
Row {
|
||||
with: Some(with),
|
||||
button,
|
||||
with: Some(GlyphOf::Menu(with)),
|
||||
of: GlyphOf::Menu(job),
|
||||
caption,
|
||||
}
|
||||
}
|
||||
@@ -155,11 +156,11 @@ impl Guide {
|
||||
/// The rows for these facts on these bindings. An unbound action has no row, and neither has one
|
||||
/// on Start, which is always pause.
|
||||
pub fn resolve(facts: &Facts, settings: &PadSettings) -> Guide {
|
||||
let row = |action: &str, caption: Option<&'static str>| {
|
||||
let button = settings
|
||||
let row = |action: &'static str, caption: Option<&'static str>| {
|
||||
settings
|
||||
.binding(action)
|
||||
.filter(|button| *button != Button::Start)?;
|
||||
Some(Row::new(button, caption?))
|
||||
Some(Row::new(GlyphOf::Bound(action), caption?))
|
||||
};
|
||||
let player = !facts.spectator;
|
||||
let any_item = facts.main_hand_item || facts.off_hand_item;
|
||||
@@ -308,10 +309,10 @@ pub struct MenuFacts {
|
||||
/// An inventory's rows, by what they ask of the player: at the bottom right one face button, at the
|
||||
/// bottom left anything else (a trigger held with a button, the bumpers, View, a stick click). What
|
||||
/// the crosshair is on comes first in its column, the screen's own rows after, except Close, which
|
||||
/// stays lowest so the rest don't move it. Menu buttons aren't rebindable, so these name them.
|
||||
/// stays lowest so the rest don't move it. Menu buttons aren't rebindable, so these name menu jobs.
|
||||
pub fn resolve_menu(f: &MenuFacts) -> Guide {
|
||||
use Button::{East, LeftBumper, LeftStick, LeftTrigger, North, RightBumper, RightTrigger};
|
||||
use Button::{Select, South, West};
|
||||
use MenuJob::{Alternate, Back, Confirm, Extra, GroupNext, GroupPrev};
|
||||
use MenuJob::{LeftStick, PageNext, PagePrev, View};
|
||||
const TAKE: &str = "horizon.controller.guide.take";
|
||||
const MOVE: &str = "horizon.controller.guide.quick_move";
|
||||
let under = match f.under {
|
||||
@@ -321,25 +322,25 @@ pub fn resolve_menu(f: &MenuFacts) -> Guide {
|
||||
result: true,
|
||||
same,
|
||||
..
|
||||
} if !f.carrying || same => vec![Row::new(South, TAKE), Row::new(North, MOVE)],
|
||||
} if !f.carrying || same => vec![Row::new(Confirm, TAKE), Row::new(Extra, MOVE)],
|
||||
Under::Slot { result: true, .. } => Vec::new(),
|
||||
Under::Slot { accepts: false, .. } if f.carrying => Vec::new(),
|
||||
Under::Slot { item, same, .. } if f.carrying => vec![
|
||||
Row::new(
|
||||
South,
|
||||
Confirm,
|
||||
if item && !same {
|
||||
"horizon.controller.guide.swap_stack"
|
||||
} else {
|
||||
"horizon.controller.guide.put"
|
||||
},
|
||||
),
|
||||
Row::new(West, "horizon.controller.guide.put_one"),
|
||||
Row::new(Alternate, "horizon.controller.guide.put_one"),
|
||||
],
|
||||
Under::Slot { item: true, .. } => vec![
|
||||
Row::new(South, TAKE),
|
||||
Row::new(West, "horizon.controller.guide.take_half"),
|
||||
Row::new(North, MOVE),
|
||||
Row::new(Select, "horizon.controller.guide.drop"),
|
||||
Row::new(Confirm, TAKE),
|
||||
Row::new(Alternate, "horizon.controller.guide.take_half"),
|
||||
Row::new(Extra, MOVE),
|
||||
Row::new(View, "horizon.controller.guide.drop"),
|
||||
Row::new(LeftStick, "horizon.controller.guide.drop_stack"),
|
||||
],
|
||||
Under::Nothing | Under::Slot { .. } => Vec::new(),
|
||||
@@ -347,43 +348,44 @@ pub fn resolve_menu(f: &MenuFacts) -> Guide {
|
||||
direct_craft,
|
||||
alternatives,
|
||||
} => [
|
||||
Some(Row::new(South, "horizon.controller.guide.place_recipe")),
|
||||
Some(Row::new(Confirm, "horizon.controller.guide.place_recipe")),
|
||||
direct_craft.then_some(Row::pair(
|
||||
LeftTrigger,
|
||||
South,
|
||||
GroupPrev,
|
||||
Confirm,
|
||||
"horizon.controller.direct_craft",
|
||||
)),
|
||||
Some(Row::new(North, "horizon.controller.guide.place_recipe_all")),
|
||||
alternatives.then_some(Row::new(West, "horizon.controller.guide.alternatives")),
|
||||
Some(Row::new(Extra, "horizon.controller.guide.place_recipe_all")),
|
||||
alternatives.then_some(Row::new(Alternate, "horizon.controller.guide.alternatives")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
Under::Control => vec![Row::new(South, "horizon.controller.guide.select")],
|
||||
Under::Control => vec![Row::new(Confirm, "horizon.controller.guide.select")],
|
||||
};
|
||||
let screen = [
|
||||
f.book.then_some(Row::pair(
|
||||
LeftBumper,
|
||||
RightBumper,
|
||||
PagePrev,
|
||||
PageNext,
|
||||
"horizon.controller.guide.pages",
|
||||
)),
|
||||
f.book.then_some(Row::pair(
|
||||
LeftTrigger,
|
||||
RightTrigger,
|
||||
GroupPrev,
|
||||
GroupNext,
|
||||
"horizon.controller.guide.categories",
|
||||
)),
|
||||
(f.tabs && !f.book).then_some(Row::pair(
|
||||
LeftBumper,
|
||||
RightBumper,
|
||||
PagePrev,
|
||||
PageNext,
|
||||
"horizon.controller.guide.tabs",
|
||||
)),
|
||||
];
|
||||
let close = Row::new(East, "horizon.controller.guide.close");
|
||||
let close = Row::new(Back, "horizon.controller.guide.close");
|
||||
let rows = std::iter::once(close)
|
||||
.chain(under)
|
||||
.chain(screen.into_iter().flatten());
|
||||
let (right, left) =
|
||||
rows.partition(|r| r.with.is_none() && matches!(r.button, South | East | West | North));
|
||||
let (right, left) = rows.partition(|r| {
|
||||
r.with.is_none() && matches!(r.of, GlyphOf::Menu(Confirm | Back | Alternate | Extra))
|
||||
});
|
||||
Guide { left, right }
|
||||
}
|
||||
|
||||
@@ -495,8 +497,11 @@ mod tests {
|
||||
..f
|
||||
}
|
||||
}
|
||||
fn rows(rows: &[Row]) -> Vec<(Button, &'static str)> {
|
||||
rows.iter().map(|r| (r.button, r.caption)).collect()
|
||||
/// Each row's button on these settings, and its caption.
|
||||
fn rows(rows: &[Row], settings: &PadSettings) -> Vec<(Button, &'static str)> {
|
||||
rows.iter()
|
||||
.map(|r| (r.of.button(settings).unwrap(), r.caption))
|
||||
.collect()
|
||||
}
|
||||
fn captions(rows: &[Row]) -> Vec<&'static str> {
|
||||
rows.iter().map(|r| r.caption).collect()
|
||||
@@ -509,9 +514,10 @@ mod tests {
|
||||
item_use: Some(Verb::Place),
|
||||
..on_block(walking(), true, None)
|
||||
};
|
||||
let guide = resolve(&facts, &PadSettings::default());
|
||||
let settings = PadSettings::default();
|
||||
let guide = resolve(&facts, &settings);
|
||||
assert_eq!(
|
||||
rows(&guide.right),
|
||||
rows(&guide.right, &settings),
|
||||
[
|
||||
(Button::RightTrigger, "horizon.controller.guide.break"),
|
||||
(Button::LeftTrigger, "horizon.controller.guide.place"),
|
||||
@@ -519,7 +525,7 @@ mod tests {
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&guide.left),
|
||||
rows(&guide.left, &settings),
|
||||
[
|
||||
(Button::South, "key.jump"),
|
||||
(Button::RightStick, "key.sneak"),
|
||||
@@ -543,7 +549,7 @@ mod tests {
|
||||
};
|
||||
let guide = resolve(&facts, &settings);
|
||||
assert_eq!(
|
||||
rows(&guide.right),
|
||||
rows(&guide.right, &settings),
|
||||
[(Button::North, "horizon.controller.guide.break")]
|
||||
);
|
||||
// Start only ever pauses.
|
||||
@@ -845,8 +851,8 @@ mod tests {
|
||||
["close", "place_recipe", "place_recipe_all", "alternatives"]
|
||||
);
|
||||
assert_eq!(recipe.left.len(), 1);
|
||||
assert_eq!(recipe.left[0].with, Some(Button::LeftTrigger));
|
||||
assert_eq!(recipe.left[0].button, Button::South);
|
||||
assert_eq!(recipe.left[0].with, Some(GlyphOf::Menu(MenuJob::GroupPrev)));
|
||||
assert_eq!(recipe.left[0].of, GlyphOf::Menu(MenuJob::Confirm));
|
||||
let plain = on(
|
||||
Under::Recipe {
|
||||
direct_craft: false,
|
||||
@@ -892,8 +898,8 @@ mod detail_tests {
|
||||
|
||||
fn rows() -> Guide {
|
||||
Guide {
|
||||
left: vec![Row::new(Button::South, "left")],
|
||||
right: vec![Row::new(Button::East, "right")],
|
||||
left: vec![Row::new(MenuJob::Confirm, "left")],
|
||||
right: vec![Row::new(MenuJob::Back, "right")],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,7 +933,7 @@ mod detail_tests {
|
||||
mod slot_tests {
|
||||
use super::*;
|
||||
|
||||
/// The rows for the slot: South or West, whichever column they're in.
|
||||
/// The rows for the slot: Confirm or Alternate, whichever column they're in.
|
||||
fn rows(item: bool, same: bool, result: bool, accepts: bool, carrying: bool) -> usize {
|
||||
let guide = resolve_menu(&MenuFacts {
|
||||
under: Under::Slot {
|
||||
@@ -942,7 +948,7 @@ mod slot_tests {
|
||||
[guide.left, guide.right]
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|row| matches!(row.button, Button::South | Button::West))
|
||||
.filter(|row| matches!(row.of, GlyphOf::Menu(MenuJob::Confirm | MenuJob::Alternate)))
|
||||
.count()
|
||||
}
|
||||
|
||||
|
||||
@@ -97,8 +97,8 @@ rustix = { version = "1", default-features = false, features = ["std", "time", "
|
||||
# Use the registered URI handler without routing links through Explorer/cmd.
|
||||
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
|
||||
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
|
||||
# on the PATH.
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or on integrated GPUs with DXC
|
||||
# (dxcompiler.dll beside the game, gfx/dx12_compiler.rs).
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
//! A world warp (`native_world_warp`, docs/native-mods.md "World warps") through the real
|
||||
//! `Renderer`, offscreen: a wall of terrain and an entity quad drawn without a warp, with one,
|
||||
//! with one that is off, with one that cuts the wall, with a pixel stage of its own and with
|
||||
//! one that doesn't compile. Run with throwaway HORIZON_HOME, HORIZON_LAUNCHER_HOME,
|
||||
//! XDG_DATA_HOME and XDG_CONFIG_HOME (AGENTS.md):
|
||||
//! `cargo run -p horizon-client-render --example world_warp_check -- <output-dir>`
|
||||
//! `--release ... -- <output-dir> --bench`: instead, the time of a frame of rolling land 49 by 49
|
||||
//! chunks around the camera without a warp, and with one (nothing culled), at 1920 by 1080.
|
||||
use client::{
|
||||
gfx::{Frame, Renderer, ScreenshotRequest, native_render},
|
||||
graphics::GraphicsApi,
|
||||
hud::HudBatch,
|
||||
mesher::{SectionMesh, TerrainVertex, Vertex},
|
||||
};
|
||||
use horizon_client_render as client;
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use winit::{
|
||||
application::ApplicationHandler,
|
||||
event::WindowEvent,
|
||||
event_loop::{ActiveEventLoop, EventLoop},
|
||||
window::{Window, WindowId},
|
||||
};
|
||||
|
||||
/// What the mod below gives the renderer each frame.
|
||||
static WARP: Mutex<Option<native_render::WorldWarp>> = Mutex::new(None);
|
||||
struct Mod;
|
||||
impl native_render::RenderHooks for Mod {
|
||||
fn world_warp(&self, event: &mut native_render::WorldWarpEvent<'_>) {
|
||||
if let Some(warp) = WARP.lock().unwrap().as_ref() {
|
||||
*event.warp = warp.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lifts what is left of x = 8 by `params[0].x` and tints it red.
|
||||
const LIFT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
let here = pos.x + warp.camera.x + warp.camera_rest.x;
|
||||
if here < 8.0 { return Warped(pos + vec3(0.0, warp.params[0].x, 0.0), vec3(1.0, 0.0, 0.0)); }
|
||||
return Warped(pos, vec3(1.0));
|
||||
}
|
||||
";
|
||||
/// Cuts every face.
|
||||
const CUT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
return Warped(pos, vec3(-1.0));
|
||||
}
|
||||
";
|
||||
/// Paints the terrain green by its pixels and moves nothing.
|
||||
const PAINT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
return Warped(pos, vec3(1.0));
|
||||
}
|
||||
// [fragments]
|
||||
fn world_fragment(f: Fragment) -> Painted {
|
||||
if !f.front { return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }
|
||||
return Painted(vec4(0.0, 1.0, 0.0, 1.0), 0.0);
|
||||
}
|
||||
";
|
||||
/// Moves entities ten blocks right: off the screen's middle.
|
||||
const SHIFT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
return Warped(pos + vec3(-10.0, 0.0, 0.0), vec3(1.0));
|
||||
}
|
||||
";
|
||||
|
||||
fn read(path: &Path) -> Vec<[u8; 4]> {
|
||||
let decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path).unwrap()));
|
||||
let mut reader = decoder.read_info().unwrap();
|
||||
let mut bytes = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let info = reader.next_frame(&mut bytes).unwrap();
|
||||
assert_eq!(info.color_type, png::ColorType::Rgba);
|
||||
bytes.truncate(info.buffer_size());
|
||||
bytes.as_chunks::<4>().0.to_vec()
|
||||
}
|
||||
|
||||
/// Pixels like `want` (within 40 a channel), of an image.
|
||||
fn count(pixels: &[[u8; 4]], want: [u8; 3]) -> usize {
|
||||
pixels
|
||||
.iter()
|
||||
.filter(|p| (0..3).all(|i| p[i].abs_diff(want[i]) < 40))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// A wall across x 0..16, y 0..16 at z = 12 of the section at y 96, both windings.
|
||||
fn wall() -> SectionMesh {
|
||||
let mut mesh = SectionMesh::default();
|
||||
let corners = [
|
||||
[0., 0., 12.],
|
||||
[0., 16., 12.],
|
||||
[16., 16., 12.],
|
||||
[16., 0., 12.],
|
||||
];
|
||||
let uvs = [[0., 1.], [0., 0.], [1., 0.], [1., 1.]];
|
||||
for order in [[0, 1, 2, 3], [3, 2, 1, 0]] {
|
||||
for i in order {
|
||||
mesh.solid
|
||||
.push(TerrainVertex::new(corners[i], uvs[i], 0, u32::MAX, 0xf0f0));
|
||||
}
|
||||
}
|
||||
mesh.sort_solid();
|
||||
mesh
|
||||
}
|
||||
|
||||
fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let out = std::env::args().nth(1).ok_or("output directory required")?;
|
||||
let out = Path::new(&out);
|
||||
std::fs::create_dir_all(out)?;
|
||||
native_render::install(Box::new([&Mod])).map_err(|_| "mod already installed")?;
|
||||
let white = [255; 16 * 16 * 4];
|
||||
let zero = [0; 4];
|
||||
let mut renderer = pollster::block_on(Renderer::new(
|
||||
window,
|
||||
true,
|
||||
&white,
|
||||
16,
|
||||
16,
|
||||
1,
|
||||
&[],
|
||||
&[],
|
||||
(&white, 16, 16),
|
||||
(&zero, 1, 1),
|
||||
(&zero, 1, 1),
|
||||
(&zero, 1),
|
||||
Arc::new(client::gfx::panorama::Faces::colour([0, 0, 0, 255])),
|
||||
None,
|
||||
None,
|
||||
GraphicsApi::Vulkan,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
renderer.resize(320, 180);
|
||||
renderer.fullbright = true;
|
||||
// (keys count sections from the bottom of the world, y -64: 10 is y 96 to 112)
|
||||
renderer.upload((0, 10, 0), &wall());
|
||||
// an entity quad in front of the wall, middle of the screen
|
||||
let entity: Vec<Vertex> = [
|
||||
[7.5, 103.5, 6.0],
|
||||
[7.5, 104.5, 6.0],
|
||||
[8.5, 104.5, 6.0],
|
||||
[8.5, 103.5, 6.0],
|
||||
]
|
||||
.into_iter()
|
||||
.zip([[0., 1.], [0., 0.], [1., 0.], [1., 1.]])
|
||||
.map(|(p, uv)| Vertex {
|
||||
pos: [p[0] - 8.0, p[1] - 104.0, p[2] - 0.5],
|
||||
uv,
|
||||
layer: 0,
|
||||
color: 0xff40_40ff,
|
||||
light: 0xf0,
|
||||
normal: [0, 0, 0, 0],
|
||||
glint_uv: [0.0; 2],
|
||||
})
|
||||
.collect();
|
||||
if std::env::args().any(|a| a == "--bench") {
|
||||
return bench(&mut renderer, out);
|
||||
}
|
||||
let hud = HudBatch::new([320, 180]);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.entities = &entity;
|
||||
let mut shot = |name: &str, warp: Option<native_render::WorldWarp>| {
|
||||
*WARP.lock().unwrap() = warp;
|
||||
let path = out.join(format!("{name}.png"));
|
||||
renderer.screenshot = Some(ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
});
|
||||
// twice: the first frame of a new warp builds it
|
||||
renderer.render(&frame).unwrap();
|
||||
renderer.render(&frame).unwrap();
|
||||
read(&path)
|
||||
};
|
||||
let warp = |source: &str, on: bool, lift: f32| {
|
||||
let mut w = native_render::WorldWarp::default();
|
||||
w.source = Some(source.into());
|
||||
w.on = on;
|
||||
w.params[0] = lift;
|
||||
Some(w)
|
||||
};
|
||||
let plain = shot("plain", None);
|
||||
let wall_px = count(&plain, [255, 255, 255]);
|
||||
let entity_px = count(&plain, [255, 64, 64]);
|
||||
assert!(wall_px > 1000, "the wall shows without a warp: {wall_px}");
|
||||
assert!(
|
||||
entity_px > 50,
|
||||
"the entity shows without a warp: {entity_px}"
|
||||
);
|
||||
|
||||
let off = shot("off", warp(LIFT, false, 4.0));
|
||||
assert!(off == plain, "a warp that is off draws as no warp");
|
||||
|
||||
let lifted = shot("lift", warp(LIFT, true, 4.0));
|
||||
let red = count(&lifted, [255, 0, 0]);
|
||||
assert!(red > 500, "the warp tints the wall's left half red: {red}");
|
||||
assert!(lifted != plain);
|
||||
|
||||
let cut = shot("cut", warp(CUT, true, 0.0));
|
||||
let left = count(&cut, [255, 255, 255]);
|
||||
assert!(
|
||||
left < wall_px / 20,
|
||||
"a cut leaves the wall out: {left} of {wall_px}"
|
||||
);
|
||||
|
||||
let painted = shot("paint", warp(PAINT, true, 0.0));
|
||||
let green = count(&painted, [0, 255, 0]);
|
||||
assert!(
|
||||
green > wall_px / 2,
|
||||
"the warp's own pixels paint the wall: {green}"
|
||||
);
|
||||
|
||||
let shifted = shot("shift", warp(SHIFT, true, 0.0));
|
||||
let entity_left = count(&shifted[..], [255, 64, 64]);
|
||||
let mid = |p: &[[u8; 4]]| -> Vec<[u8; 4]> {
|
||||
(60..120)
|
||||
.flat_map(|y| p[y * 320 + 130..y * 320 + 190].to_vec())
|
||||
.collect()
|
||||
};
|
||||
assert!(
|
||||
count(&mid(&shifted), [255, 64, 64]) < count(&mid(&plain), [255, 64, 64]) / 4,
|
||||
"the warp moves the entity out of the middle ({entity_left} red-ish pixels left)"
|
||||
);
|
||||
|
||||
let broken = shot(
|
||||
"broken",
|
||||
warp("fn world_warp() -> f32 { return 1; }", true, 0.0),
|
||||
);
|
||||
assert!(broken == plain, "a warp that doesn't compile is left out");
|
||||
|
||||
let back = shot("after", None);
|
||||
assert!(back == plain, "with the warp gone the frame is as before");
|
||||
std::fs::write(
|
||||
out.join("result.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"wall_pixels": wall_px, "entity_pixels": entity_px, "lifted_red_pixels": red,
|
||||
"cut_wall_pixels_left": left, "painted_green_pixels": green,
|
||||
"off_equals_plain": true, "broken_equals_plain": true, "after_equals_plain": true,
|
||||
"graphics_adapter": client::crash::system_details("fixture").into_iter()
|
||||
.find(|(name, _)| name == "Graphics Adapter").map(|(_, detail)| detail),
|
||||
}))?,
|
||||
)?;
|
||||
println!(
|
||||
"world warp check passed: {}",
|
||||
out.join("result.json").display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rolling land: a column of a section's top faces and the sides they show, at y 64 to 80.
|
||||
fn hills(cx: i32, cz: i32) -> SectionMesh {
|
||||
let height = |x: i32, z: i32| {
|
||||
let (x, z) = (f64::from(cx * 16 + x), f64::from(cz * 16 + z));
|
||||
(8.0 + 4.0 * (x * 0.09).sin() + 3.0 * (z * 0.07 + x * 0.02).cos()).floor() as i32
|
||||
};
|
||||
let mut mesh = SectionMesh::default();
|
||||
let mut quad = |c: [[f32; 3]; 4]| {
|
||||
for (p, uv) in c.into_iter().zip([[0., 1.], [0., 0.], [1., 0.], [1., 1.]]) {
|
||||
mesh.solid
|
||||
.push(TerrainVertex::new(p, uv, 0, 0xff60_a060, 0xf0));
|
||||
}
|
||||
};
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
let h = height(x, z);
|
||||
let (fx, fz, fh) = (x as f32, z as f32, h as f32);
|
||||
quad([
|
||||
[fx, fh, fz],
|
||||
[fx, fh, fz + 1.],
|
||||
[fx + 1., fh, fz + 1.],
|
||||
[fx + 1., fh, fz],
|
||||
]);
|
||||
// the step down to a lower neighbour, toward +x and +z
|
||||
for (dx, dz) in [(1, 0), (0, 1)] {
|
||||
let n = height(x + dx, z + dz);
|
||||
if n < h {
|
||||
let (lo, hi) = (n as f32, fh);
|
||||
if dx == 1 {
|
||||
quad([
|
||||
[fx + 1., lo, fz],
|
||||
[fx + 1., hi, fz],
|
||||
[fx + 1., hi, fz + 1.],
|
||||
[fx + 1., lo, fz + 1.],
|
||||
]);
|
||||
} else {
|
||||
quad([
|
||||
[fx, lo, fz + 1.],
|
||||
[fx + 1., lo, fz + 1.],
|
||||
[fx + 1., hi, fz + 1.],
|
||||
[fx, hi, fz + 1.],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mesh.sort_solid();
|
||||
mesh
|
||||
}
|
||||
|
||||
/// `--bench`: milliseconds a frame without a warp and with one.
|
||||
fn bench(renderer: &mut Renderer, out: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
const R: i32 = 24;
|
||||
renderer.resize(1920, 1080);
|
||||
let mut sections = 0;
|
||||
for cx in -R..=R {
|
||||
for cz in -R..=R {
|
||||
// (keys count sections from y -64: 8 is y 64 to 80)
|
||||
renderer.upload((cx, 8, cz), &hills(cx, cz));
|
||||
sections += 1;
|
||||
}
|
||||
}
|
||||
let hud = HudBatch::new([1920, 1080]);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.camera = glam::DVec3::new(8.0, 90.0, 8.0);
|
||||
frame.eye = frame.camera;
|
||||
frame.pitch = 15.0;
|
||||
frame.eye_rotation = [0.0, 15.0];
|
||||
frame.distance = 16.0 * R as f32;
|
||||
frame.detail_distance = frame.distance;
|
||||
frame.depth_far = 2000.0;
|
||||
frame.cave_culling = true;
|
||||
let mut time = |warp: Option<native_render::WorldWarp>| {
|
||||
*WARP.lock().unwrap() = warp;
|
||||
for _ in 0..30 {
|
||||
renderer.render(&frame).unwrap();
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
const FRAMES: u32 = 240;
|
||||
for _ in 0..FRAMES {
|
||||
renderer.render(&frame).unwrap();
|
||||
}
|
||||
// the last frame's picture, so the GPU has caught up
|
||||
renderer.screenshot = Some(ScreenshotRequest {
|
||||
path: out.join("bench.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
});
|
||||
renderer.render(&frame).unwrap();
|
||||
renderer.render(&frame).unwrap();
|
||||
started.elapsed().as_secs_f64() * 1000.0 / f64::from(FRAMES + 2)
|
||||
};
|
||||
let mut wave = native_render::WorldWarp::default();
|
||||
wave.source = Some(
|
||||
"fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
let x = pos.x + warp.camera.x + warp.camera_rest.x;
|
||||
return Warped(pos + vec3(0.0, sin(x * 0.05) * 3.0, 0.0), vec3(1.0));
|
||||
}\n"
|
||||
.into(),
|
||||
);
|
||||
wave.on = true;
|
||||
let plain = time(None);
|
||||
let warped = time(Some(wave));
|
||||
let again = time(None);
|
||||
let result = serde_json::json!({
|
||||
"sections": sections, "frame_ms_without_warp": plain, "frame_ms_with_warp": warped,
|
||||
"frame_ms_without_warp_after": again,
|
||||
"graphics_adapter": client::crash::system_details("bench").into_iter()
|
||||
.find(|(name, _)| name == "Graphics Adapter").map(|(_, detail)| detail),
|
||||
});
|
||||
println!("{result}");
|
||||
std::fs::write(out.join("bench.json"), serde_json::to_vec_pretty(&result)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct App;
|
||||
impl ApplicationHandler for App {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
let window = Arc::new(
|
||||
event_loop
|
||||
.create_window(Window::default_attributes().with_visible(false))
|
||||
.unwrap(),
|
||||
);
|
||||
if let Err(error) = fixture(window) {
|
||||
eprintln!("world warp check failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
event_loop.exit();
|
||||
}
|
||||
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
|
||||
}
|
||||
fn main() {
|
||||
for key in [
|
||||
"HORIZON_HOME",
|
||||
"HORIZON_LAUNCHER_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
] {
|
||||
assert!(
|
||||
std::env::var_os(key).is_some(),
|
||||
"set throwaway {key} (AGENTS.md)"
|
||||
);
|
||||
}
|
||||
EventLoop::new().unwrap().run_app(&mut App).unwrap();
|
||||
}
|
||||
|
||||
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
Frame {
|
||||
weather: &[],
|
||||
weather_time: client::weather::Time::new(0, 0.0),
|
||||
camera: glam::DVec3::new(8.0, 104.0, 0.5),
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
fov_degrees: 85.0,
|
||||
eye: glam::DVec3::new(8.0, 104.0, 0.5),
|
||||
eye_rotation: [0.0, 0.0],
|
||||
first_person: true,
|
||||
bob: glam::Mat4::IDENTITY,
|
||||
spin: glam::Mat4::IDENTITY,
|
||||
sky: client::environment::Sky::default(),
|
||||
shadow_scene_revision: (0, 0),
|
||||
shadow_cache: false,
|
||||
distance: 128.0,
|
||||
detail_distance: 128.0,
|
||||
depth_far: 1000.0,
|
||||
underwater: false,
|
||||
water_plane: None,
|
||||
cave_culling: false,
|
||||
title: false,
|
||||
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: client::post_chain::Request::none(),
|
||||
menu_blur: None,
|
||||
}
|
||||
}
|
||||
@@ -660,6 +660,34 @@ mod pack_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Biomes {
|
||||
/// Biomes from their files' JSON, by name, in id order: for tests elsewhere.
|
||||
pub(crate) fn from_jsons(entries: &[(&str, Value)]) -> Self {
|
||||
let maps = Colormaps {
|
||||
grass: Vec::new(),
|
||||
foliage: Vec::new(),
|
||||
dry: Vec::new(),
|
||||
};
|
||||
let list: Vec<_> = entries
|
||||
.iter()
|
||||
.map(|(name, v)| BiomeInfo::from_json((*name).to_owned(), v, &maps))
|
||||
.collect();
|
||||
Self {
|
||||
builtin: Arc::new(list.clone()),
|
||||
index: list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| (b.name.clone(), i as u16))
|
||||
.collect(),
|
||||
list,
|
||||
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
|
||||
colormaps: Arc::new(maps),
|
||||
packs: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod color_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -85,6 +85,11 @@ pub enum BlockEntity {
|
||||
DaylightDetector,
|
||||
/// `BellBlockEntity`: saved identity only; ringing state is kept separately.
|
||||
Bell,
|
||||
/// `ConduitBlockEntity`: the UUID of its `destroyTarget` (`Target`). Its
|
||||
/// shape and animation are kept separately (`block_entity_anim`).
|
||||
Conduit(Option<[i32; 4]>),
|
||||
/// `EnchantingTableBlockEntity`: its custom name is the world's block name.
|
||||
EnchantingTable,
|
||||
/// Suspicious sand and gravel: saved loot and the transient brushed face.
|
||||
Brushable(Box<Brushable>),
|
||||
/// A shelf holds three displayed item stacks.
|
||||
|
||||
@@ -444,6 +444,12 @@ impl BlockEntity {
|
||||
if name == "minecraft:bell" {
|
||||
return Some(Self::Bell);
|
||||
}
|
||||
if name == "minecraft:conduit" {
|
||||
return Some(Self::Conduit(None));
|
||||
}
|
||||
if name == "minecraft:enchanting_table" {
|
||||
return Some(Self::EnchantingTable);
|
||||
}
|
||||
if name == "minecraft:daylight_detector" {
|
||||
return Some(Self::DaylightDetector);
|
||||
}
|
||||
@@ -540,6 +546,8 @@ impl BlockEntity {
|
||||
Self::EndGateway(_) => name == "minecraft:end_gateway",
|
||||
Self::DaylightDetector => name == "minecraft:daylight_detector",
|
||||
Self::Bell => name == "minecraft:bell",
|
||||
Self::Conduit(_) => name == "minecraft:conduit",
|
||||
Self::EnchantingTable => name == "minecraft:enchanting_table",
|
||||
Self::StructureBlock(_) => name == "minecraft:structure_block",
|
||||
Self::TestBlock(_) => name == "minecraft:test_block",
|
||||
Self::TestInstanceBlock(_) => name == "minecraft:test_instance_block",
|
||||
@@ -579,6 +587,8 @@ impl BlockEntity {
|
||||
Self::EndGateway(_) => "minecraft:end_gateway",
|
||||
Self::DaylightDetector => "minecraft:daylight_detector",
|
||||
Self::Bell => "minecraft:bell",
|
||||
Self::Conduit(_) => "minecraft:conduit",
|
||||
Self::EnchantingTable => "minecraft:enchanting_table",
|
||||
Self::Brushable(_) => "minecraft:brushable_block",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
|
||||
@@ -614,6 +624,8 @@ impl BlockEntity {
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Conduit(_)
|
||||
| Self::EnchantingTable
|
||||
| Self::Brushable(_) => Vec::new(),
|
||||
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
@@ -645,6 +657,8 @@ impl BlockEntity {
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Conduit(_)
|
||||
| Self::EnchantingTable
|
||||
| Self::Brushable(_) => &[],
|
||||
Self::Jukebox(j) => &j.items,
|
||||
Self::Crafter(c) => &c.items,
|
||||
@@ -681,6 +695,8 @@ impl BlockEntity {
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Conduit(_)
|
||||
| Self::EnchantingTable
|
||||
| Self::Brushable(_) => &mut [],
|
||||
Self::Jukebox(j) => &mut j.items,
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
|
||||
@@ -175,7 +175,11 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
|
||||
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
|
||||
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
|
||||
// [world warp entity]
|
||||
// (a world warp's pipelines put the vertex through the mod's `world_warp`: gfx/world_warp.rs)
|
||||
let at = position;
|
||||
// [end world warp entity]
|
||||
var out = entity_vertex(at, uv, overlay & 0xffffu, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
out.tier = (overlay >> 16u) & 0xffu;
|
||||
return out;
|
||||
|
||||
@@ -163,7 +163,8 @@ pub struct Frame {
|
||||
/// The sky and block light at the camera.
|
||||
pub sky_light: i32,
|
||||
pub block_light: i32,
|
||||
/// The camera's biome's climate (`o.temperature`, `o.downfall`).
|
||||
/// The camera's biome (`o.biome()`), and its climate (`o.temperature`, `o.downfall`).
|
||||
pub biome: String,
|
||||
pub temperature: f32,
|
||||
pub downfall: f32,
|
||||
}
|
||||
@@ -216,6 +217,9 @@ impl horizon_polytone::expr::Host for FrameHost<'_> {
|
||||
"blockLight" if on(Root::Object) || on(Root::Player) => num(f64::from(f.block_light)),
|
||||
"temperature" if on(Root::Object) => num(f64::from(f.temperature)),
|
||||
"downfall" if on(Root::Object) => num(f64::from(f.downfall)),
|
||||
"biome" if on(Root::Object) || on(Root::Player) || on(Root::Camera) => {
|
||||
Ok(V::Str(f.biome.as_str().into()))
|
||||
}
|
||||
"rand" if on(Root::Random) => {
|
||||
// A splitmix64 step: the frame's own sequence.
|
||||
self.seed = self.seed.wrapping_add(0x9e37_79b9_7f4a_7c15);
|
||||
@@ -265,9 +269,9 @@ fn as_number(value: &Value) -> f64 {
|
||||
|
||||
/// A Polytone colormap used as an attribute value (wiki "Colormaps"): an image read at the
|
||||
/// column and row its two expressions pick, each 0 to 1 with 1 the top-left corner.
|
||||
struct Colormap {
|
||||
x: horizon_polytone::expr::Program,
|
||||
y: horizon_polytone::expr::Program,
|
||||
pub(crate) struct Colormap {
|
||||
x: Axis,
|
||||
y: Axis,
|
||||
/// Only the lower-left triangle: the row value is scaled by the column value, as vanilla's
|
||||
/// grass colormap scales downfall by temperature.
|
||||
triangular: bool,
|
||||
@@ -278,11 +282,72 @@ struct Colormap {
|
||||
rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A colormap axis: an expression (or a named sampler, wiki "Default Samplers"), or the
|
||||
/// `biome_id` sampler with its mapper.
|
||||
enum Axis {
|
||||
Expr(horizon_polytone::expr::Program),
|
||||
/// `biome_id`: the value the mapper gives the biome where it samples (0 for the others),
|
||||
/// and the expression naming that biome. Unlike the other axes it counts from the left (or
|
||||
/// top) edge, in pixel coordinates over the mapper's `texture_size`: the column (or row) is
|
||||
/// `floor(value * size)`, so `1` over a `texture_size` of 16 is the second pixel, and the
|
||||
/// biomes left out are the first, as the wiki's "start your mapper at 1" advice expects.
|
||||
BiomeId(HashMap<String, f64>, horizon_polytone::expr::Program),
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
fn value(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<f64> {
|
||||
match self {
|
||||
Self::Expr(program) => Some(program.eval(host).ok()?.num()),
|
||||
Self::BiomeId(mapper, biome) => {
|
||||
let biome = match biome.eval(host).ok()? {
|
||||
horizon_polytone::expr::Value::Str(s) => s,
|
||||
_ => return Some(0.0),
|
||||
};
|
||||
Some(mapper.get(biome.as_ref()).copied().unwrap_or(0.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `biome_id_mapper`: inline, or an id of `polytone/biome_id_mappers/<path>.json`. Its values
|
||||
/// are divided by its `texture_size`, when it has one (pixel coordinates).
|
||||
fn biome_id_mapper(pack_root: &Path, json: &Json) -> HashMap<String, f64> {
|
||||
let object = match json {
|
||||
Json::String(id) => {
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
let file = pack_root.join(format!(
|
||||
"assets/{namespace}/polytone/biome_id_mappers/{path}.json"
|
||||
));
|
||||
horizon_world::json_file::read::<Json>(&file).unwrap_or(Json::Null)
|
||||
}
|
||||
other => other.clone(),
|
||||
};
|
||||
let Some(object) = object.as_object() else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let size = object
|
||||
.get("texture_size")
|
||||
.and_then(|v| v.as_f64().or_else(|| v.as_str()?.parse().ok()))
|
||||
.filter(|s| *s > 0.0)
|
||||
.unwrap_or(1.0);
|
||||
object
|
||||
.iter()
|
||||
.filter(|(k, _)| *k != "texture_size")
|
||||
.filter_map(|(k, v)| {
|
||||
let id = if k.contains(':') {
|
||||
k.clone()
|
||||
} else {
|
||||
format!("minecraft:{k}")
|
||||
};
|
||||
Some((id, v.as_f64()? / size))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Colormap {
|
||||
/// `assets/<namespace>/polytone/colormaps/<path>.png` and its optional `.json` (without
|
||||
/// one, the axes are the biome's temperature and downfall). The `biome_id` axis isn't
|
||||
/// supported yet.
|
||||
fn load(pack_root: &Path, id: &str) -> Option<Self> {
|
||||
/// one, the axes are the biome's temperature and downfall).
|
||||
pub(crate) fn load(pack_root: &Path, id: &str) -> Option<Self> {
|
||||
let (namespace, path) = id.split_once(':')?;
|
||||
let base = pack_root.join(format!("assets/{namespace}/polytone/colormaps/{path}"));
|
||||
let png = base.with_extension("png");
|
||||
@@ -295,13 +360,26 @@ impl Colormap {
|
||||
};
|
||||
let json: Json =
|
||||
horizon_world::json_file::read(&base.with_extension("json")).unwrap_or(Json::Null);
|
||||
let axis = |key: &str, default: &str| -> Option<horizon_polytone::expr::Program> {
|
||||
let compile = horizon_polytone::expr::Program::compile;
|
||||
let axis = |key: &str, default: &str| -> Option<Axis> {
|
||||
let src = match &json[key] {
|
||||
Json::String(s) => s.clone(),
|
||||
Json::Number(n) => n.to_string(),
|
||||
_ => default.to_owned(),
|
||||
};
|
||||
match horizon_polytone::expr::Program::compile(&src) {
|
||||
// The named samplers (wiki "Default Samplers") as the expressions they stand for.
|
||||
let src = match src.trim() {
|
||||
"biome_id" => {
|
||||
let mapper = biome_id_mapper(pack_root, &json["biome_id_mapper"]);
|
||||
return Some(Axis::BiomeId(mapper, compile("o.biome()").ok()?));
|
||||
}
|
||||
"temperature" | "legacy_temperature" => "o.temperature".to_owned(),
|
||||
"downfall" => "o.downfall".to_owned(),
|
||||
"y_level" => "o.y / 128".to_owned(),
|
||||
"day_time" => "1 - (g.dayTime % 24000) / 24000".to_owned(),
|
||||
_ => src,
|
||||
};
|
||||
match compile(&src).map(Axis::Expr) {
|
||||
Ok(program) => Some(program),
|
||||
Err(e) => {
|
||||
log::warn!("colormap {id} {key}: {e}");
|
||||
@@ -322,24 +400,145 @@ impl Colormap {
|
||||
|
||||
/// The colour the axes pick, ARGB; `None` for a transparent pixel (the value stays what
|
||||
/// it was, the default `fallback_colormap: original`) or an axis that fails.
|
||||
fn sample(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<u32> {
|
||||
let x = self.x.eval(host).ok()?.num().clamp(0.0, 1.0);
|
||||
let mut y = self.y.eval(host).ok()?.num().clamp(0.0, 1.0);
|
||||
pub(crate) fn sample(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<u32> {
|
||||
let x = self.x.value(host)?.clamp(0.0, 1.0);
|
||||
let mut y = self.y.value(host)?.clamp(0.0, 1.0);
|
||||
if self.triangular {
|
||||
y *= x;
|
||||
}
|
||||
let pixel = |v: f64, size: u32| {
|
||||
let p = (1.0 - v) * f64::from(size.saturating_sub(1));
|
||||
let pixel = |axis: &Axis, v: f64, size: u32| {
|
||||
let last = size.saturating_sub(1);
|
||||
if let Axis::BiomeId(..) = axis {
|
||||
return ((v * f64::from(size)).floor() as u32).min(last);
|
||||
}
|
||||
let p = (1.0 - v) * f64::from(last);
|
||||
let p = if self.rounds { p.round() } else { p.floor() };
|
||||
(p as u32).min(size.saturating_sub(1))
|
||||
(p as u32).min(last)
|
||||
};
|
||||
let (px, py) = (pixel(x, self.width), pixel(y, self.height));
|
||||
let (px, py) = (
|
||||
pixel(&self.x, x, self.width),
|
||||
pixel(&self.y, y, self.height),
|
||||
);
|
||||
let i = (py as usize * self.width as usize + px as usize) * 4;
|
||||
let [r, g, b, a] = self.rgba.get(i..i + 4)?.try_into().ok()?;
|
||||
(a != 0).then(|| u32::from(a) << 24 | u32::from(r) << 16 | u32::from(g) << 8 | u32::from(b))
|
||||
}
|
||||
}
|
||||
|
||||
/// The colormaps of the Polytone block and fluid modifiers of the packs merged at `pack_root`:
|
||||
/// (a fluid modifier, its targets, the colormap's id). Inline colormaps aren't read.
|
||||
pub(crate) fn polytone_colormap_modifiers(
|
||||
pack_root: &Path,
|
||||
) -> Vec<(bool, horizon_polytone::Targets, String)> {
|
||||
let mut out = Vec::new();
|
||||
let namespaces = std::fs::read_dir(pack_root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten();
|
||||
for namespace in namespaces {
|
||||
for (kind, fluid) in [("block_modifiers", false), ("fluid_modifiers", true)] {
|
||||
let mut stack = vec![namespace.path().join("polytone").join(kind)];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
let Ok(json) = horizon_world::json_file::read::<Json>(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Some(id) = json.get("colormap").and_then(Json::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let id = if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
};
|
||||
let relative = path
|
||||
.strip_prefix(pack_root)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy();
|
||||
let implicit = horizon_polytone::implicit_id(&relative, kind);
|
||||
match horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref())
|
||||
{
|
||||
Ok(targets) => out.push((fluid, targets, id)),
|
||||
Err(e) => log::warn!("{relative}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What a block colormap's axes read where it samples: the block (`o.x`, `o.biome()`, its
|
||||
/// biome's climate) and `r.noise`. The world isn't reachable from the mesher, so the rest fails
|
||||
/// and the block keeps its own tint.
|
||||
pub(crate) struct BlockHost<'a> {
|
||||
at: [i32; 3],
|
||||
biome: &'a crate::biome::BiomeInfo,
|
||||
}
|
||||
|
||||
impl<'a> BlockHost<'a> {
|
||||
pub(crate) fn new(at: [i32; 3], biome: &'a crate::biome::BiomeInfo) -> Self {
|
||||
Self { at, biome }
|
||||
}
|
||||
}
|
||||
|
||||
/// The simplex noise `r.noise(x, y)` reads, the same for blocks and particles.
|
||||
pub(crate) fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
|
||||
static NOISE: std::sync::OnceLock<horizon_core::noise::SimplexNoise> =
|
||||
std::sync::OnceLock::new();
|
||||
NOISE.get_or_init(|| {
|
||||
horizon_core::noise::SimplexNoise::new(&mut horizon_core::random::LegacyRandom::new(0))
|
||||
})
|
||||
}
|
||||
|
||||
impl horizon_polytone::expr::Host for BlockHost<'_> {
|
||||
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
|
||||
use horizon_polytone::expr::Root;
|
||||
horizon_polytone::expr::Value::Object(match root {
|
||||
Root::Object => 1,
|
||||
Root::Random => 2,
|
||||
_ => 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn method(
|
||||
&mut self,
|
||||
target: &horizon_polytone::expr::Value,
|
||||
name: &str,
|
||||
args: &[horizon_polytone::expr::Value],
|
||||
) -> std::result::Result<horizon_polytone::expr::Value, String> {
|
||||
use horizon_polytone::expr::Value as V;
|
||||
let num = |n: f64| Ok(V::Num(n));
|
||||
match (target, name) {
|
||||
(V::Object(1), "x" | "y" | "z") => {
|
||||
num(f64::from(self.at[usize::from(name.as_bytes()[0] - b'x')]))
|
||||
}
|
||||
(V::Object(1), "biome") => Ok(V::Str(self.biome.name.as_str().into())),
|
||||
(V::Object(1), "temperature") => num(f64::from(self.biome.temperature)),
|
||||
(V::Object(1), "downfall") => num(f64::from(self.biome.downfall)),
|
||||
(V::Object(2), "noise") => {
|
||||
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
|
||||
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
|
||||
num(f64::from(polytone_noise().get2(x, y)))
|
||||
}
|
||||
_ => Err(format!("{name} is not available to block colormaps")),
|
||||
}
|
||||
}
|
||||
|
||||
fn function(
|
||||
&mut self,
|
||||
name: &str,
|
||||
_: &[horizon_polytone::expr::Value],
|
||||
) -> std::result::Result<horizon_polytone::expr::Value, String> {
|
||||
Err(format!("{name} is not available to block colormaps"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns Polytone layers into vanilla entries, compiling expressions against the frame.
|
||||
struct Resolver {
|
||||
frame: Arc<RwLock<Frame>>,
|
||||
@@ -1029,4 +1228,44 @@ mod tests {
|
||||
// A transparent pixel keeps the colour the layers before gave (the dimension's #808080).
|
||||
assert_eq!(sky(0.7), [128.0 / 255.0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn biome_id_colormaps_read_the_column_their_mapper_gives_the_biome() {
|
||||
// Left to right: red, green, transparent, blue.
|
||||
let png = png_row(&[
|
||||
[255, 0, 0, 255],
|
||||
[0, 255, 0, 255],
|
||||
[9, 9, 9, 0],
|
||||
[0, 0, 255, 255],
|
||||
]);
|
||||
let env = with_pack_files(&[
|
||||
(
|
||||
"assets/minecraft/polytone/dimension_modifiers/overworld.json",
|
||||
br#"{"attributes_modifiers": {"visual/sky_color": "mypack:sky"}}"#,
|
||||
),
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/sky.json",
|
||||
br#"{"x_axis": "biome_id", "y_axis": 0, "biome_id_mapper": "mypack:biomes"}"#,
|
||||
),
|
||||
("assets/mypack/polytone/colormaps/sky.png", &png),
|
||||
(
|
||||
// Pixel columns from the left, over a texture_size of the image's width.
|
||||
"assets/mypack/polytone/biome_id_mappers/biomes.json",
|
||||
br#"{"minecraft:desert": 3, "swamp": 1, "minecraft:plains": 2, "texture_size": 4}"#,
|
||||
),
|
||||
]);
|
||||
let sky = |biome: &str| {
|
||||
env.set_frame(Frame {
|
||||
biome: biome.to_owned(),
|
||||
..Frame::default()
|
||||
});
|
||||
env.sample(&Sample::default()).sky_color
|
||||
};
|
||||
assert_eq!(sky("minecraft:desert"), [0.0, 0.0, 1.0]);
|
||||
assert_eq!(sky("minecraft:swamp"), [0.0, 1.0, 0.0]);
|
||||
// A biome the mapper doesn't name is 0: the first column.
|
||||
assert_eq!(sky("minecraft:forest"), [1.0, 0.0, 0.0]);
|
||||
// Plains land on the transparent pixel: the colour before stays.
|
||||
assert_eq!(sky("minecraft:plains"), [128.0 / 255.0; 3]);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use crate::{
|
||||
lang,
|
||||
options_ui::{Event, Page},
|
||||
settings::Settings,
|
||||
settings_shell::{self as shell, Group, Output, Row, Screen, Sheet, Value},
|
||||
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
|
||||
};
|
||||
|
||||
const TITLE: &str = "horizon.pages.extras";
|
||||
@@ -36,7 +36,7 @@ pub(crate) enum ExtraRow {
|
||||
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
|
||||
let e = &s.extras;
|
||||
let value = match (item.key_name(), item.switch(e)) {
|
||||
(Some(key), _) => Value::Key(key, item.value(e)),
|
||||
(Some(key), _) => Value::Binding(Binding::key(key, item.value(e))),
|
||||
(None, Some(on)) => Value::Switch(on),
|
||||
(None, None) => Value::Choice(item.value(e)),
|
||||
};
|
||||
@@ -116,13 +116,10 @@ impl Screen for ExtrasScreen {
|
||||
}
|
||||
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
|
||||
match row {
|
||||
ExtraRow::Item(item) => match item.key_name() {
|
||||
Some(key) => vec![Output::BindKey(key)],
|
||||
None => {
|
||||
item.cycle(&mut s.extras, back);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
},
|
||||
ExtraRow::Item(item) => {
|
||||
item.cycle(&mut s.extras, back);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
ExtraRow::Zoom(i) => {
|
||||
s.mods.toggle(i);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
@@ -208,15 +205,21 @@ mod tests {
|
||||
/// The sort key binds instead of cycling; the wheel leaves it be.
|
||||
#[test]
|
||||
fn the_key_row_waits_for_a_key() {
|
||||
use crate::settings_shell::Capture;
|
||||
let mut s = Settings::default();
|
||||
let mut screen = ExtrasScreen;
|
||||
s.extras.sort = true;
|
||||
let mut shell = Shell::<ExtrasScreen>::default();
|
||||
let row = ExtraRow::Item(Item::SortKey);
|
||||
let key = s.extras.sort_key.clone();
|
||||
let at = shell.point_at(&s, SIZE, row);
|
||||
assert_eq!(
|
||||
screen.press(&mut s, row, false),
|
||||
vec![Output::BindKey(extras::SORT_KEY)]
|
||||
shell.click(&mut s, at, SIZE, false, false, false),
|
||||
vec![
|
||||
Output::Event(Event::ButtonClick),
|
||||
Output::Bind(Capture::Key(extras::SORT_KEY))
|
||||
]
|
||||
);
|
||||
screen.wheel_step(&mut s, row, true);
|
||||
ExtrasScreen.wheel_step(&mut s, row, true);
|
||||
assert_eq!(s.extras.sort_key, key);
|
||||
}
|
||||
|
||||
|
||||
@@ -185,6 +185,7 @@ pub mod compositor;
|
||||
pub(crate) mod core_lines;
|
||||
pub(crate) mod core_sky;
|
||||
mod cull;
|
||||
mod dx12_compiler;
|
||||
mod end_sky;
|
||||
pub mod entity_skin;
|
||||
mod flat;
|
||||
@@ -241,6 +242,7 @@ mod visbuffer;
|
||||
mod wgsl_prefix;
|
||||
mod world_post;
|
||||
pub mod world_primitives;
|
||||
mod world_warp;
|
||||
|
||||
/// A section's instance: its camera-relative origin and, in `w`, the step of
|
||||
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
|
||||
@@ -1206,6 +1208,8 @@ pub struct Renderer {
|
||||
solid_cutout: SolidPipeline,
|
||||
translucent: wgpu::RenderPipeline,
|
||||
translucent_mark: wgpu::RenderPipeline,
|
||||
/// A native mod's world warp (`world_warp`): nothing made until a mod gives one.
|
||||
world_warps: world_warp::WorldWarps,
|
||||
/// Flat section draws (`flat`), where multi-draw is emulated.
|
||||
flat: Option<flat::Flat>,
|
||||
/// Sections upload their solid quads in the compact layout (`mesher::packed`): the solid
|
||||
@@ -1558,11 +1562,23 @@ pub struct Renderer {
|
||||
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
|
||||
pub separate_ao: bool,
|
||||
/// Shader-pack terrain pages used by post processing, retained between frames.
|
||||
pack_pages: Vec<(u32, wgpu::Buffer)>,
|
||||
pack_pages: Vec<pack_post::TerrainPage>,
|
||||
/// Camera-relative selection outline vertices, retained between frames.
|
||||
outline_edges_scratch: Vec<[f32; 6]>,
|
||||
/// Selection outline line vertices, retained between frames.
|
||||
outline_lines_scratch: Vec<[f32; 8]>,
|
||||
/// Debug line vertices, retained between frames.
|
||||
debug_lines_scratch: Vec<[f32; 8]>,
|
||||
/// Screen effect vertices, retained between frames.
|
||||
screen_vertices_scratch: Vec<crate::screen_effects::ScreenVertex>,
|
||||
/// LOD leaving chunk columns, retained between frames.
|
||||
lod_leaving_scratch: crate::fast_hash::FxHashSet<(i32, i32)>,
|
||||
/// Shadow visible boxed sections, retained between frames.
|
||||
shadow_boxed_scratch: Vec<resident::Visible>,
|
||||
/// Shadow visible seen section keys, retained between frames.
|
||||
shadow_seen_scratch: crate::fast_hash::FxHashSet<SectionKey>,
|
||||
/// Shadow translucent section draws, retained between frames.
|
||||
shadow_translucent_scratch: Vec<(u64, [u32; 5], (u32, Option<u32>))>,
|
||||
/// Lava's sprite layers, which a pack's solid terrain draws (`pack_atlas::terrain_parts`).
|
||||
pack_lava_layers: Vec<u16>,
|
||||
pub hud: HudRenderer,
|
||||
@@ -1956,11 +1972,14 @@ fn mip_chain(
|
||||
/// `allow_nonconformant` offers the adapters wgpu hides for reporting no Vulkan
|
||||
/// conformance version: Mesa's hasvk, which drives Ivy Bridge and Haswell,
|
||||
/// reports 0.0.0.0 while it works. `open_device` asks for them only on a second
|
||||
/// try at a backend that had no adapter at all.
|
||||
/// try at a backend that had no adapter at all. On DirectX 12, `dx12_compiler`
|
||||
/// compiles the shaders (`gfx/dx12_compiler.rs`), unless `WGPU_DX12_COMPILER`
|
||||
/// names another.
|
||||
fn instance_descriptor(
|
||||
backends: wgpu::Backends,
|
||||
window: Option<&Arc<Window>>,
|
||||
allow_nonconformant: bool,
|
||||
dx12_compiler: wgpu::Dx12Compiler,
|
||||
) -> wgpu::InstanceDescriptor {
|
||||
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
if let Some(window) = window {
|
||||
@@ -1980,6 +1999,7 @@ fn instance_descriptor(
|
||||
.flags
|
||||
.remove(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL);
|
||||
}
|
||||
descriptor.backend_options.dx12.shader_compiler = dx12_compiler;
|
||||
let mut descriptor = descriptor.with_env();
|
||||
if allow_nonconformant {
|
||||
descriptor
|
||||
@@ -2378,30 +2398,19 @@ async fn open_backend(
|
||||
allow_cpu: bool,
|
||||
) -> std::result::Result<Opened, NoDevice> {
|
||||
let name = backends_name(backends);
|
||||
let instance = wgpu::Instance::new(instance_descriptor(
|
||||
backends,
|
||||
Some(window),
|
||||
allow_nonconformant,
|
||||
));
|
||||
let surface = if offscreen {
|
||||
None
|
||||
} else {
|
||||
match instance.create_surface(window.clone()) {
|
||||
Ok(surface) => Some(surface),
|
||||
Err(e) => return Err(NoDevice::Failed(format!("{name} surface: {e}"))),
|
||||
}
|
||||
let open = |dx12_compiler| {
|
||||
wgpu::Instance::new(instance_descriptor(
|
||||
backends,
|
||||
Some(window),
|
||||
allow_nonconformant,
|
||||
dx12_compiler,
|
||||
))
|
||||
};
|
||||
let adapter = match instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
compatible_surface: surface.as_ref(),
|
||||
force_fallback_adapter: false,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(adapter) => adapter,
|
||||
Err(e) => return Err(NoDevice::NoAdapter(format!("{name}: {e}"))),
|
||||
let instance = open(wgpu::Dx12Compiler::Fxc);
|
||||
let (surface, adapter) = match surface_and_adapter(&instance, window, offscreen).await {
|
||||
Ok(found) => found,
|
||||
Err(NoDevice::Failed(e)) => return Err(NoDevice::Failed(format!("{name} surface: {e}"))),
|
||||
Err(NoDevice::NoAdapter(e)) => return Err(NoDevice::NoAdapter(format!("{name}: {e}"))),
|
||||
};
|
||||
// NoAdapter, so `open_with_retry` asks again: with the non-conformant
|
||||
// adapters shown, wgpu ranks a real GPU (hasvk on Ivy Bridge or Haswell)
|
||||
@@ -2412,6 +2421,27 @@ async fn open_backend(
|
||||
adapter.get_info().name
|
||||
)));
|
||||
}
|
||||
// An integrated GPU on DirectX 12 compiles with DXC: the same request
|
||||
// again, on an instance that loads the DLL beside the game. Anything that
|
||||
// goes wrong keeps the FXC instance.
|
||||
let forced = dx12_compiler::forced();
|
||||
let dxc = if dx12_compiler::wants_dxc(&adapter.get_info(), forced) {
|
||||
open_dxc(&adapter.get_info(), window, offscreen, open).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let dxc_found = dxc.is_some();
|
||||
let (instance, surface, adapter) = dxc.unwrap_or((instance, surface, adapter));
|
||||
if adapter.get_info().backend == wgpu::Backend::Dx12 {
|
||||
crate::crash::set_detail(
|
||||
"DirectX 12 shader compiler",
|
||||
match (forced, dxc_found) {
|
||||
(true, _) => "WGPU_DX12_COMPILER",
|
||||
(false, true) => "DXC",
|
||||
(false, false) => "FXC",
|
||||
},
|
||||
);
|
||||
}
|
||||
describe_adapter(&adapter);
|
||||
let info = adapter.get_info();
|
||||
let limits = adapter.limits();
|
||||
@@ -2499,6 +2529,73 @@ async fn open_backend(
|
||||
Err(e) => Err(NoDevice::Failed(format!("{name} device: {e}"))),
|
||||
}
|
||||
}
|
||||
/// A surface for `window` (none `offscreen`) and the adapter `open_backend`
|
||||
/// asks `instance` for. `Failed` is the surface's error, `NoAdapter` the
|
||||
/// request's.
|
||||
async fn surface_and_adapter(
|
||||
instance: &wgpu::Instance,
|
||||
window: &Arc<Window>,
|
||||
offscreen: bool,
|
||||
) -> std::result::Result<(Option<wgpu::Surface<'static>>, wgpu::Adapter), NoDevice> {
|
||||
let surface = if offscreen {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
instance
|
||||
.create_surface(window.clone())
|
||||
.map_err(|e| NoDevice::Failed(e.to_string()))?,
|
||||
)
|
||||
};
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
compatible_surface: surface.as_ref(),
|
||||
force_fallback_adapter: false,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| NoDevice::NoAdapter(e.to_string()))?;
|
||||
Ok((surface, adapter))
|
||||
}
|
||||
/// The DirectX 12 instance, surface and adapter with DXC for the integrated
|
||||
/// GPU `fxc` (`gfx/dx12_compiler.rs`), `open` making the instance; `None`, with
|
||||
/// one line in the log, when the DLL is missing, doesn't load, or the instance
|
||||
/// offers another GPU: the FXC instance stays.
|
||||
async fn open_dxc(
|
||||
fxc: &wgpu::AdapterInfo,
|
||||
window: &Arc<Window>,
|
||||
offscreen: bool,
|
||||
open: impl FnOnce(wgpu::Dx12Compiler) -> wgpu::Instance,
|
||||
) -> Option<(
|
||||
wgpu::Instance,
|
||||
Option<wgpu::Surface<'static>>,
|
||||
wgpu::Adapter,
|
||||
)> {
|
||||
let Some(dll) = dx12_compiler::bundled_dll() else {
|
||||
log::info!(
|
||||
"Horizon renderer: no {} beside the game; DirectX 12 compiles shaders with FXC",
|
||||
dx12_compiler::DLL
|
||||
);
|
||||
return None;
|
||||
};
|
||||
let instance = open(dx12_compiler::dxc(&dll));
|
||||
let why = match surface_and_adapter(&instance, window, offscreen).await {
|
||||
Ok((surface, adapter)) if dx12_compiler::same_adapter(fxc, &adapter.get_info()) => {
|
||||
log::info!(
|
||||
"Horizon renderer: DirectX 12 compiles shaders with DXC ({}) on this integrated GPU",
|
||||
dll.display()
|
||||
);
|
||||
return Some((instance, surface, adapter));
|
||||
}
|
||||
Ok((_, adapter)) => format!("it offered another GPU, {}", adapter.get_info().name),
|
||||
Err(NoDevice::Failed(e) | NoDevice::NoAdapter(e)) => e,
|
||||
};
|
||||
log::warn!(
|
||||
"Horizon renderer: DXC from {} didn't load ({why}); DirectX 12 compiles shaders with FXC",
|
||||
dll.display()
|
||||
);
|
||||
None
|
||||
}
|
||||
/// What to tell a player whose system offers no usable graphics adapter.
|
||||
/// Vulkan is loaded at run time (the game links no graphics library), so a
|
||||
/// missing driver shows here rather than as a missing-DLL dialog.
|
||||
@@ -4756,6 +4853,7 @@ impl Renderer {
|
||||
// and cave culling can drop the listed count below MIN_SECTIONS on a normal view:
|
||||
// so Metal draws vanilla terrain flat at any count.
|
||||
let min_sections = if flat::is_broken_intel_vulkan(&info)
|
||||
|| flat::is_adreno_dx12(&info)
|
||||
|| info.backend == wgpu::Backend::Metal
|
||||
|| std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
|
||||
{
|
||||
@@ -4820,6 +4918,16 @@ impl Renderer {
|
||||
// (`follow_packed_layout`).
|
||||
let packed_quads = flat.is_some() && !shader_pack && packed_wanted();
|
||||
packed::pack_quads(packed_quads);
|
||||
// A native mod's world warp builds its pipelines with these layouts once it is given one.
|
||||
let world_warps = world_warp::WorldWarps::new(world_warp::Kit {
|
||||
terrain: bindings.clone(),
|
||||
pages: flat.as_ref().map(|f| f.draw_layout.clone()),
|
||||
entity: entity_bindings.clone(),
|
||||
static_entity: static_entity_bindings.clone(),
|
||||
format: frame_format,
|
||||
sprite_constants,
|
||||
hdr_output: hdr_output.is_some(),
|
||||
});
|
||||
let visbuf = flat
|
||||
.as_ref()
|
||||
.filter(|_| visbuffer::wanted(&device))
|
||||
@@ -4970,6 +5078,7 @@ impl Renderer {
|
||||
solid_cutout,
|
||||
translucent,
|
||||
translucent_mark,
|
||||
world_warps,
|
||||
flat_on: flat.is_some(),
|
||||
packed_quads,
|
||||
cull_on: cull.is_some(),
|
||||
@@ -5178,6 +5287,12 @@ impl Renderer {
|
||||
pack_pages: Vec::new(),
|
||||
outline_edges_scratch: Vec::new(),
|
||||
outline_lines_scratch: Vec::new(),
|
||||
debug_lines_scratch: Vec::new(),
|
||||
screen_vertices_scratch: Vec::new(),
|
||||
lod_leaving_scratch: crate::fast_hash::FxHashSet::default(),
|
||||
shadow_boxed_scratch: Vec::new(),
|
||||
shadow_seen_scratch: crate::fast_hash::FxHashSet::default(),
|
||||
shadow_translucent_scratch: Vec::new(),
|
||||
pack_lava_layers: Vec::new(),
|
||||
hud,
|
||||
menu_blur: None,
|
||||
@@ -6048,6 +6163,31 @@ impl Renderer {
|
||||
id
|
||||
});
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
if solid_slots > 0 {
|
||||
self.arena.write_sources(
|
||||
&self.device,
|
||||
encoder,
|
||||
&mut self.section_uploads,
|
||||
page,
|
||||
start,
|
||||
solid_slots,
|
||||
if packed { &[] } else { &mesh.solid_sources },
|
||||
);
|
||||
}
|
||||
if translucent_vertices > 0 {
|
||||
self.translucent_arena
|
||||
.as_mut()
|
||||
.unwrap_or(&mut self.arena)
|
||||
.write_sources(
|
||||
&self.device,
|
||||
encoder,
|
||||
&mut self.section_uploads,
|
||||
translucent_at.0,
|
||||
translucent_at.1,
|
||||
translucent_vertices,
|
||||
&mesh.translucent_sources,
|
||||
);
|
||||
}
|
||||
let translucent_arena = self.translucent_arena.as_ref().unwrap_or(&self.arena);
|
||||
let solid_bytes: &[u8] = if packed {
|
||||
bytemuck::cast_slice(&mesh.packed)
|
||||
@@ -7078,8 +7218,8 @@ impl Renderer {
|
||||
self.world_post.upscale = if taa { upscale.clamp(1.0, 4.0) } else { 1.0 };
|
||||
}
|
||||
/// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`;
|
||||
/// `block_ids` gives each block sprite layer's `mc_Entity` (id, fluid) and `at_midBlock.w`
|
||||
/// (its block's light), three values a layer.
|
||||
/// `block_ids` gives each source state its model and fluid material ids (two values
|
||||
/// per state); emission and ownership come from the mesher's quad records.
|
||||
/// Returns the programs that run and the notes of loading them, or why the pack can't run:
|
||||
/// the dimension then runs without a pack.
|
||||
pub fn set_shader_pack(
|
||||
@@ -8441,10 +8581,37 @@ impl Renderer {
|
||||
0,
|
||||
bytemuck::bytes_of(&crosshair_globals),
|
||||
);
|
||||
let pack_terrain = !frame.title
|
||||
&& self
|
||||
.pack_post
|
||||
.as_ref()
|
||||
.and_then(|p| p.1.as_ref())
|
||||
.is_some_and(|p| p.has_terrain());
|
||||
// A native mod's world warp (`world_warp`), asked once a frame. While it draws, nothing is
|
||||
// left out by where it was (the frustum, the caves, occlusion, faces turned away): the warp
|
||||
// may bring it into view. Its frames keep to the per-section draws its pipelines are built
|
||||
// for. A shader pack that draws the terrain itself (`pack_terrain`) isn't warped.
|
||||
let warp_on = !frame.title && {
|
||||
let warp = native_render::world_warp();
|
||||
self.world_warps.frame(
|
||||
&self.device,
|
||||
&mut self.uploads,
|
||||
&mut encoder,
|
||||
&warp,
|
||||
frame.camera,
|
||||
// (the frame's partial tick: `weather::Time::new`'s third value)
|
||||
frame.weather_time.values[2],
|
||||
!pack_terrain,
|
||||
)
|
||||
};
|
||||
// Frustum culling and camera-relative origins.
|
||||
let cull_proj = views.cull().view_proj(pose);
|
||||
let view = Frustum {
|
||||
planes: frustum(cull_proj),
|
||||
planes: if warp_on {
|
||||
[Vec4::W; 6]
|
||||
} else {
|
||||
frustum(cull_proj)
|
||||
},
|
||||
};
|
||||
let camera = frame.camera;
|
||||
let camera_section = camera_section(camera, self.layout.min_y());
|
||||
@@ -8452,7 +8619,7 @@ impl Renderer {
|
||||
// (above the screen's top, the far shore, the trees against the sky).
|
||||
let mirror_plane = frame
|
||||
.water_plane
|
||||
.filter(|_| !frame.title && self.wants_water_plane());
|
||||
.filter(|_| !frame.title && !warp_on && self.wants_water_plane());
|
||||
let mirror = mirror_plane.map(|plane| Frustum {
|
||||
planes: frustum(mirror_view_projection(
|
||||
cull_proj,
|
||||
@@ -8462,11 +8629,11 @@ impl Renderer {
|
||||
// Solid quads facing away from the eye are left out: the bob moves
|
||||
// the eye off `camera`, so it is found from the projection. With the mirror, those
|
||||
// facing its eye are kept too: it sits straight below, at this camera-relative y.
|
||||
let eye = projection_center(cull_proj).filter(|_| self.sides_on);
|
||||
let eye = projection_center(cull_proj).filter(|_| self.sides_on && !warp_on);
|
||||
let mirror_low = mirror_plane
|
||||
.zip(eye)
|
||||
.map(|(plane, eye)| 2.0 * (plane - frame.camera.y as f32) - eye.y);
|
||||
let culling = !frame.title && frame.cave_culling;
|
||||
let culling = !frame.title && frame.cave_culling && !warp_on;
|
||||
if culling {
|
||||
crate::spans::time("occlusion", || {
|
||||
self.start_occlusion(camera, frame.detail_distance)
|
||||
@@ -8487,12 +8654,6 @@ impl Renderer {
|
||||
native.shadow.as_ref()?;
|
||||
native.frame.shadow_view(frame)
|
||||
});
|
||||
let pack_terrain = !frame.title
|
||||
&& self
|
||||
.pack_post
|
||||
.as_ref()
|
||||
.and_then(|p| p.1.as_ref())
|
||||
.is_some_and(|p| p.has_terrain());
|
||||
// `LevelRenderer.extractSectionDrawGroups`' fade-in time, for the instances.
|
||||
let fade_ms = fade_millis(frame.chunk_fade);
|
||||
// GPU culling's fading sections, decided at `fades.now`: before the gate, the record
|
||||
@@ -8526,7 +8687,8 @@ impl Renderer {
|
||||
&& !pack_terrain
|
||||
&& shadow_view.is_none()
|
||||
&& mirror_plane.is_none()
|
||||
&& !displaced;
|
||||
&& !displaced
|
||||
&& !warp_on;
|
||||
#[cfg(debug_assertions)]
|
||||
if gpu_frame {
|
||||
if self.gpu_checks.is_multiple_of(64) {
|
||||
@@ -8582,11 +8744,10 @@ impl Renderer {
|
||||
let cutout = &self.lod_cutout;
|
||||
// Sections fading out (`leave_section`) are past the render distance:
|
||||
// drawn there until they have, or they popped out at once.
|
||||
let leaving: crate::fast_hash::FxHashSet<(i32, i32)> = self
|
||||
.lod_leaving
|
||||
.iter()
|
||||
.map(|&((x, _, z), _)| (x, z))
|
||||
.collect();
|
||||
self.lod_leaving_scratch.clear();
|
||||
self.lod_leaving_scratch
|
||||
.extend(self.lod_leaving.iter().map(|&((x, _, z), _)| (x, z)));
|
||||
let leaving = &self.lod_leaving_scratch;
|
||||
let mut visible = std::mem::take(&mut self.visible);
|
||||
visible.clear();
|
||||
crate::spans::time("visibility", || {
|
||||
@@ -8633,6 +8794,8 @@ impl Renderer {
|
||||
casters: v.view_casters.then(|| shadow_casters(cull_proj, v.light)),
|
||||
reach: v.distance,
|
||||
};
|
||||
let mut boxed = std::mem::take(&mut self.shadow_boxed_scratch);
|
||||
let mut seen = std::mem::take(&mut self.shadow_seen_scratch);
|
||||
crate::spans::time("shadow visibility", || {
|
||||
// Within the unculled distance every section, then those in the light's box.
|
||||
if let Some(d) = v.unculled {
|
||||
@@ -8653,13 +8816,15 @@ impl Renderer {
|
||||
&mut shadow_visible,
|
||||
);
|
||||
}
|
||||
let mut boxed = Vec::new();
|
||||
boxed.clear();
|
||||
self.sections
|
||||
.visible(camera, radius, &light, |_| true, |_, _| false, &mut boxed);
|
||||
let seen: crate::fast_hash::FxHashSet<_> =
|
||||
shadow_visible.iter().map(|s| s.key).collect();
|
||||
shadow_visible.extend(boxed.into_iter().filter(|s| !seen.contains(&s.key)));
|
||||
seen.clear();
|
||||
seen.extend(shadow_visible.iter().map(|s| s.key));
|
||||
shadow_visible.extend(boxed.drain(..).filter(|s| !seen.contains(&s.key)));
|
||||
});
|
||||
self.shadow_boxed_scratch = boxed;
|
||||
self.shadow_seen_scratch = seen;
|
||||
// Nearest the light first, so the depth test rejects what lies under it.
|
||||
let light = v.light;
|
||||
shadow_visible.sort_unstable_by(|a, b| {
|
||||
@@ -8821,6 +8986,7 @@ impl Renderer {
|
||||
&& self.multi_draw
|
||||
// Occlusion is found from the camera, not the mirror's eye.
|
||||
&& mirror.is_none()
|
||||
&& !warp_on
|
||||
// It tests the CPU's lists, which leave out GPU culling's sections.
|
||||
&& !gpu_frame
|
||||
&& visible.len() >= c.min_sections
|
||||
@@ -8834,7 +9000,7 @@ impl Renderer {
|
||||
// ones (`Part`), each solid pass drawing its own: `[..opaque_draws]`,
|
||||
// then the rest; phase 2 likewise. A fading list's quads all draw in the
|
||||
// cutout pass.
|
||||
let split_solid = self.info.backend == wgpu::Backend::Metal;
|
||||
let split_solid = self.info.backend == wgpu::Backend::Metal && !warp_on;
|
||||
let parts = |fading: bool| -> &'static [(u8, Quads)] {
|
||||
match (split_solid, fading) {
|
||||
(false, _) => &[(0, Quads::All)],
|
||||
@@ -9130,7 +9296,8 @@ impl Renderer {
|
||||
// them for `every_face`), then translucent ones (in their last sorted order; depth
|
||||
// decides there).
|
||||
let shadow_first = self.draws.len() as u32;
|
||||
let mut shadow_translucent = Vec::new();
|
||||
let mut shadow_translucent = std::mem::take(&mut self.shadow_translucent_scratch);
|
||||
shadow_translucent.clear();
|
||||
self.shadow_runs.clear();
|
||||
if let Some(view) = &shadow_view {
|
||||
let base = listed;
|
||||
@@ -9192,12 +9359,13 @@ impl Renderer {
|
||||
self.shadow_runs
|
||||
.regroup(&mut self.draws, &mut self.draw_pages, shadow_first as usize);
|
||||
self.shadow_runs.clear();
|
||||
for (distance, draw, pages) in shadow_translucent {
|
||||
for (distance, draw, pages) in shadow_translucent.drain(..) {
|
||||
self.shadow_runs
|
||||
.push(distance, pages.0, self.draws.len(), 1, [0; 3]);
|
||||
self.draws.push(draw);
|
||||
self.draw_pages.push(pages);
|
||||
}
|
||||
self.shadow_translucent_scratch = shadow_translucent;
|
||||
self.shadow_runs.regroup(
|
||||
&mut self.draws,
|
||||
&mut self.draw_pages,
|
||||
@@ -9243,16 +9411,19 @@ impl Renderer {
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
}
|
||||
let all: Vec<[f32; 8]> = debug_lines_opaque_first(frame.debug_lines)
|
||||
.chain(frame.debug_lines_on_top)
|
||||
.chain(frame.debug_crosshair)
|
||||
.copied()
|
||||
.collect();
|
||||
let lines = &mut self.debug_lines_scratch;
|
||||
lines.clear();
|
||||
lines.extend(
|
||||
debug_lines_opaque_first(frame.debug_lines)
|
||||
.chain(frame.debug_lines_on_top)
|
||||
.chain(frame.debug_crosshair)
|
||||
.copied(),
|
||||
);
|
||||
self.uploads.buffer(
|
||||
&mut encoder,
|
||||
&self.debug_line_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&all),
|
||||
bytemuck::cast_slice(lines),
|
||||
);
|
||||
}
|
||||
self.weather.prepare(
|
||||
@@ -9394,6 +9565,7 @@ impl Renderer {
|
||||
// path.
|
||||
let oit_on = self.improved_transparency
|
||||
&& !frame.title
|
||||
&& !warp_on
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& self.native.is_none()
|
||||
&& oit::supported(&self.device);
|
||||
@@ -9765,8 +9937,9 @@ impl Renderer {
|
||||
// Every upload before the frame's first pass: a pass ends wgpu-core's
|
||||
// command buffer, so copies between passes would need one more.
|
||||
if !frame.screen.is_empty() {
|
||||
let vertices: Vec<crate::screen_effects::ScreenVertex> =
|
||||
frame.screen.iter().flat_map(|q| q.vertices).collect();
|
||||
let vertices = &mut self.screen_vertices_scratch;
|
||||
vertices.clear();
|
||||
vertices.extend(frame.screen.iter().flat_map(|q| q.vertices));
|
||||
if vertices.len() as u64 > self.screen_capacity {
|
||||
self.screen_capacity = (vertices.len() as u64).next_power_of_two();
|
||||
self.screen_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
@@ -9781,7 +9954,7 @@ impl Renderer {
|
||||
&mut encoder,
|
||||
&self.screen_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&vertices),
|
||||
bytemuck::cast_slice(vertices),
|
||||
);
|
||||
}
|
||||
self.hud.prepare(
|
||||
@@ -9807,6 +9980,7 @@ impl Renderer {
|
||||
// They copy the CPU's lists alone (GPU culling is never offered
|
||||
// where vanilla frames draw flat).
|
||||
&& !gpu_frame
|
||||
&& !warp_on
|
||||
&& !self.draws.is_empty()
|
||||
&& (listed >= flat.min_sections
|
||||
|| pack_terrain
|
||||
@@ -10355,16 +10529,25 @@ impl Renderer {
|
||||
let material = self
|
||||
.native
|
||||
.as_ref()
|
||||
.filter(|_| !frame.title)
|
||||
.filter(|_| !frame.title && !warp_on)
|
||||
.and_then(|n| Some((n.material.as_ref()?, &n.frame.group)));
|
||||
// Without HDR output, the solid shaders without its lighting (`SolidPipeline`).
|
||||
let hdr = self.hdr.is_some();
|
||||
let solid_pipeline = material.map_or(self.solid.get(hdr), |(m, _)| &m.solid);
|
||||
// A world warp's pipelines in place of the terrain's and the entities' (`warp_on`).
|
||||
let warped = self.world_warps.pipelines().filter(|_| warp_on);
|
||||
let warp_group = self.world_warps.group().filter(|_| warp_on);
|
||||
let solid_pipeline = match warped {
|
||||
Some(w) => w.solid.get(hdr),
|
||||
None => material.map_or(self.solid.get(hdr), |(m, _)| &m.solid),
|
||||
};
|
||||
// Two solid passes only where a discard costs the GPU its hidden surface
|
||||
// removal: Apple's tiled GPUs, behind Metal. Elsewhere the second pass
|
||||
// would double the solid draw calls and vertex work for nothing.
|
||||
let split_solid = self.info.backend == wgpu::Backend::Metal;
|
||||
let translucent_pipeline = material.map_or(&self.translucent, |(m, _)| &m.translucent);
|
||||
let split_solid = self.info.backend == wgpu::Backend::Metal && !warp_on;
|
||||
let translucent_pipeline = match warped {
|
||||
Some(w) => &w.translucent,
|
||||
None => material.map_or(&self.translucent, |(m, _)| &m.translucent),
|
||||
};
|
||||
let pack_flat = material.and_then(|(m, _)| m.flat.as_ref());
|
||||
// A material stage's translucent pass reads a copy of the depth the solid terrain
|
||||
// left: it is stored across the pass that ends before the copy.
|
||||
@@ -10384,6 +10567,7 @@ impl Renderer {
|
||||
.filter(|n| {
|
||||
!frame.title
|
||||
&& !pack_draws_terrain
|
||||
&& !warp_on
|
||||
&& n.material.as_ref().is_some_and(|m| m.writes_surface)
|
||||
})
|
||||
.map(|n| n.frame.surface_view.clone());
|
||||
@@ -10686,6 +10870,9 @@ impl Renderer {
|
||||
let pulled = material.is_none() && self.flat.is_some();
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(1, self.instances.slice(..));
|
||||
if let Some(group) = warp_group {
|
||||
pass.set_bind_group(2, group, &[]);
|
||||
}
|
||||
match cull {
|
||||
Some(cull) => {
|
||||
// Phase 1 and the boxes against its depth (in
|
||||
@@ -10817,7 +11004,7 @@ impl Renderer {
|
||||
if let Some(n) = self
|
||||
.native
|
||||
.as_ref()
|
||||
.filter(|_| !frame.title && !pack_draws_terrain)
|
||||
.filter(|_| !frame.title && !pack_draws_terrain && !warp_on)
|
||||
&& let Some(d) = &n.displacement
|
||||
&& let Some(pipeline) = n.material.as_ref().and_then(|m| m.displaced.as_ref())
|
||||
{
|
||||
@@ -10829,8 +11016,17 @@ impl Renderer {
|
||||
drop(pass);
|
||||
pass = plain_pass(&mut encoder);
|
||||
}
|
||||
// A world warp's entity pipelines read its group 2.
|
||||
if let Some(group) = warp_group {
|
||||
pass.set_bind_group(2, group, &[]);
|
||||
}
|
||||
let hdr_mark = warped.map_or(&self.hdr_mark_pipeline, |w| &w.hdr_mark);
|
||||
let hdr_entity_mark =
|
||||
warped.map_or(&self.hdr_entity_mark_pipeline, |w| &w.hdr_entity_mark);
|
||||
if static_entity_quads > 0 {
|
||||
pass.set_pipeline(&self.static_entity_pipeline);
|
||||
pass.set_pipeline(
|
||||
warped.map_or(&self.static_entity_pipeline, |w| &w.static_entities),
|
||||
);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_bind_group(1, &self.static_entity_group, &[]);
|
||||
pass.set_index_buffer(
|
||||
@@ -10853,18 +11049,18 @@ impl Renderer {
|
||||
}
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
if entity_quads > 0 {
|
||||
pass.set_pipeline(&self.entity_pipeline);
|
||||
pass.set_pipeline(warped.map_or(&self.entity_pipeline, |w| &w.entities));
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
if light_marks(self.frame_format, self.hdr.is_some()) {
|
||||
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
|
||||
pass.set_pipeline(hdr_entity_mark);
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if text_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.text_polygon_offset_pipeline);
|
||||
pass.set_pipeline(warped.map_or(&self.text_polygon_offset_pipeline, |w| &w.text));
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..text_quads * 6, (entity_quads * 4) as i32, 0..1);
|
||||
@@ -10873,7 +11069,7 @@ impl Renderer {
|
||||
// FeatureRenderDispatcher.PreparedFrame.executeTranslucent: the shadows
|
||||
// come before the translucent items, which then cover them (#7026).
|
||||
if shadow_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.shadow_blob_pipeline);
|
||||
pass.set_pipeline(warped.map_or(&self.shadow_blob_pipeline, |w| &w.entity_shadows));
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
@@ -10888,7 +11084,9 @@ impl Renderer {
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if translucent_item_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.translucent_item_pipeline);
|
||||
pass.set_pipeline(
|
||||
warped.map_or(&self.translucent_item_pipeline, |w| &w.translucent_items),
|
||||
);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
@@ -10898,7 +11096,7 @@ impl Renderer {
|
||||
);
|
||||
if light_marks(self.frame_format, self.hdr.is_some()) {
|
||||
// A glowing item in the world (Item sheet quads), as entities are.
|
||||
pass.set_pipeline(&self.hdr_mark_pipeline);
|
||||
pass.set_pipeline(hdr_mark);
|
||||
pass.draw_indexed(
|
||||
0..translucent_item_quads * 6,
|
||||
((base_quads - translucent_item_quads) * 4) as i32,
|
||||
@@ -11295,6 +11493,9 @@ impl Renderer {
|
||||
}
|
||||
None => {
|
||||
pass.set_pipeline(translucent_pipeline);
|
||||
if let Some(group) = warp_group {
|
||||
pass.set_bind_group(2, group, &[]);
|
||||
}
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(1, self.instances.slice(..));
|
||||
self.draw_sections(
|
||||
@@ -11304,7 +11505,15 @@ impl Renderer {
|
||||
solid_draws,
|
||||
translucent_draws,
|
||||
);
|
||||
self.mark_translucent_terrain(&mut pass, None, solid_draws, translucent_draws);
|
||||
// (its HDR mark would fall where the water was before the warp moved it)
|
||||
if !warp_on {
|
||||
self.mark_translucent_terrain(
|
||||
&mut pass,
|
||||
None,
|
||||
solid_draws,
|
||||
translucent_draws,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// LevelRenderer.executeTranslucentAfterTerrain: vanilla's
|
||||
@@ -11312,7 +11521,10 @@ impl Renderer {
|
||||
// the quads write depth, so before it they would hide the glass
|
||||
// or water behind them.
|
||||
if translucent_particle_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.entity_pipeline);
|
||||
pass.set_pipeline(warped.map_or(&self.entity_pipeline, |w| &w.entities));
|
||||
if let Some(group) = warp_group {
|
||||
pass.set_bind_group(2, group, &[]);
|
||||
}
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -11323,7 +11535,7 @@ impl Renderer {
|
||||
);
|
||||
if light_marks(self.frame_format, self.hdr.is_some()) {
|
||||
// Fireworks: their sparks are translucent-layer particles.
|
||||
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
|
||||
pass.set_pipeline(hdr_entity_mark);
|
||||
pass.draw_indexed(
|
||||
0..translucent_particle_quads * 6,
|
||||
((base_quads - translucent_item_quads - translucent_particle_quads) * 4)
|
||||
@@ -11451,8 +11663,11 @@ impl Renderer {
|
||||
let flat = self.flat.as_ref().filter(|_| flat_frame);
|
||||
self.pack_pages.clear();
|
||||
if flat.is_some() && (pack_draws_terrain || pack_draws_shadow) {
|
||||
self.pack_pages
|
||||
.extend(self.arena.page_buffers().map(|(p, b)| (p, b.clone())));
|
||||
self.pack_pages.extend(
|
||||
self.arena
|
||||
.page_buffers()
|
||||
.map(|(p, b)| (p, b.clone(), self.arena.sources(p).cloned())),
|
||||
);
|
||||
}
|
||||
let pages = &self.pack_pages;
|
||||
let mut terrain =
|
||||
@@ -13778,7 +13993,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_second_try_at_a_backend_shows_nonconformant_adapters() {
|
||||
let shown = |allow| {
|
||||
super::instance_descriptor(wgpu::Backends::VULKAN, None, allow)
|
||||
super::instance_descriptor(wgpu::Backends::VULKAN, None, allow, wgpu::Dx12Compiler::Fxc)
|
||||
.flags
|
||||
.contains(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER)
|
||||
};
|
||||
@@ -13845,6 +14060,14 @@ mod tests {
|
||||
} else if name == "oit_clouds.wgsl" {
|
||||
let base = super::entity_shader() + include_str!("oit.wgsl");
|
||||
sources.push((path, base + &text));
|
||||
} else if name == "world_warp_fragment.wgsl" {
|
||||
// A world warp's copy of terrain.wgsl (gfx/world_warp.rs), with the plainest warp.
|
||||
let warp = super::world_warp::IDENTITY;
|
||||
sources.push((path, super::world_warp::terrain_shader(warp).unwrap()));
|
||||
} else if name == "world_warp.wgsl" {
|
||||
// And of entity.wgsl with static_entity.wgsl.
|
||||
let warp = super::world_warp::IDENTITY;
|
||||
sources.push((path, super::world_warp::entity_shader(warp).unwrap()));
|
||||
} else {
|
||||
sources.push((path, text));
|
||||
}
|
||||
@@ -14607,7 +14830,7 @@ mod tests {
|
||||
#[test]
|
||||
fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() {
|
||||
let validated = |backends| {
|
||||
super::instance_descriptor(backends, None, false)
|
||||
super::instance_descriptor(backends, None, false, wgpu::Dx12Compiler::Fxc)
|
||||
.flags
|
||||
.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)
|
||||
};
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
//! (`Arena::translucent_indices`): their back-to-front list then stays in one
|
||||
//! page of each and draws in a few multi-draws.
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
#[cfg(test)]
|
||||
#[path = "arena/source_tests.rs"]
|
||||
mod source_tests;
|
||||
|
||||
/// Ranges are rounded up to this many vertices to limit fragmentation, and
|
||||
/// start at multiples of it (`flat` names sections by these blocks).
|
||||
@@ -82,6 +85,8 @@ impl FreeList {
|
||||
/// One vertex buffer of the arena; only the newest page grows.
|
||||
struct Page {
|
||||
buffer: wgpu::Buffer,
|
||||
/// Optional shader-pack source records, indexed by the vertex index / 4.
|
||||
sources: Option<wgpu::Buffer>,
|
||||
/// Capacity in vertices.
|
||||
capacity: u64,
|
||||
free: FreeList,
|
||||
@@ -116,6 +121,7 @@ pub struct Arena {
|
||||
closing: Option<u32>,
|
||||
/// A page had to go to system memory (logged once).
|
||||
spilled: bool,
|
||||
sources_enabled: bool,
|
||||
/// Tests: GPU-local pages fail as if video memory were full.
|
||||
#[cfg(test)]
|
||||
video_memory_full: bool,
|
||||
@@ -200,6 +206,7 @@ impl Arena {
|
||||
usage,
|
||||
closing: None,
|
||||
spilled: false,
|
||||
sources_enabled: false,
|
||||
#[cfg(test)]
|
||||
video_memory_full: false,
|
||||
}
|
||||
@@ -249,7 +256,11 @@ impl Arena {
|
||||
}
|
||||
/// Bytes of every page's buffer.
|
||||
pub fn bytes(&self) -> u64 {
|
||||
self.pages.iter().flatten().map(|p| p.capacity).sum::<u64>() * self.stride
|
||||
self.pages
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|p| p.capacity * self.stride + p.sources.as_ref().map_or(0, wgpu::Buffer::size))
|
||||
.sum()
|
||||
}
|
||||
/// The most vertices a page holds.
|
||||
pub fn page_vertices(&self) -> u64 {
|
||||
@@ -281,6 +292,65 @@ impl Arena {
|
||||
.expect("live arena page")
|
||||
.buffer
|
||||
}
|
||||
|
||||
pub fn sources(&self, page: u32) -> Option<&wgpu::Buffer> {
|
||||
self.pages[page as usize].as_ref()?.sources.as_ref()
|
||||
}
|
||||
|
||||
fn source_buffer(device: &wgpu::Device, capacity: u64) -> Option<wgpu::Buffer> {
|
||||
if capacity / 4 * 8 > device.limits().max_storage_buffer_binding_size {
|
||||
return None;
|
||||
}
|
||||
try_create(
|
||||
device,
|
||||
"Terrain quad sources",
|
||||
capacity / 4 * 8,
|
||||
wgpu::BufferUsages::STORAGE
|
||||
| wgpu::BufferUsages::COPY_DST
|
||||
| wgpu::BufferUsages::COPY_SRC,
|
||||
)
|
||||
}
|
||||
|
||||
/// Native meshes allocate no sidecar. Once needed, existing pages also get a zeroed
|
||||
/// sidecar so moves can retain sources. Failure leaves geometry intact and attributes
|
||||
/// unknown; a later source upload may retry its page.
|
||||
pub fn write_sources(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
page: u32,
|
||||
start: u64,
|
||||
vertices: u64,
|
||||
sources: &[crate::mesher::TerrainSource],
|
||||
) {
|
||||
if !sources.is_empty() && !self.sources_enabled {
|
||||
self.sources_enabled = true;
|
||||
for p in self.pages.iter_mut().flatten() {
|
||||
p.sources = Self::source_buffer(device, p.capacity);
|
||||
}
|
||||
}
|
||||
let p = self.pages[page as usize].as_mut().expect("live arena page");
|
||||
if p.sources.is_none() && !sources.is_empty() {
|
||||
p.sources = Self::source_buffer(device, p.capacity);
|
||||
}
|
||||
if let Some(buffer) = &p.sources {
|
||||
let bytes = vertices / 4 * 8;
|
||||
if bytes > 0 {
|
||||
// Recycled ranges must never keep another mesh's identity, even for a
|
||||
// missing or incomplete sidecar. Zero records have no valid bit.
|
||||
encoder.clear_buffer(buffer, start / 4 * 8, Some(bytes));
|
||||
if sources.len() as u64 == vertices / 4 {
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
buffer,
|
||||
start / 4 * 8,
|
||||
bytemuck::cast_slice(sources),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Bytes of one vertex (or index).
|
||||
pub fn stride(&self) -> u64 {
|
||||
self.stride
|
||||
@@ -336,6 +406,10 @@ impl Arena {
|
||||
let start = free.take(len).expect("a new page fits one section");
|
||||
let page = Page {
|
||||
buffer,
|
||||
sources: self
|
||||
.sources_enabled
|
||||
.then(|| Self::source_buffer(device, capacity))
|
||||
.flatten(),
|
||||
capacity,
|
||||
free,
|
||||
host,
|
||||
@@ -451,6 +525,10 @@ impl Arena {
|
||||
let free = FreeList::new(capacity);
|
||||
self.pages.push(Some(Page {
|
||||
buffer,
|
||||
sources: self
|
||||
.sources_enabled
|
||||
.then(|| Self::source_buffer(device, capacity))
|
||||
.flatten(),
|
||||
capacity,
|
||||
free,
|
||||
host,
|
||||
@@ -483,6 +561,21 @@ impl Arena {
|
||||
to.1 * self.stride,
|
||||
vertices * self.stride,
|
||||
);
|
||||
if let Some(to_sources) = self.sources(to.0) {
|
||||
let bytes = vertices / 4 * 8;
|
||||
if bytes > 0 {
|
||||
match self.sources(from.0) {
|
||||
Some(from_sources) => encoder.copy_buffer_to_buffer(
|
||||
from_sources,
|
||||
from.1 / 4 * 8,
|
||||
to_sources,
|
||||
to.1 / 4 * 8,
|
||||
bytes,
|
||||
),
|
||||
None => encoder.clear_buffer(to_sources, to.1 / 4 * 8, Some(bytes)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn release(&mut self, page: u32, start: u64, vertices: u64) {
|
||||
let len = Self::span(vertices).min(self.page_limit);
|
||||
@@ -540,9 +633,17 @@ impl Arena {
|
||||
let Some((buffer, host)) = self.create(device, capacity, spill) else {
|
||||
return false;
|
||||
};
|
||||
let sources = self
|
||||
.sources_enabled
|
||||
.then(|| Self::source_buffer(device, capacity))
|
||||
.flatten();
|
||||
let old = self.pages[page].as_mut().expect("live arena page");
|
||||
// Copies recorded for the old buffer run before this one.
|
||||
encoder.copy_buffer_to_buffer(&old.buffer, 0, &buffer, 0, old.capacity * self.stride);
|
||||
if let (Some(from), Some(to)) = (&old.sources, &sources) {
|
||||
encoder.copy_buffer_to_buffer(from, 0, to, 0, from.size());
|
||||
}
|
||||
old.sources = sources;
|
||||
let previous = old.capacity;
|
||||
old.buffer = buffer;
|
||||
old.host = host;
|
||||
@@ -1447,6 +1548,7 @@ mod tests {
|
||||
let free = FreeList(BTreeMap::from([(used, capacity - used)]));
|
||||
let buffer = buffer.clone();
|
||||
arena.pages = vec![Some(Page {
|
||||
sources: None,
|
||||
buffer,
|
||||
capacity,
|
||||
free,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Original sidecar lifetime controls, executed explicitly on an offscreen adapter.
|
||||
use super::*;
|
||||
use crate::mesher::{TerrainSource, TerrainVertex};
|
||||
|
||||
fn read(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
buffer: &wgpu::Buffer,
|
||||
offset: u64,
|
||||
size: u64,
|
||||
) -> Vec<u8> {
|
||||
let out = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(buffer, offset, &out, 0, size);
|
||||
queue.submit([encoder.finish()]);
|
||||
out.slice(..).map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
out.slice(..).get_mapped_range().unwrap().to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "offscreen GPU control; run explicitly with an available adapter"]
|
||||
fn terrain_source_arena_gpu_grow_move_recycle_and_refusal() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
|
||||
.expect("GPU control requires an adapter");
|
||||
eprintln!("TERRAIN_SOURCE_ARENA_ADAPTER {:?}", adapter.get_info());
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let mut arena = Arena::new(&device, 24, GRAIN, true);
|
||||
arena.page_limit = GRAIN * 2;
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let mut uploads = super::super::upload::Uploads::new(&device);
|
||||
let first = arena.alloc(&device, &mut encoder, 4).unwrap();
|
||||
assert!(
|
||||
arena.sources(first.0).is_none(),
|
||||
"default geometry allocates no source buffer"
|
||||
);
|
||||
let geometry = [TerrainVertex::new([2.25, 3.75, 4.5], [0.25, 0.75], 7, 0xff123456, 0x8070); 4];
|
||||
uploads.buffer(
|
||||
&mut encoder,
|
||||
arena.buffer(first.0),
|
||||
first.1 * 24,
|
||||
bytemuck::cast_slice(&geometry),
|
||||
);
|
||||
let source = TerrainSource::new(71, [2, 3, 4], 13, false);
|
||||
arena.write_sources(
|
||||
&device,
|
||||
&mut encoder,
|
||||
&mut uploads,
|
||||
first.0,
|
||||
first.1,
|
||||
4,
|
||||
&[source],
|
||||
);
|
||||
let second = arena.alloc(&device, &mut encoder, GRAIN).unwrap();
|
||||
assert_eq!(second, (first.0, GRAIN), "grew the first page");
|
||||
let third = arena.alloc(&device, &mut encoder, GRAIN).unwrap();
|
||||
assert_ne!(third.0, first.0, "move crosses pages");
|
||||
arena.copy(&mut encoder, first, third, 4);
|
||||
arena.release(first.0, first.1, 4);
|
||||
let reused = arena.alloc_existing(4).unwrap();
|
||||
assert_eq!(reused, first);
|
||||
arena.write_sources(
|
||||
&device,
|
||||
&mut encoder,
|
||||
&mut uploads,
|
||||
reused.0,
|
||||
reused.1,
|
||||
4,
|
||||
&[],
|
||||
);
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
assert_eq!(
|
||||
read(
|
||||
&device,
|
||||
&queue,
|
||||
arena.sources(third.0).unwrap(),
|
||||
third.1 / 4 * 8,
|
||||
8
|
||||
),
|
||||
bytemuck::bytes_of(&source)
|
||||
);
|
||||
assert_eq!(
|
||||
read(
|
||||
&device,
|
||||
&queue,
|
||||
arena.sources(first.0).unwrap(),
|
||||
first.1 / 4 * 8,
|
||||
8
|
||||
),
|
||||
[0; 8]
|
||||
);
|
||||
assert_eq!(
|
||||
read(&device, &queue, arena.buffer(third.0), third.1 * 24, 96),
|
||||
bytemuck::cast_slice::<_, u8>(&geometry)
|
||||
);
|
||||
|
||||
// A real requested binding limit refuses metadata/table allocation. The native
|
||||
// vertex allocation still fits; this is a controlled refusal, not a real OOM.
|
||||
let limits = wgpu::Limits {
|
||||
max_storage_buffer_binding_size: 256,
|
||||
..Default::default()
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: limits,
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let mut arena = Arena::translucent(&device, 24, GRAIN);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let mut uploads = super::super::upload::Uploads::new(&device);
|
||||
let at = arena.alloc(&device, &mut encoder, 4).unwrap();
|
||||
uploads.buffer(
|
||||
&mut encoder,
|
||||
arena.buffer(at.0),
|
||||
at.1 * 24,
|
||||
bytemuck::cast_slice(&geometry),
|
||||
);
|
||||
arena.write_sources(
|
||||
&device,
|
||||
&mut encoder,
|
||||
&mut uploads,
|
||||
at.0,
|
||||
at.1,
|
||||
4,
|
||||
&[source],
|
||||
);
|
||||
assert!(
|
||||
arena.sources(at.0).is_none(),
|
||||
"sidecar refused without losing vertices"
|
||||
);
|
||||
assert!(super::super::pack_post::block_ids(&device, &queue, &[0; 128], &[0, 1]).is_none());
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
assert_eq!(
|
||||
read(&device, &queue, arena.buffer(at.0), at.1 * 24, 96),
|
||||
bytemuck::cast_slice::<_, u8>(&geometry)
|
||||
);
|
||||
}
|
||||
@@ -64,8 +64,10 @@ impl Copies {
|
||||
fn copy(mesh: &SectionMesh) -> SectionMesh {
|
||||
SectionMesh {
|
||||
solid: mesh.solid.clone(),
|
||||
solid_sources: mesh.solid_sources.clone(),
|
||||
sides: mesh.sides,
|
||||
translucent: mesh.translucent.clone(),
|
||||
translucent_sources: mesh.translucent_sources.clone(),
|
||||
packed: mesh.packed.clone(),
|
||||
packed_far: mesh.packed_far,
|
||||
solid_tints: Vec::new(),
|
||||
|
||||
@@ -27,11 +27,13 @@
|
||||
//! as many draws as without culling: drivers that unroll an indirect
|
||||
//! multi-draw on the CPU (Mesa's on Intel Gen9) pay per entry. Phase 2's
|
||||
//! counts come from the GPU, which reads them from the draws themselves:
|
||||
//! Vulkan's native multi-draw, and Metal's, which wgpu replays as one
|
||||
//! indirect draw per entry. Translucent draws are not culled.
|
||||
//! Vulkan's native multi-draw, DirectX 12's `ExecuteIndirect`, and Metal's,
|
||||
//! which wgpu replays as one indirect draw per entry. Translucent draws are
|
||||
//! not culled.
|
||||
//!
|
||||
//! Metal cannot force the depth test before a fragment shader that writes
|
||||
//! storage (naga writes no `[[early_fragment_tests]]`), so there the boxes
|
||||
//! storage (naga writes no `[[early_fragment_tests]]`), nor can DirectX 12
|
||||
//! (`[earlydepthstencil]`, which wgpu doesn't offer there), so there the boxes
|
||||
//! are drawn in a pass of their own between the phases, which reads phase
|
||||
//! 1's depth as a texture and makes the same comparison in the shader: the
|
||||
//! fragment's depth against the stored one, greater or equal passing. The
|
||||
@@ -224,9 +226,9 @@ impl Cull {
|
||||
adapter.features() & wgpu::Features::SHADER_EARLY_DEPTH_TEST
|
||||
}
|
||||
/// Where the device can run the test, if it can: Vulkan with early
|
||||
/// depth tests, or Metal, both drawing an indirect multi-draw's entries
|
||||
/// with the instance counts the GPU wrote, with fragment shaders that
|
||||
/// write storage.
|
||||
/// depth tests, or Metal and DirectX 12 with the test in the shader, all
|
||||
/// drawing an indirect multi-draw's entries with the instance counts the
|
||||
/// GPU wrote, with fragment shaders that write storage.
|
||||
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> Option<DepthTest> {
|
||||
let depth_test = match adapter.get_info().backend {
|
||||
wgpu::Backend::Vulkan | wgpu::Backend::Gl
|
||||
@@ -236,7 +238,9 @@ impl Cull {
|
||||
{
|
||||
DepthTest::Early
|
||||
}
|
||||
wgpu::Backend::Metal => DepthTest::InShader,
|
||||
// DirectX 12 has no forced early depth test in wgpu either: the test runs in the
|
||||
// shader there too.
|
||||
wgpu::Backend::Metal | wgpu::Backend::Dx12 => DepthTest::InShader,
|
||||
_ => return None,
|
||||
};
|
||||
(device
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Which compiler turns DirectX 12's shaders into bytecode: DXC on integrated
|
||||
//! GPUs, FXC on every other adapter (#8513).
|
||||
//!
|
||||
//! Measured on one laptop at render distance 32 (2560x1440, the same world and
|
||||
//! view): DXC took Intel Iris Xe from 44 to 65 FPS (22.1 to 14.7 ms of GPU
|
||||
//! time) and an NVIDIA RTX 3050 Ti from 159 down to 150 FPS. An RX 9070 gained
|
||||
//! about 9 % of GPU time at render distance 32 and lost 0.03 ms at 8. So
|
||||
//! discrete GPUs keep FXC (`d3dcompiler_47.dll`, part of Windows), and only
|
||||
//! integrated ones load DXC (`dxcompiler.dll`, which the Windows x64 zip puts
|
||||
//! beside the game: `tools/ci/fetch_dxc.py`).
|
||||
//!
|
||||
//! wgpu chooses the compiler for a whole instance, before any adapter is
|
||||
//! known. `open_backend` therefore asks for the adapter on an FXC instance and,
|
||||
//! when that adapter is an integrated GPU on DirectX 12, asks again on an
|
||||
//! instance with DXC. That happens once, when the device is opened; nothing
|
||||
//! here runs per frame. Without the DLL, or when it does not load, the game
|
||||
//! keeps the FXC instance and logs one line. `WGPU_DX12_COMPILER` (`fxc`,
|
||||
//! `dxc`, `auto`) overrides the choice: wgpu applies it to every instance.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The DLL's name, as the Windows zip ships it beside `horizon-client.exe`.
|
||||
pub(super) const DLL: &str = "dxcompiler.dll";
|
||||
|
||||
/// Whether `info`, opened on an FXC instance, should be opened again with DXC:
|
||||
/// an integrated GPU on DirectX 12, unless `forced` (`WGPU_DX12_COMPILER` is
|
||||
/// set, so its compiler is already the one every instance uses).
|
||||
pub(super) fn wants_dxc(info: &wgpu::AdapterInfo, forced: bool) -> bool {
|
||||
!forced
|
||||
&& info.backend == wgpu::Backend::Dx12
|
||||
&& info.device_type == wgpu::DeviceType::IntegratedGpu
|
||||
}
|
||||
|
||||
/// Whether `WGPU_DX12_COMPILER` names the compiler.
|
||||
pub(super) fn forced() -> bool {
|
||||
std::env::var_os("WGPU_DX12_COMPILER").is_some()
|
||||
}
|
||||
|
||||
/// `dxcompiler.dll` beside the running executable, if it is there. An absolute
|
||||
/// path, so a copy elsewhere on `PATH` (an older Windows SDK's) is never the
|
||||
/// one loaded.
|
||||
pub(super) fn bundled_dll() -> Option<PathBuf> {
|
||||
let dll = std::env::current_exe().ok()?.parent()?.join(DLL);
|
||||
dll.is_file().then_some(dll)
|
||||
}
|
||||
|
||||
/// The compiler setting that loads `dll`, and fails the instance (rather
|
||||
/// than falling back to FXC on its own) when it doesn't load: `open_backend`
|
||||
/// then keeps the FXC instance it already has.
|
||||
pub(super) fn dxc(dll: &std::path::Path) -> wgpu::Dx12Compiler {
|
||||
wgpu::Dx12Compiler::DynamicDxc {
|
||||
dxc_path: dll.to_string_lossy().into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the DXC instance offered the adapter the FXC instance had: on a
|
||||
/// laptop with two GPUs, the same request should pick the same one, and the
|
||||
/// game must not switch GPUs because the compiler changed.
|
||||
pub(super) fn same_adapter(fxc: &wgpu::AdapterInfo, dxc: &wgpu::AdapterInfo) -> bool {
|
||||
fxc.backend == dxc.backend
|
||||
&& fxc.vendor == dxc.vendor
|
||||
&& fxc.device == dxc.device
|
||||
&& fxc.device_type == dxc.device_type
|
||||
&& fxc.device_pci_bus_id == dxc.device_pci_bus_id
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn adapter(backend: wgpu::Backend, device_type: wgpu::DeviceType) -> wgpu::AdapterInfo {
|
||||
wgpu::AdapterInfo {
|
||||
name: "Intel(R) Iris(R) Xe Graphics".into(),
|
||||
vendor: 0x8086,
|
||||
device: 0x9a49,
|
||||
device_type,
|
||||
device_pci_bus_id: "0000:00:02.0".into(),
|
||||
driver: String::new(),
|
||||
driver_info: String::new(),
|
||||
backend,
|
||||
subgroup_min_size: 0,
|
||||
subgroup_max_size: 0,
|
||||
transient_saves_memory: None,
|
||||
limit_bucket: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_integrated_gpus_on_directx_12_get_dxc() {
|
||||
use wgpu::{Backend, DeviceType};
|
||||
assert!(wants_dxc(
|
||||
&adapter(Backend::Dx12, DeviceType::IntegratedGpu),
|
||||
false
|
||||
));
|
||||
for kind in [
|
||||
DeviceType::DiscreteGpu,
|
||||
DeviceType::VirtualGpu,
|
||||
DeviceType::Cpu,
|
||||
DeviceType::Other,
|
||||
] {
|
||||
assert!(!wants_dxc(&adapter(Backend::Dx12, kind), false), "{kind:?}");
|
||||
}
|
||||
// Other backends have no shader compiler to choose.
|
||||
for backend in [Backend::Vulkan, Backend::Gl, Backend::Metal] {
|
||||
assert!(
|
||||
!wants_dxc(&adapter(backend, DeviceType::IntegratedGpu), false),
|
||||
"{backend:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wgpu_dx12_compiler_overrides_the_choice() {
|
||||
let igpu = adapter(wgpu::Backend::Dx12, wgpu::DeviceType::IntegratedGpu);
|
||||
assert!(!wants_dxc(&igpu, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dxc_instance_must_offer_the_same_gpu() {
|
||||
let igpu = adapter(wgpu::Backend::Dx12, wgpu::DeviceType::IntegratedGpu);
|
||||
assert!(same_adapter(&igpu, &igpu.clone()));
|
||||
let dgpu = wgpu::AdapterInfo {
|
||||
name: "NVIDIA GeForce RTX 3050 Ti Laptop GPU".into(),
|
||||
vendor: 0x10de,
|
||||
device: 0x25a0,
|
||||
device_type: wgpu::DeviceType::DiscreteGpu,
|
||||
device_pci_bus_id: "0000:01:00.0".into(),
|
||||
..igpu.clone()
|
||||
};
|
||||
assert!(!same_adapter(&igpu, &dgpu));
|
||||
}
|
||||
}
|
||||
@@ -174,11 +174,22 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
|
||||
/// Whether frames without a shader pack's terrain draw flat: where the backend
|
||||
/// emulates multi-draw (Metal, past `MIN_SECTIONS`), on Intel Gen11/Gen12's Vulkan
|
||||
/// driver, or with `HORIZON_FLAT_DRAWS=1`. Elsewhere per-section draws stay.
|
||||
/// driver, on Qualcomm's Adreno under DirectX 12, or with `HORIZON_FLAT_DRAWS=1`.
|
||||
/// Elsewhere per-section draws stay.
|
||||
pub fn for_vanilla(info: &wgpu::AdapterInfo) -> bool {
|
||||
std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
|
||||
|| info.backend == wgpu::Backend::Metal
|
||||
|| is_broken_intel_vulkan(info)
|
||||
|| is_adreno_dx12(info)
|
||||
}
|
||||
|
||||
/// Qualcomm's Adreno under DirectX 12 (Windows on Snapdragon, where Auto picks DirectX 12),
|
||||
/// where wgpu's indirect multi-draw is `ExecuteIndirect`: drawn flat, an X1-45 took 3-11%
|
||||
/// less GPU time a frame at render distance 12 and 3-13% less at 32 (perf_bench's four
|
||||
/// scenes, held still), from 389 sections in view up, the same picture. DXGI names
|
||||
/// Qualcomm `QCOM` (0x4D4F4351); Vulkan, 0x5143.
|
||||
pub fn is_adreno_dx12(info: &wgpu::AdapterInfo) -> bool {
|
||||
info.backend == wgpu::Backend::Dx12 && matches!(info.vendor, 0x4d4f_4351 | 0x5143)
|
||||
}
|
||||
|
||||
/// Intel Gen11 (Ice Lake) and Gen12 (Tiger Lake, Rocket Lake, Alder Lake,
|
||||
@@ -1044,6 +1055,30 @@ impl Flat {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adreno_draws_flat_under_dx12_only() {
|
||||
let mut info = wgpu::AdapterInfo {
|
||||
name: "Qualcomm(R) Adreno(TM) X1-45 GPU".into(),
|
||||
vendor: 0x4d4f_4351,
|
||||
device: 0,
|
||||
device_type: wgpu::DeviceType::IntegratedGpu,
|
||||
device_pci_bus_id: String::new(),
|
||||
driver: "31.0.128.0".into(),
|
||||
driver_info: String::new(),
|
||||
backend: wgpu::Backend::Dx12,
|
||||
subgroup_min_size: 0,
|
||||
subgroup_max_size: 0,
|
||||
transient_saves_memory: None,
|
||||
limit_bucket: None,
|
||||
};
|
||||
assert!(is_adreno_dx12(&info));
|
||||
info.backend = wgpu::Backend::Vulkan;
|
||||
assert!(!is_adreno_dx12(&info));
|
||||
info.backend = wgpu::Backend::Dx12;
|
||||
info.vendor = 0x10de;
|
||||
assert!(!is_adreno_dx12(&info));
|
||||
}
|
||||
|
||||
/// Draws as `render` writes them: solid (page, quads at a base vertex)
|
||||
/// first, then translucent (page, sorted page, count, first sorted index,
|
||||
/// base vertex).
|
||||
|
||||
@@ -2735,8 +2735,9 @@ mod tests {
|
||||
|
||||
/// `test` on each device here that draws counts with first instances:
|
||||
/// Vulkan, and DirectX 12 with each of its compilers. Players' DirectX 12
|
||||
/// compiles with FXC, part of Windows: wgpu takes DXC only where
|
||||
/// `dxcompiler.dll` loads, and the game ships none.
|
||||
/// compiles with FXC, part of Windows, and with DXC (`dxcompiler.dll`,
|
||||
/// beside the game in the Windows x64 zip) on integrated GPUs
|
||||
/// (`gfx/dx12_compiler.rs`).
|
||||
fn on_each_device(test: impl Fn(&wgpu::Device, &Queue)) {
|
||||
let mut tested = false;
|
||||
for (backends, compiler, name) in [
|
||||
|
||||
@@ -149,6 +149,21 @@ fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
|
||||
vec!["terrain.wgsl", "visbuffer.wgsl"],
|
||||
super::terrain_shader() + include_str!("../visbuffer.wgsl"),
|
||||
),
|
||||
// A native mod's world warp (gfx/world_warp.rs): its copies with the plainest warp.
|
||||
(
|
||||
"world warp terrain",
|
||||
vec![
|
||||
"terrain.wgsl",
|
||||
"world_warp.wgsl",
|
||||
"world_warp_fragment.wgsl",
|
||||
],
|
||||
super::world_warp::terrain_shader(super::world_warp::IDENTITY).unwrap(),
|
||||
),
|
||||
(
|
||||
"world warp entities",
|
||||
vec!["entity.wgsl", "static_entity.wgsl", "world_warp.wgsl"],
|
||||
super::world_warp::entity_shader(super::world_warp::IDENTITY).unwrap(),
|
||||
),
|
||||
(
|
||||
"improved transparency world border",
|
||||
vec!["border.wgsl", "oit.wgsl", "oit_border.wgsl"],
|
||||
|
||||
@@ -7,14 +7,16 @@ use super::Vertex;
|
||||
use super::pack_draw::EntitySpan;
|
||||
use glam::{DVec3, Mat4};
|
||||
use std::sync::{
|
||||
OnceLock,
|
||||
Arc, OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
mod generated {
|
||||
include!(concat!(env!("OUT_DIR"), "/native_render_generated.rs"));
|
||||
}
|
||||
pub use generated::{HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
|
||||
pub use generated::{
|
||||
HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent, WorldWarpEvent,
|
||||
};
|
||||
pub use wgpu;
|
||||
|
||||
static RENDERERS: OnceLock<Box<[&'static dyn RenderHooks]>> = OnceLock::new();
|
||||
@@ -178,6 +180,38 @@ pub fn render_needs() -> RenderNeeds {
|
||||
needs
|
||||
}
|
||||
|
||||
/// A world warp ([`native_world_warp`]): WGSL that every terrain vertex (far terrain's tiles too)
|
||||
/// and entity vertex goes through, so a mod can bend the drawn world itself: its real geometry,
|
||||
/// light and depth, seen from any side. docs/native-mods.md, "World warps", has the WGSL contract.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub struct WorldWarp {
|
||||
/// WGSL with `fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped`, and optionally,
|
||||
/// after a line `// [fragments]`, `fn world_fragment(f: Fragment) -> Painted` for every
|
||||
/// pixel of the terrain. `None`: no warp.
|
||||
pub source: Option<Arc<str>>,
|
||||
/// Whether this frame draws with the warp. A source given while this is false is still
|
||||
/// built, so a mod can pay for its pipelines before the frame that needs them.
|
||||
pub on: bool,
|
||||
/// What the warp reads as `warp.params`: four `vec4<f32>`.
|
||||
pub params: [f32; 16],
|
||||
}
|
||||
|
||||
/// Asked once a frame, before the world is drawn (not on the title screen's panorama): set
|
||||
/// [`WorldWarp`] to have the frame drawn through your warp. Mods are asked in order and one warp
|
||||
/// draws: the last one set. Keep it cheap: compare and reuse your `Arc<str>`.
|
||||
#[inline(never)]
|
||||
pub fn native_world_warp(warp: &mut WorldWarp) {
|
||||
generated::dispatch_world_warp(RENDERERS.get().map_or(&[], |renderers| renderers), warp);
|
||||
}
|
||||
|
||||
/// This frame's world warp ([`native_world_warp`]).
|
||||
pub fn world_warp() -> WorldWarp {
|
||||
let mut warp = WorldWarp::default();
|
||||
native_world_warp(&mut warp);
|
||||
warp
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -378,6 +412,33 @@ mod tests {
|
||||
assert_eq!(texels, expected);
|
||||
}
|
||||
|
||||
struct Bend(&'static str);
|
||||
impl RenderHooks for Bend {
|
||||
fn world_warp(&self, event: &mut WorldWarpEvent<'_>) {
|
||||
event.warp.source = Some(self.0.into());
|
||||
event.warp.on = true;
|
||||
event.warp.params[0] += 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_mod_to_set_a_warp_draws() {
|
||||
let mut warp = WorldWarp::default();
|
||||
generated::dispatch_world_warp(&[&Depth, &Bend("a"), &Bend("b")], &mut warp);
|
||||
assert_eq!(warp.source.as_deref(), Some("b"));
|
||||
assert!(warp.on);
|
||||
// each saw what the one before left
|
||||
assert_eq!(warp.params[0], 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_mods_there_is_no_warp() {
|
||||
let mut warp = WorldWarp::default();
|
||||
generated::dispatch_world_warp(&[], &mut warp);
|
||||
assert_eq!(warp, WorldWarp::default());
|
||||
assert!(warp.source.is_none() && !warp.on);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_accumulate_in_mod_order() {
|
||||
let mut needs = RenderNeeds::default();
|
||||
|
||||
@@ -630,13 +630,12 @@ impl PackAtlas {
|
||||
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
|
||||
layout(set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };\n",
|
||||
);
|
||||
// Of each sprite layer, `mc_Entity` (id, then 1 for fluids or 0) and
|
||||
// `at_midBlock.w` (its block's light), the game's table; and the arena page's
|
||||
// vertices (`TerrainVertex`, six words each).
|
||||
// Source metadata follows each quad in its arena page. The immutable table
|
||||
// holds sprite rectangles and the current pack's ids of source states.
|
||||
declarations.push_str(
|
||||
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
|
||||
"layout(set = 3, binding = 0) readonly buffer HzQuadSources { uvec2 hz_sources[]; };\n\
|
||||
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n\
|
||||
layout(set = 3, binding = 2) readonly buffer HzSpriteRects { uvec2 hz_sprite_rects[]; };\n",
|
||||
layout(set = 3, binding = 2) readonly buffer HzTerrainTables { int hz_tables[]; };\n",
|
||||
);
|
||||
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
|
||||
} else {
|
||||
@@ -649,7 +648,8 @@ impl PackAtlas {
|
||||
let columns = self.columns;
|
||||
let (cell, side, scale) = if flat {
|
||||
(
|
||||
"uvec2 hz_rect = hz_sprite_rects[hz_layer];\n \
|
||||
"uvec2 hz_rect = uvec2(0u, 1u);\n \
|
||||
if (2u * hz_layer + 1u < uint(hz_tables[0])) hz_rect = uvec2(hz_tables[2u + 2u * hz_layer], hz_tables[3u + 2u * hz_layer]);\n \
|
||||
hz_cell = vec2(float(hz_rect.x & 65535u), float(hz_rect.x >> 16u)) / float(hz_rect.y);\n "
|
||||
.to_owned(),
|
||||
"float(hz_rect.y)",
|
||||
@@ -701,20 +701,27 @@ impl PackAtlas {
|
||||
let mut body = body;
|
||||
if flat {
|
||||
body.push_str(FACE);
|
||||
body.push_str(
|
||||
" uint hz_quad = uint(gl_VertexIndex) / 4u;\n \
|
||||
uvec2 hz_source = uvec2(0u);\n \
|
||||
if (hz_quad < uint(hz_sources.length())) hz_source = hz_sources[hz_quad];\n \
|
||||
bool hz_known_source = (hz_source.y & 8192u) != 0u;\n \
|
||||
int hz_material = -1;\n \
|
||||
if (hz_known_source && hz_source.x < uint(hz_tables[1])) hz_material = hz_tables[2u + uint(hz_tables[0]) + 2u * hz_source.x + ((hz_source.y >> 12u) & 1u)];\n",
|
||||
);
|
||||
}
|
||||
declarations.push_str(
|
||||
"vec2 hz_cell; vec2 hz_scale; vec2 hz_mid; float hz_uv_scale; vec4 hz_tangent;\n",
|
||||
);
|
||||
let entity = if flat {
|
||||
"vec4(float(hz_block_ids[3u * hz_layer]), float(hz_block_ids[3u * hz_layer + 1u]), 0.0, 0.0)"
|
||||
"vec4(float(hz_material), hz_known_source ? float((hz_source.y >> 12u) & 1u) : 0.0, 0.0, 0.0)"
|
||||
} else {
|
||||
"vec4(-1.0, 0.0, 0.0, 0.0)"
|
||||
};
|
||||
// The block a face belongs to: half a block behind its centre (the quad's diagonal);
|
||||
// w, the light it gives (Iris's `BLOCK_EMISSION_ATTRIBUTE`, 0 to 15).
|
||||
// The mesher's owning cell, even for inset, offset, multipart or extended faces;
|
||||
// w is the source's emitted light, independent of sprite and smooth light.
|
||||
let mid_block = if flat {
|
||||
"vec4((floor((hz_p0 + hz_p2) * 0.5 - 0.5 * hz_Normal) + 0.5 - hz_v_pos) * 64.0, \
|
||||
float(hz_block_ids[3u * hz_layer + 2u]))"
|
||||
"(hz_known_source ? vec4((vec3(float(hz_source.y & 15u), float((hz_source.y >> 4u) & 15u), float((hz_source.y >> 8u) & 15u)) + 0.5 - hz_v_pos) * 64.0, float((hz_source.y >> 14u) & 15u)) : vec4(0.0))"
|
||||
} else {
|
||||
"vec4(0.0)"
|
||||
};
|
||||
@@ -1154,3 +1161,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pack_atlas/source_ownership_test.rs"]
|
||||
mod source_ownership_test;
|
||||
@@ -49,12 +49,12 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
|
||||
std::env::temp_dir().join(format!("horizon-terrain-fade-{}", std::process::id())),
|
||||
);
|
||||
std::fs::create_dir_all(folder.0.join("shaders")).unwrap();
|
||||
// The guard, initializer, varying and integer output are independent probe code.
|
||||
// The initializer, varying and integer output are independent probe code. The program reads
|
||||
// `mc_chunkFade` unguarded: Horizon defines no `IRIS_FEATURE_*` (spec OQ-2).
|
||||
let vertex = "#version 330\nout vec4 observation;\n\
|
||||
#ifdef IRIS_FEATURE_FADE_VARIABLE\nfloat first = mc_chunkFade;\n#endif\n\
|
||||
void main() {\n#ifdef IRIS_FEATURE_FADE_VARIABLE\n\
|
||||
float first = mc_chunkFade;\n\
|
||||
void main() {\n\
|
||||
observation = vec4(mc_chunkFade, first, gl_Color.a, gl_MultiTexCoord0.x);\n\
|
||||
#else\nobservation = vec4(1.0, 1.0, gl_Color.a, gl_MultiTexCoord0.x);\n#endif\n\
|
||||
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n}";
|
||||
let fragment = "#version 330\nin vec4 observation;\nlayout(location = 0) out uvec4 pixel;\n\
|
||||
void main() { pixel = floatBitsToUint(observation); }";
|
||||
@@ -159,7 +159,8 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
|
||||
origin,
|
||||
0x80ffffff,
|
||||
);
|
||||
let fade = if requested { expected } else { 1.0 };
|
||||
// The fade reaches the program whether the pack requested it or not.
|
||||
let fade = expected;
|
||||
let expected = [
|
||||
fade,
|
||||
if geometry { 0.8125 } else { fade },
|
||||
@@ -307,9 +308,12 @@ fn sample(
|
||||
bytemuck::cast_slice(&vertices),
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
|
||||
);
|
||||
let ids = buffer(bytemuck::bytes_of(&[0i32; 3]), wgpu::BufferUsages::STORAGE);
|
||||
let ids = buffer(bytemuck::bytes_of(&[0u32; 2]), wgpu::BufferUsages::STORAGE);
|
||||
let rects = buffer(
|
||||
bytemuck::cast_slice(&atlas.rects_table()),
|
||||
bytemuck::cast_slice(&super::super::pack_post::terrain_tables(
|
||||
&[],
|
||||
&atlas.rects_table(),
|
||||
)),
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
let binding = |n, storage| wgpu::BindGroupLayoutEntry {
|
||||
|
||||
@@ -406,13 +406,16 @@ fn sample_probe(
|
||||
bytemuck::bytes_of(&[0., 0., 0., scale]),
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
|
||||
);
|
||||
let ids = buffer(bytemuck::bytes_of(&[0i32; 15]), wgpu::BufferUsages::STORAGE);
|
||||
let ids = buffer(bytemuck::bytes_of(&[0u32; 2]), wgpu::BufferUsages::STORAGE);
|
||||
let data = buffer(
|
||||
bytemuck::cast_slice(vertices),
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
|
||||
);
|
||||
let rects = buffer(
|
||||
bytemuck::cast_slice(&atlas.rects_table()),
|
||||
bytemuck::cast_slice(&super::super::pack_post::terrain_tables(
|
||||
&[],
|
||||
&atlas.rects_table(),
|
||||
)),
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
let bindings = [
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
//! Original numeric controls for the production terrain attribute expressions.
|
||||
//! A compute invocation stands for a terrain vertex; it runs the actual input body.
|
||||
use super::*;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
fn dispatch(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
pipeline: &wgpu::ComputePipeline,
|
||||
groups: &[wgpu::BindGroup],
|
||||
result: &wgpu::Buffer,
|
||||
) -> Vec<[f32; 4]> {
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: result.size(),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
pass.set_pipeline(pipeline);
|
||||
for (i, g) in groups.iter().enumerate() {
|
||||
pass.set_bind_group(i as u32, g, &[]);
|
||||
}
|
||||
pass.dispatch_workgroups((result.size() / 32) as u32, 1, 1);
|
||||
}
|
||||
encoder.copy_buffer_to_buffer(result, 0, &read, 0, result.size());
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "offscreen GPU control; run explicitly with an available adapter"]
|
||||
fn terrain_source_ownership_gpu() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
|
||||
.expect("executed GPU control requires an adapter");
|
||||
eprintln!("TERRAIN_SOURCE_ADAPTER {:?}", adapter.get_info());
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let atlas = PackAtlas::new(&device, &mut encoder, &[texture], 1, 1, 1).unwrap();
|
||||
let inputs = atlas.terrain_inputs(true);
|
||||
// Inset faces of two source states sharing sprite zero, an extended multipart face,
|
||||
// and the second state's fluid face. Ownership is authored
|
||||
// fixture data, not inferred from normal, sprite, position or diagonal.
|
||||
let mut vertices = Vec::new();
|
||||
for (x, y) in [(2.0, 3.25), (6.0, 3.25), (7.25, 3.75), (6.0, 3.875)] {
|
||||
for [dx, dz] in [[0.0, 0.0], [0.0, 0.5], [0.5, 0.5], [0.5, 0.0]] {
|
||||
vertices.push(crate::mesher::TerrainVertex::new(
|
||||
[x + dx, y, 4.0 + dz],
|
||||
[0.0; 2],
|
||||
0,
|
||||
u32::MAX,
|
||||
0,
|
||||
));
|
||||
}
|
||||
}
|
||||
let declarations = inputs
|
||||
.declarations
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with("layout(location"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let attribute = |name: &str| {
|
||||
inputs
|
||||
.attributes
|
||||
.iter()
|
||||
.find(|(n, _)| n == name)
|
||||
.unwrap()
|
||||
.1
|
||||
.as_str()
|
||||
};
|
||||
let source = format!(
|
||||
"#version 450\n{declarations}\n\
|
||||
layout(local_size_x=1) in;\n\
|
||||
layout(set=0,binding=0) buffer Results {{ vec4 result[]; }};\n\
|
||||
void main() {{\n\
|
||||
uint hz_index=gl_GlobalInvocationID.x;\n\
|
||||
vec3 hz_v_pos=uintBitsToFloat(uvec3(hz_vertices[6u*hz_index],hz_vertices[6u*hz_index+1u],hz_vertices[6u*hz_index+2u]));\n\
|
||||
uvec2 hz_v_uv=uvec2(0); uint hz_v_layer=0u; vec4 hz_v_color=vec4(1); uvec2 hz_v_light=uvec2(0);\n\
|
||||
vec4 hz_Vertex, hz_MultiTexCoord0, hz_MultiTexCoord1, hz_MultiTexCoord2, hz_Color; vec3 hz_Normal;\n\
|
||||
{}\nresult[2u*hz_index]={}; result[2u*hz_index+1u]={};\n}}",
|
||||
inputs.body.replace("gl_VertexIndex", "hz_index"),
|
||||
attribute("mc_Entity"),
|
||||
attribute("at_midBlock")
|
||||
);
|
||||
let mut frontend = naga::front::glsl::Frontend::default();
|
||||
let module = frontend
|
||||
.parse(
|
||||
&naga::front::glsl::Options::from(naga::ShaderStage::Compute),
|
||||
&source,
|
||||
)
|
||||
.unwrap();
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Original terrain numeric control"),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
});
|
||||
let layouts: Vec<_> = [1, 2, 0, 3]
|
||||
.into_iter()
|
||||
.map(|count| {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: None,
|
||||
entries: &(0..count)
|
||||
.map(|binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: if count == 1 {
|
||||
wgpu::ShaderStages::COMPUTE
|
||||
} else {
|
||||
wgpu::ShaderStages::COMPUTE | wgpu::ShaderStages::VERTEX
|
||||
},
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage {
|
||||
read_only: binding != 0 || count != 1,
|
||||
},
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: None,
|
||||
bind_group_layouts: &layouts.iter().map(Some).collect::<Vec<_>>(),
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: Some(&layout),
|
||||
module: &shader,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let storage = |bytes: &[u8]| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytes,
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
})
|
||||
};
|
||||
let result = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: vertices.len() as u64 * 32,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let slots = storage(bytemuck::cast_slice(&[0u32]));
|
||||
let origins = storage(bytemuck::cast_slice(&[[128.0f32, -32.0, -256.0, -1.0]]));
|
||||
let ids = storage(bytemuck::cast_slice(&[
|
||||
crate::mesher::TerrainSource::new(0, [2, 3, 4], 0, false),
|
||||
crate::mesher::TerrainSource::new(1, [6, 3, 4], 13, false),
|
||||
crate::mesher::TerrainSource::new(1, [6, 3, 4], 13, false),
|
||||
crate::mesher::TerrainSource::new(1, [6, 3, 4], 13, true),
|
||||
]));
|
||||
let vertex_storage = storage(bytemuck::cast_slice(&vertices));
|
||||
let rects = storage(bytemuck::cast_slice(
|
||||
&super::super::pack_post::terrain_tables(&[31, -1, 47, 59], &[0, 1]),
|
||||
));
|
||||
let group = |set: u32, buffers: &[&wgpu::Buffer]| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(set),
|
||||
entries: &buffers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| wgpu::BindGroupEntry {
|
||||
binding: i as u32,
|
||||
resource: b.as_entire_binding(),
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
};
|
||||
let groups = [
|
||||
group(0, &[&result]),
|
||||
group(1, &[&slots, &origins]),
|
||||
group(2, &[]),
|
||||
group(3, &[&ids, &vertex_storage, &rects]),
|
||||
];
|
||||
queue.submit([encoder.finish()]);
|
||||
let actual = dispatch(&device, &queue, &pipeline, &groups, &result);
|
||||
let mut mismatches = Vec::new();
|
||||
for (i, vertex) in vertices.iter().enumerate() {
|
||||
let q = i / 4;
|
||||
let expected_id = [31.0, 47.0, 47.0, 59.0][q];
|
||||
let expected_light = [0.0, 13.0, 13.0, 13.0][q];
|
||||
let owner = [
|
||||
[2.5, 3.5, 4.5],
|
||||
[6.5, 3.5, 4.5],
|
||||
[6.5, 3.5, 4.5],
|
||||
[6.5, 3.5, 4.5],
|
||||
][q];
|
||||
let expected_mid = [
|
||||
(owner[0] - vertex.pos[0]) * 64.0,
|
||||
(owner[1] - vertex.pos[1]) * 64.0,
|
||||
(owner[2] - vertex.pos[2]) * 64.0,
|
||||
expected_light,
|
||||
];
|
||||
if actual[i * 2] != [expected_id, if q == 3 { 1.0 } else { 0.0 }, 0.0, 0.0]
|
||||
|| actual[i * 2 + 1] != expected_mid
|
||||
{
|
||||
mismatches.push((
|
||||
i,
|
||||
actual[i * 2],
|
||||
actual[i * 2 + 1],
|
||||
expected_id,
|
||||
expected_mid,
|
||||
));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
mismatches.is_empty(),
|
||||
"terrain source mismatches: {mismatches:?}"
|
||||
);
|
||||
|
||||
// Rasterize the same native vertices and production input body, with a diagnostic
|
||||
// projection placing one face in each strip. The fragment reports id, emission and
|
||||
// reconstructed owner Y/X after a nonzero section translation; no texture color
|
||||
// can hide a wrong ownership value.
|
||||
let vertex_source = format!(
|
||||
"#version 450\n{}\nlayout(location=0) flat out vec4 control;\n\
|
||||
void main(){{vec4 hz_Vertex,hz_MultiTexCoord0,hz_MultiTexCoord1,hz_MultiTexCoord2,hz_Color;vec3 hz_Normal;\n\
|
||||
{}\nvec4 entity={};vec4 mid={};\n\
|
||||
control=vec4(entity.x,mid.w,hz_Vertex.y+mid.y/64.0,hz_Vertex.x+mid.x/64.0);\n\
|
||||
uint corner=uint(gl_VertexIndex)%4u;float q=float(uint(gl_VertexIndex)/4u);\n\
|
||||
gl_Position=vec4(-1.0+(q+float(corner>=2u))*0.5,(corner==1u||corner==2u)?1.0:-1.0,0.5,1.0);}}",
|
||||
inputs.declarations,
|
||||
inputs.body,
|
||||
attribute("mc_Entity"),
|
||||
attribute("at_midBlock")
|
||||
);
|
||||
let vertex_module = frontend
|
||||
.parse(
|
||||
&naga::front::glsl::Options::from(naga::ShaderStage::Vertex),
|
||||
&vertex_source,
|
||||
)
|
||||
.unwrap();
|
||||
let vertex_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(vertex_module)),
|
||||
});
|
||||
let fragment_shader=device.create_shader_module(wgpu::ShaderModuleDescriptor { label:None,source:wgpu::ShaderSource::Wgsl(
|
||||
"@fragment fn fs(@location(0) @interpolate(flat) control:vec4<f32>)->@location(0) vec4<f32>{return control;}".into()) });
|
||||
let raster = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vertex_shader,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 24,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &super::super::TERRAIN_ATTRIBUTES,
|
||||
})],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fragment_shader,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba32Float,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&vertices),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let indices: Vec<u32> = (0..4)
|
||||
.flat_map(|q| [4 * q, 4 * q + 1, 4 * q + 2, 4 * q, 4 * q + 2, 4 * q + 3])
|
||||
.collect();
|
||||
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&indices),
|
||||
usage: wgpu::BufferUsages::INDEX,
|
||||
});
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 16,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = target.create_view(&Default::default());
|
||||
let pixels = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&raster);
|
||||
for (i, g) in groups.iter().enumerate() {
|
||||
pass.set_bind_group(i as u32, g, &[]);
|
||||
}
|
||||
pass.set_vertex_buffer(0, vertex_buffer.slice(..));
|
||||
pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..24, 0, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
target.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &pixels,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: Some(1),
|
||||
},
|
||||
},
|
||||
target.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
pixels
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let pixels = pixels.slice(..).get_mapped_range().unwrap();
|
||||
let pixels: &[[f32; 4]] = bytemuck::cast_slice(&pixels);
|
||||
for (i, pixel) in pixels.iter().enumerate() {
|
||||
assert_eq!(
|
||||
*pixel,
|
||||
[
|
||||
[31.0, 0.0, -28.5, 130.5],
|
||||
[47.0, 13.0, -28.5, 134.5],
|
||||
[47.0, 13.0, -28.5, 134.5],
|
||||
[59.0, 13.0, -28.5, 134.5]
|
||||
][i / 4],
|
||||
"diagnostic raster pixel {i}"
|
||||
);
|
||||
}
|
||||
|
||||
// Reload changes only the immutable mapping; the same raw state/owner records
|
||||
// resolve to new material ids without baking a previous pack's ids into a mesh.
|
||||
let changed = storage(bytemuck::cast_slice(
|
||||
&super::super::pack_post::terrain_tables(&[211, -1, 223, 59], &[0, 1]),
|
||||
));
|
||||
let changed_groups = [
|
||||
groups[0].clone(),
|
||||
groups[1].clone(),
|
||||
groups[2].clone(),
|
||||
group(3, &[&ids, &vertex_storage, &changed]),
|
||||
];
|
||||
let reloaded = dispatch(&device, &queue, &pipeline, &changed_groups, &result);
|
||||
for i in 0..vertices.len() {
|
||||
assert_eq!(
|
||||
reloaded[2 * i],
|
||||
[
|
||||
if i < 4 {
|
||||
211.0
|
||||
} else if i < 12 {
|
||||
223.0
|
||||
} else {
|
||||
59.0
|
||||
},
|
||||
if i >= 12 { 1.0 } else { 0.0 },
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
);
|
||||
assert_eq!(reloaded[2 * i + 1], actual[2 * i + 1]);
|
||||
}
|
||||
let missing = storage(&[0; 8]);
|
||||
let missing_groups = [
|
||||
groups[0].clone(),
|
||||
groups[1].clone(),
|
||||
groups[2].clone(),
|
||||
group(3, &[&missing, &vertex_storage, &rects]),
|
||||
];
|
||||
let neutral = dispatch(&device, &queue, &pipeline, &missing_groups, &result);
|
||||
for i in 0..vertices.len() {
|
||||
assert_eq!(neutral[2 * i], [-1.0, 0.0, 0.0, 0.0]);
|
||||
assert_eq!(neutral[2 * i + 1], [0.0; 4]);
|
||||
}
|
||||
}
|
||||
@@ -247,7 +247,7 @@ impl Casters {
|
||||
pub fn cast(&self, span: &EntitySpan, program: usize) -> bool {
|
||||
match program {
|
||||
ENTITY => self.entities && (!self.only_player || span.player),
|
||||
BLOCK => self.blocks && (!self.only_lit || span.emissive),
|
||||
BLOCK | BLOCK_TRANSLUCENT => self.blocks && (!self.only_lit || span.emissive),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -440,7 +440,7 @@ impl PackEntities {
|
||||
let posed = skin.map_or(0, |s| s.vertices);
|
||||
if let Some(skin) = skin {
|
||||
for (span, range, overlay) in skin_runs(skin) {
|
||||
let program = program_of(&span).expect("tagged skin stage");
|
||||
let program = material_program(&span, overlay, drawn).expect("tagged skin stage");
|
||||
let pack = drawn[program] && !span.hidden;
|
||||
let casts = shadow.cast(&span, program);
|
||||
if !pack && !casts {
|
||||
@@ -462,7 +462,7 @@ impl PackEntities {
|
||||
extend_without_portals(rest, &entities[..first.min(entities.len())]);
|
||||
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let mut without_fluids = Vec::new();
|
||||
for (span, range) in runs(entities.len(), spans) {
|
||||
for (span, range, material) in material_runs(entities, spans, drawn) {
|
||||
let run_start = range.start as u32;
|
||||
let whole = &entities[range];
|
||||
// A native fluid carries sprite-array UVs, not shared-atlas UVs.
|
||||
@@ -490,7 +490,8 @@ impl PackEntities {
|
||||
// gateway: its cube and its beam): each goes to its own program.
|
||||
// Beacon beams come in a run of their own, at stage NONE (game.rs: no stage of 5.4
|
||||
// is theirs), for gbuffers_beaconbeam.
|
||||
let split = (program_of(&span) == Some(BLOCK) || span.stage == render_stage("NONE"))
|
||||
let split = (matches!(material, Some(BLOCK | BLOCK_TRANSLUCENT))
|
||||
|| span.stage == render_stage("NONE"))
|
||||
&& whole
|
||||
.chunks(4)
|
||||
.any(|q| matches!(q[0].layer & 0xffff, 6 | 9 | 10));
|
||||
@@ -520,7 +521,7 @@ impl PackEntities {
|
||||
let program = match part {
|
||||
Part::Portal => Some(BLOCK),
|
||||
Part::Beam => Some(BEAM),
|
||||
Part::Plain => program_of(&span).filter(|&p| {
|
||||
Part::Plain => material.filter(|&p| {
|
||||
plain(run)
|
||||
|| (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
|
||||
}),
|
||||
@@ -635,7 +636,7 @@ impl PackEntities {
|
||||
portal: bool,
|
||||
) {
|
||||
let (entity_id, block_id) = match program {
|
||||
BLOCK => (0, span.id),
|
||||
BLOCK | BLOCK_TRANSLUCENT => (0, span.id),
|
||||
_ => (span.id, 0),
|
||||
};
|
||||
let mut bytes = [0u8; SLOT as usize];
|
||||
@@ -782,7 +783,76 @@ pub const PARTICLE: usize = 3;
|
||||
pub const DESTROY: usize = 4;
|
||||
/// Beacon and End gateway beams (`gbuffers_beaconbeam`).
|
||||
pub const BEAM: usize = 5;
|
||||
pub const PROGRAMS: usize = 6;
|
||||
pub const BLOCK_TRANSLUCENT: usize = 6;
|
||||
pub const PARTICLE_TRANSLUCENT: usize = 7;
|
||||
pub const PROGRAMS: usize = 8;
|
||||
|
||||
/// Translucent blocks retain the ordinary block shadow program and blockEntityId.
|
||||
pub fn shadow_program(program: usize) -> usize {
|
||||
if program == BLOCK_TRANSLUCENT {
|
||||
BLOCK
|
||||
} else {
|
||||
program
|
||||
}
|
||||
}
|
||||
|
||||
fn material_program(span: &EntitySpan, layer: u32, drawn: [bool; PROGRAMS]) -> Option<usize> {
|
||||
Some(match program_of(span)? {
|
||||
BLOCK
|
||||
if drawn[BLOCK_TRANSLUCENT]
|
||||
&& (span.translucent
|
||||
|| layer & super::OIT_ENTITY != 0
|
||||
|| matches!(
|
||||
layer & 0xffff,
|
||||
super::TRANSLUCENT_ITEM_OVERLAY | super::TRANSLUCENT_GLINT_ITEM_OVERLAY
|
||||
)) =>
|
||||
{
|
||||
BLOCK_TRANSLUCENT
|
||||
}
|
||||
PARTICLE
|
||||
if drawn[PARTICLE_TRANSLUCENT]
|
||||
&& layer & 0xffff == super::TRANSLUCENT_PARTICLE_OVERLAY =>
|
||||
{
|
||||
PARTICLE_TRANSLUCENT
|
||||
}
|
||||
p => p,
|
||||
})
|
||||
}
|
||||
|
||||
/// Split only where an available variant changes a quad's program. Keep contiguous runs,
|
||||
/// their vertex order and the original single draw when no material variant is usable.
|
||||
fn material_runs<'a>(
|
||||
vertices: &'a [Vertex],
|
||||
spans: &'a [EntitySpan],
|
||||
drawn: [bool; PROGRAMS],
|
||||
) -> impl Iterator<Item = (EntitySpan, Range<usize>, Option<usize>)> + 'a {
|
||||
runs(vertices.len(), spans).flat_map(move |(span, range)| {
|
||||
let split = match program_of(&span) {
|
||||
Some(BLOCK) => drawn[BLOCK_TRANSLUCENT],
|
||||
Some(PARTICLE) => drawn[PARTICLE_TRANSLUCENT],
|
||||
_ => false,
|
||||
};
|
||||
let mut start = range.start;
|
||||
std::iter::from_fn(move || {
|
||||
if start >= range.end {
|
||||
return None;
|
||||
}
|
||||
let program = material_program(&span, vertices[start].layer, drawn);
|
||||
let end = if split {
|
||||
(start..range.end)
|
||||
.step_by(4)
|
||||
.skip(1)
|
||||
.find(|&i| material_program(&span, vertices[i].layer, drawn) != program)
|
||||
.unwrap_or(range.end)
|
||||
} else {
|
||||
range.end
|
||||
};
|
||||
let run = start..end;
|
||||
start = end;
|
||||
Some((span, run, program))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has;
|
||||
/// `None`: Horizon draws it.
|
||||
@@ -940,11 +1010,17 @@ mod tests {
|
||||
assert_eq!(drawn_by_pack(&spans[0], all), Some(ENTITY));
|
||||
assert_eq!(drawn_by_pack(&spans[1], all), Some(PARTICLE));
|
||||
assert_eq!(
|
||||
drawn_by_pack(&spans[1], [true, true, true, false, true, true]),
|
||||
drawn_by_pack(
|
||||
&spans[1],
|
||||
[true, true, true, false, true, true, false, false]
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
drawn_by_pack(&spans[2], [true, false, true, true, true, true]),
|
||||
drawn_by_pack(
|
||||
&spans[2],
|
||||
[true, false, true, true, true, true, false, false]
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK));
|
||||
@@ -1676,3 +1752,6 @@ mod tier_translate_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod material_route_tests;
|
||||
@@ -0,0 +1,62 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn optional_variants_split_contiguous_materials_without_guessing_from_alpha() {
|
||||
let mut vertices = Vec::new();
|
||||
for layer in [
|
||||
0,
|
||||
super::super::OIT_ENTITY,
|
||||
0,
|
||||
super::super::TRANSLUCENT_PARTICLE_OVERLAY,
|
||||
0,
|
||||
] {
|
||||
vertices.extend(
|
||||
[Vertex {
|
||||
layer: layer | 3 << 16,
|
||||
color: 0x40808080,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
}; 4],
|
||||
);
|
||||
}
|
||||
let spans = [
|
||||
EntitySpan {
|
||||
stage: render_stage("BLOCK_ENTITIES"),
|
||||
id: 71,
|
||||
..Default::default()
|
||||
},
|
||||
EntitySpan {
|
||||
start: 8,
|
||||
stage: render_stage("PARTICLES"),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let collect = |drawn| {
|
||||
material_runs(&vertices, &spans, drawn)
|
||||
.map(|(_, range, p)| (range, p))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let mut legacy = [true; PROGRAMS];
|
||||
legacy[BLOCK_TRANSLUCENT] = false;
|
||||
legacy[PARTICLE_TRANSLUCENT] = false;
|
||||
assert_eq!(
|
||||
collect(legacy),
|
||||
[(0..8, Some(BLOCK)), (8..20, Some(PARTICLE))]
|
||||
);
|
||||
assert_eq!(
|
||||
collect([true; PROGRAMS]),
|
||||
[
|
||||
(0..4, Some(BLOCK)),
|
||||
(4..8, Some(BLOCK_TRANSLUCENT)),
|
||||
(8..12, Some(PARTICLE)),
|
||||
(12..16, Some(PARTICLE_TRANSLUCENT)),
|
||||
(16..20, Some(PARTICLE)),
|
||||
]
|
||||
);
|
||||
let casters = Casters {
|
||||
blocks: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(casters.cast(&spans[0], BLOCK_TRANSLUCENT));
|
||||
assert_eq!(shadow_program(BLOCK_TRANSLUCENT), BLOCK);
|
||||
assert!(!casters.cast(&spans[1], PARTICLE_TRANSLUCENT));
|
||||
}
|
||||
@@ -533,8 +533,7 @@ pub(super) struct World<'a> {
|
||||
pub lightmap: wgpu::TextureView,
|
||||
/// Terrain's geometry, when the pack draws it.
|
||||
pub terrain: Option<Geometry<'a>>,
|
||||
/// Of each block sprite layer, `mc_Entity` (id, then 1 for fluids or 0) and `at_midBlock.w`
|
||||
/// (its block's light): three values a layer.
|
||||
/// Per source block state: its model material id, then its fluid material id.
|
||||
pub block_ids: &'a [i32],
|
||||
/// The atlas of the sun and moon's sprites.
|
||||
pub celestial: Option<wgpu::TextureView>,
|
||||
@@ -667,10 +666,10 @@ pub struct PackPost {
|
||||
down: HashMap<wgpu::TextureFormat, (wgpu::RenderPipeline, wgpu::BindGroupLayout)>,
|
||||
/// The per-draw uniforms of gbuffers programs.
|
||||
draws: DrawUniforms,
|
||||
/// Set 3 of flat terrain (`mc_Entity`'s table, then the sprites' places) and its bind group
|
||||
/// for each arena page, with the page buffer it was made for.
|
||||
/// Set 3 of flat terrain: an unknown-source fallback and immutable state/sprite tables.
|
||||
/// Each page's bind group tracks both its vertex buffer and optional source sidecar.
|
||||
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer)>,
|
||||
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
|
||||
page_groups: HashMap<u32, (wgpu::Buffer, Option<wgpu::Buffer>, wgpu::BindGroup)>,
|
||||
gui_size: [u32; 2],
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
pub timing: Option<super::pack_timing::PassTiming>,
|
||||
@@ -1210,30 +1209,44 @@ pub(super) struct ShadowView {
|
||||
pub depth_planes: bool,
|
||||
}
|
||||
|
||||
/// Set 3 of flat terrain, read by the vertex stage: the sprite layers' table (`mc_Entity`'s id
|
||||
/// and fluid, `at_midBlock.w`; -1, -1, 0 when empty), per arena page, the page's vertices
|
||||
/// (a vertex reads its quad's corners for its normal), and each sprite's place in a stitched
|
||||
/// atlas (`rects`; zeros for a grid, which doesn't read them).
|
||||
fn block_ids(
|
||||
/// An arena page's key, native vertices and optional per-quad source records.
|
||||
pub(super) type TerrainPage = (u32, wgpu::Buffer, Option<wgpu::Buffer>);
|
||||
|
||||
/// Header (sprite-rect words, state count), sprite rectangles, then model/fluid ids.
|
||||
/// Keeping immutable tables together leaves the existing three storage bindings intact.
|
||||
pub(super) fn terrain_tables(table: &[i32], rects: &[u32]) -> Vec<i32> {
|
||||
let rects = if rects.is_empty() { &[0, 0][..] } else { rects };
|
||||
[rects.len() as i32, (table.len() / 2) as i32]
|
||||
.into_iter()
|
||||
.chain(rects.iter().map(|&v| v as i32))
|
||||
.chain(table.iter().copied())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn block_ids(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
table: &[i32],
|
||||
rects: &[u32],
|
||||
) -> (wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer) {
|
||||
let empty = [-1, -1, 0];
|
||||
let table = if table.is_empty() { &empty[..] } else { table };
|
||||
) -> Option<(wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer)> {
|
||||
let buffer = |label, contents: &[u8]| {
|
||||
wgpu::util::DeviceExt::create_buffer_init(
|
||||
if contents.len() as u64 > device.limits().max_storage_buffer_binding_size {
|
||||
return None;
|
||||
}
|
||||
let buffer = super::arena::try_create(
|
||||
device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(label),
|
||||
contents,
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
},
|
||||
)
|
||||
label,
|
||||
contents.len() as u64,
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
)?;
|
||||
queue.write_buffer(&buffer, 0, contents);
|
||||
Some(buffer)
|
||||
};
|
||||
let rects = if rects.is_empty() { &[0, 0][..] } else { rects };
|
||||
let ids = buffer("Shader pack block ids", bytemuck::cast_slice(table));
|
||||
let rects = buffer("Shader pack sprite rects", bytemuck::cast_slice(rects));
|
||||
let ids = buffer("Unknown terrain sources", &[0; 8])?;
|
||||
let rects = buffer(
|
||||
"Shader pack terrain tables",
|
||||
bytemuck::cast_slice(&terrain_tables(table, rects)),
|
||||
)?;
|
||||
let storage = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
@@ -1248,7 +1261,7 @@ fn block_ids(
|
||||
label: Some("Shader pack terrain"),
|
||||
entries: &[storage(0), storage(1), storage(2)],
|
||||
});
|
||||
(layout, ids, rects)
|
||||
Some((layout, ids, rects))
|
||||
}
|
||||
|
||||
/// The game's geometry a gbuffers program draws: its vertex buffers and, for flat draws
|
||||
@@ -2583,6 +2596,20 @@ impl PackPost {
|
||||
.fold(0, |m, n| m | 1 << n),
|
||||
jobs: defer.then(Default::default),
|
||||
};
|
||||
// Flat terrain resolves each quad's source state against the current pack.
|
||||
let block_ids = world
|
||||
.terrain
|
||||
.as_ref()
|
||||
.filter(|g| g.table.is_some())
|
||||
.map(|_| {
|
||||
block_ids(device, queue, world.block_ids, world.sprite_rects).ok_or_else(|| {
|
||||
Refused {
|
||||
reason: "terrain source tables could not be allocated".into(),
|
||||
notes: notes.clone(),
|
||||
}
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
|
||||
// Not wanted any more: the rest is skipped and the build refused below.
|
||||
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
@@ -2694,12 +2721,6 @@ impl PackPost {
|
||||
}
|
||||
Some(pass)
|
||||
};
|
||||
// Flat terrain reads `mc_Entity` from a table of the sprite layers.
|
||||
let block_ids = world
|
||||
.terrain
|
||||
.as_ref()
|
||||
.filter(|g| g.table.is_some())
|
||||
.map(|_| block_ids(device, world.block_ids, world.sprite_rects));
|
||||
let terrain_geometry = world.terrain.as_ref().map(|g| Geometry {
|
||||
buffers: g.buffers,
|
||||
table: g.table,
|
||||
@@ -2779,14 +2800,18 @@ impl PackPost {
|
||||
post.particles,
|
||||
post.damaged_block,
|
||||
post.beacon_beam,
|
||||
post.block_translucent,
|
||||
post.particles_translucent,
|
||||
];
|
||||
let late = [
|
||||
let mut late = [
|
||||
false,
|
||||
false,
|
||||
post.translucent_late,
|
||||
post.particles_late,
|
||||
false,
|
||||
true,
|
||||
post.translucent_late,
|
||||
true,
|
||||
];
|
||||
let shadow_only = (post.shadow_only_player, post.shadow_only_lit_blocks);
|
||||
let shadow_programs = [post.shadow_entities, post.shadow_block];
|
||||
@@ -2828,6 +2853,11 @@ impl PackPost {
|
||||
}
|
||||
})
|
||||
});
|
||||
// Translation alone is insufficient: a refused GPU pipeline must retain the
|
||||
// legacy single particle pass and its timing as well as its geometry grouping.
|
||||
if entity_passes[super::pack_entities::PARTICLE_TRANSLUCENT].is_some() {
|
||||
late[super::pack_entities::PARTICLE] = false;
|
||||
}
|
||||
let shadow_entities = shadow_programs.map(|p| {
|
||||
p.zip(entity_geometry.as_ref())
|
||||
.and_then(|(p, g)| paged(build(p, Some(g))))
|
||||
@@ -2851,10 +2881,12 @@ impl PackPost {
|
||||
cull: false,
|
||||
});
|
||||
let hides_clouds = post.clouds_off || post.clouds.is_some() && cloud_geometry.is_some();
|
||||
// Rain and snow test depth but never write it: Iris 1.11.7 left none of either in
|
||||
// `depthtex0`, whatever `rain.depth` said (probe packs of ours, #10492).
|
||||
let weather_geometry = world.weather.then(|| Geometry {
|
||||
buffers: &entity_buffers,
|
||||
table: None,
|
||||
depth_write: post.rain_depth,
|
||||
depth_write: false,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
@@ -3938,7 +3970,7 @@ impl PackPost {
|
||||
}
|
||||
let (groups, ids) = (&self.page_groups, pass.ids);
|
||||
let page = |rp: &mut wgpu::RenderPass<'_>, page: u32| {
|
||||
if let Some((_, group)) = groups.get(&page).filter(|_| ids) {
|
||||
if let Some((_, _, group)) = groups.get(&page).filter(|_| ids) {
|
||||
rp.set_bind_group(3, group, &[]);
|
||||
}
|
||||
};
|
||||
@@ -4156,7 +4188,7 @@ impl PackPost {
|
||||
continue;
|
||||
};
|
||||
let mut draws = (geometry.draws.iter())
|
||||
.filter(|d| d.shadow && d.program == program)
|
||||
.filter(|d| d.shadow && super::pack_entities::shadow_program(d.program) == program)
|
||||
.peekable();
|
||||
if draws.peek().is_none() {
|
||||
continue;
|
||||
@@ -4168,7 +4200,11 @@ impl PackPost {
|
||||
encoder,
|
||||
pass,
|
||||
size,
|
||||
(geometry.draws.iter()).any(|d| d.shadow && d.program == program && d.portal),
|
||||
(geometry.draws.iter()).any(|d| {
|
||||
d.shadow
|
||||
&& super::pack_entities::shadow_program(d.program) == program
|
||||
&& d.portal
|
||||
}),
|
||||
);
|
||||
let mut on_portal = false;
|
||||
let mut rp = self.begin(encoder, pass);
|
||||
@@ -5153,7 +5189,7 @@ impl PackPost {
|
||||
inputs: &PackInputs,
|
||||
size: [u32; 2],
|
||||
window: [u32; 2],
|
||||
pages: &[(u32, wgpu::Buffer)],
|
||||
pages: &[TerrainPage],
|
||||
terrain: &mut TerrainDraw<'_>,
|
||||
) {
|
||||
self.resize(device, size);
|
||||
@@ -5189,8 +5225,8 @@ impl PackPost {
|
||||
if let Some((layout, ids, rects)) = &self.block_ids {
|
||||
// A page that grew has a new buffer.
|
||||
self.page_groups
|
||||
.retain(|p, (b, _)| pages.iter().any(|(q, c)| q == p && c == b));
|
||||
for (page, buffer) in pages {
|
||||
.retain(|p, (b, s, _)| pages.iter().any(|(q, c, t)| q == p && c == b && s == t));
|
||||
for (page, buffer, sources) in pages {
|
||||
self.page_groups.entry(*page).or_insert_with(|| {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack terrain"),
|
||||
@@ -5198,7 +5234,7 @@ impl PackPost {
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: ids.as_entire_binding(),
|
||||
resource: sources.as_ref().unwrap_or(ids).as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
@@ -5210,7 +5246,7 @@ impl PackPost {
|
||||
},
|
||||
],
|
||||
});
|
||||
(buffer.clone(), group)
|
||||
(buffer.clone(), sources.clone(), group)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6413,3 +6449,6 @@ mod mipmap_box_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod colortex_mip_blend_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod material_route_tests;
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Original end-to-end material routing, timing and blending control; no pack assets.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::gfx::{OIT_ENTITY, TRANSLUCENT_PARTICLE_OVERLAY, Vertex, pack_draw::EntitySpan};
|
||||
use crate::hud::HudBatch;
|
||||
use horizon_shaders::render_stage;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires an offscreen graphics adapter; run explicitly"]
|
||||
fn authored_material_programs_render_and_legacy_draws_stay_unsplit() {
|
||||
let gpu = Gpu::new().expect("an executed adapter is required for this control");
|
||||
let mut vertices = Vec::new();
|
||||
for (i, layer) in [0, OIT_ENTITY, 0, TRANSLUCENT_PARTICLE_OVERLAY]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let x = -1.0 + i as f32 * 0.5;
|
||||
for pos in [
|
||||
[x, -1.0, 0.0],
|
||||
[x + 0.5, -1.0, 0.0],
|
||||
[x + 0.5, 1.0, 0.0],
|
||||
[x, 1.0, 0.0],
|
||||
] {
|
||||
vertices.push(Vertex {
|
||||
pos,
|
||||
layer: layer | 2 << 16,
|
||||
color: 0xffff_ffff,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
});
|
||||
}
|
||||
}
|
||||
let spans = [
|
||||
EntitySpan {
|
||||
stage: render_stage("BLOCK_ENTITIES"),
|
||||
id: 17,
|
||||
..Default::default()
|
||||
},
|
||||
EntitySpan {
|
||||
start: 8,
|
||||
stage: render_stage("PARTICLES"),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
for variants in [false, true] {
|
||||
let vertex = "#version 120\nvoid main(){gl_Position=gl_Vertex;}";
|
||||
let mut files = vec![
|
||||
("final.vsh".into(), VERTEX.into()),
|
||||
("final.fsh".into(), "#version 120\nuniform sampler2D colortex0; varying vec2 uv; void main(){gl_FragColor=vec4(texture2D(colortex0,uv).rgb,1.0);}".into()),
|
||||
("deferred.vsh".into(), VERTEX.into()),
|
||||
("deferred.fsh".into(), "#version 120\nuniform sampler2D colortex0; varying vec2 uv; void main(){gl_FragData[0]=vec4(texture2D(colortex0,uv).rgb*0.5,1.0);}".into()),
|
||||
("shaders.properties".into(), "particles.ordering=mixed\nseparateEntityDraws=true\nblend.gbuffers_block=off\nblend.gbuffers_particles=off\nblend.gbuffers_block_translucent=SRC_ALPHA ONE_MINUS_SRC_ALPHA ZERO ZERO\nblend.gbuffers_particles_translucent=SRC_ALPHA ONE_MINUS_SRC_ALPHA ZERO ZERO\n".into()),
|
||||
];
|
||||
let programs = [
|
||||
("gbuffers_block", "vec4(1.0,0.0,0.0,1.0)"),
|
||||
("gbuffers_particles", "vec4(0.0,0.0,1.0,1.0)"),
|
||||
("gbuffers_block_translucent", "vec4(0.0,1.0,0.0,0.5)"),
|
||||
("gbuffers_particles_translucent", "vec4(1.0,1.0,0.0,0.5)"),
|
||||
];
|
||||
for (name, color) in programs.into_iter().take(if variants { 4 } else { 2 }) {
|
||||
let expected_id = if name.starts_with("gbuffers_block") {
|
||||
17
|
||||
} else {
|
||||
0
|
||||
};
|
||||
files.push((format!("{name}.vsh"), vertex.into()));
|
||||
files.push((format!("{name}.fsh"), format!("#version 120\nuniform int blockEntityId; void main(){{gl_FragData[0]={color}; if (blockEntityId != {expected_id}) gl_FragData[0]=vec4(1.0);}}")));
|
||||
}
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
let mut input = super::super::pack_entities::inputs();
|
||||
input.body = "hz_Vertex=vec4(hz_v_pos,1.0); hz_Color=vec4(1.0);".into();
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
horizon_shaders::GameInputs {
|
||||
entities: Some(input),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let hud = HudBatch::new(SIZE);
|
||||
for frame in 0..2 {
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
let mut rest = Vec::new();
|
||||
post.prepare_entities(
|
||||
&gpu.device,
|
||||
&gpu.queue,
|
||||
&vertices,
|
||||
&spans,
|
||||
[0.; 4],
|
||||
[0.; 4],
|
||||
&mut rest,
|
||||
None,
|
||||
(&[], &[]),
|
||||
);
|
||||
assert!(rest.is_empty());
|
||||
let entities = post.entities.as_ref().unwrap();
|
||||
assert_eq!(entities.geometry.draws.len(), if variants { 4 } else { 2 });
|
||||
let pixels = gpu.render(
|
||||
&mut post,
|
||||
&color,
|
||||
&depth,
|
||||
&fixture_frame(&hud),
|
||||
&mut |_, _, _, _| {},
|
||||
);
|
||||
// Early opaque draws are halved by deferred. Late variants blend alpha 1/2
|
||||
// over world clear 1/16 halved by deferred, giving 1/64 + color/2.
|
||||
let expected = if variants {
|
||||
[[128, 0, 0], [4, 132, 4], [0, 0, 128], [132, 132, 4]]
|
||||
} else {
|
||||
[[128, 0, 0], [128, 0, 0], [0, 0, 255], [0, 0, 255]]
|
||||
};
|
||||
for (i, rgb) in expected.into_iter().enumerate() {
|
||||
let pixel = pixels[(SIZE[1] / 2 * SIZE[0] + i as u32 * 4 + 2) as usize];
|
||||
eprintln!(
|
||||
"MATERIAL_ROUTE variants={variants} frame={frame} strip={i} actual={pixel:?} expected={rgb:?}"
|
||||
);
|
||||
assert!(pixel[..3].iter().zip(rgb).all(|(&a, b)| a.abs_diff(b) <= 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use wgpu::util::DeviceExt;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; missing adapter is a failure"]
|
||||
fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() {
|
||||
fn rain_and_snow_leave_depth_alone_whatever_rain_depth_says() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Weather depth GPU: {:?}", adapter.get_info());
|
||||
@@ -16,14 +16,10 @@ fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() {
|
||||
}))
|
||||
.unwrap();
|
||||
let mut depth_failures = Vec::new();
|
||||
// Iris 1.11.7 left no rain or snow in `depthtex0` with `rain.depth` absent, true or false,
|
||||
// drawn by `gbuffers_weather` or its fallback (probe packs of ours, #10492).
|
||||
for program in ["gbuffers_weather", "gbuffers_textured_lit"] {
|
||||
for (property, writes) in [
|
||||
("rain.depth=true", true),
|
||||
("rain.depth=false", false),
|
||||
// An evidence-limited compatibility choice: preserve accepted main here.
|
||||
// The permitted packs do not establish the external omitted-value default.
|
||||
("", true),
|
||||
] {
|
||||
for property in ["rain.depth=true", "rain.depth=false", ""] {
|
||||
let mut post = pack(&device, &queue, &adapter, program, property);
|
||||
// Retain the pipelines for another frame, then resize their targets.
|
||||
for size in [[6, 1], [6, 1], [12, 2]] {
|
||||
@@ -90,7 +86,7 @@ fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() {
|
||||
}
|
||||
let depth =
|
||||
f32::from_le_bytes(depths[offset..offset + 4].try_into().unwrap());
|
||||
let expected_depth = if visible && writes { 0.25 } else { 0.5 };
|
||||
let expected_depth = 0.5;
|
||||
if depth != expected_depth {
|
||||
depth_failures.push(format!(
|
||||
"{program} {property:?} size={size:?} ({x},{y}): depth={depth}, expected={expected_depth}"
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
//! A native mod's world warp ([`native_world_warp`](super::native_render::native_world_warp),
|
||||
//! docs/native-mods.md "World warps"): WGSL that every terrain vertex, far terrain's tiles
|
||||
//! included, and entity vertex goes through, so a mod can bend the drawn world itself (its real
|
||||
//! blocks, light and depth, seen from any side) rather than a picture of it.
|
||||
//!
|
||||
//! The stock shaders hold only comment markers where a warp goes in (terrain.wgsl's
|
||||
//! `[world warp ...]`, entity.wgsl's and static_entity.wgsl's): a frame without a warp draws
|
||||
//! with exactly the shaders it always did. For a warp, copies of them get the markers filled,
|
||||
//! the declarations of `world_warp.wgsl`, the mod's WGSL and, for the terrain, the pixel stage of
|
||||
//! `world_warp_fragment.wgsl` with the mod's `world_fragment` or [`DEFAULT_FRAGMENT`]; the
|
||||
//! pipelines built from them read the warp's uniform at group 2.
|
||||
//!
|
||||
//! Those pipelines don't cull back faces (a warp may turn a face over), and a frame drawn with
|
||||
//! them leaves nothing out by where it was (`Renderer::render`): what is out of view, hidden or
|
||||
//! facing away may be brought into view. It also keeps to the per-section draws, which these
|
||||
//! pipelines are built for.
|
||||
use super::native_render::WorldWarp;
|
||||
use super::{SolidPipeline, TERRAIN_ATTRIBUTES, TerrainVertex, Vertex};
|
||||
use glam::DVec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The line in a warp's source after which the terrain's pixel stage begins.
|
||||
const FRAGMENTS: &str = "// [fragments]";
|
||||
/// The pixel stage of a warp without one: the terrain as the game draws it, less faces it cut.
|
||||
pub const DEFAULT_FRAGMENT: &str = "fn world_fragment(f: Fragment) -> Painted {
|
||||
if f.cut > 0.0 { return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }
|
||||
return Painted(sprite(f.uv, f.layer) * f.color, 1.0);
|
||||
}
|
||||
";
|
||||
/// What terrain.wgsl's markers are filled with: the warp's two values in `TerrainOut`, and how
|
||||
/// they cross to the pixel stage in `TerrainWire`, after its five locations.
|
||||
const VARYINGS: &str = "warp_home: vec4<f32>,
|
||||
warp_at: vec3<f32>,";
|
||||
const WIRE: &str = "@location(5) warp_home: vec4<f32>,
|
||||
@location(6) warp_at: vec3<f32>,";
|
||||
const TO_WIRE: &str = "w.warp_home = o.warp_home;
|
||||
w.warp_at = o.warp_at;";
|
||||
const FROM_WIRE: &str = "o.warp_home = w.warp_home;
|
||||
o.warp_at = w.warp_at;";
|
||||
const VERTEX: &str = "let warp_home = position + origin;
|
||||
let warped = world_warp(warp_home, origin);
|
||||
let pos = warped.pos;";
|
||||
const VERTEX_OUT: &str = "out.color = out.color * vec4(max(warped.tint, vec3(0.0)), 1.0);
|
||||
out.warp_home = vec4(warp_home, select(0.0, 1.0, warped.tint.r < 0.0));
|
||||
out.warp_at = pos;";
|
||||
/// And entity.wgsl's and static_entity.wgsl's: an entity's vertex is its own `section`; a block
|
||||
/// entity's is its section's corner.
|
||||
const ENTITY: &str = "let at = world_warp(position, position).pos;";
|
||||
const STATIC_ENTITY: &str =
|
||||
"let section_corner = vec3<f32>(block_delta) - static_entity.camera_fraction.xyz;
|
||||
let camera_relative = world_warp(section_corner + position, section_corner).pos;";
|
||||
|
||||
/// The plainest warp, for the tests of the shaders it makes.
|
||||
#[cfg(test)]
|
||||
pub(super) const IDENTITY: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped { return Warped(pos, vec3(1.0)); }\n";
|
||||
|
||||
/// `shader` with what stands between the markers `// [name]` and `// [end name]` replaced.
|
||||
fn fill(shader: &str, name: &str, with: &str) -> Result<String, String> {
|
||||
let (from, to) = (format!("// [{name}]"), format!("// [end {name}]"));
|
||||
let a = shader
|
||||
.find(&from)
|
||||
.ok_or_else(|| format!("no `{from}` in the shader"))?;
|
||||
let b = shader[a..]
|
||||
.find(&to)
|
||||
.ok_or_else(|| format!("no `{to}` in the shader"))?
|
||||
+ a;
|
||||
Ok(format!("{}{with}{}", &shader[..a], &shader[b + to.len()..]))
|
||||
}
|
||||
|
||||
/// A warp's source split into what every warped shader takes and the terrain's pixel stage.
|
||||
fn split(source: &str) -> (&str, Option<&str>) {
|
||||
match source.find(FRAGMENTS) {
|
||||
Some(at) => (&source[..at], Some(&source[at + FRAGMENTS.len()..])),
|
||||
None => (source, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The warp's copy of terrain.wgsl.
|
||||
pub(super) fn terrain_shader(source: &str) -> Result<String, String> {
|
||||
let (vertex, fragment) = split(source);
|
||||
let fragment = fragment
|
||||
.filter(|f| f.contains("fn world_fragment"))
|
||||
.unwrap_or(DEFAULT_FRAGMENT);
|
||||
let shader = fill(&super::terrain_shader(), "world warp varyings", VARYINGS)?;
|
||||
let shader = fill(&shader, "world warp wire", WIRE)?;
|
||||
let shader = fill(&shader, "world warp to wire", TO_WIRE)?;
|
||||
let shader = fill(&shader, "world warp from wire", FROM_WIRE)?;
|
||||
let shader = fill(&shader, "world warp vertex", VERTEX)?;
|
||||
let shader = fill(&shader, "world warp out", VERTEX_OUT)?;
|
||||
Ok(format!(
|
||||
"{shader}\n{}\n{vertex}\n{}\n{fragment}",
|
||||
include_str!("world_warp.wgsl"),
|
||||
include_str!("world_warp_fragment.wgsl"),
|
||||
))
|
||||
}
|
||||
|
||||
/// The warp's copy of entity.wgsl with static_entity.wgsl, for every entity pipeline it draws.
|
||||
pub(super) fn entity_shader(source: &str) -> Result<String, String> {
|
||||
let (vertex, _) = split(source);
|
||||
let shader = fill(&super::static_entity_shader(), "world warp entity", ENTITY)?;
|
||||
let shader = fill(&shader, "world warp static entity", STATIC_ENTITY)?;
|
||||
Ok(format!(
|
||||
"{shader}\n{}\n{vertex}",
|
||||
include_str!("world_warp.wgsl")
|
||||
))
|
||||
}
|
||||
|
||||
fn validate(source: &str) -> Result<(), String> {
|
||||
let module = naga::front::wgsl::parse_str(source).map_err(|e| e.emit_to_string(source))?;
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.map_err(|e| e.emit_to_string(source))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The warp's uniform (`world_warp.wgsl`'s `WorldWarpUniform`): the mod's sixteen floats, then
|
||||
/// the camera split into f32 and what f32 left out of it, with the frame's partial tick.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Uniform {
|
||||
params: [[f32; 4]; 4],
|
||||
camera: [f32; 4],
|
||||
camera_rest: [f32; 4],
|
||||
}
|
||||
|
||||
fn uniform(warp: &WorldWarp, camera: DVec3, partial_tick: f32) -> Uniform {
|
||||
let (params, _) = warp.params.as_chunks::<4>();
|
||||
let params: [[f32; 4]; 4] = params.try_into().expect("sixteen floats");
|
||||
let at = camera.as_vec3();
|
||||
let rest = camera - at.as_dvec3();
|
||||
Uniform {
|
||||
params,
|
||||
camera: [at.x, at.y, at.z, partial_tick],
|
||||
camera_rest: [rest.x as f32, rest.y as f32, rest.z as f32, 0.0],
|
||||
}
|
||||
}
|
||||
|
||||
/// What the warp's pipelines are built with: the renderer's own layouts and formats.
|
||||
pub(super) struct Kit {
|
||||
/// terrain.wgsl's group 0, and with flat draws the per-section draws' page group 1
|
||||
/// (`vs_section` reads its vertices from it).
|
||||
pub terrain: wgpu::BindGroupLayout,
|
||||
pub pages: Option<wgpu::BindGroupLayout>,
|
||||
/// entity.wgsl's group 0 and static_entity.wgsl's group 1.
|
||||
pub entity: wgpu::BindGroupLayout,
|
||||
pub static_entity: wgpu::BindGroupLayout,
|
||||
pub format: wgpu::TextureFormat,
|
||||
pub sprite_constants: [(&'static str, f64); 2],
|
||||
/// Whether the display offers HDR output: the solid terrain is built lit by it too.
|
||||
pub hdr_output: bool,
|
||||
}
|
||||
|
||||
/// The warp's pipelines, in place of the renderer's while it is on.
|
||||
pub(super) struct Pipelines {
|
||||
pub solid: SolidPipeline,
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
pub entities: wgpu::RenderPipeline,
|
||||
pub static_entities: wgpu::RenderPipeline,
|
||||
pub translucent_items: wgpu::RenderPipeline,
|
||||
pub text: wgpu::RenderPipeline,
|
||||
pub entity_shadows: wgpu::RenderPipeline,
|
||||
pub hdr_mark: wgpu::RenderPipeline,
|
||||
pub hdr_entity_mark: wgpu::RenderPipeline,
|
||||
}
|
||||
|
||||
/// The warp's uniform, and what binds it.
|
||||
struct Bound {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
buffer: wgpu::Buffer,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
/// The renderer's world warp: nothing made until a mod gives one.
|
||||
pub(super) struct WorldWarps {
|
||||
kit: Kit,
|
||||
bound: Option<Bound>,
|
||||
built: Option<(Arc<str>, Pipelines)>,
|
||||
/// The last source that didn't compile, so it is tried once.
|
||||
refused: Option<Arc<str>>,
|
||||
/// Whether the log said a warp went undrawn under a shader pack drawing the terrain.
|
||||
told_undrawable: bool,
|
||||
}
|
||||
|
||||
impl WorldWarps {
|
||||
pub fn new(kit: Kit) -> Self {
|
||||
Self {
|
||||
kit,
|
||||
bound: None,
|
||||
built: None,
|
||||
refused: None,
|
||||
told_undrawable: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Readies this frame's warp: builds its pipelines when its source is new and writes its
|
||||
/// uniform. Whether the frame draws with it: not when it is off, didn't compile, or the
|
||||
/// frame can't take it (`drawable`: a shader pack draws the terrain).
|
||||
pub fn frame(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
warp: &WorldWarp,
|
||||
camera: DVec3,
|
||||
partial_tick: f32,
|
||||
drawable: bool,
|
||||
) -> bool {
|
||||
let Some(source) = &warp.source else {
|
||||
return false;
|
||||
};
|
||||
let same = |s: &Arc<str>| Arc::ptr_eq(s, source) || **s == **source;
|
||||
if !self.built.as_ref().is_some_and(|(s, _)| same(s)) {
|
||||
if self.refused.as_ref().is_some_and(same) {
|
||||
return false;
|
||||
}
|
||||
match self.build(device, source) {
|
||||
Ok(pipelines) => self.built = Some((source.clone(), pipelines)),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: a mod's world warp did not compile: {e}");
|
||||
self.refused = Some(source.clone());
|
||||
self.built = None;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !warp.on {
|
||||
return false;
|
||||
}
|
||||
if !drawable {
|
||||
if !self.told_undrawable {
|
||||
log::info!(
|
||||
"Horizon: a mod's world warp isn't drawn while a shader pack draws the terrain"
|
||||
);
|
||||
self.told_undrawable = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let bound = self.bound(device);
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
&bound.buffer,
|
||||
0,
|
||||
bytemuck::bytes_of(&uniform(warp, camera, partial_tick)),
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// The pipelines of the warp `frame` readied.
|
||||
pub fn pipelines(&self) -> Option<&Pipelines> {
|
||||
self.built.as_ref().map(|(_, p)| p)
|
||||
}
|
||||
|
||||
/// The warp's group 2, once a frame has drawn with it.
|
||||
pub fn group(&self) -> Option<&wgpu::BindGroup> {
|
||||
self.bound.as_ref().map(|b| &b.group)
|
||||
}
|
||||
|
||||
fn bound(&mut self, device: &wgpu::Device) -> &Bound {
|
||||
self.bound.get_or_insert_with(|| bind(device))
|
||||
}
|
||||
|
||||
fn build(&mut self, device: &wgpu::Device, source: &str) -> Result<Pipelines, String> {
|
||||
let started = std::time::Instant::now();
|
||||
let terrain = terrain_shader(source)?;
|
||||
let entity = entity_shader(source)?;
|
||||
validate(&terrain)?;
|
||||
validate(&entity)?;
|
||||
// Validation failures above are the mod's; past here wgpu's are the game's.
|
||||
let warp = &self.bound.get_or_insert_with(|| bind(device)).layout;
|
||||
let kit = &self.kit;
|
||||
let layout = |label, groups: &[Option<&wgpu::BindGroupLayout>]| {
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some(label),
|
||||
bind_group_layouts: groups,
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let terrain_layout = layout(
|
||||
"Warped terrain layout",
|
||||
&[Some(&kit.terrain), None, Some(warp)],
|
||||
);
|
||||
let pages_layout = kit.pages.as_ref().map(|pages| {
|
||||
layout(
|
||||
"Warped terrain layout (pulled)",
|
||||
&[Some(&kit.terrain), Some(pages), Some(warp)],
|
||||
)
|
||||
});
|
||||
let entity_layout = layout(
|
||||
"Warped entity layout",
|
||||
&[Some(&kit.entity), None, Some(warp)],
|
||||
);
|
||||
let static_layout = layout(
|
||||
"Warped static block-entity layout",
|
||||
&[Some(&kit.entity), Some(&kit.static_entity), Some(warp)],
|
||||
);
|
||||
let module = |label, source: String| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(label),
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
})
|
||||
};
|
||||
let terrain = module("Warped terrain", terrain);
|
||||
let entity = module("Warped entities", entity);
|
||||
|
||||
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
|
||||
let instances = wgpu::VertexBufferLayout {
|
||||
array_stride: 16,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &instance_attributes,
|
||||
};
|
||||
let vertices = wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &TERRAIN_ATTRIBUTES,
|
||||
};
|
||||
let buffered = [Some(vertices), Some(instances.clone())];
|
||||
let pulled = [None, Some(instances)];
|
||||
// As gfx.rs's: a float frame takes light where others take marks.
|
||||
let write_light = (
|
||||
"HDR_WRITE_LIGHT",
|
||||
f64::from(u8::from(kit.format == super::LINEAR_FRAME)),
|
||||
);
|
||||
let lit = [
|
||||
kit.sprite_constants[0],
|
||||
kit.sprite_constants[1],
|
||||
write_light,
|
||||
];
|
||||
let plain = [
|
||||
kit.sprite_constants[0],
|
||||
kit.sprite_constants[1],
|
||||
("HDR_LIGHTING", 0.0),
|
||||
write_light,
|
||||
];
|
||||
let depth = |write| wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(write),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
};
|
||||
// As gfx.rs's terrain pipelines, without back-face culling.
|
||||
let terrain_pipeline = |label,
|
||||
layout: &wgpu::PipelineLayout,
|
||||
vertex,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
fragment,
|
||||
constants: &[(&str, f64)],
|
||||
blend| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &terrain,
|
||||
entry_point: Some(vertex),
|
||||
compilation_options: Default::default(),
|
||||
buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: Some(depth(true)),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &terrain,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: kit.format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
// The per-section solid draws read the arena page with flat draws (`vs_section`), and
|
||||
// vertex buffers without.
|
||||
let (solid_layout, solid_vertex, solid_buffers): (_, _, &[_]) = match &pages_layout {
|
||||
Some(pages) => (pages, "vs_section", &pulled),
|
||||
None => (&terrain_layout, "vs_terrain", &buffered),
|
||||
};
|
||||
let solid = |constants: &[(&str, f64)]| {
|
||||
terrain_pipeline(
|
||||
"Warped terrain solid",
|
||||
solid_layout,
|
||||
solid_vertex,
|
||||
solid_buffers,
|
||||
"fs_warp_solid",
|
||||
constants,
|
||||
None,
|
||||
)
|
||||
};
|
||||
let solid = SolidPipeline {
|
||||
plain: solid(&plain),
|
||||
hdr: kit.hdr_output.then(|| solid(&lit)),
|
||||
};
|
||||
let translucent = terrain_pipeline(
|
||||
"Warped terrain translucent",
|
||||
&terrain_layout,
|
||||
"vs_terrain",
|
||||
&buffered,
|
||||
"fs_warp_translucent",
|
||||
&lit,
|
||||
Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
);
|
||||
|
||||
let entity_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
let entity_vertices = wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &entity_attributes,
|
||||
};
|
||||
let origin_attributes = wgpu::vertex_attr_array![7=>Sint32x3];
|
||||
let static_buffers = [
|
||||
Some(entity_vertices.clone()),
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<[i32; 3]>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &origin_attributes,
|
||||
}),
|
||||
];
|
||||
let entity_buffers = [Some(entity_vertices)];
|
||||
// As gfx.rs's `entity_pipeline_with`, for the pipelines a warped frame swaps.
|
||||
let entity_pipeline = |look: &EntityLook| {
|
||||
let (layout, vertex, buffers): (_, _, &[_]) = if look.static_mesh {
|
||||
(&static_layout, "vs_static_entity", &static_buffers)
|
||||
} else {
|
||||
(&entity_layout, "vs_entity", &entity_buffers)
|
||||
};
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(look.label),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &entity,
|
||||
entry_point: Some(vertex),
|
||||
compilation_options: Default::default(),
|
||||
buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: look.cull,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
bias: look.bias,
|
||||
..depth(look.depth_write)
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &entity,
|
||||
entry_point: Some(look.fragment),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &[write_light],
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: kit.format,
|
||||
blend: look.blend,
|
||||
write_mask: look.write_mask,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let pipelines = Pipelines {
|
||||
solid,
|
||||
translucent,
|
||||
entities: entity_pipeline(&ENTITIES),
|
||||
static_entities: entity_pipeline(&STATIC_ENTITIES),
|
||||
translucent_items: entity_pipeline(&TRANSLUCENT_ITEMS),
|
||||
text: entity_pipeline(&TEXT),
|
||||
entity_shadows: entity_pipeline(&ENTITY_SHADOWS),
|
||||
hdr_mark: entity_pipeline(&HDR_MARK),
|
||||
hdr_entity_mark: entity_pipeline(&HDR_ENTITY_MARK),
|
||||
};
|
||||
log::info!(
|
||||
"Horizon: a mod's world warp built in {} ms",
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
Ok(pipelines)
|
||||
}
|
||||
}
|
||||
|
||||
fn bind(device: &wgpu::Device) -> Bound {
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("World warp"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("World warp"),
|
||||
size: std::mem::size_of::<Uniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("World warp"),
|
||||
layout: &layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
Bound {
|
||||
layout,
|
||||
buffer,
|
||||
group,
|
||||
}
|
||||
}
|
||||
|
||||
/// How one of the entity pipelines a warped frame swaps draws: as gfx.rs builds the stock one
|
||||
/// of the same name.
|
||||
struct EntityLook {
|
||||
label: &'static str,
|
||||
static_mesh: bool,
|
||||
cull: Option<wgpu::Face>,
|
||||
depth_write: bool,
|
||||
bias: wgpu::DepthBiasState,
|
||||
blend: Option<wgpu::BlendState>,
|
||||
fragment: &'static str,
|
||||
write_mask: wgpu::ColorWrites,
|
||||
}
|
||||
const NO_BIAS: wgpu::DepthBiasState = wgpu::DepthBiasState {
|
||||
constant: 0,
|
||||
slope_scale: 0.0,
|
||||
clamp: 0.0,
|
||||
};
|
||||
const ENTITIES: EntityLook = EntityLook {
|
||||
label: "Warped entities",
|
||||
static_mesh: false,
|
||||
cull: None,
|
||||
depth_write: true,
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 128,
|
||||
slope_scale: 0.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
fragment: "fs_entity",
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
};
|
||||
const STATIC_ENTITIES: EntityLook = EntityLook {
|
||||
label: "Warped static block entities",
|
||||
static_mesh: true,
|
||||
bias: NO_BIAS,
|
||||
blend: None,
|
||||
fragment: "fs_entity_unmarked",
|
||||
..ENTITIES
|
||||
};
|
||||
const TRANSLUCENT_ITEMS: EntityLook = EntityLook {
|
||||
label: "Warped translucent items",
|
||||
bias: NO_BIAS,
|
||||
..ENTITIES
|
||||
};
|
||||
const TEXT: EntityLook = EntityLook {
|
||||
label: "Warped polygon offset text",
|
||||
cull: Some(wgpu::Face::Back),
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 10,
|
||||
slope_scale: 1.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
fragment: "fs_entity_unmarked",
|
||||
..ENTITIES
|
||||
};
|
||||
const ENTITY_SHADOWS: EntityLook = EntityLook {
|
||||
label: "Warped entity shadows",
|
||||
cull: Some(wgpu::Face::Back),
|
||||
depth_write: false,
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 128,
|
||||
slope_scale: 0.015,
|
||||
clamp: 0.0,
|
||||
},
|
||||
fragment: "fs_entity_shadow",
|
||||
..ENTITIES
|
||||
};
|
||||
const HDR_MARK: EntityLook = EntityLook {
|
||||
label: "Warped HDR particle mark",
|
||||
depth_write: false,
|
||||
bias: NO_BIAS,
|
||||
blend: Some(wgpu::BlendState::REPLACE),
|
||||
fragment: "fs_hdr_mark",
|
||||
write_mask: wgpu::ColorWrites::ALPHA,
|
||||
..ENTITIES
|
||||
};
|
||||
/// `HDR_MARK` at the entities' depth bias, for what they drew.
|
||||
const HDR_ENTITY_MARK: EntityLook = EntityLook {
|
||||
label: "Warped HDR entity mark",
|
||||
bias: ENTITIES.bias,
|
||||
..HDR_MARK
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const BEND: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
let lift = sin(pos.x * 0.1 + warp.params[0].x) * warp.params[0].y + section.y * 0.0;
|
||||
return Warped(pos + vec3(0.0, lift, 0.0), vec3(1.0));
|
||||
}
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn a_warp_without_fragments_draws_the_terrain_as_the_game_does() {
|
||||
let terrain = terrain_shader(BEND).unwrap();
|
||||
validate(&terrain).unwrap();
|
||||
assert!(terrain.contains(DEFAULT_FRAGMENT));
|
||||
let entity = entity_shader(BEND).unwrap();
|
||||
validate(&entity).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_warps_own_fragments_replace_the_default_and_stay_out_of_the_entities() {
|
||||
let source = format!(
|
||||
"{BEND}{FRAGMENTS}
|
||||
fn world_fragment(f: Fragment) -> Painted {{
|
||||
if !f.front || f.light.x < 1.0 {{ return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }}
|
||||
let far = length(f.home - f.at) + f.cut;
|
||||
return Painted(sprite(f.uv * 2.0, f.layer) * f.color, 1.0 - min(far, 1.0));
|
||||
}}
|
||||
"
|
||||
);
|
||||
let terrain = terrain_shader(&source).unwrap();
|
||||
validate(&terrain).unwrap();
|
||||
assert!(!terrain.contains(DEFAULT_FRAGMENT));
|
||||
let entity = entity_shader(&source).unwrap();
|
||||
validate(&entity).unwrap();
|
||||
assert!(!entity.contains("world_fragment"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stock_shaders_hold_nothing_of_a_warp() {
|
||||
for (name, shader) in [
|
||||
("terrain", super::super::terrain_shader()),
|
||||
("entity", super::super::static_entity_shader()),
|
||||
] {
|
||||
validate(&shader).unwrap();
|
||||
for word in ["world_warp(", "warp_home", "Warped", "group(2)"] {
|
||||
assert!(!shader.contains(word), "{name}.wgsl holds {word}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_marker_is_found() {
|
||||
let terrain = terrain_shader(BEND).unwrap();
|
||||
let entity = entity_shader(BEND).unwrap();
|
||||
for shader in [&terrain, &entity] {
|
||||
assert!(!shader.contains("// [world warp"), "a marker left unfilled");
|
||||
}
|
||||
assert!(terrain.contains("let warped = world_warp(warp_home, origin);"));
|
||||
assert!(entity.contains("let at = world_warp(position, position).pos;"));
|
||||
assert!(entity.contains("world_warp(section_corner + position, section_corner)"));
|
||||
assert_eq!(
|
||||
fill("a // [x] b // [end x] c", "x", "B"),
|
||||
Ok("a B c".to_owned())
|
||||
);
|
||||
assert!(fill("a // [x] b", "x", "B").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_warp_that_does_not_compile_is_refused_with_naga_s_reason() {
|
||||
let terrain =
|
||||
terrain_shader("fn world_warp(pos: vec3<f32>) -> Warped { return 1; }").unwrap();
|
||||
let e = validate(&terrain).unwrap_err();
|
||||
assert!(e.contains("world_warp"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_uniform_keeps_the_camera_exact_far_out() {
|
||||
let mut warp = WorldWarp::default();
|
||||
warp.params[5] = 2.0;
|
||||
let camera = DVec3::new(29_999_984.3, 70.25, -12_345_678.9);
|
||||
let u = uniform(&warp, camera, 0.25);
|
||||
assert_eq!(u.params[1][1], 2.0);
|
||||
assert_eq!(u.camera[3], 0.25);
|
||||
for i in 0..3 {
|
||||
let whole = f64::from(u.camera[i]) + f64::from(u.camera_rest[i]);
|
||||
assert!((whole - camera[i]).abs() < 1e-6, "{whole} {}", camera[i]);
|
||||
}
|
||||
assert_eq!(std::mem::size_of::<Uniform>(), 96);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// A native mod's world warp (gfx/world_warp.rs, docs/native-mods.md "World warps"): what the
|
||||
// game declares around the mod's WGSL in the warp's own copies of terrain.wgsl and entity.wgsl.
|
||||
// None of it is in the shaders a frame without a warp draws with.
|
||||
|
||||
// What `world_warp` makes of a vertex: where to draw it, from the camera, and what to multiply
|
||||
// its colour by (terrain only; red under 0 marks its faces cut: `Fragment.cut`).
|
||||
struct Warped {
|
||||
pos: vec3<f32>,
|
||||
tint: vec3<f32>,
|
||||
}
|
||||
struct WorldWarpUniform {
|
||||
// `WorldWarp::params`.
|
||||
params: array<vec4<f32>, 4>,
|
||||
// xyz: the camera in the world as f32; `camera_rest.xyz` what f32 left out of it, so
|
||||
// `(p - warp.camera.xyz) - warp.camera_rest.xyz` is the world's point `p` from the camera,
|
||||
// exact far from the world's origin. w: the frame's partial tick, 0 to 1, for motion
|
||||
// smoother than the ticks `params` are set in.
|
||||
camera: vec4<f32>,
|
||||
camera_rest: vec4<f32>,
|
||||
}
|
||||
@group(2) @binding(0) var<uniform> warp: WorldWarpUniform;
|
||||
@@ -0,0 +1,70 @@
|
||||
// A world warp's terrain pixels (gfx/world_warp.rs): added to the warp's copy of terrain.wgsl
|
||||
// only. `world_fragment` is the mod's if its source has one after `// [fragments]`, else
|
||||
// `DEFAULT_FRAGMENT` in gfx/world_warp.rs (the terrain as the game draws it).
|
||||
|
||||
// A pixel of the land as `world_fragment` gets it.
|
||||
struct Fragment {
|
||||
// Where this point of the world really is, from the camera: `world_warp`'s `pos`.
|
||||
home: vec3<f32>,
|
||||
// Where it is drawn, from the camera.
|
||||
at: vec3<f32>,
|
||||
// `sprite(uv, layer)` is the block's texture here.
|
||||
uv: vec2<f32>,
|
||||
layer: u32,
|
||||
// The vertices' colour: biome tint, light, ambient occlusion and the warp's tint.
|
||||
color: vec4<f32>,
|
||||
// Sky and block light, 0 to 15.
|
||||
light: vec2<f32>,
|
||||
// Over 0 where a vertex of the face had a tint with red under 0.
|
||||
cut: f32,
|
||||
// False on a face's back: the warp's pipelines don't cull, so both sides draw.
|
||||
front: bool,
|
||||
}
|
||||
// What `world_fragment` makes of it: a colour (alpha under 0 leaves the pixel out) and how much
|
||||
// of the game's fog it takes, 0 to 1.
|
||||
struct Painted {
|
||||
color: vec4<f32>,
|
||||
fog: f32,
|
||||
}
|
||||
fn warp_fragment(in: TerrainOut, front: bool) -> Fragment {
|
||||
return Fragment(in.warp_home.xyz, in.warp_at, in.uv, in.layer & 0x7fffu, in.color,
|
||||
in.scene.yx, in.warp_home.w, front);
|
||||
}
|
||||
// As `solid_color`, with the mod's colour in place of the texture's.
|
||||
@fragment fn fs_warp_solid(wire: TerrainWire, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
let dx = dpdx(in.local);
|
||||
let dy = dpdy(in.local);
|
||||
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
|
||||
hdr_normal = normalize(cross(dy, dx));
|
||||
}
|
||||
// (far terrain's tiles and the sections replacing them still cross-fade)
|
||||
if faded(in.fade, in.local, dx, dy) { discard; }
|
||||
let p = world_fragment(warp_fragment(in, front));
|
||||
if p.color.a < 0.0 { discard; }
|
||||
let color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, p.color.a), p.color, in.visibility);
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
let finished = solid_finish_at(in, color, color, dpdx(in.uv), dpdy(in.uv));
|
||||
return solid_light(vec4(mix(color.rgb, finished.rgb, p.fog), finished.a));
|
||||
}
|
||||
// As `fs_translucent`.
|
||||
@fragment fn fs_warp_translucent(wire: TerrainWire, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
|
||||
}
|
||||
let p = world_fragment(warp_fragment(in, front));
|
||||
if p.color.a < 0.0 { discard; }
|
||||
let color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, p.color.a), p.color, in.visibility);
|
||||
if color.a < 0.01 { discard; }
|
||||
let fogged = apply_fog(color, in.spherical, in.cylindrical);
|
||||
let finished = vec4(mix(color.rgb, fogged.rgb, p.fog), color.a);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fog));
|
||||
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
|
||||
}
|
||||
return finished;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use crate::{
|
||||
lang,
|
||||
options_ui::{Event, Page as OptionsPage},
|
||||
settings::{Opt, Settings},
|
||||
settings_shell::{self as shell, Group, Output, Row, Screen, Sheet, Value},
|
||||
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
|
||||
text::Text,
|
||||
};
|
||||
|
||||
@@ -1333,7 +1333,7 @@ fn shown(item: Item, s: &Settings) -> bool {
|
||||
/// A control's label as its name and its value ("Render Distance" and "12 chunks").
|
||||
fn parts(item: Item, s: &Settings) -> (String, Option<String>) {
|
||||
// The shortcut's label is one translated "Shortcut: %s"; the row shows its key apart, as
|
||||
// KeyBindsScreen does ("> key <" while it waits, `settings_shell::Value::Key`).
|
||||
// KeyBindsScreen does ("> key <" while it waits, `settings_shell::Value::Binding`).
|
||||
if item == Item::Horizon(H::HdrKey) {
|
||||
let key = crate::keybinds::display_name(&s.horizon.hdr_key).plain();
|
||||
return (lang::get("Shortcut"), Some(key));
|
||||
@@ -1347,9 +1347,10 @@ fn row(setting: Setting, s: &Settings) -> Row<Item> {
|
||||
let item = setting.item;
|
||||
let (name, value) = parts(item, s);
|
||||
let value = match (item, item.switch(s), item.slider(s)) {
|
||||
(Item::Horizon(H::HdrKey), ..) => {
|
||||
Value::Key(crate::graphics::HDR_KEY, value.unwrap_or_default())
|
||||
}
|
||||
(Item::Horizon(H::HdrKey), ..) => Value::Binding(Binding::key(
|
||||
crate::graphics::HDR_KEY,
|
||||
value.unwrap_or_default(),
|
||||
)),
|
||||
(_, Some(on), _) => Value::Switch(on),
|
||||
(_, None, Some(position)) => Value::Slider {
|
||||
position,
|
||||
@@ -1486,7 +1487,6 @@ impl Screen for GraphicsScreen {
|
||||
Output::Push(OptionsPage::ShaderPacks),
|
||||
]
|
||||
}
|
||||
Item::Horizon(H::HdrKey) => vec![Output::BindKey(crate::graphics::HDR_KEY)],
|
||||
_ => {
|
||||
item.cycle(s, back, self.framebuffer);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
@@ -1564,7 +1564,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
graphics_style as style,
|
||||
settings_shell::{Columns, Hosted, Shell, Target},
|
||||
settings_shell::{Capture, Columns, Hosted, Shell, Target},
|
||||
};
|
||||
use winit::keyboard::KeyCode as K;
|
||||
|
||||
@@ -1868,7 +1868,7 @@ mod tests {
|
||||
let key = row(&mut ui, &s, Item::Horizon(H::HdrKey));
|
||||
assert_eq!(
|
||||
press(&mut ui, &mut s, at(key)),
|
||||
[CLICK, Output::BindKey(crate::graphics::HDR_KEY)]
|
||||
[CLICK, Output::Bind(Capture::Key(crate::graphics::HDR_KEY))]
|
||||
);
|
||||
assert!(ui.changed());
|
||||
}
|
||||
@@ -2092,7 +2092,8 @@ mod tests {
|
||||
);
|
||||
let key = find(&s, Item::Horizon(H::HdrKey));
|
||||
assert_eq!(key.name, "Shortcut");
|
||||
assert!(matches!(key.value, Value::Key(crate::graphics::HDR_KEY, _)));
|
||||
let waits = Capture::Key(crate::graphics::HDR_KEY);
|
||||
assert!(matches!(key.value, Value::Binding(ref b) if b.capture == waits));
|
||||
let calibrate = find(&s, Item::Horizon(H::HdrCalibrate));
|
||||
assert_eq!(calibrate.value, Value::Press);
|
||||
}
|
||||
|
||||
@@ -1355,7 +1355,15 @@ impl HudAtlas {
|
||||
.filter(|&size| size != (w, h));
|
||||
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
|
||||
let animated = frames && animation.is_some();
|
||||
let (gw, gh) = gui_size.unwrap_or((w, h));
|
||||
// The cap below keeps GUI textures small; a particle sprite isn't capped, as
|
||||
// vanilla's particle atlas stitches any size (packs draw light shafts and fog
|
||||
// banks from 1024-wide textures).
|
||||
let particle = name.starts_with("particle/") || name.contains(":particle/");
|
||||
let (gw, gh) = if particle {
|
||||
(0, 0)
|
||||
} else {
|
||||
gui_size.unwrap_or((w, h))
|
||||
};
|
||||
let maps = read_maps
|
||||
.then(|| {
|
||||
let stem = path.file_stem()?.to_str()?;
|
||||
@@ -3427,6 +3435,32 @@ mod tests {
|
||||
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
/// A pack's particle sprite of any size packs whole, as vanilla's particle atlas stitches
|
||||
/// it (a light shaft drawn from a 1024-wide texture); the cap on GUI textures stays.
|
||||
#[test]
|
||||
fn large_particle_sprites_pack_and_large_gui_textures_dont() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-big-particle-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let rays = root.join("assets/mypack/textures/particle/light_rays");
|
||||
fs::create_dir_all(&rays).unwrap();
|
||||
write_strip(
|
||||
&rays.join("02.png"),
|
||||
1024,
|
||||
1024,
|
||||
&[[250, 240, 200, 255]],
|
||||
None,
|
||||
);
|
||||
let gui = root.join("assets/minecraft/textures/gui");
|
||||
fs::create_dir_all(&gui).unwrap();
|
||||
write_strip(&gui.join("huge.png"), 2048, 64, &[[1, 2, 3, 255]], None);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let rect = atlas.sprites["mypack:particle/light_rays/02"];
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (1024, 1024));
|
||||
assert_eq!(texel_at(&atlas, rect, 512, 512), [250, 240, 200, 255]);
|
||||
assert!(!atlas.sprites.contains_key("gui/huge"));
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
/// OptiFine random entity variants outside `textures/` pack under the names
|
||||
/// `RandomTextures` gives them; one beside its texture packs once, under its own.
|
||||
#[test]
|
||||
|
||||
@@ -50,10 +50,14 @@ pub fn text_with_backdrop(
|
||||
text(out, t, x, y, color, true);
|
||||
}
|
||||
|
||||
/// `Arrays.sort(T[], Comparator)` for the short arrays the HUD sorts: Java's
|
||||
/// TimSort below `MIN_MERGE` (a natural run, then binary insertion), which
|
||||
/// matters when the comparator is not a total order. Longer arrays fall back
|
||||
/// to a stable sort.
|
||||
/// `Arrays.sort(T[], Comparator)` for the short arrays the HUD sorts. Its Javadoc
|
||||
/// says it is adapted from Tim Peters's list sort for Python, and for fewer than 32
|
||||
/// elements that sort is one run plus binary insertion, as Tim Peters describes it
|
||||
/// in `listsort.txt` (CPython's `Objects/listsort.txt`): take the run at the start,
|
||||
/// reversed in place when it is strictly descending, then insert each remaining
|
||||
/// element by binary search after the elements equal to it, so the sort is stable.
|
||||
/// The order of the comparisons matters because vanilla's comparator here is not a
|
||||
/// total order. Longer arrays fall back to a stable sort.
|
||||
pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
|
||||
let n = a.len();
|
||||
if n < 2 {
|
||||
@@ -63,7 +67,7 @@ pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
|
||||
a.sort_by(cmp);
|
||||
return;
|
||||
}
|
||||
// countRunAndMakeAscending.
|
||||
// The run at the start: strictly descending (then reversed) or non-descending.
|
||||
let mut run = 2;
|
||||
if cmp(&a[1], &a[0]) == Ordering::Less {
|
||||
while run < n && cmp(&a[run], &a[run - 1]) == Ordering::Less {
|
||||
@@ -75,7 +79,7 @@ pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
|
||||
run += 1;
|
||||
}
|
||||
}
|
||||
// binarySort.
|
||||
// Binary insertion of the rest, each element after the last one not greater.
|
||||
for start in run..n {
|
||||
let (mut left, mut right) = (0, start);
|
||||
while left < right {
|
||||
|
||||
@@ -1283,7 +1283,7 @@ fn relight_layer(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use crate::chunk::{HEIGHT, MIN_Y, SECTIONS};
|
||||
|
||||
@@ -1312,13 +1312,13 @@ mod tests {
|
||||
assert_eq!(dirty(&[(0, 0, 0)]).len(), 8);
|
||||
}
|
||||
|
||||
const AIR: u32 = 0;
|
||||
pub(crate) const AIR: u32 = 0;
|
||||
const STONE: u32 = 1;
|
||||
const TORCH: u32 = 2;
|
||||
pub(crate) const TORCH: u32 = 2;
|
||||
const SLAB: u32 = 3;
|
||||
/// Air, stone, torch and a bottom slab (full bottom face, half sides,
|
||||
/// empty top), with a third face shape for the half sides.
|
||||
fn tables() -> LightTables {
|
||||
pub(crate) fn tables() -> LightTables {
|
||||
let cell = |dampening, emission, faces: [u16; 6]| Cell {
|
||||
dampening,
|
||||
emission,
|
||||
@@ -1337,7 +1337,7 @@ mod tests {
|
||||
3,
|
||||
)
|
||||
}
|
||||
fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
|
||||
pub(crate) fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
|
||||
world_of(crate::chunk::ColumnLayout::VANILLA, edit)
|
||||
}
|
||||
/// Lit chunks around (0, 0) of `layout`, stone up to y 0.
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::{
|
||||
mesher::SectionMesh,
|
||||
streaming::{LodSource, WorkerContext},
|
||||
};
|
||||
pub mod bake;
|
||||
pub mod cache;
|
||||
mod mesh;
|
||||
pub mod packed;
|
||||
@@ -2860,6 +2861,14 @@ impl Lod {
|
||||
if self.outstanding_meshes >= self.mesh_workers * 4 {
|
||||
return;
|
||||
}
|
||||
// As in `request_samples`: the far meshing threads run behind the render thread, so it
|
||||
// never waits for this lock. Busy now, the dirty tiles stay dirty for the next frame.
|
||||
let shared = self.shared.clone();
|
||||
let mut queued = match shared.mesh_jobs.try_lock() {
|
||||
Ok(queued) => queued,
|
||||
Err(std::sync::TryLockError::WouldBlock) => return,
|
||||
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
|
||||
};
|
||||
let keys: Vec<(TileKey, u64)> = self
|
||||
.dirty
|
||||
.drain()
|
||||
@@ -2878,7 +2887,6 @@ impl Lod {
|
||||
split: self.plan.split.clone(),
|
||||
});
|
||||
let covered = Arc::new(self.covered.clone());
|
||||
let mut queued = self.shared.mesh_jobs.lock().unwrap();
|
||||
let count = keys.len();
|
||||
for (key, version) in keys {
|
||||
let finer = self.finer_of(key);
|
||||
@@ -2989,7 +2997,14 @@ impl Lod {
|
||||
self.wanted_stale = false;
|
||||
}
|
||||
let mut issued = 0;
|
||||
let mut jobs = self.shared.jobs.lock().unwrap();
|
||||
// The far sampling threads take this lock too, and they run behind the render thread
|
||||
// (`far_terrain_current`): one preempted while holding it kept a frame waiting for over
|
||||
// 100 ms on a busy machine. Busy now, the requests go out next frame.
|
||||
let mut jobs = match self.shared.jobs.try_lock() {
|
||||
Ok(jobs) => jobs,
|
||||
Err(std::sync::TryLockError::WouldBlock) => return,
|
||||
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
|
||||
};
|
||||
while issued < free {
|
||||
let Some(std::cmp::Reverse(wanted)) = self.wanted.pop() else {
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
//! Baked far terrain: the generator's far samples (`LodSampler::grid`), kept
|
||||
//! in the dimension's folder (`horizon_lod/`) so the next session draws them
|
||||
//! without sampling the noise again. The detail chunks' columns are kept by
|
||||
//! `lod::cache`.
|
||||
//!
|
||||
//! Only generator output is baked: saved chunks still override it
|
||||
//! (`save::SavedLodSampler`), so edits show as before. A fingerprint of the
|
||||
//! game build, the world's data packs, the block and biome registries and the
|
||||
//! level's shape guards the folder; when any of them changes it is emptied.
|
||||
//! Reduced far detail's approximate samples are kept apart from full ones.
|
||||
use super::{Column, Span, Surface};
|
||||
use crate::{chunk::LevelShape, fast_hash::FxHashMap};
|
||||
use flate2::{Compression, read::ZlibDecoder, write::ZlibEncoder};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufReader, Read, Seek, SeekFrom, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
const FORMAT: &str = "horizon-lod-bake 3";
|
||||
const FOLDER: &str = "horizon_lod";
|
||||
const FINGERPRINT: &str = "fingerprint.txt";
|
||||
/// Tiles per bucket file side.
|
||||
const GRID_BUCKET: i32 = 8;
|
||||
/// The key written before each record: kind, x, z, stride, side.
|
||||
const KEY_BYTES: usize = 15;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
enum Key {
|
||||
/// `LodSampler::grid(x, z, stride, side)`, sampled approximately or not.
|
||||
Grid {
|
||||
approximate: bool,
|
||||
x: i32,
|
||||
z: i32,
|
||||
stride: i32,
|
||||
side: u16,
|
||||
},
|
||||
}
|
||||
impl Key {
|
||||
fn file(self) -> String {
|
||||
match self {
|
||||
Key::Grid {
|
||||
approximate,
|
||||
x,
|
||||
z,
|
||||
stride,
|
||||
side,
|
||||
} => {
|
||||
let span = stride * i32::from(side) * GRID_BUCKET;
|
||||
format!(
|
||||
"{}{stride}x{side}.{}.{}.bin",
|
||||
if approximate { "a" } else { "g" },
|
||||
x.div_euclid(span),
|
||||
z.div_euclid(span)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn bytes(self) -> [u8; KEY_BYTES] {
|
||||
let (kind, x, z, stride, side) = match self {
|
||||
Key::Grid {
|
||||
approximate,
|
||||
x,
|
||||
z,
|
||||
stride,
|
||||
side,
|
||||
} => (u8::from(approximate), x, z, stride, side),
|
||||
};
|
||||
let mut out = [0; KEY_BYTES];
|
||||
out[0] = kind;
|
||||
out[1..5].copy_from_slice(&x.to_le_bytes());
|
||||
out[5..9].copy_from_slice(&z.to_le_bytes());
|
||||
out[9..13].copy_from_slice(&stride.to_le_bytes());
|
||||
out[13..15].copy_from_slice(&side.to_le_bytes());
|
||||
out
|
||||
}
|
||||
fn from_bytes(b: &[u8; KEY_BYTES]) -> Option<Self> {
|
||||
let int = |at: usize| i32::from_le_bytes(b[at..at + 4].try_into().unwrap());
|
||||
match b[0] {
|
||||
kind @ (0 | 1) => Some(Key::Grid {
|
||||
approximate: kind == 1,
|
||||
x: int(1),
|
||||
z: int(5),
|
||||
stride: int(9),
|
||||
side: u16::from_le_bytes([b[13], b[14]]),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One append-only file of records: `[u32 length][key][zlib columns]`.
|
||||
struct Bucket {
|
||||
file: fs::File,
|
||||
/// Each record's compressed columns: offset and length.
|
||||
index: FxHashMap<Key, (u64, u32)>,
|
||||
end: u64,
|
||||
}
|
||||
impl Bucket {
|
||||
fn open(path: &Path) -> std::io::Result<Self> {
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(path)?;
|
||||
let mut index = FxHashMap::default();
|
||||
let mut end = 0u64;
|
||||
let len = file.metadata()?.len();
|
||||
{
|
||||
let mut reader = BufReader::new(&mut file);
|
||||
loop {
|
||||
let mut length = [0; 4];
|
||||
if reader.read_exact(&mut length).is_err() {
|
||||
break;
|
||||
}
|
||||
let length = u32::from_le_bytes(length);
|
||||
let mut key = [0; KEY_BYTES];
|
||||
if (length as usize) < KEY_BYTES || reader.read_exact(&mut key).is_err() {
|
||||
break;
|
||||
}
|
||||
let Some(key) = Key::from_bytes(&key) else {
|
||||
break;
|
||||
};
|
||||
let body = length - KEY_BYTES as u32;
|
||||
let start = end + 4 + KEY_BYTES as u64;
|
||||
// A record cut short by a crash ends the file there.
|
||||
if start + u64::from(body) > len || reader.seek_relative(i64::from(body)).is_err() {
|
||||
break;
|
||||
}
|
||||
index.insert(key, (start, body));
|
||||
end = start + u64::from(body);
|
||||
}
|
||||
}
|
||||
if len != end {
|
||||
file.set_len(end)?;
|
||||
}
|
||||
Ok(Self { file, index, end })
|
||||
}
|
||||
fn read(&mut self, key: Key) -> Option<Vec<u8>> {
|
||||
let &(offset, length) = self.index.get(&key)?;
|
||||
let mut body = vec![0; length as usize];
|
||||
self.file.seek(SeekFrom::Start(offset)).ok()?;
|
||||
self.file.read_exact(&mut body).ok()?;
|
||||
Some(body)
|
||||
}
|
||||
fn append(&mut self, key: Key, body: &[u8]) -> std::io::Result<()> {
|
||||
let length = (KEY_BYTES + body.len()) as u32;
|
||||
let mut record = Vec::with_capacity(4 + length as usize);
|
||||
record.extend_from_slice(&length.to_le_bytes());
|
||||
record.extend_from_slice(&key.bytes());
|
||||
record.extend_from_slice(body);
|
||||
self.file.seek(SeekFrom::Start(self.end))?;
|
||||
self.file.write_all(&record)?;
|
||||
let start = self.end + 4 + KEY_BYTES as u64;
|
||||
self.index.insert(key, (start, body.len() as u32));
|
||||
self.end = start + body.len() as u64;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A dimension's baked far terrain, shared by the far sampling threads.
|
||||
pub struct LodBake {
|
||||
dir: PathBuf,
|
||||
/// Open buckets by file name; `None` for one that failed to open.
|
||||
buckets: Mutex<FxHashMap<String, Option<Bucket>>>,
|
||||
}
|
||||
impl LodBake {
|
||||
/// Opens `dimension/horizon_lod`, emptying it when its fingerprint is not
|
||||
/// this build's, world's, level's and registries'. `None` (no baking) on
|
||||
/// an error.
|
||||
pub fn open(
|
||||
dimension: &Path,
|
||||
shape: LevelShape,
|
||||
block_states: usize,
|
||||
biomes: usize,
|
||||
) -> Option<Self> {
|
||||
let dir = dimension.join(FOLDER);
|
||||
let fingerprint = fingerprint(dimension, shape, block_states, biomes);
|
||||
let current = fs::read_to_string(dir.join(FINGERPRINT)).ok();
|
||||
if current.as_deref() != Some(fingerprint.as_str()) {
|
||||
if current.is_some() {
|
||||
log::info!("Horizon: baked far terrain is out of date; baking it again");
|
||||
}
|
||||
let reset = || -> std::io::Result<()> {
|
||||
if dir.exists() {
|
||||
fs::remove_dir_all(&dir)?;
|
||||
}
|
||||
fs::create_dir_all(&dir)?;
|
||||
fs::write(dir.join(FINGERPRINT), &fingerprint)
|
||||
};
|
||||
if let Err(e) = reset() {
|
||||
log::warn!(
|
||||
"Horizon: far terrain will not be baked ({}): {e}",
|
||||
dir.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Self {
|
||||
dir,
|
||||
buckets: Mutex::new(FxHashMap::default()),
|
||||
})
|
||||
}
|
||||
fn with_bucket<T>(&self, key: Key, f: impl FnOnce(&mut Bucket) -> Option<T>) -> Option<T> {
|
||||
let name = key.file();
|
||||
let mut buckets = self.buckets.lock().unwrap();
|
||||
let bucket = buckets
|
||||
.entry(name)
|
||||
.or_insert_with_key(|name| match Bucket::open(&self.dir.join(name)) {
|
||||
Ok(bucket) => Some(bucket),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: baked far terrain {name}: {e}");
|
||||
None
|
||||
}
|
||||
})
|
||||
.as_mut()?;
|
||||
f(bucket)
|
||||
}
|
||||
fn get(&self, key: Key, count: usize) -> Option<Vec<Column>> {
|
||||
let body = self.with_bucket(key, |bucket| bucket.read(key))?;
|
||||
let mut raw = Vec::new();
|
||||
ZlibDecoder::new(&body[..]).read_to_end(&mut raw).ok()?;
|
||||
decode(&raw, count)
|
||||
}
|
||||
fn put(&self, key: Key, columns: &[Column]) {
|
||||
if self.with_bucket(key, |bucket| Some(bucket.index.contains_key(&key))) != Some(false) {
|
||||
return;
|
||||
}
|
||||
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
|
||||
let _ = encoder.write_all(&encode(columns));
|
||||
let Ok(body) = encoder.finish() else { return };
|
||||
self.with_bucket(key, |bucket| {
|
||||
if !bucket.index.contains_key(&key)
|
||||
&& let Err(e) = bucket.append(key, &body)
|
||||
{
|
||||
log::warn!("Horizon: baking far terrain: {e}");
|
||||
}
|
||||
Some(())
|
||||
});
|
||||
}
|
||||
/// The baked `LodSampler::grid` result, if any.
|
||||
pub fn grid(
|
||||
&self,
|
||||
approximate: bool,
|
||||
[x, z]: [i32; 2],
|
||||
stride: i32,
|
||||
side: usize,
|
||||
) -> Option<Vec<Column>> {
|
||||
let side16 = u16::try_from(side).ok()?;
|
||||
self.get(
|
||||
Key::Grid {
|
||||
approximate,
|
||||
x,
|
||||
z,
|
||||
stride,
|
||||
side: side16,
|
||||
},
|
||||
side * side,
|
||||
)
|
||||
}
|
||||
pub fn put_grid(
|
||||
&self,
|
||||
approximate: bool,
|
||||
[x, z]: [i32; 2],
|
||||
stride: i32,
|
||||
side: usize,
|
||||
columns: &[Column],
|
||||
) {
|
||||
// A failed direct sample comes back as empty columns: never keep it.
|
||||
let Ok(side) = u16::try_from(side) else {
|
||||
return;
|
||||
};
|
||||
if columns.iter().all(|c| *c == Column::default()) {
|
||||
return;
|
||||
}
|
||||
self.put(
|
||||
Key::Grid {
|
||||
approximate,
|
||||
x,
|
||||
z,
|
||||
stride,
|
||||
side,
|
||||
},
|
||||
columns,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What baked columns depend on besides the world's seed (fixed per world).
|
||||
fn fingerprint(dimension: &Path, shape: LevelShape, block_states: usize, biomes: usize) -> String {
|
||||
let build = super::cache::build_key(block_states, biomes);
|
||||
let packs = world_packs_digest(dimension);
|
||||
format!(
|
||||
"{FORMAT}\nbuild {build:016x}\nlevel {} {}\ndatapacks {packs}\n",
|
||||
shape.min_y, shape.height,
|
||||
)
|
||||
}
|
||||
/// `files_digest` of the data packs of the world `dimension` is a folder of.
|
||||
pub fn world_packs_digest(dimension: &Path) -> String {
|
||||
// `dimensions/<namespace>/<path>` inside the world folder.
|
||||
dimension
|
||||
.ancestors()
|
||||
.nth(3)
|
||||
.map(|world| files_digest(&world.join("datapacks")))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// Every file under `root`: its path, size and change time.
|
||||
pub fn files_digest(root: &Path) -> String {
|
||||
let mut files = Vec::new();
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Ok(meta) = entry.metadata() else { continue };
|
||||
if meta.is_dir() {
|
||||
stack.push(path);
|
||||
} else {
|
||||
let modified = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map_or(0, |d| d.as_nanos());
|
||||
let relative = path.strip_prefix(root).unwrap_or(&path);
|
||||
files.push(format!("{} {} {modified}", relative.display(), meta.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sort_unstable();
|
||||
let mut hash = Sha256::new();
|
||||
for file in &files {
|
||||
hash.update(file.as_bytes());
|
||||
hash.update(b"\n");
|
||||
}
|
||||
hash.finalize().iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn encode(columns: &[Column]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(columns.len() * 24);
|
||||
out.extend_from_slice(&(columns.len() as u32).to_le_bytes());
|
||||
for column in columns {
|
||||
match column.surface {
|
||||
None => out.push(0),
|
||||
Some(s) => {
|
||||
out.push(if s.water.is_some() { 2 } else { 1 });
|
||||
out.extend_from_slice(&s.height.to_le_bytes());
|
||||
out.extend_from_slice(&s.state.0.to_le_bytes());
|
||||
out.extend_from_slice(&s.biome.to_le_bytes());
|
||||
if let Some((height, state)) = s.water {
|
||||
out.extend_from_slice(&height.to_le_bytes());
|
||||
out.extend_from_slice(&state.0.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&(column.spans.len() as u16).to_le_bytes());
|
||||
for span in &column.spans {
|
||||
out.extend_from_slice(&span.min.to_le_bytes());
|
||||
out.extend_from_slice(&span.max.to_le_bytes());
|
||||
out.extend_from_slice(&span.state.0.to_le_bytes());
|
||||
out.extend_from_slice(&span.biome.to_le_bytes());
|
||||
out.extend_from_slice(&span.light.to_le_bytes());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn decode(mut b: &[u8], count: usize) -> Option<Vec<Column>> {
|
||||
fn take<const N: usize>(b: &mut &[u8]) -> Option<[u8; N]> {
|
||||
let (head, rest) = b.split_first_chunk::<N>()?;
|
||||
*b = rest;
|
||||
Some(*head)
|
||||
}
|
||||
let i32_ = |b: &mut &[u8]| take::<4>(b).map(i32::from_le_bytes);
|
||||
let u32_ = |b: &mut &[u8]| take::<4>(b).map(u32::from_le_bytes);
|
||||
let u16_ = |b: &mut &[u8]| take::<2>(b).map(u16::from_le_bytes);
|
||||
let i16_ = |b: &mut &[u8]| take::<2>(b).map(i16::from_le_bytes);
|
||||
if u32_(&mut b)? as usize != count {
|
||||
return None;
|
||||
}
|
||||
let mut columns = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let kind = take::<1>(&mut b)?[0];
|
||||
let surface = match kind {
|
||||
0 => None,
|
||||
1 | 2 => {
|
||||
let height = i32_(&mut b)?;
|
||||
let state = BlockStateId(u32_(&mut b)?);
|
||||
let biome = u16_(&mut b)?;
|
||||
let water = if kind == 2 {
|
||||
Some((i32_(&mut b)?, BlockStateId(u32_(&mut b)?)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(Surface {
|
||||
height,
|
||||
state,
|
||||
water,
|
||||
biome,
|
||||
})
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let spans = (0..u16_(&mut b)?)
|
||||
.map(|_| {
|
||||
Some(Span {
|
||||
min: i16_(&mut b)?,
|
||||
max: i16_(&mut b)?,
|
||||
state: BlockStateId(u32_(&mut b)?),
|
||||
biome: u16_(&mut b)?,
|
||||
light: u16_(&mut b)?,
|
||||
})
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
columns.push(Column {
|
||||
surface,
|
||||
spans: spans.into(),
|
||||
});
|
||||
}
|
||||
b.is_empty().then_some(columns)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn sample(seed: i32) -> Column {
|
||||
Column {
|
||||
surface: Some(Surface {
|
||||
height: 70 + seed,
|
||||
state: BlockStateId(9),
|
||||
water: (seed % 2 == 0).then_some((80, BlockStateId(86))),
|
||||
biome: 3,
|
||||
}),
|
||||
spans: vec![
|
||||
Span {
|
||||
min: -64,
|
||||
max: (70 + seed) as i16,
|
||||
state: BlockStateId(1),
|
||||
biome: 3,
|
||||
light: 0,
|
||||
},
|
||||
Span {
|
||||
min: (70 + seed) as i16,
|
||||
max: 80,
|
||||
state: BlockStateId(86),
|
||||
biome: 3,
|
||||
light: 240,
|
||||
},
|
||||
]
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn columns_round_trip_and_survive_reopening() {
|
||||
let world = std::env::temp_dir().join(format!("horizon-lod-bake-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&world);
|
||||
let dimension = world.join("dimensions").join("minecraft").join("overworld");
|
||||
fs::create_dir_all(&dimension).unwrap();
|
||||
let grid: Vec<Column> = (0..256).map(sample).collect();
|
||||
let other: Vec<Column> = (1..257).map(sample).collect();
|
||||
let shape = LevelShape::OVERWORLD;
|
||||
{
|
||||
let bake = LodBake::open(&dimension, shape, 100, 10).unwrap();
|
||||
assert_eq!(bake.grid(false, [-64, 32], 4, 16), None);
|
||||
bake.put_grid(false, [-64, 32], 4, 16, &grid);
|
||||
// A second put of the same key is ignored, not appended.
|
||||
bake.put_grid(false, [-64, 32], 4, 16, &[sample(-1)]);
|
||||
assert_eq!(
|
||||
bake.grid(false, [-64, 32], 4, 16).as_deref(),
|
||||
Some(&grid[..])
|
||||
);
|
||||
// Reduced far detail's samples are kept apart from full ones.
|
||||
assert_eq!(bake.grid(true, [-64, 32], 4, 16), None);
|
||||
bake.put_grid(true, [-64, 32], 4, 16, &other);
|
||||
assert_eq!(
|
||||
bake.grid(true, [-64, 32], 4, 16).as_deref(),
|
||||
Some(&other[..])
|
||||
);
|
||||
}
|
||||
let bake = LodBake::open(&dimension, shape, 100, 10).unwrap();
|
||||
assert_eq!(
|
||||
bake.grid(false, [-64, 32], 4, 16).as_deref(),
|
||||
Some(&grid[..])
|
||||
);
|
||||
assert_eq!(bake.grid(false, [-64, 48], 4, 16), None);
|
||||
drop(bake);
|
||||
// A cut-off record (a crash mid-write) is dropped, the rest is kept:
|
||||
// (-128, 32) shares (-64, 32)'s bucket file.
|
||||
let key = Key::Grid {
|
||||
approximate: false,
|
||||
x: -64,
|
||||
z: 32,
|
||||
stride: 4,
|
||||
side: 16,
|
||||
};
|
||||
let file = dimension.join(FOLDER).join(key.file());
|
||||
let full = fs::metadata(&file).unwrap().len();
|
||||
let bake = LodBake::open(&dimension, shape, 100, 10).unwrap();
|
||||
bake.put_grid(false, [-128, 32], 4, 16, &other);
|
||||
drop(bake);
|
||||
let f = fs::OpenOptions::new().write(true).open(&file).unwrap();
|
||||
f.set_len(fs::metadata(&file).unwrap().len() - 5).unwrap();
|
||||
drop(f);
|
||||
let bake = LodBake::open(&dimension, shape, 100, 10).unwrap();
|
||||
assert_eq!(bake.grid(false, [-128, 32], 4, 16), None);
|
||||
assert_eq!(
|
||||
bake.grid(false, [-64, 32], 4, 16).as_deref(),
|
||||
Some(&grid[..])
|
||||
);
|
||||
assert_eq!(fs::metadata(&file).unwrap().len(), full);
|
||||
drop(bake);
|
||||
// Another registry (or data pack, build or level shape) empties the folder.
|
||||
let bake = LodBake::open(&dimension, shape, 101, 10).unwrap();
|
||||
assert_eq!(bake.grid(false, [-64, 32], 4, 16), None);
|
||||
drop(bake);
|
||||
fs::remove_dir_all(&world).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn empty_samples_are_not_baked() {
|
||||
let world = std::env::temp_dir().join(format!("horizon-lod-empty-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&world);
|
||||
let dimension = world.join("dimensions").join("minecraft").join("overworld");
|
||||
fs::create_dir_all(&dimension).unwrap();
|
||||
let bake = LodBake::open(&dimension, LevelShape::OVERWORLD, 1, 1).unwrap();
|
||||
bake.put_grid(false, [0, 0], 16, 16, &vec![Column::default(); 256]);
|
||||
assert_eq!(bake.grid(false, [0, 0], 16, 16), None);
|
||||
drop(bake);
|
||||
fs::remove_dir_all(&world).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ mod leaf_culling;
|
||||
mod lighting;
|
||||
pub mod packed;
|
||||
mod sides;
|
||||
#[cfg(test)]
|
||||
#[path = "mesher/source_tests.rs"]
|
||||
mod source_tests;
|
||||
use crate::connected::{Pick, TileLayer};
|
||||
use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
@@ -340,13 +343,41 @@ mod section_mesh_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// A shader terrain quad's unmodified source state and owning section cell. Two words
|
||||
/// per quad, kept beside (not in) the native 24-byte vertex format. Word 1 has cell XYZ
|
||||
/// in three nibbles, a fluid flag, a valid flag, then the emitted light (0..15).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct TerrainSource {
|
||||
state: u32,
|
||||
cell: u32,
|
||||
}
|
||||
impl TerrainSource {
|
||||
pub(crate) fn new(state: u32, pos: [i32; 3], emission: u8, fluid: bool) -> Self {
|
||||
debug_assert!(pos.iter().all(|&v| (0..16).contains(&v)));
|
||||
Self {
|
||||
state,
|
||||
cell: pos[0] as u32
|
||||
| (pos[1] as u32) << 4
|
||||
| (pos[2] as u32) << 8
|
||||
| u32::from(fluid) << 12
|
||||
| 1 << 13
|
||||
| u32::from(emission.min(15)) << 14,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SectionMesh {
|
||||
pub solid: Vec<TerrainVertex>,
|
||||
/// One source per quad when a shader pack meshes terrain; independent of its sprite
|
||||
/// and geometry. Empty on native/default meshes and on distant terrain.
|
||||
pub solid_sources: Vec<TerrainSource>,
|
||||
/// The buckets `solid` is sorted in by the side its quads face: empty
|
||||
/// until `sort_solid`, which the meshing workers call; `solid` is in
|
||||
/// model order before.
|
||||
pub sides: Sides,
|
||||
pub translucent: Vec<TerrainVertex>,
|
||||
pub translucent_sources: Vec<TerrainSource>,
|
||||
/// Whether `packed` holds far tile records (`lod::packed`) rather than
|
||||
/// section records (`mesher::packed`): the two are read apart, by the base
|
||||
/// vertex their draws use.
|
||||
@@ -380,8 +411,10 @@ impl Default for SectionMesh {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
solid: Vec::new(),
|
||||
solid_sources: Vec::new(),
|
||||
sides: Sides::default(),
|
||||
translucent: Vec::new(),
|
||||
translucent_sources: Vec::new(),
|
||||
packed: Vec::new(),
|
||||
packed_far: false,
|
||||
solid_tints: Vec::new(),
|
||||
@@ -416,7 +449,9 @@ impl SectionMesh {
|
||||
/// Retain geometry capacity after an upload, but none of its old contents.
|
||||
pub fn clear(&mut self) {
|
||||
self.solid.clear();
|
||||
self.solid_sources.clear();
|
||||
self.translucent.clear();
|
||||
self.translucent_sources.clear();
|
||||
self.packed.clear();
|
||||
self.packed_far = false;
|
||||
self.solid_tints.clear();
|
||||
@@ -431,6 +466,8 @@ impl SectionMesh {
|
||||
pub(crate) fn capacity_bytes(&self) -> usize {
|
||||
self.solid.capacity() * std::mem::size_of::<TerrainVertex>()
|
||||
+ self.translucent.capacity() * std::mem::size_of::<TerrainVertex>()
|
||||
+ (self.solid_sources.capacity() + self.translucent_sources.capacity())
|
||||
* std::mem::size_of::<TerrainSource>()
|
||||
+ self.packed.capacity() * std::mem::size_of::<[u32; 6]>()
|
||||
+ self.solid_tints.capacity() * std::mem::size_of::<u32>()
|
||||
+ self.block_entities.capacity() * std::mem::size_of::<([i32; 3], u32)>()
|
||||
@@ -445,7 +482,11 @@ impl SectionMesh {
|
||||
if !self.solid_tints.is_empty() {
|
||||
self.solid_tints.resize(quads, packed::UNKNOWN_TINT);
|
||||
}
|
||||
self.sides = sides::sort_tinted(&mut self.solid, &mut self.solid_tints);
|
||||
self.sides = sides::sort_sourced(
|
||||
&mut self.solid,
|
||||
&mut self.solid_tints,
|
||||
&mut self.solid_sources,
|
||||
);
|
||||
self.bounds = self.compute_bounds();
|
||||
self.packed.clear();
|
||||
self.packed_far = false;
|
||||
@@ -455,6 +496,20 @@ impl SectionMesh {
|
||||
self.solid_tints.clear();
|
||||
}
|
||||
|
||||
/// Every face emitted since `start` belongs to this block, including multipart,
|
||||
/// connected-texture pieces, overlays and duplicated/back-facing fluid faces.
|
||||
fn record_source(&mut self, start: [usize; 2], source: TerrainSource) {
|
||||
for (vertices, sources, start) in [
|
||||
(&self.solid, &mut self.solid_sources, start[0]),
|
||||
(&self.translucent, &mut self.translucent_sources, start[1]),
|
||||
] {
|
||||
if vertices.len() > start {
|
||||
sources.resize(start / 4, TerrainSource::default());
|
||||
sources.resize(vertices.len() / 4, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the tight axis-aligned bounding box [min, max] of actual terrain geometry vertices.
|
||||
pub fn compute_bounds(&self) -> Option<[glam::Vec3; 2]> {
|
||||
if self.solid.is_empty() && self.translucent.is_empty() {
|
||||
@@ -644,6 +699,8 @@ struct Info {
|
||||
block_entity: bool,
|
||||
tints: [Tint; 2],
|
||||
tint_count: u8,
|
||||
/// A resource pack's Polytone colormap over its tints (an index in `MeshTables::colormaps`).
|
||||
colormap: Option<u16>,
|
||||
/// `OffsetType` as (max horizontal, max vertical): vertical 0 for XZ.
|
||||
offset: Option<(f32, f32)>,
|
||||
/// `isSolid()` (legacy), for fluid corner heights.
|
||||
@@ -700,6 +757,9 @@ pub struct MeshTables {
|
||||
lava_still: u16,
|
||||
lava_flow: u16,
|
||||
animated_layers: Vec<bool>,
|
||||
/// Resource packs' Polytone block and fluid colormaps (#11486), and the water's.
|
||||
colormaps: Vec<Arc<crate::environment::Colormap>>,
|
||||
water_colormap: Option<u16>,
|
||||
}
|
||||
fn stem_color(age: u8) -> u32 {
|
||||
let a = u32::from(age);
|
||||
@@ -842,6 +902,7 @@ impl MeshTables {
|
||||
block_entity: s.block_entity,
|
||||
tints,
|
||||
tint_count,
|
||||
colormap: None,
|
||||
offset,
|
||||
legacy_solid: s.legacy_solid,
|
||||
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
|
||||
@@ -875,8 +936,52 @@ impl MeshTables {
|
||||
lava_still: layer("minecraft:block/lava_still")?,
|
||||
lava_flow: layer("minecraft:block/lava_flow")?,
|
||||
animated_layers,
|
||||
colormaps: Vec::new(),
|
||||
water_colormap: None,
|
||||
})
|
||||
}
|
||||
/// These tables with the colormaps of the Polytone block and fluid modifiers of the packs
|
||||
/// merged at `pack_root` (wiki "Colormaps"): a tinted block they target takes its colour
|
||||
/// from the colormap, and its own tint where the colormap's pixel is transparent.
|
||||
#[must_use]
|
||||
pub fn with_polytone_colormaps(
|
||||
mut self,
|
||||
pack_root: &std::path::Path,
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
) -> Self {
|
||||
let modifiers = crate::environment::polytone_colormap_modifiers(pack_root);
|
||||
if modifiers.is_empty() {
|
||||
return self;
|
||||
}
|
||||
let mut loaded: std::collections::HashMap<String, Option<u16>> = Default::default();
|
||||
let mut index = |id: &str, colormaps: &mut Vec<Arc<crate::environment::Colormap>>| {
|
||||
*loaded.entry(id.to_owned()).or_insert_with(|| {
|
||||
let colormap = crate::environment::Colormap::load(pack_root, id)?;
|
||||
colormaps.push(Arc::new(colormap));
|
||||
Some((colormaps.len() - 1) as u16)
|
||||
})
|
||||
};
|
||||
for (fluid, targets, id) in &modifiers {
|
||||
if *fluid {
|
||||
if targets.matches("minecraft:water", |_| false) {
|
||||
self.water_colormap = index(id, &mut self.colormaps);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(i) = index(id, &mut self.colormaps) else {
|
||||
continue;
|
||||
};
|
||||
for (state, info) in self.info.iter_mut().enumerate() {
|
||||
let Some(block) = registry.state(BlockStateId(state as u32)) else {
|
||||
continue;
|
||||
};
|
||||
if info.tint_count > 0 && targets.matches(&block.name, |_| false) {
|
||||
info.colormap = Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
/// Whether a terrain sprite layer has an animated clip.
|
||||
pub fn is_animated(&self, layer: u16) -> bool {
|
||||
self.animated_layers
|
||||
@@ -971,7 +1076,7 @@ impl MeshTables {
|
||||
}
|
||||
let [x, y, z] = pos;
|
||||
let origin = [x >> 4, z >> 4, y & !15];
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin).with_colormaps(self);
|
||||
Mesher::tint(info, 0, &mut tints, [x & 15, y & 15, z & 15])
|
||||
}
|
||||
/// `BlockColors` for each of the state's tint indices at `pos`, as the
|
||||
@@ -989,7 +1094,8 @@ impl MeshTables {
|
||||
let sources = self.info.get(state.0 as usize).map(|info| {
|
||||
let [x, y, z] = pos;
|
||||
let origin = [x >> 4, z >> 4, y & !15];
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
let mut tints =
|
||||
Tints::new(region, biomes, zoom, blend_radius, origin).with_colormaps(self);
|
||||
let local = [x & 15, y & 15, z & 15];
|
||||
let colors = [0, 1].map(|i| Mesher::tint(info, i, &mut tints, local));
|
||||
(colors, i32::from(info.tint_count))
|
||||
@@ -1382,6 +1488,11 @@ struct Tints<'a> {
|
||||
layers: [Option<Box<Layer>>; 16],
|
||||
/// `uniform_biome`, once computed.
|
||||
uniform: Option<Option<u16>>,
|
||||
/// The packs' colormaps (`MeshTables::colormaps`), the water's, and their colours so far
|
||||
/// by colormap and section-local block.
|
||||
colormaps: &'a [Arc<crate::environment::Colormap>],
|
||||
water_colormap: Option<u16>,
|
||||
sampled: std::collections::HashMap<(u16, [i32; 3]), Option<u32>>,
|
||||
}
|
||||
impl<'a> Tints<'a> {
|
||||
fn new(
|
||||
@@ -1400,8 +1511,41 @@ impl<'a> Tints<'a> {
|
||||
origin,
|
||||
layers: Default::default(),
|
||||
uniform: None,
|
||||
colormaps: &[],
|
||||
water_colormap: None,
|
||||
sampled: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
/// With the packs' colormaps of `tables`.
|
||||
fn with_colormaps(mut self, tables: &'a MeshTables) -> Self {
|
||||
self.colormaps = &tables.colormaps;
|
||||
self.water_colormap = tables.water_colormap;
|
||||
self
|
||||
}
|
||||
/// Colormap `index`'s colour at block `(x, y, z)` (section-local), `None` where its pixel
|
||||
/// is transparent.
|
||||
fn colormap(&mut self, index: u16, x: i32, y: i32, z: i32) -> Option<u32> {
|
||||
if let Some(&known) = self.sampled.get(&(index, [x, y, z])) {
|
||||
return known;
|
||||
}
|
||||
let colormap = self.colormaps.get(usize::from(index))?;
|
||||
let id = Self::biome_id(&self.zoom, self.region, self.plains, self.origin, [x, y, z]);
|
||||
let [cx, cz, base_y] = self.origin;
|
||||
let at = [cx * 16 + x, base_y + y, cz * 16 + z];
|
||||
let mut host = crate::environment::BlockHost::new(at, self.biomes.get(id));
|
||||
let colour = colormap.sample(&mut host).map(|c| c & 0xFF_FFFF);
|
||||
self.sampled.insert((index, [x, y, z]), colour);
|
||||
colour
|
||||
}
|
||||
/// The water's tint at block `(x, y, z)`: the packs' water colormap, else the biome's.
|
||||
fn water(&mut self, x: i32, y: i32, z: i32) -> u32 {
|
||||
if let Some(index) = self.water_colormap
|
||||
&& let Some(colour) = self.colormap(index, x, y, z)
|
||||
{
|
||||
return colour;
|
||||
}
|
||||
self.get(Resolver::Water, x, y, z)
|
||||
}
|
||||
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
|
||||
/// picks it.
|
||||
fn biome(&self, x: i32, y: i32, z: i32) -> &'a str {
|
||||
@@ -1645,7 +1789,8 @@ impl Mesher<'_> {
|
||||
self.zoom,
|
||||
self.blend_radius,
|
||||
[center.x, center.z, base_y],
|
||||
);
|
||||
)
|
||||
.with_colormaps(self.tables);
|
||||
let info = &self.tables.info;
|
||||
let full_faces = &self.tables.full_faces;
|
||||
let catalog = &self.render.model_catalog;
|
||||
@@ -1674,7 +1819,8 @@ impl Mesher<'_> {
|
||||
continue;
|
||||
}
|
||||
if state.fluid.kind() != FluidKind::Empty {
|
||||
let water = tints.get(Resolver::Water, x, y, z);
|
||||
let start = [out.solid.len(), out.translucent.len()];
|
||||
let water = tints.water(x, y, z);
|
||||
let shade = (
|
||||
self.nether_shading,
|
||||
self.shader_pack_lighting,
|
||||
@@ -1682,6 +1828,12 @@ impl Mesher<'_> {
|
||||
self.shader_pack,
|
||||
);
|
||||
fluid::tesselate(self.tables, shade, pad, water, &mut out, [x, y, z]);
|
||||
if self.shader_pack {
|
||||
out.record_source(
|
||||
start,
|
||||
TerrainSource::new(id, [x, y, z], state.emission, true),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !state.model {
|
||||
continue;
|
||||
@@ -1754,6 +1906,7 @@ impl Mesher<'_> {
|
||||
.unwrap_or(true);
|
||||
}
|
||||
let connected = &self.render.connected;
|
||||
let start = [out.solid.len(), out.translucent.len()];
|
||||
for model in &models {
|
||||
for face in &model.faces {
|
||||
if face.quad.cull.is_some_and(|d| !visible[d as usize]) {
|
||||
@@ -1797,6 +1950,12 @@ impl Mesher<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
if self.shader_pack {
|
||||
out.record_source(
|
||||
start,
|
||||
TerrainSource::new(id, [x, y, z], state.emission, false),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1808,13 +1967,18 @@ impl Mesher<'_> {
|
||||
if index < 0 || index >= i32::from(state.tint_count) {
|
||||
return None;
|
||||
}
|
||||
if let Some(colormap) = state.colormap
|
||||
&& let Some(colour) = tints.colormap(colormap, x, y, z)
|
||||
{
|
||||
return Some(colour);
|
||||
}
|
||||
Some(match state.tints[(index as usize).min(1)] {
|
||||
Tint::None => return None,
|
||||
Tint::Grass | Tint::GrassBlock | Tint::SugarCane => tints.get(Resolver::Grass, x, y, z),
|
||||
Tint::TallGrassUpper => tints.get(Resolver::Grass, x, y, z),
|
||||
Tint::Foliage => tints.get(Resolver::Foliage, x, y, z),
|
||||
Tint::DryFoliage => tints.get(Resolver::Dry, x, y, z),
|
||||
Tint::Water => tints.get(Resolver::Water, x, y, z),
|
||||
Tint::Water => tints.water(x, y, z),
|
||||
Tint::Constant(c) => c,
|
||||
Tint::LilyPad => 0x20_8030,
|
||||
Tint::Redstone(p) => redstone_color(p),
|
||||
|
||||
@@ -335,6 +335,7 @@ mod tests {
|
||||
block_entity: false,
|
||||
tints: [super::super::Tint::None; 2],
|
||||
tint_count: 0,
|
||||
colormap: None,
|
||||
offset: None,
|
||||
legacy_solid: false,
|
||||
overlay: leaves,
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
//! cutout ones, so one pass over all of them draws the runs it drew before.
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use super::{OPAQUE_LAYER, TerrainVertex};
|
||||
use super::{OPAQUE_LAYER, TerrainSource, TerrainVertex};
|
||||
|
||||
/// The buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y. Seen from
|
||||
/// above (+Y, the commonest case), each pair of horizontal sides with a
|
||||
@@ -247,15 +247,29 @@ fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
}
|
||||
|
||||
/// `sort`, moving each quad's tint in `tints` (one per quad, or none) with it.
|
||||
#[cfg(test)]
|
||||
pub fn sort_tinted(solid: &mut Vec<TerrainVertex>, tints: &mut Vec<u32>) -> Sides {
|
||||
sort_sourced(solid, tints, &mut Vec::new())
|
||||
}
|
||||
|
||||
pub(super) fn sort_sourced(
|
||||
solid: &mut Vec<TerrainVertex>,
|
||||
tints: &mut Vec<u32>,
|
||||
sources: &mut Vec<TerrainSource>,
|
||||
) -> Sides {
|
||||
if !sources.is_empty() {
|
||||
sources.resize(solid.len() / 4, TerrainSource::default());
|
||||
}
|
||||
let grouped = GROUPED.load(Ordering::Relaxed);
|
||||
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, tints, grouped, scratch))
|
||||
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, tints, sources, grouped, scratch))
|
||||
}
|
||||
|
||||
/// `sort`, with all the opaque parts first when `grouped`.
|
||||
#[cfg(test)]
|
||||
fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
|
||||
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, &mut Vec::new(), grouped, scratch))
|
||||
SORT_SCRATCH.with_borrow_mut(|scratch| {
|
||||
sort_with(solid, &mut Vec::new(), &mut Vec::new(), grouped, scratch)
|
||||
})
|
||||
}
|
||||
|
||||
/// `sort`'s buffers, kept by each meshing thread so sorting a mesh allocates
|
||||
@@ -267,6 +281,7 @@ struct SortScratch {
|
||||
keys: Vec<u64>,
|
||||
sorted: Vec<[TerrainVertex; 4]>,
|
||||
tints: Vec<u32>,
|
||||
sources: Vec<TerrainSource>,
|
||||
}
|
||||
thread_local! {
|
||||
static SORT_SCRATCH: std::cell::RefCell<SortScratch> = Default::default();
|
||||
@@ -281,6 +296,7 @@ fn total_order_key(v: f32) -> u32 {
|
||||
fn sort_with(
|
||||
solid: &mut Vec<TerrainVertex>,
|
||||
tints: &mut Vec<u32>,
|
||||
sources: &mut Vec<TerrainSource>,
|
||||
grouped: bool,
|
||||
scratch: &mut SortScratch,
|
||||
) -> Sides {
|
||||
@@ -354,6 +370,13 @@ fn sort_with(
|
||||
sorted_tints.extend(keys.iter().map(|&k| tints[(k & 0xFF_FFFF) as usize]));
|
||||
std::mem::swap(tints, sorted_tints);
|
||||
}
|
||||
if sources.len() == quads_len {
|
||||
scratch.sources.clear();
|
||||
scratch
|
||||
.sources
|
||||
.extend(keys.iter().map(|&k| sources[(k & 0xFF_FFFF) as usize]));
|
||||
std::mem::swap(sources, &mut scratch.sources);
|
||||
}
|
||||
}
|
||||
Sides {
|
||||
ends: slot_ends,
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Original synthetic block/model/world controls; no prepared game resources.
|
||||
use super::*;
|
||||
use crate::assets::{Properties, SpritePixels};
|
||||
use horizon_world::registry::BlockRegistry;
|
||||
use serde_json::json;
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture() -> (Scratch, Assets, RenderAssets, MeshTables, Biomes) {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"terrain-source-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
let write = |name: &str, value: serde_json::Value| {
|
||||
let file = path.join(name);
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
std::fs::write(file, value.to_string()).unwrap();
|
||||
};
|
||||
let registry = json!({"defaults":{"minecraft:air":0,"fixture:signal":1},"states":[
|
||||
["air",true,false,"", "minecraft:air"],
|
||||
["signal",false,false,"",{"id":"fixture:signal","properties":{"active":"false","waterlogged":"false"}}],
|
||||
["signal",false,true,"",{"id":"fixture:signal","properties":{"active":"true","waterlogged":"true"}}]
|
||||
]});
|
||||
let registry = Box::leak(Box::new(
|
||||
BlockRegistry::from_facts_json(registry.to_string().as_bytes()).unwrap(),
|
||||
));
|
||||
write(
|
||||
"assets/fixture/blockstates/signal.json",
|
||||
json!({"multipart":[
|
||||
{"apply":{"model":"fixture:block/low"}}, {"apply":{"model":"fixture:block/extended"}}
|
||||
]}),
|
||||
);
|
||||
for (name, from, to) in [
|
||||
("low", [0, 4, 0], [8, 4, 8]),
|
||||
("extended", [20, 12, 0], [28, 12, 8]),
|
||||
] {
|
||||
write(
|
||||
&format!("assets/fixture/models/block/{name}.json"),
|
||||
json!({
|
||||
"textures":{"all":"fixture:block/shared"},
|
||||
"elements":[{"from":from,"to":to,"faces":{"up":{"texture":"#all"}}}]
|
||||
}),
|
||||
);
|
||||
}
|
||||
// The same authored sprite is used by every part of both states.
|
||||
let png_path = path.join("assets/fixture/textures/block/shared.png");
|
||||
std::fs::create_dir_all(png_path.parent().unwrap()).unwrap();
|
||||
let encode = |file: PathBuf| {
|
||||
let mut encoder = png::Encoder::new(std::fs::File::create(file).unwrap(), 1, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&[63, 127, 191, 255])
|
||||
.unwrap();
|
||||
};
|
||||
encode(png_path);
|
||||
let facts = BTreeMap::from([(
|
||||
"fixture:signal".into(),
|
||||
json!({
|
||||
"schema":{"active":{"named":["false","true"]},"waterlogged":{"named":["false","true"]}},
|
||||
"states":[{"active":"false","waterlogged":"false"},{"active":"true","waterlogged":"true"}]
|
||||
}),
|
||||
)]);
|
||||
let mut sprites = crate::sprites::Sprites::default();
|
||||
let catalog = crate::models::StateModels::from_facts(
|
||||
&path,
|
||||
registry,
|
||||
&["fixture:signal"],
|
||||
&mut sprites,
|
||||
&facts,
|
||||
)
|
||||
.unwrap();
|
||||
let render = RenderAssets {
|
||||
sprite_size: 1,
|
||||
sprite_minimum_size: 1,
|
||||
pixels: SpritePixels::new(vec![255; 4]),
|
||||
texture_layers: 1,
|
||||
model_catalog: catalog,
|
||||
model_library: Err("unused synthetic item models".into()),
|
||||
model_placements: vec![],
|
||||
model_gallery: vec![],
|
||||
animations: vec![],
|
||||
sprite_layers: BTreeMap::new(),
|
||||
emissive: vec![],
|
||||
translucent_layers: vec![false],
|
||||
sprite_mips: vec![],
|
||||
pbr_normals: vec![],
|
||||
pbr_specular: vec![],
|
||||
pbr_size: 1,
|
||||
connected: Default::default(),
|
||||
fusion: Default::default(),
|
||||
generation: 1,
|
||||
};
|
||||
let assets = Assets {
|
||||
registry,
|
||||
properties: Properties::for_tests(&[
|
||||
(true, false, false, false),
|
||||
(false, false, false, false),
|
||||
(false, true, false, false),
|
||||
]),
|
||||
material_states: [BlockStateId(0); 6],
|
||||
blocks_fluid_flow: Default::default(),
|
||||
};
|
||||
let air = Info {
|
||||
solid: false,
|
||||
full: false,
|
||||
permeable: true,
|
||||
shade: 1.0,
|
||||
emission: 0,
|
||||
tier: 0,
|
||||
emissive: false,
|
||||
fluid: FluidState::EMPTY,
|
||||
model: false,
|
||||
block_entity: false,
|
||||
tints: [Tint::None; 2],
|
||||
tint_count: 0,
|
||||
colormap: None,
|
||||
offset: None,
|
||||
legacy_solid: false,
|
||||
overlay: false,
|
||||
leaves: false,
|
||||
pane: false,
|
||||
opaque_cube: false,
|
||||
blocks_flow: false,
|
||||
ice: false,
|
||||
opaque_ice: false,
|
||||
sturdy: 0,
|
||||
face: [0; 6],
|
||||
cover: [0.0; 6],
|
||||
};
|
||||
let info = vec![
|
||||
air,
|
||||
Info { model: true, ..air },
|
||||
Info {
|
||||
model: true,
|
||||
emission: 13,
|
||||
fluid: FluidState::WATER,
|
||||
..air
|
||||
},
|
||||
];
|
||||
let tables = MeshTables {
|
||||
info,
|
||||
solid: vec![false; 3],
|
||||
full_faces: vec![0; 3],
|
||||
visibility: FaceVisibility::empty(),
|
||||
water_still: 0,
|
||||
water_flow: 0,
|
||||
water_overlay: 0,
|
||||
lava_still: 0,
|
||||
lava_flow: 0,
|
||||
animated_layers: vec![false],
|
||||
colormaps: Vec::new(),
|
||||
water_colormap: None,
|
||||
};
|
||||
let maps = path.join("assets/minecraft/textures/colormap");
|
||||
std::fs::create_dir_all(&maps).unwrap();
|
||||
for name in ["grass", "foliage", "dry_foliage"] {
|
||||
encode(maps.join(format!("{name}.png")));
|
||||
}
|
||||
write(
|
||||
"data/minecraft/worldgen/biome/plains.json",
|
||||
json!({"temperature":0.5,"downfall":0.5,"effects":{"water_color":4159204}}),
|
||||
);
|
||||
let biomes = Biomes::load(&path).unwrap();
|
||||
(Scratch(path), assets, render, tables, biomes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terrain_source_cpu_multipart_and_waterlogged_faces_keep_their_owner() {
|
||||
let (_scratch, assets, render, tables, biomes) = fixture();
|
||||
let mut chunk = Chunk::with_layout(3, -2, crate::chunk::ColumnLayout::new(0, 16).unwrap());
|
||||
chunk.set(2, 3, 4, 1);
|
||||
chunk.set(6, 3, 4, 2);
|
||||
let region = [
|
||||
[None, None, None],
|
||||
[None, Some(&chunk), None],
|
||||
[None, None, None],
|
||||
];
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
separate_ao: false,
|
||||
shader_pack: true,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
biomes: &biomes,
|
||||
visibility: FaceVisibility::empty(),
|
||||
zoom: BiomeZoom::new(0),
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves: true,
|
||||
opaque_ice: false,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
let mut mesh = mesher.mesh(®ion, 0);
|
||||
assert_eq!(mesh.solid.len(), 16, "two parts per source state");
|
||||
assert!(
|
||||
!mesh.translucent.is_empty(),
|
||||
"waterlogged fluid geometry was emitted"
|
||||
);
|
||||
assert_eq!(
|
||||
mesh.solid_sources,
|
||||
[
|
||||
TerrainSource::new(1, [2, 3, 4], 0, false),
|
||||
TerrainSource::new(1, [2, 3, 4], 0, false),
|
||||
TerrainSource::new(2, [6, 3, 4], 13, false),
|
||||
TerrainSource::new(2, [6, 3, 4], 13, false)
|
||||
]
|
||||
);
|
||||
assert_eq!(mesh.translucent_sources.len(), mesh.translucent.len() / 4);
|
||||
assert!(
|
||||
mesh.translucent_sources
|
||||
.iter()
|
||||
.all(|s| *s == TerrainSource::new(2, [6, 3, 4], 13, true))
|
||||
);
|
||||
let before: Vec<_> = mesh
|
||||
.solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.zip(&mesh.solid_sources)
|
||||
.map(|(q, s)| (bytemuck::cast_slice::<_, u8>(q).to_vec(), *s))
|
||||
.collect();
|
||||
mesh.sort_solid();
|
||||
for (q, s) in mesh
|
||||
.solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.zip(&mesh.solid_sources)
|
||||
{
|
||||
assert!(
|
||||
before.contains(&(bytemuck::cast_slice::<_, u8>(q).to_vec(), *s)),
|
||||
"sort changed face ownership"
|
||||
);
|
||||
}
|
||||
// Reusing the same mesh for native/default rendering clears metadata and keeps
|
||||
// native model vertices byte-for-byte (fluid splitting already differs with packs).
|
||||
mesher.shader_pack = false;
|
||||
let native = mesher.mesh_reusing(®ion, 0, mesh, &mut MeshScratch::default());
|
||||
assert!(native.solid_sources.is_empty() && native.translucent_sources.is_empty());
|
||||
for q in native.solid.as_chunks::<4>().0.iter() {
|
||||
assert!(
|
||||
before
|
||||
.iter()
|
||||
.any(|(bytes, _)| bytes == bytemuck::cast_slice::<_, u8>(q)),
|
||||
"source tracking altered native vertices"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terrain_source_cpu_partial_metadata_never_inherits_a_previous_face() {
|
||||
let mut mesh = SectionMesh::default();
|
||||
let quad = |y| {
|
||||
[[0.0, y, 0.0], [0.0, y, 1.0], [1.0, y, 1.0], [1.0, y, 0.0]]
|
||||
.map(|pos| TerrainVertex::new(pos, [0.0; 2], 0, 0, 0))
|
||||
};
|
||||
mesh.solid.extend(quad(1.0));
|
||||
let start = [mesh.solid.len(), 0];
|
||||
mesh.solid.extend(quad(3.0));
|
||||
mesh.solid.extend(quad(2.0));
|
||||
mesh.record_source(start, TerrainSource::new(71, [15, 0, 8], 7, false));
|
||||
assert_eq!(mesh.solid_sources[0], TerrainSource::default());
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u32>(&mesh.solid_sources[1..]),
|
||||
[71, 124943, 71, 124943]
|
||||
);
|
||||
mesh.sort_solid();
|
||||
assert_eq!(
|
||||
mesh.solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| q[0].pos[1])
|
||||
.collect::<Vec<_>>(),
|
||||
[3.0, 2.0, 1.0]
|
||||
);
|
||||
assert_eq!(
|
||||
mesh.solid_sources,
|
||||
[
|
||||
TerrainSource::new(71, [15, 0, 8], 7, false),
|
||||
TerrainSource::new(71, [15, 0, 8], 7, false),
|
||||
TerrainSource::default()
|
||||
]
|
||||
);
|
||||
mesh.clear();
|
||||
assert!(mesh.solid_sources.is_empty());
|
||||
}
|
||||
@@ -112,3 +112,72 @@ fn one_biome_shortcut_blends_like_the_full_square() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pack root holding `files` (path, bytes), for colormap tests.
|
||||
fn pack(files: &[(&str, Vec<u8>)]) -> std::path::PathBuf {
|
||||
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-mesher-colormap-{}-{n}",
|
||||
std::process::id()
|
||||
));
|
||||
for (path, bytes) in files {
|
||||
let path = root.join(path);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
/// A one-row PNG of RGBA pixels.
|
||||
fn png_row(pixels: &[[u8; 4]]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut out, pixels.len() as u32, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer.write_image_data(&pixels.concat()).unwrap();
|
||||
writer.finish().unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_polytone_colormap_tints_by_biome_and_keeps_the_biome_colour_where_transparent() {
|
||||
let biomes = Biomes::from_jsons(&[
|
||||
(
|
||||
"minecraft:plains",
|
||||
serde_json::json!({"effects": {"water_color": 0x3F76E4}}),
|
||||
),
|
||||
(
|
||||
"minecraft:desert",
|
||||
serde_json::json!({"effects": {"water_color": 0x32A598}}),
|
||||
),
|
||||
]);
|
||||
// Left to right: transparent, red. Desert maps to column 1, plains (unnamed) to column 0.
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/water.png",
|
||||
png_row(&[[0, 0, 0, 0], [255, 0, 0, 255]]),
|
||||
),
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/water.json",
|
||||
br#"{"x_axis": "biome_id", "y_axis": 0, "biome_id_mapper": {"minecraft:desert": 1, "texture_size": 2}}"#
|
||||
.to_vec(),
|
||||
),
|
||||
]);
|
||||
let colormaps = vec![Arc::new(
|
||||
crate::environment::Colormap::load(&root, "mypack:water").unwrap(),
|
||||
)];
|
||||
for (biome, expected) in [(1, 0xFF_0000), (0, 0x3F_76E4)] {
|
||||
let chunks: Vec<Vec<Chunk>> = (0..3)
|
||||
.map(|rz| (0..3).map(|rx| filled(rx - 1, rz - 1, biome)).collect())
|
||||
.collect();
|
||||
let region: [[Option<&Chunk>; 3]; 3] =
|
||||
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz][rx])));
|
||||
let mut tints = Tints::new(®ion, &biomes, BiomeZoom::new(0), 0, [0, 0, 64]);
|
||||
tints.colormaps = &colormaps;
|
||||
tints.water_colormap = Some(0);
|
||||
assert_eq!(tints.water(3, 4, 5), expected, "biome {biome}");
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
@@ -84,6 +84,7 @@ fn info(solid: bool) -> Info {
|
||||
block_entity: false,
|
||||
tints: [Tint::None; 2],
|
||||
tint_count: 0,
|
||||
colormap: None,
|
||||
offset: None,
|
||||
legacy_solid: false,
|
||||
overlay: false,
|
||||
|
||||
@@ -1612,11 +1612,9 @@ pub struct OptionsUi {
|
||||
selected_native_key: Option<(usize, usize)>,
|
||||
/// Bindings: the action waiting for a pad button.
|
||||
pub selected_pad_action: Option<&'static str>,
|
||||
/// The glyphs the binds list shows (the setting, else the pad's family).
|
||||
pub pad_style: GlyphStyle,
|
||||
/// The pad goes by its letters (`PadSettings::nintendo_letters`) and the glyphs are
|
||||
/// Nintendo's: each part shows the letter of the button playing it.
|
||||
pub pad_by_letter: bool,
|
||||
/// The glyphs menus show (`Glyphs::of`: the setting, else the pad's family, and a Nintendo
|
||||
/// pad's letters when it goes by them). The game sets it each frame.
|
||||
pub pad_glyphs: crate::pad_glyph::Glyphs,
|
||||
/// The pad's focus drives the page: its hidden pointer sits mid-widget, so a tooltip goes
|
||||
/// under the widget instead of beside the pointer.
|
||||
pub pad_driving: bool,
|
||||
@@ -1660,7 +1658,8 @@ pub struct OptionsUi {
|
||||
/// Scroll the selected language into the middle on the next layout.
|
||||
center_language: bool,
|
||||
graphics: shell::Shell<crate::graphics_ui::GraphicsScreen>,
|
||||
pub controller: crate::controller_ui::ControllerUi,
|
||||
/// Controller Settings, on the settings shell.
|
||||
controller: shell::Shell<crate::controller_ui::ControllerScreen>,
|
||||
/// Horizon Extras, on the settings shell.
|
||||
extras: shell::Shell<crate::extras_ui::ExtrasScreen>,
|
||||
pub shader: crate::shader_ui::ShaderUi,
|
||||
@@ -1715,8 +1714,7 @@ impl Default for OptionsUi {
|
||||
selected_key: None,
|
||||
selected_native_key: None,
|
||||
selected_pad_action: None,
|
||||
pad_style: GlyphStyle::Xbox,
|
||||
pad_by_letter: false,
|
||||
pad_glyphs: crate::pad_glyph::Glyphs::default(),
|
||||
pad_driving: false,
|
||||
languages: Vec::new(),
|
||||
language_choice: None,
|
||||
@@ -2100,7 +2098,6 @@ impl OptionsUi {
|
||||
.pack_screen
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.search_focused()),
|
||||
Page::Controller | Page::PadBinds => self.controller.typing(),
|
||||
_ if Self::shader_page(self.page()).is_some() => self.shader.typing(),
|
||||
_ => match self.shell() {
|
||||
Some(shell) => shell.typing(),
|
||||
@@ -2212,9 +2209,6 @@ impl OptionsUi {
|
||||
if page == Page::Packs {
|
||||
self.pack_screen = None;
|
||||
}
|
||||
if let Some(at) = Self::controller_page(page) {
|
||||
self.controller.open(at);
|
||||
}
|
||||
if let Some(at) = Self::shader_page(page) {
|
||||
self.shader.open(at);
|
||||
}
|
||||
@@ -2224,6 +2218,9 @@ impl OptionsUi {
|
||||
if let Some(at) = Self::graphics_page(page) {
|
||||
self.graphics.show(at);
|
||||
}
|
||||
if let Some(at) = Self::controller_page(page) {
|
||||
self.controller.show(at);
|
||||
}
|
||||
if page == Page::SplitSetup {
|
||||
self.split_setup.reset();
|
||||
}
|
||||
@@ -2241,6 +2238,7 @@ impl OptionsUi {
|
||||
match self.page() {
|
||||
Page::Extras => Some(&self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
|
||||
page if Self::controller_page(page).is_some() => Some(&self.controller),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -2248,6 +2246,7 @@ impl OptionsUi {
|
||||
match self.page() {
|
||||
Page::Extras => Some(&mut self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
|
||||
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -2256,10 +2255,20 @@ impl OptionsUi {
|
||||
pub fn reveal(&mut self, s: &Settings, size: (f32, f32), rect: [f32; 4]) -> Option<f32> {
|
||||
self.shell_mut()?.reveal(s, size, rect)
|
||||
}
|
||||
/// The window's size, for the graphics screen's vanilla rows that step through its
|
||||
/// resolutions: before each input that can press one.
|
||||
fn graphics_framebuffer(&mut self) {
|
||||
/// The window's size, for the graphics and controller screens' rows that step an option
|
||||
/// (`Opt::cycle`, which a fullscreen resolution needs): before each input that can press one.
|
||||
fn shell_framebuffers(&mut self) {
|
||||
self.graphics.screen.framebuffer = self.framebuffer;
|
||||
self.controller.screen.framebuffer = self.framebuffer;
|
||||
}
|
||||
/// Who and what Controller Settings is about: the game sets it before each draw.
|
||||
pub fn set_controller_context(&mut self, context: crate::controller_ui::Context) {
|
||||
self.controller.screen.context = context;
|
||||
}
|
||||
/// The binding the screen waits on: a controller button's, else a key's.
|
||||
fn waiting(&self) -> Option<shell::Capture> {
|
||||
let pad = self.selected_pad_action.map(shell::Capture::Pad);
|
||||
pad.or(self.selected_key.map(shell::Capture::Key))
|
||||
}
|
||||
/// What a shell screen's outputs do to these options.
|
||||
fn shell_events(&mut self, s: &mut Settings, outputs: Vec<shell::Output>) -> Vec<Event> {
|
||||
@@ -2267,7 +2276,10 @@ impl OptionsUi {
|
||||
for output in outputs {
|
||||
match output {
|
||||
shell::Output::Event(event) => events.push(event),
|
||||
shell::Output::BindKey(name) => self.selected_key = Some(name),
|
||||
shell::Output::Bind(shell::Capture::Key(name)) => self.selected_key = Some(name),
|
||||
shell::Output::Bind(shell::Capture::Pad(action)) => {
|
||||
self.selected_pad_action = Some(action)
|
||||
}
|
||||
shell::Output::Push(page) => {
|
||||
if page == Page::HdrCalibration {
|
||||
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
|
||||
@@ -2279,25 +2291,6 @@ impl OptionsUi {
|
||||
}
|
||||
events
|
||||
}
|
||||
/// What the controller screen's actions do to these options.
|
||||
fn controller_events(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
actions: Vec<crate::controller_ui::Action>,
|
||||
) -> Vec<Event> {
|
||||
use crate::controller_ui::Action;
|
||||
let mut events = Vec::new();
|
||||
for action in actions {
|
||||
match action {
|
||||
Action::ButtonClick => events.push(Event::ButtonClick),
|
||||
Action::Changed => events.push(Event::Changed),
|
||||
Action::SliderDrag => events.push(Event::SliderDrag),
|
||||
Action::BindPad(action) => self.selected_pad_action = Some(action),
|
||||
Action::Done => events.extend(self.back(s)),
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
/// What the shader screen's actions do to these options.
|
||||
fn shader_events(
|
||||
&mut self,
|
||||
@@ -3687,16 +3680,11 @@ impl OptionsUi {
|
||||
c.draw(batch, atlas, cursor);
|
||||
return;
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
self.controller.binding = self.selected_pad_action;
|
||||
self.controller.draw(s, batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
self.shader.draw(s, batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
let waiting = self.selected_key;
|
||||
let waiting = self.waiting();
|
||||
let in_world = self.in_world;
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.draw(s, batch, atlas, cursor, in_world, waiting);
|
||||
@@ -3713,7 +3701,7 @@ impl OptionsUi {
|
||||
}
|
||||
if self.page() == Page::SplitProfiles {
|
||||
self.split_profiles
|
||||
.draw(batch, atlas, cursor, self.in_world, self.pad_style);
|
||||
.draw(batch, atlas, cursor, self.in_world, self.pad_glyphs);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::Studio {
|
||||
@@ -4050,6 +4038,10 @@ impl OptionsUi {
|
||||
return self.calibration_outcome(s, outcome);
|
||||
}
|
||||
if self.shell().is_some() {
|
||||
// Waiting on a controller button: a click cancels the wait.
|
||||
if !drag && self.selected_pad_action.take().is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Waiting on a key: a mouse button binds, as on Key Binds.
|
||||
if self.selected_key.is_some() && !drag {
|
||||
let button = if right {
|
||||
@@ -4061,22 +4053,12 @@ impl OptionsUi {
|
||||
}
|
||||
let shift = self.shift;
|
||||
self.framebuffer = framebuffer;
|
||||
self.graphics_framebuffer();
|
||||
self.shell_framebuffers();
|
||||
let outputs = self.shell_mut().map_or_else(Vec::new, |shell| {
|
||||
shell.click(s, (x, y), size, drag, right, shift)
|
||||
});
|
||||
return self.shell_events(s, outputs);
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
// Waiting on a button: a click cancels the wait.
|
||||
if !drag && self.selected_pad_action.take().is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
let actions =
|
||||
self.controller
|
||||
.click(s, (x, y), size, framebuffer, drag, right, self.shift);
|
||||
return self.controller_events(s, actions);
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
let actions = self.shader.click(s, (x, y), size, drag, right, self.shift);
|
||||
return self.shader_events(s, actions);
|
||||
@@ -4349,7 +4331,7 @@ impl OptionsUi {
|
||||
let slider = self.dragging.take().is_some();
|
||||
let (shader_slider, shader_pack) = self.shader.release();
|
||||
let shell_slider = self.shell_mut().is_some_and(|shell| shell.release());
|
||||
let graphics_slider = self.controller.release() | shader_slider | shell_slider;
|
||||
let graphics_slider = shader_slider | shell_slider;
|
||||
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
|
||||
let split_drop = self.split_setup.release();
|
||||
if slider || graphics_slider || calibration_slider {
|
||||
@@ -4429,7 +4411,7 @@ impl OptionsUi {
|
||||
if self.resource_error.is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.graphics_framebuffer();
|
||||
self.shell_framebuffers();
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
let outputs = shell.wheel(s, size, cursor, amount);
|
||||
return self.shell_events(s, outputs);
|
||||
@@ -4438,10 +4420,6 @@ impl OptionsUi {
|
||||
self.studio.wheel(amount);
|
||||
return Vec::new();
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
let actions = self.controller.wheel(s, size, cursor, framebuffer, amount);
|
||||
return self.controller_events(s, actions);
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
self.shader.scroll(s, size, amount);
|
||||
return Vec::new();
|
||||
@@ -4529,10 +4507,6 @@ impl OptionsUi {
|
||||
self.studio.wheel(amount);
|
||||
return;
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
self.controller.scroll(s, size, amount);
|
||||
return;
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
self.shader.scroll(s, size, amount);
|
||||
return;
|
||||
@@ -4721,8 +4695,6 @@ impl OptionsUi {
|
||||
if shell.typing() {
|
||||
shell.type_text(text);
|
||||
}
|
||||
} else if Self::controller_page(self.page()).is_some() && self.controller.typing() {
|
||||
self.controller.type_text(text);
|
||||
} else if Self::shader_page(self.page()).is_some() && self.shader.typing() {
|
||||
self.shader.type_text(text);
|
||||
} else if self.settings_search_active() {
|
||||
@@ -4759,9 +4731,6 @@ impl OptionsUi {
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
return shell.shortcut(shortcut, clipboard);
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
return self.controller.shortcut(shortcut, clipboard);
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
return self.shader.shortcut(shortcut, clipboard);
|
||||
}
|
||||
@@ -4881,21 +4850,11 @@ impl OptionsUi {
|
||||
} else {
|
||||
MIN_KEY_GUI
|
||||
};
|
||||
self.graphics_framebuffer();
|
||||
self.shell_framebuffers();
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
let outputs = shell.key(s, key, text, shift, gui);
|
||||
return self.shell_events(s, outputs);
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
|
||||
let fb = [self.framebuffer[0].max(480), self.framebuffer[1].max(240)];
|
||||
let (actions, _) = self.controller.key_modifiers(s, key, text, shift, size, fb);
|
||||
let mut events = self.controller_events(s, actions);
|
||||
if key == K::Escape {
|
||||
events.extend(self.back(s));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
|
||||
let (actions, handled) = self.shader.key_modifiers(s, key, text, shift, size);
|
||||
@@ -6644,6 +6603,42 @@ mod tests {
|
||||
assert_eq!(ui.page(), Page::Horizon);
|
||||
}
|
||||
|
||||
/// A Bindings row pressed waits for a controller button: the next click or key cancels the
|
||||
/// wait, Escape unbinds the action as on Key Binds, and none of them leaves the screen.
|
||||
#[test]
|
||||
fn a_controller_binding_waits_and_a_click_or_a_key_ends_the_wait() {
|
||||
use crate::controller_ui::Item;
|
||||
use winit::keyboard::KeyCode as K;
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::PadBinds);
|
||||
let size = (854.0, 480.0);
|
||||
ui.size = size;
|
||||
let jump = Item::Bind("key.jump");
|
||||
let (x, y) = ui.controller.point_at(&s, size, jump);
|
||||
let press = |ui: &mut OptionsUi, s: &mut Settings| {
|
||||
ui.click(s, &atlas, x, y, size, [1708, 960], false, false)
|
||||
};
|
||||
assert_eq!(press(&mut ui, &mut s), vec![Event::ButtonClick]);
|
||||
assert_eq!(ui.selected_pad_action, Some("key.jump"));
|
||||
assert!(press(&mut ui, &mut s).is_empty());
|
||||
assert_eq!(ui.selected_pad_action, None);
|
||||
press(&mut ui, &mut s);
|
||||
assert_eq!(ui.key(&mut s, &atlas, K::KeyQ, None), vec![Event::Changed]);
|
||||
assert_eq!(
|
||||
(ui.selected_pad_action, s.controller.is_default("key.jump")),
|
||||
(None, true)
|
||||
);
|
||||
press(&mut ui, &mut s);
|
||||
assert_eq!(
|
||||
ui.key(&mut s, &atlas, K::Escape, None),
|
||||
vec![Event::Changed]
|
||||
);
|
||||
assert_eq!(s.controller.binding("key.jump"), None);
|
||||
assert_eq!(ui.page(), Page::PadBinds);
|
||||
}
|
||||
|
||||
/// Every way a pad or a wheel scrolls a page on the shell moves the same list: Replay Studio's
|
||||
/// took the wheel and missed `scroll_at`, so a stick couldn't scroll it.
|
||||
#[test]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! A controller button as the player sees it: Horizon's own art where the pad's family has one
|
||||
//! (`assets/horizon/textures/gui/controller/`, which a resource pack can replace), else the label
|
||||
//! printed on the pad, in its colour, on a dark key. Whatever shows a button draws and measures it
|
||||
//! here; `Button::label` stays for plain text and narration.
|
||||
use crate::gamepad::{Button, GlyphStyle};
|
||||
//! here, from a [`Glyph`]: what the button does (`GlyphOf`), in the player's [`Glyphs`]. A Nintendo
|
||||
//! pad going by its letters is part of `Glyphs`, which only the settings make, so no caller handles
|
||||
//! the letters, and nothing outside this module can turn a bare button into art or a label.
|
||||
use crate::gamepad::{Button, GlyphOf, GlyphStyle, MenuJob, PadSettings};
|
||||
use crate::hud::{HudAtlas, HudBatch, rgba};
|
||||
use crate::pad_cursor;
|
||||
|
||||
@@ -94,23 +96,167 @@ static ART: &[Art] = art![
|
||||
Nintendo DPadRight "nx_right",
|
||||
];
|
||||
|
||||
/// The glyphs a player sees: a pad family's art, and whether a Nintendo pad goes by its letters.
|
||||
/// Opaque: it is only made from the settings and the pad (`of`), or by the one named exception
|
||||
/// (`unheld`), so nothing can drop the letter setting. Letter mode is part of it, and callers
|
||||
/// never handle it: the game already reads such a pad as parts (`PadState::by_letter`), and a
|
||||
/// glyph shows the button that plays its part.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Glyphs {
|
||||
family: GlyphStyle,
|
||||
letters: bool,
|
||||
}
|
||||
|
||||
impl Default for Glyphs {
|
||||
/// The glyphs of the default settings for a pad of no known maker, before any pad is read.
|
||||
fn default() -> Self {
|
||||
Self::of(&PadSettings::default(), GlyphStyle::Auto)
|
||||
}
|
||||
}
|
||||
|
||||
impl Glyphs {
|
||||
/// The glyphs for a pad of `family` under the player's settings: the family they chose, else
|
||||
/// the pad's, going by letters when the pad does (`PadSettings::by_letter`) and the glyphs
|
||||
/// are Nintendo's.
|
||||
pub fn of(settings: &PadSettings, family: GlyphStyle) -> Self {
|
||||
let shown = settings.glyphs.resolve(family);
|
||||
Self {
|
||||
family: shown,
|
||||
letters: shown == GlyphStyle::Nintendo && settings.by_letter(family),
|
||||
}
|
||||
}
|
||||
/// The glyphs for a pad of `family` by the buttons' places, whatever the letter setting. Only
|
||||
/// for a question a controller nobody holds yet answers (splitscreen's join and
|
||||
/// lost-controller prompts): it isn't playing, so its letters aren't read yet
|
||||
/// (docs/controllers.md, Nintendo pads).
|
||||
pub fn unheld(settings: &PadSettings, family: GlyphStyle) -> Self {
|
||||
Self {
|
||||
family: settings.glyphs.resolve(family),
|
||||
letters: false,
|
||||
}
|
||||
}
|
||||
/// The family the art comes from, for naming it ("Glyphs in use").
|
||||
pub fn family(self) -> GlyphStyle {
|
||||
self.family
|
||||
}
|
||||
/// The family whose print shows `part`, and the button on the pad that plays it: the one
|
||||
/// place letter mode turns a part into a physical button (`Button::by_letter`, as input is
|
||||
/// read).
|
||||
fn on_pad(self, part: Button) -> (GlyphStyle, Button) {
|
||||
let button = if self.letters { part.by_letter() } else { part };
|
||||
(self.family, button)
|
||||
}
|
||||
}
|
||||
|
||||
/// A glyph ready to draw: the part a button plays, in a player's [`Glyphs`]. It is only built from
|
||||
/// what the button does (`GlyphOf`), so a rebinding or a pad's letters reach every glyph; there is
|
||||
/// no way to draw or name a bare button.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Glyph {
|
||||
part: Button,
|
||||
glyphs: Glyphs,
|
||||
}
|
||||
|
||||
impl Glyph {
|
||||
/// The glyph for `of` on these settings, in `glyphs`. None while a gameplay action is unbound.
|
||||
pub fn of(of: GlyphOf, settings: &PadSettings, glyphs: Glyphs) -> Option<Self> {
|
||||
let part = of.button(settings)?;
|
||||
Some(Self { part, glyphs })
|
||||
}
|
||||
/// A menu job's glyph: menus aren't rebindable, so it is always there.
|
||||
pub fn menu(job: MenuJob, glyphs: Glyphs) -> Self {
|
||||
Self {
|
||||
part: job.button(),
|
||||
glyphs,
|
||||
}
|
||||
}
|
||||
/// The part this glyph is for (what the pad reads, after any letters), for `pad status`.
|
||||
pub fn part(self) -> Button {
|
||||
self.part
|
||||
}
|
||||
/// What is printed on the button that plays the part: in letter mode, that button's letter.
|
||||
pub fn label(self) -> &'static str {
|
||||
let (family, button) = self.glyphs.on_pad(self.part);
|
||||
printed(button, family)
|
||||
}
|
||||
/// The colour (RGB) of the button that plays the part.
|
||||
pub fn color(self) -> [u8; 3] {
|
||||
let (family, button) = self.glyphs.on_pad(self.part);
|
||||
colour(button, family)
|
||||
}
|
||||
/// Its art, if the family has a picture of the button.
|
||||
fn art(self) -> Option<&'static Art> {
|
||||
let (family, button) = self.glyphs.on_pad(self.part);
|
||||
find(button, family)
|
||||
}
|
||||
}
|
||||
|
||||
/// What is printed on a button of a pad family (`Auto` reads as Xbox).
|
||||
fn printed(button: Button, style: GlyphStyle) -> &'static str {
|
||||
let i = button as usize;
|
||||
match style {
|
||||
GlyphStyle::PlayStation => [
|
||||
"×", "○", "□", "△", "L1", "R1", "L2", "R2", "Create", "Options", "L3", "R3", "↑", "↓",
|
||||
"←", "→",
|
||||
][i],
|
||||
GlyphStyle::Nintendo => [
|
||||
"B", "A", "Y", "X", "L", "R", "ZL", "ZR", "−", "+", "LS", "RS", "↑", "↓", "←", "→",
|
||||
][i],
|
||||
GlyphStyle::Xbox | GlyphStyle::Auto => [
|
||||
"A", "B", "X", "Y", "LB", "RB", "LT", "RT", "View", "Menu", "LS", "RS", "↑", "↓", "←",
|
||||
"→",
|
||||
][i],
|
||||
}
|
||||
}
|
||||
/// The glyph's colour (RGB): face buttons in their family's colours,
|
||||
/// the rest white.
|
||||
fn colour(button: Button, style: GlyphStyle) -> [u8; 3] {
|
||||
let face = match button {
|
||||
Button::South => 0,
|
||||
Button::East => 1,
|
||||
Button::West => 2,
|
||||
Button::North => 3,
|
||||
_ => return [255, 255, 255],
|
||||
};
|
||||
match style {
|
||||
GlyphStyle::PlayStation => [
|
||||
[124, 178, 232],
|
||||
[235, 110, 110],
|
||||
[222, 145, 205],
|
||||
[92, 205, 170],
|
||||
][face],
|
||||
GlyphStyle::Nintendo => [255, 255, 255],
|
||||
GlyphStyle::Xbox | GlyphStyle::Auto => [
|
||||
[110, 200, 90],
|
||||
[235, 90, 80],
|
||||
[80, 150, 240],
|
||||
[240, 200, 60],
|
||||
][face],
|
||||
}
|
||||
}
|
||||
|
||||
/// The picture of a button in a family.
|
||||
fn find(button: Button, style: GlyphStyle) -> Option<&'static Art> {
|
||||
let family = match style {
|
||||
GlyphStyle::Auto => GlyphStyle::Xbox,
|
||||
style => style,
|
||||
family => family,
|
||||
};
|
||||
ART.iter()
|
||||
.find(|art| art.family == family && art.button == button)
|
||||
}
|
||||
|
||||
/// The sprite of a button in a family, if it has one.
|
||||
pub fn sprite(button: Button, style: GlyphStyle) -> Option<&'static str> {
|
||||
/// The sprite of a button in a family, if it has one: the tests name the files by it.
|
||||
#[cfg(test)]
|
||||
fn sprite(button: Button, style: GlyphStyle) -> Option<&'static str> {
|
||||
find(button, style).map(|art| art.sprite)
|
||||
}
|
||||
|
||||
/// The sprite this atlas can draw for the button; none (the boot screen's atlas) means text.
|
||||
pub fn art(atlas: &HudAtlas, button: Button, style: GlyphStyle) -> Option<&'static str> {
|
||||
sprite(button, style).filter(|name| atlas.sprites.contains_key(*name))
|
||||
/// The sprite this atlas can draw for the glyph; none (the boot screen's atlas) means text.
|
||||
pub fn art(atlas: &HudAtlas, glyph: Glyph) -> Option<&'static str> {
|
||||
glyph
|
||||
.art()
|
||||
.map(|art| art.sprite)
|
||||
.filter(|name| atlas.sprites.contains_key(*name))
|
||||
}
|
||||
|
||||
/// How wide the art shows: half its texture (IHDR's width, in a PNG's first bytes) less its edge.
|
||||
@@ -120,31 +266,24 @@ fn art_width(art: &Art) -> f32 {
|
||||
texels as f32 / 2.0 - 2.0 * MARGIN
|
||||
}
|
||||
|
||||
/// How wide the glyph shows.
|
||||
pub fn width(atlas: &HudAtlas, button: Button, style: GlyphStyle) -> f32 {
|
||||
match find(button, style).filter(|a| atlas.sprites.contains_key(a.sprite)) {
|
||||
/// How wide the glyph shows, in GUI pixels.
|
||||
pub fn width(atlas: &HudAtlas, glyph: Glyph) -> f32 {
|
||||
match glyph.art().filter(|a| atlas.sprites.contains_key(a.sprite)) {
|
||||
Some(art) => art_width(art),
|
||||
None => HudBatch::text_width(atlas, button.label(style)).ceil() + 4.0,
|
||||
None => HudBatch::text_width(atlas, glyph.label()).ceil() + 4.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the glyph covers with its label's text at `(x, y)`: `[x, y, w, h]`.
|
||||
pub fn rect(atlas: &HudAtlas, button: Button, style: GlyphStyle, x: f32, y: f32) -> [f32; 4] {
|
||||
[x - 1.0, y - 2.0, width(atlas, button, style), HEIGHT]
|
||||
/// What the glyph covers with its label's text at `(x, y)`: `[x, y, w, h]` in GUI pixels.
|
||||
pub fn rect(atlas: &HudAtlas, glyph: Glyph, x: f32, y: f32) -> [f32; 4] {
|
||||
[x - 1.0, y - 2.0, width(atlas, glyph), HEIGHT]
|
||||
}
|
||||
|
||||
/// Draw the glyph over the 8 pixel text line at `(x, y)`, filling [`rect`]. A dark key backs text
|
||||
/// only: the art brings its own.
|
||||
pub fn draw(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
button: Button,
|
||||
style: GlyphStyle,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
let [left, top, wide, tall] = rect(atlas, button, style, x, y);
|
||||
if let Some(name) = art(atlas, button, style) {
|
||||
pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, glyph: Glyph, x: f32, y: f32) {
|
||||
let [left, top, wide, tall] = rect(atlas, glyph, x, y);
|
||||
if let Some(name) = art(atlas, glyph) {
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
name,
|
||||
@@ -155,16 +294,9 @@ pub fn draw(
|
||||
u32::MAX,
|
||||
);
|
||||
} else {
|
||||
let [r, g, b] = button.color(style);
|
||||
let [r, g, b] = glyph.color();
|
||||
batch.fill(atlas, left, top, wide, tall, rgba(40, 40, 40, 255));
|
||||
batch.text(
|
||||
atlas,
|
||||
button.label(style),
|
||||
x + 1.0,
|
||||
y,
|
||||
rgba(r, g, b, 255),
|
||||
false,
|
||||
);
|
||||
batch.text(atlas, glyph.label(), x + 1.0, y, rgba(r, g, b, 255), false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +340,20 @@ mod tests {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
/// Settings as `horizon-controller.txt` has them, with Nintendo Buttons set as `letters` says.
|
||||
fn settings(letters: bool) -> PadSettings {
|
||||
PadSettings::parse(&format!("glyphs:auto\nnintendoLetters:{letters}\n"))
|
||||
}
|
||||
|
||||
/// A glyph for any part on a pad of `family` going by places: the tests reach the table
|
||||
/// directly.
|
||||
fn g(part: Button, family: GlyphStyle) -> Glyph {
|
||||
Glyph {
|
||||
part,
|
||||
glyphs: Glyphs::of(&settings(false), family),
|
||||
}
|
||||
}
|
||||
|
||||
fn file(button: Button, style: GlyphStyle) -> Option<&'static str> {
|
||||
sprite(button, style).and_then(|name| name.strip_prefix("horizon:gui/controller/"))
|
||||
}
|
||||
@@ -271,7 +417,9 @@ mod tests {
|
||||
assert_eq!(file(Button::East, style), Some("nx_a"));
|
||||
assert_eq!(file(Button::West, style), Some("nx_y"));
|
||||
assert_eq!(file(Button::North, style), Some("nx_x"));
|
||||
assert_eq!(file(Button::LeftTrigger, style), Some("nx_zl"));
|
||||
assert_eq!(file(Button::LeftBumper, style), Some("nx_l"));
|
||||
assert_eq!(file(Button::RightTrigger, style), Some("nx_zr"));
|
||||
assert_eq!(file(Button::Select, style), Some("nx_minus"));
|
||||
assert_eq!(file(Button::Start, style), Some("nx_plus"));
|
||||
}
|
||||
|
||||
@@ -283,8 +431,34 @@ mod tests {
|
||||
(GlyphStyle::Nintendo, "nx_"),
|
||||
] {
|
||||
for button in Button::ALL {
|
||||
let name = file(button, style).unwrap();
|
||||
assert!(name.starts_with(prefix), "{button:?} {name}");
|
||||
let name = file(button, style).unwrap_or_else(|| panic!("{button:?}"));
|
||||
assert!(name.starts_with(prefix), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Each family's art is its own drawing: Nintendo's once was Xbox's files copied (LB for L, a red
|
||||
/// B), and View, Create and − were one plain minus.
|
||||
#[test]
|
||||
fn no_two_buttons_share_a_picture_across_families() {
|
||||
let differs = |a: &Art, b: &Art| a.button == b.button && a.family != b.family;
|
||||
// The sticks and the d-pad are the same part on every pad, so they may share a picture.
|
||||
let shared = [
|
||||
Button::LeftStick,
|
||||
Button::RightStick,
|
||||
Button::DPadUp,
|
||||
Button::DPadDown,
|
||||
Button::DPadLeft,
|
||||
Button::DPadRight,
|
||||
];
|
||||
for (i, art) in ART.iter().enumerate() {
|
||||
for other in ART[..i].iter().filter(|o| o.png == art.png) {
|
||||
assert!(
|
||||
differs(art, other) && shared.contains(&art.button),
|
||||
"{} is {}",
|
||||
art.sprite,
|
||||
other.sprite
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,20 +493,24 @@ mod tests {
|
||||
#[test]
|
||||
fn art_measures_from_its_shape_and_text_from_its_label() {
|
||||
let atlas = atlas();
|
||||
assert_eq!(width(&atlas, Button::South, GlyphStyle::Xbox), 12.0);
|
||||
assert_eq!(width(&atlas, Button::LeftBumper, GlyphStyle::Xbox), 20.0);
|
||||
assert_eq!(width(&atlas, g(Button::South, GlyphStyle::Xbox)), 12.0);
|
||||
assert_eq!(width(&atlas, g(Button::LeftBumper, GlyphStyle::Xbox)), 20.0);
|
||||
assert_eq!(
|
||||
width(&atlas, Button::LeftBumper, GlyphStyle::PlayStation),
|
||||
width(&atlas, g(Button::LeftBumper, GlyphStyle::PlayStation)),
|
||||
20.0
|
||||
);
|
||||
assert_eq!(width(&atlas, g(Button::DPadUp, GlyphStyle::Nintendo)), 12.0);
|
||||
assert_eq!(
|
||||
width(&atlas, g(Button::LeftTrigger, GlyphStyle::Nintendo)),
|
||||
20.0
|
||||
);
|
||||
assert_eq!(width(&atlas, Button::DPadUp, GlyphStyle::Nintendo), 12.0);
|
||||
let mut no_art = atlas;
|
||||
no_art
|
||||
.sprites
|
||||
.remove(sprite(Button::LeftTrigger, GlyphStyle::Nintendo).unwrap());
|
||||
let label = HudBatch::text_width(&no_art, "ZL").ceil() + 4.0;
|
||||
assert_eq!(
|
||||
width(&no_art, Button::LeftTrigger, GlyphStyle::Nintendo),
|
||||
width(&no_art, g(Button::LeftTrigger, GlyphStyle::Nintendo)),
|
||||
label
|
||||
);
|
||||
}
|
||||
@@ -343,9 +521,9 @@ mod tests {
|
||||
atlas
|
||||
.sprites
|
||||
.remove(sprite(Button::South, GlyphStyle::Xbox).unwrap());
|
||||
assert_eq!(art(&atlas, Button::South, GlyphStyle::Xbox), None);
|
||||
assert_eq!(art(&atlas, g(Button::South, GlyphStyle::Xbox)), None);
|
||||
let label = HudBatch::text_width(&atlas, "A").ceil() + 4.0;
|
||||
assert_eq!(width(&atlas, Button::South, GlyphStyle::Xbox), label);
|
||||
assert_eq!(width(&atlas, g(Button::South, GlyphStyle::Xbox)), label);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -355,8 +533,7 @@ mod tests {
|
||||
draw(
|
||||
&mut batch,
|
||||
&atlas,
|
||||
Button::South,
|
||||
GlyphStyle::Xbox,
|
||||
g(Button::South, GlyphStyle::Xbox),
|
||||
10.0,
|
||||
20.0,
|
||||
);
|
||||
@@ -365,8 +542,7 @@ mod tests {
|
||||
draw(
|
||||
&mut batch,
|
||||
&atlas,
|
||||
Button::LeftBumper,
|
||||
GlyphStyle::Xbox,
|
||||
g(Button::LeftBumper, GlyphStyle::Xbox),
|
||||
10.0,
|
||||
20.0,
|
||||
);
|
||||
@@ -379,8 +555,83 @@ mod tests {
|
||||
let style = GlyphStyle::Nintendo;
|
||||
atlas.sprites.remove(sprite(Button::South, style).unwrap());
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
draw(&mut batch, &atlas, Button::South, style, 10.0, 20.0);
|
||||
let [x, y, w, h] = rect(&atlas, Button::South, style, 10.0, 20.0);
|
||||
draw(&mut batch, &atlas, g(Button::South, style), 10.0, 20.0);
|
||||
let [x, y, w, h] = rect(&atlas, g(Button::South, style), 10.0, 20.0);
|
||||
assert_eq!(quads(&batch)[0], [x, y, x + w, y + h]);
|
||||
}
|
||||
|
||||
/// The part a pad going by its letters reads when `button` is pressed (`PadState::by_letter`,
|
||||
/// as `Pads` reads the pad), so the expectation doesn't come from the glyph code.
|
||||
fn part_read_from(button: Button) -> Button {
|
||||
let mut pad = crate::gamepad::PadState::default();
|
||||
pad.set(button, true);
|
||||
let read = pad.by_letter();
|
||||
Button::ALL.into_iter().find(|b| read.held(*b)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letter_mode_shows_the_button_that_plays_each_part() {
|
||||
let atlas = atlas();
|
||||
let letters = Glyphs::of(&settings(true), GlyphStyle::Nintendo);
|
||||
for button in Button::ALL {
|
||||
let shown = Glyph {
|
||||
part: part_read_from(button),
|
||||
glyphs: letters,
|
||||
};
|
||||
let printed = g(button, GlyphStyle::Nintendo);
|
||||
assert_eq!(shown.label(), printed.label(), "{button:?}");
|
||||
assert_eq!(art(&atlas, shown), art(&atlas, printed), "{button:?}");
|
||||
}
|
||||
// As a Switch: the A on the right confirms and jumps, B goes back, X and Y swap.
|
||||
let label = |job| Glyph::menu(job, letters).label();
|
||||
assert_eq!(label(MenuJob::Confirm), "A");
|
||||
assert_eq!(label(MenuJob::Back), "B");
|
||||
assert_eq!(label(MenuJob::Alternate), "X");
|
||||
assert_eq!(label(MenuJob::Extra), "Y");
|
||||
let confirm = art(&atlas, Glyph::menu(MenuJob::Confirm, letters));
|
||||
assert_eq!(confirm, Some("horizon:gui/controller/nx_a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_nintendo_pad_with_nintendo_glyphs_goes_by_letters() {
|
||||
let label = |settings: &PadSettings, family| {
|
||||
Glyph::menu(MenuJob::Confirm, Glyphs::of(settings, family)).label()
|
||||
};
|
||||
// By places, a Nintendo pad's confirm is the B at the bottom.
|
||||
assert_eq!(label(&settings(false), GlyphStyle::Nintendo), "B");
|
||||
// An Xbox pad has no letters to swap.
|
||||
assert_eq!(label(&settings(true), GlyphStyle::Xbox), "A");
|
||||
// A player who chose Xbox glyphs sees them, though the Nintendo pad is read by letters.
|
||||
let chosen = PadSettings::parse("glyphs:xbox\nnintendoLetters:true\n");
|
||||
assert_eq!(label(&chosen, GlyphStyle::Nintendo), "A");
|
||||
// A controller nobody holds yet is asked by places.
|
||||
let unheld = Glyphs::unheld(&settings(true), GlyphStyle::Nintendo);
|
||||
assert_eq!(Glyph::menu(MenuJob::Confirm, unheld).label(), "B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_mode_names_each_part_by_its_place() {
|
||||
assert_eq!(g(Button::South, GlyphStyle::Nintendo).label(), "B");
|
||||
assert_eq!(g(Button::South, GlyphStyle::Xbox).label(), "A");
|
||||
assert_eq!(g(Button::South, GlyphStyle::PlayStation).label(), "×");
|
||||
assert_eq!(
|
||||
g(Button::RightTrigger, GlyphStyle::PlayStation).label(),
|
||||
"R2"
|
||||
);
|
||||
assert_eq!(g(Button::DPadLeft, GlyphStyle::Xbox).label(), "←");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebinding_an_action_changes_its_glyph() {
|
||||
let mut settings = settings(false);
|
||||
let xbox = Glyphs::of(&settings, GlyphStyle::Xbox);
|
||||
let jump = GlyphOf::Bound("key.jump");
|
||||
let before = Glyph::of(jump, &settings, xbox).unwrap();
|
||||
settings.bind("key.jump", Some(Button::North));
|
||||
let after = Glyph::of(jump, &settings, xbox).unwrap();
|
||||
assert_ne!(before.label(), after.label());
|
||||
assert_eq!(after.label(), "Y");
|
||||
settings.bind("key.jump", None);
|
||||
assert_eq!(Glyph::of(jump, &settings, xbox), None);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ pub use horizon_client_core::particle_type::ParticleType;
|
||||
pub use horizon_items::firework::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape};
|
||||
mod native;
|
||||
pub mod polytone;
|
||||
mod polytone_gpu;
|
||||
pub use native::{ParticleController, ParticleUpdate};
|
||||
|
||||
/// `ParticleOptions` with the parameters of the option classes used here.
|
||||
@@ -528,6 +529,10 @@ pub struct Particle {
|
||||
controller: Option<Box<dyn native::Controller>>,
|
||||
/// A resource pack's Polytone custom particle type, for its `limit`.
|
||||
polytone_type: Option<std::sync::Arc<str>>,
|
||||
/// How a Polytone custom particle's quad turns (`rotation_mode`).
|
||||
orientation: polytone_gpu::Orientation,
|
||||
/// Blended additively (a Polytone `additive_translucent` particle).
|
||||
additive: bool,
|
||||
}
|
||||
fn aabb_at(p: [f64; 3], w: f32, h: f32) -> Aabb {
|
||||
let w = f64::from(w) / 2.0;
|
||||
@@ -890,6 +895,8 @@ impl Particle {
|
||||
random: LegacyRandom::new(0),
|
||||
controller: None,
|
||||
polytone_type: None,
|
||||
orientation: polytone_gpu::Orientation::Camera,
|
||||
additive: false,
|
||||
}
|
||||
}
|
||||
/// The normal age/removal test and previous pose, without vanilla motion.
|
||||
@@ -1286,6 +1293,11 @@ pub struct ParticleEngine {
|
||||
set_sprites: Vec<Vec<u32>>,
|
||||
/// Resource packs' Polytone custom particle types (#11486).
|
||||
polytone: Option<std::sync::Arc<polytone::Registry>>,
|
||||
/// The packs' GPU particles' records, one store per type, and the ticks they count in.
|
||||
polytone_gpu: Vec<polytone_gpu::Store>,
|
||||
polytone_ticks: u64,
|
||||
/// The last frame's additively blended quads (`take_additive`).
|
||||
additive: std::sync::Mutex<Vec<Vertex>>,
|
||||
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
|
||||
emitters: Vec<(ParticleOptions, [f64; 3], f64, f64, u32)>,
|
||||
/// Camera position for the 32-block spawn limiter.
|
||||
@@ -1479,6 +1491,9 @@ impl ParticleEngine {
|
||||
mod_sets: HashMap::new(),
|
||||
set_sprites: Vec::new(),
|
||||
polytone: None,
|
||||
polytone_gpu: Vec::new(),
|
||||
polytone_ticks: 0,
|
||||
additive: std::sync::Mutex::default(),
|
||||
emitters: Vec::new(),
|
||||
starters: Vec::new(),
|
||||
tracked_explosions: Vec::new(),
|
||||
@@ -1701,7 +1716,25 @@ impl ParticleEngine {
|
||||
}
|
||||
/// Sets (or clears) the resource packs' Polytone custom particle types.
|
||||
pub fn set_polytone(&mut self, registry: Option<std::sync::Arc<polytone::Registry>>) {
|
||||
self.polytone = registry.filter(|r| !r.is_empty());
|
||||
let registry = registry.filter(|r| !r.is_empty());
|
||||
let same = match (&self.polytone, ®istry) {
|
||||
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !same {
|
||||
self.polytone_gpu.clear();
|
||||
}
|
||||
self.polytone = registry;
|
||||
}
|
||||
/// The quads the last render blends additively (`RenderPipelines` additive, src alpha and
|
||||
/// one), for the frame's additive pass.
|
||||
pub fn take_additive(&self) -> Vec<Vertex> {
|
||||
std::mem::take(&mut *self.additive.lock().unwrap_or_else(|e| e.into_inner()))
|
||||
}
|
||||
/// Live GPU particle records (spawns, before `area` spreads them), for tests and status.
|
||||
pub fn polytone_gpu_records(&self) -> usize {
|
||||
self.polytone_gpu.iter().map(polytone_gpu::Store::len).sum()
|
||||
}
|
||||
/// Choose a frame from this particle's declared sprite set.
|
||||
pub fn set_sprite_frame(&self, particle: &mut Particle, frame: usize) {
|
||||
@@ -2110,7 +2143,22 @@ impl ParticleEngine {
|
||||
&& let Some(registry) = &self.polytone
|
||||
&& let Some(def) = registry.get(name)
|
||||
{
|
||||
return polytone::make(self, ®istry.clone(), name, &def.clone(), o, p, v, seed);
|
||||
let (registry, def) = (registry.clone(), def.clone());
|
||||
let particle = polytone::make(self, ®istry, name, &def, o, p, v, seed)?;
|
||||
if def.gpu.is_some() {
|
||||
// A GPU particle is only a record: it never becomes an engine particle.
|
||||
let now = self.polytone_ticks;
|
||||
let store = match self.polytone_gpu.iter().position(|s| s.is(&def)) {
|
||||
Some(i) => &mut self.polytone_gpu[i],
|
||||
None => {
|
||||
self.polytone_gpu.push(polytone_gpu::Store::new(&def)?);
|
||||
self.polytone_gpu.last_mut()?
|
||||
}
|
||||
};
|
||||
store.spawn(&particle, now, seed as u64);
|
||||
return None;
|
||||
}
|
||||
return Some(particle);
|
||||
}
|
||||
if let Some(name) = &o.mod_particle {
|
||||
let (set, behaviour) = *self.mod_sets.get(name)?;
|
||||
@@ -3746,6 +3794,14 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
pub fn tick(&mut self, level: &ParticleLevel) {
|
||||
self.polytone_ticks += 1;
|
||||
for store in &mut self.polytone_gpu {
|
||||
store.expire(self.polytone_ticks);
|
||||
}
|
||||
// The sounds the packs' custom particles played last tick.
|
||||
if let Some(registry) = &self.polytone {
|
||||
self.sounds.extend(registry.take_sounds());
|
||||
}
|
||||
self.tick_starters();
|
||||
self.tick_explosions(level);
|
||||
for (options, feet, width, height, life) in std::mem::take(&mut self.emitters) {
|
||||
@@ -4738,6 +4794,8 @@ impl ParticleEngine {
|
||||
let base = Quat::from_rotation_y(std::f32::consts::PI - yaw.to_radians())
|
||||
* Quat::from_rotation_x(-pitch.to_radians());
|
||||
let mut light_cache = ParticleLightCache::default();
|
||||
let mut additive = self.additive.lock().unwrap_or_else(|e| e.into_inner());
|
||||
additive.clear();
|
||||
let mut section_cache =
|
||||
(CULL && frustum.is_some()).then(ParticleSectionFrustumCache::default);
|
||||
for p in &self.particles {
|
||||
@@ -4809,7 +4867,15 @@ impl ParticleEngine {
|
||||
) - camera;
|
||||
(pos.as_vec3(), p.quad_size(partial))
|
||||
});
|
||||
let rotation = if p.roll != 0.0 {
|
||||
let rotation = if p.orientation != polytone_gpu::Orientation::Camera {
|
||||
let velocity = FVec3::new(
|
||||
p.velocity[0] as f32,
|
||||
p.velocity[1] as f32,
|
||||
p.velocity[2] as f32,
|
||||
);
|
||||
let roll = p.o_roll + (p.roll - p.o_roll) * partial;
|
||||
polytone_gpu::orient(p.orientation, center, velocity, roll, base)
|
||||
} else if p.roll != 0.0 {
|
||||
base * Quat::from_rotation_z(p.o_roll + (p.roll - p.o_roll) * partial)
|
||||
} else {
|
||||
base
|
||||
@@ -4891,6 +4957,7 @@ impl ParticleEngine {
|
||||
r.u0 + du * p.uv[2],
|
||||
r.v0 + dv * p.uv[3],
|
||||
);
|
||||
let target: &mut Vec<Vertex> = if p.additive { &mut additive } else { &mut *out };
|
||||
for rotation in rotations.into_iter().flatten() {
|
||||
for (n, uv) in [
|
||||
((1.0, -1.0), [u1, v1]),
|
||||
@@ -4899,7 +4966,7 @@ impl ParticleEngine {
|
||||
((-1.0, -1.0), [u0, v1]),
|
||||
] {
|
||||
let v = center + rotation * FVec3::new(n.0, n.1, 0.0) * size;
|
||||
out.push(Vertex {
|
||||
target.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: v.to_array(),
|
||||
uv,
|
||||
@@ -4916,6 +4983,19 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
for store in &self.polytone_gpu {
|
||||
store.render(
|
||||
&self.atlas,
|
||||
camera,
|
||||
base,
|
||||
self.polytone_ticks,
|
||||
partial,
|
||||
&mut light_cache,
|
||||
light_at,
|
||||
out,
|
||||
&mut additive,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// How brightly particle `ty` shines in HDR output, as `mesher::hdr_tier`: flames,
|
||||
|
||||
@@ -8,11 +8,11 @@ use super::{
|
||||
ParticleUpdate, Sprite,
|
||||
};
|
||||
use crate::environment::Frame;
|
||||
use horizon_core::noise::SimplexNoise;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_polytone::expr::{Host, Program, Root, Value as V};
|
||||
use horizon_polytone::particles::{
|
||||
BlockEmitters, CustomParticle, Emitter, EntityEmitters, LiquidAffinity, RenderType,
|
||||
SoundEmitter,
|
||||
};
|
||||
use horizon_world::registry::BlockRegistry;
|
||||
use serde_json::Value as Json;
|
||||
@@ -35,7 +35,82 @@ pub struct Registry {
|
||||
pub frame: Arc<RwLock<Frame>>,
|
||||
configs: horizon_polytone::pack::Configs,
|
||||
blocks: &'static BlockRegistry,
|
||||
noise: SimplexNoise,
|
||||
/// Each custom particle type's colormap (`colormap`), by type id.
|
||||
tints: HashMap<String, Arc<Tint>>,
|
||||
/// Sound ids as the engine's sound queue holds them, interned once.
|
||||
sound_names: Mutex<HashMap<String, &'static str>>,
|
||||
/// Sounds the custom particles played this tick (`ParticleEngine::tick` plays them).
|
||||
sounds: Mutex<Vec<Sound>>,
|
||||
}
|
||||
|
||||
/// A sound for the engine's queue: id, position, volume, pitch, delayed by the distance.
|
||||
type Sound = (&'static str, [f64; 3], f32, f32, bool);
|
||||
|
||||
/// Where a particle's colormap colour comes from.
|
||||
enum TintSource {
|
||||
/// A vanilla biome colour (`grass_color`, `foliage_color`, `dry_foliage_color`,
|
||||
/// `water_color`): the biome's own, as blocks get it.
|
||||
Biome(fn(&crate::biome::BiomeInfo) -> u32),
|
||||
/// A pack's colormap (`polytone/colormaps`), its axes run against the particle.
|
||||
Pack(crate::environment::Colormap),
|
||||
}
|
||||
|
||||
/// When a particle's colormap is sampled again (`cache`).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum TintCache {
|
||||
EveryTick,
|
||||
PerPosition,
|
||||
OnSpawn,
|
||||
}
|
||||
|
||||
/// A custom particle type's colormap: multiplied into its colour.
|
||||
struct Tint {
|
||||
source: TintSource,
|
||||
cache: TintCache,
|
||||
}
|
||||
|
||||
impl Tint {
|
||||
/// `colormap`: an id, or `{"colormap": id, "cache": ...}`. Inline colormaps aren't read.
|
||||
fn read(pack_root: &Path, json: &Json) -> Option<Self> {
|
||||
let (id, cache) = match json {
|
||||
Json::String(id) => (id.as_str(), TintCache::EveryTick),
|
||||
Json::Object(o) => (
|
||||
o.get("colormap")?.as_str()?,
|
||||
match o.get("cache").and_then(Json::as_str) {
|
||||
Some("per_position") => TintCache::PerPosition,
|
||||
Some("on_spawn") => TintCache::OnSpawn,
|
||||
_ => TintCache::EveryTick,
|
||||
},
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
let id = if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
};
|
||||
let source = match id.as_str() {
|
||||
"minecraft:grass_color" => TintSource::Biome(|b| b.grass),
|
||||
"minecraft:foliage_color" => TintSource::Biome(|b| b.foliage),
|
||||
"minecraft:dry_foliage_color" => TintSource::Biome(|b| b.dry_foliage),
|
||||
"minecraft:water_color" => TintSource::Biome(|b| b.water),
|
||||
_ => TintSource::Pack(crate::environment::Colormap::load(pack_root, &id)?),
|
||||
};
|
||||
Some(Self { source, cache })
|
||||
}
|
||||
|
||||
/// The colour (0 to 1 per channel) at the particle in `ctx`; `None` keeps its own.
|
||||
fn sample(&self, ctx: &mut Ctx) -> Option<[f32; 3]> {
|
||||
let rgb = match &self.source {
|
||||
TintSource::Biome(colour) => {
|
||||
let at = ParticleLevel::block_at(ctx.position());
|
||||
let info = ctx.biome_info(at)?;
|
||||
colour(&info)
|
||||
}
|
||||
TintSource::Pack(colormap) => colormap.sample(ctx)?,
|
||||
};
|
||||
Some([16, 8, 0].map(|shift| ((rgb >> shift) & 0xff) as f32 / 255.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `.json` under `dir`, with its path relative to `root`.
|
||||
@@ -104,7 +179,7 @@ impl Registry {
|
||||
for (relative, json) in json_files(pack_root, &polytone.join("block_modifiers")) {
|
||||
let implicit = horizon_polytone::implicit_id(&relative, "block_modifiers");
|
||||
match BlockEmitters::parse(&json, implicit.as_deref()) {
|
||||
Ok(Some(m)) if !m.particle_emitters.is_empty() => modifiers.push(Arc::new(m)),
|
||||
Ok(Some(m)) => modifiers.push(Arc::new(m)),
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("{relative}: {e}"),
|
||||
}
|
||||
@@ -137,6 +212,19 @@ impl Registry {
|
||||
.clone()
|
||||
})
|
||||
.collect();
|
||||
let tints = types
|
||||
.iter()
|
||||
.filter_map(|(id, def)| {
|
||||
let json = def.render.get("colormap")?;
|
||||
match Tint::read(pack_root, json) {
|
||||
Some(tint) => Some((id.clone(), Arc::new(tint))),
|
||||
None => {
|
||||
log::warn!("custom particle {id}: colormap {json} not found");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
types,
|
||||
block_emitters,
|
||||
@@ -146,7 +234,9 @@ impl Registry {
|
||||
frame: Arc::new(RwLock::new(Frame::default())),
|
||||
configs,
|
||||
blocks,
|
||||
noise: SimplexNoise::new(&mut LegacyRandom::new(0)),
|
||||
tints,
|
||||
sound_names: Mutex::default(),
|
||||
sounds: Mutex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +246,22 @@ impl Registry {
|
||||
&& self.entity_modifiers.is_empty()
|
||||
}
|
||||
|
||||
/// A sound id as the engine's sound queue holds it.
|
||||
fn sound_name(&self, id: &str) -> &'static str {
|
||||
let mut names = self.sound_names.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(name) = names.get(id) {
|
||||
return name;
|
||||
}
|
||||
let name: &'static str = Box::leak(id.to_owned().into_boxed_str());
|
||||
names.insert(id.to_owned(), name);
|
||||
name
|
||||
}
|
||||
|
||||
/// The sounds custom particles played since the last call.
|
||||
pub(super) fn take_sounds(&self) -> Vec<Sound> {
|
||||
std::mem::take(&mut *self.sounds.lock().unwrap_or_else(|e| e.into_inner()))
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&Arc<CustomParticle>> {
|
||||
self.types.get(id)
|
||||
}
|
||||
@@ -219,6 +325,54 @@ struct Controller {
|
||||
ticks: u32,
|
||||
/// Sprites in its set, for the age-driven frame.
|
||||
frames: usize,
|
||||
/// Its colormap, and what the last sample left.
|
||||
tint: Option<Arc<Tint>>,
|
||||
tinting: Tinting,
|
||||
}
|
||||
|
||||
/// A particle's colormap state: its colour before the colormap, the block it was last
|
||||
/// sampled at, and that sample.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Tinting {
|
||||
base: Option<[f32; 3]>,
|
||||
at: Option<[i32; 3]>,
|
||||
rgb: [f32; 3],
|
||||
}
|
||||
|
||||
impl Default for Tinting {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base: None,
|
||||
at: None,
|
||||
rgb: [1.0; 3],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Tinting {
|
||||
/// Samples the colormap as its `cache` says and multiplies it into the colour.
|
||||
fn apply(&mut self, tint: &Tint, ctx: &mut Ctx) {
|
||||
let Subject::Particle(p) = &ctx.subject else {
|
||||
return;
|
||||
};
|
||||
let base = [p.rgba[0], p.rgba[1], p.rgba[2]];
|
||||
let at = ParticleLevel::block_at(p.position);
|
||||
let resample = match tint.cache {
|
||||
TintCache::EveryTick => true,
|
||||
TintCache::PerPosition => self.at != Some(at),
|
||||
TintCache::OnSpawn => self.at.is_none(),
|
||||
};
|
||||
if resample {
|
||||
self.at = Some(at);
|
||||
self.rgb = tint.sample(ctx).unwrap_or([1.0; 3]);
|
||||
}
|
||||
self.base = Some(base);
|
||||
if let Subject::Particle(p) = &mut ctx.subject {
|
||||
for i in 0..3 {
|
||||
p.rgba[i] = base[i] * self.rgb[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Controller {
|
||||
@@ -280,6 +434,13 @@ impl Ctx<'_> {
|
||||
Subject::Entity(at) => *at,
|
||||
}
|
||||
}
|
||||
/// The biome at a block, when the level and biomes are known.
|
||||
fn biome_info(&self, at: [i32; 3]) -> Option<crate::biome::BiomeInfo> {
|
||||
let chunk = self.level?.world.chunks.get(&(at[0] >> 4, at[2] >> 4))?;
|
||||
let id = chunk.biome(at[0], at[1], at[2]);
|
||||
let biomes = self.registry.biomes.read().ok()?;
|
||||
Some(biomes.as_ref()?.get(id).clone())
|
||||
}
|
||||
/// The biome at a block (`minecraft:plains`), when the level and biomes are known.
|
||||
fn biome(&self, at: [i32; 3]) -> Option<String> {
|
||||
let chunk = self.level?.world.chunks.get(&(at[0] >> 4, at[2] >> 4))?;
|
||||
@@ -300,11 +461,18 @@ impl Ctx<'_> {
|
||||
if name == "biome" {
|
||||
return Ok(V::Str(self.biome(at).unwrap_or_default().into()));
|
||||
}
|
||||
// The biome's climate where it is known, else the camera's.
|
||||
if name == "temperature" {
|
||||
return num(f64::from(self.frame.temperature));
|
||||
let t = self
|
||||
.biome_info(at)
|
||||
.map_or(self.frame.temperature, |b| b.temperature);
|
||||
return num(f64::from(t));
|
||||
}
|
||||
if name == "downfall" {
|
||||
return num(f64::from(self.frame.downfall));
|
||||
let d = self
|
||||
.biome_info(at)
|
||||
.map_or(self.frame.downfall, |b| b.downfall);
|
||||
return num(f64::from(d));
|
||||
}
|
||||
let arg = |i: usize| args.get(i).map_or(0.0, V::num) as i32;
|
||||
let neighbour = match name {
|
||||
@@ -517,7 +685,7 @@ impl Host for Ctx<'_> {
|
||||
// `r.noise(x, y)`: one simplex noise (named noises aren't read yet).
|
||||
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
|
||||
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
|
||||
num(f64::from(self.registry.noise.get2(x, y)))
|
||||
num(f64::from(crate::environment::polytone_noise().get2(x, y)))
|
||||
}
|
||||
(id, _) if id >= BLOCKS => {
|
||||
let at = *self
|
||||
@@ -709,6 +877,45 @@ fn emit(emitters: &[Emitter], ctx: &mut Ctx, out: &mut Vec<(ParticleOptions, [f6
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs sound emitters from the subject: each plays when its `chance` beats a random draw, at
|
||||
/// its position relative to the subject (a block's centre).
|
||||
fn emit_sounds(emitters: &[SoundEmitter], ctx: &mut Ctx, out: &mut Vec<Sound>) {
|
||||
for emitter in emitters {
|
||||
let chance = emitter
|
||||
.fields
|
||||
.get("chance")
|
||||
.map_or(Some(1.0), |c| eval(c, ctx))
|
||||
.unwrap_or(0.0);
|
||||
if ctx.rand() >= chance {
|
||||
continue;
|
||||
}
|
||||
let field = |ctx: &mut Ctx, key: &str| emitter.fields.get(key).and_then(|p| eval(p, ctx));
|
||||
let base = match &ctx.subject {
|
||||
Subject::Block(b) => b.map(|c| f64::from(c) + 0.5),
|
||||
_ => ctx.position(),
|
||||
};
|
||||
if let Some(biomes) = &emitter.biomes
|
||||
&& !biome_matches(biomes, ctx, ParticleLevel::block_at(base))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let at = [
|
||||
base[0] + field(ctx, "x").unwrap_or(0.0),
|
||||
base[1] + field(ctx, "y").unwrap_or(0.0),
|
||||
base[2] + field(ctx, "z").unwrap_or(0.0),
|
||||
];
|
||||
if let Some(predicate) = &emitter.state_predicate
|
||||
&& !state_matches(predicate, ctx, ParticleLevel::block_at(at))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let volume = field(ctx, "volume").unwrap_or(1.0) as f32;
|
||||
let pitch = field(ctx, "pitch").unwrap_or(1.0) as f32;
|
||||
let name = ctx.registry.sound_name(&emitter.sound);
|
||||
out.push((name, at, volume, pitch, emitter.distance_delay));
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets each `keys` field the program map holds on `p` through `apply`.
|
||||
fn run_fields(
|
||||
programs: &BTreeMap<String, Program>,
|
||||
@@ -782,6 +989,10 @@ impl super::ParticleController for Controller {
|
||||
let Ok(frame) = registry.frame.read() else {
|
||||
return update;
|
||||
};
|
||||
// Expressions see the colour before the colormap.
|
||||
if let Some(base) = self.tinting.base {
|
||||
p.rgba[..3].copy_from_slice(&base);
|
||||
}
|
||||
let mut random = std::mem::replace(&mut p.random, LegacyRandom::new(0));
|
||||
let mut ctx = Ctx {
|
||||
subject: Subject::Particle(p),
|
||||
@@ -811,6 +1022,18 @@ impl super::ParticleController for Controller {
|
||||
}
|
||||
}
|
||||
emit(&def.emitters, &mut ctx, &mut update.spawned);
|
||||
if !def.sound_emitters.is_empty() {
|
||||
let mut sounds = Vec::new();
|
||||
emit_sounds(&def.sound_emitters, &mut ctx, &mut sounds);
|
||||
registry
|
||||
.sounds
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.extend(sounds);
|
||||
}
|
||||
if let Some(tint) = self.tint.clone() {
|
||||
self.tinting.apply(&tint, &mut ctx);
|
||||
}
|
||||
}
|
||||
drop(ctx);
|
||||
p.random = random;
|
||||
@@ -867,12 +1090,11 @@ pub(super) fn make(
|
||||
velocity: [f64; 3],
|
||||
seed: i64,
|
||||
) -> Option<Particle> {
|
||||
// GPU particles (`"type": "gpu"`) are a renderer of their own, not ported yet.
|
||||
if def.kind == horizon_polytone::particles::ParticleKind::Gpu {
|
||||
return None;
|
||||
}
|
||||
// `limit`: at most that many of the type alive at once.
|
||||
if let Some(limit) = def.limit {
|
||||
// (A GPU particle's `limit` is its ring of records instead.)
|
||||
if let Some(limit) = def.limit
|
||||
&& def.gpu.is_none()
|
||||
{
|
||||
let alive = engine
|
||||
.particles
|
||||
.iter()
|
||||
@@ -889,8 +1111,11 @@ pub(super) fn make(
|
||||
particle.gravity = 0.0;
|
||||
particle.has_physics = def.has_physics;
|
||||
let mut frames = 0;
|
||||
// A model particle (`model`) isn't drawn yet: it still ticks and emits.
|
||||
let hidden = def.render_type == RenderType::Invisible || def.render.contains_key("model");
|
||||
// A model particle (`model`) isn't drawn yet: it still ticks and emits. A GPU particle is
|
||||
// drawn from its record instead (`polytone_gpu`).
|
||||
let hidden = def.render_type == RenderType::Invisible
|
||||
|| def.render.contains_key("model")
|
||||
|| def.gpu.is_some();
|
||||
match engine.mod_sets.get(name) {
|
||||
Some(&(set, _)) => {
|
||||
let sprites = engine.set_sprites.get(set as usize)?;
|
||||
@@ -907,6 +1132,8 @@ pub(super) fn make(
|
||||
None => return None,
|
||||
}
|
||||
particle.no_render = hidden;
|
||||
particle.orientation = super::polytone_gpu::Orientation::parse(&def.rotation_mode);
|
||||
particle.additive = def.render_type == RenderType::AdditiveTranslucent;
|
||||
particle.behaviour = Behaviour::Controlled {
|
||||
translucent: def.render_type != RenderType::Opaque,
|
||||
};
|
||||
@@ -952,6 +1179,8 @@ pub(super) fn make(
|
||||
customs,
|
||||
ticks: 0,
|
||||
frames,
|
||||
tint: registry.tints.get(name).cloned(),
|
||||
tinting: Tinting::default(),
|
||||
}));
|
||||
Some(particle)
|
||||
}
|
||||
@@ -1025,14 +1254,17 @@ impl ParticleEngine {
|
||||
level: Some(level),
|
||||
blocks: Vec::new(),
|
||||
};
|
||||
let mut sounds = Vec::new();
|
||||
for modifier in modifiers.iter() {
|
||||
emit(&modifier.particle_emitters, &mut ctx, &mut out);
|
||||
emit_sounds(&modifier.sound_emitters, &mut ctx, &mut sounds);
|
||||
}
|
||||
drop(ctx);
|
||||
drop(frame);
|
||||
for (options, position, velocity) in out {
|
||||
self.add(&options, position, velocity);
|
||||
}
|
||||
self.sounds.extend(sounds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1041,6 +1273,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::chunk::Chunk;
|
||||
use crate::world::World;
|
||||
use glam::DVec3;
|
||||
use horizon_world::DATA_VERSION;
|
||||
use horizon_world::block_collision::BlockCollisionRegistry;
|
||||
use horizon_world::collision::CachedCollisionRegistry;
|
||||
@@ -1263,4 +1496,135 @@ mod tests {
|
||||
assert_eq!(engine.pending.len(), 1);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gpu_particle_is_a_record_drawn_until_its_life_ends() {
|
||||
let blocks = blocks();
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/test/polytone/custom_particles/rain.json",
|
||||
serde_json::json!({
|
||||
"type": "gpu", "texture": "test:textures/particle/rain.png",
|
||||
"render_type": "translucent", "rotation_mode": "movement_aligned",
|
||||
"initializer": {"lifetime": "3", "size": "0.5"},
|
||||
"gravity": 0.1, "area": {"size": [4, 4, 4], "count": 3}
|
||||
}),
|
||||
),
|
||||
(
|
||||
// Additive: drawn in the frame's additive pass instead.
|
||||
"assets/test/polytone/custom_particles/glow.json",
|
||||
serde_json::json!({
|
||||
"type": "gpu", "texture": "test:textures/particle/rain.png",
|
||||
"render_type": "additive_translucent", "initializer": {"lifetime": "3"}
|
||||
}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/player.json",
|
||||
serde_json::json!({"emitters": [{"particle": "test:rain"}, {"particle": "test:glow"}]}),
|
||||
),
|
||||
]);
|
||||
let scene = scene(blocks);
|
||||
let level = ParticleLevel {
|
||||
world: &scene.world,
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
let rect = crate::hud::Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
engine
|
||||
.atlas
|
||||
.insert("test:particle/rain".into(), (rect, true));
|
||||
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
|
||||
// No engine particle: a record each; the rain drawn as its area's three quads.
|
||||
assert!(engine.pending.is_empty());
|
||||
assert_eq!(engine.polytone_gpu_records(), 2);
|
||||
let mut out = Vec::new();
|
||||
engine.render(
|
||||
DVec3::new(1.0, 70.0, 1.0),
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
&|_| 0xF0,
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(out.len(), 3 * 4);
|
||||
assert_eq!(engine.take_additive().len(), 4);
|
||||
assert!(
|
||||
out.iter()
|
||||
.all(|v| v.layer == crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY)
|
||||
);
|
||||
for _ in 0..3 {
|
||||
engine.tick(&level);
|
||||
}
|
||||
assert_eq!(
|
||||
engine.polytone_gpu_records(),
|
||||
0,
|
||||
"gone once its 3 ticks are over"
|
||||
);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_colormap_tints_the_particle_and_sound_emitters_play() {
|
||||
let blocks = blocks();
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/test/polytone/custom_particles/leaf.json",
|
||||
serde_json::json!({
|
||||
"has_physics": false, "colormap": "minecraft:foliage_color",
|
||||
"initializer": {"red": "1", "green": "0.5", "blue": "1"},
|
||||
"sound_emitters": [{"sound": "block.note_block.harp", "chance": "1", "volume": "0.5"}]
|
||||
}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/player.json",
|
||||
serde_json::json!({"emitters": [{"particle": "test:leaf"}]}),
|
||||
),
|
||||
]);
|
||||
let scene = scene(blocks);
|
||||
let level = ParticleLevel {
|
||||
world: &scene.world,
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
};
|
||||
let registry = Arc::new(Registry::read(&root, Default::default(), blocks));
|
||||
// The chunk's biome is id 0: plains, whose foliage is #336699.
|
||||
registry.set_biomes(&Arc::new(crate::biome::Biomes::from_jsons(&[(
|
||||
"minecraft:plains",
|
||||
serde_json::json!({"effects": {"foliage_color": 0x336699}}),
|
||||
)])));
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.mod_sets.insert("test:leaf".into(), (0, None));
|
||||
engine.set_sprites.push(vec![10]);
|
||||
engine.camera = [1.0, 65.0, 1.0];
|
||||
engine.set_polytone(Some(registry.clone()));
|
||||
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
|
||||
let mut p = engine.pending.pop().unwrap();
|
||||
let tinted = [
|
||||
0x33 as f32 / 255.0,
|
||||
0.5 * 0x66 as f32 / 255.0,
|
||||
0x99 as f32 / 255.0,
|
||||
];
|
||||
for _ in 0..2 {
|
||||
engine.tick_particle(&mut p, &level);
|
||||
// Multiplied into its own colour, every tick, without compounding.
|
||||
assert_eq!(&p.rgba[..3], &tinted);
|
||||
}
|
||||
let sounds = registry.take_sounds();
|
||||
assert_eq!(sounds.len(), 2);
|
||||
assert_eq!(
|
||||
(sounds[0].0, sounds[0].2, sounds[0].3),
|
||||
("minecraft:block.note_block.harp", 0.5, 1.0)
|
||||
);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
//! How resource packs' Polytone particles are drawn (#11486): the rotation modes of custom
|
||||
//! particles, and GPU particles (wiki "GPU Particles"). A GPU particle never ticks: its spawn is a
|
||||
//! record (position, velocity, spawn time, lifetime, seed, colour, size, roll), and where it is,
|
||||
//! how big, what colour and which frame are a function of that record and the time. Here the
|
||||
//! function runs when the frame's particle quads are built, beside the engine's own particles.
|
||||
use super::{ParticleLevel, ParticleLightCache};
|
||||
use crate::hud::Rect;
|
||||
use crate::mesher::Vertex;
|
||||
use glam::{DVec3, Mat3, Quat, Vec3 as FVec3};
|
||||
use horizon_polytone::particles::{CustomParticle, Gpu, RenderType};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// How a particle quad turns (`rotation_mode`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) enum Orientation {
|
||||
/// `look_at_xyz`: faces the camera (what every other mode falls back to).
|
||||
#[default]
|
||||
Camera,
|
||||
/// `look_at_y`: turns around Y only.
|
||||
AroundY,
|
||||
/// `look_up`: lies flat, facing up.
|
||||
Flat,
|
||||
/// `movement_aligned`: stands along its velocity, facing the camera around it.
|
||||
Movement,
|
||||
}
|
||||
|
||||
impl Orientation {
|
||||
pub(super) fn parse(mode: &str) -> Self {
|
||||
match mode {
|
||||
"look_at_y" => Self::AroundY,
|
||||
"look_up" => Self::Flat,
|
||||
"movement_aligned" => Self::Movement,
|
||||
_ => Self::Camera,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rotation of a quad (corners in its XY plane, facing +Z) at `center` (relative to the
|
||||
/// camera) moving along `velocity`, rolled by `roll`; `camera` is the camera-facing rotation.
|
||||
pub(super) fn orient(
|
||||
mode: Orientation,
|
||||
center: FVec3,
|
||||
velocity: FVec3,
|
||||
roll: f32,
|
||||
camera: Quat,
|
||||
) -> Quat {
|
||||
let rolled = |q: Quat| q * Quat::from_rotation_z(roll);
|
||||
match mode {
|
||||
Orientation::Camera => rolled(camera),
|
||||
Orientation::AroundY => rolled(Quat::from_rotation_y((-center.x).atan2(-center.z))),
|
||||
Orientation::Flat => rolled(Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2)),
|
||||
Orientation::Movement => {
|
||||
let up = velocity.normalize_or_zero();
|
||||
let right = up.cross((-center).normalize_or_zero()).normalize_or_zero();
|
||||
if right == FVec3::ZERO {
|
||||
return rolled(camera);
|
||||
}
|
||||
Quat::from_mat3(&Mat3::from_cols(right, up, right.cross(up)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One GPU particle spawn.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Record {
|
||||
position: [f64; 3],
|
||||
velocity: [f64; 3],
|
||||
/// The engine's tick it spawned on, and its life in ticks.
|
||||
spawn: u64,
|
||||
lifetime: f32,
|
||||
seed: u64,
|
||||
rgba: [f32; 4],
|
||||
size: f32,
|
||||
roll: f32,
|
||||
}
|
||||
|
||||
/// A GPU particle type's records: a ring of `limit` slots, the oldest overwritten.
|
||||
pub(super) struct Store {
|
||||
def: Arc<CustomParticle>,
|
||||
gpu: Gpu,
|
||||
orientation: Orientation,
|
||||
/// The texture's sprite name in the particle atlas (`ns:particle/path`).
|
||||
sprite: String,
|
||||
records: Vec<Record>,
|
||||
next: usize,
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
/// The atlas name of a full texture path: `ns:textures/particle/a/b.png` is `ns:particle/a/b`
|
||||
/// (`particle/a/b` in `minecraft`).
|
||||
fn sprite_name(texture: &str) -> String {
|
||||
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
|
||||
let path = path
|
||||
.trim_start_matches("textures/")
|
||||
.trim_end_matches(".png");
|
||||
if namespace == "minecraft" {
|
||||
path.to_owned()
|
||||
} else {
|
||||
format!("{namespace}:{path}")
|
||||
}
|
||||
}
|
||||
|
||||
/// SplitMix64: a well-mixed number from `seed` and `i`, for each quad's own randoms.
|
||||
fn mix(seed: u64, i: u64) -> u64 {
|
||||
let mut z = seed ^ i.wrapping_mul(0x9E37_79B9_7F4A_7C15);
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// A number in 0..1 from `mix`.
|
||||
fn unit(seed: u64, i: u64) -> f64 {
|
||||
(mix(seed, i) >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
/// Where a record's particle is after `t` ticks, and how fast it moves: gravity pulls down,
|
||||
/// friction (`friction` kept per tick, as a continuous drag) slows it toward a terminal velocity.
|
||||
fn motion(gpu: &Gpu, position: [f64; 3], velocity: [f64; 3], t: f64) -> ([f64; 3], [f64; 3]) {
|
||||
let drag = if gpu.friction > 0.0 && gpu.friction < 1.0 {
|
||||
-gpu.friction.ln()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let gravity = [0.0, -gpu.gravity, 0.0];
|
||||
let mut at = [0.0; 3];
|
||||
let mut moving = [0.0; 3];
|
||||
for i in 0..3 {
|
||||
let (v, g) = (velocity[i], gravity[i]);
|
||||
if drag > 1.0e-9 {
|
||||
let e = (-drag * t).exp();
|
||||
at[i] = position[i] + v * (1.0 - e) / drag + g * (t / drag - (1.0 - e) / (drag * drag));
|
||||
moving[i] = v * e + g * (1.0 - e) / drag;
|
||||
} else {
|
||||
at[i] = position[i] + v * t + 0.5 * g * t * t;
|
||||
moving[i] = v + g * t;
|
||||
}
|
||||
}
|
||||
(at, moving)
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub(super) fn new(def: &Arc<CustomParticle>) -> Option<Self> {
|
||||
let gpu = def.gpu.clone()?;
|
||||
Some(Self {
|
||||
def: def.clone(),
|
||||
orientation: Orientation::parse(&def.rotation_mode),
|
||||
sprite: sprite_name(&gpu.texture),
|
||||
gpu,
|
||||
records: Vec::new(),
|
||||
next: 0,
|
||||
// The wiki's default ring capacity.
|
||||
limit: def.limit.map_or(16_384, |l| l.max(1) as usize),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn is(&self, def: &Arc<CustomParticle>) -> bool {
|
||||
Arc::ptr_eq(&self.def, def)
|
||||
}
|
||||
|
||||
/// Records a spawn from a particle its initializer set up (position, motion, life, look).
|
||||
pub(super) fn spawn(&mut self, p: &super::Particle, now: u64, seed: u64) {
|
||||
if p.lifetime <= 0 {
|
||||
return;
|
||||
}
|
||||
let record = Record {
|
||||
position: p.position,
|
||||
velocity: p.velocity,
|
||||
spawn: now,
|
||||
lifetime: p.lifetime as f32,
|
||||
seed,
|
||||
rgba: p.rgba,
|
||||
size: p.size,
|
||||
roll: p.roll,
|
||||
};
|
||||
if self.records.len() < self.limit {
|
||||
self.records.push(record);
|
||||
} else {
|
||||
self.records[self.next] = record;
|
||||
self.next = (self.next + 1) % self.limit;
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets the records whose life is over.
|
||||
pub(super) fn expire(&mut self, now: u64) {
|
||||
let before = self.records.len();
|
||||
self.records
|
||||
.retain(|r| (now.saturating_sub(r.spawn) as f32) < r.lifetime);
|
||||
if self.records.len() != before {
|
||||
self.next = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn len(&self) -> usize {
|
||||
self.records.len()
|
||||
}
|
||||
|
||||
/// The quads of every live record at `now + partial` ticks.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn render(
|
||||
&self,
|
||||
atlas: &HashMap<String, (Rect, bool)>,
|
||||
camera: DVec3,
|
||||
facing: Quat,
|
||||
now: u64,
|
||||
partial: f32,
|
||||
light_cache: &mut ParticleLightCache,
|
||||
light_at: &dyn Fn([i32; 3]) -> u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
additive: &mut Vec<Vertex>,
|
||||
) {
|
||||
if self.def.render_type == RenderType::Invisible {
|
||||
return;
|
||||
}
|
||||
let Some(&(rect, _)) = atlas.get(&self.sprite) else {
|
||||
return;
|
||||
};
|
||||
let gpu = &self.gpu;
|
||||
let translucent = matches!(
|
||||
self.def.render_type,
|
||||
RenderType::Translucent | RenderType::AdditiveTranslucent
|
||||
);
|
||||
let blend_add = self.def.render_type == RenderType::AdditiveTranslucent;
|
||||
let target = if blend_add { additive } else { out };
|
||||
let layer = if translucent && !blend_add {
|
||||
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let (count, area) = gpu.area.map_or((1, [0.0; 3]), |(size, n)| (n, size));
|
||||
let frames = gpu.frames.max(1);
|
||||
for r in &self.records {
|
||||
let t = (now.saturating_sub(r.spawn)) as f32 + partial;
|
||||
if t >= r.lifetime {
|
||||
continue;
|
||||
}
|
||||
let life = (t / r.lifetime).clamp(0.0, 1.0);
|
||||
let (at, moving) = motion(gpu, r.position, r.velocity, f64::from(t));
|
||||
// Fading in and out, and the colour and size toward their ends.
|
||||
let [fade_in, fade_out] = gpu.fade.map(|f| f as f32);
|
||||
let mut alpha = r.rgba[3];
|
||||
if fade_in > 0.0 {
|
||||
alpha *= (life / fade_in).min(1.0);
|
||||
}
|
||||
if fade_out > 0.0 {
|
||||
alpha *= ((1.0 - life) / fade_out).min(1.0);
|
||||
}
|
||||
let mut rgb = [r.rgba[0], r.rgba[1], r.rgba[2]];
|
||||
if let Some(end) = gpu.color_end {
|
||||
let channel = |shift: u32| ((end >> shift) & 0xff) as f32 / 255.0;
|
||||
let to = [channel(16), channel(8), channel(0)];
|
||||
for i in 0..3 {
|
||||
rgb[i] += (to[i] - rgb[i]) * life;
|
||||
}
|
||||
alpha += (alpha * channel(24) - alpha) * life;
|
||||
}
|
||||
let size = gpu
|
||||
.size_end
|
||||
.map_or(r.size, |end| r.size + (end as f32 - r.size) * life);
|
||||
let c = |v: f32| (v.clamp(0.0, 1.0) * 255.0) as u32;
|
||||
let color = c(alpha) << 24 | c(rgb[2]) << 16 | c(rgb[1]) << 8 | c(rgb[0]);
|
||||
let frame = if self.def.random_sprite {
|
||||
(mix(r.seed, 0) % u64::from(frames)) as u32
|
||||
} else {
|
||||
((life * frames as f32) as u32).min(frames - 1)
|
||||
};
|
||||
let (dv, v0) = ((rect.v1 - rect.v0) / frames as f32, rect.v0);
|
||||
let (u0, u1) = (rect.u0, rect.u1);
|
||||
let (v0, v1) = (v0 + dv * frame as f32, v0 + dv * (frame + 1) as f32);
|
||||
let velocity = FVec3::new(moving[0] as f32, moving[1] as f32, moving[2] as f32);
|
||||
for quad in 0..u64::from(count) {
|
||||
// Each quad of an area has its own place in the box, sway phase and roll.
|
||||
let offset: [f64; 3] = if count > 1 {
|
||||
std::array::from_fn(|i| (unit(r.seed, quad * 8 + i as u64 + 1) - 0.5) * area[i])
|
||||
} else {
|
||||
[0.0; 3]
|
||||
};
|
||||
let phase = unit(r.seed, quad * 8 + 4) * std::f64::consts::TAU;
|
||||
let sway = gpu.sway * (f64::from(t) * 0.1 + phase).sin();
|
||||
let sway_z = gpu.sway * (f64::from(t) * 0.07 + phase).cos();
|
||||
let world = [
|
||||
at[0] + offset[0] + sway,
|
||||
at[1] + offset[1],
|
||||
at[2] + offset[2] + sway_z,
|
||||
];
|
||||
let block = ParticleLevel::block_at(world);
|
||||
let packed = light_cache.get(block, light_at);
|
||||
// kill_below_heightmap: under the surface, where the sky doesn't reach.
|
||||
if gpu.kill_below_heightmap && packed >> 4 < 15 {
|
||||
continue;
|
||||
}
|
||||
let block_light = u32::from((packed & 15).max(self.def.light_level));
|
||||
let light = (u32::from(packed >> 4) * 16) | ((block_light * 16) << 8);
|
||||
let center = (DVec3::from_array(world) - camera).as_vec3();
|
||||
let roll = r.roll
|
||||
+ gpu.spin as f32 * t
|
||||
+ if count > 1 {
|
||||
(unit(r.seed, quad * 8 + 5) * std::f64::consts::TAU) as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let rotation = orient(self.orientation, center, velocity, roll, facing);
|
||||
let half = FVec3::new(size, size * gpu.aspect as f32, 0.0);
|
||||
for (n, uv) in [
|
||||
((1.0, -1.0), [u1, v1]),
|
||||
((1.0, 1.0), [u1, v0]),
|
||||
((-1.0, 1.0), [u0, v0]),
|
||||
((-1.0, -1.0), [u0, v1]),
|
||||
] {
|
||||
let corner = FVec3::new(n.0 * half.x, n.1 * half.y, 0.0);
|
||||
target.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: (center + rotation * corner).to_array(),
|
||||
uv,
|
||||
layer,
|
||||
color,
|
||||
light,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn gpu(gravity: f64, friction: f64) -> Gpu {
|
||||
Gpu {
|
||||
texture: String::new(),
|
||||
frames: 1,
|
||||
gravity,
|
||||
friction,
|
||||
size_end: None,
|
||||
color_end: None,
|
||||
fade: [0.0; 2],
|
||||
sway: 0.0,
|
||||
spin: 0.0,
|
||||
aspect: 1.0,
|
||||
area: None,
|
||||
kill_below_heightmap: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_paths_name_their_atlas_sprites() {
|
||||
assert_eq!(
|
||||
sprite_name("davidparticles:textures/particle/weather/rain.png"),
|
||||
"davidparticles:particle/weather/rain"
|
||||
);
|
||||
assert_eq!(
|
||||
sprite_name("minecraft:textures/particle/generic_7.png"),
|
||||
"particle/generic_7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_follows_gravity_and_drag() {
|
||||
// No drag: a thrown body, x = v t, y = -g t^2 / 2.
|
||||
let (at, v) = motion(&gpu(0.1, 1.0), [0.0; 3], [1.0, 0.0, 0.0], 10.0);
|
||||
assert_eq!(at, [10.0, -5.0, 0.0]);
|
||||
assert!((v[1] + 1.0).abs() < 1e-12);
|
||||
// Drag: the fall speed tends to g / drag (terminal velocity).
|
||||
let friction = 0.9;
|
||||
let (_, v) = motion(&gpu(0.1, friction), [0.0; 3], [0.0; 3], 1000.0);
|
||||
assert!((v[1] + 0.1 / -f64::ln(friction)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientations_turn_the_quad_normal() {
|
||||
let normal = |q: Quat| q * FVec3::Z;
|
||||
let close = |a: FVec3, b: FVec3| (a - b).length() < 1e-5;
|
||||
// Lying flat: facing up.
|
||||
assert!(close(
|
||||
normal(orient(
|
||||
Orientation::Flat,
|
||||
FVec3::ONE,
|
||||
FVec3::ZERO,
|
||||
0.0,
|
||||
Quat::IDENTITY
|
||||
)),
|
||||
FVec3::Y
|
||||
));
|
||||
// Around Y: facing the camera horizontally (the camera at the origin, the quad east).
|
||||
assert!(close(
|
||||
normal(orient(
|
||||
Orientation::AroundY,
|
||||
FVec3::new(5.0, 3.0, 0.0),
|
||||
FVec3::ZERO,
|
||||
0.0,
|
||||
Quat::IDENTITY
|
||||
)),
|
||||
-FVec3::X
|
||||
));
|
||||
// Along its motion: its up is the velocity, its face toward the camera.
|
||||
let q = orient(
|
||||
Orientation::Movement,
|
||||
FVec3::new(0.0, 0.0, -4.0),
|
||||
FVec3::new(0.0, -2.0, 0.0),
|
||||
0.0,
|
||||
Quat::IDENTITY,
|
||||
);
|
||||
assert!(close(q * FVec3::Y, -FVec3::Y));
|
||||
assert!(close(normal(q), FVec3::Z));
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,10 @@ use crate::{
|
||||
text::{Style, Text},
|
||||
ui::nine_slice,
|
||||
};
|
||||
use std::{sync::Mutex, time::Instant};
|
||||
use std::{
|
||||
sync::Mutex,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// `PreeditEvent`: the composition, its caret and the blocks of text the
|
||||
/// input method set apart (the one being converted is `focused_block`).
|
||||
@@ -122,16 +125,23 @@ struct Caret {
|
||||
input_height: f32,
|
||||
}
|
||||
|
||||
/// Grace window used to swallow a trailing `KeyboardInput` (such as `Enter` or
|
||||
/// `Escape`) emitted by the OS/windowing backend in the same key press right
|
||||
/// after `Ime::Preedit("", None)` or `Ime::Commit(...)` finishes composition.
|
||||
const POST_COMPOSITION_KEY_GRACE: Duration = Duration::from_millis(50);
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
/// `KeyboardHandler.lastPreeditEvent`, with when the overlay was created.
|
||||
preedit: Option<(PreeditEvent, Instant)>,
|
||||
last_ended: Option<Instant>,
|
||||
caret: Option<Caret>,
|
||||
area: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
static STATE: Mutex<State> = Mutex::new(State {
|
||||
preedit: None,
|
||||
last_ended: None,
|
||||
caret: None,
|
||||
area: None,
|
||||
});
|
||||
@@ -141,10 +151,32 @@ fn state() -> std::sync::MutexGuard<'static, State> {
|
||||
}
|
||||
|
||||
/// `KeyboardHandler.textEditing`: the input method's composition changed
|
||||
/// (`None`: it ended). A new `IMEPreeditOverlay` restarts the caret blink.
|
||||
/// (`None` or an empty string: it ended). A new `IMEPreeditOverlay` restarts
|
||||
/// the caret blink.
|
||||
pub fn set_preedit(event: Option<PreeditEvent>) {
|
||||
let mut s = state();
|
||||
s.preedit = event.map(|e| (e, Instant::now()));
|
||||
let now = Instant::now();
|
||||
let event = event.filter(|e| !e.full_text.is_empty());
|
||||
if s.preedit.is_some() && event.is_none() {
|
||||
s.last_ended = Some(now);
|
||||
}
|
||||
s.preedit = event.map(|e| (e, now));
|
||||
}
|
||||
|
||||
/// Record that an IME commit just occurred, clearing any active preedit and
|
||||
/// arming the post-composition key suppression window.
|
||||
pub fn mark_committed() {
|
||||
let mut s = state();
|
||||
s.preedit = None;
|
||||
s.last_ended = Some(Instant::now());
|
||||
}
|
||||
|
||||
/// Clear both active preedit and any pending post-composition suppression window
|
||||
/// (used when the text field itself loses focus or IME is disabled).
|
||||
pub fn reset() {
|
||||
let mut s = state();
|
||||
s.preedit = None;
|
||||
s.last_ended = None;
|
||||
}
|
||||
|
||||
/// Whether text is being composed.
|
||||
@@ -152,6 +184,22 @@ pub fn composing() -> bool {
|
||||
state().preedit.is_some()
|
||||
}
|
||||
|
||||
/// Returns `true` if a physical key press should be suppressed because IME
|
||||
/// composition is currently active or just finished in the same key press event
|
||||
/// sequence (consuming the post-composition grace token on first match).
|
||||
pub fn should_suppress_key() -> bool {
|
||||
let mut s = state();
|
||||
if s.preedit.is_some() {
|
||||
return true;
|
||||
}
|
||||
if let Some(ended) = s.last_ended.take()
|
||||
&& ended.elapsed() <= POST_COMPOSITION_KEY_GRACE
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// What the focused field's `extractRenderState` ends with: its caret is at
|
||||
/// (`x`, `y`) in GUI pixels and `input_height` tall (a line plus one).
|
||||
/// Without a composition that is the area for the candidate window; with one
|
||||
@@ -301,4 +349,48 @@ mod tests {
|
||||
.all(|(_, s)| s.underlined == Some(true))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_clear_preedit() {
|
||||
reset();
|
||||
set_preedit(PreeditEvent::from_winit("nihongo", Some((0, 7))));
|
||||
assert!(composing());
|
||||
set_preedit(None);
|
||||
assert!(!composing());
|
||||
reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_preedit_clears_composing_and_suppresses_trailing_enter_once() {
|
||||
reset();
|
||||
set_preedit(PreeditEvent::from_winit("こんにちは", Some((0, 15))));
|
||||
assert!(composing());
|
||||
assert!(should_suppress_key());
|
||||
|
||||
// OS sends Ime::Preedit("", None) when user presses Enter to confirm conversion.
|
||||
set_preedit(Some(PreeditEvent {
|
||||
full_text: String::new(),
|
||||
caret_position: 0,
|
||||
blocks: Vec::new(),
|
||||
focused_block: 0,
|
||||
}));
|
||||
assert!(!composing());
|
||||
|
||||
// The first trailing KeyboardInput(Enter) in the same key press is suppressed,
|
||||
// while a subsequent Enter press is allowed through to submit chat.
|
||||
assert!(should_suppress_key());
|
||||
assert!(!should_suppress_key());
|
||||
reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_committed_suppresses_single_trailing_key() {
|
||||
reset();
|
||||
set_preedit(PreeditEvent::from_winit("漢字", Some((0, 6))));
|
||||
mark_committed();
|
||||
assert!(!composing());
|
||||
assert!(should_suppress_key());
|
||||
assert!(!should_suppress_key());
|
||||
reset();
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,13 @@
|
||||
//! A new screen implements [`Screen`], describing its rows from the settings, and [`Shell`] does
|
||||
//! everything else: drawing, clicks, one scroll path, a controller's focus and its declared
|
||||
//! buttons, keys and each row's description (`extras_ui` is the smallest; `graphics_ui` has
|
||||
//! pages, sliders, costs and Undo). `controller_ui` and `shader_ui` still drive the parts below
|
||||
//! by hand.
|
||||
//! pages, sliders, costs and Undo; `controller_ui` has bindings, [`Value::Binding`], whose wait
|
||||
//! for a button or a key the shell runs). `shader_ui` still drives the parts below by hand.
|
||||
use crate::{
|
||||
graphics_style as style,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
lang,
|
||||
pad_glyph::{self, Glyph},
|
||||
settings::Settings,
|
||||
text::Text,
|
||||
ui,
|
||||
@@ -573,6 +574,225 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
|
||||
}
|
||||
}
|
||||
|
||||
/// A binding's reset button, at the end of its row at `y`.
|
||||
fn reset_rect(cols: &Columns, y: f32) -> [f32; 4] {
|
||||
let right = cols.list_x + cols.list_w - RESET_INSET;
|
||||
[right - RESET_W, y + RESET_INSET, RESET_W, style::CONTROL_H]
|
||||
}
|
||||
|
||||
/// What a row takes the focus and a press on: all of it but a binding's reset.
|
||||
fn row_rect<R>(row: &Row<R>, cols: &Columns, y: f32) -> [f32; 4] {
|
||||
let resets = row.binding().is_some_and(|b| b.reset.shown());
|
||||
short_of_reset(cols, y, resets)
|
||||
}
|
||||
/// The row at `y`, short of its reset button where it has one.
|
||||
fn short_of_reset(cols: &Columns, y: f32, resets: bool) -> [f32; 4] {
|
||||
let [x, y, w, h] = cols.row(y);
|
||||
let w = if resets { w - RESET_W - RESET_GAP } else { w };
|
||||
[x, y, w, h]
|
||||
}
|
||||
|
||||
/// A row's lit background while it is pointed at or focused.
|
||||
fn lit(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, _]: [f32; 4]) {
|
||||
style::fill(
|
||||
batch,
|
||||
atlas,
|
||||
[x + 2.0, y + 1.0, w - 4.0, style::ROW - 2.0],
|
||||
style::HOVER,
|
||||
);
|
||||
}
|
||||
|
||||
/// A value to read ([`Value::Read`]) at `y`: its name, and the value at the right, cut short to
|
||||
/// fit. `(label, name, value)`; `(hover, focused)`.
|
||||
fn read_row(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cols: &Columns,
|
||||
y: f32,
|
||||
(label, name, value): (&str, &str, &str),
|
||||
(hover, focused): (bool, bool),
|
||||
) {
|
||||
let rect = cols.row(y);
|
||||
crate::widgets::record(label, rect, true);
|
||||
crate::widgets::narratable(|| {
|
||||
crate::widgets::Narratable::button(label.to_owned(), rect, true)
|
||||
.hovered(hover)
|
||||
.focused(focused)
|
||||
});
|
||||
crate::widgets::target(rect);
|
||||
if hover || focused {
|
||||
lit(batch, atlas, rect);
|
||||
}
|
||||
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
|
||||
let left = rect[0] + style::INSET;
|
||||
let name_w = atlas.font.width(name);
|
||||
let shown = Text::literal(name.to_owned());
|
||||
batch.text_styled(atlas, &shown, left, ty, style::WHITE, true);
|
||||
let room = rect[2] - 2.0 * style::INSET - name_w - 8.0;
|
||||
let value = style::fit(atlas, value, room);
|
||||
let vx = rect[0] + rect[2] - style::INSET - atlas.font.width(&value);
|
||||
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::SOFT, true);
|
||||
if focused {
|
||||
style::ring(
|
||||
batch,
|
||||
atlas,
|
||||
[rect[0] + 1.0, y, rect[2] - 2.0, style::ROW],
|
||||
style::WHITE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A controller binding's row as the shell reads it ([`Shown::Pad`]).
|
||||
struct PadRow<'a> {
|
||||
/// The whole caption, for the narrator and the widget tree.
|
||||
label: &'a str,
|
||||
name: &'a str,
|
||||
/// What the strip says, for the narrator.
|
||||
hint: &'a str,
|
||||
glyph: Option<Glyph>,
|
||||
waits: bool,
|
||||
shared: bool,
|
||||
reset: Reset,
|
||||
live: bool,
|
||||
/// The row and its reset: pointed at, focused.
|
||||
hover: (bool, bool),
|
||||
focus: (bool, bool),
|
||||
}
|
||||
|
||||
/// A controller binding's row at `y`: its name, a chip with the bound button's art ("Not Bound"
|
||||
/// while nothing is), marked "> <" in yellow while it waits and "[ ]" while its button does
|
||||
/// something else too, and its reset at the end.
|
||||
fn pad_row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32, view: &PadRow) {
|
||||
let &PadRow {
|
||||
label,
|
||||
name,
|
||||
hint,
|
||||
glyph,
|
||||
waits,
|
||||
shared,
|
||||
reset,
|
||||
live,
|
||||
hover: (hover, reset_hover),
|
||||
focus: (focused, reset_focused),
|
||||
} = view;
|
||||
let rect = short_of_reset(cols, y, reset.shown());
|
||||
if live && (hover || focused) {
|
||||
lit(batch, atlas, rect);
|
||||
}
|
||||
crate::widgets::widget_cursor(hover, live);
|
||||
crate::widgets::record(label, rect, live);
|
||||
crate::widgets::narratable(|| {
|
||||
let mut n = crate::widgets::Narratable::button(label.to_owned(), rect, live)
|
||||
.hovered(hover)
|
||||
.focused(focused);
|
||||
n.hint = Some(hint.to_owned());
|
||||
n
|
||||
});
|
||||
if live {
|
||||
crate::widgets::target(rect);
|
||||
}
|
||||
// The chip: the button's art, or its name where there is no art or nothing is bound.
|
||||
let art = glyph.filter(|g| pad_glyph::art(atlas, *g).is_some());
|
||||
let text = match glyph {
|
||||
Some(g) => g.label().to_owned(),
|
||||
None => lang::get(NOT_BOUND),
|
||||
};
|
||||
let inner = match art {
|
||||
Some(g) => pad_glyph::width(atlas, g),
|
||||
None => atlas.font.width(&text),
|
||||
};
|
||||
let marks = match (waits, shared) {
|
||||
(true, _) => Some((">", "<")),
|
||||
(false, true) => Some(("[", "]")),
|
||||
(false, false) => None,
|
||||
};
|
||||
let mark_w = marks.map_or(0.0, |(open, close)| {
|
||||
atlas.font.width(open) + atlas.font.width(close) + 2.0 * PAD_MARK_GAP
|
||||
});
|
||||
let chip_w = (inner + mark_w + PAD_CHIP_PAD).max(PAD_CHIP_W);
|
||||
let chip = [
|
||||
rect[0] + rect[2] - chip_w - 2.0,
|
||||
y + (style::ROW - style::CONTROL_H) / 2.0,
|
||||
chip_w,
|
||||
style::CONTROL_H,
|
||||
];
|
||||
// Text in the chip only where there is no art, yellow with its marks.
|
||||
let face = match (art, marks) {
|
||||
(Some(_), _) => Text::literal(String::new()),
|
||||
(None, Some(_)) => Text::literal(text).with_color(crate::text::YELLOW),
|
||||
(None, None) => Text::literal(text),
|
||||
};
|
||||
style::button(batch, atlas, chip, &face, (live && hover, live, false));
|
||||
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
|
||||
let middle = chip[0] + chip[2] / 2.0;
|
||||
if let Some(g) = art {
|
||||
pad_glyph::draw(batch, atlas, g, (middle - inner / 2.0 + 1.0).floor(), ty);
|
||||
}
|
||||
if let Some((open, close)) = marks {
|
||||
let yellow = |mark: &str| Text::literal(mark.to_owned()).with_color(crate::text::YELLOW);
|
||||
let left = middle - inner / 2.0 - PAD_MARK_GAP - atlas.font.width(open);
|
||||
batch.text_styled(atlas, &yellow(open), left.floor(), ty, style::WHITE, true);
|
||||
let right = middle + inner / 2.0 + PAD_MARK_GAP;
|
||||
batch.text_styled(atlas, &yellow(close), right.ceil(), ty, style::WHITE, true);
|
||||
}
|
||||
let left = rect[0] + style::INSET;
|
||||
let color = if live { style::WHITE } else { style::MUTED };
|
||||
let shown = Text::literal(style::fit(atlas, name, chip[0] - 6.0 - left));
|
||||
batch.text_styled(atlas, &shown, left, ty, color, true);
|
||||
if focused {
|
||||
style::ring(batch, atlas, rect, style::WHITE);
|
||||
}
|
||||
if !reset.shown() {
|
||||
return;
|
||||
}
|
||||
// The reset: a target only where it would do something.
|
||||
let resets = live && reset == Reset::Changed;
|
||||
let reset = reset_rect(cols, y);
|
||||
let reset_label = lang::get("controls.reset");
|
||||
let reset_hover = resets && reset_hover;
|
||||
crate::widgets::widget_cursor(reset_hover, resets);
|
||||
crate::widgets::record(&reset_label, reset, resets);
|
||||
crate::widgets::narratable(|| {
|
||||
let what = lang::tr("narrator.controls.reset", &[&name]);
|
||||
crate::widgets::Narratable::button(what, reset, resets)
|
||||
.hovered(reset_hover)
|
||||
.focused(reset_focused)
|
||||
});
|
||||
if resets {
|
||||
crate::widgets::target(reset);
|
||||
}
|
||||
let shown = Text::literal(reset_label);
|
||||
style::button(batch, atlas, reset, &shown, (reset_hover, resets, false));
|
||||
if reset_focused {
|
||||
style::ring(batch, atlas, reset, style::WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines a screen shows under its page list (`Screen::side`), from the place of the `below`th page.
|
||||
fn side_lines(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cols: &Columns,
|
||||
below: usize,
|
||||
lines: &[String],
|
||||
) {
|
||||
let [x, y, w, _] = cols.page(below);
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let rect = [
|
||||
x + SIDE_INSET,
|
||||
y + SIDE_INSET + i as f32 * SIDE_LINE_H,
|
||||
w - 2.0 * SIDE_INSET,
|
||||
8.0,
|
||||
];
|
||||
crate::widgets::record(line, rect, false);
|
||||
let shown = Text::literal(style::fit(atlas, line, rect[2]));
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
|
||||
}
|
||||
}
|
||||
/// A line under the page list: its inset, and its height.
|
||||
const SIDE_INSET: f32 = 2.0;
|
||||
const SIDE_LINE_H: f32 = 10.0;
|
||||
|
||||
/// Vanilla's scroller, inside the panel's right edge, sized as `ui::Scroll` drags it.
|
||||
pub(crate) fn scroller(
|
||||
batch: &mut HudBatch,
|
||||
@@ -761,14 +981,122 @@ pub(crate) enum Value {
|
||||
steps: Option<u32>,
|
||||
text: String,
|
||||
},
|
||||
/// A key binding (`.0`: what the host waits on), as it reads now.
|
||||
Key(&'static str, String),
|
||||
/// A binding: a press makes the host wait for a key or a controller button
|
||||
/// ([`Output::Bind`]), and the row shows what it is bound to now ([`Binding`]).
|
||||
Binding(Binding),
|
||||
/// A value to read beside the name, which the focus reaches (unlike `Note`), so the strip and
|
||||
/// the narrator say what it is; nothing presses it.
|
||||
Read(String),
|
||||
/// A button across the row.
|
||||
Press,
|
||||
/// A line to read: nothing presses it and no focus lands on it.
|
||||
/// A line to read, which no focus reaches (unlike `Read`) and nothing presses.
|
||||
Note,
|
||||
}
|
||||
|
||||
/// What a binding row waits for once pressed, by the name the host binds.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Capture {
|
||||
/// A key or a mouse button, as on Key Binds (`OptionsUi::selected_key`): Escape unbinds.
|
||||
Key(&'static str),
|
||||
/// A controller button for a gameplay action (`OptionsUi::selected_pad_action`): the game binds
|
||||
/// the next button pressed, Start cancels, and the keyboard's Escape unbinds, as on Key Binds.
|
||||
Pad(&'static str),
|
||||
}
|
||||
|
||||
/// What a binding row is bound to now.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum Shown {
|
||||
/// The key's name, as Key Binds shows it.
|
||||
Key(String),
|
||||
/// The controller button's glyph, drawn as its art; `None` while nothing is bound. `cancel`
|
||||
/// is the button that cancels the wait (`MenuJob::Start`), named in the strip meanwhile.
|
||||
Pad { glyph: Option<Glyph>, cancel: Glyph },
|
||||
}
|
||||
|
||||
/// A binding row's value ([`Value::Binding`]). The screen says what it binds and what it is bound
|
||||
/// to; the shell starts the wait, shows it ("> A <"), draws the button, says in the strip that
|
||||
/// it isn't bound or what else its button does, and draws the row's reset.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Binding {
|
||||
pub(crate) capture: Capture,
|
||||
pub(crate) shown: Shown,
|
||||
/// What else the same button does, by name: shown in the strip, and the button in yellow.
|
||||
pub(crate) shared: Vec<String>,
|
||||
/// Its reset button at the row's end (`Screen::reset`).
|
||||
pub(crate) reset: Reset,
|
||||
}
|
||||
/// A binding row's reset button.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Reset {
|
||||
/// The row has none (a key, as Key Binds lists it).
|
||||
None,
|
||||
/// Greyed: the binding is its default already.
|
||||
Default,
|
||||
/// Live: the binding differs from its default.
|
||||
Changed,
|
||||
}
|
||||
impl Reset {
|
||||
/// The row has a reset button, live or not.
|
||||
fn shown(self) -> bool {
|
||||
self != Reset::None
|
||||
}
|
||||
}
|
||||
impl Binding {
|
||||
/// A key's binding, as Key Binds lists it: no reset of its own.
|
||||
pub(crate) fn key(name: &'static str, shown: String) -> Self {
|
||||
Self {
|
||||
capture: Capture::Key(name),
|
||||
shown: Shown::Key(shown),
|
||||
shared: Vec::new(),
|
||||
reset: Reset::None,
|
||||
}
|
||||
}
|
||||
/// What it reads as, for the narrator and the widget tree: a pad's shared button in brackets,
|
||||
/// as the row draws it in yellow.
|
||||
fn text(&self) -> String {
|
||||
match &self.shown {
|
||||
Shown::Key(text) => text.clone(),
|
||||
Shown::Pad { glyph, .. } => {
|
||||
let name = glyph.map_or_else(|| lang::get(NOT_BOUND), |g| g.label().to_owned());
|
||||
if self.shared.is_empty() {
|
||||
name
|
||||
} else {
|
||||
format!("[ {name} ]")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What the strip says about it while `waiting`, else when it isn't bound or shares its
|
||||
/// button; `None` leaves the row's own description.
|
||||
fn note(&self, waiting: bool) -> Option<String> {
|
||||
match (&self.shown, waiting) {
|
||||
(Shown::Pad { cancel, .. }, true) => Some(lang::tr(WAITING_PAD, &[&cancel.label()])),
|
||||
(Shown::Pad { glyph: None, .. }, false) => Some(lang::get(UNBOUND)),
|
||||
_ if !self.shared.is_empty() => Some(lang::tr(SHARED, &[&self.shared.join(", ")])),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Vanilla's word for a key bound to nothing.
|
||||
const NOT_BOUND: &str = "key.keyboard.unknown";
|
||||
/// The strip while a controller button is awaited, naming the one that cancels.
|
||||
const WAITING_PAD: &str = "Press the button for this. %s cancels.";
|
||||
/// The strip over a binding with no button.
|
||||
const UNBOUND: &str = "Not bound: press this row, then the button that should do it.";
|
||||
/// The strip over a binding whose button does other things too.
|
||||
const SHARED: &str = "This button also does: %s";
|
||||
/// A pad binding's chip, at least: wide enough for most labels in vanilla's font.
|
||||
const PAD_CHIP_W: f32 = 56.0;
|
||||
/// A pad binding's chip around its art or text, and the room for the marks ("> <", "[ ]").
|
||||
const PAD_CHIP_PAD: f32 = 8.0;
|
||||
/// Clear of the art's edge, which its sprite draws past (`pad_glyph::rect`).
|
||||
const PAD_MARK_GAP: f32 = 5.0;
|
||||
/// A binding's reset button at the row's end, and the gap left before it.
|
||||
const RESET_W: f32 = 36.0;
|
||||
const RESET_GAP: f32 = 4.0;
|
||||
/// The reset button's inset from the list's right edge.
|
||||
const RESET_INSET: f32 = 2.0;
|
||||
|
||||
/// A row as a screen describes it, built afresh from the settings each time it is read.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Row<R> {
|
||||
@@ -841,12 +1169,20 @@ impl<R> Row<R> {
|
||||
} else {
|
||||
lang::get_or("Off", "Off")
|
||||
}),
|
||||
Value::Choice(text) | Value::Slider { text, .. } | Value::Key(_, text) => {
|
||||
Value::Choice(text) | Value::Slider { text, .. } | Value::Read(text) => {
|
||||
Some(text.clone())
|
||||
}
|
||||
Value::Binding(binding) => Some(binding.text()),
|
||||
Value::Press | Value::Note => None,
|
||||
}
|
||||
}
|
||||
/// Its binding, for a binding row.
|
||||
fn binding(&self) -> Option<&Binding> {
|
||||
match &self.value {
|
||||
Value::Binding(binding) => Some(binding),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows under a group's name (a language key).
|
||||
@@ -869,8 +1205,8 @@ pub(crate) struct Sheet<P, R> {
|
||||
pub(crate) enum Output {
|
||||
/// Passed on to the game as it is.
|
||||
Event(crate::options_ui::Event),
|
||||
/// Wait for a key or a mouse button to bind to this name.
|
||||
BindKey(&'static str),
|
||||
/// Wait for what a binding row binds: a key or a mouse button, or a controller button.
|
||||
Bind(Capture),
|
||||
/// Open another options page over this one.
|
||||
Push(crate::options_ui::Page),
|
||||
/// Leave the screen.
|
||||
@@ -911,6 +1247,14 @@ pub(crate) trait Screen {
|
||||
) -> Option<Vec<Output>> {
|
||||
None
|
||||
}
|
||||
/// A binding row's reset was pressed: its binding goes back to its default.
|
||||
fn reset(&mut self, _s: &mut Settings, _row: Self::Row) -> Vec<Output> {
|
||||
Vec::new()
|
||||
}
|
||||
/// Lines under the page list: whose settings these are, say.
|
||||
fn side(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
/// Whether the screen has Undo, which puts back what it changed (`undo`) since it opened.
|
||||
fn undoes(&self) -> bool {
|
||||
false
|
||||
@@ -921,6 +1265,13 @@ pub(crate) trait Screen {
|
||||
fn changes(&self, outputs: &[Output]) -> bool {
|
||||
changes_settings(outputs)
|
||||
}
|
||||
/// Whether what the screen changes differs now from `before`, for a screen that can tell:
|
||||
/// Undo then shows exactly while it would do something, a binding the host made (the game
|
||||
/// binds a controller button) included, and a value stepped back isn't a change. `None`
|
||||
/// leaves it to `changes`.
|
||||
fn differs(&self, _s: &Settings, _before: &Settings) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `outputs` say a setting changed, or a key is being bound to one.
|
||||
@@ -929,7 +1280,7 @@ pub(crate) fn changes_settings(outputs: &[Output]) -> bool {
|
||||
let changed = |output: &Output| {
|
||||
matches!(
|
||||
output,
|
||||
Output::BindKey(_) | Output::Event(Event::Changed | Event::SliderDrag)
|
||||
Output::Bind(_) | Output::Event(Event::Changed | Event::SliderDrag)
|
||||
)
|
||||
};
|
||||
outputs.iter().any(changed)
|
||||
@@ -956,7 +1307,7 @@ pub(crate) fn stamp_of(parts: &impl std::fmt::Debug) -> u64 {
|
||||
pub(crate) trait Hosted {
|
||||
/// The screen opens, afresh.
|
||||
fn open(&mut self);
|
||||
/// `waiting`: the key binding the host waits on.
|
||||
/// `waiting`: the binding the host waits on.
|
||||
fn draw(
|
||||
&mut self,
|
||||
s: &Settings,
|
||||
@@ -964,7 +1315,7 @@ pub(crate) trait Hosted {
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
in_world: bool,
|
||||
waiting: Option<&'static str>,
|
||||
waiting: Option<Capture>,
|
||||
);
|
||||
/// A press (or a drag with `drag`) at `at`.
|
||||
fn click(
|
||||
@@ -1023,6 +1374,8 @@ pub(crate) trait Hosted {
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum Target<P, R> {
|
||||
Row(R),
|
||||
/// A binding row's reset button.
|
||||
Reset(R),
|
||||
Page(P),
|
||||
Search,
|
||||
Undo,
|
||||
@@ -1071,7 +1424,7 @@ pub(crate) struct Shell<S: Screen> {
|
||||
page: Option<S::Page>,
|
||||
/// While searching, the page last picked in the page list: current until the list scrolls.
|
||||
picked: Option<S::Page>,
|
||||
waiting: Option<&'static str>,
|
||||
waiting: Option<Capture>,
|
||||
/// The rows as last built, and what for.
|
||||
built: Option<(Built<S::Page, S::Row>, Rc<View<S::Page, S::Row>>)>,
|
||||
/// The settings as the screen found them, for Undo (`Screen::undoes`), and whether it
|
||||
@@ -1150,10 +1503,15 @@ impl<S: Screen> Shell<S> {
|
||||
self.changed = true;
|
||||
}
|
||||
}
|
||||
/// Remember the settings before the first change, for Undo.
|
||||
/// Remember the settings before the first change, for Undo, and ask a screen that can tell
|
||||
/// whether they differ now (`Screen::differs`).
|
||||
fn keep(&mut self, s: &Settings) {
|
||||
if self.screen.undoes() && self.before.is_none() {
|
||||
self.before = Some(Box::new(s.clone()));
|
||||
if !self.screen.undoes() {
|
||||
return;
|
||||
}
|
||||
let before = self.before.get_or_insert_with(|| Box::new(s.clone()));
|
||||
if let Some(differs) = self.screen.differs(s, before) {
|
||||
self.changed = differs;
|
||||
}
|
||||
}
|
||||
/// Undo, gone once it has put the settings back.
|
||||
@@ -1263,22 +1621,21 @@ impl<S: Screen> Shell<S> {
|
||||
let lines = view.lines.iter();
|
||||
lines.filter_map(move |l| Some((&view.rows[l.setting?], top + l.y - scroll)))
|
||||
}
|
||||
/// The row under the pointer, inside the list's view.
|
||||
/// The row under the pointer, inside the list's view, and where it is.
|
||||
fn row_at<'a>(
|
||||
&self,
|
||||
view: &'a View<S::Page, S::Row>,
|
||||
(x, y): (f32, f32),
|
||||
) -> Option<&'a Row<S::Row>> {
|
||||
) -> Option<(&'a Row<S::Row>, f32)> {
|
||||
let cols = &view.cols;
|
||||
if y < cols.top || y >= cols.bottom {
|
||||
return None;
|
||||
}
|
||||
let mut placed = self.placed(view);
|
||||
let hit = placed.find(|&(_, ry)| {
|
||||
placed.find(|&(_, ry)| {
|
||||
let [rx, ry, rw, rh] = cols.row(ry);
|
||||
ui::hovered((x, y), rx, ry, rw, rh)
|
||||
});
|
||||
hit.map(|(row, _)| row)
|
||||
})
|
||||
}
|
||||
/// Press `row`: `track` is where on its slider's track (`None` from the keyboard, or off it).
|
||||
fn activate(
|
||||
@@ -1289,7 +1646,14 @@ impl<S: Screen> Shell<S> {
|
||||
track: Option<f32>,
|
||||
) -> Vec<Output> {
|
||||
match row.value {
|
||||
Value::Note => Vec::new(),
|
||||
Value::Note | Value::Read(_) => Vec::new(),
|
||||
// The shell, not the screen, starts the wait.
|
||||
Value::Binding(ref binding) => {
|
||||
self.can_change_value = false;
|
||||
let outputs = vec![CLICK, Output::Bind(binding.capture)];
|
||||
self.did(&outputs);
|
||||
outputs
|
||||
}
|
||||
Value::Slider { .. } => {
|
||||
self.can_change_value = true;
|
||||
let Some(position) = track else {
|
||||
@@ -1322,13 +1686,18 @@ impl<S: Screen> Shell<S> {
|
||||
out.push((Target::Page(page), cols.page(i)));
|
||||
}
|
||||
}
|
||||
let rows: Vec<(S::Row, f32, bool)> = self
|
||||
let rows: Vec<(&Row<S::Row>, f32, bool)> = self
|
||||
.placed(view)
|
||||
.map(|(row, y)| (row.id, y, row.live))
|
||||
.map(|(row, y)| (row, y, row.live))
|
||||
.collect();
|
||||
for &(id, y, live) in &rows[reach(&rows, cols, near)] {
|
||||
if live {
|
||||
out.push((Target::Row(id), cols.row(y)));
|
||||
for &(row, y, live) in &rows[reach(&rows, cols, near)] {
|
||||
if !live {
|
||||
continue;
|
||||
}
|
||||
out.push((Target::Row(row.id), row_rect(row, cols, y)));
|
||||
// A binding's reset, right of it, only while it would do something.
|
||||
if row.binding().is_some_and(|b| b.reset == Reset::Changed) {
|
||||
out.push((Target::Reset(row.id), reset_rect(cols, y)));
|
||||
}
|
||||
}
|
||||
if self.changed {
|
||||
@@ -1363,17 +1732,37 @@ impl<S: Screen> Shell<S> {
|
||||
let row = |id| view.rows.iter().find(|r| r.id == id);
|
||||
self.can_change_value = matches!(target, Target::Row(id)
|
||||
if row(id).is_some_and(|r| matches!(r.value, Value::Slider { .. })));
|
||||
if matches!(target, Target::Row(_)) {
|
||||
if matches!(target, Target::Row(_) | Target::Reset(_)) {
|
||||
scroll_into_view(&mut self.scroll, rect, &view.cols, view.content);
|
||||
self.picked = None;
|
||||
}
|
||||
}
|
||||
/// A binding row's reset: the binding goes back to its default and, as the reset goes away
|
||||
/// with it, the focus to its row.
|
||||
fn reset(&mut self, s: &mut Settings, row: S::Row) -> Vec<Output> {
|
||||
let mut outputs = vec![CLICK];
|
||||
outputs.extend(self.screen.reset(s, row));
|
||||
self.did(&outputs);
|
||||
self.focus = Some(Target::Row(row));
|
||||
outputs
|
||||
}
|
||||
/// The search changed: the list starts over at its top.
|
||||
fn searched(&mut self) {
|
||||
self.scroll.amount = 0.0;
|
||||
self.picked = None;
|
||||
}
|
||||
/// A row of the list at `y`.
|
||||
/// What the strip and the narrator say about `row`: a binding's wait, its missing button or
|
||||
/// its shared one, else what the row does.
|
||||
fn strip_text(&self, row: &Row<S::Row>) -> String {
|
||||
let note = row.binding().and_then(|b| b.note(self.waits(row)));
|
||||
note.unwrap_or_else(|| row.described().to_owned())
|
||||
}
|
||||
/// Whether the host waits on `row`'s binding.
|
||||
fn waits(&self, row: &Row<S::Row>) -> bool {
|
||||
row.binding()
|
||||
.is_some_and(|b| self.waiting == Some(b.capture))
|
||||
}
|
||||
/// A row of the list at `y`; `pointer`: the cursor, while it is over the list.
|
||||
fn draw_row(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
@@ -1381,7 +1770,7 @@ impl<S: Screen> Shell<S> {
|
||||
cols: &Columns,
|
||||
row: &Row<S::Row>,
|
||||
y: f32,
|
||||
hover: bool,
|
||||
pointer: Option<(f32, f32)>,
|
||||
) {
|
||||
if row.value == Value::Note {
|
||||
let rect = cols.row(y);
|
||||
@@ -1392,11 +1781,39 @@ impl<S: Screen> Shell<S> {
|
||||
batch.text_styled(atlas, &name, rect[0] + style::INSET, ty, style::MUTED, true);
|
||||
return;
|
||||
}
|
||||
let waits = matches!(row.value, Value::Key(key, _) if self.waiting == Some(key));
|
||||
let over = |[x, y, w, h]: [f32; 4]| pointer.is_some_and(|at| ui::hovered(at, x, y, w, h));
|
||||
let hover = over(row_rect(row, cols, y));
|
||||
let waits = self.waits(row);
|
||||
let value = row
|
||||
.value_text()
|
||||
.map(|text| if waits { format!("> {text} <") } else { text });
|
||||
let label = row.label(value.as_deref());
|
||||
let focused = self.shows_focus(Target::Row(row.id));
|
||||
let hint = self.strip_text(row);
|
||||
if let Value::Read(value) = &row.value {
|
||||
let view = (label.as_str(), row.name.as_str(), value.as_str());
|
||||
read_row(batch, atlas, cols, y, view, (hover, focused));
|
||||
crate::widgets::last_narratable(|n| n.hint = Some(hint.clone()));
|
||||
return;
|
||||
}
|
||||
if let Some(binding) = row.binding()
|
||||
&& let Shown::Pad { glyph, .. } = binding.shown
|
||||
{
|
||||
let view = PadRow {
|
||||
label: &label,
|
||||
name: &row.name,
|
||||
hint: &hint,
|
||||
glyph,
|
||||
waits,
|
||||
shared: !binding.shared.is_empty(),
|
||||
reset: binding.reset,
|
||||
live: row.live,
|
||||
hover: (hover, over(reset_rect(cols, y))),
|
||||
focus: (focused, self.shows_focus(Target::Reset(row.id))),
|
||||
};
|
||||
pad_row(batch, atlas, cols, y, &view);
|
||||
return;
|
||||
}
|
||||
let view = RowView {
|
||||
label: &label,
|
||||
name: &row.name,
|
||||
@@ -1411,15 +1828,15 @@ impl<S: Screen> Shell<S> {
|
||||
},
|
||||
live: row.live,
|
||||
hover,
|
||||
focused: self.shows_focus(Target::Row(row.id)),
|
||||
focused,
|
||||
held: self.dragging == Some(row.id),
|
||||
key: matches!(row.value, Value::Key(..)).then_some(waits),
|
||||
key: row.binding().map(|_| waits),
|
||||
};
|
||||
self::row(batch, atlas, cols, y, &view);
|
||||
if row.live && matches!(row.value, Value::Choice(_)) {
|
||||
crate::widgets::steps_value(cols.row(y));
|
||||
}
|
||||
crate::widgets::last_narratable(|n| n.hint = Some(row.described().to_owned()));
|
||||
crate::widgets::last_narratable(|n| n.hint = Some(hint.clone()));
|
||||
}
|
||||
/// The strip under the plate: the hovered or focused row's name and what it does; with none,
|
||||
/// what the open page holds.
|
||||
@@ -1438,9 +1855,10 @@ impl<S: Screen> Shell<S> {
|
||||
style::panel(batch, atlas, cols.dock);
|
||||
let row = described.map(|i| &view.rows[i]);
|
||||
let why = row.and_then(|row| row.why.as_deref().filter(|_| !row.live));
|
||||
let (name, about) = match row {
|
||||
Some(row) => (row.name.as_str(), why.unwrap_or(row.described())),
|
||||
None => {
|
||||
let said = row.map(|row| self.strip_text(row));
|
||||
let (name, about) = match (row, &said) {
|
||||
(Some(row), Some(said)) => (row.name.as_str(), why.unwrap_or(said)),
|
||||
_ => {
|
||||
let Some(page) = view.pages.iter().find(|p| Some(p.0) == current) else {
|
||||
return;
|
||||
};
|
||||
@@ -1484,7 +1902,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
in_world: bool,
|
||||
waiting: Option<&'static str>,
|
||||
waiting: Option<Capture>,
|
||||
) {
|
||||
self.waiting = waiting;
|
||||
self.keep(s);
|
||||
@@ -1502,6 +1920,8 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
let name = &view.pages[i].1;
|
||||
page_tab(batch, atlas, cols.page(i), name, flags, cursor);
|
||||
}
|
||||
let below = self.tabs(&view).len();
|
||||
side_lines(batch, atlas, &cols, below, &self.screen.side());
|
||||
let focused = self.focus == Some(Target::Search);
|
||||
search_field(&mut self.search, batch, atlas, &cols, cursor, focused);
|
||||
let name_of = |page| view.pages.iter().find(|p| p.0 == page).map(|p| p.1.clone());
|
||||
@@ -1514,6 +1934,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
self::plate(batch, atlas, &cols, &heading);
|
||||
let over_list = cursor.1 >= cols.top && cursor.1 < cols.bottom;
|
||||
let mut described = None;
|
||||
let mut waiting = None;
|
||||
self::list(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -1525,19 +1946,23 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
let row = &view.rows[i];
|
||||
let [rx, ry, rw, rh] = cols.row(y);
|
||||
let hover = over_list && ui::hovered(cursor, rx, ry, rw, rh);
|
||||
let focused = self.shows_focus(Target::Row(row.id));
|
||||
// The pointer's row wins over the keyboard's.
|
||||
let focused = self.shows_focus(Target::Row(row.id))
|
||||
|| self.shows_focus(Target::Reset(row.id));
|
||||
// A wait is what the strip says; then the pointer's row wins over the keyboard's.
|
||||
if self.waits(row) {
|
||||
waiting = Some(i);
|
||||
}
|
||||
if hover || self.dragging == Some(row.id) || (focused && described.is_none()) {
|
||||
described = Some(i);
|
||||
}
|
||||
self.draw_row(batch, atlas, &cols, row, y, hover);
|
||||
self.draw_row(batch, atlas, &cols, row, y, over_list.then_some(cursor));
|
||||
},
|
||||
);
|
||||
scroller(batch, atlas, &cols, view.content, self.scroll.amount);
|
||||
if let Some((_, row)) = self.first_row(&view) {
|
||||
crate::widgets::focus_link(cols.search(), true, row);
|
||||
}
|
||||
self.draw_dock(batch, atlas, &view, described, current);
|
||||
self.draw_dock(batch, atlas, &view, waiting.or(described), current);
|
||||
// Undo, over Done, only once there is something to undo.
|
||||
if self.changed {
|
||||
let undo = cols.button(1);
|
||||
@@ -1624,10 +2049,14 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
return vec![CLICK];
|
||||
}
|
||||
}
|
||||
let Some(row) = self.row_at(&view, (x, y)).filter(|row| row.live) else {
|
||||
let Some((row, row_y)) = self.row_at(&view, (x, y)).filter(|(row, _)| row.live) else {
|
||||
return Vec::new();
|
||||
};
|
||||
self.focus = Some(Target::Row(row.id));
|
||||
if row.binding().is_some_and(|b| b.reset == Reset::Changed) && at(reset_rect(&cols, row_y))
|
||||
{
|
||||
return self.reset(s, row.id);
|
||||
}
|
||||
// A press on the track sets the value there; on the name, only focuses.
|
||||
let track = (x >= cols.track().0).then(|| cols.slider_at(x));
|
||||
self.activate(s, row, shift, track)
|
||||
@@ -1661,7 +2090,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
) -> Vec<Output> {
|
||||
if amount != 0.0 && s.extras.wheel_cycles_settings {
|
||||
let view = self.view(s, size);
|
||||
if let Some(row) = self.row_at(&view, at).filter(|row| row.live)
|
||||
if let Some((row, _)) = self.row_at(&view, at).filter(|(row, _)| row.live)
|
||||
&& let Some(outputs) = self.screen.wheel_step(s, row.id, amount > 0.0)
|
||||
{
|
||||
self.did(&outputs);
|
||||
@@ -1783,6 +2212,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
Some(row) => self.activate(s, row, shift, None),
|
||||
None => Vec::new(),
|
||||
},
|
||||
Some(Target::Reset(row)) => self.reset(s, row),
|
||||
Some(Target::Undo) => {
|
||||
// Undo goes away with the change it undid: Done has the focus.
|
||||
self.focus = Some(Target::Done);
|
||||
@@ -1919,6 +2349,11 @@ impl<S: Screen> Shell<S> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The names of the rows the list has now.
|
||||
pub(crate) fn row_names(&mut self, s: &Settings) -> Vec<String> {
|
||||
let view = self.view(s, (854.0, 480.0));
|
||||
view.rows.iter().map(|r| r.name.clone()).collect()
|
||||
}
|
||||
/// The captions of the rows the list has now, as the widget tree reads them.
|
||||
pub(crate) fn row_labels(&mut self, s: &Settings) -> Vec<String> {
|
||||
let view = self.view(s, (854.0, 480.0));
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
//! game fills the list when the page opens (`filled`), takes the choice, and says why a pick
|
||||
//! can't play (`refused`). Built from vanilla's widgets.
|
||||
use crate::{
|
||||
gamepad::{Button as PadButton, GlyphStyle},
|
||||
gamepad::MenuJob,
|
||||
graphics_style,
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
lang, pad_glyph,
|
||||
lang,
|
||||
pad_glyph::{self, Glyph, Glyphs},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
@@ -243,7 +244,7 @@ impl SplitProfiles {
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
in_world: bool,
|
||||
style: GlyphStyle,
|
||||
glyphs: Glyphs,
|
||||
) {
|
||||
let (w, h) = (batch.width.ceil(), batch.height.ceil());
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
@@ -306,7 +307,7 @@ impl SplitProfiles {
|
||||
None => tr("horizon.pages.split_profiles", "Who's playing?"),
|
||||
};
|
||||
graphics_style::title(batch, atlas, &Text::literal(title));
|
||||
self.draw_bumpers(batch, atlas, style);
|
||||
self.draw_bumpers(batch, atlas, glyphs);
|
||||
// Under Cancel: where offline profiles live.
|
||||
let y = buttons.last().map_or(0.0, |b| b.0[1] + 30.0);
|
||||
line(
|
||||
@@ -400,7 +401,7 @@ impl SplitProfiles {
|
||||
|
||||
/// Under the title: the bumpers as keys, as the prompts over a player's view draw them (the
|
||||
/// controller's own symbols, in its colours), then where they put the view.
|
||||
fn draw_bumpers(&self, batch: &mut HudBatch, atlas: &HudAtlas, style: GlyphStyle) {
|
||||
fn draw_bumpers(&self, batch: &mut HudBatch, atlas: &HudAtlas, glyphs: Glyphs) {
|
||||
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
|
||||
let words = match &self.place {
|
||||
Some(place) => tr(
|
||||
@@ -410,14 +411,14 @@ impl SplitProfiles {
|
||||
.replacen("%s", place, 1),
|
||||
None => tr("horizon.splitscreen.profiles.move", "Move where you play"),
|
||||
};
|
||||
let keys = [PadButton::LeftBumper, PadButton::RightBumper];
|
||||
let key_width = |b: PadButton| pad_glyph::width(atlas, b, style);
|
||||
let keys = [MenuJob::PagePrev, MenuJob::PageNext].map(|job| Glyph::menu(job, glyphs));
|
||||
let key_width = |b: Glyph| pad_glyph::width(atlas, b);
|
||||
let total = keys.iter().map(|&b| key_width(b) + 4.0).sum::<f32>() + 2.0 + width(&words);
|
||||
let (mut x, y) = (((batch.width - total) / 2.0).floor(), 30.0);
|
||||
for b in keys {
|
||||
let kw = key_width(b);
|
||||
// A light edge around a dark key: it stands out on the menu's dark background.
|
||||
if pad_glyph::art(atlas, b, style).is_none() {
|
||||
if pad_glyph::art(atlas, b).is_none() {
|
||||
batch.fill(
|
||||
atlas,
|
||||
x - 2.0,
|
||||
@@ -427,7 +428,7 @@ impl SplitProfiles {
|
||||
rgba(170, 170, 170, 255),
|
||||
);
|
||||
}
|
||||
pad_glyph::draw(batch, atlas, b, style, x, y);
|
||||
pad_glyph::draw(batch, atlas, b, x, y);
|
||||
x += kw + 4.0;
|
||||
}
|
||||
batch.text(atlas, &words, x + 2.0, y, rgba(240, 240, 240, 255), true);
|
||||
|
||||
@@ -12,7 +12,10 @@ struct StaticEntityUniform {
|
||||
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>,
|
||||
@location(7) section_origin: vec3<i32>) -> Out {
|
||||
let block_delta = section_origin - static_entity.camera_block.xyz;
|
||||
// [world warp static entity]
|
||||
// (a world warp's pipelines put the vertex through the mod's `world_warp`: gfx/world_warp.rs)
|
||||
let camera_relative = vec3<f32>(block_delta) + position - static_entity.camera_fraction.xyz;
|
||||
// [end world warp static entity]
|
||||
var out = entity_vertex(camera_relative,
|
||||
uv, overlay & 0xffffu, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
|
||||
@@ -2258,10 +2258,14 @@ impl Streaming {
|
||||
world.dirty_light.extend(columns);
|
||||
}
|
||||
/// Sections whose meshes sample a changed light value (faces and corners
|
||||
/// read light from neighbouring blocks).
|
||||
/// read light from neighbouring blocks). Every column whose stored light
|
||||
/// changed is saved again (`ChunkHolder.sectionLightChanged` marks the
|
||||
/// chunk unsaved): an edit's light reaches up to 15 blocks into columns
|
||||
/// the edit itself did not mark, which may already have been saved.
|
||||
fn mark_light_changes(world: &mut World, changed: &LightChanges) {
|
||||
if let Some(log) = &mut world.light_log {
|
||||
for (x, sy, z) in changed.sections() {
|
||||
for (x, sy, z) in changed.sections() {
|
||||
world.modified.insert((x, z));
|
||||
if let Some(log) = &mut world.light_log {
|
||||
log.entry((x, z)).or_default().insert(sy as usize);
|
||||
}
|
||||
}
|
||||
@@ -4363,6 +4367,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An edit's light change in a neighbouring column marks that column
|
||||
/// unsaved too, or a column saved before the edit keeps its old light on
|
||||
/// disk.
|
||||
#[test]
|
||||
fn light_spilling_into_a_saved_neighbour_marks_it_unsaved() {
|
||||
use crate::light::tests::{AIR, TORCH, tables, world};
|
||||
let tables = tables();
|
||||
let mut w = World::new(horizon_world::registry::BlockStateId(0), 0);
|
||||
for (key, chunk) in world(|c| {
|
||||
c.set(14, 0, 8, TORCH);
|
||||
}) {
|
||||
w.chunks.insert(key, chunk);
|
||||
}
|
||||
assert!(w.modified.is_empty());
|
||||
Arc::make_mut(w.chunks.get_mut(&(0, 0)).unwrap()).set(14, 0, 8, AIR);
|
||||
let mut changed = crate::light::LightChanges::default();
|
||||
crate::light::relight(
|
||||
&mut w.chunks,
|
||||
&tables,
|
||||
true,
|
||||
&[(14, 0, 8)],
|
||||
false,
|
||||
&mut changed,
|
||||
);
|
||||
assert_eq!(w.chunks[&(1, 0)].light(0, 0, 8) & 15, 0); // was 12
|
||||
Streaming::mark_light_changes(&mut w, &changed);
|
||||
assert!(w.modified.contains(&(1, 0)));
|
||||
assert!(w.modified.contains(&(0, 0)));
|
||||
// The torch never reached x -1: that column's light is unchanged.
|
||||
assert!(!w.modified.contains(&(-1, 0)));
|
||||
}
|
||||
|
||||
fn resident_column(world: &mut World, light: u8) -> Chunk {
|
||||
let mut chunk = Chunk::empty(2, -3);
|
||||
chunk.set(1, 10, 1, 1);
|
||||
|
||||
@@ -224,6 +224,10 @@ struct TerrainOut {
|
||||
// The camera-relative height, for the water mirror's cut at the plane.
|
||||
height: f32,
|
||||
emissive_layer: u32,
|
||||
// [world warp varyings]
|
||||
// (a native mod's world warp adds its own here, and in `TerrainWire`, `to_wire` and
|
||||
// `from_wire`: gfx/world_warp.rs)
|
||||
// [end world warp varyings]
|
||||
}
|
||||
// `TerrainOut` in 5 interpolant locations instead of 12. A GPU's vertex stage pays for each
|
||||
// location it exports (AMD's parameter cache gives one a whole slot, however few of its four
|
||||
@@ -242,6 +246,8 @@ struct TerrainWire {
|
||||
@location(3) d: vec4<f32>,
|
||||
// `layer`, `emission`, `fade` and `emissive_layer`.
|
||||
@location(4) @interpolate(flat) e: vec4<u32>,
|
||||
// [world warp wire]
|
||||
// [end world warp wire]
|
||||
}
|
||||
fn to_wire(o: TerrainOut) -> TerrainWire {
|
||||
var w: TerrainWire;
|
||||
@@ -251,6 +257,8 @@ fn to_wire(o: TerrainOut) -> TerrainWire {
|
||||
w.c = vec4(o.uv, o.scene);
|
||||
w.d = vec4(o.spherical, o.cylindrical, o.height, 0.0);
|
||||
w.e = vec4(o.layer, o.emission, o.fade, o.emissive_layer);
|
||||
// [world warp to wire]
|
||||
// [end world warp to wire]
|
||||
return w;
|
||||
}
|
||||
fn from_wire(w: TerrainWire) -> TerrainOut {
|
||||
@@ -268,6 +276,8 @@ fn from_wire(w: TerrainWire) -> TerrainOut {
|
||||
o.emission = w.e.y;
|
||||
o.fade = w.e.z;
|
||||
o.emissive_layer = w.e.w;
|
||||
// [world warp from wire]
|
||||
// [end world warp from wire]
|
||||
return o;
|
||||
}
|
||||
// Whether a fading tile or section leaves this pixel out (gfx/lod.rs's
|
||||
@@ -662,7 +672,10 @@ fn hdr_facing() -> f32 {
|
||||
}
|
||||
fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>, origin: vec3<f32>, w: f32) -> TerrainOut {
|
||||
// [world warp vertex]
|
||||
// (a world warp's pipelines put the point through the mod's `world_warp`: gfx/world_warp.rs)
|
||||
let pos = position + origin;
|
||||
// [end world warp vertex]
|
||||
var out: TerrainOut;
|
||||
out.clip = fade_depth(globals.view_proj * vec4(pos, 1.0), w);
|
||||
out.local = position;
|
||||
@@ -695,6 +708,8 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
|
||||
const CHUNK_FULLY_VISIBLE_RANGE: f32 = 16.0;
|
||||
out.visibility = mix(1.0, instance_visibility(w),
|
||||
clamp((length(pos) - CHUNK_FULLY_VISIBLE_RANGE) / CHUNK_FULLY_VISIBLE_RANGE, 0.0, 1.0));
|
||||
// [world warp out]
|
||||
// [end world warp out]
|
||||
return out;
|
||||
}
|
||||
fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
|
||||
@@ -9,6 +9,9 @@ use horizon_core::random::LegacyRandom;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod modified;
|
||||
pub use modified::ModifiedChunks;
|
||||
|
||||
/// Sections (x, section index, z) waiting to be remeshed, kept by column
|
||||
/// (a bit per section index), so the mesh scheduler looks at each column once
|
||||
/// rather than at every section of it.
|
||||
@@ -87,7 +90,7 @@ pub struct World {
|
||||
/// was generated rather than read from the save: the game tells native mods.
|
||||
pub loaded_chunks: Vec<(i32, i32, bool)>,
|
||||
/// Chunks changed since the last save.
|
||||
pub modified: FxHashSet<(i32, i32)>,
|
||||
pub modified: ModifiedChunks,
|
||||
/// Block entities, and the order they tick in.
|
||||
pub block_entities: crate::block_entities::BlockEntities,
|
||||
/// Each chest's `ContainerOpenersCounter`: opener count and largest
|
||||
@@ -224,7 +227,7 @@ impl World {
|
||||
loaded_entities: Vec::new(),
|
||||
entities_pending: FxHashSet::default(),
|
||||
loaded_chunks: Vec::new(),
|
||||
modified: FxHashSet::default(),
|
||||
modified: ModifiedChunks::default(),
|
||||
attachments: Default::default(),
|
||||
attachment_syncs: FxHashSet::default(),
|
||||
block_entities: Default::default(),
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! `LevelChunk.markUnsaved` and `ChunkMap`'s insertion-ordered eager queue.
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
ops::Deref,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
type Key = (i32, i32);
|
||||
const COOLDOWN: Duration = Duration::from_millis(10_000);
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ModifiedChunks {
|
||||
dirty: FxHashSet<Key>,
|
||||
// Ordered independently of the dirty set: an autosave leaves queue entries
|
||||
// in place until the eager loop visits them, including cooled-down ones.
|
||||
order: BTreeMap<u64, Key>,
|
||||
queued: FxHashMap<Key, u64>,
|
||||
sequence: u64,
|
||||
next_save: FxHashMap<Key, Instant>,
|
||||
}
|
||||
impl Deref for ModifiedChunks {
|
||||
type Target = FxHashSet<Key>;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.dirty
|
||||
}
|
||||
}
|
||||
impl IntoIterator for ModifiedChunks {
|
||||
type Item = Key;
|
||||
type IntoIter = std::collections::hash_set::IntoIter<Key>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.dirty.into_iter()
|
||||
}
|
||||
}
|
||||
impl ModifiedChunks {
|
||||
pub fn insert(&mut self, key: Key) -> bool {
|
||||
let changed = self.dirty.insert(key);
|
||||
if changed && !self.queued.contains_key(&key) {
|
||||
self.sequence += 1;
|
||||
self.order.insert(self.sequence, key);
|
||||
self.queued.insert(key, self.sequence);
|
||||
}
|
||||
changed
|
||||
}
|
||||
pub fn extend(&mut self, keys: impl IntoIterator<Item = Key>) {
|
||||
for key in keys {
|
||||
self.insert(key);
|
||||
}
|
||||
}
|
||||
pub fn remove(&mut self, key: &Key) -> bool {
|
||||
self.dirty.remove(key)
|
||||
}
|
||||
pub fn drain(&mut self) -> impl Iterator<Item = Key> + '_ {
|
||||
self.dirty.drain()
|
||||
}
|
||||
/// Like `drain`: queue entries of chunks no longer dirty go at the next eager pass.
|
||||
pub fn clear(&mut self) {
|
||||
self.dirty.clear();
|
||||
}
|
||||
/// `ChunkMap.scheduleUnload`: forget the cooldown, including clean chunks.
|
||||
pub fn unloaded(&mut self, key: &Key) {
|
||||
self.next_save.remove(key);
|
||||
}
|
||||
/// `ChunkMap.saveAllChunks(false)`: clear cooldowns, then save all dirty
|
||||
/// full chunks without a limit, starting a fresh cooldown for each write.
|
||||
pub fn autosaved(&mut self, keys: &[Key], now: Instant) {
|
||||
self.next_save.clear();
|
||||
self.next_save
|
||||
.extend(keys.iter().map(|&key| (key, now + COOLDOWN)));
|
||||
}
|
||||
/// `ChunkMap.saveChunksEagerly` / `saveChunkIfNeeded`. `available` checks
|
||||
/// both haveTime and activeChunkWrites before EACH candidate; `save` takes
|
||||
/// an immutable snapshot and queues it. Failed submissions stay dirty.
|
||||
pub fn save_eagerly(
|
||||
&mut self,
|
||||
now: Instant,
|
||||
mut available: impl FnMut() -> bool,
|
||||
mut loaded: impl FnMut(Key) -> bool,
|
||||
mut save: impl FnMut(Key) -> bool,
|
||||
) {
|
||||
let mut saved = 0;
|
||||
let mut remove = Vec::new();
|
||||
for (&sequence, &key) in &self.order {
|
||||
if saved == 20 || !available() {
|
||||
break;
|
||||
}
|
||||
if !loaded(key) || !self.dirty.contains(&key) {
|
||||
remove.push((sequence, key));
|
||||
} else if self.next_save.get(&key).is_none_or(|&at| now >= at) && save(key) {
|
||||
self.dirty.remove(&key);
|
||||
self.next_save.insert(key, now + COOLDOWN);
|
||||
remove.push((sequence, key));
|
||||
saved += 1;
|
||||
}
|
||||
}
|
||||
for (sequence, key) in remove {
|
||||
self.order.remove(&sequence);
|
||||
self.queued.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,8 +92,8 @@ dunce = "1.0.5"
|
||||
# Use the registered URI handler without routing links through Explorer/cmd.
|
||||
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
|
||||
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
|
||||
# on the PATH.
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or on integrated GPUs with DXC
|
||||
# (dxcompiler.dll beside the game, gfx/dx12_compiler.rs).
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
@@ -116,7 +116,7 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
|
||||
# cosmetics and emotes, the "Horizon player" presence, the official and partner servers added to
|
||||
# cosmetics and emotes, the "Horizon player" presence, the official server added to
|
||||
# the server list, the Horizon Settings and Mods pages, Discord, Play with Friends and the
|
||||
# heartbeat. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
|
||||
|
||||
@@ -1008,6 +1008,7 @@
|
||||
"Editing: %s": "Editing: %s",
|
||||
"Press the button for this. %s cancels.": "Press the button for this. %s cancels.",
|
||||
"This button also does: %s": "This button also does: %s",
|
||||
"Not bound: press this row, then the button that should do it.": "Not bound: press this row, then the button that should do it.",
|
||||
"horizon.pad.notice.connected.press": "Press to use it",
|
||||
"horizon.recipe_transfer.missing": "Missing Items",
|
||||
"horizon.recipe_transfer.no_room": "Inventory full",
|
||||
|
||||
@@ -7,10 +7,64 @@
|
||||
"horizon.options.vibration": "Vibración",
|
||||
"horizon.options.play_in_background": "Jugar en segundo plano",
|
||||
"horizon.options.adaptive_triggers": "Gatillos adaptativos",
|
||||
"horizon.options.gameplay_guide": "Guía de juego",
|
||||
"horizon.options.gameplay_guide.basic": "Básica",
|
||||
"horizon.options.gameplay_guide.full": "Completa",
|
||||
"horizon.options.gameplay_guide.off": "Desactivada",
|
||||
"horizon.controller.guide.use": "Usar",
|
||||
"horizon.controller.guide.open": "Abrir",
|
||||
"horizon.controller.guide.ride": "Montar",
|
||||
"horizon.controller.guide.trade": "Comerciar",
|
||||
"horizon.controller.guide.feed": "Alimentar",
|
||||
"horizon.controller.guide.place": "Colocar",
|
||||
"horizon.controller.guide.eat": "Comer",
|
||||
"horizon.controller.guide.drink": "Beber",
|
||||
"horizon.controller.guide.shoot": "Disparar",
|
||||
"horizon.controller.guide.load": "Cargar",
|
||||
"horizon.controller.guide.throw": "Lanzar",
|
||||
"horizon.controller.guide.raise": "Alzar",
|
||||
"horizon.controller.guide.equip": "Equipar",
|
||||
"horizon.controller.guide.fish": "Pescar",
|
||||
"horizon.controller.guide.zoom": "Zoom",
|
||||
"horizon.controller.guide.boost": "Acelerar",
|
||||
"horizon.controller.guide.break": "Romper",
|
||||
"horizon.controller.guide.attack": "Atacar",
|
||||
"horizon.controller.guide.spectate": "Espectar",
|
||||
"horizon.controller.guide.spectator_menu": "Menú de espectador",
|
||||
"horizon.controller.guide.release_shoot": "Suelta para disparar",
|
||||
"horizon.controller.guide.release_throw": "Suelta para lanzar",
|
||||
"horizon.controller.guide.release_stop": "Suelta para parar",
|
||||
"horizon.controller.guide.fly": "Volar",
|
||||
"horizon.controller.guide.fly_up": "Volar arriba",
|
||||
"horizon.controller.guide.fly_down": "Volar abajo",
|
||||
"horizon.controller.guide.swim_up": "Nadar arriba",
|
||||
"horizon.controller.guide.swim_down": "Nadar abajo",
|
||||
"horizon.controller.guide.glide": "Planear",
|
||||
"horizon.controller.guide.dismount": "Desmontar",
|
||||
"horizon.controller.guide.stand": "Levantarse",
|
||||
"horizon.controller.guide.drop": "Soltar objeto",
|
||||
"horizon.controller.guide.swap": "Cambiar de mano",
|
||||
"horizon.controller.guide.take": "Coger",
|
||||
"horizon.controller.guide.take_half": "Coger la mitad",
|
||||
"horizon.controller.guide.put": "Dejar",
|
||||
"horizon.controller.guide.put_one": "Dejar uno",
|
||||
"horizon.controller.guide.swap_stack": "Intercambiar",
|
||||
"horizon.controller.guide.quick_move": "Mover rápido",
|
||||
"horizon.controller.guide.drop_stack": "Soltar pila",
|
||||
"horizon.controller.guide.place_recipe": "Colocar receta",
|
||||
"horizon.controller.guide.place_recipe_all": "Colocar todo",
|
||||
"horizon.controller.guide.alternatives": "Alternativas",
|
||||
"horizon.controller.guide.select": "Seleccionar",
|
||||
"horizon.controller.guide.close": "Cerrar",
|
||||
"horizon.controller.guide.pages": "Páginas",
|
||||
"horizon.controller.guide.categories": "Categorías",
|
||||
"horizon.controller.guide.tabs": "Pestañas",
|
||||
"horizon.options.lightbar": "Barra de luz",
|
||||
"horizon.options.look_sensitivity": "Sensibilidad de vista",
|
||||
"horizon.options.invert_x": "Invertir vista X",
|
||||
"horizon.options.invert_y": "Invertir vista Y",
|
||||
"horizon.options.craft_on_servers": "Fabricación rápida en servidores",
|
||||
"horizon.controller.direct_craft": "Fabricación rápida",
|
||||
"horizon.options.sneak_button": "Botón de agacharse",
|
||||
"horizon.options.move_deadzone": "Zona muerta de movimiento",
|
||||
"horizon.options.look_deadzone": "Zona muerta de vista",
|
||||
@@ -24,6 +78,9 @@
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.nintendo_buttons": "Botones de Nintendo",
|
||||
"horizon.options.nintendo_buttons.position": "Por posición",
|
||||
"horizon.options.nintendo_buttons.letter": "Por letra",
|
||||
"horizon.options.keyboard.auto": "Automático",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
@@ -52,6 +109,21 @@
|
||||
"horizon.studio.replay.seek.invalid": "Posición de replay no válida",
|
||||
"horizon.studio.replay.status": "Replay de Studio: %s / %s ms (%s%%), velocidad %s×, en pausa %s, finalizado %s",
|
||||
"horizon.studio.replay.closed": "Replay de Studio cerrado",
|
||||
"horizon.studio.freecam.enabled": "Cámara libre de Studio activada. Usa WASD para moverte, Espacio y Mayús para subir o bajar, y Control para ir más rápido.",
|
||||
"horizon.studio.freecam.disabled": "Cámara libre de Studio desactivada",
|
||||
"horizon.studio.freecam.unavailable": "La cámara libre de Studio no encontró la cámara del jugador",
|
||||
"horizon.studio.freecam.replay_required": "La cámara libre de Studio está disponible durante la reproducción de un replay",
|
||||
"horizon.studio.freecam.usage": "Uso: /studio freecam <on|off>",
|
||||
"horizon.studio.keyframe.added": "Fotograma clave de cámara añadido en %s ms. La ruta se guarda al cerrar el replay.",
|
||||
"horizon.studio.keyframe.cleared": "Ruta de cámara borrada. El cambio se guarda al cerrar el replay.",
|
||||
"horizon.studio.keyframe.removed": "Fotograma clave de cámara %s eliminado. El cambio se guarda al cerrar el replay.",
|
||||
"horizon.studio.keyframe.save_error": "No se pudo guardar la ruta de cámara: %s",
|
||||
"horizon.studio.keyframe.remove_usage": "Uso: /studio keyframe remove <ÍNDICE>",
|
||||
"horizon.studio.keyframe.index_invalid": "El índice del fotograma clave de cámara está fuera de rango",
|
||||
"horizon.studio.keyframe.usage": "Uso: /studio keyframe <add|remove ÍNDICE|clear>",
|
||||
"horizon.studio.path.locked": "Reproducción de la ruta de cámara de Studio activada",
|
||||
"horizon.studio.path.unlocked": "Reproducción de la ruta de cámara de Studio detenida",
|
||||
"horizon.studio.path.usage": "Uso: /studio path <lock|unlock> (añade al menos un fotograma clave antes)",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Paquetes de shaders",
|
||||
"horizon.pages.shader_options": "Opciones de shaders",
|
||||
@@ -164,6 +236,7 @@
|
||||
"Resolution Scale": "Escala de resolución",
|
||||
"Anti-aliasing": "Antialiasing",
|
||||
"Upscaling": "Reescalado",
|
||||
"Upscaling Sharpness": "Nitidez del reescalado",
|
||||
"Paper White": "Blanco de papel",
|
||||
"Peak Brightness": "Brillo máximo",
|
||||
"Black Level": "Nivel de negro",
|
||||
@@ -242,6 +315,7 @@
|
||||
"How far away mobs and items are drawn, as a share of normal.": "A qué distancia se dibujan mobs y objetos, como proporción de lo normal.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Omite el terreno oculto tras secciones cerradas. Desactivarlo cuesta mucho.",
|
||||
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Descarta el terreno cercano en la GPU: menos CPU con mucha distancia de renderizado, más GPU.",
|
||||
"Auto: tries it where your CPU limits the frame, keeps it if frames get faster.": "Automático: lo prueba donde tu CPU limita los fotogramas y lo mantiene si van más rápido.",
|
||||
"Hides entities behind opaque blocks.": "Oculta entidades tras bloques opacos.",
|
||||
"No entity occlusion culling.": "Sin oclusión de entidades.",
|
||||
"CPU occlusion test runs this frame.": "La CPU comprueba la oclusión en este fotograma.",
|
||||
@@ -265,6 +339,7 @@
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Píxeles del mundo por píxel de pantalla. Por debajo del 100 % es más rápido en la GPU.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Suaviza los bordes dentados. FXAA: más suave. TAA: el más suave, lo que se mueve puede dejar estela.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renderiza el mundo más pequeño y lo reconstruye con TAA. Más rápido y más suave.",
|
||||
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Aumenta la nitidez de la imagen reescalada. Más alto: bordes más nítidos y más parpadeo.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Salida HDR en una pantalla HDR: la lava, el fuego y la luz del sol brillan más.",
|
||||
"How frames reach the screen while VSync is off.": "Cómo llegan los fotogramas a la pantalla con VSync desactivado.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Eficiente duerme hasta el siguiente fotograma; Preciso gira, como en vanilla.",
|
||||
@@ -503,6 +578,8 @@
|
||||
"Reuses statue geometry until its block state changes.": "Reutiliza la geometría de la estatua hasta que cambia el estado de su bloque.",
|
||||
"Leaf Culling": "Descarte de hojas",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Todas las caras de las hojas. Las hojas detalladas de vanilla.",
|
||||
"Opaque Ice": "Hielo opaco",
|
||||
"Draws ice in the solid pass, without its see-through texture.": "Dibuja el hielo en la pasada sólida, sin su textura transparente.",
|
||||
"Distant Plant Dither": "Tramado de plantas lejanas",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Evita que las plantas finas de los paquetes de alta resolución se difuminen a lo lejos.",
|
||||
"Screenshot Quality": "Calidad de las capturas",
|
||||
@@ -888,6 +965,23 @@
|
||||
"Hold the button to sneak, or press it once to start and again to stop.": "Mantén el botón para agacharte, o pulsa una vez para empezar y otra para parar.",
|
||||
"How far the left stick moves before you walk. Raise it if you drift.": "Cuánto se mueve la palanca izquierda antes de que camines. Súbelo si te desplazas solo.",
|
||||
"How far a trigger is pulled before it counts as pressed.": "Cuánto hay que apretar un gatillo para que cuente como pulsado.",
|
||||
"Shows what your buttons do right now, in play and in inventories.": "Muestra lo que hacen tus botones ahora mismo, al jugar y en los inventarios.",
|
||||
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "Muestra solo lo que puedes hacer con lo que estás mirando y los botones principales del inventario.",
|
||||
"Doesn't show what your buttons do.": "No muestra lo que hacen tus botones.",
|
||||
"Shows the buttons of the controller you're using.": "Muestra los botones del mando que estás usando.",
|
||||
"Always shows Xbox buttons.": "Muestra siempre los botones de Xbox.",
|
||||
"Always shows PlayStation buttons.": "Muestra siempre los botones de PlayStation.",
|
||||
"Always shows Nintendo buttons.": "Muestra siempre los botones de Nintendo.",
|
||||
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A, a la derecha, confirma y salta, y B vuelve atrás, como en una Switch.",
|
||||
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B, abajo, confirma y salta, y A vuelve atrás, como en otros mandos.",
|
||||
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Abre el teclado de Steam en una Steam Deck bajo Steam y, si no, el de Horizon.",
|
||||
"Always opens Horizon's keyboard.": "Abre siempre el teclado de Horizon.",
|
||||
"Always opens Steam's keyboard.": "Abre siempre el teclado de Steam.",
|
||||
"A DualSense's light shows your health, from green through amber to red.": "La luz de un DualSense muestra tu salud, de verde a ámbar y a rojo.",
|
||||
"A DualSense's light changes with the dimension you're in.": "La luz de un DualSense cambia con la dimensión en la que estás.",
|
||||
"A DualSense keeps its own light.": "Un DualSense mantiene su propia luz.",
|
||||
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "Permite fabricar con LT + A en servidores que no son tus mundos. Algunos servidores lo cuentan como macro.",
|
||||
"Show the buttons for what you can do, while you play with a controller.": "Muestra los botones de lo que puedes hacer mientras juegas con mando.",
|
||||
"How fast the stick moves the crosshair in inventories.": "La rapidez con la que la palanca mueve el cursor en los inventarios.",
|
||||
"How fast the right stick scrolls a list.": "La rapidez con la que la palanca derecha desplaza una lista.",
|
||||
"How long a held direction waits before it repeats.": "Cuánto espera una dirección mantenida antes de repetirse.",
|
||||
@@ -899,6 +993,7 @@
|
||||
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "El fabricante y el producto USB que informa el sistema. Útil al reportar un mando que falla.",
|
||||
"The glyph family the game picked from the controller's maker.": "La familia de símbolos que el juego eligió según el fabricante del mando.",
|
||||
"Follow the controller, or always show one family's buttons.": "Sigue al mando o muestra siempre los botones de una familia.",
|
||||
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "Si los botones A y B de un mando de Nintendo funcionan por su posición o por su letra.",
|
||||
"The buttons menus show right now.": "Los botones que muestran los menús ahora mismo.",
|
||||
"Rumble when something happens to you.": "Vibra cuando te pasa algo.",
|
||||
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "El mando sigue funcionando aunque esta ventana no tenga el foco, y perder el foco no pone el juego en pausa.",
|
||||
@@ -917,5 +1012,38 @@
|
||||
"horizon.recipe_transfer.missing": "Faltan objetos",
|
||||
"horizon.recipe_transfer.no_room": "Inventario lleno",
|
||||
"horizon.recipe_transfer.unavailable": "La receta no cabe en este menú",
|
||||
"horizon.recipe_transfer.unsupported": "El servidor requiere una receta conocida"
|
||||
"horizon.recipe_transfer.unsupported": "El servidor requiere una receta conocida",
|
||||
"horizon.studio.timeline.freecam_on": "Cámara libre: SÍ",
|
||||
"horizon.studio.timeline.freecam_off": "Cámara libre: NO",
|
||||
"horizon.studio.timeline.freecam_short": "Cám.",
|
||||
"horizon.studio.timeline.freecam_active_short": "Cám. ✓",
|
||||
"horizon.studio.timeline.add_keyframe_short": "+ Clave",
|
||||
"horizon.studio.timeline.delete_keyframe_short": "− Borrar",
|
||||
"horizon.studio.timeline.split": "Dividir",
|
||||
"horizon.studio.timeline.marker": "Marcador %s",
|
||||
"horizon.studio.timeline.seconds": "%s s",
|
||||
"horizon.studio.timeline.scene_count": "%s escenas",
|
||||
"horizon.studio.timeline.scene_title": "Escena %s",
|
||||
"horizon.studio.timeline.sequence": "Secuencia",
|
||||
"horizon.studio.topbar.title": "✦ Studio",
|
||||
"horizon.studio.topbar.file": "Archivo",
|
||||
"horizon.studio.topbar.camera": "Cámara",
|
||||
"horizon.studio.topbar.view": "Vista",
|
||||
"horizon.studio.topbar.file_short": "Arch.",
|
||||
"horizon.studio.topbar.camera_short": "Cám.",
|
||||
"horizon.studio.topbar.view_short": "Vista",
|
||||
"horizon.studio.topbar.game_pause_menu": "⏸ Menú de pausa",
|
||||
"horizon.studio.topbar.pause_short": "Pausa",
|
||||
"horizon.studio.topbar.open_recordings": "📁 Abrir carpeta de grabaciones",
|
||||
"horizon.studio.topbar.close_replay": "✕ Cerrar replay",
|
||||
"horizon.studio.topbar.exit": "Salir",
|
||||
"horizon.studio.topbar.toggle_freecam": "Alternar cámara libre",
|
||||
"horizon.studio.topbar.reset_to_player": "👤 Volver al jugador",
|
||||
"horizon.studio.topbar.unlock_freecam": "🔓 Liberar cámara libre",
|
||||
"horizon.studio.topbar.lock_to_spline": "🔒 Fijar a la curva",
|
||||
"horizon.studio.topbar.add_keyframe": "➕ Añadir fotograma clave",
|
||||
"horizon.studio.topbar.add_marker": "Añadir marcador",
|
||||
"horizon.studio.topbar.clear_keyframes": "🗑 Borrar fotogramas clave",
|
||||
"horizon.studio.topbar.timeline_hud": "⏱ Línea de tiempo",
|
||||
"horizon.studio.topbar.split_scene": "Dividir escena"
|
||||
}
|
||||
@@ -1,29 +1,155 @@
|
||||
{
|
||||
"horizon.extras.header.world": "Monde",
|
||||
"horizon.extras.pregen": "Prégénération de chunks",
|
||||
"horizon.extras.pregen.tooltip": "Active /pregen dans vos mondes solo et en LAN. Avec les triches activées, utilisez /pregen start pour générer une zone avant de l'explorer. N'active pas la commande sur les serveurs que vous rejoignez.",
|
||||
"horizon.options.controller": "Manette",
|
||||
"horizon.options.three_d_skin_layers": "Couches de skin 3D",
|
||||
"horizon.options.vibration": "Vibrations",
|
||||
"horizon.options.play_in_background": "Jouer en arrière-plan",
|
||||
"horizon.options.adaptive_triggers": "Gâchettes adaptatives",
|
||||
"horizon.options.gameplay_guide": "Guide de jeu",
|
||||
"horizon.options.gameplay_guide.basic": "Basique",
|
||||
"horizon.options.gameplay_guide.full": "Complet",
|
||||
"horizon.options.gameplay_guide.off": "Désactivé",
|
||||
"horizon.controller.guide.use": "Utiliser",
|
||||
"horizon.controller.guide.open": "Ouvrir",
|
||||
"horizon.controller.guide.ride": "Monter",
|
||||
"horizon.controller.guide.trade": "Échanger",
|
||||
"horizon.controller.guide.feed": "Nourrir",
|
||||
"horizon.controller.guide.place": "Placer",
|
||||
"horizon.controller.guide.eat": "Manger",
|
||||
"horizon.controller.guide.drink": "Boire",
|
||||
"horizon.controller.guide.shoot": "Tirer",
|
||||
"horizon.controller.guide.load": "Charger",
|
||||
"horizon.controller.guide.throw": "Lancer",
|
||||
"horizon.controller.guide.raise": "Lever",
|
||||
"horizon.controller.guide.equip": "Équiper",
|
||||
"horizon.controller.guide.fish": "Pêcher",
|
||||
"horizon.controller.guide.zoom": "Zoom",
|
||||
"horizon.controller.guide.boost": "Accélérer",
|
||||
"horizon.controller.guide.break": "Casser",
|
||||
"horizon.controller.guide.attack": "Attaquer",
|
||||
"horizon.controller.guide.spectate": "Observer",
|
||||
"horizon.controller.guide.spectator_menu": "Menu spectateur",
|
||||
"horizon.controller.guide.release_shoot": "Relâchez pour tirer",
|
||||
"horizon.controller.guide.release_throw": "Relâchez pour lancer",
|
||||
"horizon.controller.guide.release_stop": "Relâchez pour arrêter",
|
||||
"horizon.controller.guide.fly": "Voler",
|
||||
"horizon.controller.guide.fly_up": "Monter",
|
||||
"horizon.controller.guide.fly_down": "Descendre",
|
||||
"horizon.controller.guide.swim_up": "Nager vers le haut",
|
||||
"horizon.controller.guide.swim_down": "Nager vers le bas",
|
||||
"horizon.controller.guide.glide": "Planer",
|
||||
"horizon.controller.guide.dismount": "Descendre",
|
||||
"horizon.controller.guide.stand": "Se lever",
|
||||
"horizon.controller.guide.drop": "Lâcher l'objet",
|
||||
"horizon.controller.guide.swap": "Changer de main",
|
||||
"horizon.controller.guide.take": "Prendre",
|
||||
"horizon.controller.guide.take_half": "Prendre la moitié",
|
||||
"horizon.controller.guide.put": "Poser",
|
||||
"horizon.controller.guide.put_one": "Poser un",
|
||||
"horizon.controller.guide.swap_stack": "Échanger",
|
||||
"horizon.controller.guide.quick_move": "Déplacement rapide",
|
||||
"horizon.controller.guide.drop_stack": "Lâcher la pile",
|
||||
"horizon.controller.guide.place_recipe": "Placer la recette",
|
||||
"horizon.controller.guide.place_recipe_all": "Tout placer",
|
||||
"horizon.controller.guide.alternatives": "Alternatives",
|
||||
"horizon.controller.guide.select": "Sélectionner",
|
||||
"horizon.controller.guide.close": "Fermer",
|
||||
"horizon.controller.guide.pages": "Pages",
|
||||
"horizon.controller.guide.categories": "Catégories",
|
||||
"horizon.controller.guide.tabs": "Onglets",
|
||||
"horizon.options.lightbar": "Barre lumineuse",
|
||||
"horizon.options.look_sensitivity": "Sensibilité de la vue",
|
||||
"horizon.options.invert_x": "Inverser l'axe X de la vue",
|
||||
"horizon.options.invert_y": "Inverser l'axe Y de la vue",
|
||||
"horizon.options.craft_on_servers": "Artisanat rapide sur les serveurs",
|
||||
"horizon.controller.direct_craft": "Artisanat rapide",
|
||||
"horizon.options.sneak_button": "Bouton pour s'accroupir",
|
||||
"horizon.options.move_deadzone": "Zone morte du déplacement",
|
||||
"horizon.options.look_deadzone": "Zone morte de la vue",
|
||||
"horizon.options.trigger_threshold": "Seuil de la gâchette",
|
||||
"horizon.options.button_glyphs": "Symboles des boutons",
|
||||
"horizon.options.on_screen_keyboard": "Clavier à l'écran",
|
||||
"horizon.options.chat_delay": "Délai du tchat",
|
||||
"horizon.options.lightbar.health": "Santé",
|
||||
"horizon.options.lightbar.dimension": "Dimension",
|
||||
"horizon.options.glyphs.auto": "Auto",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.nintendo_buttons": "Boutons Nintendo",
|
||||
"horizon.options.nintendo_buttons.position": "Par position",
|
||||
"horizon.options.nintendo_buttons.letter": "Par lettre",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Cette fonction n'est pas encore disponible dans Horizon.",
|
||||
"horizon.options.search": "Rechercher une option...",
|
||||
"horizon.studio.record.usage": "Utilisation : /studio record <start [nom]|stop|status>",
|
||||
"horizon.studio.record.started": "Enregistrement Studio démarré : %s",
|
||||
"horizon.studio.record.saved": "Enregistrement Studio sauvegardé : %s",
|
||||
"horizon.studio.record.error": "Erreur d'enregistrement Studio : %s",
|
||||
"horizon.studio.record.status.active": "Enregistrement Studio actif — %s s, %s paquets, %s Ko (%s)",
|
||||
"horizon.studio.record.status.inactive": "Enregistrement Studio inactif",
|
||||
"horizon.studio.replay.usage": "Utilisation : /studio replay <open CHEMIN|pause|resume|speed MULTIPLICATEUR|seek MS|status|close>",
|
||||
"horizon.studio.replay.open.usage": "Utilisation : /studio replay open <CHEMIN>",
|
||||
"horizon.studio.replay.opened": "Replay Studio ouvert : %s (%s s, %s paquets)",
|
||||
"horizon.studio.replay.open_error": "Impossible d'ouvrir le replay : %s",
|
||||
"horizon.studio.replay.paused": "Replay Studio en pause",
|
||||
"horizon.studio.replay.resumed": "Replay Studio repris",
|
||||
"horizon.studio.replay.inactive": "Aucun replay Studio actif",
|
||||
"horizon.studio.replay.speed.usage": "Utilisation : /studio replay speed <MULTIPLICATEUR>",
|
||||
"horizon.studio.replay.speed.set": "Vitesse du replay Studio réglée sur %s×",
|
||||
"horizon.studio.replay.speed.invalid": "Vitesse de replay invalide",
|
||||
"horizon.studio.replay.seek.usage": "Utilisation : /studio replay seek <MILLISECONDES>",
|
||||
"horizon.studio.replay.seeked": "Replay Studio avancé à %s ms (%s s)",
|
||||
"horizon.studio.replay.seek.error": "Impossible de déplacer le replay : %s",
|
||||
"horizon.studio.replay.seek.backward_requires_standalone": "Le retour en arrière n'est disponible que pour un replay autonome.",
|
||||
"horizon.studio.replay.seek.invalid": "Position de replay invalide",
|
||||
"horizon.studio.replay.status": "Replay Studio : %s / %s ms (%s%%), vitesse %s×, en pause %s, terminé %s",
|
||||
"horizon.studio.replay.closed": "Replay Studio fermé",
|
||||
"horizon.studio.freecam.enabled": "Caméra libre Studio activée. WASD pour vous déplacer, Espace et Maj pour monter ou descendre, Ctrl pour aller plus vite.",
|
||||
"horizon.studio.freecam.disabled": "Caméra libre Studio désactivée",
|
||||
"horizon.studio.freecam.unavailable": "La caméra libre Studio n'a pas trouvé de caméra de joueur",
|
||||
"horizon.studio.freecam.replay_required": "La caméra libre Studio est disponible pendant la lecture d'un replay",
|
||||
"horizon.studio.freecam.usage": "Utilisation : /studio freecam <on|off>",
|
||||
"horizon.studio.keyframe.added": "Image clé de caméra ajoutée à %s ms. Le tracé est sauvegardé à la fermeture du replay.",
|
||||
"horizon.studio.keyframe.cleared": "Tracé de caméra effacé. La modification est sauvegardée à la fermeture du replay.",
|
||||
"horizon.studio.keyframe.removed": "Image clé de caméra %s supprimée. La modification est sauvegardée à la fermeture du replay.",
|
||||
"horizon.studio.keyframe.save_error": "Impossible d'enregistrer le tracé de caméra : %s",
|
||||
"horizon.studio.keyframe.remove_usage": "Utilisation : /studio keyframe remove <INDEX>",
|
||||
"horizon.studio.keyframe.index_invalid": "L'indice de l'image clé de caméra est hors limites",
|
||||
"horizon.studio.keyframe.usage": "Utilisation : /studio keyframe <add|remove INDEX|clear>",
|
||||
"horizon.studio.path.locked": "Lecture du tracé de caméra Studio activée",
|
||||
"horizon.studio.path.unlocked": "Lecture du tracé de caméra Studio arrêtée",
|
||||
"horizon.studio.path.usage": "Utilisation : /studio path <lock|unlock> (ajoutez d'abord au moins une image clé)",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Packs de shaders",
|
||||
"horizon.pages.shader_options": "Options des shaders",
|
||||
"horizon.pages.controller": "Options de la manette",
|
||||
"horizon.pages.controller_binds": "Assignation des boutons de la manette",
|
||||
"horizon.pages.extras": "Extras Horizon",
|
||||
"horizon.pages.settings": "Options Horizon",
|
||||
"horizon.pages.studio": "Replay Studio",
|
||||
"horizon.pages.agent": "Agent IA",
|
||||
"horizon.pages.split_profiles": "Qui joue ?",
|
||||
"horizon.pages.music": "Musique",
|
||||
"horizon.pages.music.menu": "Menu",
|
||||
"horizon.pages.music.overworld": "Surface",
|
||||
"horizon.pages.music.creative": "Créatif",
|
||||
"horizon.pages.music.underwater": "Sous l'eau",
|
||||
"horizon.pages.music.end": "L'End",
|
||||
"horizon.pages.music.boss": "Boss",
|
||||
"horizon.pages.music.credits": "Générique",
|
||||
"horizon.pages.music.overworld_biomes": "Biomes de la Surface",
|
||||
"horizon.pages.music.nether_biomes": "Biomes du Nether",
|
||||
"horizon.pages.music.end_biomes": "Biomes de l'End",
|
||||
"horizon.pages.music.other": "Autre musique",
|
||||
"horizon.pages.music.empty": "Aucune piste musicale : les sons du jeu ne sont pas chargés.",
|
||||
"horizon.pages.music.tooltip": "Les pistes intégrées, par situation : lancez-en une maintenant, ou désactivez-en une pour que le jeu arrête de la choisir.",
|
||||
"horizon.pages.settings.tooltip": "Les options propres à Horizon : graphismes, manettes, extras et mods.",
|
||||
"horizon.pages.studio.tooltip": "Parcourez les enregistrements sauvegardés et leurs métadonnées.",
|
||||
"horizon.pages.graphics.tooltip": "Les graphismes, simplement : chaque réglage indique son coût.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Tous les réglages graphiques, y compris ceux d'Horizon, avec le coût de chacun.",
|
||||
"horizon.pages.controller.tooltip": "Les manettes : leurs boutons, leurs sticks, les vibrations et la navigation dans les menus.",
|
||||
@@ -36,15 +162,27 @@
|
||||
"horizon.pages.open.graphics_advanced": "Graphismes avancés...",
|
||||
"horizon.pages.open.shader_packs": "Packs de shaders...",
|
||||
"horizon.pages.open.controller": "Options de la manette...",
|
||||
"horizon.pages.open.extras": "Extras...",
|
||||
"horizon.pages.open.mods": "Mods...",
|
||||
"horizon.pages.open.agent": "Agent IA...",
|
||||
"horizon.pages.open.horizon_graphics": "Graphismes Horizon...",
|
||||
"horizon.pages.open.controller_binds": "Boutons manette...",
|
||||
"horizon.pages.open.horizon_settings": "Options Horizon...",
|
||||
"horizon.pages.open.studio": "Replay Studio...",
|
||||
"horizon.studio.saved_recordings": "Enregistrements sauvegardés",
|
||||
"horizon.studio.empty": "Aucun enregistrement dans le dossier des enregistrements.",
|
||||
"horizon.studio.empty_hint": "Utilisez /studio record start <nom> dans un monde, ou déposez-y des fichiers .mcpr.",
|
||||
"horizon.studio.open_folder": "Ouvrir les enregistrements",
|
||||
"horizon.studio.open_replay": "Lire le replay",
|
||||
"horizon.studio.refresh": "Actualiser",
|
||||
"horizon.studio.unknown_server": "Inconnu",
|
||||
"horizon.pages.open.music": "Musique...",
|
||||
"horizon.mods.empty": "Aucun mod : placez des fichiers .hmod dans le dossier horizon-mods",
|
||||
"horizon.mods.stopped": " (arrêté)",
|
||||
"horizon.mods.not_built": " (non compilé)",
|
||||
"horizon.mods.dev": " (dév.)",
|
||||
"horizon.mods.settings": "Options",
|
||||
"horizon.mods.in_world": " (quittez le monde pour changer)",
|
||||
"horizon.mods.open_folder": "Ouvrir le dossier des mods",
|
||||
"horizon.mods.browse": "Parcourir les mods...",
|
||||
"horizon.mods.browse.title": "Parcourir les mods",
|
||||
@@ -79,6 +217,7 @@
|
||||
"LOD Distance": "Distance LOD",
|
||||
"Far Detail": "Détails lointains",
|
||||
"Cave Culling": "Occlusion des grottes",
|
||||
"GPU Culling": "Culling GPU",
|
||||
"Entity Culling": "Occlusion des entités",
|
||||
"Chunk Updates": "MàJ chunks",
|
||||
"Upload Budget": "Transfert",
|
||||
@@ -91,10 +230,15 @@
|
||||
"Worker Threads": "Threads",
|
||||
"Resolution Scale": "Échelle de résolution",
|
||||
"Anti-aliasing": "Anticrénelage",
|
||||
"Upscaling Sharpness": "Netteté de la mise à l'échelle",
|
||||
"Paper White": "Point blanc",
|
||||
"Peak Brightness": "Luminosité max.",
|
||||
"Black Level": "Niveau de noir",
|
||||
"UI Brightness": "Luminosité UI",
|
||||
"HDR Lighting": "Éclairage HDR",
|
||||
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Les sources de lumière, le soleil et la lumière du soleil brillent au-delà du blanc. Désactivé : comme en SDR.",
|
||||
"Smooth Gradients": "Dégradés doux",
|
||||
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "Image 16 bits : les ciels et les ombres sombres ne se dégradent plus en bandes. Un peu plus de VRAM. Au prochain démarrage.",
|
||||
"Battery Mode": "Mode batterie",
|
||||
"Graphics API": "API graphique",
|
||||
"Translucent Batching": "Regroupement translucide",
|
||||
@@ -103,27 +247,72 @@
|
||||
"On": "Oui",
|
||||
"Off": "Non",
|
||||
"Unlimited": "Illimité",
|
||||
"Auto": "Auto",
|
||||
"Custom": "Personnalisé",
|
||||
"Performance": "Performances",
|
||||
"Balanced": "Équilibré",
|
||||
"Quality": "Qualité",
|
||||
"Ultra": "Ultra",
|
||||
"Ultra Performance": "Ultra perfs",
|
||||
"Full": "Complet",
|
||||
"Reduced": "Réduit",
|
||||
"Efficient": "Économe",
|
||||
"Precise": "Précis",
|
||||
"Auto (VSync)": "Auto (VSync)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "Mailbox",
|
||||
"Vulkan": "Vulkan",
|
||||
"DirectX 12": "DirectX 12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS et graphique",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "Aucun coût",
|
||||
"Low cost": "Coût faible",
|
||||
"Medium cost": "Coût moyen",
|
||||
"High cost": "Coût élevé",
|
||||
"Saves time": "Fait gagner du temps",
|
||||
"HDR": "HDR",
|
||||
"Not with software rendering, which runs it on the CPU.": "Pas avec le rendu logiciel, qui l'exécute sur le CPU.",
|
||||
"Out of video memory: off until the next start.": "Mémoire vidéo épuisée : désactivé jusqu'au prochain démarrage.",
|
||||
"The graphics device was lost: off until the next start.": "Le périphérique graphique a été perdu : désactivé jusqu'au prochain démarrage.",
|
||||
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Pas encore sur DirectX 12 : essayez l'API graphique Vulkan.",
|
||||
"Auto (%s)": "Auto (%s)",
|
||||
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Élimine le terrain proche sur le GPU : moins de CPU à grande distance de rendu, plus de GPU.",
|
||||
"Auto: tries it where your CPU limits the frame, keeps it if frames get faster.": "Auto : l'essaie là où votre CPU limite l'image, le garde si les images s'accélèrent.",
|
||||
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Accentue la netteté de l'image mise à l'échelle. Plus haut : bords plus nets, plus de scintillement.",
|
||||
"horizon.realms.not_signed_in": "Connectez-vous avec votre compte Microsoft dans le lanceur Horizon pour jouer sur Realms.",
|
||||
"Distance": "Distance",
|
||||
"Display": "Affichage",
|
||||
"Interface": "Interface",
|
||||
"Textures": "Textures",
|
||||
"Image": "Image",
|
||||
"Overlay": "Surimpression",
|
||||
"Graphics": "Graphismes",
|
||||
"Done": "Terminé",
|
||||
"This feature is not yet available in Horizon.": "Cette fonction n'est pas encore disponible dans Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Votre écran ou votre système ne propose pas de sortie HDR.",
|
||||
"horizon.extras.header.chat": "Chat",
|
||||
"horizon.extras.header.menus": "Menus",
|
||||
"horizon.extras.music_page": "Page Musique",
|
||||
"horizon.extras.zoom": "Zoom",
|
||||
"horizon.extras.zoom_mod_install": "Zoom : à installer depuis Mods",
|
||||
"horizon.extras.pack_browser": "Navigateur de packs de ressources",
|
||||
"horizon.extras.music_page.tooltip": "Musique et sons gagne une page Musique... : lancez n'importe quelle piste intégrée maintenant, ou désactivez-en une pour que le jeu arrête de la choisir. Rien ne change tant que vous ne désactivez pas une piste.",
|
||||
"horizon.friends.relay_stopped": "Trop de codes courts erronés : le relais est désactivé jusqu'à ce que vous partagiez à nouveau. Vos amis peuvent toujours rejoindre avec le code complet.",
|
||||
"horizon.extras.pack_browser.tooltip": "La sélection des packs de ressources gagne un bouton Ajouter qui cherche sur Modrinth et installe un pack dans votre dossier de packs. Désactivé, le jeu ne contacte jamais Modrinth.",
|
||||
"horizon.splitscreen.setup.layout.two.0": "Auto",
|
||||
"Opaque Ice": "Glace opaque",
|
||||
"Draws ice in the solid pass, without its see-through texture.": "Dessine la glace dans la passe solide, sans sa texture transparente.",
|
||||
"Hidden Faces": "Faces cachées",
|
||||
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Supprime les faces de feuilles derrière deux couches de feuilles : plus rapide, mêmes houppiers.",
|
||||
"%s nits": "%s nits",
|
||||
"Auto (%s nits)": "Auto (%s nits)",
|
||||
"%.2f nits": "%.2f nits",
|
||||
"Entity Culling Precheck": "Pré-vérification du culling des entités",
|
||||
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Lance la pré-vérification AABB des créatures après ce nombre de créatures chargées. Zéro la désactive.",
|
||||
"%s mobs": "%s créatures",
|
||||
"Only applies to asynchronous entity culling.": "S'applique uniquement à l'occlusion asynchrone des entités.",
|
||||
"Turn HDR on to use it.": "Activez le HDR pour l'utiliser.",
|
||||
"Your GPU or graphics API doesn't support it.": "Votre GPU ou votre API graphique ne le prend pas en charge.",
|
||||
@@ -428,6 +617,24 @@
|
||||
"Draws every particle.": "Dessine toutes les particules.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "Ignore les particules hors écran, trop petites ou masquées par le terrain.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Ignore les particules hors écran, trop petites ou masquées par le terrain avec moins de travail sur le thread de rendu.",
|
||||
"horizon.pack.add": "+",
|
||||
"horizon.pack.addInfo": "Ajouter un pack de ressources depuis Modrinth",
|
||||
"horizon.pack.addWorking": "Interrogation de Modrinth...",
|
||||
"horizon.pack.addNone": "Aucun pack de ressources trouvé",
|
||||
"horizon.pack.addTitle": "Ajouter un pack de ressources",
|
||||
"horizon.pack.install": "Installer",
|
||||
"horizon.pack.installing": "Installation",
|
||||
"horizon.pack.installed": "Installé",
|
||||
"horizon.pack.search": "Rechercher",
|
||||
"horizon.pack.searchHint": "Rechercher sur Modrinth...",
|
||||
"horizon.pack.filters": "Filtres",
|
||||
"horizon.pack.filter.realistic": "Réaliste",
|
||||
"horizon.pack.filter.simplistic": "Simpliste",
|
||||
"horizon.pack.filter.vanillaLike": "Style vanilla",
|
||||
"horizon.pack.filter.x16": "16x",
|
||||
"horizon.pack.filter.x32": "32x",
|
||||
"horizon.pack.filter.x64": "64x",
|
||||
"horizon.pack.addFailed": "Impossible d'ajouter le pack : %s",
|
||||
"horizon.extras.header.cosmetics": "Cosmétiques",
|
||||
"horizon.extras.cosmetics_mine": "Mes cosmétiques",
|
||||
"horizon.extras.cosmetics_mine.tooltip": "Masqués : tous les joueurs Horizon vous voient sans vos cosmétiques, vous compris. Le même réglage que la garde-robe du launcher.",
|
||||
@@ -535,5 +742,308 @@
|
||||
"horizon.options.scroll_speed": "Vitesse de défilement des menus",
|
||||
"horizon.options.repeat_delay": "Délai de répétition de navigation",
|
||||
"horizon.options.repeat_interval": "Intervalle de répétition de navigation",
|
||||
"horizon.options.milliseconds": "%s ms"
|
||||
"horizon.options.milliseconds": "%s ms",
|
||||
"horizon.pages.pvp": "PvP et HUD",
|
||||
"horizon.pages.pvp.tooltip": "Réticule, HUD et réglages PvP, tous désactivés par défaut. Rien ici n'affiche ce que vanilla n'affiche pas.",
|
||||
"horizon.pages.open.pvp": "PvP et HUD...",
|
||||
"horizon.pvp.header.crosshair": "Réticule",
|
||||
"horizon.pvp.crosshair": "Réticule personnalisé",
|
||||
"horizon.pvp.crosshair_shape": "Forme",
|
||||
"horizon.pvp.crosshair_size": "Taille",
|
||||
"horizon.pvp.crosshair_gap": "Écart",
|
||||
"horizon.pvp.crosshair_thickness": "Épaisseur",
|
||||
"horizon.pvp.crosshair_colour": "Couleur",
|
||||
"horizon.pvp.crosshair_outline": "Contour",
|
||||
"horizon.pvp.crosshair_dot": "Point central",
|
||||
"horizon.pvp.crosshair_hover": "Couleur sur les entités",
|
||||
"horizon.pvp.crosshair.tooltip": "Dessine votre propre réticule à la place de celui de vanilla. Ses premiers réglages ressemblent à ceux de vanilla.",
|
||||
"horizon.pvp.crosshair_shape.tooltip": "Une croix, un T, un X, un cercle, un carré ou seulement un point.",
|
||||
"horizon.pvp.crosshair_size.tooltip": "La longueur de chaque branche, en pixels d'interface.",
|
||||
"horizon.pvp.crosshair_gap.tooltip": "L'espace vide au centre.",
|
||||
"horizon.pvp.crosshair_thickness.tooltip": "La largeur de chaque trait.",
|
||||
"horizon.pvp.crosshair_colour.tooltip": "Inversé affiche la couleur en dessous, inversée, comme le fait le réticule de vanilla.",
|
||||
"horizon.pvp.crosshair_outline.tooltip": "Un fin liseré noir, pour qu'il reste visible sur n'importe quel fond.",
|
||||
"horizon.pvp.crosshair_dot.tooltip": "Un point au milieu du réticule.",
|
||||
"horizon.pvp.crosshair_hover.tooltip": "Une autre couleur quand le réticule est sur une créature ou un joueur que vous pourriez toucher, comme le sait l'indicateur d'attaque de vanilla.",
|
||||
"horizon.pvp.colour.inverted": "Inversé",
|
||||
"horizon.pvp.colour.white": "Blanc",
|
||||
"horizon.pvp.colour.red": "Rouge",
|
||||
"horizon.pvp.colour.orange": "Orange",
|
||||
"horizon.pvp.colour.yellow": "Jaune",
|
||||
"horizon.pvp.colour.green": "Vert",
|
||||
"horizon.pvp.colour.cyan": "Cyan",
|
||||
"horizon.pvp.colour.blue": "Bleu",
|
||||
"horizon.pvp.colour.magenta": "Magenta",
|
||||
"horizon.pvp.colour.black": "Noir",
|
||||
"horizon.pvp.shape.cross": "Croix",
|
||||
"horizon.pvp.shape.t": "T",
|
||||
"horizon.pvp.shape.x": "X",
|
||||
"horizon.pvp.shape.circle": "Cercle",
|
||||
"horizon.pvp.shape.square": "Carré",
|
||||
"horizon.pvp.shape.dot": "Point",
|
||||
"horizon.pvp.outline": "Contour de bloc personnalisé",
|
||||
"horizon.pvp.outline.tooltip": "Change le contour du bloc que vous regardez.",
|
||||
"horizon.pvp.outline_colour": "Couleur",
|
||||
"horizon.pvp.outline_colour.tooltip": "La couleur du contour. Noir à 40 % est celui de vanilla.",
|
||||
"horizon.pvp.outline_opacity": "Opacité",
|
||||
"horizon.pvp.outline_opacity.tooltip": "À quel point le contour est opaque.",
|
||||
"horizon.pvp.outline_thickness": "Épaisseur",
|
||||
"horizon.pvp.outline_thickness.tooltip": "La largeur du contour, en multiples de celui de vanilla.",
|
||||
"horizon.pvp.outline_fill": "Remplissage",
|
||||
"horizon.pvp.outline_fill.tooltip": "Teinte légèrement les faces du bloc que vous regardez.",
|
||||
"horizon.pvp.hit": "Couleur de coup personnalisée",
|
||||
"horizon.pvp.hit.tooltip": "Change la couleur dont les créatures et les joueurs clignotent quand ils sont blessés. Rouge à 30 % est celle de vanilla.",
|
||||
"horizon.pvp.hit_colour": "Couleur",
|
||||
"horizon.pvp.hit_colour.tooltip": "La couleur dont clignotent les créatures et les joueurs blessés.",
|
||||
"horizon.pvp.hit_opacity": "Opacité",
|
||||
"horizon.pvp.hit_opacity.tooltip": "La part de couleur qui les recouvre.",
|
||||
"horizon.pvp.header.outline": "Contour de bloc",
|
||||
"horizon.pvp.header.hit": "Couleur de coup",
|
||||
"horizon.pvp.hud_armour": "Armure",
|
||||
"horizon.pvp.hud_armour.tooltip": "L'armure que vous portez, tête en premier, avec les utilisations restantes de chaque pièce.",
|
||||
"horizon.pvp.hud_armour_corner": "Coin de l'armure",
|
||||
"horizon.pvp.hud_armour_corner.tooltip": "Où l'armure s'affiche.",
|
||||
"horizon.pvp.hud_armour_percent": "Durabilité en %",
|
||||
"horizon.pvp.hud_armour_percent.tooltip": "Les utilisations restantes en pourcentage du maximum de la pièce, plutôt qu'en nombre.",
|
||||
"horizon.pvp.hud_effects": "Effets",
|
||||
"horizon.pvp.hud_effects.tooltip": "Vos effets avec leurs noms, niveaux et temps restant, à la place des icônes en haut à droite.",
|
||||
"horizon.pvp.hud_effects_corner": "Coin des effets",
|
||||
"horizon.pvp.hud_effects_corner.tooltip": "Où les effets s'affichent.",
|
||||
"horizon.pvp.hud_held": "Nombre d'objets en main",
|
||||
"horizon.pvp.hud_held.tooltip": "Combien d'exemplaires de l'objet en main votre inventaire contient ; des flèches avec un arc ou une arbalète.",
|
||||
"horizon.pvp.hud_held_corner": "Coin du compteur en main",
|
||||
"horizon.pvp.hud_held_corner.tooltip": "Où le compteur s'affiche.",
|
||||
"horizon.pvp.hud_background": "Fonds des widgets",
|
||||
"horizon.pvp.hud_background.tooltip": "Une boîte sombre derrière chaque widget.",
|
||||
"horizon.pvp.hud_opacity": "Opacité du fond",
|
||||
"horizon.pvp.hud_opacity.tooltip": "À quel point les boîtes derrière les widgets sont sombres.",
|
||||
"horizon.pvp.hud_text_colour": "Couleur du texte",
|
||||
"horizon.pvp.hud_text_colour.tooltip": "La couleur du texte des widgets. La durabilité garde son vert à rouge.",
|
||||
"horizon.pvp.hud_shadow": "Ombre du texte",
|
||||
"horizon.pvp.hud_shadow.tooltip": "Une ombre sous le texte des widgets, comme le texte du HUD de vanilla.",
|
||||
"horizon.pvp.header.hud": "Widgets du HUD",
|
||||
"horizon.pvp.corner.top_left": "En haut à gauche",
|
||||
"horizon.pvp.corner.top_right": "En haut à droite",
|
||||
"horizon.pvp.corner.bottom_left": "En bas à gauche",
|
||||
"horizon.pvp.corner.bottom_right": "En bas à droite",
|
||||
"horizon.pvp.hud_ping": "Ping",
|
||||
"horizon.pvp.hud_ping.tooltip": "La latence que le serveur vous rapporte, en millisecondes. Multijoueur uniquement.",
|
||||
"horizon.pvp.hud_ping_corner": "Coin du ping",
|
||||
"horizon.pvp.hud_ping_corner.tooltip": "Où le ping s'affiche.",
|
||||
"horizon.pvp.hud_coords": "Coordonnées",
|
||||
"horizon.pvp.hud_coords.tooltip": "Votre position en blocs et la direction que vous regardez, comme F3 les affiche. Masqué quand le monde ou le serveur réduit F3.",
|
||||
"horizon.pvp.hud_coords_corner": "Coin des coordonnées",
|
||||
"horizon.pvp.hud_coords_corner.tooltip": "Où les coordonnées s'affichent.",
|
||||
"horizon.pvp.hud_server": "Adresse du serveur",
|
||||
"horizon.pvp.hud_server.tooltip": "L'adresse du serveur sur lequel vous jouez. Multijoueur uniquement.",
|
||||
"horizon.pvp.hud_server_corner": "Coin du serveur",
|
||||
"horizon.pvp.hud_server_corner.tooltip": "Où l'adresse s'affiche.",
|
||||
"horizon.pvp.hud_combo": "Compteur de combo",
|
||||
"horizon.pvp.hud_combo.tooltip": "Les coups consécutifs que le serveur a confirmés, jusqu'à ce que vous soyez blessé ou que 3 secondes passent sans coup.",
|
||||
"horizon.pvp.hud_combo_corner": "Coin du combo",
|
||||
"horizon.pvp.hud_combo_corner.tooltip": "Où le combo s'affiche.",
|
||||
"horizon.pvp.hud.ping": "Ping : %s ms",
|
||||
"horizon.pvp.hud.coords": "XYZ : %s %s %s",
|
||||
"horizon.pvp.hud.facing": "Direction : %s",
|
||||
"horizon.pvp.hud.combo": "Combo : %s",
|
||||
"horizon.pvp.hud.direction.north": "Nord",
|
||||
"horizon.pvp.hud.direction.south": "Sud",
|
||||
"horizon.pvp.hud.direction.east": "Est",
|
||||
"horizon.pvp.hud.direction.west": "Ouest",
|
||||
"horizon.pvp.hud_fps": "FPS",
|
||||
"horizon.pvp.hud_fps.tooltip": "Images dessinées pendant la dernière seconde.",
|
||||
"horizon.pvp.hud_fps_corner": "Coin des FPS",
|
||||
"horizon.pvp.hud_fps_corner.tooltip": "Où les FPS s'affichent.",
|
||||
"horizon.pvp.hud_cps": "Clics par seconde",
|
||||
"horizon.pvp.hud_cps.tooltip": "Vos clics gauche et droit pendant la dernière seconde.",
|
||||
"horizon.pvp.hud_cps_corner": "Coin des CPS",
|
||||
"horizon.pvp.hud_cps_corner.tooltip": "Où les clics par seconde s'affichent.",
|
||||
"horizon.pvp.hud_keys": "Touches",
|
||||
"horizon.pvp.hud_keys.tooltip": "Vos touches de déplacement, boutons de souris et saut, allumés tant que vous les tenez, avec les clics par seconde.",
|
||||
"horizon.pvp.hud_keys_corner": "Coin des touches",
|
||||
"horizon.pvp.hud_keys_corner.tooltip": "Où les touches s'affichent.",
|
||||
"horizon.pvp.hud_clock": "Horloge",
|
||||
"horizon.pvp.hud_clock.tooltip": "L'heure de votre ordinateur.",
|
||||
"horizon.pvp.hud_clock_corner": "Coin de l'horloge",
|
||||
"horizon.pvp.hud_clock_corner.tooltip": "Où l'horloge s'affiche.",
|
||||
"horizon.pvp.hud_reach": "Portée",
|
||||
"horizon.pvp.hud_reach.tooltip": "À quelle distance de vos yeux votre dernier coup a porté, pendant 3 secondes. Ce n'est qu'une mesure : votre portée reste celle de vanilla.",
|
||||
"horizon.pvp.hud_reach_corner": "Coin de la portée",
|
||||
"horizon.pvp.hud_reach_corner.tooltip": "Où la portée s'affiche.",
|
||||
"horizon.pvp.hud_speed": "Vitesse",
|
||||
"horizon.pvp.hud_speed.tooltip": "À quelle vitesse vous vous déplacez au sol, en blocs par seconde.",
|
||||
"horizon.pvp.hud_speed_corner": "Coin de la vitesse",
|
||||
"horizon.pvp.hud_speed_corner.tooltip": "Où la vitesse s'affiche.",
|
||||
"horizon.pvp.hud.fps": "%s FPS",
|
||||
"horizon.pvp.hud.cps": "CPS : %s | %s",
|
||||
"horizon.pvp.hud.reach": "Portée : %s",
|
||||
"horizon.pvp.hud.speed": "Vitesse : %s b/s",
|
||||
"horizon.pvp.hud.keys.attack": "Clic G",
|
||||
"horizon.pvp.hud.keys.use": "Clic D",
|
||||
"horizon.pvp.hud.keys.cps": "%s CPS",
|
||||
"horizon.pvp.numeric_enchants": "Niveaux d'enchantement en chiffres",
|
||||
"horizon.pvp.numeric_enchants.tooltip": "Les infobulles et la table d'enchantement affichent Tranchant 5 au lieu de Tranchant V.",
|
||||
"horizon.pvp.header.items": "Objets",
|
||||
"horizon.pvp.fire_lower": "Feu plus bas",
|
||||
"horizon.pvp.fire_lower.tooltip": "Descend les flammes que vous voyez en brûlant, pour qu'elles couvrent moins l'écran.",
|
||||
"horizon.pvp.shield_size": "Taille du bouclier",
|
||||
"horizon.pvp.shield_size.tooltip": "La taille du bouclier en main, à la première personne.",
|
||||
"horizon.pvp.totem_size": "Taille de l'animation du totem",
|
||||
"horizon.pvp.totem_size.tooltip": "La taille du totem qui remplit l'écran quand il vous sauve.",
|
||||
"horizon.pvp.glint_colour": "Couleur du reflet d'enchantement",
|
||||
"horizon.pvp.glint_colour.tooltip": "La couleur du scintillement sur les objets et armures enchantés. Désactivé, c'est le violet de vanilla.",
|
||||
"horizon.pvp.clear_water": "Eau limpide",
|
||||
"horizon.pvp.clear_water.tooltip": "Sous l'eau, voyez aussi loin que vanilla le permet après 30 secondes, dès le départ.",
|
||||
"horizon.pvp.clear_lava": "Lave limpide",
|
||||
"horizon.pvp.clear_lava.tooltip": "Dans la lave, voyez aussi loin que la Résistance au feu de vanilla le permet, sans l'effet.",
|
||||
"horizon.pvp.header.visual": "Ajustements visuels",
|
||||
"horizon.pvp.view_time": "Heure du jour",
|
||||
"horizon.pvp.view_time.tooltip": "L'heure du jour que vous voyez : seulement le ciel et sa lumière. Le temps du monde continue et rien n'est envoyé au serveur.",
|
||||
"horizon.pvp.view_weather": "Météo",
|
||||
"horizon.pvp.view_weather.tooltip": "La météo que vous voyez : seulement la pluie, sa lumière et le brouillard. La météo du monde continue et rien n'est envoyé au serveur.",
|
||||
"horizon.pvp.header.view": "Votre vue",
|
||||
"horizon.pvp.view.world": "Celle du monde",
|
||||
"horizon.pvp.time.morning": "Matin",
|
||||
"horizon.pvp.time.noon": "Midi",
|
||||
"horizon.pvp.time.sunset": "Coucher du soleil",
|
||||
"horizon.pvp.time.midnight": "Minuit",
|
||||
"horizon.pvp.weather.clear": "Dégagé",
|
||||
"horizon.pvp.weather.rain": "Pluie",
|
||||
"horizon.pvp.weather.thunder": "Orage",
|
||||
"horizon.chat.timestamp": "[%s] ",
|
||||
"horizon.chat.repeats": " (x%s)",
|
||||
"horizon.pvp.header.chat": "Chat",
|
||||
"horizon.chat.copied": "Message copié",
|
||||
"horizon.chat.search": "Rechercher dans le chat :",
|
||||
"horizon.pvp.chat_timestamps": "Horodatage",
|
||||
"horizon.pvp.chat_timestamps.tooltip": "L'heure d'arrivée de chaque message, devant lui.",
|
||||
"horizon.pvp.chat_compact": "Chat compact",
|
||||
"horizon.pvp.chat_compact.tooltip": "Un message répété à la suite ne s'affiche qu'une fois, avec le nombre de fois : (x3).",
|
||||
"horizon.pvp.chat_copy": "Copier au clic",
|
||||
"horizon.pvp.chat_copy.tooltip": "Cliquer sur un message dans le chat ouvert copie son texte, sauf si le clic fait autre chose.",
|
||||
"horizon.pvp.chat_search": "Recherche dans le chat",
|
||||
"horizon.pvp.chat_search.tooltip": "Ctrl+F dans le chat ouvert : ce que vous tapez n'affiche que les messages qui le contiennent. Entrée ou Ctrl+F à nouveau arrête.",
|
||||
"horizon.pvp.preset": "Préréglage PvP",
|
||||
"horizon.pvp.preset.tooltip": "Active le réticule personnalisé, les widgets d'armure et d'effets, le sprint et l'accroupissement à bascule, le feu plus bas, et les mods compteur de FPS et touches s'ils sont installés. Vos réglages de chacun restent.",
|
||||
"horizon.pvp.hud_editor_key": "Touche de l'éditeur de HUD",
|
||||
"horizon.pvp.hud_editor_key.tooltip": "Ouvre l'éditeur de HUD depuis le jeu. Cliquez, puis appuyez sur une touche ; Échap la retire.",
|
||||
"horizon.pvp.editor": "Modifier le HUD",
|
||||
"horizon.pvp.editor.tooltip": "Déplacez les widgets sur le jeu : faites-les glisser n'importe où, redimensionnez-les avec la molette, clic droit sur l'un pour le remettre dans son coin.",
|
||||
"horizon.pvp.editor.hint": "Glisser les widgets · Molette : taille · Clic droit : retour au coin",
|
||||
"horizon.pvp.combat_feel": "Sensation de combat",
|
||||
"horizon.pvp.combat_feel.tooltip": "Legacy dessine les combats comme en 1.8.9 : l'objet en main, son balancement, le fait de manger, l'arc et une épée levée tant que vous maintenez l'utilisation, sans la plongée après chaque coup ni l'indicateur d'attaque. Seulement l'apparence : ce que vous envoyez à un serveur reste pareil.",
|
||||
"horizon.pvp.combat_indicator": "Indicateur d'attaque en Legacy",
|
||||
"horizon.pvp.combat_indicator.tooltip": "Garde l'indicateur d'attaque sous Legacy, que 1.8.9 n'avait pas.",
|
||||
"horizon.pvp.combat_feel.modern": "Moderne",
|
||||
"horizon.pvp.combat_feel.legacy": "Legacy (1.8.9)",
|
||||
"horizon.pvp.header.combat": "Sensation de combat",
|
||||
"horizon.pvp.combat_sounds": "Sons de coup de 1.8",
|
||||
"horizon.pvp.combat_sounds.tooltip": "Sous Legacy, omet les sons d'attaque ajoutés en 1.9 (fort, faible, balayage, critique...). Les sons de blessure restent.",
|
||||
"horizon.pvp.combat_rules": "Combat 1.8 dans les mondes que vous hébergez",
|
||||
"horizon.pvp.combat_rules.tooltip": "Le solo et les mondes que vous ouvrez en LAN se battent selon les règles de 1.8.9 : pas de temps de recharge d'attaque ni de balayage, dégâts d'arme de 1.8, Force, Tranchant et coups critiques. Les serveurs que vous rejoignez gardent leurs propres règles.",
|
||||
"Bindings": "Boutons",
|
||||
"Play": "Jeu",
|
||||
"Device": "Appareil",
|
||||
"Look": "Vue",
|
||||
"Movement": "Déplacement",
|
||||
"Menus": "Menus",
|
||||
"Actions": "Actions",
|
||||
"Reset": "Réinitialiser",
|
||||
"Detected": "Détecté",
|
||||
"Glyphs": "Symboles",
|
||||
"Feedback": "Retour",
|
||||
"Controller": "Manette",
|
||||
"How the sticks turn and move you, and how menus repeat and scroll.": "Comment les sticks vous font tourner et avancer, et comment les menus se répètent et défilent.",
|
||||
"Which button does what. Each player has their own.": "Quel bouton fait quoi. Chaque joueur a les siens.",
|
||||
"The controller the game sees, its button glyphs and its feedback.": "La manette que le jeu voit, les symboles de ses boutons et son retour.",
|
||||
"Press this row, then the button that should do it.": "Appuyez sur cette ligne, puis sur le bouton qui doit le faire.",
|
||||
"Off, the game ignores every controller for this player.": "Désactivé, le jeu ignore toutes les manettes pour ce joueur.",
|
||||
"How fast the right stick turns the view.": "À quelle vitesse le stick droit fait tourner la vue.",
|
||||
"Turn the view the other way, left and right.": "Fait tourner la vue dans l'autre sens, de gauche à droite.",
|
||||
"Turn the view the other way, up and down.": "Fait tourner la vue dans l'autre sens, de haut en bas.",
|
||||
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Jusqu'où le stick droit bouge avant que la vue tourne. Augmentez-le si la vue dérive.",
|
||||
"Hold the button to sneak, or press it once to start and again to stop.": "Maintenez le bouton pour s'accroupir, ou appuyez une fois pour commencer et de nouveau pour arrêter.",
|
||||
"How far the left stick moves before you walk. Raise it if you drift.": "Jusqu'où le stick gauche bouge avant que vous marchiez. Augmentez-le si vous dérivez.",
|
||||
"How far a trigger is pulled before it counts as pressed.": "Jusqu'où une gâchette est enfoncée avant d'être comptée comme pressée.",
|
||||
"Shows what your buttons do right now, in play and in inventories.": "Montre ce que font vos boutons maintenant, en jeu et dans les inventaires.",
|
||||
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "Montre seulement ce que vous pouvez faire avec ce que vous regardez, et les boutons principaux de l'inventaire.",
|
||||
"Doesn't show what your buttons do.": "N'affiche pas ce que font vos boutons.",
|
||||
"Shows the buttons of the controller you're using.": "Montre les boutons de la manette que vous utilisez.",
|
||||
"Always shows Xbox buttons.": "Montre toujours les boutons Xbox.",
|
||||
"Always shows PlayStation buttons.": "Montre toujours les boutons PlayStation.",
|
||||
"Always shows Nintendo buttons.": "Montre toujours les boutons Nintendo.",
|
||||
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A, à droite, confirme et saute, et B revient en arrière, comme sur une Switch.",
|
||||
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B, en bas, confirme et saute, et A revient en arrière, comme sur les autres manettes.",
|
||||
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Ouvre le clavier de Steam sur un Steam Deck sous Steam, et celui de Horizon sinon.",
|
||||
"Always opens Horizon's keyboard.": "Ouvre toujours le clavier de Horizon.",
|
||||
"Always opens Steam's keyboard.": "Ouvre toujours le clavier de Steam.",
|
||||
"A DualSense's light shows your health, from green through amber to red.": "La lumière d'une DualSense montre votre santé, du vert à l'orange puis au rouge.",
|
||||
"A DualSense's light changes with the dimension you're in.": "La lumière d'une DualSense change selon la dimension où vous êtes.",
|
||||
"A DualSense keeps its own light.": "Une DualSense garde sa propre lumière.",
|
||||
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "Permet de fabriquer avec LT + A sur les serveurs, pas seulement dans vos propres mondes. Certains serveurs le considèrent comme une macro.",
|
||||
"Show the buttons for what you can do, while you play with a controller.": "Affiche les boutons de ce que vous pouvez faire pendant que vous jouez à la manette.",
|
||||
"How fast the stick moves the crosshair in inventories.": "À quelle vitesse le stick déplace le réticule dans les inventaires.",
|
||||
"How fast the right stick scrolls a list.": "À quelle vitesse le stick droit fait défiler une liste.",
|
||||
"How long a held direction waits before it repeats.": "Combien de temps une direction maintenue attend avant de se répéter.",
|
||||
"How long a held direction takes between repeats.": "Combien de temps une direction maintenue met entre deux répétitions.",
|
||||
"Which on-screen keyboard opens when a text field is pressed.": "Quel clavier à l'écran s'ouvre quand on appuie dans un champ de texte.",
|
||||
"Every action back on its default button.": "Remet chaque action sur son bouton par défaut.",
|
||||
"These settings belong to this player's profile.": "Ces réglages appartiennent au profil de ce joueur.",
|
||||
"The controller driving this player, or else the first one connected.": "La manette qui pilote ce joueur, ou sinon la première connectée.",
|
||||
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "Le fabricant et le produit USB que le système rapporte. Utile pour signaler une manette qui se comporte mal.",
|
||||
"The glyph family the game picked from the controller's maker.": "La famille de symboles que le jeu a choisie d'après le fabricant de la manette.",
|
||||
"Follow the controller, or always show one family's buttons.": "Suivre la manette, ou toujours montrer les boutons d'une même famille.",
|
||||
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "Si les boutons A et B d'une manette Nintendo fonctionnent par leur position ou par leur lettre.",
|
||||
"The buttons menus show right now.": "Les boutons que les menus montrent maintenant.",
|
||||
"Rumble when something happens to you.": "Vibre quand quelque chose vous arrive.",
|
||||
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "La manette continue de jouer quand cette fenêtre n'a pas le focus, et le perdre ne met pas en pause.",
|
||||
"A DualSense's triggers push back on bows and tools.": "Les gâchettes d'une DualSense résistent sur les arcs et les outils.",
|
||||
"What a DualSense's light shows, over USB or Bluetooth.": "Ce que montre la lumière d'une DualSense, en USB ou en Bluetooth.",
|
||||
"Player profile": "Profil du joueur",
|
||||
"Vendor and product": "Fabricant et produit",
|
||||
"Detected glyphs": "Symboles détectés",
|
||||
"Glyphs in use": "Symboles utilisés",
|
||||
"Unknown": "Inconnu",
|
||||
"No controller": "Aucune manette",
|
||||
"Editing: %s": "Modification : %s",
|
||||
"Press the button for this. %s cancels.": "Appuyez sur le bouton pour cela. %s annule.",
|
||||
"This button also does: %s": "Ce bouton fait aussi : %s",
|
||||
"horizon.pad.notice.connected.press": "Appuyez pour l'utiliser",
|
||||
"horizon.recipe_transfer.missing": "Objets manquants",
|
||||
"horizon.recipe_transfer.no_room": "Inventaire plein",
|
||||
"horizon.recipe_transfer.unavailable": "La recette ne tient pas dans ce menu",
|
||||
"horizon.recipe_transfer.unsupported": "Le serveur exige une recette connue",
|
||||
"horizon.studio.timeline.freecam_on": "Caméra libre : OUI",
|
||||
"horizon.studio.timeline.freecam_off": "Caméra libre : NON",
|
||||
"horizon.studio.timeline.freecam_short": "Cam.",
|
||||
"horizon.studio.timeline.freecam_active_short": "Cam. ✓",
|
||||
"horizon.studio.timeline.add_keyframe_short": "+ Image",
|
||||
"horizon.studio.timeline.delete_keyframe_short": "− Suppr.",
|
||||
"horizon.studio.timeline.split": "Diviser",
|
||||
"horizon.studio.timeline.marker": "Marqueur %s",
|
||||
"horizon.studio.timeline.seconds": "%s s",
|
||||
"horizon.studio.timeline.scene_count": "%s scènes",
|
||||
"horizon.studio.timeline.scene_title": "Scène %s",
|
||||
"horizon.studio.timeline.sequence": "Séquence",
|
||||
"horizon.studio.topbar.title": "✦ Studio",
|
||||
"horizon.studio.topbar.file": "Fichier",
|
||||
"horizon.studio.topbar.camera": "Caméra",
|
||||
"horizon.studio.topbar.view": "Vue",
|
||||
"horizon.studio.topbar.file_short": "Fich.",
|
||||
"horizon.studio.topbar.camera_short": "Cam.",
|
||||
"horizon.studio.topbar.view_short": "Vue",
|
||||
"horizon.studio.topbar.game_pause_menu": "⏸ Menu de pause",
|
||||
"horizon.studio.topbar.pause_short": "Pause",
|
||||
"horizon.studio.topbar.open_recordings": "📁 Ouvrir le dossier des enregistrements",
|
||||
"horizon.studio.topbar.close_replay": "✕ Fermer le replay",
|
||||
"horizon.studio.topbar.exit": "Quitter",
|
||||
"horizon.studio.topbar.toggle_freecam": "Basculer la caméra libre",
|
||||
"horizon.studio.topbar.reset_to_player": "👤 Revenir au joueur",
|
||||
"horizon.studio.topbar.unlock_freecam": "🔓 Libérer la caméra libre",
|
||||
"horizon.studio.topbar.lock_to_spline": "🔒 Fixer à la courbe",
|
||||
"horizon.studio.topbar.add_keyframe": "➕ Ajouter une image clé",
|
||||
"horizon.studio.topbar.add_marker": "Ajouter un marqueur",
|
||||
"horizon.studio.topbar.clear_keyframes": "🗑 Effacer les images clés",
|
||||
"horizon.studio.topbar.timeline_hud": "⏱ Chronologie",
|
||||
"horizon.studio.topbar.split_scene": "Diviser la scène"
|
||||
}
|
||||
@@ -3,27 +3,127 @@
|
||||
"horizon.extras.pregen": "Pré-gerar chunks",
|
||||
"horizon.extras.pregen.tooltip": "Habilita /pregen nos seus mundos de um jogador e em LAN. Com comandos habilitados, use /pregen start para gerar uma área antes de explorá-la. Não habilita o comando nos servidores em que você entra.",
|
||||
"horizon.options.controller": "Controlador",
|
||||
"horizon.options.three_d_skin_layers": "Camadas de skin 3D",
|
||||
"horizon.options.vibration": "Vibração",
|
||||
"horizon.options.play_in_background": "Jogar em segundo plano",
|
||||
"horizon.options.adaptive_triggers": "Gatilhos adaptativos",
|
||||
"horizon.options.gameplay_guide": "Guia de jogo",
|
||||
"horizon.options.gameplay_guide.basic": "Básico",
|
||||
"horizon.options.gameplay_guide.full": "Completo",
|
||||
"horizon.options.gameplay_guide.off": "Desligado",
|
||||
"horizon.controller.guide.use": "Usar",
|
||||
"horizon.controller.guide.open": "Abrir",
|
||||
"horizon.controller.guide.ride": "Montar",
|
||||
"horizon.controller.guide.trade": "Negociar",
|
||||
"horizon.controller.guide.feed": "Alimentar",
|
||||
"horizon.controller.guide.place": "Colocar",
|
||||
"horizon.controller.guide.eat": "Comer",
|
||||
"horizon.controller.guide.drink": "Beber",
|
||||
"horizon.controller.guide.shoot": "Atirar",
|
||||
"horizon.controller.guide.load": "Carregar",
|
||||
"horizon.controller.guide.throw": "Arremessar",
|
||||
"horizon.controller.guide.raise": "Erguer",
|
||||
"horizon.controller.guide.equip": "Equipar",
|
||||
"horizon.controller.guide.fish": "Pescar",
|
||||
"horizon.controller.guide.zoom": "Zoom",
|
||||
"horizon.controller.guide.boost": "Acelerar",
|
||||
"horizon.controller.guide.break": "Quebrar",
|
||||
"horizon.controller.guide.attack": "Atacar",
|
||||
"horizon.controller.guide.spectate": "Assistir",
|
||||
"horizon.controller.guide.spectator_menu": "Menu de espectador",
|
||||
"horizon.controller.guide.release_shoot": "Solte para atirar",
|
||||
"horizon.controller.guide.release_throw": "Solte para arremessar",
|
||||
"horizon.controller.guide.release_stop": "Solte para parar",
|
||||
"horizon.controller.guide.fly": "Voar",
|
||||
"horizon.controller.guide.fly_up": "Subir voando",
|
||||
"horizon.controller.guide.fly_down": "Descer voando",
|
||||
"horizon.controller.guide.swim_up": "Subir nadando",
|
||||
"horizon.controller.guide.swim_down": "Descer nadando",
|
||||
"horizon.controller.guide.glide": "Planejar",
|
||||
"horizon.controller.guide.dismount": "Desmontar",
|
||||
"horizon.controller.guide.stand": "Levantar-se",
|
||||
"horizon.controller.guide.drop": "Soltar item",
|
||||
"horizon.controller.guide.swap": "Trocar de mão",
|
||||
"horizon.controller.guide.take": "Pegar",
|
||||
"horizon.controller.guide.take_half": "Pegar metade",
|
||||
"horizon.controller.guide.put": "Depositar",
|
||||
"horizon.controller.guide.put_one": "Depositar um",
|
||||
"horizon.controller.guide.swap_stack": "Trocar",
|
||||
"horizon.controller.guide.quick_move": "Mover rápido",
|
||||
"horizon.controller.guide.drop_stack": "Soltar pilha",
|
||||
"horizon.controller.guide.place_recipe": "Colocar receita",
|
||||
"horizon.controller.guide.place_recipe_all": "Colocar tudo",
|
||||
"horizon.controller.guide.alternatives": "Alternativas",
|
||||
"horizon.controller.guide.select": "Selecionar",
|
||||
"horizon.controller.guide.close": "Fechar",
|
||||
"horizon.controller.guide.pages": "Páginas",
|
||||
"horizon.controller.guide.categories": "Categorias",
|
||||
"horizon.controller.guide.tabs": "Abas",
|
||||
"horizon.options.lightbar": "Barra de luzes",
|
||||
"horizon.options.look_sensitivity": "Sensibilidade da câmera",
|
||||
"horizon.options.invert_x": "Inverter câmera X",
|
||||
"horizon.options.invert_y": "Inverter câmera Y",
|
||||
"horizon.options.craft_on_servers": "Criação rápida em servidores",
|
||||
"horizon.controller.direct_craft": "Criação rápida",
|
||||
"horizon.options.sneak_button": "Botão de agachar",
|
||||
"horizon.options.move_deadzone": "Zona morta do movimento",
|
||||
"horizon.options.look_deadzone": "Zona morta da câmera",
|
||||
"horizon.options.trigger_threshold": "Limiar do gatilho",
|
||||
"horizon.options.button_glyphs": "Glifos de botão",
|
||||
"horizon.options.on_screen_keyboard": "Teclado na tela",
|
||||
"horizon.options.chat_delay": "Atraso no bate-papo",
|
||||
"horizon.options.lightbar.health": "Saúde",
|
||||
"horizon.options.lightbar.dimension": "Dimensão",
|
||||
"horizon.options.glyphs.auto": "Automático",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "Playstation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.nintendo_buttons": "Botões da Nintendo",
|
||||
"horizon.options.nintendo_buttons.position": "Por posição",
|
||||
"horizon.options.nintendo_buttons.letter": "Por letra",
|
||||
"horizon.options.keyboard.auto": "Automático",
|
||||
"horizon.options.keyboard.horizon": "Horizonte",
|
||||
"horizon.options.keyboard.steam": "Vapor",
|
||||
"horizon.options.unavailable": "Este recurso ainda não está disponível no Horizon.",
|
||||
"horizon.options.search": "Configurações de pesquisa...",
|
||||
"horizon.studio.record.usage": "Uso: /studio record <start [nome]|stop|status>",
|
||||
"horizon.studio.record.started": "Gravação do estúdio iniciada: %s",
|
||||
"horizon.studio.record.saved": "Gravação do estúdio salva: %s",
|
||||
"horizon.studio.record.error": "Erro na gravação do estúdio: %s",
|
||||
"horizon.studio.record.status.active": "Gravação do estúdio ativa — %s s, %s pacotes, %s KB (%s)",
|
||||
"horizon.studio.record.status.inactive": "Gravação do estúdio inativa",
|
||||
"horizon.studio.replay.usage": "Uso: /studio replay <open CAMINHO|pause|resume|speed MULTIPLICADOR|seek MS|status|close>",
|
||||
"horizon.studio.replay.open.usage": "Uso: /studio replay open <CAMINHO>",
|
||||
"horizon.studio.replay.opened": "Reprodução do estúdio aberta: %s (%s s, %s pacotes)",
|
||||
"horizon.studio.replay.open_error": "Não foi possível abrir a reprodução: %s",
|
||||
"horizon.studio.replay.paused": "Reprodução do estúdio pausada",
|
||||
"horizon.studio.replay.resumed": "Reprodução do estúdio retomada",
|
||||
"horizon.studio.replay.inactive": "Nenhuma reprodução do estúdio ativa",
|
||||
"horizon.studio.replay.speed.usage": "Uso: /studio replay speed <MULTIPLICADOR>",
|
||||
"horizon.studio.replay.speed.set": "Velocidade da reprodução do estúdio definida para %s×",
|
||||
"horizon.studio.replay.speed.invalid": "Velocidade de reprodução inválida",
|
||||
"horizon.studio.replay.seek.usage": "Uso: /studio replay seek <MILISSEGUNDOS>",
|
||||
"horizon.studio.replay.seeked": "Reprodução do estúdio avançada para %s ms (%s s)",
|
||||
"horizon.studio.replay.seek.error": "Não foi possível avançar a reprodução: %s",
|
||||
"horizon.studio.replay.seek.backward_requires_standalone": "Retroceder só está disponível para uma reprodução autônoma.",
|
||||
"horizon.studio.replay.seek.invalid": "Posição de reprodução inválida",
|
||||
"horizon.studio.replay.status": "Reprodução do estúdio: %s / %s ms (%s%%), velocidade %s×, pausada %s, concluída %s",
|
||||
"horizon.studio.replay.closed": "Reprodução do estúdio fechada",
|
||||
"horizon.studio.freecam.enabled": "Câmera livre do estúdio ativada. Use WASD para se mover, Espaço e Shift para subir ou descer, e Control para ir mais rápido.",
|
||||
"horizon.studio.freecam.disabled": "Câmera livre do estúdio desativada",
|
||||
"horizon.studio.freecam.unavailable": "A câmera livre do estúdio não encontrou uma câmera de jogador",
|
||||
"horizon.studio.freecam.replay_required": "A câmera livre do estúdio fica disponível durante a reprodução de uma gravação",
|
||||
"horizon.studio.freecam.usage": "Uso: /studio freecam <on|off>",
|
||||
"horizon.studio.keyframe.added": "Quadro-chave de câmera adicionado em %s ms. O caminho é salvo quando você fecha a reprodução.",
|
||||
"horizon.studio.keyframe.cleared": "Caminho da câmera limpo. A alteração é salva quando você fecha a reprodução.",
|
||||
"horizon.studio.keyframe.removed": "Quadro-chave de câmera %s removido. A alteração é salva quando você fecha a reprodução.",
|
||||
"horizon.studio.keyframe.save_error": "Não foi possível salvar o caminho da câmera: %s",
|
||||
"horizon.studio.keyframe.remove_usage": "Uso: /studio keyframe remove <ÍNDICE>",
|
||||
"horizon.studio.keyframe.index_invalid": "O índice do quadro-chave de câmera está fora do intervalo",
|
||||
"horizon.studio.keyframe.usage": "Uso: /studio keyframe <add|remove ÍNDICE|clear>",
|
||||
"horizon.studio.path.locked": "Reprodução do caminho da câmera do estúdio ativada",
|
||||
"horizon.studio.path.unlocked": "Reprodução do caminho da câmera do estúdio parada",
|
||||
"horizon.studio.path.usage": "Uso: /studio path <lock|unlock> (adicione pelo menos um quadro-chave primeiro)",
|
||||
"horizon.pages.mods": "Modificações",
|
||||
"horizon.pages.shader_packs": "Pacotes de sombreadores",
|
||||
"horizon.pages.shader_options": "Opções de sombreamento",
|
||||
@@ -31,6 +131,7 @@
|
||||
"horizon.pages.controller_binds": "Ligações de controlador",
|
||||
"horizon.pages.extras": "Extras Horizonte",
|
||||
"horizon.pages.settings": "Configurações do Horizon",
|
||||
"horizon.pages.studio": "Replay Studio",
|
||||
"horizon.pages.agent": "Agente de IA",
|
||||
"horizon.split.leave": "Sair",
|
||||
"horizon.pages.split_settings": "Configurações de tela dividida",
|
||||
@@ -51,6 +152,7 @@
|
||||
"horizon.pages.music.empty": "Sem faixas de música: os sons do jogo não são carregados.",
|
||||
"horizon.pages.music.tooltip": "As faixas integradas, por situação: toque uma agora ou desligue alguma para que o jogo pare de selecioná-la.",
|
||||
"horizon.pages.settings.tooltip": "Configurações próprias do Horizon: gráficos, controladores, extras e mods.",
|
||||
"horizon.pages.studio.tooltip": "Navegue pelas gravações salvas e seus metadados.",
|
||||
"horizon.pages.graphics.tooltip": "Gráficos, simplesmente: cada configuração diz quanto custa.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Todas as configurações gráficas, incluindo Horizon, com o custo de cada uma.",
|
||||
"horizon.pages.controller.tooltip": "Controladores: seus botões, manípulos, vibração e navegação no menu.",
|
||||
@@ -65,11 +167,20 @@
|
||||
"horizon.pages.open.shader_packs": "Pacotes de sombreadores...",
|
||||
"horizon.pages.open.controller": "Configurações do controlador...",
|
||||
"horizon.pages.open.split_setup": "Tela dividida...",
|
||||
"horizon.pages.open.extras": "Extras...",
|
||||
"horizon.pages.open.mods": "Modos...",
|
||||
"horizon.pages.open.agent": "Agente de IA...",
|
||||
"horizon.pages.open.horizon_graphics": "Gráficos Horizonte...",
|
||||
"horizon.pages.open.controller_binds": "Ligações do controlador...",
|
||||
"horizon.pages.open.horizon_settings": "Configurações do Horizon...",
|
||||
"horizon.pages.open.studio": "Replay Studio...",
|
||||
"horizon.studio.saved_recordings": "Gravações Salvas",
|
||||
"horizon.studio.empty": "Nenhuma gravação encontrada na pasta de gravações.",
|
||||
"horizon.studio.empty_hint": "Use /studio record start <nome> em um mundo, ou coloque arquivos .mcpr lá.",
|
||||
"horizon.studio.open_folder": "Abrir gravações",
|
||||
"horizon.studio.open_replay": "Reproduzir gravação",
|
||||
"horizon.studio.refresh": "Atualizar",
|
||||
"horizon.studio.unknown_server": "Desconhecido",
|
||||
"horizon.pages.open.music": "Música...",
|
||||
"horizon.mods.empty": "Sem mods: coloque os arquivos .hmod na pasta horizonte-mods",
|
||||
"horizon.mods.stopped": " (Parado)",
|
||||
@@ -78,6 +189,28 @@
|
||||
"horizon.mods.settings": "Configurações",
|
||||
"horizon.mods.in_world": " (deixe o mundo mudar)",
|
||||
"horizon.mods.open_folder": "Abra a pasta Mod",
|
||||
"horizon.mods.browse": "Explorar mods...",
|
||||
"horizon.mods.browse.title": "Explorar mods",
|
||||
"horizon.mods.browse.search": "Pesquisar mods...",
|
||||
"horizon.mods.browse.project": "Mod",
|
||||
"horizon.mods.browse.loading": "Lendo o registro de mods do Horizon...",
|
||||
"horizon.mods.browse.failed": "Não foi possível ler o registro de mods: %s",
|
||||
"horizon.mods.browse.none": "Nenhum mod encontrado",
|
||||
"horizon.mods.browse.has_update": " (atualização)",
|
||||
"horizon.mods.browse.is_installed": " (instalado)",
|
||||
"horizon.mods.browse.by": "por %s",
|
||||
"horizon.mods.browse.installing": "Instalando...",
|
||||
"horizon.mods.browse.install": "Instalar",
|
||||
"horizon.mods.browse.update": "Atualizar",
|
||||
"horizon.mods.browse.installed": "Instalado",
|
||||
"horizon.mods.browse.open_page": "Abrir página",
|
||||
"horizon.mods.browse.enabled": "Mod",
|
||||
"horizon.mods.browse.changelog": "Registro de alterações",
|
||||
"horizon.mods.browse.done": "%s %s está instalado e ligado.",
|
||||
"horizon.mods.browse.install_failed": "Não foi possível instalar: %s",
|
||||
"horizon.mods.switch.worldgen": "muda a geração do mundo",
|
||||
"horizon.mods.switch.registries": "adiciona blocos, itens ou outras entradas de registro",
|
||||
"horizon.mods.switch.restart": "Reinicie o jogo para aplicar %s: eles adicionam blocos, itens ou geração de mundo.",
|
||||
"horizon.shader_options.profile": "Perfil",
|
||||
"horizon.agent.copy_details": "Copiar detalhes da conexão",
|
||||
"horizon.multiplayer.join": "Junte-se",
|
||||
@@ -89,6 +222,7 @@
|
||||
"LOD Distance": "Distância de LOD",
|
||||
"Far Detail": "Detalhe distante",
|
||||
"Cave Culling": "Abate em Caverna",
|
||||
"GPU Culling": "Abate na GPU",
|
||||
"Entity Culling": "Seleção de entidade",
|
||||
"Chunk Updates": "Atualizações de blocos",
|
||||
"Upload Budget": "Carregar orçamento",
|
||||
@@ -100,11 +234,17 @@
|
||||
"Frame Pacing": "Ritmo de quadro",
|
||||
"Worker Threads": "Threads de trabalho",
|
||||
"Resolution Scale": "Escala de resolução",
|
||||
"Anti-aliasing": "Antisserrilhado",
|
||||
"Upscaling": "Aumento de escala",
|
||||
"Upscaling Sharpness": "Nitidez do aumento de escala",
|
||||
"Paper White": "Papel Branco",
|
||||
"Peak Brightness": "Brilho máximo",
|
||||
"Black Level": "Nível de preto",
|
||||
"UI Brightness": "Brilho da IU",
|
||||
"HDR Lighting": "Iluminação HDR",
|
||||
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Fontes de luz, o sol e a luz solar brilham além do branco. Desligado: igual ao SDR.",
|
||||
"Smooth Gradients": "Gradientes suaves",
|
||||
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "Quadro de 16 bits: céus escuros e sombras não formam faixas. Um pouco mais de VRAM. Próxima inicialização.",
|
||||
"Battery Mode": "Modo Bateria",
|
||||
"Graphics API": "API gráfica",
|
||||
"Translucent Batching": "Lote Translúcido",
|
||||
@@ -118,6 +258,7 @@
|
||||
"Performance": "Desempenho",
|
||||
"Balanced": "Equilibrado",
|
||||
"Quality": "Qualidade",
|
||||
"Ultra": "Ultra",
|
||||
"Ultra Performance": "Ultradesempenho",
|
||||
"Native": "Nativo",
|
||||
"Full": "Completo",
|
||||
@@ -125,22 +266,32 @@
|
||||
"Efficient": "Eficiente",
|
||||
"Precise": "Preciso",
|
||||
"Auto (VSync)": "Automático (VSync)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "Caixa de correio",
|
||||
"Immediate": "Imediato",
|
||||
"Vulkan": "Vulcano",
|
||||
"DirectX 12": "DirectX12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS e gráfico",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "Sem custo",
|
||||
"Low cost": "Baixo custo",
|
||||
"Medium cost": "Custo médio",
|
||||
"High cost": "Alto custo",
|
||||
"Saves time": "Economiza tempo",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "Gráficos",
|
||||
"Done": "Concluído",
|
||||
"This feature is not yet available in Horizon.": "Este recurso ainda não está disponível no Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Seu monitor ou sistema não oferece saída HDR.",
|
||||
"Turn HDR on to use it.": "Ative o HDR para usá-lo.",
|
||||
"Your GPU or graphics API doesn't support it.": "Sua GPU ou API gráfica não oferece suporte.",
|
||||
"Not with software rendering, which runs it on the CPU.": "Não com renderização por software, que o executa na CPU.",
|
||||
"Out of video memory: off until the next start.": "Sem memória de vídeo: desligado até a próxima inicialização.",
|
||||
"The graphics device was lost: off until the next start.": "O dispositivo gráfico foi perdido: desligado até a próxima inicialização.",
|
||||
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Ainda não no DirectX 12: tente a API gráfica Vulkan.",
|
||||
"%s chunks": "%s pedaços",
|
||||
"%s per frame": "%s por quadro",
|
||||
"%s MiB/frame": "%s MiB/quadro",
|
||||
@@ -163,6 +314,8 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Até onde mobs, colheitas e máquinas continuam trabalhando. Tempo de CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "A que distância os mobs e os itens são atraídos, em proporção ao normal.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Ignora terreno escondido atrás de seções fechadas. Fora custa muito.",
|
||||
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Abate o terreno próximo na GPU: menos CPU com distância de renderização alta, mais GPU.",
|
||||
"Auto: tries it where your CPU limits the frame, keeps it if frames get faster.": "Automático: tenta onde sua CPU limita o quadro e mantém se os quadros ficarem mais rápidos.",
|
||||
"Hides entities behind opaque blocks.": "Oculta entidades atrás de blocos opacos.",
|
||||
"No entity occlusion culling.": "Nenhuma seleção de oclusão de entidade.",
|
||||
"CPU occlusion test runs this frame.": "O teste de oclusão da CPU executa este quadro.",
|
||||
@@ -186,6 +339,7 @@
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Pixels mundiais por pixel da tela. Abaixo de 100% é mais rápido na GPU.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Suaviza bordas irregulares. FXAA: mais suave. TAA: coisas mais suaves e em movimento podem deixar rastros.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Torna o mundo menor e o reconstrói com TAA. Mais rápido, mais suave.",
|
||||
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Deixa a imagem ampliada mais nítida. Mais alto: bordas mais nítidas, mais cintilação.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Saída HDR em uma tela HDR: lava, fogo e luz solar brilham mais intensamente.",
|
||||
"How frames reach the screen while VSync is off.": "Como os frames chegam à tela enquanto o VSync está desligado.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Eficiente dorme até o próximo quadro; Giros precisos, como baunilha.",
|
||||
@@ -205,6 +359,7 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "Taxa de quadros e tempos de quadros ao longo do jogo, com gráfico.",
|
||||
"horizon.multiplayer.finding_friend": "Encontrando o mundo do seu amigo...",
|
||||
"horizon.multiplayer.not_signed_in": "Este servidor está em modo online. Faça login com sua conta da Microsoft no inicializador do Horizon para ingressar.",
|
||||
"horizon.realms.not_signed_in": "Entre com sua conta Microsoft no launcher do Horizon para jogar nos Realms.",
|
||||
"horizon.multiplayer.invalid_address": "Endereço de servidor inválido: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Conectado diretamente ao seu amigo...",
|
||||
"horizon.multiplayer.connected_friend_relay": "Conectado ao seu amigo através do relé...",
|
||||
@@ -215,6 +370,7 @@
|
||||
"Shortcut": "Atalho",
|
||||
"Distance": "Distância",
|
||||
"Display": "Exibição",
|
||||
"Interface": "Interface",
|
||||
"Sky and weather": "Céu e clima",
|
||||
"Lighting and entities": "Iluminação e entidades",
|
||||
"Textures": "Texturas",
|
||||
@@ -257,9 +413,12 @@
|
||||
"horizon.extras.no_ai": "Sem IA",
|
||||
"horizon.extras.agent_control": "Controle de Agente",
|
||||
"horizon.extras.discord_presence": "Presença rica em discórdia",
|
||||
"horizon.extras.zoom": "Zoom",
|
||||
"horizon.extras.zoom_mod_install": "Zoom: instale em Mods",
|
||||
"horizon.extras.splitscreen": "Tela dividida",
|
||||
"horizon.extras.chat_animation": "Bate-papo tranquilo",
|
||||
"horizon.extras.chat_indicator": "Indicador de mensagem",
|
||||
"horizon.extras.wheel_cycles_list_settings.tooltip": "A roda de rolagem sobre um botão em uma lista de configurações altera seu valor. Desligado, como na vanilla, a roda rola a lista.",
|
||||
"horizon.extras.wheel_cycles_settings": "A roda de rolagem altera as configurações",
|
||||
"horizon.extras.pack_browser": "Navegador de pacote de recursos",
|
||||
"horizon.extras.fullbright.tooltip": "Mostre blocos e entidades em plena luz, mesmo em áreas escuras.",
|
||||
@@ -320,6 +479,7 @@
|
||||
"horizon.friends.stopped": "Parou de compartilhar o código de convite.",
|
||||
"horizon.friends.short_code_changed": "Alguém continuou tentando um código curto errado, então ele mudou.",
|
||||
"horizon.friends.wrong_code": "Alguém tentou um código errado.",
|
||||
"horizon.friends.relay_stopped": "Muitos códigos curtos errados, então o relé fica desligado até você compartilhar de novo. Amigos ainda podem entrar com o código completo.",
|
||||
"horizon.friends.code_ready": "Seu código de convite está pronto: Menu do Jogo, Jogue com Amigos. Copie e envie para seus amigos.",
|
||||
"horizon.friends.share_failed": "Não foi possível compartilhar o mundo: %s",
|
||||
"horizon.friends.finding_route": "Descobrir como os amigos podem entrar em contato com você…",
|
||||
@@ -339,6 +499,7 @@
|
||||
"horizon.splitscreen.setup.new_window": "Nova janela",
|
||||
"horizon.splitscreen.setup.fill_monitor": "Preencha %s",
|
||||
"horizon.splitscreen.setup.done": "Concluído",
|
||||
"horizon.splitscreen.setup.switch": "%s: %s",
|
||||
"horizon.splitscreen.setup.on": "Ligado",
|
||||
"horizon.splitscreen.setup.off": "Desligado",
|
||||
"horizon.splitscreen.setup.player": "Jogador %s",
|
||||
@@ -396,6 +557,9 @@
|
||||
"horizon.splitscreen.setup.help.give_up_pad": "Seu controlador para de dirigir você e é convidado a entrar (A).",
|
||||
"horizon.splitscreen.setup.note.held_at": "%s a %s: A deixa cair aqui, B coloca de volta.",
|
||||
"horizon.splitscreen.setup.note.held": "%s pegou: esquerda/direita escolhe onde, A deixa cair, B coloca de volta.",
|
||||
"horizon.splitscreen.online_only": "Este servidor está no modo online: perfis off-line não podem entrar nele, cada jogador precisa da própria conta Minecraft.",
|
||||
"horizon.splitscreen.own_account": "Este servidor precisa da conta Minecraft de cada jogador",
|
||||
"horizon.splitscreen.local_only": "Outros jogadores só podem entrar neste servidor com a própria conta Minecraft: perfis off-line jogam no seu mundo, na LAN ou em um servidor na sua rede local.",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Oculta modelos atrás de blocos opacos. Async verifica fora do thread; entidades marcam.",
|
||||
"Block Entities": "Bloquear entidades",
|
||||
"Shulker Boxes": "Caixas Shulker",
|
||||
@@ -414,6 +578,8 @@
|
||||
"Reuses statue geometry until its block state changes.": "Reutiliza a geometria da estátua até que o estado do bloco mude.",
|
||||
"Leaf Culling": "Abate de folhas",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Cada rosto de folha. Folhas elegantes de baunilha.",
|
||||
"Opaque Ice": "Gelo opaco",
|
||||
"Draws ice in the solid pass, without its see-through texture.": "Desenha o gelo na passagem sólida, sem sua textura transparente.",
|
||||
"Distant Plant Dither": "Vibração de plantas distantes",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Evita que plantas finas em embalagens de alta resolução desapareçam ao longe.",
|
||||
"Screenshot Quality": "Qualidade da captura de tela",
|
||||
@@ -421,7 +587,18 @@
|
||||
"Frame Generation": "Geração de quadros",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Mostra um quadro composto entre cada dois quadros reais: mais suave, um pouco mais de atraso.",
|
||||
"Shadow Cache": "Cache de sombra",
|
||||
"Shaders": "Sombreadores",
|
||||
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Pacotes de sombreadores, do Horizon e da pasta shaders/, e suas opções.",
|
||||
"Shader packs": "Pacotes de sombreadores",
|
||||
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "Predefinição da própria vanilla: define apenas as opções da vanilla, como o Minecraft faz.",
|
||||
"Fullscreen takes the display over, at the mode below.": "A tela cheia assume o monitor, no modo abaixo.",
|
||||
"The display mode exclusive fullscreen uses. Applies when you leave.": "O modo de exibição que a tela cheia exclusiva usa. Aplica-se quando você sai.",
|
||||
"Blurs the world behind menus.": "Desfoca o mundo atrás dos menus.",
|
||||
"How textures are sampled: sharper at a slant with Anisotropic.": "Como as texturas são amostradas: mais nítidas em ângulo com o modo Anisotrópico.",
|
||||
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "Até onde vai a filtragem anisotrópica. Mais alto é mais nítido em ângulo.",
|
||||
"Only with Anisotropic texture filtering.": "Apenas com filtragem de textura anisotrópica.",
|
||||
"Shader Packs...": "Pacotes de sombreadores...",
|
||||
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "Escolhe um pacote de sombreadores, do Horizon ou da pasta shaders/, e define suas opções.",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Reutiliza o mapa de sombras do pacote quando o sol, a visão e o mundo correspondem. Desativado por padrão.",
|
||||
"Find BlueMap": "Encontre o BlueMap",
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.": "Nos servidores, encontra seu mapa da web BlueMap para terrenos distantes. Próxima adesão.",
|
||||
@@ -429,6 +606,8 @@
|
||||
"How far around you BlueMap tiles are downloaded. Next join.": "A que distância ao seu redor os blocos do BlueMap são baixados. Próxima adesão.",
|
||||
"Synchronous": "Síncrono",
|
||||
"Asynchronous": "Assíncrono",
|
||||
"Hidden Faces": "Faces ocultas",
|
||||
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Descarta faces de folhas atrás de duas camadas de folhas: mais rápido, as mesmas copas.",
|
||||
"Solid": "Sólido",
|
||||
"Solid Aggressive": "Sólido Agressivo",
|
||||
"Hollow": "Oco",
|
||||
@@ -450,6 +629,10 @@
|
||||
"Auto (%s nits)": "Automático (%s lêndeas)",
|
||||
"%.2f nits": "%.2f lêndeas",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Oculta modelos atrás de blocos opacos. Atualização de entidades. Saia com pacotes personalizados.",
|
||||
"Entity Culling Precheck": "Pré-verificação do abate de entidades",
|
||||
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Executa a pré-verificação AABB de mobs após esta quantidade de mobs carregados. Zero a desativa.",
|
||||
"%s mobs": "%s mobs",
|
||||
"Only applies to asynchronous entity culling.": "Aplica-se apenas ao abate assíncrono de entidades.",
|
||||
"horizon.extras.repro_keys": "Atalhos de gravação",
|
||||
"horizon.extras.repro_keys.tooltip": "F3+R inicia ou interrompe uma gravação de reprodução e F3+E grava seu pacote. Desativado por padrão: uma gravação copia o mundo.",
|
||||
"horizon.agent.record_session": "Iniciar sessão de gravação",
|
||||
@@ -461,6 +644,17 @@
|
||||
"horizon.repro.error": "Reprodução: %s",
|
||||
"horizon.repro.no_recording": "Ainda não há gravação para exportar.",
|
||||
"horizon.repro.replay_started": "Repetição iniciada: %s",
|
||||
"horizon.splitscreen.profiles.offline": " (Off-line)",
|
||||
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
|
||||
"horizon.splitscreen.profiles.sign_in": "Entre novamente no launcher",
|
||||
"horizon.splitscreen.profiles.online": "Microsoft on-line",
|
||||
"horizon.splitscreen.account_unavailable": "%s está off-line. Entre novamente no launcher e reinicie o jogo.",
|
||||
"horizon.splitscreen.account_expired": "A sessão Microsoft de %s expirou. Entre novamente no launcher e reinicie o jogo.",
|
||||
"Particle Culling": "Abate de partículas",
|
||||
"Draws every particle.": "Desenha todas as partículas.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "Ignora partículas fora da tela, subpixel e escondidas pelo terreno.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Ignora partículas fora da tela, subpixel e escondidas pelo terreno com menos trabalho na thread de renderização.",
|
||||
"horizon.pack.add": "+",
|
||||
"horizon.pack.addInfo": "Adicione um pacote de recursos do Modrinth",
|
||||
"horizon.pack.addWorking": "Perguntando a Modrinth...",
|
||||
"horizon.pack.addNone": "Nenhum pacote de recursos encontrado",
|
||||
@@ -474,10 +668,17 @@
|
||||
"horizon.pack.filter.realistic": "Realista",
|
||||
"horizon.pack.filter.simplistic": "Simplista",
|
||||
"horizon.pack.filter.vanillaLike": "Semelhante à baunilha",
|
||||
"horizon.pack.filter.x16": "16x",
|
||||
"horizon.pack.filter.x32": "32x",
|
||||
"horizon.pack.filter.x64": "64x",
|
||||
"horizon.pack.addFailed": "O pacote não pôde ser adicionado: %s",
|
||||
"horizon.extras.header.cosmetics": "Cosméticos",
|
||||
"horizon.extras.cosmetics_mine": "Meus cosméticos",
|
||||
"horizon.extras.cosmetics_mine.tooltip": "Oculto: todo jogador do Horizon vê você sem seus cosméticos, inclusive você. A mesma opção do guarda-roupa do lançador.",
|
||||
"key.emote": "Roda de emotes",
|
||||
"horizon.emote_wheel.title": "Emotes",
|
||||
"horizon.emote_wheel.empty": "Nenhum emote ainda",
|
||||
"horizon.emote_wheel.help": "Solte ou clique para reproduzir · 1–8 · Esc fecha",
|
||||
"horizon.extras.cosmetics_others": "Cosméticos de outros jogadores",
|
||||
"horizon.extras.cosmetics_others.tooltip": "O que os outros jogadores do Horizon usam: tudo, apenas os estilos dos crachás ou nada. Escondido, o jogo nunca pergunta o que eles vestem.",
|
||||
"horizon.extras.cosmetics_hide_fx": "Ocultar rastros e auras de outros jogadores",
|
||||
@@ -501,5 +702,348 @@
|
||||
"Pack settings": "Configurações do pacote",
|
||||
"Built-in settings": "Configurações internas",
|
||||
"%s settings": "Configurações de %s",
|
||||
"Settings made by the pack's author: they change only this pack.": "Configurações criadas pelo autor do pacote: elas mudam apenas este pacote."
|
||||
"Settings made by the pack's author: they change only this pack.": "Configurações criadas pelo autor do pacote: elas mudam apenas este pacote.",
|
||||
"horizon.moderation.duration.minute": "%s minuto",
|
||||
"horizon.moderation.duration.minutes": "%s minutos",
|
||||
"horizon.moderation.duration.hour": "%s hora",
|
||||
"horizon.moderation.duration.hours": "%s horas",
|
||||
"horizon.moderation.duration.day": "%s dia",
|
||||
"horizon.moderation.duration.days": "%s dias",
|
||||
"horizon.moderation.reason": "%1$s: %2$s",
|
||||
"horizon.moderation.mutelist.empty": "Não há ninguém silenciado",
|
||||
"horizon.moderation.mutelist.count": "Há %s silenciado(s):",
|
||||
"horizon.moderation.chat_filter": "O filtro de bate-papo",
|
||||
"horizon.moderation.until_unmuted": "até ser liberado",
|
||||
"horizon.moderation.remaining": "por mais %s",
|
||||
"horizon.moderation.mutelist.entry": "%2$s silenciou %1$s %3$s",
|
||||
"horizon.moderation.invalid_duration": "%s não é uma duração: 10m, 2h, 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <jogador> [duração] [motivo]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <jogador> <duração> [motivo]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <jogador>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
"horizon.moderation.for_duration": "por %s",
|
||||
"horizon.moderation.muted": "%1$s foi silenciado %2$s",
|
||||
"horizon.moderation.unmuted": "%s foi liberado",
|
||||
"horizon.moderation.not_muted": "%s não está silenciado",
|
||||
"horizon.tempban.usage": "/tempban <jogador> <duração> [motivo]",
|
||||
"horizon.tempban.invalid_duration": "%s não é uma duração: 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Banimentos temporários precisam de um servidor dedicado",
|
||||
"horizon.tempban.success": "%1$s por %2$s, até %3$s",
|
||||
"horizon.tempban.list.empty": "Não há banimentos temporários",
|
||||
"horizon.tempban.list.count": "Há %s banimento(s) temporário(s):",
|
||||
"horizon.tempban.list.entry": "%1$s foi banido por %2$s %3$s (até %4$s)",
|
||||
"horizon.options.pointer_speed": "Velocidade do ponteiro do menu",
|
||||
"horizon.options.scroll_speed": "Velocidade de rolagem do menu",
|
||||
"horizon.options.repeat_delay": "Atraso de repetição da navegação",
|
||||
"horizon.options.repeat_interval": "Intervalo de repetição da navegação",
|
||||
"horizon.options.milliseconds": "%s ms",
|
||||
"horizon.extras.cosmetics_solo": "No modo um jogador",
|
||||
"horizon.extras.cosmetics_solo.tooltip": "Mostre seus cosméticos e o emblema do seu crachá também nos seus mundos de um jogador (seu crachá com Crachá próprio). Desligado: apenas em servidores.",
|
||||
"horizon.extras.horizon_marks": "Logos do Horizon",
|
||||
"horizon.extras.horizon_marks.tooltip": "Mostra o logo do Horizon ao lado dos jogadores do Horizon: um pequeno logo antes dos nomes de outros jogadores que usam o Horizon, acima das cabeças deles e na lista de jogadores. Só jogadores do Horizon o veem: os servidores não recebem nada.",
|
||||
"horizon.extras.horizon_me": "Mostrar-me como Horizon",
|
||||
"horizon.extras.horizon_me.tooltip": "Mostra você como jogador do Horizon: outros jogadores do Horizon veem o logo ao lado do seu nome. Ligado, o serviço responde a quem perguntar sobre você que você jogou Horizon nas últimas 3 horas; desligado, ele nunca informa sobre você.",
|
||||
"horizon.pages.pvp": "PvP e HUD",
|
||||
"horizon.pages.pvp.tooltip": "Configurações de mira, HUD e PvP, todas desligadas por padrão. Nada aqui mostra o que a vanilla não mostra.",
|
||||
"horizon.pages.open.pvp": "PvP e HUD...",
|
||||
"horizon.pvp.header.crosshair": "Mira",
|
||||
"horizon.pvp.crosshair": "Mira personalizada",
|
||||
"horizon.pvp.crosshair_shape": "Forma",
|
||||
"horizon.pvp.crosshair_size": "Tamanho",
|
||||
"horizon.pvp.crosshair_gap": "Espaço",
|
||||
"horizon.pvp.crosshair_thickness": "Espessura",
|
||||
"horizon.pvp.crosshair_colour": "Cor",
|
||||
"horizon.pvp.crosshair_outline": "Contorno",
|
||||
"horizon.pvp.crosshair_dot": "Ponto central",
|
||||
"horizon.pvp.crosshair_hover": "Cor sobre entidades",
|
||||
"horizon.pvp.crosshair.tooltip": "Desenha a sua própria mira em vez da mira da vanilla. Suas primeiras configurações se parecem com as da vanilla.",
|
||||
"horizon.pvp.crosshair_shape.tooltip": "Uma cruz, um T, um X, um círculo, um quadrado ou apenas um ponto.",
|
||||
"horizon.pvp.crosshair_size.tooltip": "O comprimento de cada braço, em pixels da interface.",
|
||||
"horizon.pvp.crosshair_gap.tooltip": "O espaço vazio no meio.",
|
||||
"horizon.pvp.crosshair_thickness.tooltip": "A largura de cada linha.",
|
||||
"horizon.pvp.crosshair_colour.tooltip": "Invertido mostra a cor sob a mira invertida, como a mira da vanilla faz.",
|
||||
"horizon.pvp.crosshair_outline.tooltip": "Uma borda preta fina, para aparecer em qualquer fundo.",
|
||||
"horizon.pvp.crosshair_dot.tooltip": "Um ponto no meio da mira.",
|
||||
"horizon.pvp.crosshair_hover.tooltip": "Outra cor enquanto a mira está sobre um mob ou um jogador que você poderia atingir, como o indicador de ataque da vanilla reconhece.",
|
||||
"horizon.pvp.colour.inverted": "Invertido",
|
||||
"horizon.pvp.colour.white": "Branco",
|
||||
"horizon.pvp.colour.red": "Vermelho",
|
||||
"horizon.pvp.colour.orange": "Laranja",
|
||||
"horizon.pvp.colour.yellow": "Amarelo",
|
||||
"horizon.pvp.colour.green": "Verde",
|
||||
"horizon.pvp.colour.cyan": "Ciano",
|
||||
"horizon.pvp.colour.blue": "Azul",
|
||||
"horizon.pvp.colour.magenta": "Magenta",
|
||||
"horizon.pvp.colour.black": "Preto",
|
||||
"horizon.pvp.shape.cross": "Cruz",
|
||||
"horizon.pvp.shape.t": "T",
|
||||
"horizon.pvp.shape.x": "X",
|
||||
"horizon.pvp.shape.circle": "Círculo",
|
||||
"horizon.pvp.shape.square": "Quadrado",
|
||||
"horizon.pvp.shape.dot": "Ponto",
|
||||
"horizon.pvp.outline": "Contorno de bloco personalizado",
|
||||
"horizon.pvp.outline.tooltip": "Altera o contorno ao redor do bloco que você olha.",
|
||||
"horizon.pvp.outline_colour": "Cor",
|
||||
"horizon.pvp.outline_colour.tooltip": "A cor do contorno. Preto a 40% é o da vanilla.",
|
||||
"horizon.pvp.outline_opacity": "Opacidade",
|
||||
"horizon.pvp.outline_opacity.tooltip": "Quão sólido é o contorno.",
|
||||
"horizon.pvp.outline_thickness": "Espessura",
|
||||
"horizon.pvp.outline_thickness.tooltip": "A largura do contorno, em vezes a da vanilla.",
|
||||
"horizon.pvp.outline_fill": "Preenchimento",
|
||||
"horizon.pvp.outline_fill.tooltip": "Colore levemente as faces do bloco que você olha.",
|
||||
"horizon.pvp.hit": "Cor de acerto personalizada",
|
||||
"horizon.pvp.hit.tooltip": "Altera a cor que mobs e jogadores piscam quando são feridos. Vermelho a 30% é o da vanilla.",
|
||||
"horizon.pvp.hit_colour": "Cor",
|
||||
"horizon.pvp.hit_colour.tooltip": "A cor que mobs e jogadores feridos piscam.",
|
||||
"horizon.pvp.hit_opacity": "Opacidade",
|
||||
"horizon.pvp.hit_opacity.tooltip": "Quanto da cor os cobre.",
|
||||
"horizon.pvp.header.outline": "Contorno de bloco",
|
||||
"horizon.pvp.header.hit": "Cor de acerto",
|
||||
"horizon.pvp.hud_armour": "Armadura",
|
||||
"horizon.pvp.hud_armour.tooltip": "A armadura que você veste, começando pela cabeça, com quantos usos restam em cada peça.",
|
||||
"horizon.pvp.hud_armour_corner": "Canto da armadura",
|
||||
"horizon.pvp.hud_armour_corner.tooltip": "Onde a armadura aparece.",
|
||||
"horizon.pvp.hud_armour_percent": "Durabilidade em %",
|
||||
"horizon.pvp.hud_armour_percent.tooltip": "Usos restantes como porcentagem do máximo da peça, em vez de um número.",
|
||||
"horizon.pvp.hud_effects": "Efeitos",
|
||||
"horizon.pvp.hud_effects.tooltip": "Seus efeitos com nomes, níveis e tempo restante, no lugar dos ícones no canto superior direito.",
|
||||
"horizon.pvp.hud_effects_corner": "Canto dos efeitos",
|
||||
"horizon.pvp.hud_effects_corner.tooltip": "Onde os efeitos aparecem.",
|
||||
"horizon.pvp.hud_held": "Contagem do item na mão",
|
||||
"horizon.pvp.hud_held.tooltip": "Quantos itens da sua mão o inventário tem; flechas com arco ou besta.",
|
||||
"horizon.pvp.hud_held_corner": "Canto da contagem na mão",
|
||||
"horizon.pvp.hud_held_corner.tooltip": "Onde a contagem aparece.",
|
||||
"horizon.pvp.hud_background": "Fundos dos widgets",
|
||||
"horizon.pvp.hud_background.tooltip": "Uma caixa escura atrás de cada widget.",
|
||||
"horizon.pvp.hud_opacity": "Opacidade do fundo",
|
||||
"horizon.pvp.hud_opacity.tooltip": "Quão escuras são as caixas atrás dos widgets.",
|
||||
"horizon.pvp.hud_text_colour": "Cor do texto",
|
||||
"horizon.pvp.hud_text_colour.tooltip": "A cor do texto dos widgets. A durabilidade mantém o gradiente de verde a vermelho.",
|
||||
"horizon.pvp.hud_shadow": "Sombra do texto",
|
||||
"horizon.pvp.hud_shadow.tooltip": "Uma sombra sob o texto dos widgets, como o texto do HUD da vanilla tem.",
|
||||
"horizon.pvp.header.hud": "Widgets do HUD",
|
||||
"horizon.pvp.corner.top_left": "Superior esquerdo",
|
||||
"horizon.pvp.corner.top_right": "Superior direito",
|
||||
"horizon.pvp.corner.bottom_left": "Inferior esquerdo",
|
||||
"horizon.pvp.corner.bottom_right": "Inferior direito",
|
||||
"horizon.pvp.hud_ping": "Ping",
|
||||
"horizon.pvp.hud_ping.tooltip": "A latência que o servidor informa para você, em milissegundos. Apenas multijogador.",
|
||||
"horizon.pvp.hud_ping_corner": "Canto do ping",
|
||||
"horizon.pvp.hud_ping_corner.tooltip": "Onde o ping aparece.",
|
||||
"horizon.pvp.hud_coords": "Coordenadas",
|
||||
"horizon.pvp.hud_coords.tooltip": "Sua posição em blocos e a direção para onde você olha, como o F3 mostra. Oculto quando o mundo ou o servidor reduz o F3.",
|
||||
"horizon.pvp.hud_coords_corner": "Canto das coordenadas",
|
||||
"horizon.pvp.hud_coords_corner.tooltip": "Onde as coordenadas aparecem.",
|
||||
"horizon.pvp.hud_server": "Endereço do servidor",
|
||||
"horizon.pvp.hud_server.tooltip": "O endereço do servidor em que você joga. Apenas multijogador.",
|
||||
"horizon.pvp.hud_server_corner": "Canto do servidor",
|
||||
"horizon.pvp.hud_server_corner.tooltip": "Onde o endereço aparece.",
|
||||
"horizon.pvp.hud_combo": "Contador de combo",
|
||||
"horizon.pvp.hud_combo.tooltip": "Acertos seguidos que o servidor confirmou, até você ser ferido ou passarem 3 segundos sem um.",
|
||||
"horizon.pvp.hud_combo_corner": "Canto do combo",
|
||||
"horizon.pvp.hud_combo_corner.tooltip": "Onde o combo aparece.",
|
||||
"horizon.pvp.hud.ping": "Ping: %s ms",
|
||||
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
|
||||
"horizon.pvp.hud.facing": "Olhando para: %s",
|
||||
"horizon.pvp.hud.combo": "Combo: %s",
|
||||
"horizon.pvp.hud.direction.north": "Norte",
|
||||
"horizon.pvp.hud.direction.south": "Sul",
|
||||
"horizon.pvp.hud.direction.east": "Leste",
|
||||
"horizon.pvp.hud.direction.west": "Oeste",
|
||||
"horizon.pvp.hud_fps": "FPS",
|
||||
"horizon.pvp.hud_fps.tooltip": "Quadros desenhados no último segundo.",
|
||||
"horizon.pvp.hud_fps_corner": "Canto do FPS",
|
||||
"horizon.pvp.hud_fps_corner.tooltip": "Onde o FPS aparece.",
|
||||
"horizon.pvp.hud_cps": "Cliques por segundo",
|
||||
"horizon.pvp.hud_cps.tooltip": "Seus cliques esquerdo e direito no último segundo.",
|
||||
"horizon.pvp.hud_cps_corner": "Canto do CPS",
|
||||
"horizon.pvp.hud_cps_corner.tooltip": "Onde os cliques por segundo aparecem.",
|
||||
"horizon.pvp.hud_keys": "Teclas",
|
||||
"horizon.pvp.hud_keys.tooltip": "Suas teclas de movimento, botões do mouse e salto, acesos enquanto você os segura, com cliques por segundo.",
|
||||
"horizon.pvp.hud_keys_corner": "Canto das teclas",
|
||||
"horizon.pvp.hud_keys_corner.tooltip": "Onde as teclas aparecem.",
|
||||
"horizon.pvp.hud_clock": "Relógio",
|
||||
"horizon.pvp.hud_clock.tooltip": "A hora do seu computador.",
|
||||
"horizon.pvp.hud_clock_corner": "Canto do relógio",
|
||||
"horizon.pvp.hud_clock_corner.tooltip": "Onde o relógio aparece.",
|
||||
"horizon.pvp.hud_reach": "Alcance",
|
||||
"horizon.pvp.hud_reach.tooltip": "A que distância dos seus olhos seu último golpe acertou, por 3 segundos. Apenas mede: seu alcance continua o da vanilla.",
|
||||
"horizon.pvp.hud_reach_corner": "Canto do alcance",
|
||||
"horizon.pvp.hud_reach_corner.tooltip": "Onde o alcance aparece.",
|
||||
"horizon.pvp.hud_speed": "Velocidade",
|
||||
"horizon.pvp.hud_speed.tooltip": "Quão rápido você se move pelo chão, em blocos por segundo.",
|
||||
"horizon.pvp.hud_speed_corner": "Canto da velocidade",
|
||||
"horizon.pvp.hud_speed_corner.tooltip": "Onde a velocidade aparece.",
|
||||
"horizon.pvp.hud.fps": "%s FPS",
|
||||
"horizon.pvp.hud.cps": "CPS: %s | %s",
|
||||
"horizon.pvp.hud.reach": "Alcance: %s",
|
||||
"horizon.pvp.hud.speed": "Velocidade: %s b/s",
|
||||
"horizon.pvp.hud.keys.attack": "Esq.",
|
||||
"horizon.pvp.hud.keys.use": "Dir.",
|
||||
"horizon.pvp.hud.keys.cps": "%s CPS",
|
||||
"horizon.pvp.numeric_enchants": "Níveis de encantamento em números",
|
||||
"horizon.pvp.numeric_enchants.tooltip": "As dicas e a mesa de encantamento mostram Afiação 5 em vez de Afiação V.",
|
||||
"horizon.pvp.header.items": "Itens",
|
||||
"horizon.pvp.fire_lower": "Fogo mais baixo",
|
||||
"horizon.pvp.fire_lower.tooltip": "Baixa as chamas que você vê enquanto queima, para cobrirem menos a tela.",
|
||||
"horizon.pvp.shield_size": "Tamanho do escudo",
|
||||
"horizon.pvp.shield_size.tooltip": "O tamanho do escudo na sua mão, em primeira pessoa.",
|
||||
"horizon.pvp.totem_size": "Tamanho da animação do totem",
|
||||
"horizon.pvp.totem_size.tooltip": "O tamanho do totem que preenche a tela quando ele salva você.",
|
||||
"horizon.pvp.glint_colour": "Cor do brilho de encantamento",
|
||||
"horizon.pvp.glint_colour.tooltip": "A cor do brilho em itens e armaduras encantados. Desligado é o roxo da vanilla.",
|
||||
"horizon.pvp.clear_water": "Água límpida",
|
||||
"horizon.pvp.clear_water.tooltip": "Debaixo d'água, veja desde o início tão longe quanto a vanilla permite após 30 segundos ali.",
|
||||
"horizon.pvp.clear_lava": "Lava límpida",
|
||||
"horizon.pvp.clear_lava.tooltip": "Na lava, veja tão longe quanto a Resistência ao Fogo da vanilla permite, sem o efeito.",
|
||||
"horizon.pvp.header.visual": "Ajustes visuais",
|
||||
"horizon.pvp.view_time": "Hora do dia",
|
||||
"horizon.pvp.view_time.tooltip": "A hora do dia que você vê: apenas o céu e sua luz. O tempo do mundo continua e nada é enviado ao servidor.",
|
||||
"horizon.pvp.view_weather": "Clima",
|
||||
"horizon.pvp.view_weather.tooltip": "O clima que você vê: apenas a chuva, sua luz e o nevoeiro. O clima do mundo continua e nada é enviado ao servidor.",
|
||||
"horizon.pvp.header.view": "Sua visão",
|
||||
"horizon.pvp.view.world": "Do mundo",
|
||||
"horizon.pvp.time.morning": "Manhã",
|
||||
"horizon.pvp.time.noon": "Meio-dia",
|
||||
"horizon.pvp.time.sunset": "Pôr do sol",
|
||||
"horizon.pvp.time.midnight": "Meia-noite",
|
||||
"horizon.pvp.weather.clear": "Limpo",
|
||||
"horizon.pvp.weather.rain": "Chuva",
|
||||
"horizon.pvp.weather.thunder": "Trovoada",
|
||||
"horizon.chat.timestamp": "[%s] ",
|
||||
"horizon.chat.repeats": " (x%s)",
|
||||
"horizon.pvp.header.chat": "Bate-papo",
|
||||
"horizon.chat.copied": "Mensagem copiada",
|
||||
"horizon.chat.search": "Pesquisar no bate-papo:",
|
||||
"horizon.pvp.chat_timestamps": "Horários",
|
||||
"horizon.pvp.chat_timestamps.tooltip": "A hora em que cada mensagem chegou, antes dela.",
|
||||
"horizon.pvp.chat_compact": "Bate-papo compacto",
|
||||
"horizon.pvp.chat_compact.tooltip": "Uma mensagem repetida em sequência aparece uma vez, com quantas vezes chegou: (x3).",
|
||||
"horizon.pvp.chat_copy": "Copiar ao clicar",
|
||||
"horizon.pvp.chat_copy.tooltip": "Clicar em uma mensagem no bate-papo aberto copia seu texto, a menos que o clique faça outra coisa.",
|
||||
"horizon.pvp.chat_search": "Pesquisa no bate-papo",
|
||||
"horizon.pvp.chat_search.tooltip": "Ctrl+F no bate-papo aberto: o que você digita mostra apenas as mensagens que o contêm. Enter ou Ctrl+F de novo interrompe.",
|
||||
"horizon.pvp.preset": "Predefinição de PvP",
|
||||
"horizon.pvp.preset.tooltip": "Ativa a mira personalizada, os widgets de armadura e efeitos, a alternância de corrida e agachar, o fogo mais baixo e os mods de contador de FPS e teclas, se instalados. Suas configurações de cada um são mantidas.",
|
||||
"horizon.pvp.hud_editor_key": "Tecla do editor de HUD",
|
||||
"horizon.pvp.hud_editor_key.tooltip": "Abre o editor de HUD a partir do jogo. Clique e pressione uma tecla; Esc remove a atribuição.",
|
||||
"horizon.pvp.editor": "Editar HUD",
|
||||
"horizon.pvp.editor.tooltip": "Mova os widgets no jogo: arraste-os para qualquer lugar, redimensione-os com a roda e clique com o botão direito em um para devolvê-lo ao canto.",
|
||||
"horizon.pvp.editor.hint": "Arraste os widgets · Roda: tamanho · Botão direito: volta ao canto",
|
||||
"horizon.pvp.combat_feel": "Sensação de combate",
|
||||
"horizon.pvp.combat_feel.tooltip": "O Legado desenha as lutas como a 1.8.9: o item na sua mão, seu balanço, comer, o arco e uma espada erguida enquanto você segura o uso, sem a descida após cada golpe nem o indicador de ataque. Apenas a aparência: o que você envia a um servidor continua igual.",
|
||||
"horizon.pvp.combat_indicator": "Indicador de ataque no Legado",
|
||||
"horizon.pvp.combat_indicator.tooltip": "Mantém o indicador de ataque no Legado, que a 1.8.9 não tinha.",
|
||||
"horizon.pvp.combat_feel.modern": "Moderno",
|
||||
"horizon.pvp.combat_feel.legacy": "Legado (1.8.9)",
|
||||
"horizon.pvp.header.combat": "Sensação de combate",
|
||||
"horizon.pvp.combat_sounds": "Sons de acerto da 1.8",
|
||||
"horizon.pvp.combat_sounds.tooltip": "No Legado, deixa de fora os sons de ataque que a 1.9 adicionou (forte, fraco, varredura, crítico...). Os sons de dano permanecem.",
|
||||
"horizon.pvp.combat_rules": "Combate da 1.8 nos mundos que você hospeda",
|
||||
"horizon.pvp.combat_rules.tooltip": "O modo um jogador e os mundos que você abre para a LAN lutam pelas regras da 1.8.9: sem recarga de ataque nem varredura, dano de arma da 1.8, Força, Afiação e acertos críticos. Os servidores em que você entra mantêm as próprias regras.",
|
||||
"Bindings": "Ligações",
|
||||
"Play": "Jogar",
|
||||
"Device": "Dispositivo",
|
||||
"Look": "Câmera",
|
||||
"Movement": "Movimento",
|
||||
"Menus": "Menus",
|
||||
"Actions": "Ações",
|
||||
"Reset": "Redefinir",
|
||||
"Detected": "Detectado",
|
||||
"Glyphs": "Glifos",
|
||||
"Feedback": "Feedback",
|
||||
"Controller": "Controlador",
|
||||
"How the sticks turn and move you, and how menus repeat and scroll.": "Como os manípulos viram e movem você, e como os menus repetem e rolam.",
|
||||
"Which button does what. Each player has their own.": "Qual botão faz o quê. Cada jogador tem os seus.",
|
||||
"The controller the game sees, its button glyphs and its feedback.": "O controlador que o jogo vê, seus glifos de botão e seu feedback.",
|
||||
"Press this row, then the button that should do it.": "Pressione esta linha e depois o botão que deve fazer isso.",
|
||||
"Off, the game ignores every controller for this player.": "Desligado, o jogo ignora todos os controladores deste jogador.",
|
||||
"How fast the right stick turns the view.": "Quão rápido o manípulo direito vira a câmera.",
|
||||
"Turn the view the other way, left and right.": "Inverte a câmera na horizontal.",
|
||||
"Turn the view the other way, up and down.": "Inverte a câmera na vertical.",
|
||||
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Quanto o manípulo direito se move antes de a câmera virar. Aumente se a câmera se mover sozinha.",
|
||||
"Hold the button to sneak, or press it once to start and again to stop.": "Segure o botão para agachar, ou pressione-o uma vez para começar e de novo para parar.",
|
||||
"How far the left stick moves before you walk. Raise it if you drift.": "Quanto o manípulo esquerdo se move antes de você andar. Aumente se você se mover sozinho.",
|
||||
"How far a trigger is pulled before it counts as pressed.": "Quanto um gatilho precisa ser puxado para contar como pressionado.",
|
||||
"Shows what your buttons do right now, in play and in inventories.": "Mostra o que seus botões fazem agora, no jogo e nos inventários.",
|
||||
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "Mostra apenas o que você pode fazer com o que está olhando e os botões principais do inventário.",
|
||||
"Doesn't show what your buttons do.": "Não mostra o que seus botões fazem.",
|
||||
"Shows the buttons of the controller you're using.": "Mostra os botões do controlador que você está usando.",
|
||||
"Always shows Xbox buttons.": "Sempre mostra os botões do Xbox.",
|
||||
"Always shows PlayStation buttons.": "Sempre mostra os botões do PlayStation.",
|
||||
"Always shows Nintendo buttons.": "Sempre mostra os botões da Nintendo.",
|
||||
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A, à direita, confirma e pula, e B volta, como no Switch.",
|
||||
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B, embaixo, confirma e pula, e A volta, como nos outros controladores.",
|
||||
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Abre o teclado do Steam em um Steam Deck pelo Steam, e o do Horizon caso contrário.",
|
||||
"Always opens Horizon's keyboard.": "Sempre abre o teclado do Horizon.",
|
||||
"Always opens Steam's keyboard.": "Sempre abre o teclado do Steam.",
|
||||
"A DualSense's light shows your health, from green through amber to red.": "A luz de um DualSense mostra sua saúde, do verde passando pelo âmbar até o vermelho.",
|
||||
"A DualSense's light changes with the dimension you're in.": "A luz de um DualSense muda com a dimensão em que você está.",
|
||||
"A DualSense keeps its own light.": "Um DualSense mantém a própria luz.",
|
||||
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "Permite criar com LT + A em servidores que não são os seus mundos. Alguns servidores consideram isso um macro.",
|
||||
"Show the buttons for what you can do, while you play with a controller.": "Mostra os botões para o que você pode fazer enquanto joga com um controlador.",
|
||||
"How fast the stick moves the crosshair in inventories.": "Quão rápido o manípulo move a mira nos inventários.",
|
||||
"How fast the right stick scrolls a list.": "Quão rápido o manípulo direito rola uma lista.",
|
||||
"How long a held direction waits before it repeats.": "Quanto tempo uma direção segurada espera antes de repetir.",
|
||||
"How long a held direction takes between repeats.": "Quanto tempo uma direção segurada leva entre repetições.",
|
||||
"Which on-screen keyboard opens when a text field is pressed.": "Qual teclado na tela abre quando um campo de texto é pressionado.",
|
||||
"Every action back on its default button.": "Coloca todas as ações de volta no botão padrão.",
|
||||
"These settings belong to this player's profile.": "Estas configurações pertencem ao perfil deste jogador.",
|
||||
"The controller driving this player, or else the first one connected.": "O controlador que controla este jogador, ou então o primeiro conectado.",
|
||||
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "O fornecedor e o produto USB que o sistema informa. Útil ao relatar um controlador com mau funcionamento.",
|
||||
"The glyph family the game picked from the controller's maker.": "A família de glifos que o jogo escolheu a partir do fabricante do controlador.",
|
||||
"Follow the controller, or always show one family's buttons.": "Siga o controlador, ou mostre sempre os botões de uma família.",
|
||||
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "Se o A e o B de um controlador da Nintendo funcionam pela posição ou pelas letras.",
|
||||
"The buttons menus show right now.": "Os botões que os menus mostram agora.",
|
||||
"Rumble when something happens to you.": "Vibração quando algo acontece com você.",
|
||||
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "O controlador continua funcionando enquanto esta janela não está em foco, e perder o foco não pausa.",
|
||||
"A DualSense's triggers push back on bows and tools.": "Os gatilhos de um DualSense oferecem resistência em arcos e ferramentas.",
|
||||
"What a DualSense's light shows, over USB or Bluetooth.": "O que a luz de um DualSense mostra, por USB ou Bluetooth.",
|
||||
"Player profile": "Perfil do jogador",
|
||||
"Vendor and product": "Fornecedor e produto",
|
||||
"Detected glyphs": "Glifos detectados",
|
||||
"Glyphs in use": "Glifos em uso",
|
||||
"Unknown": "Desconhecido",
|
||||
"No controller": "Nenhum controlador",
|
||||
"Editing: %s": "Editando: %s",
|
||||
"Press the button for this. %s cancels.": "Pressione o botão para isso. %s cancela.",
|
||||
"This button also does: %s": "Este botão também faz: %s",
|
||||
"horizon.pad.notice.connected.press": "Pressione para usá-lo",
|
||||
"horizon.recipe_transfer.missing": "Itens faltando",
|
||||
"horizon.recipe_transfer.no_room": "Inventário cheio",
|
||||
"horizon.recipe_transfer.unavailable": "A receita não cabe neste menu",
|
||||
"horizon.recipe_transfer.unsupported": "O servidor exige uma receita conhecida",
|
||||
"horizon.studio.timeline.freecam_on": "Câmera livre: LIGADA",
|
||||
"horizon.studio.timeline.freecam_off": "Câmera livre: DESLIGADA",
|
||||
"horizon.studio.timeline.freecam_short": "Câm",
|
||||
"horizon.studio.timeline.freecam_active_short": "Câm ✓",
|
||||
"horizon.studio.timeline.add_keyframe_short": "+ Chave",
|
||||
"horizon.studio.timeline.delete_keyframe_short": "− Excl",
|
||||
"horizon.studio.timeline.split": "Dividir",
|
||||
"horizon.studio.timeline.marker": "Marcador %s",
|
||||
"horizon.studio.timeline.seconds": "%s s",
|
||||
"horizon.studio.timeline.scene_count": "%s Cenas",
|
||||
"horizon.studio.timeline.scene_title": "Cena %s",
|
||||
"horizon.studio.timeline.sequence": "Sequência",
|
||||
"horizon.studio.topbar.title": "✦ Estúdio",
|
||||
"horizon.studio.topbar.file": "Arquivo",
|
||||
"horizon.studio.topbar.camera": "Câmera",
|
||||
"horizon.studio.topbar.view": "Exibir",
|
||||
"horizon.studio.topbar.file_short": "Arquivo",
|
||||
"horizon.studio.topbar.camera_short": "Câm",
|
||||
"horizon.studio.topbar.view_short": "Ver",
|
||||
"horizon.studio.topbar.game_pause_menu": "⏸ Menu de pausa",
|
||||
"horizon.studio.topbar.pause_short": "Pausa",
|
||||
"horizon.studio.topbar.open_recordings": "📁 Abrir pasta de gravações",
|
||||
"horizon.studio.topbar.close_replay": "✕ Fechar reprodução",
|
||||
"horizon.studio.topbar.exit": "Sair",
|
||||
"horizon.studio.topbar.toggle_freecam": "Alternar câmera livre",
|
||||
"horizon.studio.topbar.reset_to_player": "👤 Voltar ao jogador",
|
||||
"horizon.studio.topbar.unlock_freecam": "🔓 Desbloquear câmera livre",
|
||||
"horizon.studio.topbar.lock_to_spline": "🔒 Fixar na spline",
|
||||
"horizon.studio.topbar.add_keyframe": "➕ Adicionar quadro-chave",
|
||||
"horizon.studio.topbar.add_marker": "Adicionar marcador",
|
||||
"horizon.studio.topbar.clear_keyframes": "🗑 Limpar quadros-chave",
|
||||
"horizon.studio.topbar.timeline_hud": "⏱ Linha do tempo",
|
||||
"horizon.studio.topbar.split_scene": "Dividir cena"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 948 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 885 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 805 B |
|
Before Width: | Height: | Size: 1020 B After Width: | Height: | Size: 692 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 808 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1018 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 906 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 861 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1020 B After Width: | Height: | Size: 918 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 851 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 835 B |
|
Before Width: | Height: | Size: 1020 B After Width: | Height: | Size: 859 B |
@@ -320,16 +320,16 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[flag("world", Ty::Str, "world folder to open or create"), flag("seed", Ty::Str, "seed for a new world"), flag("creative", Ty::Bool, "creative mode for a new world"),
|
||||
flag("version", Ty::Str, "an installed launcher version tag such as v2026.10.13"), flag("game", Ty::Str, "path to a horizon-client binary"),
|
||||
flag("wait", Ty::Bool, "also wait until the world is ready (default in MCP)"),
|
||||
flag("offscreen", Ty::Bool, "render without showing a window (screenshots still work): locked or headless sessions"),
|
||||
flag("offscreen", Ty::Bool, "render without showing a window, splitscreen's too (screenshots still work): locked or headless sessions"),
|
||||
flag("headless", Ty::Bool, "no display, window or GPU: a player that plays through these commands only (no screenshots)")],
|
||||
mcp: true, local: true),
|
||||
cmd!("wait-for", Session, "wait-for screen NAME|widget ID|loaded|chat TEXT|event KIND [--timeout S] [--since SEQ]", "wait until the game reaches a state, then reply",
|
||||
"screen takes the names state shows (playing, inventory, paused, dead, title...); widget an id from ui; loaded means a world is open, the player is in it and no chunk is pending; chat a new line containing TEXT; event a kind from events (death, respawn, item, screen...). chat and event count from --since SEQ (take last from events before acting), else from now. A timeout (default 30 s) is an error with kind timeout.",
|
||||
["horizon-ctl wait-for screen inventory", "horizon-ctl wait-for loaded --timeout 120", "horizon-ctl wait-for chat joined the game", "horizon-ctl wait-for event death --timeout 600"],
|
||||
&[flag("timeout", Ty::Num, "seconds (default 30)"), flag("since", Ty::Int, "chat and event: count from this event seq"), p("condition", Ty::Enum(&["screen", "widget", "loaded", "chat", "event"]), "what to wait for"), opt("value", Ty::Rest, "the screen name, widget id, chat text or event kind")], mcp: true, local: false),
|
||||
cmd!("events", Observe, "events [--since SEQ]", "what happened since SEQ, oldest first: screens, chat, deaths, respawns, items gained or lost, dimensions, worlds",
|
||||
"Every event has seq and tick; pass the reply's last as --since next time. The game keeps the newest 256.",
|
||||
["horizon-ctl events", "horizon-ctl events --since 42"], &[flag("since", Ty::Int, "only events after this seq")], mcp: true, local: false),
|
||||
cmd!("events", Observe, "events [--since SEQ] [--limit N]", "what happened since SEQ, oldest first: screens, chat, deaths, respawns, items gained or lost, damage taken (hurt, with its cause), healing, food, dimensions, worlds",
|
||||
"Every event has seq and tick; pass the reply's last as --since next time. --limit keeps the newest N and skipped counts the older ones left out. hurt and death carry the player position (a death drops the inventory there). The game keeps the newest 256.",
|
||||
["horizon-ctl events", "horizon-ctl events --since 42", "horizon-ctl events --limit 10"], &[flag("since", Ty::Int, "only events after this seq"), flag("limit", Ty::Int, "newest N events")], mcp: true, local: false),
|
||||
cmd!("input-log", Observe, "input-log [--since SEQ] [--limit N] [--origin user|agent|game]", "recent input and who caused it: a person, an agent or the game",
|
||||
"Every event has seq, tick and origin. `user` is the window's own keyboard, mouse or pad; `agent` is the control socket (this tool); `game` is input the game made up. A `user-input` event in `events` means a person stepped in while an agent was driving. The game keeps the newest 512.",
|
||||
["horizon-ctl input-log", "horizon-ctl input-log --origin user --limit 20", "horizon-ctl input-log --since 120"], &[flag("since", Ty::Int, "only events after this seq"), flag("limit", Ty::Int, "newest N events"), flag("origin", Ty::Enum(&["user", "agent", "game"]), "user, agent or game")], mcp: true, local: false),
|
||||
@@ -413,7 +413,7 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[p("direction", Ty::Enum(&["forward", "back", "left", "right"]), "direction relative to the view"), p("seconds", Ty::Num, "how long to walk"),
|
||||
flag("sprint", Ty::Bool, "sprint"), flag("sneak", Ty::Bool, "sneak"), flag("jump", Ty::Bool, "keep jumping")], mcp: true, local: false),
|
||||
cmd!("goto", Move, "goto X Y Z [--near R] [--sprint] [--direct] [--timeout S]", "walk to a position, finding a path through loaded terrain",
|
||||
"A* over standable blocks (steps up one block, drops up to three, avoids lava, swims when it must), then steering with jumps when stuck. Without a path it fails at once (--direct walks straight at the target anyway, falls included). --near R stops within R blocks (use 1.5-3 to reach a block you then break). Replies with arrived, position and distance, or an error when stuck or out of time (default 60 s).",
|
||||
"A* over standable blocks (steps up one block, drops up to three, avoids lava, swims when it must), then steering with jumps when stuck. Whole-number X and Z name a block and aim at its center (8 64 3 is 8.5 64 3.5); decimals are exact positions. Without a path it fails at once (--direct walks straight at the target anyway, falls included). --near R stops within R blocks (use 1.5-3 to reach a block you then break). Replies with arrived, position and distance, or an error when stuck or out of time (default 60 s).",
|
||||
["horizon-ctl goto 120 70 -40", "horizon-ctl goto 8 64 3 --near 2 --sprint"],
|
||||
&[XYZ[0], XYZ[1], XYZ[2], flag("near", Ty::Num, "arrive within this many blocks (default 0.6)"), flag("sprint", Ty::Bool, "sprint"), flag("direct", Ty::Bool, "walk straight when no path is found"), flag("timeout", Ty::Num, "give up after this many seconds (default 60)")], mcp: true, local: false),
|
||||
cmd!("jump", Move, "jump", "jump once and reply when landed", "", ["horizon-ctl jump"], &[], mcp: true, local: false),
|
||||
@@ -443,14 +443,14 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[opt("id", Ty::Str, "an owned emote's id, or stop")], mcp: true, local: false),
|
||||
// Act
|
||||
cmd!("break", Act, "break X Y Z [--timeout S]", "mine a block, holding the attack button until it breaks",
|
||||
"Aims at a visible face (the block must be within reach: use goto --near 2 first), mines with the held item, then waits up to a second for the drops. Replies with block_before, block, ticks, drops and picked_up (inventory change).",
|
||||
"Aims at a visible face (the block must be within reach: use goto --near 2 first), mines with the held item, then waits up to a second for the drops. Replies with block_before, block, ticks, drops, picked_up (inventory change, where a tool that broke shows as -1) and held (the item in hand afterwards, also on a timeout: a broken tool leaves the bare hand).",
|
||||
["horizon-ctl break 10 64 3"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], flag("timeout", Ty::Num, "give up after this many seconds (default 30)")], mcp: true, local: false),
|
||||
cmd!("place", Act, "place X Y Z [--item ITEM]", "place the held item (or ITEM from the hotbar) at an empty position",
|
||||
cmd!("place", Act, "place X Y Z [--item ITEM] [--sneak]", "place the held item (or ITEM from the hotbar) at an empty position",
|
||||
"Clicks the face of a solid neighbour touching X Y Z. Replies with the block placed and the inventory change.",
|
||||
["horizon-ctl place 10 64 4 --item oak_planks"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], flag("item", Ty::Str, "hotbar item to select first")], mcp: true, local: false),
|
||||
cmd!("interact", Act, "interact X Y Z [FACE]", "right-click a block: open a chest or crafting table, press a button, open a door",
|
||||
["horizon-ctl place 10 64 4 --item oak_planks"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], flag("item", Ty::Str, "hotbar item to select first"), flag("sneak", Ty::Bool, "sneak while clicking: place against a crafting table or chest instead of opening it")], mcp: true, local: false),
|
||||
cmd!("interact", Act, "interact X Y Z [FACE] [--sneak]", "right-click a block: open a chest or crafting table, press a button, open a door",
|
||||
"Replies with the screen afterwards and whether the block changed.", ["horizon-ctl interact 5 64 2"],
|
||||
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("face", FACE, "which face to click")], mcp: true, local: false),
|
||||
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("face", FACE, "which face to click"), flag("sneak", Ty::Bool, "sneak while clicking: use the held item on the block instead of opening it")], mcp: true, local: false),
|
||||
cmd!("use-item", Act, "use-item [--hold SECONDS]", "right-click with the held item: eat, drink, throw, draw a bow",
|
||||
"Hold for 1.7 s to eat.", ["horizon-ctl use-item --hold 1.7"], &[flag("hold", Ty::Num, "hold the button this long")], mcp: true, local: false),
|
||||
cmd!("attack", Act, "attack [ID]", "swing at what the crosshair targets (after facing entity ID)",
|
||||
@@ -460,6 +460,14 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
["horizon-ctl select 3", "horizon-ctl select oak_planks"], &[p("slot", Ty::Str, "1-9 or an item id")], mcp: true, local: false),
|
||||
cmd!("drop", Act, "drop [all]", "drop one of the held item, or the whole stack", "", ["horizon-ctl drop", "horizon-ctl drop all"],
|
||||
&[opt("all", Ty::Bool, "drop the whole stack")], mcp: true, local: false),
|
||||
cmd!("hunt", Act, "hunt ID [--timeout S]", "walk up to a mob and hit it until it is gone, then pick up its drops",
|
||||
"For animals and monsters that move between calls: it re-reads the entity's position, walks within reach (sprinting), attacks with the held item, waits 12 ticks for the weapon to recharge, and repeats. Once the entity is gone (killed or despawned) it walks over its last position to collect the drops. Replies with hits, last_seen and inventory_change; times out with kind timeout (default 30 s).",
|
||||
["horizon-ctl hunt 90", "horizon-ctl hunt 90 --timeout 60"],
|
||||
&[p("id", Ty::Int, "entity id from entities"), flag("timeout", Ty::Num, "give up after this many seconds (default 30)")], mcp: true, local: true),
|
||||
cmd!("collect", Act, "collect [RADIUS]", "walk over every dropped item within RADIUS blocks (default 8) to pick it up",
|
||||
"Drops from break, hunt or a death often land out of pickup range, in a hole or behind a corner. collect walks to each item nearest first, trying each once, and replies with inventory_change and left: the items it could not reach (with why, the walk's error, when it had one). It stops with kind hurt, listing the hurt events, as soon as the player takes damage.",
|
||||
["horizon-ctl collect", "horizon-ctl collect 16"],
|
||||
&[opt("radius", Ty::Num, "search radius in blocks (default 8)")], mcp: true, local: true),
|
||||
cmd!("hold", Act, "hold KEY SECONDS", "press a key, wait, release it", "Keys as for key.", ["horizon-ctl hold w 2"],
|
||||
&[p("key", Ty::Str, "key name"), p("seconds", Ty::Num, "how long")], mcp: false, local: true),
|
||||
cmd!("key", Act, "key KEY down|up|tap|repeat", "press, release or auto-repeat a key (w a s d space shift ctrl e q esc f3 1-9 arrows ...)",
|
||||
@@ -468,12 +476,12 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("mouse", Act, "mouse left|right|middle down|up|click", "press or release a mouse button in the world", "",
|
||||
["horizon-ctl mouse right click"], &[p("button", Ty::Enum(&["left", "right", "middle"]), "button"), p("action", Ty::Enum(&["down", "up", "click"]), "what to do")], mcp: true, local: false),
|
||||
cmd!("interact-entity", Act, "interact-entity ID", "right-click an entity: trade with a villager, mount a horse, ride a boat, shear a sheep",
|
||||
"Faces the entity (from entities), right-clicks it with the held item, waits two ticks and replies with the screen.",
|
||||
"Faces the entity (from entities), right-clicks it with the held item, waits two ticks and replies with the screen and inventory_change (items gained or lost). Fails without clicking when the entity is not under the crosshair, naming what is in the way.",
|
||||
["horizon-ctl interact-entity 42"], &[p("id", Ty::Int, "entity id from entities")], mcp: true, local: true),
|
||||
cmd!("pad", Act, "pad BUTTON down|up|tap | pad stick left|right X Y | pad trigger left|right V | pad release | pad status | pad connection connected|disconnected", "a virtual controller: buttons, sticks and triggers as a real pad would send them",
|
||||
"Buttons by position: south east west north lb rb lt rt select start ls rs up down left right (south is Xbox A / PlayStation cross). Sticks run -1..1 with up positive and stay tilted until centred; triggers 0..1. Merged with any real controller; status lists the connected ones and what is held.",
|
||||
cmd!("pad", Act, "pad BUTTON down|up|tap | pad stick left|right X Y | pad trigger left|right V | pad release | pad status | pad connection connected [FAMILY]|disconnected | pad family FAMILY", "a virtual controller: buttons, sticks and triggers as a real pad would send them",
|
||||
"Buttons by position: south east west north lb rb lt rt select start ls rs up down left right (south is Xbox A / PlayStation cross). Sticks run -1..1 with up positive and stay tilted until centred; triggers 0..1. Merged with any real controller; status lists the connected ones and what is held. FAMILY is xbox, playstation, nintendo or auto: connection connected raises the notice of that family's controller (PlayStation unless given), and family makes the pad playing that family's (its glyphs; a Nintendo one goes by its letters when Nintendo Buttons says so).",
|
||||
["horizon-ctl pad south tap", "horizon-ctl pad stick left 0 1", "horizon-ctl pad trigger right 1", "horizon-ctl pad status"],
|
||||
&[p("what", Ty::Str, "a button name, stick, trigger, release, status or connection"), opt("a", Ty::Str, "down|up|tap, left|right, or connected|disconnected"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: true, local: false),
|
||||
&[p("what", Ty::Str, "a button name, stick, trigger, release, status, connection or family"), opt("a", Ty::Str, "down|up|tap, left|right, connected|disconnected, or a family"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: true, local: false),
|
||||
cmd!("release", Act, "release", "release every key and mouse button", "", ["horizon-ctl release"], &[], mcp: true, local: false),
|
||||
cmd!("stop", Act, "stop", "cancel the running action (goto, break...) and release every key", "", ["horizon-ctl stop"], &[], mcp: true, local: false),
|
||||
// Screens
|
||||
@@ -578,9 +586,9 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
"Lines are split like a shell (quotes keep spaces); # starts a comment; sleep SECONDS waits. Stops at the first error unless --keep-going. horizon-ctl - is the older form: read stdin and keep going.",
|
||||
["horizon-ctl script play.txt", "printf 'state\\ngoto 0 64 0\\n' | horizon-ctl script"], &[], mcp: false, local: true),
|
||||
cmd!("sleep", Scripting, "sleep SECONDS", "wait (in scripts)", "", ["sleep 0.5"], &[], mcp: false, local: true),
|
||||
cmd!("mcp", Scripting, "mcp", "serve the Model Context Protocol on stdin/stdout, one tool per command",
|
||||
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image.",
|
||||
["horizon-ctl mcp"], &[], mcp: false, local: true),
|
||||
cmd!("mcp", Scripting, "mcp [--paced]", "serve the Model Context Protocol on stdin/stdout, one tool per command",
|
||||
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image. --paced stops a single-player world between tool calls: the pause menu opens after each action and closes before the next, so time passes only while an action (or wait) runs, not while the agent thinks; reading tools (state, inventory, entities, events...) leave it paused.",
|
||||
["horizon-ctl mcp", "horizon-ctl mcp --paced"], &[], mcp: false, local: true),
|
||||
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset] [raw]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again"), opt("raw", Ty::Bool, "every frame time too (frame_ms, cpu_ms, gpu_ms)")], mcp: true, local: false),
|
||||
cmd!("gpu-passes", Scripting, "gpu-passes [on|off]", "time Horizon's own render passes on the GPU, shown in F3 and in stats and bench", "", ["horizon-ctl gpu-passes on"], &[opt("mode", Ty::Enum(&["on", "off"]), "on or off; without it, only reports")], mcp: false, local: false),
|
||||
|
||||
@@ -142,6 +142,10 @@ pub struct Session {
|
||||
/// `--api LINE`: the protocol line this run speaks; a game on another line is refused.
|
||||
pub api: Option<String>,
|
||||
client: Option<Client>,
|
||||
/// `mcp --paced`: the world stands still between tool calls (`mcp::Session::call`).
|
||||
pub paced: bool,
|
||||
/// The pause menu `--paced` opened, which the next action closes.
|
||||
pub(crate) held: bool,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -152,6 +156,8 @@ impl Session {
|
||||
home: horizon_client::logging::game_home(),
|
||||
api: None,
|
||||
client: None,
|
||||
paced: false,
|
||||
held: false,
|
||||
}
|
||||
}
|
||||
/// A failure while No AI is on: every command refuses (help aside).
|
||||
@@ -185,6 +191,124 @@ impl Session {
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
/// `hunt ID`: walk up to a mob and hit it until it is gone, at most `timeout` seconds.
|
||||
fn hunt(&mut self, id: i64, timeout: f64) -> Result<Value, Failure> {
|
||||
let deadline = Instant::now() + Duration::from_secs_f64(timeout.max(0.0));
|
||||
let before = self.game("inventory")?;
|
||||
let (mut hits, mut seen) = (0, Value::Null);
|
||||
loop {
|
||||
let near = self.game("entities 48")?;
|
||||
let Some(mob) = find_entity(&near, id) else {
|
||||
break;
|
||||
};
|
||||
if mob["health"].as_f64().is_some_and(|h| h <= 0.0) {
|
||||
// Dying: it vanishes after its death animation.
|
||||
seen = mob;
|
||||
break;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return Err(Failure {
|
||||
reply: json!({
|
||||
"error": format!("entity {id} still alive after {timeout} s"),
|
||||
"kind": "timeout",
|
||||
"hits": hits,
|
||||
"entity": mob,
|
||||
}),
|
||||
status: GAME_ERROR,
|
||||
});
|
||||
}
|
||||
seen = mob.clone();
|
||||
let pos = &mob["pos"];
|
||||
if mob["distance"].as_f64().unwrap_or(f64::MAX) > 2.5 {
|
||||
// A path may not exist (water, a fence); try again from wherever this ends.
|
||||
let _ = self.game(&format!(
|
||||
"goto {} {} {} --near 1.5 --sprint --timeout 3",
|
||||
pos[0], pos[1], pos[2]
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if self.game(&format!("attack {id}")).is_ok() {
|
||||
hits += 1;
|
||||
// Let the weapon recharge (a sword needs about 12 ticks).
|
||||
self.game("wait 12")?;
|
||||
} else {
|
||||
let _ = self.game(&format!(
|
||||
"goto {} {} {} --near 0.8 --timeout 2",
|
||||
pos[0], pos[1], pos[2]
|
||||
));
|
||||
}
|
||||
}
|
||||
// Walk over the drops so they are picked up.
|
||||
if let Some(pos) = seen["pos"].as_array() {
|
||||
let _ = self.game(&format!(
|
||||
"goto {} {} {} --near 0.5 --timeout 4",
|
||||
pos[0], pos[1], pos[2]
|
||||
));
|
||||
self.game("wait 10")?;
|
||||
}
|
||||
let after = self.game("inventory")?;
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"gone": true,
|
||||
"hits": hits,
|
||||
"last_seen": seen,
|
||||
"inventory_change": count_change(&before["counts"], &after["counts"]),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `collect [RADIUS]`: walk over every dropped item within RADIUS, nearest first.
|
||||
fn collect(&mut self, radius: f64) -> Result<Value, Failure> {
|
||||
let before = self.game("inventory")?;
|
||||
// Only the newest sequence number: events after it are this walk's.
|
||||
let mut seq = self.game("events --since 18446744073709551615")?["last"]
|
||||
.as_u64()
|
||||
.unwrap_or_default();
|
||||
let mut tried = Vec::new();
|
||||
let mut left = Vec::new();
|
||||
// Each item is tried once; one that stays put after a walk is out of reach.
|
||||
while let Some(item) = nearest_item(&self.game(&format!("entities {radius}"))?, &tried) {
|
||||
let id = item["id"].as_i64().unwrap_or_default();
|
||||
tried.push(id);
|
||||
let pos = &item["pos"];
|
||||
let walk = self.game(&format!(
|
||||
"goto {} {} {} --near 0.4 --timeout 4",
|
||||
pos[0], pos[1], pos[2]
|
||||
));
|
||||
self.game("wait 4")?;
|
||||
let near = self.game(&format!("entities {radius}"))?;
|
||||
if let Some(mut still) = find_entity(&near, id) {
|
||||
if let Err(e) = walk {
|
||||
still["why"] = e.reply["error"].clone();
|
||||
}
|
||||
left.push(still);
|
||||
}
|
||||
// Stop when something hurts the player: walking on into a mob,
|
||||
// a creeper or lava kills an agent that only reads the reply.
|
||||
let events = self.game(&format!("events --since {seq}"))?;
|
||||
seq = events["last"].as_u64().unwrap_or(seq);
|
||||
let hurt = hurts(&events);
|
||||
if !hurt.is_empty() {
|
||||
let after = self.game("inventory")?;
|
||||
return Err(Failure {
|
||||
reply: json!({
|
||||
"error": "stopped: the player was hurt",
|
||||
"kind": "hurt",
|
||||
"hurt": hurt,
|
||||
"inventory_change": count_change(&before["counts"], &after["counts"]),
|
||||
"left": left,
|
||||
}),
|
||||
status: GAME_ERROR,
|
||||
});
|
||||
}
|
||||
}
|
||||
let after = self.game("inventory")?;
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"inventory_change": count_change(&before["counts"], &after["counts"]),
|
||||
"left": left,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Run one command (CLI words); Ok(reply) or a failure.
|
||||
pub fn run(&mut self, words: &[String]) -> Result<Value, Failure> {
|
||||
let Some(name) = words.first().map(String::as_str) else {
|
||||
@@ -233,13 +357,43 @@ impl Session {
|
||||
[_, id] if id.parse::<i64>().is_ok() => id,
|
||||
_ => return Err(Failure::usage(format!("usage: {}", usage(name)))),
|
||||
};
|
||||
self.game(&format!("look-at entity {id}"))?;
|
||||
let id: i64 = id.parse().unwrap_or_default();
|
||||
let look = self.game(&format!("look-at entity {id}"))?;
|
||||
if let Err(error) = aimed_at(&look, id) {
|
||||
return Err(Failure {
|
||||
reply: json!({ "error": error, "target": look["target"] }),
|
||||
status: GAME_ERROR,
|
||||
});
|
||||
}
|
||||
let before = self.game("inventory")?;
|
||||
self.game("mouse right click")?;
|
||||
self.game("wait 2")?;
|
||||
let after = self.game("inventory")?;
|
||||
let mut screen = self.game("screen")?;
|
||||
screen["ok"] = json!(true);
|
||||
screen["inventory_change"] = count_change(&before["counts"], &after["counts"]);
|
||||
Ok(screen)
|
||||
}
|
||||
"hunt" => {
|
||||
let (id, timeout) = match words {
|
||||
[_, id] => (id.parse::<i64>().ok(), Some(30.0)),
|
||||
[_, id, flag, s] if flag == "--timeout" => {
|
||||
(id.parse::<i64>().ok(), s.parse::<f64>().ok())
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
let (Some(id), Some(timeout)) = (id, timeout) else {
|
||||
return Err(Failure::usage(format!("usage: {}", usage(name))));
|
||||
};
|
||||
self.hunt(id, timeout)
|
||||
}
|
||||
"collect" => match words {
|
||||
[_] => self.collect(8.0),
|
||||
[_, r] if r.parse::<f64>().is_ok_and(|r| r > 0.0) => {
|
||||
self.collect(r.parse().unwrap_or(8.0))
|
||||
}
|
||||
_ => Err(Failure::usage(format!("usage: {}", usage(name)))),
|
||||
},
|
||||
"hold" => {
|
||||
let (key, seconds) = match words {
|
||||
[_, key, _] => (key.clone(), number(2, "SECONDS")?),
|
||||
@@ -300,6 +454,79 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `look` (a `look-at entity` reply) put entity `id` under the crosshair; if not,
|
||||
/// what is there instead, so a right click is not spent on a tree or the air.
|
||||
fn aimed_at(look: &Value, id: i64) -> Result<(), String> {
|
||||
let target = &look["target"];
|
||||
let entity = &target["entity"];
|
||||
if entity["id"].as_i64() == Some(id) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(if let Some(other) = entity["id"].as_i64() {
|
||||
format!(
|
||||
"entity {id} is not under the crosshair: {} {other} is in the way",
|
||||
entity["type"].as_str().unwrap_or("an entity")
|
||||
)
|
||||
} else if let Some(block) = target["block"]["block"].as_str() {
|
||||
format!(
|
||||
"entity {id} is not under the crosshair: {block} at {} is in the way",
|
||||
target["block"]["pos"]
|
||||
)
|
||||
} else {
|
||||
format!("entity {id} is not under the crosshair: it is out of reach or moved; walk closer")
|
||||
})
|
||||
}
|
||||
|
||||
/// The entity `id` in an `entities` reply.
|
||||
fn find_entity(reply: &Value, id: i64) -> Option<Value> {
|
||||
reply["entities"]
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|e| e["id"].as_i64() == Some(id))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// The nearest dropped item in an `entities` reply (nearest first) whose id is not in `skip`.
|
||||
fn nearest_item(reply: &Value, skip: &[i64]) -> Option<Value> {
|
||||
reply["entities"]
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|e| e["kind"] == "item" && !e["id"].as_i64().is_some_and(|id| skip.contains(&id)))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// The `hurt` events in an `events` reply.
|
||||
fn hurts(reply: &Value) -> Vec<Value> {
|
||||
let events = reply["events"]
|
||||
.as_array()
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default();
|
||||
events
|
||||
.iter()
|
||||
.filter(|e| e["event"] == "hurt")
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Item counts that changed from `before` to `after` (`inventory` replies' `counts`).
|
||||
fn count_change(before: &Value, after: &Value) -> Value {
|
||||
let empty = serde_json::Map::new();
|
||||
let (before, after) = (
|
||||
before.as_object().unwrap_or(&empty),
|
||||
after.as_object().unwrap_or(&empty),
|
||||
);
|
||||
let mut change = serde_json::Map::new();
|
||||
for item in before.keys().chain(after.keys()) {
|
||||
let n =
|
||||
|m: &serde_json::Map<String, Value>| m.get(item).and_then(Value::as_i64).unwrap_or(0);
|
||||
let delta = n(after) - n(before);
|
||||
if delta != 0 {
|
||||
change.insert(item.clone(), json!(delta));
|
||||
}
|
||||
}
|
||||
Value::Object(change)
|
||||
}
|
||||
|
||||
/// `logs [--lines N] [--crash]` for the game in `home`: the reply's `text` is the log's last
|
||||
/// lines or the report.
|
||||
fn logs(home: &Path, words: &[String]) -> Result<Value, Failure> {
|
||||
@@ -563,13 +790,16 @@ fn main() -> ExitCode {
|
||||
eprintln!("horizon-ctl mcp: {}", horizon_core::no_ai::REFUSAL);
|
||||
DENIED
|
||||
}
|
||||
Some("mcp") => match mcp::serve(&mut session) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
eprintln!("horizon-ctl mcp: {e}");
|
||||
UNREACHABLE
|
||||
Some("mcp") => {
|
||||
session.paced = args[1..].iter().any(|a| a == "--paced");
|
||||
match mcp::serve(&mut session) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
eprintln!("horizon-ctl mcp: {e}");
|
||||
UNREACHABLE
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
// The original form: stdin, keep going after errors.
|
||||
Some("-") => script(&mut session, std::io::stdin().lock(), true, pretty),
|
||||
Some("script") => {
|
||||
@@ -617,6 +847,74 @@ fn main() -> ExitCode {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aimed_at_names_what_is_under_the_crosshair_instead() {
|
||||
let on = json!({ "target": { "entity": { "id": 91, "type": "minecraft:sheep" } } });
|
||||
assert_eq!(aimed_at(&on, 91), Ok(()));
|
||||
let sheep = aimed_at(&on, 107).unwrap_err();
|
||||
assert!(
|
||||
sheep.contains("minecraft:sheep 91 is in the way"),
|
||||
"{sheep}"
|
||||
);
|
||||
let log = json!({
|
||||
"target": { "block": { "block": "minecraft:oak_log", "pos": [1, 2, 3] } }
|
||||
});
|
||||
let log = aimed_at(&log, 91).unwrap_err();
|
||||
assert!(
|
||||
log.contains("minecraft:oak_log at [1,2,3] is in the way"),
|
||||
"{log}"
|
||||
);
|
||||
let nothing = aimed_at(&json!({ "target": null }), 91).unwrap_err();
|
||||
assert!(nothing.contains("walk closer"), "{nothing}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_entity_picks_the_id_out_of_an_entities_reply() {
|
||||
let near = json!({ "entities": [{ "id": 3 }, { "id": 90, "type": "minecraft:chicken" }] });
|
||||
assert_eq!(find_entity(&near, 90).unwrap()["type"], "minecraft:chicken");
|
||||
assert!(find_entity(&near, 7).is_none());
|
||||
assert!(find_entity(&json!({}), 90).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hurts_keeps_only_hurt_events() {
|
||||
let reply = json!({ "events": [
|
||||
{ "event": "item", "seq": 1 },
|
||||
{ "event": "hurt", "amount": 3.0, "seq": 2 },
|
||||
{ "event": "heal", "seq": 3 },
|
||||
], "last": 3 });
|
||||
assert_eq!(
|
||||
hurts(&reply),
|
||||
vec![json!({ "event": "hurt", "amount": 3.0, "seq": 2 })]
|
||||
);
|
||||
assert!(hurts(&json!({})).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_item_skips_mobs_and_items_already_tried() {
|
||||
let near = json!({ "entities": [
|
||||
{ "id": 1, "kind": "mob" },
|
||||
{ "id": 2, "kind": "item" },
|
||||
{ "id": 3, "kind": "item" },
|
||||
] });
|
||||
assert_eq!(nearest_item(&near, &[]).unwrap()["id"], 2);
|
||||
assert_eq!(nearest_item(&near, &[2]).unwrap()["id"], 3);
|
||||
assert!(nearest_item(&near, &[2, 3]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_change_keeps_only_items_whose_count_moved() {
|
||||
let before = json!({ "minecraft:shears": 1, "minecraft:white_wool": 1 });
|
||||
let after = json!({
|
||||
"minecraft:shears": 1, "minecraft:white_wool": 3, "minecraft:stick": 2
|
||||
});
|
||||
assert_eq!(
|
||||
count_change(&before, &after),
|
||||
json!({ "minecraft:white_wool": 2, "minecraft:stick": 2 })
|
||||
);
|
||||
assert_eq!(count_change(&after, &before)["minecraft:stick"], json!(-2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_reads_the_end_of_latest_log_and_the_newest_crash_report() {
|
||||
let home = std::env::temp_dir().join(format!("horizon-ctl-logs-{}", std::process::id()));
|
||||
|
||||
@@ -69,9 +69,56 @@ pub fn base64(bytes: &[u8]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Under `--paced`, whether a tool runs without the world moving: it only reads, or it
|
||||
/// manages the session or the pause itself.
|
||||
fn still(cmd: &commands::Cmd) -> bool {
|
||||
matches!(cmd.group, commands::Group::Observe)
|
||||
|| matches!(
|
||||
cmd.name,
|
||||
"hello"
|
||||
| "status"
|
||||
| "worlds"
|
||||
| "logs"
|
||||
| "save"
|
||||
| "launch"
|
||||
| "quit"
|
||||
| "pause"
|
||||
| "resume"
|
||||
| "inventory"
|
||||
| "ui"
|
||||
)
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// One tool call: (content blocks, is_error).
|
||||
/// The screen the game shows, if it answers.
|
||||
fn screen_now(&mut self) -> Option<String> {
|
||||
let reply = self.run(&["screen".to_owned()]).ok()?;
|
||||
reply["screen"].as_str().map(str::to_owned)
|
||||
}
|
||||
|
||||
/// `--paced`: an agent thinks between tool calls, often for many seconds, and in single
|
||||
/// player the world went on without it (a mob kept hitting). The pause menu stops a
|
||||
/// single-player world as vanilla's does; it is opened after an action and closed before
|
||||
/// the next, only when the game is playing (not over another menu, nor a pause a person
|
||||
/// opened). Reading tools leave it open. A LAN or server world does not stop.
|
||||
fn call(&mut self, name: &str, args: &Value) -> (Vec<Value>, bool) {
|
||||
let cmd = commands::COMMANDS
|
||||
.iter()
|
||||
.find(|c| c.mcp && commands::tool_name(c) == name);
|
||||
let pace = self.paced && cmd.is_some_and(|c| !still(c));
|
||||
if pace && std::mem::take(&mut self.held) && self.screen_now().as_deref() == Some("paused")
|
||||
{
|
||||
let _ = self.run(&["resume".to_owned()]);
|
||||
}
|
||||
let out = self.call_tool(name, args);
|
||||
if pace && self.screen_now().as_deref() == Some("playing") {
|
||||
self.held = self.run(&["pause".to_owned()]).is_ok();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One tool call: (content blocks, is_error).
|
||||
fn call_tool(&mut self, name: &str, args: &Value) -> (Vec<Value>, bool) {
|
||||
let text = |v: &Value| json!({ "type": "text", "text": serde_json::to_string_pretty(v).unwrap_or_default() });
|
||||
let Some(cmd) = commands::COMMANDS
|
||||
.iter()
|
||||
@@ -181,9 +228,37 @@ pub fn serve(session: &mut Session) -> Result<(), String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn paced_reading_tools_keep_the_world_still() {
|
||||
let find = |n: &str| commands::COMMANDS.iter().find(|c| c.name == n).unwrap();
|
||||
for n in [
|
||||
"state",
|
||||
"inventory",
|
||||
"entities",
|
||||
"events",
|
||||
"find-block",
|
||||
"pause",
|
||||
"save",
|
||||
] {
|
||||
assert!(still(find(n)), "{n} should not let time pass");
|
||||
}
|
||||
let moving = [
|
||||
"goto",
|
||||
"break",
|
||||
"place",
|
||||
"attack",
|
||||
"wait",
|
||||
"wait-for",
|
||||
"screenshot",
|
||||
"click-slot",
|
||||
];
|
||||
for n in moving {
|
||||
assert!(!still(find(n)), "{n} needs the world running");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_matches_rfc_4648() {
|
||||
assert_eq!(base64(b""), "");
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
//! if it helps, how long it stays. A view holds one at a time (`NoticeSlot`), so they never stack:
|
||||
//! each player's game has its own slot, and what raises a notice (splitscreen's joins and
|
||||
//! reconnects, a solo game's controller coming and going) decides that for itself.
|
||||
use crate::gamepad::{Button, GlyphStyle};
|
||||
use crate::hud::{HudAtlas, HudBatch, rgba};
|
||||
use crate::hud_kit::plate::{self, Plate};
|
||||
use crate::pad_glyph;
|
||||
use crate::pad_glyph::{self, Glyph};
|
||||
use crate::toasts::Slide;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -30,13 +29,6 @@ pub enum Priority {
|
||||
Attention,
|
||||
}
|
||||
|
||||
/// A button shown before the body, in the player's glyphs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Glyph {
|
||||
pub button: Button,
|
||||
pub style: GlyphStyle,
|
||||
}
|
||||
|
||||
pub struct Notice {
|
||||
id: NoticeId,
|
||||
priority: Priority,
|
||||
@@ -66,6 +58,7 @@ impl Notice {
|
||||
slide: Slide::default(),
|
||||
}
|
||||
}
|
||||
/// A button shown before the body, in the glyphs of the controller it is about.
|
||||
pub fn with_glyph(mut self, glyph: Glyph) -> Self {
|
||||
self.glyph = Some(glyph);
|
||||
self
|
||||
@@ -132,9 +125,7 @@ impl NoticeSlot {
|
||||
let w = batch.width;
|
||||
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
|
||||
let max = (w - 24.0).min(260.0);
|
||||
let glyph = notice
|
||||
.glyph
|
||||
.map(|g| (g, pad_glyph::width(atlas, g.button, g.style) + 4.0));
|
||||
let glyph = notice.glyph.map(|g| (g, pad_glyph::width(atlas, g) + 4.0));
|
||||
let indent = glyph.map_or(0.0, |g| g.1);
|
||||
let lines = crate::split_profiles::wrap(¬ice.body, max - indent, width);
|
||||
let inner = lines
|
||||
@@ -152,7 +143,7 @@ impl NoticeSlot {
|
||||
let lx = (px + (pw - width(line) - indent) / 2.0).floor();
|
||||
let y = py + 18.0 + 10.0 * k as f32;
|
||||
if let (0, Some((g, _))) = (k, glyph) {
|
||||
pad_glyph::draw(batch, atlas, g.button, g.style, lx, y);
|
||||
pad_glyph::draw(batch, atlas, g, lx, y);
|
||||
}
|
||||
batch.text(atlas, line, lx + indent, y, rgba(200, 200, 200, 255), false);
|
||||
}
|
||||
|
||||
@@ -1342,7 +1342,9 @@ pub struct ExperienceOrb {
|
||||
pub position: Vec3,
|
||||
pub previous: Vec3,
|
||||
pub velocity: Vec3,
|
||||
pub value: u32,
|
||||
/// `ExperienceOrb.DATA_VALUE`: an int that a save or command can leave at
|
||||
/// 0 or below, which a pickup turns into no experience.
|
||||
pub value: i32,
|
||||
pub age: u32,
|
||||
pub on_ground: bool,
|
||||
/// `ExperienceOrb.count`: orbs merged into this one; each pickup gives
|
||||
@@ -1416,9 +1418,14 @@ impl ExperienceOrb {
|
||||
}
|
||||
/// `ExperienceOrb.canMerge(orb, id, value)` for an orb that is not removed:
|
||||
/// the same value and ids a multiple of 40 apart.
|
||||
pub fn can_merge(&self, id: u32, value: u32) -> bool {
|
||||
pub fn can_merge(&self, id: u32, value: i32) -> bool {
|
||||
self.value == value && (self.id as i32).wrapping_sub(id as i32) % 40 == 0
|
||||
}
|
||||
/// The experience one pickup gives: `playerTouch` gives only a positive
|
||||
/// remainder, so an orb of value 0 or below gives none. Mods see this too.
|
||||
pub fn points(&self) -> u32 {
|
||||
self.value.max(0) as u32
|
||||
}
|
||||
/// ExperienceOrb.getExperienceValue: the largest orb size not above `value`.
|
||||
pub fn split(value: u32) -> u32 {
|
||||
[2477, 1237, 617, 307, 149, 73, 37, 17, 7, 3, 1]
|
||||
|
||||
@@ -1056,15 +1056,17 @@ pub fn mob_to_nbt(
|
||||
} else {
|
||||
map.insert("equipment".into(), Tag::compound(equipment));
|
||||
}
|
||||
let modelled = [
|
||||
("minecraft:max_health", f64::from(m.attrs.max_health)),
|
||||
("minecraft:movement_speed", m.attrs.speed),
|
||||
("minecraft:jump_strength", m.extra.vector[0]),
|
||||
];
|
||||
// AttributeMap.pack writes the instances that exist: the ones the
|
||||
// entity was loaded with, or all three randomized by finalizeSpawn.
|
||||
let attributes = match map.get(&"attributes".into()) {
|
||||
Some(Tag::List(list)) => list
|
||||
// entity was loaded with. When the tag had no attributes list,
|
||||
// instances are not synthesized (SummonCommand without finalizeSpawn
|
||||
// saves attributes: []).
|
||||
if let Some(Tag::List(list)) = map.get(&"attributes".into()) {
|
||||
let modelled = [
|
||||
("minecraft:max_health", f64::from(m.attrs.max_health)),
|
||||
("minecraft:movement_speed", m.attrs.speed),
|
||||
("minecraft:jump_strength", m.extra.vector[0]),
|
||||
];
|
||||
let attributes = list
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let id = text(a.get("id"));
|
||||
@@ -1078,15 +1080,9 @@ pub fn mob_to_nbt(
|
||||
_ => a.clone(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
_ => modelled
|
||||
.iter()
|
||||
.map(|(id, v)| {
|
||||
Tag::compound([("id", string(id)), ("base", Tag::Double(v.to_bits()))])
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
map.insert("attributes".into(), Tag::List(attributes));
|
||||
.collect();
|
||||
map.insert("attributes".into(), Tag::List(attributes));
|
||||
}
|
||||
}
|
||||
"minecraft:pig" | "minecraft:strider" => {
|
||||
let mut equipment = match map.get(&"equipment".into()) {
|
||||
@@ -1390,12 +1386,14 @@ pub fn mob_to_nbt(
|
||||
// Zombie and Skeleton.addAdditionalSaveData (a husk keeps Zombie's):
|
||||
// `ConversionTracker`'s conversion and affliction times, -1 when it is
|
||||
// not converting or not afflicted.
|
||||
// The tracker ticks for these, so the live counters win over the loaded
|
||||
// values: one that left the water or the snow saves -1.
|
||||
"minecraft:zombie" | "minecraft:husk" => {
|
||||
for (key, value) in [
|
||||
("DrownedConversionTime", conversion_time(m)),
|
||||
("InWaterTime", m.extra.counter),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(Tag::Int(value));
|
||||
map.insert(key.into(), Tag::Int(value));
|
||||
}
|
||||
}
|
||||
"minecraft:skeleton" => {
|
||||
@@ -1403,7 +1401,7 @@ pub fn mob_to_nbt(
|
||||
("StrayConversionTime", conversion_time(m)),
|
||||
("FreezingTime", m.extra.counter),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(Tag::Int(value));
|
||||
map.insert(key.into(), Tag::Int(value));
|
||||
}
|
||||
}
|
||||
// Drowned.convertsInWater is false, so its tracker never ticks: vanilla
|
||||
@@ -1450,6 +1448,16 @@ pub fn mob_to_nbt(
|
||||
if !m.fluids.eye_in_water {
|
||||
map.insert("InWaterTime".into(), Tag::Int(-1));
|
||||
}
|
||||
// ZombieVillager and ZombifiedPiglin save Zombie's tracker too; Horizon
|
||||
// never starts their water conversion, so its time is -1.
|
||||
if matches!(m.species(), S::ZombieVillager | S::ZombifiedPiglin) {
|
||||
map.entry("DrownedConversionTime".into())
|
||||
.or_insert(Tag::Int(-1));
|
||||
}
|
||||
}
|
||||
// Skeleton supplies its cached isInPowderSnow to the same isAfflicted check.
|
||||
if m.species() == S::Skeleton && !m.in_powder_snow {
|
||||
map.insert("FreezingTime".into(), Tag::Int(-1));
|
||||
}
|
||||
if matches!(
|
||||
id,
|
||||
@@ -1485,8 +1493,9 @@ pub fn mob_to_nbt(
|
||||
// Every other living entity's first tick makes the movement_speed instance
|
||||
// (LivingEntity.removeFrost reads it through getAttribute); before it, the
|
||||
// mob has only the instances it was loaded with (the kept list above).
|
||||
let speed_at_load =
|
||||
crate::mobs::doors::is_zombie(species) || species == crate::mobs::Species::Piglin;
|
||||
let speed_at_load = crate::mobs::doors::is_zombie(species)
|
||||
|| species == crate::mobs::Species::Piglin
|
||||
|| (species == crate::mobs::Species::Ravager && crate::mobs::ravager::immobile(m));
|
||||
let mut made = Vec::new();
|
||||
if speed_at_load || m.age > 0 {
|
||||
made.push(Attr::MovementSpeed);
|
||||
@@ -1504,16 +1513,23 @@ pub fn mob_to_nbt(
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let attr_id = format!("minecraft:{}", attr.name());
|
||||
if !list
|
||||
.iter()
|
||||
.any(|t| text(t.get("id")).as_deref() == Some(attr_id.as_str()))
|
||||
if let Some(existing) = list
|
||||
.iter_mut()
|
||||
.find(|t| text(t.get("id")).as_deref() == Some(attr_id.as_str()))
|
||||
{
|
||||
if species == crate::mobs::Species::Ravager
|
||||
&& attr == Attr::MovementSpeed
|
||||
&& let Tag::Compound(c) = existing
|
||||
{
|
||||
c.insert("base".into(), Tag::Double(base.to_bits()));
|
||||
}
|
||||
} else {
|
||||
list.push(Tag::compound([
|
||||
("id", string(&attr_id)),
|
||||
("base", Tag::Double(base.to_bits())),
|
||||
]));
|
||||
map.insert("attributes".into(), Tag::List(list));
|
||||
}
|
||||
map.insert("attributes".into(), Tag::List(list));
|
||||
}
|
||||
// Mob.addAdditionalSaveData: the chances that are not the default.
|
||||
if let Some(chances) = &m.extra.drop_chances {
|
||||
@@ -2298,7 +2314,11 @@ pub fn mob_from_nbt(
|
||||
}
|
||||
}
|
||||
if m.extra.vector[0] <= 0.0 {
|
||||
m.extra.vector[0] = 0.7;
|
||||
m.extra.vector[0] = if crate::mobs::equine::is_chested(m.species()) {
|
||||
0.5
|
||||
} else {
|
||||
0.7
|
||||
};
|
||||
}
|
||||
}
|
||||
if crate::mobs::finalize::saves_variant_name(m.species())
|
||||
@@ -2604,6 +2624,11 @@ pub fn mob_from_nbt(
|
||||
m.extra.counter = number(tag.get("AttackTick")).unwrap_or(0) as i32;
|
||||
m.extra.counter2 = number(tag.get("StunTick")).unwrap_or(0) as i32;
|
||||
m.extra.anim_ticks = number(tag.get("RoarTick")).unwrap_or(0) as i32;
|
||||
if crate::mobs::ravager::immobile(&m)
|
||||
&& !matches!(tag.get("attributes"), Some(Tag::List(l)) if l.iter().any(|a| text(a.get("id")).as_deref() == Some("minecraft:movement_speed")))
|
||||
{
|
||||
m.attrs.speed = 0.0;
|
||||
}
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Endermite {
|
||||
m.extra.counter = number(tag.get("Lifetime")).unwrap_or(0) as i32;
|
||||
@@ -3014,7 +3039,7 @@ pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
|
||||
for (key, value) in [
|
||||
("Health", Tag::Short(o.health as i16)),
|
||||
("Age", Tag::Short(o.age.min(i16::MAX as u32) as i16)),
|
||||
("Value", Tag::Short(o.value.min(i16::MAX as u32) as i16)),
|
||||
("Value", Tag::Short(o.value as i16)),
|
||||
("Count", Tag::Int(o.count.min(i32::MAX as u32) as i32)),
|
||||
] {
|
||||
put(&mut tag, key, value);
|
||||
@@ -3039,7 +3064,8 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
|
||||
position,
|
||||
previous: position,
|
||||
velocity: motion(tag),
|
||||
value: number(tag.get("Value")).unwrap_or(1).max(1) as u32,
|
||||
// `getShortOr("Value", 0)`, unclamped.
|
||||
value: tag.get("Value").and_then(int_value).unwrap_or(0) as i16 as i32,
|
||||
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
|
||||
on_ground: flag(tag, "OnGround"),
|
||||
count,
|
||||
@@ -3620,11 +3646,15 @@ pub struct TridentSave<'a> {
|
||||
pub life: i32,
|
||||
pub shake: u32,
|
||||
pub dealt_damage: bool,
|
||||
pub creative_only: bool,
|
||||
pub pickup: Pickup,
|
||||
pub left_owner: bool,
|
||||
pub has_been_shot: bool,
|
||||
pub owner: Option<[i32; 4]>,
|
||||
pub no_gravity: bool,
|
||||
pub fire_ticks: i32,
|
||||
pub item: &'a crate::items::ItemStack,
|
||||
}
|
||||
/// `ThrownTrident.addAdditionalSaveData`, including its arrow/projectile state.
|
||||
pub fn trident_to_nbt(t: &TridentSave, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let mut tag = Base {
|
||||
id: "minecraft:trident",
|
||||
@@ -3636,19 +3666,26 @@ pub fn trident_to_nbt(t: &TridentSave, uuid: [i32; 4], raw: Option<&Tag>) -> Tag
|
||||
fall_distance: 0.0,
|
||||
}
|
||||
.tag(raw);
|
||||
// Projectile.addAdditionalSaveData: LeftOwner once set. A trident keeps no
|
||||
// owner, and an ownerless projectile has left it on its first tick.
|
||||
// Projectile.addAdditionalSaveData omits LeftOwner until it is true,
|
||||
// including when raw NBT explicitly supplied false.
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.entry("LeftOwner".into()).or_insert(Tag::Byte(1));
|
||||
map.remove(&"LeftOwner".into());
|
||||
map.remove(&"Owner".into());
|
||||
}
|
||||
if t.left_owner {
|
||||
put(&mut tag, "LeftOwner", Tag::Byte(1));
|
||||
}
|
||||
if let Some(owner) = t.owner {
|
||||
put(&mut tag, "Owner", Tag::IntArray(owner.to_vec()));
|
||||
}
|
||||
for (key, value) in [
|
||||
("life", Tag::Short(t.life as i16)),
|
||||
("shake", Tag::Byte(t.shake as i8)),
|
||||
("inGround", Tag::Byte(i8::from(t.in_ground))),
|
||||
// AbstractArrow.Pickup: 0 disallowed, 1 allowed, 2 creative only.
|
||||
("pickup", Tag::Byte(if t.creative_only { 2 } else { 1 })),
|
||||
("pickup", Tag::Byte(t.pickup.byte())),
|
||||
("damage", Tag::Double(2.0_f64.to_bits())),
|
||||
("HasBeenShot", Tag::Byte(1)),
|
||||
("HasBeenShot", Tag::Byte(i8::from(t.has_been_shot))),
|
||||
("crit", Tag::Byte(0)),
|
||||
("PierceLevel", Tag::Byte(0)),
|
||||
("SoundEvent", string("minecraft:item.trident.hit_ground")),
|
||||
@@ -3666,8 +3703,7 @@ pub fn trident_to_nbt(t: &TridentSave, uuid: [i32; 4], raw: Option<&Tag>) -> Tag
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// (position, motion, yaw, pitch, in ground, life, shake, dealt damage,
|
||||
/// creative only, the trident item)
|
||||
/// `AbstractArrow` and `Projectile.readAdditionalSaveData` for a trident.
|
||||
pub struct TridentNbt {
|
||||
pub position: Vec3,
|
||||
pub motion: Vec3,
|
||||
@@ -3677,11 +3713,15 @@ pub struct TridentNbt {
|
||||
pub life: i32,
|
||||
pub shake: u32,
|
||||
pub dealt_damage: bool,
|
||||
pub creative_only: bool,
|
||||
pub pickup: Pickup,
|
||||
pub left_owner: bool,
|
||||
pub has_been_shot: bool,
|
||||
pub owner: Option<[i32; 4]>,
|
||||
pub no_gravity: bool,
|
||||
pub fire_ticks: i32,
|
||||
pub item: crate::items::ItemStack,
|
||||
}
|
||||
/// `ThrownTrident.readAdditionalSaveData`, including its arrow/projectile defaults.
|
||||
pub fn trident_from_nbt(tag: &Tag) -> Option<TridentNbt> {
|
||||
let [yaw, pitch] = rotation(tag);
|
||||
Some(TridentNbt {
|
||||
@@ -3693,7 +3733,10 @@ pub fn trident_from_nbt(tag: &Tag) -> Option<TridentNbt> {
|
||||
life: number(tag.get("life")).unwrap_or(0) as i32,
|
||||
shake: number(tag.get("shake")).unwrap_or(0).max(0) as u32,
|
||||
dealt_damage: flag(tag, "DealtDamage"),
|
||||
creative_only: number(tag.get("pickup")) == Some(2),
|
||||
pickup: Pickup::from_byte(number(tag.get("pickup")).unwrap_or(0)),
|
||||
left_owner: flag(tag, "LeftOwner"),
|
||||
has_been_shot: flag(tag, "HasBeenShot"),
|
||||
owner: owner_uuid(tag),
|
||||
no_gravity: flag(tag, "NoGravity"),
|
||||
fire_ticks: number(tag.get("Fire")).unwrap_or(0) as i32,
|
||||
item: tag
|
||||
@@ -5115,7 +5158,10 @@ mod tests {
|
||||
life: 9,
|
||||
shake: 3,
|
||||
dealt_damage: true,
|
||||
creative_only: true,
|
||||
pickup: Pickup::CreativeOnly,
|
||||
left_owner: true,
|
||||
has_been_shot: true,
|
||||
owner: Some([5, 6, 7, 8]),
|
||||
no_gravity: true,
|
||||
fire_ticks: 0,
|
||||
item: &trident,
|
||||
@@ -5128,7 +5174,9 @@ mod tests {
|
||||
assert!(t.no_gravity);
|
||||
assert!(matches!(tag.get("NoGravity"), Some(Tag::Byte(1))));
|
||||
assert_eq!((t.in_ground, t.life, t.shake), (true, 9, 3));
|
||||
assert_eq!((t.dealt_damage, t.creative_only), (true, true));
|
||||
assert_eq!((t.dealt_damage, t.pickup), (true, Pickup::CreativeOnly));
|
||||
assert!(t.left_owner && t.has_been_shot);
|
||||
assert_eq!(t.owner, Some([5, 6, 7, 8]));
|
||||
assert_eq!((t.yaw, t.pitch), (10.0, 20.0));
|
||||
}
|
||||
|
||||
@@ -6073,7 +6121,14 @@ mod tests {
|
||||
assert_eq!(attributes(&armor_stand_from_nbt(&saved, 1).unwrap()), speed);
|
||||
}
|
||||
#[test]
|
||||
fn ghast_and_trident_save_their_always_present_keys() {
|
||||
fn summoned_trident_defaults_are_not_pickable_or_already_shot() {
|
||||
let raw = Tag::compound([
|
||||
("Pos", doubles(&[0.0, 0.0, 0.0])),
|
||||
("LeftOwner", Tag::Byte(0)),
|
||||
]);
|
||||
let loaded = trident_from_nbt(&raw).unwrap();
|
||||
assert_eq!(loaded.pickup, Pickup::Disallowed);
|
||||
assert!(!loaded.left_owner && !loaded.has_been_shot);
|
||||
let trident = ItemStack::new("minecraft:trident", 1);
|
||||
let tag = trident_to_nbt(
|
||||
&TridentSave {
|
||||
@@ -6085,15 +6140,20 @@ mod tests {
|
||||
life: 0,
|
||||
shake: 0,
|
||||
dealt_damage: false,
|
||||
creative_only: false,
|
||||
pickup: loaded.pickup,
|
||||
left_owner: loaded.left_owner,
|
||||
has_been_shot: loaded.has_been_shot,
|
||||
owner: loaded.owner,
|
||||
no_gravity: false,
|
||||
fire_ticks: 0,
|
||||
item: &trident,
|
||||
},
|
||||
[0; 4],
|
||||
None,
|
||||
Some(&raw),
|
||||
);
|
||||
assert_eq!(tag.get("LeftOwner"), Some(&Tag::Byte(1)));
|
||||
assert!(tag.get("LeftOwner").is_none());
|
||||
assert_eq!(tag.get("HasBeenShot"), Some(&Tag::Byte(0)));
|
||||
assert_eq!(tag.get("pickup"), Some(&Tag::Byte(0)));
|
||||
}
|
||||
#[test]
|
||||
fn orbs_keep_their_count() {
|
||||
@@ -7581,7 +7641,13 @@ mod sheep_nbt_tests {
|
||||
assert_eq!(number(tag.get("InWaterTime")), Some(600));
|
||||
assert_eq!(number(tag.get("DrownedConversionTime")), Some(299));
|
||||
assert_eq!((back.extra.counter, back.extra.counter2), (600, 300));
|
||||
// A skeleton outside powder snow is not afflicted: -1 whatever the
|
||||
// counter holds, as with a fresh or NoAI one.
|
||||
let (_, tag) = round_trip(&new("minecraft:skeleton"));
|
||||
assert_eq!(number(tag.get("FreezingTime")), Some(-1));
|
||||
assert_eq!(number(tag.get("StrayConversionTime")), Some(-1));
|
||||
let mut skeleton = new("minecraft:skeleton");
|
||||
skeleton.in_powder_snow = true;
|
||||
skeleton.extra.counter = 70;
|
||||
let (back, tag) = round_trip(&skeleton);
|
||||
assert_eq!(number(tag.get("FreezingTime")), Some(70));
|
||||
@@ -7604,6 +7670,30 @@ mod sheep_nbt_tests {
|
||||
assert_eq!(number(tag.get(conversion)), Some(-1));
|
||||
assert_eq!((back.extra.counter, back.extra.counter2), (-1, 0));
|
||||
}
|
||||
// Zombie villagers and zombified piglins keep Zombie's tracker tags.
|
||||
for kind in ["minecraft:zombie_villager", "minecraft:zombified_piglin"] {
|
||||
let (_, tag) = round_trip(&new(kind));
|
||||
assert_eq!(number(tag.get("DrownedConversionTime")), Some(-1), "{kind}");
|
||||
assert_eq!(number(tag.get("InWaterTime")), Some(-1), "{kind}");
|
||||
}
|
||||
// A ticking one that left the snow or the water saves -1 over the times
|
||||
// it was loaded with (ConversionTracker.tick: setConverting(false)).
|
||||
for (kind, conversion, affliction) in [
|
||||
("minecraft:skeleton", "StrayConversionTime", "FreezingTime"),
|
||||
("minecraft:zombie", "DrownedConversionTime", "InWaterTime"),
|
||||
] {
|
||||
let loaded = Tag::compound([
|
||||
("id", string(kind)),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
(conversion, Tag::Int(200)),
|
||||
(affliction, Tag::Int(5)),
|
||||
]);
|
||||
let mut m = new(kind);
|
||||
(m.extra.counter, m.extra.counter2) = (-1, 0);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&loaded), Some(®istry), |_| None);
|
||||
assert_eq!(number(tag.get(conversion)), Some(-1), "{kind}");
|
||||
assert_eq!(number(tag.get(affliction)), Some(-1), "{kind}");
|
||||
}
|
||||
// A saved conversion time starts one: `startConversion`.
|
||||
let saved = Tag::compound([
|
||||
("id", string("minecraft:zombie")),
|
||||
@@ -10351,4 +10441,48 @@ mod equine_temper_preservation_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equine_and_ravager_attributes_parity() {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
Arc::make_mut(&mut kind).species = Species::Donkey;
|
||||
let types = [("minecraft:donkey".into(), kind.clone())].into();
|
||||
let donkey = mob_from_nbt(
|
||||
&Tag::compound([
|
||||
("id", string("minecraft:donkey")),
|
||||
("Pos", doubles(&[0.0, 0.0, 0.0])),
|
||||
]),
|
||||
&types,
|
||||
1,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(donkey.extra.vector[0], 0.5);
|
||||
assert_eq!(
|
||||
mob_to_nbt(&donkey, [0; 4], None, None, |_| None).get("attributes"),
|
||||
Some(&Tag::List(vec![]))
|
||||
);
|
||||
Arc::make_mut(&mut kind).species = Species::Ravager;
|
||||
let types = [("minecraft:ravager".into(), kind)].into();
|
||||
let mut ravager = mob_from_nbt(
|
||||
&Tag::compound([
|
||||
("id", string("minecraft:ravager")),
|
||||
("Pos", doubles(&[0.0, 0.0, 0.0])),
|
||||
("RoarTick", Tag::Int(10)),
|
||||
]),
|
||||
&types,
|
||||
2,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
ravager.age = 1;
|
||||
let saved = mob_to_nbt(&ravager, [0; 4], None, None, |_| None);
|
||||
assert_eq!(
|
||||
saved.get("attributes"),
|
||||
Some(&Tag::List(vec![Tag::compound([
|
||||
("id", string("minecraft:movement_speed")),
|
||||
("base", Tag::Double(0.0f64.to_bits()))
|
||||
])]))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3287,6 +3287,8 @@ struct Game<'a> {
|
||||
saved_for_exit: bool,
|
||||
native_client_stopped: bool,
|
||||
pending_save: Option<saving::ClosingSave>,
|
||||
/// Scheduled end of the server tick (`MinecraftServer.haveTime`).
|
||||
server_tick_deadline: Option<Instant>,
|
||||
/// The last world frame reached the swap chain. A window the compositor
|
||||
/// stops showing gets no frames, and the event loop must not spin on it.
|
||||
frame_drawn: bool,
|
||||
@@ -5552,6 +5554,7 @@ impl Game<'_> {
|
||||
laps = horizon_client::spans::laps();
|
||||
let sent = self.lan_broadcast_changes();
|
||||
laps.lap("tb_broadcast");
|
||||
self.save_chunks_eagerly();
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.run_block_events(level, ticks);
|
||||
});
|
||||
@@ -6266,7 +6269,10 @@ impl Game<'_> {
|
||||
);
|
||||
self.container_edge_sound(key, true);
|
||||
}
|
||||
} else if &*name == "minecraft:beacon" && self.open_station(pos, &name) {
|
||||
} else if matches!(&*name, "minecraft:beacon" | "minecraft:enchanting_table")
|
||||
&& self.open_station(pos, &name)
|
||||
{
|
||||
// Station menus whose block has a block entity, not a container.
|
||||
self.world.block_entities.get_or_insert_with(key, || {
|
||||
BlockEntity::for_block(&name).unwrap_or(BlockEntity::Chest(Vec::new()))
|
||||
});
|
||||
@@ -7885,6 +7891,10 @@ impl Game<'_> {
|
||||
// keep their own packet cutoff (`ordered.announced`, #10063).
|
||||
self.ticked_entities_through = self.next_entity;
|
||||
}
|
||||
// ChunkMap still ticks while /tick freeze holds block/entity ticks.
|
||||
if !running {
|
||||
self.save_chunks_eagerly();
|
||||
}
|
||||
self.begin_ordered_ticks();
|
||||
if running {
|
||||
horizon_client::spans::time("tick_paintings", || self.tick_paintings());
|
||||
@@ -8569,6 +8579,8 @@ impl Game<'_> {
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::DaylightDetector
|
||||
| BlockEntity::Bell
|
||||
| BlockEntity::Conduit(_)
|
||||
| BlockEntity::EnchantingTable
|
||||
| BlockEntity::Campfire(_)
|
||||
| BlockEntity::ChiseledBookshelf(_)
|
||||
| BlockEntity::Brushable(_)
|
||||
@@ -8633,6 +8645,8 @@ impl Game<'_> {
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::DaylightDetector
|
||||
| BlockEntity::Bell
|
||||
| BlockEntity::Conduit(_)
|
||||
| BlockEntity::EnchantingTable
|
||||
| BlockEntity::Campfire(_)
|
||||
| BlockEntity::ChiseledBookshelf(_)
|
||||
| BlockEntity::Brushable(_)
|
||||
@@ -8768,6 +8782,8 @@ impl Game<'_> {
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::DaylightDetector
|
||||
| BlockEntity::Bell
|
||||
| BlockEntity::Conduit(_)
|
||||
| BlockEntity::EnchantingTable
|
||||
| BlockEntity::Campfire(_)
|
||||
| BlockEntity::ChiseledBookshelf(_)
|
||||
| BlockEntity::Brushable(_)
|
||||
@@ -12504,7 +12520,8 @@ impl Game<'_> {
|
||||
if !o.bounds().intersects(area) {
|
||||
continue;
|
||||
}
|
||||
let (id, orb_value, vanilla) = (o.id, o.value, o.can_merge(merge_id as u32, value));
|
||||
let vanilla = o.can_merge(merge_id as u32, value as i32);
|
||||
let (id, orb_value) = (o.id, o.points());
|
||||
if self.orb_merges(id, orb_value, 0, value, vanilla) {
|
||||
target = Some(i);
|
||||
break;
|
||||
@@ -12569,9 +12586,9 @@ impl Game<'_> {
|
||||
(
|
||||
b.can_merge(a.id, a.value),
|
||||
a.id,
|
||||
a.value,
|
||||
a.points(),
|
||||
b.id,
|
||||
b.value,
|
||||
b.points(),
|
||||
b.count,
|
||||
b.age,
|
||||
)
|
||||
@@ -12638,7 +12655,8 @@ impl Game<'_> {
|
||||
position: p,
|
||||
previous: p,
|
||||
velocity,
|
||||
value,
|
||||
// `getExperienceValue` sizes: 1 to 2477.
|
||||
value: value as i32,
|
||||
age: 0,
|
||||
on_ground: false,
|
||||
count: 1,
|
||||
@@ -12806,7 +12824,7 @@ impl Game<'_> {
|
||||
// ExperienceOrb.playerTouch: one orb every 2 ticks; a
|
||||
// merged orb gives its value once per count.
|
||||
self.xp_delay = 2;
|
||||
gained += o.value;
|
||||
gained += o.points();
|
||||
taken.push(o.clone());
|
||||
o.count -= 1;
|
||||
return o.count > 0;
|
||||
@@ -13997,11 +14015,15 @@ impl Game<'_> {
|
||||
self.save
|
||||
.save_chunk_tickets(&self.streaming.forced, self.tickets.portal_tickets())?;
|
||||
self.streaming.save(&mut self.world, wait);
|
||||
let now = Instant::now();
|
||||
let keys: Vec<_> = self.world.modified.drain().collect();
|
||||
let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k)));
|
||||
self.save
|
||||
.save_later(&chunks, self.pack.assets.registry, world_biomes!(self))?;
|
||||
// Prepare entities while the chunk writer runs, then use one barrier.
|
||||
if !wait {
|
||||
self.world.modified.autosaved(&keys, now);
|
||||
}
|
||||
self.save_entities(false)?;
|
||||
self.save_raids();
|
||||
if wait {
|
||||
@@ -14009,6 +14031,51 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `ChunkMap.saveChunksEagerly`: snapshot only while the tick and writer
|
||||
/// have room. NBT encoding, compression and durable writes stay on workers.
|
||||
fn save_chunks_eagerly(&mut self) {
|
||||
// No world open (`world_root` is set by `finish_open_world`): the save
|
||||
// is `Game::new`'s placeholder under the working directory.
|
||||
if self.remote.is_some()
|
||||
|| self.world_root.as_os_str().is_empty()
|
||||
|| self.agent.hold_server
|
||||
|| self.clock.sprinting()
|
||||
|| self
|
||||
.server_lists
|
||||
.as_ref()
|
||||
.is_some_and(|lists| !lists.lock().unwrap().autosave)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let deadline = self
|
||||
.server_tick_deadline
|
||||
.unwrap_or_else(|| Instant::now() + self.clock.tick_duration());
|
||||
let mut modified = std::mem::take(&mut self.world.modified);
|
||||
modified.save_eagerly(
|
||||
Instant::now(),
|
||||
|| Instant::now() < deadline && self.save.active_chunk_writes() < 128,
|
||||
|key| self.world.chunks.contains_key(&key),
|
||||
|key| {
|
||||
// `saveChunkIfNeeded` saves only a LevelChunk, so a lit one; a
|
||||
// column still waiting for its light keeps its queue entry.
|
||||
let Some(chunk) = self.world.chunks.get(&key).filter(|c| c.lit) else {
|
||||
return false;
|
||||
};
|
||||
let chunks = self.with_entities([chunk]);
|
||||
match self
|
||||
.save
|
||||
.save_later(&chunks, self.pack.assets.registry, world_biomes!(self))
|
||||
{
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: failed to save changed chunks: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
self.world.modified = modified;
|
||||
}
|
||||
/// `GameRenderer.tryTakeScreenshotIfNeeded`: while a local world without a
|
||||
/// saved icon is on the screen, grab the rendered frame once a second until
|
||||
/// the save has one. The Select World list shows it as the world's preview.
|
||||
@@ -14350,6 +14417,7 @@ impl Game<'_> {
|
||||
camera: camera.to_array(),
|
||||
sky_light: i32::from(light >> 4),
|
||||
block_light: i32::from(light & 15),
|
||||
biome: biomes.get(biome).name.clone(),
|
||||
temperature: biomes.get(biome).temperature,
|
||||
downfall: biomes.get(biome).downfall,
|
||||
};
|
||||
@@ -14628,7 +14696,9 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.bolt_instances(camera)
|
||||
};
|
||||
let mut additive = Vec::new();
|
||||
// Particles blended additively (resource packs' Polytone ones, #11486), from the
|
||||
// render that built `entities`.
|
||||
let mut additive = self.particles.take_additive();
|
||||
// The dragon is drawn in every dimension, as any entity: a dragon
|
||||
// summoned outside the End (the server's AddEntity) is shown too.
|
||||
if !title {
|
||||
@@ -15152,6 +15222,9 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
}
|
||||
for chunk in &unloaded {
|
||||
self.world.modified.unloaded(&(chunk.x, chunk.z));
|
||||
}
|
||||
let dirty: Vec<_> = unloaded
|
||||
.iter()
|
||||
.filter(|c| self.world.modified.remove(&(c.x, c.z)))
|
||||
@@ -15406,6 +15479,18 @@ impl Game<'_> {
|
||||
{
|
||||
title.drop_file(path.clone());
|
||||
}
|
||||
// GLFW never hands a field the keys an input method consumes: while a
|
||||
// composition is open, and the Enter or Escape that ends it (winit on
|
||||
// Windows still sends that one right after `Ime::Commit`).
|
||||
if let WindowEvent::KeyboardInput {
|
||||
event: key_event, ..
|
||||
} = &event
|
||||
&& key_event.state == ElementState::Pressed
|
||||
&& self.text_field_focused()
|
||||
&& horizon_client::preedit::should_suppress_key()
|
||||
{
|
||||
return;
|
||||
}
|
||||
// A character that comes with no key (Windows' Alt codes, `Alt` + digits on the numeric
|
||||
// keypad: a `WM_CHAR` after the Alt is released) reaches winit as a key event with no
|
||||
// physical key. GLFW's char callback types it in the focused text field (`charTyped`),
|
||||
@@ -15756,6 +15841,7 @@ impl Game<'_> {
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
self.agent_cancel("the game window was closed; the game saves and exits");
|
||||
self.begin_closing_save(true)?;
|
||||
}
|
||||
// Input method composition: the committed text types like a key's text.
|
||||
@@ -17563,6 +17649,7 @@ impl<'a> Game<'a> {
|
||||
saved_for_exit: false,
|
||||
native_client_stopped: false,
|
||||
pending_save: None,
|
||||
server_tick_deadline: None,
|
||||
frame_drawn: true,
|
||||
world_icon: None,
|
||||
forced_hit: None,
|
||||
@@ -18239,6 +18326,10 @@ pub fn run_with() -> Result<()> {
|
||||
horizon_client::spans::record_here(true);
|
||||
}
|
||||
let vanilla_root = options.resources.clone();
|
||||
// Saves from before 1.13 upgrade with vanilla's tables, prepared from the player's jar.
|
||||
horizon_world::datafix::set_resources(
|
||||
&std::path::absolute(&vanilla_root).unwrap_or_else(|_| vanilla_root.clone()),
|
||||
);
|
||||
if let Some(error) = missing_resources(&vanilla_root) {
|
||||
// The game build started by hand (the release's zip), not by the
|
||||
// launcher: a window says where to get it. Scripts and servers get
|
||||
@@ -18258,16 +18349,14 @@ pub fn run_with() -> Result<()> {
|
||||
#[cfg(feature = "heartbeat")]
|
||||
horizon_client::heartbeat::start();
|
||||
let player_dir = horizon_client::logging::player_dir();
|
||||
// horizon-extras: the official and partner servers are Horizon's (docs/forking.md).
|
||||
// horizon-extras: the official server is Horizon's (docs/forking.md).
|
||||
if cfg!(feature = "horizon-extras")
|
||||
&& let Err(e) = horizon_client::multiplayer::add_official_server(&player_dir)
|
||||
{
|
||||
log::warn!("could not add the official server to servers.dat: {e}");
|
||||
}
|
||||
if cfg!(feature = "horizon-extras")
|
||||
&& let Err(e) = horizon_client::multiplayer::add_partner_servers(&player_dir)
|
||||
{
|
||||
log::warn!("could not add the partner servers to servers.dat: {e}");
|
||||
if let Err(e) = horizon_client::multiplayer::withdraw_partner_servers(&player_dir) {
|
||||
log::warn!("could not take the withdrawn partner server out of servers.dat: {e}");
|
||||
}
|
||||
}
|
||||
mods::register_components(&vanilla_root)?;
|
||||
@@ -18308,17 +18397,18 @@ pub fn run_with() -> Result<()> {
|
||||
return run_loading(options, early_settings, vanilla_root, None, started);
|
||||
}
|
||||
let mut pack_error = None;
|
||||
options.resources = packs::startup_root(&vanilla_root, &early_settings.resource_packs, &[])
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("Horizon: resource packs not applied: {e}");
|
||||
pack_error = Some(e.to_string());
|
||||
vanilla_root.clone()
|
||||
});
|
||||
options.resources = mods::content_root(
|
||||
&options.resources,
|
||||
let base = mods::content_root(
|
||||
&vanilla_root,
|
||||
&early_settings.mods.enabled,
|
||||
&options.dev_mods,
|
||||
);
|
||||
options.resources =
|
||||
packs::startup_root(&vanilla_root, &base, &early_settings.resource_packs, &[])
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("Horizon: resource packs not applied: {e}");
|
||||
pack_error = Some(e.to_string());
|
||||
base.clone()
|
||||
});
|
||||
let loaded = load_resources(
|
||||
options.resources.clone(),
|
||||
launch_world(&options),
|
||||
@@ -18825,6 +18915,103 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eager_saves_keep_dirty_order_cooldowns_and_writer_budget() {
|
||||
use horizon_client::world::ModifiedChunks;
|
||||
use std::cell::Cell;
|
||||
let now = Instant::now();
|
||||
let mut dirty = ModifiedChunks::default();
|
||||
dirty.extend((0..27).map(|x| (x, 0)));
|
||||
dirty.autosaved(&[(0, 0), (2, 0)], now);
|
||||
dirty.remove(&(1, 0)); // already clean
|
||||
let writes = Cell::new(127);
|
||||
let mut chosen = Vec::new();
|
||||
dirty.save_eagerly(
|
||||
now,
|
||||
|| writes.get() < 128,
|
||||
|k| k != (3, 0),
|
||||
|k| {
|
||||
chosen.push(k);
|
||||
writes.set(writes.get() + 1);
|
||||
true
|
||||
},
|
||||
);
|
||||
assert_eq!(chosen, [(4, 0)]); // cooldown, clean, absent, then budget exhausted
|
||||
chosen.clear();
|
||||
dirty.save_eagerly(
|
||||
now,
|
||||
|| true,
|
||||
|_| true,
|
||||
|k| {
|
||||
chosen.push(k);
|
||||
true
|
||||
},
|
||||
);
|
||||
assert_eq!(chosen, (5..25).map(|x| (x, 0)).collect::<Vec<_>>()); // exactly 20
|
||||
dirty.insert((4, 0)); // a later edit goes AFTER the still-cooled entries
|
||||
chosen.clear();
|
||||
dirty.save_eagerly(
|
||||
now + Duration::from_secs(10),
|
||||
|| true,
|
||||
|_| true,
|
||||
|k| {
|
||||
chosen.push(k);
|
||||
true
|
||||
},
|
||||
);
|
||||
assert_eq!(chosen, [(0, 0), (2, 0), (25, 0), (26, 0), (4, 0)]);
|
||||
dirty.insert((4, 0));
|
||||
dirty.save_eagerly(
|
||||
now + Duration::from_secs(11),
|
||||
|| false,
|
||||
|_| true,
|
||||
|_| panic!("no time"),
|
||||
);
|
||||
assert!(dirty.contains(&(4, 0)));
|
||||
dirty.unloaded(&(4, 0)); // unloading removes the cooldown even if reloaded immediately
|
||||
chosen.clear();
|
||||
dirty.save_eagerly(
|
||||
now + Duration::from_secs(11),
|
||||
|| true,
|
||||
|_| true,
|
||||
|k| {
|
||||
chosen.push(k);
|
||||
true
|
||||
},
|
||||
);
|
||||
assert_eq!(chosen, [(4, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn eager_saves_wait_for_an_open_world_and_for_light() {
|
||||
let mut game = super::native_machines::tests::server_game();
|
||||
let dir = std::env::temp_dir().join(format!("horizon-eager-game-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
game.save = Arc::new(horizon_client::save::SaveDir::new(
|
||||
dir.clone(),
|
||||
horizon_client::save::LevelShape::OVERWORLD,
|
||||
));
|
||||
Arc::make_mut(game.world.chunks.get_mut(&(0, 0)).unwrap()).lit = true;
|
||||
game.world
|
||||
.chunks
|
||||
.insert((1, 0), Arc::new(horizon_client::chunk::Chunk::empty(1, 0)));
|
||||
game.world.modified.extend([(0, 0), (1, 0)]);
|
||||
game.save_chunks_eagerly(); // no world open: nothing to save into
|
||||
assert!(game.world.modified.contains(&(0, 0)));
|
||||
game.world_root = dir.clone();
|
||||
game.save_chunks_eagerly();
|
||||
assert!(!game.world.modified.contains(&(0, 0)));
|
||||
assert!(game.world.modified.contains(&(1, 0))); // not lit yet
|
||||
Arc::make_mut(game.world.chunks.get_mut(&(1, 0)).unwrap()).lit = true;
|
||||
game.save_chunks_eagerly(); // still queued: no new dirty transition needed
|
||||
assert!(!game.world.modified.contains(&(1, 0)));
|
||||
game.save.flush().unwrap();
|
||||
assert!(dir.join("region/r.0.0.mca").exists());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrated_server_save_persists_owner_player_data() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
@@ -18901,6 +19088,8 @@ mod golem_defense_tests;
|
||||
#[cfg(test)]
|
||||
mod inline_feature_parse_tests;
|
||||
#[cfg(test)]
|
||||
mod inline_loot_reference_tests;
|
||||
#[cfg(test)]
|
||||
mod interaction_data_tests;
|
||||
#[cfg(test)]
|
||||
mod kill_die_cancel_tests;
|
||||
@@ -18911,14 +19100,20 @@ mod loot_kill_guest_tests;
|
||||
#[cfg(test)]
|
||||
mod mannequin_save_tests;
|
||||
#[cfg(test)]
|
||||
mod mob_landing_support_tests;
|
||||
#[cfg(test)]
|
||||
mod orb_data_tests;
|
||||
#[cfg(test)]
|
||||
mod stand_break_loot_tests;
|
||||
#[cfg(test)]
|
||||
mod stand_command_hurt_tests;
|
||||
#[cfg(test)]
|
||||
mod storage_alias_tests;
|
||||
#[cfg(test)]
|
||||
mod sulfur_bucket_tests;
|
||||
#[cfg(test)]
|
||||
mod sulfur_prime_phase_tests;
|
||||
#[cfg(test)]
|
||||
mod test_pos_guest_tests;
|
||||
#[cfg(test)]
|
||||
mod vibration_source_tests;
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
//! events (`CriteriaTriggers`), awarding with rewards, the toast and the
|
||||
//! chat announcement, and `players/advancements/<uuid>.json`.
|
||||
use super::{Dim, Game};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "inventory_changed_tests.rs"]
|
||||
mod inventory_changed_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "mod_toast_tests.rs"]
|
||||
mod mod_toast_tests;
|
||||
use horizon_client::actions::Action;
|
||||
use horizon_client::{
|
||||
advancements::{self as adv, EntityView, Frame, ItemView, Progress, Tree},
|
||||
@@ -24,6 +31,9 @@ pub(super) struct Toast {
|
||||
icon: String,
|
||||
frame: Frame,
|
||||
title: Text,
|
||||
/// A mod's own toast (`native_add_toast`): the line drawn in place of
|
||||
/// the frame's label, `title` then being its description.
|
||||
label: Option<Text>,
|
||||
slide: Slide,
|
||||
/// `ToastInstance.firstSlotIndex`: the 32-pixel slot it stacks in once it
|
||||
/// leaves the queue.
|
||||
@@ -42,6 +52,7 @@ impl Toast {
|
||||
icon: String::new(),
|
||||
frame: Frame::Task,
|
||||
title: Text::literal(""),
|
||||
label: None,
|
||||
slide: Slide::default(),
|
||||
slot: None,
|
||||
recipes: items,
|
||||
@@ -49,6 +60,26 @@ impl Toast {
|
||||
last_changed: 0,
|
||||
}
|
||||
}
|
||||
/// A mod's own toast (`native_add_toast`, API @API:add_toast@): drawn as an `AdvancementToast`
|
||||
/// with `label` in the frame label's place and `description` as its
|
||||
/// title, yellow (pink, with the challenge sound, for a challenge).
|
||||
pub(super) fn of_mod(icon: &str, label: Text, description: Text, challenge: bool) -> Self {
|
||||
Self {
|
||||
icon: icon.to_owned(),
|
||||
frame: if challenge {
|
||||
Frame::Challenge
|
||||
} else {
|
||||
Frame::Task
|
||||
},
|
||||
title: description,
|
||||
label: Some(label),
|
||||
slide: Slide::default(),
|
||||
slot: None,
|
||||
recipes: Vec::new(),
|
||||
changed: false,
|
||||
last_changed: 0,
|
||||
}
|
||||
}
|
||||
pub(super) fn is_recipe(&self) -> bool {
|
||||
!self.recipes.is_empty()
|
||||
}
|
||||
@@ -1020,6 +1051,7 @@ impl Game<'_> {
|
||||
icon: d.icon.clone(),
|
||||
frame: d.frame,
|
||||
title: text_of(&d.title),
|
||||
label: None,
|
||||
slide: Slide::default(),
|
||||
slot: None,
|
||||
recipes: Vec::new(),
|
||||
@@ -1127,6 +1159,17 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
self.adv.last_inventory = key;
|
||||
// Native mods hear the change before any criterion is looked at, so
|
||||
// they do with no `inventory_changed` advancement in the tree too
|
||||
// (`native_player_inventory_changed`, API @API:player_inventory_changed@).
|
||||
if self.remote.is_none()
|
||||
&& !self.no_player_here()
|
||||
&& !horizon_client::mod_api::mods().is_empty()
|
||||
{
|
||||
let stacks = inventory_slots(&self.inventory, &self.armor, &self.offhand);
|
||||
let player = self.native_acting_id();
|
||||
self.native_player_inventory_changed(player, &stacks);
|
||||
}
|
||||
let stacks: Vec<ItemView> = self
|
||||
.inventory
|
||||
.iter()
|
||||
@@ -2234,6 +2277,7 @@ impl Game<'_> {
|
||||
let x = batch.width - 160.0 * t.slide.portion;
|
||||
let fully_for = t.slide.fully_for;
|
||||
let (title, frame, icon) = (t.title.clone(), t.frame, t.icon.clone());
|
||||
let mod_label = t.label.clone();
|
||||
let recipe = t
|
||||
.is_recipe()
|
||||
.then(|| t.recipes[t.displayed_recipe(fully_for, multiplier)].clone());
|
||||
@@ -2260,7 +2304,7 @@ impl Game<'_> {
|
||||
// the frame's label; a longer one is wrapped at 125, the label fading
|
||||
// out first (to 1.5 s) and the lines fading in after it over 0.3 s.
|
||||
let lines = atlas.font.split(&title.resolve_legacy(), 125.0);
|
||||
let label = Text::translate(key, &[]);
|
||||
let label = mod_label.unwrap_or_else(|| Text::translate(key, &[]));
|
||||
let rgb = color & 0x00FF_FFFF;
|
||||
if let [line] = lines.as_slice() {
|
||||
batch.text_styled(atlas, &label, x + 30.0, y + 7.0, color, false);
|
||||
@@ -2489,6 +2533,7 @@ impl Game<'_> {
|
||||
icon: d.icon.clone(),
|
||||
frame: d.frame,
|
||||
title: text_of(&d.title),
|
||||
label: None,
|
||||
slide: Slide::default(),
|
||||
slot: None,
|
||||
recipes: Vec::new(),
|
||||
@@ -3421,3 +3466,18 @@ mod movement_predicate_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A player's 41 slots in `Inventory` numbering, for
|
||||
/// `native_player_inventory_changed`: the main inventory (0..36), the armour
|
||||
/// feet to head (36..40; `armor` holds it head first), the off hand (40).
|
||||
pub(super) fn inventory_slots(
|
||||
inventory: &[Option<ItemStack>],
|
||||
armor: &[Option<ItemStack>; 4],
|
||||
offhand: &Option<ItemStack>,
|
||||
) -> Vec<Option<ItemStack>> {
|
||||
let mut slots: Vec<Option<ItemStack>> = inventory.iter().take(36).cloned().collect();
|
||||
slots.resize(36, None);
|
||||
slots.extend(armor.iter().rev().cloned());
|
||||
slots.push(offhand.clone());
|
||||
slots
|
||||
}
|
||||
@@ -54,8 +54,8 @@ pub(super) const HELP: &[&str] = &[
|
||||
"mouse-move DX DY relative mouse motion, turned like a player's mouse",
|
||||
"attack [ID] swing at the target (after looking at entity ID)",
|
||||
"break X Y Z [--timeout S] mine a block until it breaks; reports drops and pickups",
|
||||
"place X Y Z [--item ITEM] place the held (or given) item at a position",
|
||||
"interact X Y Z [FACE] right-click a block (open a door, chest, crafting table...)",
|
||||
"place X Y Z [--item ITEM] [--sneak] place the held (or given) item at a position",
|
||||
"interact X Y Z [FACE] [--sneak] right-click a block (open a door, chest, crafting table...)",
|
||||
"use-item [--hold S] right-click with the held item (eat, drink, throw, draw a bow)",
|
||||
"select SLOT|ITEM select a hotbar slot (1-9) or the hotbar slot holding ITEM",
|
||||
"drop [all] drop one of the held item (all: the stack)",
|
||||
@@ -133,6 +133,16 @@ impl Args<'_> {
|
||||
v.parse()
|
||||
.map_err(|_| format!("{what} must be a number, not {v}").into())
|
||||
}
|
||||
/// X or Z of a goto target: a whole number names a block, so aim at its
|
||||
/// center the way the player stands in it; a decimal is an exact position.
|
||||
fn column(&self, i: usize, what: &str) -> Result<f64> {
|
||||
let v = self.f64(i, what)?;
|
||||
Ok(if whole(self.get(i, what)?) {
|
||||
v + 0.5
|
||||
} else {
|
||||
v
|
||||
})
|
||||
}
|
||||
fn i32(&self, i: usize, what: &str) -> Result<i32> {
|
||||
let v = self.get(i, what)?;
|
||||
v.parse()
|
||||
@@ -179,6 +189,9 @@ fn face_dir(name: &str) -> Result<[i32; 3]> {
|
||||
fn add(a: [i32; 3], b: [i32; 3]) -> [i32; 3] {
|
||||
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
|
||||
}
|
||||
fn whole(v: &str) -> bool {
|
||||
!v.contains(['.', 'e', 'E'])
|
||||
}
|
||||
fn dist(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
|
||||
}
|
||||
@@ -236,6 +249,8 @@ enum TaskKind {
|
||||
report_tick: u64,
|
||||
release: Option<Instant>,
|
||||
place: bool,
|
||||
/// `--sneak`: the click waits until the sneak key has been held.
|
||||
sneak: Option<SneakClick>,
|
||||
},
|
||||
Attack {
|
||||
id: Option<u32>,
|
||||
@@ -275,6 +290,16 @@ struct Break {
|
||||
broke_tick: u64,
|
||||
drops: BTreeMap<u32, ItemStack>,
|
||||
}
|
||||
/// A right click made while sneaking (placing against a crafting table or a
|
||||
/// chest instead of opening it): the sneak key is held for a couple of ticks
|
||||
/// first, so the server has the player's shift state before the click.
|
||||
struct SneakClick {
|
||||
/// Not clicked yet: click on this tick.
|
||||
click_tick: Option<u64>,
|
||||
/// The block (and face) to aim at again, as sneaking lowers the eye.
|
||||
aim: Option<([i32; 3], Option<[i32; 3]>)>,
|
||||
hold: f64,
|
||||
}
|
||||
struct Task {
|
||||
kind: TaskKind,
|
||||
reply: Sender<String>,
|
||||
@@ -382,6 +407,13 @@ impl Game<'_> {
|
||||
if s.damage > 0 {
|
||||
v["damage"] = json!(s.damage);
|
||||
}
|
||||
// How worn a tool or armour piece is: its durability and what is left.
|
||||
let items = self.res.items();
|
||||
if horizon_client::stations::is_damageable(items, s) {
|
||||
let max = horizon_client::stations::max_damage(items, s);
|
||||
v["max_damage"] = json!(max);
|
||||
v["durability_left"] = json!((i64::from(max) - i64::from(s.damage)).max(0));
|
||||
}
|
||||
if let Some(c) = horizon_client::item_data::encode_components(s) {
|
||||
v["components"] = json!(horizon_client::snbt::to_snbt(&c));
|
||||
}
|
||||
@@ -758,7 +790,20 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
list.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
json!({ "entities": list.into_iter().map(|(_, v)| v).collect::<Vec<_>>() })
|
||||
// What the search covered, so an empty list reads as "none within
|
||||
// the radius" rather than "none at all": everything this client
|
||||
// tracks, and the loaded chunks.
|
||||
json!({
|
||||
"entities": list.into_iter().map(|(_, v)| v).collect::<Vec<_>>(),
|
||||
"radius": radius,
|
||||
"type": want,
|
||||
"tracked": {
|
||||
"mobs": self.mobs.iter().filter(|m| !m.removed).count(),
|
||||
"items": self.dropped.len(),
|
||||
"orbs": self.orbs.len(),
|
||||
},
|
||||
"loaded_chunks": self.world.chunks.len(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn inventory_value(&self) -> Value {
|
||||
@@ -895,12 +940,15 @@ impl Game<'_> {
|
||||
let point = |f: &[i32; 3]| [0, 1, 2].map(|i| centre[i] + f64::from(f[i]) * 0.45);
|
||||
dist(eye, point(a)).total_cmp(&dist(eye, point(b)))
|
||||
});
|
||||
// The first other block a ray stopped at, to name what is in the way.
|
||||
let mut in_the_way: Option<[i32; 3]> = None;
|
||||
for f in faces {
|
||||
for inset in [0.45, 0.3, 0.0] {
|
||||
let point = [0, 1, 2].map(|i| centre[i] + f64::from(f[i]) * inset);
|
||||
let (yaw, pitch) = yaw_pitch(eye, point);
|
||||
let dir = super::view_vector(yaw, pitch);
|
||||
if let Some(hit) = self.clip_ray(eye, dir, self.reach())
|
||||
let hit = self.clip_ray(eye, dir, self.reach());
|
||||
if let Some(hit) = &hit
|
||||
&& hit.pos == pos
|
||||
&& (face.is_none() || hit.face == f)
|
||||
{
|
||||
@@ -908,6 +956,11 @@ impl Game<'_> {
|
||||
self.pitch = pitch;
|
||||
return Ok(hit.face);
|
||||
}
|
||||
if let Some(hit) = hit
|
||||
&& hit.pos != pos
|
||||
{
|
||||
in_the_way.get_or_insert(hit.pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
let d = dist(eye, centre);
|
||||
@@ -918,13 +971,56 @@ impl Game<'_> {
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if let Some(p) = in_the_way {
|
||||
let name = &self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(p[0], p[1], p[2]))
|
||||
.name;
|
||||
return Err(format!(
|
||||
"no face of block {pos:?} is visible from here: {name} at {p:?} is in the way"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Err(format!("no face of block {pos:?} is visible from here").into())
|
||||
}
|
||||
|
||||
/// `--sneak` on place or interact: hold the sneak key now and click a
|
||||
/// couple of ticks later, from the lowered eye (see [`SneakClick`]).
|
||||
fn sneak_click(
|
||||
&mut self,
|
||||
a: &Args<'_>,
|
||||
aim: Option<([i32; 3], Option<[i32; 3]>)>,
|
||||
hold: f64,
|
||||
) -> Option<SneakClick> {
|
||||
if !a.has("sneak") {
|
||||
return None;
|
||||
}
|
||||
self.keys.press(KeyCode::ShiftLeft, Source::Agent);
|
||||
Some(SneakClick {
|
||||
click_tick: Some(self.ticks + 2),
|
||||
aim,
|
||||
hold,
|
||||
})
|
||||
}
|
||||
|
||||
/// A left or right click, as the mouse would.
|
||||
fn agent_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
|
||||
self.mouse_mapping(button, pressed, Source::Agent)
|
||||
}
|
||||
/// Ends the running agent command (and a turn) with `why`, so the agent
|
||||
/// hears why rather than the socket just closing (the window closing
|
||||
/// while it waits, say).
|
||||
pub(super) fn agent_cancel(&mut self, why: &str) {
|
||||
let reply = json!({ "error": why, "cancelled": true }).to_string();
|
||||
if let Some(t) = self.agent.task.take() {
|
||||
let _ = t.reply.send(reply.clone());
|
||||
}
|
||||
if let Some(g) = self.agent.glide.take() {
|
||||
let _ = g.reply.send(reply);
|
||||
}
|
||||
self.release_agent_keys();
|
||||
}
|
||||
fn release_agent_keys(&mut self) {
|
||||
for k in MOVE_KEYS {
|
||||
self.keys.release(&k, Source::Agent);
|
||||
@@ -1195,7 +1291,7 @@ impl Game<'_> {
|
||||
}
|
||||
"goto" => {
|
||||
self.playing()?;
|
||||
let target = [a.f64(0, "X")?, a.f64(1, "Y")?, a.f64(2, "Z")?];
|
||||
let target = [a.column(0, "X")?, a.f64(1, "Y")?, a.column(2, "Z")?];
|
||||
let near = a.num("near", 0.6)?.max(0.3);
|
||||
let timeout = a.num("timeout", 60.0)?;
|
||||
let p = self.player.as_ref().ok_or("no player")?.position;
|
||||
@@ -1375,6 +1471,7 @@ impl Game<'_> {
|
||||
let mut aimed = !self.res.facts().is_air(before)
|
||||
&& !self.res.facts().get(before).outline.is_empty()
|
||||
&& self.aim_block(pos, None).is_ok();
|
||||
let mut aim_at = aimed.then_some((pos, None));
|
||||
for (_, d) in FACES.iter().filter(|_| !aimed) {
|
||||
let d = *d;
|
||||
let support = add(pos, d);
|
||||
@@ -1389,6 +1486,7 @@ impl Game<'_> {
|
||||
let face = d.map(|v| -v);
|
||||
if self.aim_block(support, Some(face)).is_ok() {
|
||||
aimed = true;
|
||||
aim_at = Some((support, Some(face)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1399,8 +1497,11 @@ impl Game<'_> {
|
||||
.into());
|
||||
}
|
||||
let inventory = self.inventory_counts();
|
||||
self.agent_mouse(MouseButton::Right, true)?;
|
||||
self.agent_mouse(MouseButton::Right, false)?;
|
||||
let sneak = self.sneak_click(&a, aim_at, 0.0);
|
||||
if sneak.is_none() {
|
||||
self.agent_mouse(MouseButton::Right, true)?;
|
||||
self.agent_mouse(MouseButton::Right, false)?;
|
||||
}
|
||||
let report_tick = self.ticks + 2;
|
||||
self.start_task(
|
||||
"place",
|
||||
@@ -1411,6 +1512,7 @@ impl Game<'_> {
|
||||
report_tick,
|
||||
release: None,
|
||||
place: true,
|
||||
sneak,
|
||||
},
|
||||
reply,
|
||||
5.0,
|
||||
@@ -1419,22 +1521,29 @@ impl Game<'_> {
|
||||
"interact" | "use-item" => {
|
||||
self.playing()?;
|
||||
let hold = a.num("hold", 0.0)?;
|
||||
let mut aim_at = None;
|
||||
let pos = if name == "interact" {
|
||||
let pos = a.block_pos(0)?;
|
||||
let face = a.pos.get(3).map(|f| face_dir(f)).transpose()?;
|
||||
self.aim_block(pos, face)?;
|
||||
aim_at = Some((pos, face));
|
||||
Some(pos)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let before = pos.map(|p| self.world.block(p[0], p[1], p[2]));
|
||||
let inventory = self.inventory_counts();
|
||||
self.agent_mouse(MouseButton::Right, true)?;
|
||||
let release = if hold > 0.0 {
|
||||
Some(Instant::now() + Duration::from_secs_f64(hold))
|
||||
} else {
|
||||
self.agent_mouse(MouseButton::Right, false)?;
|
||||
let sneak = self.sneak_click(&a, aim_at, hold);
|
||||
let release = if sneak.is_some() {
|
||||
None
|
||||
} else {
|
||||
self.agent_mouse(MouseButton::Right, true)?;
|
||||
if hold > 0.0 {
|
||||
Some(Instant::now() + Duration::from_secs_f64(hold))
|
||||
} else {
|
||||
self.agent_mouse(MouseButton::Right, false)?;
|
||||
None
|
||||
}
|
||||
};
|
||||
let report_tick = self.ticks + 2;
|
||||
self.start_task(
|
||||
@@ -1450,6 +1559,7 @@ impl Game<'_> {
|
||||
report_tick,
|
||||
release,
|
||||
place: false,
|
||||
sneak,
|
||||
},
|
||||
reply,
|
||||
hold + 5.0,
|
||||
@@ -1964,7 +2074,29 @@ impl Game<'_> {
|
||||
report_tick,
|
||||
release,
|
||||
place,
|
||||
sneak,
|
||||
} => {
|
||||
if let Some(s) = sneak
|
||||
&& let Some(at) = s.click_tick
|
||||
{
|
||||
if self.ticks < at && !timed_out && interrupted.is_none() {
|
||||
return None;
|
||||
}
|
||||
s.click_tick = None;
|
||||
if interrupted.is_none() && !timed_out {
|
||||
if let Some((p, f)) = s.aim {
|
||||
let _ = self.aim_block(p, f);
|
||||
}
|
||||
let _ = self.agent_mouse(MouseButton::Right, true);
|
||||
if s.hold > 0.0 {
|
||||
*release = Some(Instant::now() + Duration::from_secs_f64(s.hold));
|
||||
} else {
|
||||
let _ = self.agent_mouse(MouseButton::Right, false);
|
||||
}
|
||||
*report_tick = self.ticks + 2;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if let Some(at) = release {
|
||||
if Instant::now() < *at && !timed_out && interrupted.is_none() {
|
||||
return None;
|
||||
@@ -1990,6 +2122,23 @@ impl Game<'_> {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Likewise a block's menu (crafting table, furnace, chest)
|
||||
// opens once the server's open-screen packet comes: wait for
|
||||
// it while nothing else came of the click yet.
|
||||
if !*place
|
||||
&& pos.is_some()
|
||||
&& !changed
|
||||
&& self.remote.is_some()
|
||||
&& self.screen == Screen::Playing
|
||||
&& *inventory == self.inventory_counts()
|
||||
&& self.ticks < *report_tick + 20
|
||||
&& !timed_out
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if sneak.is_some() {
|
||||
self.keys.release(&KeyCode::ShiftLeft, Source::Agent);
|
||||
}
|
||||
let after = pos.map(|p| self.block_json(p));
|
||||
let used = delta(inventory, &self.inventory_counts());
|
||||
let mut v = json!({
|
||||
@@ -2001,9 +2150,24 @@ impl Game<'_> {
|
||||
"screen": self.screen_label(),
|
||||
});
|
||||
if *place && !changed {
|
||||
v["error"] = json!(
|
||||
// The player's own box (0.6 wide, 1.8 high) in the cell
|
||||
// is the usual reason: say so.
|
||||
let p = self.position();
|
||||
let player_inside = pos.is_some_and(|b| {
|
||||
(0..3).all(|i| {
|
||||
let (lo, hi) = if i == 1 {
|
||||
(p[1], p[1] + 1.8)
|
||||
} else {
|
||||
(p[i] - 0.3, p[i] + 0.3)
|
||||
};
|
||||
lo < f64::from(b[i]) + 1.0 && hi > f64::from(b[i])
|
||||
})
|
||||
});
|
||||
v["error"] = json!(if player_inside {
|
||||
"the block did not change: the player is standing in that cell; move out of it first"
|
||||
} else {
|
||||
"the block did not change (blocked by an entity, or the item cannot be placed)"
|
||||
);
|
||||
});
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
@@ -2036,13 +2200,13 @@ impl Game<'_> {
|
||||
if navigation::arrived(p, g.target, g.near) {
|
||||
return Some(
|
||||
json!({ "ok": true, "arrived": true, "position": p, "target": g.target,
|
||||
"distance": dist(p, g.target), "warning": g.warning }),
|
||||
"distance": dist(p, g.target), "horizontal_distance": horizontal(p, g.target), "warning": g.warning }),
|
||||
);
|
||||
}
|
||||
if timed_out {
|
||||
return Some(
|
||||
json!({ "error": "timed out before arriving", "arrived": false, "position": p,
|
||||
"target": g.target, "distance": dist(p, g.target), "warning": g.warning }),
|
||||
"target": g.target, "distance": dist(p, g.target), "horizontal_distance": horizontal(p, g.target), "warning": g.warning }),
|
||||
);
|
||||
}
|
||||
let previous_next = g.next;
|
||||
@@ -2065,7 +2229,7 @@ impl Game<'_> {
|
||||
if g.replans > 3 {
|
||||
return Some(
|
||||
json!({ "error": "stuck: cannot make progress", "arrived": false,
|
||||
"position": p, "target": g.target, "distance": dist(p, g.target) }),
|
||||
"position": p, "target": g.target, "distance": dist(p, g.target), "horizontal_distance": horizontal(p, g.target) }),
|
||||
);
|
||||
}
|
||||
if g.next > 0 && g.next < g.path.nodes.len() {
|
||||
@@ -2084,7 +2248,7 @@ impl Game<'_> {
|
||||
return Some(
|
||||
json!({ "error": "no further walking progress through loaded terrain",
|
||||
"arrived": false, "position": p, "target": g.target,
|
||||
"distance": dist(p, g.target), "warning": g.warning }),
|
||||
"distance": dist(p, g.target), "horizontal_distance": horizontal(p, g.target), "warning": g.warning }),
|
||||
);
|
||||
};
|
||||
g.path = path;
|
||||
@@ -2127,6 +2291,14 @@ impl Game<'_> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether a mined block is gone: air, or the bare fluid a waterlogged
|
||||
/// block or ice leaves behind.
|
||||
fn block_gone(&self, state: BlockStateId) -> bool {
|
||||
let facts = self.res.facts();
|
||||
let f = facts.get(state);
|
||||
facts.is_air(state) || f.fluid != horizon_client::blocks::Fluid::None && !f.waterlogged
|
||||
}
|
||||
|
||||
fn step_break(
|
||||
&mut self,
|
||||
b: &mut Break,
|
||||
@@ -2138,6 +2310,11 @@ impl Game<'_> {
|
||||
if let Some(why) = interrupted {
|
||||
return Some(json!({ "error": why, "pos": b.pos }));
|
||||
}
|
||||
if state != b.before && !self.block_gone(state) {
|
||||
// Changed into another block while mined (dirt spread to
|
||||
// grass, a crop grew, copper weathered): keep mining it.
|
||||
b.before = state;
|
||||
}
|
||||
if state != b.before {
|
||||
// Broken: collect what drops for a moment.
|
||||
let _ = self.agent_mouse(MouseButton::Left, false);
|
||||
@@ -2145,10 +2322,13 @@ impl Game<'_> {
|
||||
b.collect_until = Some(self.ticks + 20);
|
||||
} else if timed_out {
|
||||
let _ = self.agent_mouse(MouseButton::Left, false);
|
||||
return Some(
|
||||
json!({ "error": "timed out before the block broke", "pos": b.pos,
|
||||
"block": self.state_string(state), "ticks": self.ticks - b.started_tick }),
|
||||
);
|
||||
// The held item explains most slow breaks: a tool that broke
|
||||
// mid-vein leaves the bare hand, and stone never breaks by hand.
|
||||
return Some(json!({
|
||||
"error": "timed out before the block broke", "pos": b.pos,
|
||||
"block": self.state_string(state), "ticks": self.ticks - b.started_tick,
|
||||
"held": self.held().map(|s| self.stack_value(s)),
|
||||
}));
|
||||
} else if self.screen != Screen::Playing {
|
||||
return Some(
|
||||
json!({ "error": format!("the {} screen opened", self.screen_label()), "pos": b.pos }),
|
||||
@@ -2193,6 +2373,7 @@ impl Game<'_> {
|
||||
"ticks": b.broke_tick - b.started_tick,
|
||||
"drops": counts(b.drops.values().cloned()),
|
||||
"picked_up": delta(&b.inventory, &self.inventory_counts()),
|
||||
"held": self.held().map(|s| self.stack_value(s)),
|
||||
"items_left_on_ground": remaining,
|
||||
"on_ground": self.dropped.iter().filter(|i| b.drops.contains_key(&i.id)).map(|i| json!({
|
||||
"id": i.id, "item": &*i.stack.item, "count": i.stack.count,
|
||||
@@ -2206,6 +2387,12 @@ impl Game<'_> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn whole_goto_coordinates_name_a_block() {
|
||||
assert!(whole("8") && whole("-12"));
|
||||
assert!(!whole("8.0") && !whole("8.25") && !whole("1e2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_wait_keeps_held_input() {
|
||||
assert!(!TaskKind::Wait { until_tick: 40 }.releases_input());
|
||||
|
||||
@@ -1158,6 +1158,10 @@ impl Game<'_> {
|
||||
pub(super) fn break_armor_stand_by_anything(&mut self, i: usize) {
|
||||
let a = self.armor_stands.remove(i);
|
||||
let at = [a.position.x, a.position.y, a.position.z];
|
||||
// brokenByAnything's dropAllDeathLoot, before the equipment.
|
||||
if !a.small && self.ui.rules.bool("mob_drops") {
|
||||
self.armor_stand_death_loot(at, a.fire_ticks > 0);
|
||||
}
|
||||
let (pos, drops) = broken_armor_stand_equipment(a);
|
||||
for stack in drops {
|
||||
self.pop_resource(pos, stack);
|
||||
@@ -1167,6 +1171,42 @@ impl Game<'_> {
|
||||
self.entity_killed_event(at, false);
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
}
|
||||
/// `LivingEntity.dropFromLootTable` for a stand that is not small, with
|
||||
/// `mob_drops` on (`shouldDropLoot`): its type's table
|
||||
/// `minecraft:entities/armor_stand` with seed 0 (`getLootTableSeed`), so
|
||||
/// the table's random sequence or else the level's random, each stack
|
||||
/// spawned at the stand's position. Its view is only the type and whether
|
||||
/// it burns; no damage source, killer or player is known here.
|
||||
fn armor_stand_death_loot(&mut self, at: [f64; 3], on_fire: bool) {
|
||||
const TABLE: &str = "minecraft:entities/armor_stand";
|
||||
let Some(loot) = horizon_client::structure_blocks::loot_data(
|
||||
&self.options.resources,
|
||||
&self.pack_files(),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
if !loot.has_table(TABLE) {
|
||||
return;
|
||||
}
|
||||
let params = horizon_world::loot::EntityLootParams {
|
||||
this: horizon_world::loot::LootEntity {
|
||||
on_fire,
|
||||
baby: false,
|
||||
..horizon_world::loot::LootEntity::new("minecraft:armor_stand")
|
||||
},
|
||||
..horizon_world::loot::EntityLootParams::default()
|
||||
};
|
||||
let stacks = match loot.random_sequence(TABLE) {
|
||||
Some(id) => {
|
||||
let random = self.rand_sequences.get(id, self.level.seed);
|
||||
loot.roll_entity(TABLE, random, ¶ms)
|
||||
}
|
||||
None => loot.roll_entity(TABLE, &mut self.world.random, ¶ms),
|
||||
};
|
||||
for stack in stacks.iter().filter(|s| !s.is_empty()) {
|
||||
self.spawn_item_own(super::creatures::from_loot(stack), at, 10);
|
||||
}
|
||||
}
|
||||
/// `ArmorStand.hurtServer` for a command's damage of `damage_type` from the
|
||||
/// causing entity `causing`, up to its sources that act and still return
|
||||
/// false: a blast breaks the stand (`brokenByAnything`, `kill`), fire that
|
||||
|
||||
@@ -702,12 +702,26 @@ impl Game<'_> {
|
||||
/// `ConduitBlockEntity.updateAndAttackTarget`: while the conduit hunts
|
||||
/// (42 frame blocks) its target stays until it dies or leaves the 8
|
||||
/// blocks, else a random enemy in water or rain is chosen; the target
|
||||
/// takes 4 magic damage.
|
||||
/// takes 4 magic damage. The target is the block entity's (`Target`), so
|
||||
/// a saved or `/data` one is kept or dropped as vanilla's reference is.
|
||||
fn conduit_attack(&mut self, conduit: &mut ConduitServer, pos: [i32; 3], hunting: bool) {
|
||||
conduit.target = match conduit.target {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let saved = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::Conduit(target)) => Some(*target),
|
||||
_ => None,
|
||||
};
|
||||
// EntityReference.getLivingEntity: a reference no loaded mob answers
|
||||
// is no target, and no new one is chosen in its place this time.
|
||||
let current = match saved {
|
||||
Some(Some(uuid)) => Some(self.mob_by_uuid(uuid)),
|
||||
Some(None) => None,
|
||||
None => conduit.target.map(Some),
|
||||
};
|
||||
conduit.target = match current {
|
||||
_ if !hunting => None,
|
||||
None => self.conduit_new_target(pos),
|
||||
Some(id) => self
|
||||
Some(None) => None,
|
||||
Some(Some(id)) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && !m.removed)
|
||||
@@ -723,6 +737,16 @@ impl Game<'_> {
|
||||
})
|
||||
.map(|m| m.id),
|
||||
};
|
||||
if saved.is_some() {
|
||||
let target = conduit.target.map(|id| self.mob_uuid(id));
|
||||
if saved != Some(target)
|
||||
&& let Some(BlockEntity::Conduit(saved)) = self.world.block_entities.get_mut(&key)
|
||||
{
|
||||
// `sendBlockUpdated`: the new target reaches the clients.
|
||||
*saved = target;
|
||||
self.mark_block_entity_updated(key);
|
||||
}
|
||||
}
|
||||
let Some(m) = conduit
|
||||
.target
|
||||
.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id))
|
||||
|
||||
@@ -323,6 +323,8 @@ impl Game<'_> {
|
||||
) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
self.pending.banner.remove(&pos);
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.sculk_place_entity(state);
|
||||
let Some(saved) = self.saved_block_entity(key) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -744,7 +744,7 @@ impl Game<'_> {
|
||||
inv_friction = 0.9;
|
||||
}
|
||||
Status::UnderWater => {
|
||||
buoyancy = 0.01;
|
||||
buoyancy = f64::from(0.01_f32);
|
||||
inv_friction = 0.45;
|
||||
}
|
||||
Status::InAir => inv_friction = 0.9,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Run alone with private prepared resources.
|
||||
use super::super::*;
|
||||
use super::{MAX_CLIENT_FIREWORK_EXPLOSIONS, client_fireworks_ok};
|
||||
use crate::mod_api::{FireworkExplosion, FireworkShape, Host};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_client::particles::ParticleLevel;
|
||||
|
||||
fn star(colors: &[u32]) -> FireworkExplosion {
|
||||
FireworkExplosion {
|
||||
shape: FireworkShape::Star,
|
||||
colors: colors.to_vec(),
|
||||
fade: vec![0xFFFFFF],
|
||||
trail: true,
|
||||
twinkle: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_finite_shows_of_up_to_64_explosions_are_drawn() {
|
||||
let at = [0.0, 70.0, 0.0];
|
||||
assert!(client_fireworks_ok(at, [0.0; 3], &[]));
|
||||
assert!(client_fireworks_ok(
|
||||
at,
|
||||
[0.0, 0.1, 0.0],
|
||||
&[star(&[0xFF0000])]
|
||||
));
|
||||
let many = vec![star(&[0x00FF00]); MAX_CLIENT_FIREWORK_EXPLOSIONS];
|
||||
assert!(client_fireworks_ok(at, [0.0; 3], &many));
|
||||
let too_many = vec![star(&[0x00FF00]); MAX_CLIENT_FIREWORK_EXPLOSIONS + 1];
|
||||
assert!(!client_fireworks_ok(at, [0.0; 3], &too_many));
|
||||
for bad in [f64::NAN, f64::INFINITY] {
|
||||
assert!(!client_fireworks_ok([bad, 70.0, 0.0], [0.0; 3], &[]));
|
||||
assert!(!client_fireworks_ok(at, [0.0, bad, 0.0], &[]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_mods_fireworks_burst_and_bang_on_the_client_only() {
|
||||
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = execution_role::ExecutionRole::Client;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.world = World::new(game.res.facts().air, 1);
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
let at = [0.5, 70.0, 0.5];
|
||||
let show = [star(&[0xFF5555, 0xFFFF55]), star(&[0x55FF55])];
|
||||
|
||||
// No client player yet: nothing.
|
||||
game.player = None;
|
||||
assert!(!(&mut game as &mut dyn Host).client_fireworks(at, [0.0; 3], &show));
|
||||
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(0.5, 64.0, 0.5)).unwrap());
|
||||
let entities = game.rockets.len();
|
||||
assert!((&mut game as &mut dyn Host).client_fireworks(at, [0.0, 0.05, 0.0], &show));
|
||||
assert!(
|
||||
!(&mut game as &mut dyn Host).client_fireworks(at, [f64::NAN, 0.0, 0.0], &show),
|
||||
"non-finite input"
|
||||
);
|
||||
// No rocket: only the picture and the sound.
|
||||
assert_eq!(game.rockets.len(), entities);
|
||||
|
||||
game.particles.sounds.clear();
|
||||
let tick = |game: &mut Game<'_>| {
|
||||
let level = ParticleLevel {
|
||||
world: &game.world,
|
||||
facts: game.res.facts(),
|
||||
collision: game.collision,
|
||||
player: None,
|
||||
};
|
||||
game.particles.tick(&level);
|
||||
};
|
||||
tick(&mut game);
|
||||
assert!(
|
||||
game.particles
|
||||
.sounds
|
||||
.iter()
|
||||
.any(|s| s.0 == "minecraft:entity.firework_rocket.blast"),
|
||||
"the starter bangs on its first tick: {:?}",
|
||||
game.particles.sounds
|
||||
);
|
||||
// Its first explosion's sparks reached the live queue.
|
||||
tick(&mut game);
|
||||
assert!(!game.particles.is_empty());
|
||||
|
||||
// A server's game draws nothing.
|
||||
game.options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
assert!(!(&mut game as &mut dyn Host).client_fireworks(at, [0.0; 3], &show));
|
||||
}
|
||||
@@ -165,6 +165,17 @@ pub(super) struct CombatTracker {
|
||||
fall_before: f64,
|
||||
}
|
||||
impl CombatTracker {
|
||||
/// The newest damage of this fight for `events`: when it landed (game
|
||||
/// time), its damage type, the amount and the name of what dealt it.
|
||||
pub(super) fn last_hit(&self) -> Option<(i64, String, Option<f32>, Option<String>)> {
|
||||
let e = self.entries.last()?;
|
||||
let by = e
|
||||
.attacker
|
||||
.as_ref()
|
||||
.map(|(_, t)| t.plain())
|
||||
.or_else(|| e.direct.as_ref().map(Text::plain));
|
||||
Some((self.last_damage, e.damage_type.clone(), Some(e.damage), by))
|
||||
}
|
||||
/// The last blow taken: its damage type and the causing entity's id (0
|
||||
/// for none), for `LivingDeathEvent`.
|
||||
pub(super) fn last_blow(&self) -> Option<(&str, u32)> {
|
||||
|
||||
@@ -133,6 +133,9 @@ impl Game<'_> {
|
||||
{
|
||||
return Ok((guest.conn, menu.slot_count() - 36, *window));
|
||||
}
|
||||
if let Some(station) = host.play.get(&guest.conn).and_then(|p| p.station.as_ref()) {
|
||||
return Ok((guest.conn, station.menu.slot_count() - 36, station.window));
|
||||
}
|
||||
Ok((guest.conn, 0, 0))
|
||||
})
|
||||
}
|
||||
|
||||