mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 10:31:29 -10:00
Merge remote-tracking branch 'origin/main' into fix/effect-amplifier-nbt
This commit is contained in:
commit
bfd6dc917d
316 files changed
+14707
-1674
No files matched your search
@@ -150,7 +150,7 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
|
||||
@@ -30,6 +30,7 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
|
||||
6 hours ([docs/release-flow.md](docs/release-flow.md)).
|
||||
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
|
||||
- **No hardcoded user-facing strings.** Keep UI translation-ready from the start. In the launcher (`launcher/`), wrap visible text with `tr("...")` or `trf("... {arg} ...", &[("arg", &val)])` from `crate::i18n` instead of raw string literals or `format!`. In the game, use translation keys (`Text::tr`, `lang::get`), never hardcoded English strings.
|
||||
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
|
||||
issue is taken: pick another. `/unclaim` when you stop; a claim with no open PR linking it is released after 5 days.
|
||||
|
||||
@@ -39,7 +40,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
|
||||
| Crate | Owns |
|
||||
|---|---|
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~100 flat modules: gameplay, networking, title screens) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`) and thin executable (`src/main.rs`). Also the dedicated server (`--dedicated`). Re-exports `horizon-client-lib` for existing mod paths. |
|
||||
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
|
||||
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
|
||||
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
|
||||
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
|
||||
@@ -84,8 +86,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
|
||||
```sh
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
@@ -102,8 +104,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
|
||||
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
|
||||
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
|
||||
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
|
||||
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
|
||||
- Harness selection uses Cargo dep-info and reachable-code narrowing (`tools/parity_reach.py`).
|
||||
`horizon-client-lib` builds the control tool and client probes without gameplay; their dep-info
|
||||
excludes `game.rs` and `game/`, so gameplay edits do not rebuild or select those probes.
|
||||
Source paths remain in `crates/horizon-client/`. Measure actual harness selection on refreshed
|
||||
records with `tools/parity.py --changed-files paths.txt --list`; historical counts are not a guarantee.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
@@ -128,8 +133,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
## horizon-client: where things live
|
||||
|
||||
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
|
||||
the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
|
||||
`Game`; bin code reaches the lib as `horizon_client::<module>`.
|
||||
the same `horizon_client::<module>` paths), `G:` = gameplay (`crates/horizon-client/src/game/`), also in the lib in normal builds.
|
||||
Gameplay keeps its `horizon_client::<module>` imports through a self-crate alias. The executable
|
||||
contains only the allocator, mod API marker and a call to `entry::main`. For unit tests, the lib
|
||||
excludes `entry` and `game`, and the executable test target owns `game` so its process-wide test
|
||||
state stays separate from the library tests. This compiles gameplay twice for executable tests.
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
@@ -151,7 +159,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
@@ -160,7 +168,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
|
||||
|
||||
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
|
||||
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
|
||||
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
|
||||
`impl Game<'_>` block for one feature, with its own state types. Fields stay private to the gameplay module and its descendants. Gameplay tests run in the gameplay library, separate from reusable-library tests.
|
||||
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
|
||||
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
|
||||
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
|
||||
|
||||
Generated
+57
@@ -1314,6 +1314,7 @@ dependencies = [
|
||||
"horizon-chat-filter",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-client-lib",
|
||||
"horizon-client-render",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
@@ -1374,6 +1375,62 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-client-lib"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"flate2",
|
||||
"fs4",
|
||||
"gilrs",
|
||||
"glam",
|
||||
"horizon-anticheat",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-chat-filter",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-client-render",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-dualsense",
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-mods",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
"horizon-world",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"naga",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"open",
|
||||
"png",
|
||||
"pollster",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"unicode-bidi",
|
||||
"ureq",
|
||||
"wgpu",
|
||||
"winit",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-client-render"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -8,6 +8,7 @@ members = [
|
||||
"crates/horizon-hdr",
|
||||
"crates/horizon-world",
|
||||
"crates/horizon-client",
|
||||
"crates/horizon-client-lib",
|
||||
"crates/horizon-client-core",
|
||||
"crates/horizon-client-input",
|
||||
"crates/horizon-client-render",
|
||||
|
||||
@@ -202,10 +202,8 @@ fn os_version() -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cpu() -> Option<String> {
|
||||
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
|
||||
// x86 names the CPU per core; ARM boards name the machine once.
|
||||
key_value(&info, "model name", ':')
|
||||
.or_else(|| key_value(&info, "Model", ':'))
|
||||
.map(str::to_owned)
|
||||
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
|
||||
horizon_telemetry::system::linux_cpu_name(&info)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -227,7 +225,7 @@ fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn os_version() -> Option<String> {
|
||||
output("cmd", &["/C", "ver"])
|
||||
horizon_telemetry::system::windows_version()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -73,8 +73,11 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
|
||||
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
|
||||
in the same tick (a multi-target kill aura, for the anticheat's tests);
|
||||
the report gives each bot's last health
|
||||
the report gives each bot's last health. A UUID instead of a NAME: the
|
||||
entity with that UUID (a boat summoned with it, say)
|
||||
--attack-after S start attacking S seconds after joining; default 5
|
||||
--attack-rounds N stop after N rounds of attacks (each hits every target seen so far once);
|
||||
default no limit
|
||||
--no-swing attack without swinging (a kill aura hiding its swing, for the anticheat's tests)
|
||||
--no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
|
||||
ignores the player's moves and actions for its first 3 seconds
|
||||
@@ -153,9 +156,11 @@ struct Options {
|
||||
seed: u64,
|
||||
explore_speed: f64,
|
||||
pvp: bool,
|
||||
/// The player to attack, and how long after joining to start.
|
||||
/// The players or entity UUIDs to attack, how long after joining to start,
|
||||
/// and how many rounds.
|
||||
attack: Option<String>,
|
||||
attack_after: f64,
|
||||
attack_rounds: u32,
|
||||
no_swing: bool,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
@@ -190,6 +195,7 @@ fn options() -> Result<Options, String> {
|
||||
pvp: true,
|
||||
attack: None,
|
||||
attack_after: 5.0,
|
||||
attack_rounds: u32::MAX,
|
||||
no_swing: false,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
@@ -247,6 +253,7 @@ fn options() -> Result<Options, String> {
|
||||
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
|
||||
"--attack" => o.attack = Some(value),
|
||||
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
|
||||
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
"--brand" => o.brand = Some(value),
|
||||
@@ -401,6 +408,16 @@ fn join_summary(mut times: Vec<u64>) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// An `--attack` target's UUID: a UUID as written (`8-4-4-4-12` hex), else
|
||||
/// the offline UUID of the player with that name (a name has no `-`).
|
||||
fn target_uuid(name: &str) -> u128 {
|
||||
let hex: String = name.chars().filter(|&c| c != '-').collect();
|
||||
match u128::from_str_radix(&hex, 16) {
|
||||
Ok(uuid) if name.contains('-') && hex.len() == 32 => uuid,
|
||||
_ => offline_uuid(name),
|
||||
}
|
||||
}
|
||||
|
||||
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
|
||||
struct Random(u64);
|
||||
impl Random {
|
||||
@@ -984,7 +1001,7 @@ fn run_bot(
|
||||
.attack
|
||||
.iter()
|
||||
.flat_map(|names| names.split(','))
|
||||
.map(|name| (offline_uuid(name), None))
|
||||
.map(|name| (target_uuid(name), None))
|
||||
.collect();
|
||||
bot.online.store(true, Ordering::SeqCst);
|
||||
{
|
||||
@@ -1039,6 +1056,7 @@ fn run_bot(
|
||||
send(play::Serverbound::SetCarriedItem(play::SetCarriedItem { slot: 0 }).encode());
|
||||
}
|
||||
let mut sequence = 0;
|
||||
let mut rounds = 0;
|
||||
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
|
||||
let mut pos = spawn;
|
||||
// Fight bots stay at their spawn height; the others climb to fly above the terrain.
|
||||
@@ -1202,8 +1220,13 @@ fn run_bot(
|
||||
send(body);
|
||||
}
|
||||
}
|
||||
if joined.elapsed().as_secs_f64() >= o.attack_after && tick % 10 == 0 {
|
||||
if joined.elapsed().as_secs_f64() >= o.attack_after
|
||||
&& tick % 10 == 0
|
||||
&& rounds < o.attack_rounds
|
||||
{
|
||||
let targets: Vec<i32> = lock(&bot.target).iter().filter_map(|t| t.1).collect();
|
||||
// A round counts once a target is known.
|
||||
rounds += u32::from(!targets.is_empty());
|
||||
for id in targets {
|
||||
send(play::Serverbound::Attack(play::Attack { id }).encode());
|
||||
// Minecraft.startAttack swings right after the attack.
|
||||
@@ -1446,6 +1469,20 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
/// `--attack`: a UUID names that entity, anything else a player.
|
||||
#[test]
|
||||
fn attack_targets_a_uuid_or_a_player() {
|
||||
assert_eq!(target_uuid("00000000-0000-0000-0000-00000000000a"), 10);
|
||||
assert_eq!(
|
||||
target_uuid("0000000a-0000-0001-0000-000000000002"),
|
||||
0x0000000a_0000_0001_0000_000000000002
|
||||
);
|
||||
assert_eq!(target_uuid("V0"), offline_uuid("V0"));
|
||||
// A name of 32 hex digits is a name: a UUID is written with dashes.
|
||||
let name = "0123456789abcdef0123456789abcdef";
|
||||
assert_eq!(target_uuid(name), offline_uuid(name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_living_entities_only() {
|
||||
let mut e = Entities::default();
|
||||
|
||||
@@ -72,6 +72,19 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/tr_tr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
|
||||
}
|
||||
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
|
||||
}
|
||||
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
|
||||
"assets/horizon/lang/de_ch.json" => {
|
||||
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
|
||||
return Some(de.replace('ß', "ss").into_bytes());
|
||||
}
|
||||
"assets/horizon/lang/fr_fr.json"
|
||||
| "assets/horizon/lang/fr_ca.json"
|
||||
| "assets/horizon/lang/fr_ch.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
|
||||
}
|
||||
"assets/horizon/lang/es_es.json"
|
||||
| "assets/horizon/lang/es_ar.json"
|
||||
| "assets/horizon/lang/es_cl.json"
|
||||
@@ -316,6 +329,20 @@ mod tests {
|
||||
assert_eq!(tr.get("horizon.options.controller"), Some("Kontrolcü"));
|
||||
let es = Language::load(&[&empty], "es_es");
|
||||
assert_eq!(es.get("horizon.options.controller"), Some("Mando"));
|
||||
// German and French, and their regional variants on the same file
|
||||
// (Swiss German without "ß").
|
||||
let de = Language::load(&[&empty], "de_de");
|
||||
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
|
||||
let fr = Language::load(&[&empty], "fr_fr");
|
||||
assert_eq!(fr.get("horizon.osk.close"), Some("Fermer"));
|
||||
for (code, base) in [("de_at", &de), ("fr_ca", &fr), ("fr_ch", &fr)] {
|
||||
let variant = Language::load(&[&empty], code);
|
||||
assert_eq!(variant.map(), base.map(), "{code}");
|
||||
}
|
||||
let de_ch = Language::load(&[&empty], "de_ch");
|
||||
assert_eq!(de_ch.get("horizon.osk.close"), Some("Schliessen"));
|
||||
assert!(de_ch.map().values().all(|v| !v.contains('ß')));
|
||||
assert_eq!(de_ch.len(), de.len());
|
||||
let es_ve = Language::load(&[&empty], "es_ve");
|
||||
assert_eq!(
|
||||
es_ve.get("horizon.options.on_screen_keyboard"),
|
||||
@@ -323,6 +350,148 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `crates/horizon-client/assets/horizon/lang`, where Horizon's own strings live.
|
||||
fn bundled_dir() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-client/assets/horizon/lang")
|
||||
}
|
||||
/// The keys of a flat bundled file in file order, duplicates included (a map
|
||||
/// would keep only the last of two equal keys). One `"key": "value"` per line.
|
||||
fn file_keys(body: &str) -> Vec<String> {
|
||||
body.lines()
|
||||
.filter(|line| line.starts_with(" \""))
|
||||
.map(|line| {
|
||||
serde_json::Deserializer::from_str(line.trim_start())
|
||||
.into_iter::<String>()
|
||||
.next()
|
||||
.and_then(Result::ok)
|
||||
.unwrap_or_else(|| panic!("no key on line {line}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The fields the code fills in (`%s`, `%d`, `%1$s`, `%.2f`, `%%`), sorted. A
|
||||
/// percent sign that starts none of them ("100% is") is plain text.
|
||||
fn placeholders(text: &str) -> Vec<String> {
|
||||
let mut found = Vec::new();
|
||||
let mut rest = text;
|
||||
while let Some(at) = rest.find('%') {
|
||||
rest = &rest[at + 1..];
|
||||
let spec = rest
|
||||
.find(|c: char| !(c.is_ascii_digit() || c == '$' || c == '.'))
|
||||
.unwrap_or(rest.len());
|
||||
let after = rest[spec..].chars().next();
|
||||
if after.is_some_and(|c| matches!(c, 's' | 'd' | 'f' | '%')) {
|
||||
let end = spec + 1;
|
||||
found.push(format!("%{}", &rest[..end]));
|
||||
rest = &rest[end..];
|
||||
}
|
||||
}
|
||||
found.sort();
|
||||
found
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
|
||||
let dir = bundled_dir();
|
||||
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
|
||||
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
|
||||
let mut seen = 0;
|
||||
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
let Some(code) = name.strip_suffix(".json") else {
|
||||
continue;
|
||||
};
|
||||
seen += 1;
|
||||
// A file that Bundled::read does not name is never loaded.
|
||||
let path = format!("assets/horizon/lang/{name}");
|
||||
assert!(
|
||||
Bundled.read(&path).is_some(),
|
||||
"{name} is not served by Bundled"
|
||||
);
|
||||
let body = std::fs::read_to_string(entry.path()).unwrap();
|
||||
let map: HashMap<String, String> = serde_json::from_str(&body)
|
||||
.unwrap_or_else(|e| panic!("{name} is not a flat string map: {e}"));
|
||||
let keys = file_keys(&body);
|
||||
assert_eq!(
|
||||
keys.len(),
|
||||
map.len(),
|
||||
"{name} repeats a key or has a multi-line entry"
|
||||
);
|
||||
for (key, value) in &map {
|
||||
// Stale: the code no longer asks for it, or it was renamed in en_us only.
|
||||
let english = en
|
||||
.get(key)
|
||||
.unwrap_or_else(|| panic!("{code}: {key} is not in en_us.json"));
|
||||
// A translation keeps the fields the code fills in.
|
||||
assert_eq!(
|
||||
placeholders(value),
|
||||
placeholders(english),
|
||||
"{code}: {key} changes the placeholders of the English text"
|
||||
);
|
||||
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
seen >= 7,
|
||||
"found {seen} bundled lang files in {}",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_horizon_key_the_code_names_is_in_en_us() {
|
||||
fn rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
rust_files(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let en: HashMap<String, String> = serde_json::from_str(
|
||||
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let mut files = Vec::new();
|
||||
for krate in [
|
||||
"horizon-client",
|
||||
"horizon-client-render",
|
||||
"horizon-client-core",
|
||||
] {
|
||||
rust_files(&crates.join(krate).join("src"), &mut files);
|
||||
}
|
||||
assert!(files.len() > 100, "found only {} sources", files.len());
|
||||
let mut found = 0;
|
||||
for file in files {
|
||||
let text = std::fs::read_to_string(&file).unwrap();
|
||||
for (at, _) in text.match_indices("\"horizon.") {
|
||||
let rest = &text[at + 1..];
|
||||
let Some(end) = rest.find('"') else { continue };
|
||||
let key = &rest[..end];
|
||||
// A key is `horizon.<group>.<name>`: lower-case letters, digits and `_`
|
||||
// between the dots. A key built at run time has a brace; a file name
|
||||
// has one dot; the server addresses that look like keys are listed.
|
||||
const NOT_KEYS: &[&str] = &["horizon.bunchy7s.dev"];
|
||||
let key_like = key
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_.".contains(&b));
|
||||
if key.matches('.').count() < 2 || !key_like || NOT_KEYS.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
found += 1;
|
||||
assert!(
|
||||
en.contains_key(key),
|
||||
"{} names {key}, which is not in en_us.json",
|
||||
file.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(found > 50, "found only {found} key names");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_selected_language_over_english() {
|
||||
let base = Mem(HashMap::from([
|
||||
|
||||
@@ -483,6 +483,21 @@ impl KeyBinds {
|
||||
.find(|mapping| self.get(mapping) == name)
|
||||
.and_then(|mapping| key_code(Self::default_of(mapping)))
|
||||
}
|
||||
/// `AbstractContainerScreen.checkHotbarKeyPressed`: the off hand and the
|
||||
/// hotbar slots, as the controls bind them now. Answers the key the menu
|
||||
/// acts on (`KeyF` for the off hand, `DigitN` for a slot), so a rebound key
|
||||
/// swaps instead of reaching the screen.
|
||||
pub fn container_hotbar_key(&self, key: KeyCode) -> Option<KeyCode> {
|
||||
let name = key_name(key)?;
|
||||
if self.get("key.swapOffhand") == name {
|
||||
return Some(KeyCode::KeyF);
|
||||
}
|
||||
MAPPINGS
|
||||
.iter()
|
||||
.filter(|m| m.0.starts_with("key.hotbar."))
|
||||
.find(|m| self.get(m.0) == name)
|
||||
.and_then(|m| key_code(Self::default_of(m.0)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -519,6 +534,29 @@ mod tests {
|
||||
assert_eq!(keys.container_mouse_key(MouseButton::Forward, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_hotkeys_follow_the_bound_keys() {
|
||||
let mut keys = KeyBinds::default();
|
||||
assert_eq!(
|
||||
keys.container_hotbar_key(KeyCode::KeyF),
|
||||
Some(KeyCode::KeyF)
|
||||
);
|
||||
assert_eq!(
|
||||
keys.container_hotbar_key(KeyCode::Digit3),
|
||||
Some(KeyCode::Digit3)
|
||||
);
|
||||
assert_eq!(keys.container_hotbar_key(KeyCode::Tab), None);
|
||||
// The report's key: bound to the off hand, the menu takes it first.
|
||||
keys.set("key.swapOffhand", "key.keyboard.tab");
|
||||
assert_eq!(keys.container_hotbar_key(KeyCode::Tab), Some(KeyCode::KeyF));
|
||||
assert_eq!(keys.container_hotbar_key(KeyCode::KeyF), None);
|
||||
keys.set("key.hotbar.3", "key.keyboard.t");
|
||||
assert_eq!(
|
||||
keys.container_hotbar_key(KeyCode::KeyT),
|
||||
Some(KeyCode::Digit3)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebinding_translates_to_defaults() {
|
||||
let mut k = KeyBinds::default();
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
[package]
|
||||
name = "horizon-client-lib"
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
description = "Reusable client systems, control tool and parity probes"
|
||||
|
||||
[lib]
|
||||
path = "../horizon-client/src/client_lib.rs"
|
||||
|
||||
[dependencies]
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-client-input = { path = "../horizon-client-input" }
|
||||
horizon-client-render = { path = "../horizon-client-render" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
# server.properties, eula.txt and the user lists, shared with horizon-server.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
|
||||
horizon-anticheat = { path = "../horizon-anticheat" }
|
||||
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
|
||||
horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
|
||||
horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
# Their runtime is the `mods` feature.
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
bytemuck = { version = "1.24", features = ["derive"] }
|
||||
glam = "0.30"
|
||||
png = "0.18"
|
||||
sha2 = "0.10.9"
|
||||
# Server resource packs are checked and cached by their SHA-1, as vanilla does.
|
||||
sha1 = "0.10"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
|
||||
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
miniz_oxide = "0.8"
|
||||
ab_glyph = "0.2.32"
|
||||
log = "0.4"
|
||||
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
|
||||
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
|
||||
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
gilrs = { version = "0.11", optional = true }
|
||||
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
|
||||
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
|
||||
# HTTPS to the Minecraft services (online-mode session join).
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
unicode-bidi = "0.3.18"
|
||||
# `LevelStorageAccess.estimateDiskSpace`: usable disk space, without unsafe code on Windows.
|
||||
fs4 = { version = "1.1.0", default-features = false, features = ["sync"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Use the registered URI handler without routing links through Explorer/cmd.
|
||||
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
|
||||
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
|
||||
# on the PATH.
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
|
||||
# `above_menu_bar`); all already in the tree through winit and wgpu.
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
|
||||
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
|
||||
# DirectX 12 driver (`graphicsApi`, graphics.rs).
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
windows-mimalloc = []
|
||||
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
|
||||
windows-segment-heap = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
|
||||
|
||||
[[bin]]
|
||||
name = "horizon-ctl"
|
||||
path = "../horizon-client/src/bin/horizon-ctl/main.rs"
|
||||
|
||||
[[example]]
|
||||
name = "chunk_load_bench"
|
||||
path = "../horizon-client/examples/chunk_load_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "gameplay_decode_profile"
|
||||
path = "../horizon-client/examples/gameplay_decode_profile.rs"
|
||||
|
||||
[[example]]
|
||||
name = "json_load_bench"
|
||||
path = "../horizon-client/examples/json_load_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "mod_port_probe"
|
||||
path = "../horizon-client/examples/mod_port_probe.rs"
|
||||
|
||||
[[example]]
|
||||
name = "model_library_bench"
|
||||
path = "../horizon-client/examples/model_library_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "model_states_bench"
|
||||
path = "../horizon-client/examples/model_states_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "old_world_fixture"
|
||||
path = "../horizon-client/examples/old_world_fixture.rs"
|
||||
|
||||
[[example]]
|
||||
name = "portal_scan_bench"
|
||||
path = "../horizon-client/examples/portal_scan_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "random_tick_eligibility_bench"
|
||||
path = "../horizon-client/examples/random_tick_eligibility_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "resource_memory"
|
||||
path = "../horizon-client/examples/resource_memory.rs"
|
||||
|
||||
[[example]]
|
||||
name = "save_batch_bench"
|
||||
path = "../horizon-client/examples/save_batch_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "shared_json_startup_profile"
|
||||
path = "../horizon-client/examples/shared_json_startup_profile.rs"
|
||||
|
||||
[[example]]
|
||||
name = "sprite_cache_parity"
|
||||
path = "../horizon-client/examples/sprite_cache_parity.rs"
|
||||
|
||||
[[example]]
|
||||
name = "client_probe"
|
||||
path = "../horizon-client/examples/probe/main.rs"
|
||||
|
||||
[[test]]
|
||||
name = "custom_component_api"
|
||||
path = "../horizon-client/tests/custom_component_api.rs"
|
||||
|
||||
[[test]]
|
||||
name = "custom_component_unregistered"
|
||||
path = "../horizon-client/tests/custom_component_unregistered.rs"
|
||||
|
||||
[[test]]
|
||||
name = "custom_component_wire"
|
||||
path = "../horizon-client/tests/custom_component_wire.rs"
|
||||
|
||||
[[test]]
|
||||
name = "forced_chunks"
|
||||
path = "../horizon-client/tests/forced_chunks.rs"
|
||||
|
||||
[[test]]
|
||||
name = "generation_saves"
|
||||
path = "../horizon-client/tests/generation_saves.rs"
|
||||
|
||||
[[test]]
|
||||
name = "lan_bot"
|
||||
path = "../horizon-client/tests/lan_bot.rs"
|
||||
|
||||
[[test]]
|
||||
name = "streaming_meshes"
|
||||
path = "../horizon-client/tests/streaming_meshes.rs"
|
||||
|
||||
[[test]]
|
||||
name = "tall_column"
|
||||
path = "../horizon-client/tests/tall_column.rs"
|
||||
|
||||
[[test]]
|
||||
name = "wide_states"
|
||||
path = "../horizon-client/tests/wide_states.rs"
|
||||
@@ -76,6 +76,26 @@ pub struct BlockEntities {
|
||||
/// The fluid tanks of mod blocks' block entities, by block state id;
|
||||
/// built with `states`, like the containers.
|
||||
mod_fluids: FxHashMap<u32, Arc<ModFluidTanks>>,
|
||||
/// The positions of the mod block entities (`BlockEntity::Mod`) by chunk,
|
||||
/// for the renderer: the mesher lists block entities by their template,
|
||||
/// which leaves out a mod block whose template has none. One taken out
|
||||
/// (`take`) stays listed until `put` or `remove`.
|
||||
mods: FxHashMap<(i32, i32), FxHashSet<Pos>>,
|
||||
}
|
||||
|
||||
fn chunk_of((x, _, z): Pos) -> (i32, i32) {
|
||||
(x >> 4, z >> 4)
|
||||
}
|
||||
|
||||
/// Takes `pos` out of its chunk's mod block entity set.
|
||||
fn forget_mod(mods: &mut FxHashMap<(i32, i32), FxHashSet<Pos>>, pos: Pos) {
|
||||
let chunk = chunk_of(pos);
|
||||
if let Some(set) = mods.get_mut(&chunk) {
|
||||
set.remove(&pos);
|
||||
if set.is_empty() {
|
||||
mods.remove(&chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for BlockEntities {
|
||||
@@ -190,6 +210,23 @@ impl BlockEntities {
|
||||
self.pending.remove(pos);
|
||||
}
|
||||
|
||||
/// Whether any mod block entity is in the map: until one is, a block change
|
||||
/// never looks one up (`LevelAccess::has_mod_block_entity`).
|
||||
pub fn has_mods(&self) -> bool {
|
||||
!self.mods.is_empty()
|
||||
}
|
||||
/// The mod block entities within `radius` blocks of `x`, `z` (by chunk, so
|
||||
/// far ones are skipped whole; the caller checks each distance).
|
||||
pub fn mods_near(&self, x: f64, z: f64, radius: f64) -> impl Iterator<Item = &Pos> {
|
||||
let far = move |c: i32, at: f64| {
|
||||
let (lo, hi) = (f64::from(c * 16), f64::from(c * 16 + 16));
|
||||
at < lo - radius || at > hi + radius
|
||||
};
|
||||
self.mods
|
||||
.iter()
|
||||
.filter(move |((cx, cz), _)| !far(*cx, x) && !far(*cz, z))
|
||||
.flat_map(|(_, set)| set)
|
||||
}
|
||||
pub fn get_mut(&mut self, pos: &Pos) -> Option<&mut BlockEntity> {
|
||||
self.map.get_mut(pos)
|
||||
}
|
||||
@@ -202,10 +239,16 @@ impl BlockEntities {
|
||||
/// `LevelChunk.setBlockEntity` (+ `addAndRegisterBlockEntity`): a new
|
||||
/// block entity may get a slot; one replacing another keeps its slot.
|
||||
pub fn insert(&mut self, pos: Pos, entity: BlockEntity) -> Option<BlockEntity> {
|
||||
let new_is_mod = matches!(entity, BlockEntity::Mod(_));
|
||||
let old = self.map.insert(pos, entity);
|
||||
if old.is_none() {
|
||||
self.add(pos);
|
||||
}
|
||||
if new_is_mod {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
} else if matches!(old, Some(BlockEntity::Mod(_))) {
|
||||
forget_mod(&mut self.mods, pos);
|
||||
}
|
||||
old
|
||||
}
|
||||
/// The block entity at `pos`, made by `make` when there is none.
|
||||
@@ -214,15 +257,24 @@ impl BlockEntities {
|
||||
pos: Pos,
|
||||
make: impl FnOnce() -> BlockEntity,
|
||||
) -> &mut BlockEntity {
|
||||
if !self.map.contains_key(&pos) {
|
||||
let absent = !self.map.contains_key(&pos);
|
||||
if absent {
|
||||
self.add(pos);
|
||||
}
|
||||
self.map.entry(pos).or_insert_with(make)
|
||||
let entity = self.map.entry(pos).or_insert_with(make);
|
||||
if absent && matches!(entity, BlockEntity::Mod(_)) {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
}
|
||||
entity
|
||||
}
|
||||
/// `LevelChunk.removeBlockEntity`: the block entity and its slot.
|
||||
pub fn remove(&mut self, pos: &Pos) -> Option<BlockEntity> {
|
||||
self.cancel(pos);
|
||||
self.map.remove(pos)
|
||||
let removed = self.map.remove(pos);
|
||||
if matches!(removed, Some(BlockEntity::Mod(_))) {
|
||||
forget_mod(&mut self.mods, *pos);
|
||||
}
|
||||
removed
|
||||
}
|
||||
/// The block entity at `pos`, out of the map for a while: its slot stays
|
||||
/// for `put` to bring it back (a tick working on it).
|
||||
@@ -236,6 +288,9 @@ impl BlockEntities {
|
||||
/// Brings back what `take` took, in its slot.
|
||||
pub fn put(&mut self, pos: Pos, entity: BlockEntity) {
|
||||
self.out.remove(&pos);
|
||||
if matches!(entity, BlockEntity::Mod(_)) {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
}
|
||||
if self.map.insert(pos, entity).is_none()
|
||||
&& !self.live.contains_key(&pos)
|
||||
&& !self.pending.contains_key(&pos)
|
||||
@@ -247,17 +302,20 @@ impl BlockEntities {
|
||||
/// (`clearAllBlockEntities` of unloaded chunks).
|
||||
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
|
||||
let (live, pending, freed) = (&mut self.live, &mut self.pending, &mut self.freed);
|
||||
let mods = &mut self.mods;
|
||||
self.map.retain(|p, e| {
|
||||
let kept = keep(p, e);
|
||||
if !kept {
|
||||
*freed |= live.remove(p).is_some();
|
||||
pending.remove(p);
|
||||
forget_mod(mods, *p);
|
||||
}
|
||||
kept
|
||||
});
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.map.clear();
|
||||
self.mods.clear();
|
||||
self.tickers = Arc::default();
|
||||
self.live.clear();
|
||||
self.added.clear();
|
||||
@@ -274,9 +332,16 @@ impl BlockEntities {
|
||||
pub fn load_chunk(&mut self, entities: Vec<(Pos, BlockEntity)>) {
|
||||
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
|
||||
for (p, e) in entities {
|
||||
if self.map.insert(p, e).is_some() {
|
||||
let new_is_mod = matches!(e, BlockEntity::Mod(_));
|
||||
let old = self.map.insert(p, e);
|
||||
if old.is_some() {
|
||||
self.replaced.insert(p);
|
||||
}
|
||||
if new_is_mod {
|
||||
self.mods.entry(chunk_of(p)).or_default().insert(p);
|
||||
} else if matches!(old, Some(BlockEntity::Mod(_))) {
|
||||
forget_mod(&mut self.mods, p);
|
||||
}
|
||||
}
|
||||
for p in order {
|
||||
self.add(p);
|
||||
@@ -369,6 +434,38 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn near(b: &BlockEntities, x: f64, z: f64, radius: f64) -> Vec<Pos> {
|
||||
let mut found: Vec<Pos> = b.mods_near(x, z, radius).copied().collect();
|
||||
found.sort();
|
||||
found
|
||||
}
|
||||
#[test]
|
||||
fn mod_block_entities_are_listed_by_chunk_until_they_go() {
|
||||
let mut b = level();
|
||||
let modded = || BlockEntity::Mod(Box::default());
|
||||
b.insert((1, 64, 1), modded());
|
||||
b.insert((200, 64, 1), modded());
|
||||
b.insert((2, 64, 2), hopper());
|
||||
b.get_or_insert_with((3, 64, 3), modded);
|
||||
b.load_chunk(vec![((4, 64, 4), modded()), ((5, 64, 5), hopper())]);
|
||||
// Only mods, and only chunks within reach.
|
||||
assert_eq!(
|
||||
near(&b, 0.0, 0.0, 64.0),
|
||||
[(1, 64, 1), (3, 64, 3), (4, 64, 4)]
|
||||
);
|
||||
assert_eq!(near(&b, 200.0, 0.0, 8.0), [(200, 64, 1)]);
|
||||
// Taken out for a tick, still listed; removed or replaced, gone.
|
||||
let taken = b.take(&(1, 64, 1)).unwrap();
|
||||
assert_eq!(near(&b, 0.0, 0.0, 64.0).len(), 3);
|
||||
b.put((1, 64, 1), taken);
|
||||
b.remove(&(3, 64, 3));
|
||||
b.insert((4, 64, 4), hopper());
|
||||
assert_eq!(near(&b, 0.0, 0.0, 64.0), [(1, 64, 1)]);
|
||||
b.retain(|p, _| p.0 != 1);
|
||||
assert!(near(&b, 0.0, 0.0, 64.0).is_empty());
|
||||
b.clear();
|
||||
assert!(near(&b, 200.0, 0.0, 8.0).is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn slots_follow_vanilla_lifecycle() {
|
||||
let mut b = level();
|
||||
|
||||
@@ -212,7 +212,7 @@ fn operating_system() -> String {
|
||||
} else if cfg!(target_os = "macos") {
|
||||
command_output("sw_vers", &["-productVersion"]).map(|v| format!("macOS {v}"))
|
||||
} else if cfg!(windows) {
|
||||
command_output("cmd", &["/C", "ver"])
|
||||
horizon_telemetry::system::windows_version()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -227,12 +227,7 @@ fn operating_system() -> String {
|
||||
pub fn cpu() -> String {
|
||||
let threads = std::thread::available_parallelism().map_or(0, |n| n.get());
|
||||
let name = if cfg!(target_os = "linux") {
|
||||
read("/proc/cpuinfo").and_then(|t| {
|
||||
t.lines()
|
||||
.find(|l| l.starts_with("model name"))
|
||||
.and_then(|l| l.split_once(':'))
|
||||
.map(|(_, v)| v.trim().to_owned())
|
||||
})
|
||||
read("/proc/cpuinfo").and_then(|t| horizon_telemetry::system::linux_cpu_name(&t))
|
||||
} else if cfg!(target_os = "macos") {
|
||||
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
} else if cfg!(windows) {
|
||||
|
||||
@@ -428,32 +428,84 @@ pub enum Item {
|
||||
/// On the Splitscreen settings page.
|
||||
Splitscreen,
|
||||
}
|
||||
/// The Extras page: headers and items.
|
||||
/// The Extras page: headers (language keys) and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
use Item::*;
|
||||
vec![
|
||||
Err("Lighting"),
|
||||
Err("horizon.extras.header.lighting"),
|
||||
Ok(Fullbright),
|
||||
Ok(DynamicLights),
|
||||
Err("Third person"),
|
||||
Err("horizon.extras.header.third_person"),
|
||||
Ok(OwnNameTag),
|
||||
Err("Particles"),
|
||||
Err("horizon.extras.header.particles"),
|
||||
Ok(HideBlockParticles),
|
||||
Err("Inventory"),
|
||||
Err("horizon.extras.header.inventory"),
|
||||
Ok(MouseTweaks),
|
||||
Ok(MouseTweaksWheel),
|
||||
Ok(Sort),
|
||||
Ok(SortKey),
|
||||
Err("Chat"),
|
||||
Err("horizon.extras.header.chat"),
|
||||
Ok(ChatAnimation),
|
||||
Ok(ChatIndicator),
|
||||
Err("Multiplayer"),
|
||||
Err("horizon.extras.header.multiplayer"),
|
||||
Ok(AutoReconnect),
|
||||
Err("Sound"),
|
||||
Err("horizon.extras.header.sound"),
|
||||
Ok(UiSoundReserve),
|
||||
]
|
||||
}
|
||||
impl Item {
|
||||
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
|
||||
pub fn caption_key(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "horizon.extras.fullbright",
|
||||
Item::DynamicLights => "horizon.extras.dynamic_lights",
|
||||
Item::OwnNameTag => "horizon.extras.own_name_tag",
|
||||
Item::HideBlockParticles => "horizon.extras.hide_block_particles",
|
||||
Item::AutoReconnect => "horizon.extras.auto_reconnect",
|
||||
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve",
|
||||
Item::MouseTweaks => "horizon.extras.mouse_tweaks",
|
||||
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel",
|
||||
Item::Sort => "horizon.extras.sort",
|
||||
Item::SortKey => "horizon.extras.sort_key",
|
||||
Item::NoAi => "horizon.extras.no_ai",
|
||||
Item::AgentControl => "horizon.extras.agent_control",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator",
|
||||
}
|
||||
}
|
||||
/// The language key of the description shown as the row's tooltip.
|
||||
pub fn description_key(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "horizon.extras.fullbright.tooltip",
|
||||
Item::DynamicLights => "horizon.extras.dynamic_lights.tooltip",
|
||||
Item::OwnNameTag => "horizon.extras.own_name_tag.tooltip",
|
||||
Item::HideBlockParticles => "horizon.extras.hide_block_particles.tooltip",
|
||||
Item::AutoReconnect => "horizon.extras.auto_reconnect.tooltip",
|
||||
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve.tooltip",
|
||||
Item::MouseTweaks => "horizon.extras.mouse_tweaks.tooltip",
|
||||
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel.tooltip",
|
||||
Item::Sort => "horizon.extras.sort.tooltip",
|
||||
Item::SortKey => "horizon.extras.sort_key.tooltip",
|
||||
Item::NoAi => "horizon.extras.no_ai.tooltip",
|
||||
Item::AgentControl => "horizon.extras.agent_control.tooltip",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
|
||||
}
|
||||
}
|
||||
/// The caption in the current language (English when the key is missing).
|
||||
pub fn caption_text(self) -> String {
|
||||
crate::lang::get_or(self.caption_key(), self.caption())
|
||||
}
|
||||
/// The description in the current language (English when the key is missing).
|
||||
pub fn description_text(self) -> String {
|
||||
crate::lang::get_or(self.description_key(), self.description())
|
||||
}
|
||||
/// The English caption: the fallback of [`Item::caption_text`], and the text of
|
||||
/// `horizon.extras.*` in en_us.json.
|
||||
pub fn caption(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "Fullbright",
|
||||
@@ -489,7 +541,7 @@ impl Item {
|
||||
}
|
||||
})
|
||||
}
|
||||
/// What the row says under its caption.
|
||||
/// The English description: the fallback of [`Item::description_text`].
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "Show blocks and entities at full light, even in dark areas.",
|
||||
@@ -581,8 +633,8 @@ impl Item {
|
||||
match self {
|
||||
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
|
||||
other => match other.switch(e) {
|
||||
Some(true) => "On".into(),
|
||||
_ => "Off".into(),
|
||||
Some(true) => crate::lang::get_or("On", "On"),
|
||||
_ => crate::lang::get_or("Off", "Off"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ mod end_sky;
|
||||
mod flat;
|
||||
mod frame_gen;
|
||||
mod hdr;
|
||||
mod hdr_icc;
|
||||
mod hdr_stats;
|
||||
pub use hdr::Calibration as HdrCalibration;
|
||||
pub mod block_light;
|
||||
@@ -629,8 +630,9 @@ pub struct ScreenshotRequest {
|
||||
pub struct Renderer {
|
||||
instance: wgpu::Instance,
|
||||
adapter: wgpu::Adapter,
|
||||
/// The window's swapchain; `None` when rendering offscreen (`--offscreen`).
|
||||
surface: Option<wgpu::Surface<'static>>,
|
||||
/// The window's swapchain; `None` when rendering offscreen (`--offscreen`). Shared
|
||||
/// with frame generation's present thread while it runs.
|
||||
surface: Option<Arc<wgpu::Surface<'static>>>,
|
||||
/// The surface `release_surface` set aside for the renderer that replaces
|
||||
/// this one on the same device: reused, not created again, as a new
|
||||
/// surface for the window can come back lost (Wayland on Mesa).
|
||||
@@ -878,6 +880,10 @@ pub struct Renderer {
|
||||
layout: crate::chunk::ColumnLayout,
|
||||
/// Resolution scale, FXAA and TAA (Horizon settings).
|
||||
world_post: world_post::Post,
|
||||
/// Frame generation (Horizon setting).
|
||||
frame_gen: frame_gen::FrameGen,
|
||||
/// The entities' texture atlas (`entity_group`'s), for frame generation's motion.
|
||||
entity_atlas: wgpu::TextureView,
|
||||
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
|
||||
hdr: Option<hdr::Hdr>,
|
||||
/// Which pixels of each block sprite may shine in HDR output (terrain.wgsl's
|
||||
@@ -1773,10 +1779,14 @@ impl Renderer {
|
||||
let caps = surface.get_capabilities(&adapter);
|
||||
config.format = surface_format(&caps.formats, config.format);
|
||||
config.view_formats = vec![config.format.remove_srgb_suffix()];
|
||||
// Screenshots copy the presented frame back from the swapchain.
|
||||
// Screenshots copy the presented frame back from the swapchain, frame
|
||||
// generation's present thread copies its frames in.
|
||||
if caps.usages.contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
config.usage |= wgpu::TextureUsages::COPY_SRC;
|
||||
}
|
||||
if caps.usages.contains(wgpu::TextureUsages::COPY_DST) {
|
||||
config.usage |= wgpu::TextureUsages::COPY_DST;
|
||||
}
|
||||
config.present_mode = previous
|
||||
.map(|renderer| renderer.config.present_mode)
|
||||
.filter(|mode| caps.present_modes.contains(mode))
|
||||
@@ -1806,7 +1816,9 @@ impl Renderer {
|
||||
} else {
|
||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::COPY_DST,
|
||||
format,
|
||||
width: size.width.clamp(1, max_side),
|
||||
height: size.height.clamp(1, max_side),
|
||||
@@ -3427,7 +3439,7 @@ impl Renderer {
|
||||
display_fresh: false,
|
||||
instance,
|
||||
adapter,
|
||||
surface,
|
||||
surface: surface.map(Arc::new),
|
||||
spare_surface: std::sync::Mutex::new(None),
|
||||
offscreen,
|
||||
in_flight: std::collections::VecDeque::new(),
|
||||
@@ -3581,6 +3593,8 @@ impl Renderer {
|
||||
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
|
||||
layout: crate::chunk::ColumnLayout::VANILLA,
|
||||
world_post,
|
||||
frame_gen: frame_gen::FrameGen::new(),
|
||||
entity_atlas: atlas_view.clone(),
|
||||
hdr: None,
|
||||
emission_stats,
|
||||
hdr_output,
|
||||
@@ -3674,11 +3688,13 @@ impl Renderer {
|
||||
/// Hand the window surface over to the renderer that replaces this one on
|
||||
/// the same device (it takes `spare_surface`).
|
||||
pub fn release_surface(&mut self) {
|
||||
self.frame_gen.stop();
|
||||
self.queue.wait();
|
||||
*self
|
||||
.spare_surface
|
||||
.get_mut()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = self.surface.take();
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) =
|
||||
self.surface.take().and_then(|s| Arc::try_unwrap(s).ok());
|
||||
}
|
||||
/// Reattach a window after surface loss or a failed renderer replacement.
|
||||
pub fn recreate_surface(&mut self) -> Result<()> {
|
||||
@@ -3694,6 +3710,7 @@ impl Renderer {
|
||||
.get_mut()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.take();
|
||||
self.frame_gen.stop();
|
||||
self.surface = None;
|
||||
let surface = match spare {
|
||||
Some(surface) => surface,
|
||||
@@ -3712,7 +3729,7 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
self.surface_unconfigured = configured.is_err();
|
||||
self.surface = Some(surface);
|
||||
self.surface = Some(Arc::new(surface));
|
||||
Ok(())
|
||||
}
|
||||
/// The world's depth, which occlusion culling's test and post effects may read.
|
||||
@@ -4063,14 +4080,18 @@ impl Renderer {
|
||||
.iter()
|
||||
.take_while(|&&p| p == (page, index_page))
|
||||
.count();
|
||||
// Translucent draws, and only they, have sorted indices.
|
||||
let vertices = match &self.translucent_arena {
|
||||
Some(arena) if index_page.is_some() => arena.buffer(page),
|
||||
_ => self.arena.buffer(page),
|
||||
// Translucent draws, and only they, have sorted indices; split, their large
|
||||
// pages are bound up to their last range in use (`Arena::binding`).
|
||||
let (vertices, indices) = match (&self.translucent_arena, index_page) {
|
||||
(Some(arena), Some(p)) => (arena.binding(page), self.sorted_indices.binding(p)),
|
||||
(_, p) => (
|
||||
self.arena.buffer(page).slice(..),
|
||||
p.map_or(&self.indices, |p| self.sorted_indices.buffer(p))
|
||||
.slice(..),
|
||||
),
|
||||
};
|
||||
pass.set_vertex_buffer(0, vertices.slice(..));
|
||||
let indices = index_page.map_or(&self.indices, |p| self.sorted_indices.buffer(p));
|
||||
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, vertices);
|
||||
pass.set_index_buffer(indices, wgpu::IndexFormat::Uint32);
|
||||
if self.multi_draw {
|
||||
let offset = (i - base as usize) as u64 * 20;
|
||||
pass.multi_draw_indexed_indirect(indirect, offset, run as u32);
|
||||
@@ -4402,7 +4423,8 @@ impl Renderer {
|
||||
}
|
||||
/// Configures the window surface again. One that fails is still the one
|
||||
/// wgpu configured before, which acquisition reports as a validation error.
|
||||
fn configure_surface(&self) {
|
||||
fn configure_surface(&mut self) {
|
||||
self.frame_gen.stop();
|
||||
// The submit thread may still present to the old swapchain.
|
||||
self.queue.wait();
|
||||
if let Some(surface) = &self.surface
|
||||
@@ -4492,7 +4514,7 @@ impl Renderer {
|
||||
// game never waits on the compositor for it.
|
||||
self.display = if output.is_some() {
|
||||
let (watch, now) =
|
||||
DisplayWatch::new(&self.window, self.surface.as_ref(), &self.adapter);
|
||||
DisplayWatch::new(&self.window, self.surface.as_deref(), &self.adapter);
|
||||
crate::graphics::report(now);
|
||||
self.display_fresh = true;
|
||||
watch
|
||||
@@ -4542,12 +4564,24 @@ impl Renderer {
|
||||
if std::mem::take(&mut self.display_fresh) {
|
||||
return true;
|
||||
}
|
||||
let Some(now) = self.display.changed(self.surface.as_ref(), &self.adapter) else {
|
||||
let Some(now) = self.display.changed(self.surface.as_deref(), &self.adapter) else {
|
||||
return false;
|
||||
};
|
||||
crate::graphics::report(now);
|
||||
true
|
||||
}
|
||||
/// The share of real frames frame generation shows a made frame before (1 while it
|
||||
/// keeps up), `None` while it isn't running.
|
||||
pub fn frame_generation_share(&self) -> Option<f32> {
|
||||
self.frame_gen.made_share
|
||||
}
|
||||
/// Frame generation on or off (FSR 3.1's frame interpolation).
|
||||
pub fn set_frame_generation(&mut self, on: bool) {
|
||||
if !on {
|
||||
self.frame_gen.stop();
|
||||
}
|
||||
self.frame_gen.on = on;
|
||||
}
|
||||
/// `upscale`: output pixels per rendered pixel, for TAA to rebuild (1: native).
|
||||
pub fn set_world_post(&mut self, scale: f32, antialias: bool, taa: bool, upscale: f32) {
|
||||
let taa = taa && self.world_post.supports_taa();
|
||||
@@ -5091,6 +5125,7 @@ impl Renderer {
|
||||
if self.device_lost.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return Err("the graphics device was lost".into());
|
||||
}
|
||||
self.frame_gen.stop();
|
||||
if Self::configure(
|
||||
&self.device,
|
||||
surface,
|
||||
@@ -5104,8 +5139,42 @@ impl Renderer {
|
||||
}
|
||||
self.surface_unconfigured = false;
|
||||
}
|
||||
// Frame generation: its present thread acquires and presents, this frame draws
|
||||
// into frames of its own. Not over menus that blur the frame, a shader pack's
|
||||
// own post passes, HDR or the title screen.
|
||||
let fg = self.frame_gen.on
|
||||
&& !self.frame_gen.paused
|
||||
&& self.hdr.is_none()
|
||||
&& !frame.title
|
||||
&& frame.menu_blur.is_none()
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& self.native.as_ref().is_none_or(|n| n.post.is_none())
|
||||
&& (self.surface.is_none()
|
||||
|| self.config.usage.contains(wgpu::TextureUsages::COPY_DST));
|
||||
if !fg {
|
||||
self.frame_gen.stop();
|
||||
}
|
||||
let acquire = Instant::now();
|
||||
let acquired = self.surface.as_ref().map(|s| s.get_current_texture());
|
||||
let fg_frames = fg
|
||||
.then(|| {
|
||||
self.frame_gen.frames(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
self.surface.as_ref(),
|
||||
self.offscreen.as_ref(),
|
||||
&self.window,
|
||||
&self.config,
|
||||
)
|
||||
})
|
||||
.flatten();
|
||||
// The present thread failed: this frame acquires as without frame generation.
|
||||
let fg = fg_frames.is_some();
|
||||
let mut fg_made = false;
|
||||
let acquired = if fg {
|
||||
None
|
||||
} else {
|
||||
self.surface.as_ref().map(|s| s.get_current_texture())
|
||||
};
|
||||
self.last_wait = acquire.elapsed();
|
||||
let (surface_frame, reconfigure) = match acquired {
|
||||
None => (None, false),
|
||||
@@ -5141,9 +5210,11 @@ impl Renderer {
|
||||
};
|
||||
if surface_frame.is_some() {
|
||||
self.acquire_validation_failures = 0;
|
||||
self.frame_gen.paused = false;
|
||||
}
|
||||
let size = [self.config.width, self.config.height];
|
||||
let target_texture = match (&surface_frame, &mut self.hdr) {
|
||||
_ if let Some((real, _)) = &fg_frames => real.clone(),
|
||||
(Some(_), Some(hdr)) => hdr.frame(&self.device, size),
|
||||
// A screenshot of a swapchain without COPY_SRC (OpenGL) draws
|
||||
// into a texture that has it, then onto the swapchain.
|
||||
@@ -5210,6 +5281,8 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
let view = views.primary().view;
|
||||
// The hand's bob and view (`hand_globals`).
|
||||
let hand_view = frame.bob * view;
|
||||
let sun_angle = frame.sky.sun_angle.to_radians();
|
||||
// Celestial bodies rotate around the X axis; angle 0 = noon.
|
||||
let sun = Vec3::new(0.0, sun_angle.cos(), -sun_angle.sin());
|
||||
@@ -6033,6 +6106,19 @@ impl Renderer {
|
||||
&frame.lightning,
|
||||
camera,
|
||||
);
|
||||
// Frame generation's made frame draws its own hand, halfway, after the others.
|
||||
let made_hand_vertex = self.entity_batch.combined.len() as i32;
|
||||
if fg {
|
||||
self.frame_gen
|
||||
.halfway_hand(frame.hand, hand_view, &mut self.entity_batch.combined);
|
||||
self.frame_gen.track_entities(
|
||||
&self.device,
|
||||
&self.entity_atlas,
|
||||
frame.entities,
|
||||
frame.entity_motion,
|
||||
frame.camera,
|
||||
);
|
||||
}
|
||||
let all_entities = &self.entity_batch.combined;
|
||||
let hand_quads = (frame.hand.len() / 4) as u32;
|
||||
let additive_quads = (frame.additive.len() / 4) as u32 - self.entity_batch.moved_additive;
|
||||
@@ -6252,7 +6338,8 @@ impl Renderer {
|
||||
let keep_depth = pack_reads_world
|
||||
|| (taa && self.world_post.taa)
|
||||
|| oit_on
|
||||
|| native_render::render_needs().world_depth;
|
||||
|| native_render::render_needs().world_depth
|
||||
|| fg;
|
||||
// The pack's program reads the vertices this frame's draws bind.
|
||||
let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat;
|
||||
// The shadow camera's lists, drawn by the pack's shadow programs.
|
||||
@@ -6296,7 +6383,7 @@ impl Renderer {
|
||||
let scaled = self.world_post.prepare(
|
||||
&self.device,
|
||||
[self.config.width, self.config.height],
|
||||
pack_reads_world,
|
||||
pack_reads_world || fg,
|
||||
);
|
||||
let (world_view, world_depth) = match &scaled {
|
||||
Some((color, depth)) => (color, depth),
|
||||
@@ -6312,6 +6399,8 @@ impl Renderer {
|
||||
&& !pack_draws_hand
|
||||
&& scaled.is_none()
|
||||
&& self.hdr.is_none()
|
||||
// Frame generation draws the hand over the real and the made frame after the world.
|
||||
&& fg_frames.is_none()
|
||||
&& !native_render::world_drawn_hooked();
|
||||
if oit_on {
|
||||
let size = self
|
||||
@@ -7513,6 +7602,47 @@ impl Renderer {
|
||||
history_reset,
|
||||
native.timing.as_mut(),
|
||||
);
|
||||
} else if let (Some((_, generated)), Some((_, depth))) = (&fg_frames, &scaled) {
|
||||
let world = self
|
||||
.frame_gen
|
||||
.world(&self.device, &self.world_post, &self.config);
|
||||
self.world_post
|
||||
.finish(&self.queue, &mut encoder, &world, self.own_timing.as_mut());
|
||||
let camera = frame_gen::Camera {
|
||||
jittered,
|
||||
view_proj,
|
||||
depth: depth.clone(),
|
||||
render_size: self.world_post.size().unwrap_or(size),
|
||||
jitter: self.world_post.jitter_pixels(),
|
||||
reproject: self.frame_gen.camera_motion(view_proj, frame.camera),
|
||||
near: NEAR_PLANE,
|
||||
far: frame.depth_far,
|
||||
fov_y: frame.fov_degrees.to_radians(),
|
||||
};
|
||||
let generated_view = generated.create_view(&wgpu::TextureViewDescriptor {
|
||||
format: Some(self.config.format.remove_srgb_suffix()),
|
||||
..Default::default()
|
||||
});
|
||||
fg_made = self.frame_gen.generate(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&mut encoder,
|
||||
&self.world_post,
|
||||
&view_target,
|
||||
&generated_view,
|
||||
&camera,
|
||||
);
|
||||
if fg_made {
|
||||
let overlay = Overlay {
|
||||
hand_quads: hand_quads * u32::from(!pack_draws_hand),
|
||||
first_hand_vertex: made_hand_vertex,
|
||||
crosshair,
|
||||
first_crosshair: debug_depth + debug_top,
|
||||
world_depth: false,
|
||||
real: false,
|
||||
};
|
||||
self.draw_overlay(&mut encoder, frame, &generated_view, generated, &overlay);
|
||||
}
|
||||
} else if scaled.is_some() {
|
||||
self.world_post.finish(
|
||||
&self.queue,
|
||||
@@ -7545,178 +7675,16 @@ impl Renderer {
|
||||
quad_indices: &self.indices,
|
||||
});
|
||||
}
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.keep_world(&mut encoder, self.own_timing.as_mut());
|
||||
}
|
||||
if hand_quads > 0 && !pack_draws_hand && !hand_in_world {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view_target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("First-person hand"),
|
||||
timestamp_writes: self.own_timing.as_mut().and_then(|t| t.writes("Hand")),
|
||||
color_attachments: &attachments,
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
store: wgpu::StoreOp::Discard,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
self.draw_hand(&mut pass, hand_quads, world_quads + additive_quads);
|
||||
drop(pass);
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.mark_hand(&mut encoder, self.own_timing.as_mut());
|
||||
}
|
||||
}
|
||||
// GameRenderer.render: LevelRenderer.blitEntityOutline after the level and the hand.
|
||||
self.entity_outline.draw(
|
||||
&mut encoder,
|
||||
&view_target,
|
||||
&self.entity_group,
|
||||
&self.indices,
|
||||
self.own_timing.as_mut(),
|
||||
);
|
||||
// The world icon grabs here: after the level and the hand, before
|
||||
// the effects and the HUD draw over them (`tryTakeScreenshotIfNeeded`
|
||||
// runs right after `renderLevel`).
|
||||
let readback = self
|
||||
.screenshot
|
||||
.take_if(|request| request.size.is_some())
|
||||
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture));
|
||||
// GameRenderer.render: applyPostEffects, after the level and before the GUI.
|
||||
if !frame.title
|
||||
&& !frame.post_effects.effects.is_empty()
|
||||
&& target_texture
|
||||
.usage()
|
||||
.contains(wgpu::TextureUsages::COPY_SRC)
|
||||
{
|
||||
let device = &self.device;
|
||||
self.post_effects
|
||||
.get_or_insert_with(|| crate::post_chain::PostEffects::new(device))
|
||||
.apply(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&mut encoder,
|
||||
&target_texture,
|
||||
&view_target,
|
||||
[self.config.width, self.config.height],
|
||||
// The scaled world pass drew its own depth buffer.
|
||||
scaled.is_none().then_some(&self.depth),
|
||||
&frame.post_effects,
|
||||
self.own_timing.as_mut(),
|
||||
);
|
||||
}
|
||||
if !frame.screen.is_empty() {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view_target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Screen effects"),
|
||||
timestamp_writes: self
|
||||
.own_timing
|
||||
.as_mut()
|
||||
.and_then(|t| t.writes("Screen effects")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.screen_buffer.slice(..));
|
||||
for (i, quad) in frame.screen.iter().enumerate() {
|
||||
let blend = match quad.blend {
|
||||
crate::screen_effects::Blend::Alpha => 0,
|
||||
crate::screen_effects::Blend::Vignette => 4,
|
||||
};
|
||||
pass.set_pipeline(&self.screen_pipelines[blend + (quad.sampling & 3) as usize]);
|
||||
pass.draw_indexed(0..6, (i * 4) as i32, 0..1);
|
||||
}
|
||||
}
|
||||
// GameRenderer.render3dHud: the debug crosshair after the hand and
|
||||
// the screen effects.
|
||||
if crosshair > 0 {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view_target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("3d crosshair"),
|
||||
timestamp_writes: self.own_timing.as_mut().and_then(|t| t.writes("Crosshair")),
|
||||
color_attachments: &attachments,
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
store: wgpu::StoreOp::Discard,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.debug_lines_on_top);
|
||||
pass.set_bind_group(0, &self.crosshair_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
|
||||
let first = debug_depth + debug_top;
|
||||
pass.draw(0..4, first..first + crosshair);
|
||||
}
|
||||
let menu_blur = frame
|
||||
.menu_blur
|
||||
.filter(|_| self.config.usage.contains(wgpu::TextureUsages::COPY_SRC));
|
||||
let blur_at = menu_blur.map_or(0, |_| frame.hud.blur_at);
|
||||
self.hud.draw_range(
|
||||
&mut encoder,
|
||||
&view_target,
|
||||
&self.depth,
|
||||
frame.hud,
|
||||
[self.config.width, self.config.height],
|
||||
0..blur_at,
|
||||
self.own_timing.as_mut(),
|
||||
);
|
||||
if let Some(radius) = menu_blur {
|
||||
let format = self.config.format;
|
||||
let device = &self.device;
|
||||
self.menu_blur
|
||||
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format))
|
||||
.apply(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&mut encoder,
|
||||
&target_texture,
|
||||
&view_target,
|
||||
[self.config.width, self.config.height],
|
||||
radius,
|
||||
self.own_timing.as_mut(),
|
||||
);
|
||||
}
|
||||
self.hud.draw_range(
|
||||
&mut encoder,
|
||||
&view_target,
|
||||
&self.depth,
|
||||
frame.hud,
|
||||
[self.config.width, self.config.height],
|
||||
blur_at..frame.hud.vertices.len() as u32,
|
||||
self.own_timing.as_mut(),
|
||||
);
|
||||
let overlay = Overlay {
|
||||
hand_quads: hand_quads * u32::from(!pack_draws_hand && !hand_in_world),
|
||||
first_hand_vertex: ((world_quads + additive_quads) * 4) as i32,
|
||||
crosshair,
|
||||
first_crosshair: debug_depth + debug_top,
|
||||
world_depth: scaled.is_none(),
|
||||
real: true,
|
||||
};
|
||||
let readback =
|
||||
self.draw_overlay(&mut encoder, frame, &view_target, &target_texture, &overlay);
|
||||
if culling && let Some(cull) = &mut self.cull {
|
||||
cull.end(&self.device, &mut encoder);
|
||||
}
|
||||
@@ -7727,10 +7695,15 @@ impl Renderer {
|
||||
(&view_target, self.config.format.remove_srgb_suffix()),
|
||||
);
|
||||
}
|
||||
// Under `HORIZON_FRAMEGEN_DEBUG=generated` a screenshot is the made frame.
|
||||
let shown_texture = match &fg_frames {
|
||||
Some((_, generated)) if fg_made && frame_gen::generated_only() => generated,
|
||||
_ => &target_texture,
|
||||
};
|
||||
let readback = readback.or_else(|| {
|
||||
self.screenshot
|
||||
.take()
|
||||
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture))
|
||||
.and_then(|request| self.take_readback(request, &mut encoder, shown_texture))
|
||||
});
|
||||
self.uploads.finish(&encoder);
|
||||
if has_section_copies {
|
||||
@@ -7764,7 +7737,9 @@ impl Renderer {
|
||||
// Finished, submitted and presented on the submit thread when nothing after the
|
||||
// submission needs this one: screenshots' readbacks, the error scopes (one per
|
||||
// thread), the first frame's startup log, VSync's frame callback, a reconfigure.
|
||||
// Frame generation presents its own frames (`frame_gen.present` below).
|
||||
let (encoder, surface_frame, after) = if readback.is_none()
|
||||
&& fg_frames.is_none()
|
||||
&& hdr_readback.is_none()
|
||||
&& checks.is_none()
|
||||
&& !first_frame
|
||||
@@ -7869,7 +7844,11 @@ impl Renderer {
|
||||
if reconfigure {
|
||||
self.configure_surface();
|
||||
}
|
||||
} else {
|
||||
} else if let Some((real, generated)) = fg_frames {
|
||||
self.frame_gen.present(real, generated, fg_made);
|
||||
}
|
||||
if self.surface.is_none() {
|
||||
// No swapchain paces offscreen frames: keep two on the GPU at most.
|
||||
self.in_flight.push_back(submission);
|
||||
if !self.paced_elsewhere {
|
||||
self.wait_in_flight();
|
||||
@@ -7884,6 +7863,204 @@ impl Renderer {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
impl Renderer {
|
||||
/// What draws over the world, on the real frame and frame generation's made one:
|
||||
/// the hand, entity outlines, post effects, screen effects, the debug crosshair and
|
||||
/// the HUD. Returns the world icon's readback, taken on the real frame.
|
||||
fn draw_overlay(
|
||||
&mut self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &Frame,
|
||||
view_target: &wgpu::TextureView,
|
||||
target_texture: &wgpu::Texture,
|
||||
overlay: &Overlay,
|
||||
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
|
||||
// Horizon's own pass timings (#5887) time the real frame's overlay only: a made frame's
|
||||
// would count the same passes twice.
|
||||
let mut timing = if overlay.real {
|
||||
self.own_timing.take()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.keep_world(encoder, timing.as_mut());
|
||||
}
|
||||
if overlay.hand_quads > 0 {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: view_target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("First-person hand"),
|
||||
timestamp_writes: timing.as_mut().and_then(|t| t.writes("Hand")),
|
||||
color_attachments: &attachments,
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
store: wgpu::StoreOp::Discard,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.entity_pipeline);
|
||||
pass.set_bind_group(0, &self.hand_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..overlay.hand_quads * 6, overlay.first_hand_vertex, 0..1);
|
||||
drop(pass);
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.mark_hand(encoder, timing.as_mut());
|
||||
}
|
||||
}
|
||||
// GameRenderer.render: LevelRenderer.blitEntityOutline after the level and the hand.
|
||||
self.entity_outline.draw(
|
||||
encoder,
|
||||
view_target,
|
||||
&self.entity_group,
|
||||
&self.indices,
|
||||
timing.as_mut(),
|
||||
);
|
||||
// The world icon grabs here: after the level and the hand, before
|
||||
// the effects and the HUD draw over them (`tryTakeScreenshotIfNeeded`
|
||||
// runs right after `renderLevel`).
|
||||
let readback = self
|
||||
.screenshot
|
||||
.take_if(|request| overlay.real && request.size.is_some())
|
||||
.and_then(|request| self.take_readback(request, encoder, target_texture));
|
||||
// GameRenderer.render: applyPostEffects, after the level and before the GUI.
|
||||
if !frame.title
|
||||
&& !frame.post_effects.effects.is_empty()
|
||||
&& target_texture
|
||||
.usage()
|
||||
.contains(wgpu::TextureUsages::COPY_SRC)
|
||||
{
|
||||
let device = &self.device;
|
||||
self.post_effects
|
||||
.get_or_insert_with(|| crate::post_chain::PostEffects::new(device))
|
||||
.apply(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
encoder,
|
||||
target_texture,
|
||||
view_target,
|
||||
[self.config.width, self.config.height],
|
||||
// The scaled world pass drew its own depth buffer.
|
||||
overlay.world_depth.then_some(&self.depth),
|
||||
&frame.post_effects,
|
||||
timing.as_mut(),
|
||||
);
|
||||
}
|
||||
if !frame.screen.is_empty() {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: view_target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Screen effects"),
|
||||
timestamp_writes: timing.as_mut().and_then(|t| t.writes("Screen effects")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.screen_buffer.slice(..));
|
||||
for (i, quad) in frame.screen.iter().enumerate() {
|
||||
let blend = match quad.blend {
|
||||
crate::screen_effects::Blend::Alpha => 0,
|
||||
crate::screen_effects::Blend::Vignette => 4,
|
||||
};
|
||||
pass.set_pipeline(&self.screen_pipelines[blend + (quad.sampling & 3) as usize]);
|
||||
pass.draw_indexed(0..6, (i * 4) as i32, 0..1);
|
||||
}
|
||||
}
|
||||
// GameRenderer.render3dHud: the debug crosshair after the hand and
|
||||
// the screen effects.
|
||||
if overlay.crosshair > 0 {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: view_target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("3d crosshair"),
|
||||
timestamp_writes: timing.as_mut().and_then(|t| t.writes("Crosshair")),
|
||||
color_attachments: &attachments,
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
store: wgpu::StoreOp::Discard,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.debug_lines_on_top);
|
||||
pass.set_bind_group(0, &self.crosshair_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
|
||||
let first = overlay.first_crosshair;
|
||||
pass.draw(0..4, first..first + overlay.crosshair);
|
||||
}
|
||||
let menu_blur = frame
|
||||
.menu_blur
|
||||
.filter(|_| self.config.usage.contains(wgpu::TextureUsages::COPY_SRC));
|
||||
let blur_at = menu_blur.map_or(0, |_| frame.hud.blur_at);
|
||||
self.hud.draw_range(
|
||||
encoder,
|
||||
view_target,
|
||||
&self.depth,
|
||||
frame.hud,
|
||||
[self.config.width, self.config.height],
|
||||
0..blur_at,
|
||||
timing.as_mut(),
|
||||
);
|
||||
if let Some(radius) = menu_blur {
|
||||
let format = self.config.format;
|
||||
let device = &self.device;
|
||||
self.menu_blur
|
||||
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format))
|
||||
.apply(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
encoder,
|
||||
target_texture,
|
||||
view_target,
|
||||
[self.config.width, self.config.height],
|
||||
radius,
|
||||
timing.as_mut(),
|
||||
);
|
||||
}
|
||||
self.hud.draw_range(
|
||||
encoder,
|
||||
view_target,
|
||||
&self.depth,
|
||||
frame.hud,
|
||||
[self.config.width, self.config.height],
|
||||
blur_at..frame.hud.vertices.len() as u32,
|
||||
timing.as_mut(),
|
||||
);
|
||||
if overlay.real {
|
||||
self.own_timing = timing;
|
||||
}
|
||||
readback
|
||||
}
|
||||
}
|
||||
/// Out-of-memory and validation error scopes, popped together: the first error either caught.
|
||||
struct GpuChecks {
|
||||
memory: wgpu::ErrorScopeGuard,
|
||||
@@ -8074,19 +8251,19 @@ impl Renderer {
|
||||
Ok([width, height])
|
||||
}
|
||||
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
|
||||
/// third edition), with the peak and paper white as its content light levels (cLLI).
|
||||
/// third edition), with the peak and paper white as its content light levels (cLLI), and
|
||||
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
|
||||
fn write_hdr_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
|
||||
let Some(hdr) = &self.hdr else {
|
||||
return Ok(());
|
||||
};
|
||||
let pixels = hdr10_to_rgb16(&self.read_frame(buffer)?);
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(path)?),
|
||||
self.config.width,
|
||||
self.config.height,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder.set_depth(png::BitDepth::Sixteen);
|
||||
let mut info = png::Info::with_size(self.config.width, self.config.height);
|
||||
info.color_type = png::ColorType::Rgb;
|
||||
info.bit_depth = png::BitDepth::Sixteen;
|
||||
info.icc_profile = Some(hdr_icc::profile().into());
|
||||
let encoder =
|
||||
png::Encoder::with_info(std::io::BufWriter::new(std::fs::File::create(path)?), info)?;
|
||||
let mut writer = encoder.write_header()?;
|
||||
// BT.2020 primaries, PQ, RGB, full range.
|
||||
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
|
||||
@@ -8223,8 +8400,24 @@ fn camera_section(camera: DVec3, min_y: i32) -> SectionKey {
|
||||
)
|
||||
}
|
||||
|
||||
/// The near plane of the world's projection, in blocks.
|
||||
const NEAR_PLANE: f32 = 0.05;
|
||||
|
||||
/// Counts and offsets `Renderer::draw_overlay` draws from this frame's buffers.
|
||||
struct Overlay {
|
||||
/// Zero when a shader pack draws the hand.
|
||||
hand_quads: u32,
|
||||
first_hand_vertex: i32,
|
||||
crosshair: u32,
|
||||
first_crosshair: u32,
|
||||
/// The world drew into `depth` (post effects may read it), not a scaled target's.
|
||||
world_depth: bool,
|
||||
/// The real frame, not frame generation's made one: the world icon grabs it.
|
||||
real: bool,
|
||||
}
|
||||
|
||||
fn world_projection(fov_degrees: f32, aspect: f32, far: f32) -> Mat4 {
|
||||
Mat4::perspective_rh(fov_degrees.to_radians(), aspect, far, 0.05)
|
||||
Mat4::perspective_rh(fov_degrees.to_radians(), aspect, far, NEAR_PLANE)
|
||||
}
|
||||
|
||||
/// `view_proj` mirrored about the horizontal plane at the camera-relative height `plane`:
|
||||
|
||||
@@ -252,6 +252,18 @@ impl Arena {
|
||||
pub fn page_vertices(&self) -> u64 {
|
||||
self.page_limit
|
||||
}
|
||||
/// `page`'s buffer up to the end of its last range in use, as draws bind it:
|
||||
/// wgpu clears what a binding covers and nothing wrote yet when the frame is
|
||||
/// submitted, most of a page just made or grown.
|
||||
pub fn binding(&self, page: u32) -> wgpu::BufferSlice<'_> {
|
||||
let p = self.pages[page as usize].as_ref().expect("live arena page");
|
||||
let end = match p.free.0.last_key_value() {
|
||||
Some((&start, &len)) if start + len == p.capacity => start,
|
||||
_ => p.capacity,
|
||||
};
|
||||
// A page a draw names holds a range; wgpu refuses an empty slice.
|
||||
p.buffer.slice(..end.max(GRAIN) * self.stride)
|
||||
}
|
||||
pub fn has_page(&self, page: u32) -> bool {
|
||||
self.pages.get(page as usize).is_some_and(Option::is_some)
|
||||
}
|
||||
@@ -784,6 +796,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws bind a large page up to the end of its last range in use, a grain at
|
||||
/// least: wgpu clears what a binding covers and nothing wrote when the frame is
|
||||
/// submitted, so a grown page bound whole had its new tail cleared on the GPU.
|
||||
#[test]
|
||||
fn large_pages_are_bound_up_to_their_last_range_in_use() {
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let mut arena = Arena::translucent(&device, 24, GRAIN);
|
||||
let ranges: Vec<_> = (0..3)
|
||||
.map(|_| alloc(&mut arena, &device, &queue, GRAIN).unwrap())
|
||||
.collect();
|
||||
// The third grain grew the page to four.
|
||||
assert_eq!(arena.buffer(0).size(), 4 * GRAIN * 24);
|
||||
assert_eq!(arena.binding(0).size(), 3 * GRAIN * 24);
|
||||
// A free range before the last in use stays bound, none after it; an empty
|
||||
// page binds a grain (wgpu refuses an empty slice).
|
||||
arena.release(0, ranges[1].1, GRAIN);
|
||||
assert_eq!(arena.binding(0).size(), 3 * GRAIN * 24);
|
||||
arena.release(0, ranges[2].1, GRAIN);
|
||||
assert_eq!(arena.binding(0).size(), GRAIN * 24);
|
||||
arena.release(0, ranges[0].1, GRAIN);
|
||||
assert_eq!(arena.binding(0).size(), GRAIN * 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
|
||||
let Some((device, queue)) = device() else {
|
||||
|
||||
@@ -1,11 +1,572 @@
|
||||
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
|
||||
//! two real ones. So far this holds its optical flow, its interpolation and the motion
|
||||
//! vectors of what moves on its own; the setting that runs them comes next.
|
||||
//! Frame generation (a Horizon setting, off by default): FSR 3.1's frame interpolation
|
||||
//! makes a frame halfway between each two real ones. The world, without the hand and
|
||||
//! HUD, is kept at the screen's size each frame; optical flow and the interpolation run
|
||||
//! on it, the hand and HUD are drawn over the made frame as over the real one, and a
|
||||
//! present thread shows the made frame, then the real one half a real frame later.
|
||||
//!
|
||||
//! The renderer draws into frames of its own while this is on; the present thread
|
||||
//! copies each to the swapchain, which it alone acquires and presents.
|
||||
|
||||
// Unused until the renderer runs them.
|
||||
#[allow(dead_code)]
|
||||
mod interpolation;
|
||||
#[allow(dead_code)]
|
||||
mod motion;
|
||||
#[allow(dead_code)]
|
||||
mod optical_flow;
|
||||
|
||||
use glam::{DMat4, DVec3, Mat4};
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// Frames to draw into: a pair being drawn, a pair waiting and a pair being shown.
|
||||
const FRAMES: usize = 6;
|
||||
|
||||
/// `HORIZON_FRAMEGEN_DEBUG=generated`: show only made frames (AMD's debug view), so a
|
||||
/// screenshot or an offscreen frame is the made one; `motion`: the same, each made frame
|
||||
/// showing what moved on its own in blue (its motion vectors in red and green).
|
||||
pub(super) fn generated_only() -> bool {
|
||||
debug().is_some()
|
||||
}
|
||||
|
||||
fn debug() -> Option<&'static str> {
|
||||
static DEBUG: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||
DEBUG
|
||||
.get_or_init(|| {
|
||||
std::env::var("HORIZON_FRAMEGEN_DEBUG")
|
||||
.ok()
|
||||
.filter(|v| v == "generated" || v == "motion")
|
||||
})
|
||||
.as_deref()
|
||||
}
|
||||
|
||||
pub struct FrameGen {
|
||||
pub on: bool,
|
||||
/// The present thread failed to show a frame (or died): until the renderer has
|
||||
/// acquired a frame itself, the normal path runs and handles the surface.
|
||||
pub paused: bool,
|
||||
gpu: Option<Gpu>,
|
||||
presenter: Option<Presenter>,
|
||||
/// When the last real frame was handed over, and the time between real frames.
|
||||
last: Option<Instant>,
|
||||
interval: Duration,
|
||||
/// The last frame's unjittered `view_proj` and camera.
|
||||
previous: Option<(DMat4, DVec3)>,
|
||||
/// The last frame's hand vertices and the bob and view they were drawn with.
|
||||
hand: Vec<crate::mesher::Vertex>,
|
||||
hand_view: Mat4,
|
||||
/// Offscreen, the frame the made frame is drawn into.
|
||||
offscreen_generated: Option<wgpu::Texture>,
|
||||
/// Motion vectors for what moves on its own, made on the first frame.
|
||||
motion: Option<motion::ObjectMotion>,
|
||||
/// Real frames and made ones handed to the screen since `shown_since`, and the share
|
||||
/// of real frames that had a made frame over the last half second (the F3 screen's),
|
||||
/// `None` while off.
|
||||
real: u32,
|
||||
made: u32,
|
||||
shown_since: Instant,
|
||||
pub made_share: Option<f32>,
|
||||
}
|
||||
|
||||
/// What runs on the GPU for one screen size.
|
||||
struct Gpu {
|
||||
size: [u32; 2],
|
||||
flow: optical_flow::OpticalFlow,
|
||||
interpolation: interpolation::Interpolation,
|
||||
/// This frame's world at the screen's size, and bindings that copy it or the
|
||||
/// interpolation's output (`Post::finish_with`).
|
||||
world: wgpu::TextureView,
|
||||
world_group: wgpu::BindGroup,
|
||||
output_group: wgpu::BindGroup,
|
||||
/// The next interpolation has no previous frame.
|
||||
reset: bool,
|
||||
}
|
||||
|
||||
/// This frame's camera, for the interpolation.
|
||||
pub struct Camera {
|
||||
/// The world's projection this frame (with TAA's jitter) and without the jitter.
|
||||
pub jittered: Mat4,
|
||||
pub view_proj: Mat4,
|
||||
pub depth: wgpu::TextureView,
|
||||
pub render_size: [u32; 2],
|
||||
pub jitter: [f32; 2],
|
||||
/// This frame's unjittered clip space to the previous frame's, `None` without one.
|
||||
pub reproject: Option<Mat4>,
|
||||
pub near: f32,
|
||||
pub far: f32,
|
||||
pub fov_y: f32,
|
||||
}
|
||||
|
||||
impl FrameGen {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
on: false,
|
||||
paused: false,
|
||||
gpu: None,
|
||||
presenter: None,
|
||||
last: None,
|
||||
interval: Duration::from_millis(16),
|
||||
previous: None,
|
||||
hand: Vec::new(),
|
||||
hand_view: Mat4::IDENTITY,
|
||||
offscreen_generated: None,
|
||||
motion: None,
|
||||
real: 0,
|
||||
made: 0,
|
||||
shown_since: Instant::now(),
|
||||
made_share: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends frame generation's frames and present thread: before the surface is
|
||||
/// reconfigured or the renderer acquires from it itself.
|
||||
pub fn stop(&mut self) {
|
||||
self.presenter = None;
|
||||
self.made_share = None;
|
||||
(self.real, self.made) = (0, 0);
|
||||
self.last = None;
|
||||
self.previous = None;
|
||||
self.hand.clear();
|
||||
if let Some(motion) = &mut self.motion {
|
||||
motion.reset();
|
||||
}
|
||||
if let Some(gpu) = &mut self.gpu {
|
||||
gpu.reset = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// The hand for the made frame, appended to `out`: each vertex halfway between where
|
||||
/// it was on screen last frame and where it is now (the hand swings and bobs on its
|
||||
/// own, so the world's interpolation can't move it), in this frame's space
|
||||
/// (`view`: this frame's bob and view). This frame's hand where the two differ in
|
||||
/// shape (another item, the first frame).
|
||||
pub fn halfway_hand(
|
||||
&mut self,
|
||||
hand: &[crate::mesher::Vertex],
|
||||
view: Mat4,
|
||||
out: &mut Vec<crate::mesher::Vertex>,
|
||||
) {
|
||||
let same = self.hand.len() == hand.len()
|
||||
&& self
|
||||
.hand
|
||||
.iter()
|
||||
.zip(hand)
|
||||
.all(|(a, b)| a.layer == b.layer && a.uv == b.uv);
|
||||
if same {
|
||||
let back = view.inverse();
|
||||
out.extend(self.hand.iter().zip(hand).map(|(before, now)| {
|
||||
let then = self.hand_view.transform_point3(before.pos.into());
|
||||
let mid = (then + view.transform_point3(now.pos.into())) * 0.5;
|
||||
crate::mesher::Vertex {
|
||||
pos: back.transform_point3(mid).into(),
|
||||
..*now
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
out.extend_from_slice(hand);
|
||||
}
|
||||
self.hand.clear();
|
||||
self.hand.extend_from_slice(hand);
|
||||
self.hand_view = view;
|
||||
}
|
||||
|
||||
/// This frame's entity vertices (relative to `camera`) and the entity ids of their
|
||||
/// spans (`Frame::entity_motion`), for their own motion vectors; `atlas`: the
|
||||
/// entities' textures. Before `generate`, every frame.
|
||||
pub fn track_entities(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
atlas: &wgpu::TextureView,
|
||||
entities: &[crate::mesher::Vertex],
|
||||
spans: &[(u32, u64)],
|
||||
camera: DVec3,
|
||||
) {
|
||||
self.motion
|
||||
.get_or_insert_with(|| motion::ObjectMotion::new(device, atlas.clone()))
|
||||
.track(entities, spans, camera);
|
||||
}
|
||||
|
||||
/// This frame's unjittered clip space to the last frame's, from their `view_proj`s
|
||||
/// (relative to `camera`); `None` on the first frame.
|
||||
pub fn camera_motion(&mut self, view_proj: Mat4, camera: DVec3) -> Option<Mat4> {
|
||||
let current = view_proj.as_dmat4();
|
||||
let previous = self.previous.replace((current, camera));
|
||||
previous.map(|(p, pc)| super::world_post::reprojection(p, pc, current, camera).as_mat4())
|
||||
}
|
||||
|
||||
/// Frames to draw the real and the made frame into, from the present thread's
|
||||
/// (started for this `config`'s surface) or, offscreen, the offscreen target and
|
||||
/// one of its own. `None` once the present thread failed or died: it is stopped and
|
||||
/// frame generation `paused`.
|
||||
pub fn frames(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
surface: Option<&Arc<wgpu::Surface<'static>>>,
|
||||
offscreen: Option<&wgpu::Texture>,
|
||||
window: &Arc<winit::window::Window>,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
) -> Option<(wgpu::Texture, wgpu::Texture)> {
|
||||
let Some(surface) = surface else {
|
||||
let real = offscreen.cloned().unwrap_or_else(|| frame(device, config));
|
||||
if self
|
||||
.offscreen_generated
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size() != real.size())
|
||||
{
|
||||
self.offscreen_generated = Some(frame(device, config));
|
||||
}
|
||||
let generated = self.offscreen_generated.clone().expect("made above");
|
||||
return Some((real, generated));
|
||||
};
|
||||
if self.presenter.as_ref().is_some_and(|p| p.config != *config) {
|
||||
self.presenter = None;
|
||||
}
|
||||
let presenter = self.presenter.get_or_insert_with(|| {
|
||||
Presenter::start(device, queue, surface.clone(), window.clone(), config)
|
||||
});
|
||||
// A closed channel: the thread died.
|
||||
let pair = if presenter.failed() {
|
||||
None
|
||||
} else {
|
||||
presenter.free.recv().ok().zip(presenter.free.recv().ok())
|
||||
};
|
||||
if pair.is_none() {
|
||||
self.stop();
|
||||
self.paused = true;
|
||||
}
|
||||
pair
|
||||
}
|
||||
|
||||
/// The world's target at the screen's size this frame, where `Post::finish` draws.
|
||||
pub fn world(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
post: &super::world_post::Post,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
) -> wgpu::TextureView {
|
||||
let size = [config.width, config.height];
|
||||
if self.gpu.as_ref().is_none_or(|g| g.size != size) {
|
||||
let world = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Frame generation world"),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
// COPY_SRC: the interpolation keeps it as the previous frame.
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
let interpolation = interpolation::Interpolation::new(device, size);
|
||||
self.gpu = Some(Gpu {
|
||||
size,
|
||||
flow: optical_flow::OpticalFlow::new(device, size),
|
||||
world_group: post.bind(device, &world),
|
||||
output_group: post.bind(device, interpolation.output()),
|
||||
interpolation,
|
||||
world,
|
||||
reset: true,
|
||||
});
|
||||
}
|
||||
self.gpu.as_ref().expect("made above").world.clone()
|
||||
}
|
||||
|
||||
/// Copy the world (`world`) onto `screen` for the real frame, and draw the made
|
||||
/// frame's world onto `generated`. Returns false when there is no made frame (the
|
||||
/// first after a reset): `generated` then holds this frame's world too.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn generate(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
post: &super::world_post::Post,
|
||||
screen: &wgpu::TextureView,
|
||||
generated: &wgpu::TextureView,
|
||||
camera: &Camera,
|
||||
) -> bool {
|
||||
let gpu = self.gpu.as_mut().expect("world() ran this frame");
|
||||
post.finish_with(encoder, screen, &gpu.world_group, None);
|
||||
let reset = gpu.reset || camera.reproject.is_none();
|
||||
let reproject = camera.reproject.unwrap_or(Mat4::IDENTITY);
|
||||
let object_motion = self
|
||||
.motion
|
||||
.as_mut()
|
||||
.expect("track_entities() ran this frame")
|
||||
.draw(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
&camera.depth,
|
||||
camera.render_size,
|
||||
camera.jittered,
|
||||
camera.view_proj,
|
||||
reproject * camera.view_proj,
|
||||
);
|
||||
gpu.flow.dispatch(device, queue, encoder, &gpu.world, reset);
|
||||
gpu.interpolation.dispatch(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
&interpolation::Inputs {
|
||||
color: &gpu.world,
|
||||
depth: &camera.depth,
|
||||
render_size: camera.render_size,
|
||||
jitter: camera.jitter,
|
||||
reproject,
|
||||
object_motion: &object_motion,
|
||||
near: camera.near,
|
||||
far: camera.far,
|
||||
fov_y: camera.fov_y,
|
||||
reset,
|
||||
flow_vectors: gpu.flow.vectors(),
|
||||
scene_change: gpu.flow.scene_change(),
|
||||
},
|
||||
);
|
||||
gpu.reset = false;
|
||||
if debug() == Some("motion") {
|
||||
post.finish_with(encoder, generated, &post.bind(device, &object_motion), None);
|
||||
return true;
|
||||
}
|
||||
if reset {
|
||||
post.finish_with(encoder, generated, &gpu.world_group, None);
|
||||
} else {
|
||||
post.finish_with(encoder, generated, &gpu.output_group, None);
|
||||
}
|
||||
!reset
|
||||
}
|
||||
|
||||
/// Hand this frame's pair to the present thread, after the frame's submission: the
|
||||
/// made frame (when `made`) goes first.
|
||||
pub fn present(&mut self, real: wgpu::Texture, generated: wgpu::Texture, made: bool) {
|
||||
let now = Instant::now();
|
||||
if let Some(last) = self.last {
|
||||
// A real frame's time, smoothed: the made frame shows for half of it.
|
||||
let dt = (now - last).min(Duration::from_millis(250));
|
||||
self.interval = self.interval.mul_f32(0.9) + dt.mul_f32(0.1);
|
||||
}
|
||||
self.last = Some(now);
|
||||
self.real += 1;
|
||||
self.made += u32::from(made && !generated_only());
|
||||
if now - self.shown_since >= Duration::from_millis(500) {
|
||||
self.made_share = Some(self.made as f32 / self.real as f32);
|
||||
(self.real, self.made, self.shown_since) = (0, 0, now);
|
||||
}
|
||||
let Some(send) = self.presenter.as_ref().and_then(|p| p.send.as_ref()) else {
|
||||
return;
|
||||
};
|
||||
let pair = if generated_only() {
|
||||
Pair {
|
||||
first: None,
|
||||
second: generated,
|
||||
spare: Some(real),
|
||||
interval: self.interval,
|
||||
}
|
||||
} else if made {
|
||||
Pair {
|
||||
first: Some(generated),
|
||||
second: real,
|
||||
spare: None,
|
||||
interval: self.interval,
|
||||
}
|
||||
} else {
|
||||
Pair {
|
||||
first: None,
|
||||
second: real,
|
||||
spare: Some(generated),
|
||||
interval: self.interval,
|
||||
}
|
||||
};
|
||||
let _ = send.send(pair);
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame to draw into, like the swapchain's.
|
||||
fn frame(device: &wgpu::Device, config: &wgpu::SurfaceConfiguration) -> wgpu::Texture {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Frame generation frame"),
|
||||
size: wgpu::Extent3d {
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: config.format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &config.view_formats,
|
||||
})
|
||||
}
|
||||
|
||||
/// One real frame's frames for the present thread: `first` (the made frame) now,
|
||||
/// `second` (the real one) half an `interval` later; `spare` goes straight back.
|
||||
struct Pair {
|
||||
first: Option<wgpu::Texture>,
|
||||
second: wgpu::Texture,
|
||||
spare: Option<wgpu::Texture>,
|
||||
interval: Duration,
|
||||
}
|
||||
|
||||
/// The thread that copies frames to the swapchain and presents them on time.
|
||||
struct Presenter {
|
||||
config: wgpu::SurfaceConfiguration,
|
||||
send: Option<mpsc::SyncSender<Pair>>,
|
||||
free: mpsc::Receiver<wgpu::Texture>,
|
||||
/// The surface gave something other than a frame: the renderer takes it back.
|
||||
failed: Arc<AtomicBool>,
|
||||
thread: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Presenter {
|
||||
fn start(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
surface: Arc<wgpu::Surface<'static>>,
|
||||
window: Arc<winit::window::Window>,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
) -> Self {
|
||||
// One pair waiting at most: the renderer then waits as for a swapchain image.
|
||||
let (send, receive) = mpsc::sync_channel::<Pair>(1);
|
||||
let (give, free) = mpsc::channel();
|
||||
for _ in 0..FRAMES {
|
||||
let _ = give.send(frame(device, config));
|
||||
}
|
||||
let (device, queue) = (device.clone(), queue.clone());
|
||||
let vsync = config.present_mode == wgpu::PresentMode::Fifo;
|
||||
let failed = Arc::new(AtomicBool::new(false));
|
||||
let failed_here = failed.clone();
|
||||
let thread = thread::Builder::new()
|
||||
.name("Frame generation present".into())
|
||||
.spawn(move || {
|
||||
let show = |texture: &wgpu::Texture| {
|
||||
if failed_here.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
// Anything but a frame (outdated, lost, occluded...): the renderer
|
||||
// stops this thread and acquires itself, handling it as it does
|
||||
// without frame generation. A suboptimal frame still shows.
|
||||
let target = match surface.get_current_texture() {
|
||||
wgpu::CurrentSurfaceTexture::Success(t) => t,
|
||||
wgpu::CurrentSurfaceTexture::Suboptimal(t) => {
|
||||
failed_here.store(true, Ordering::Relaxed);
|
||||
t
|
||||
}
|
||||
_ => {
|
||||
failed_here.store(true, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_texture_to_texture(
|
||||
texture.as_image_copy(),
|
||||
target.texture.as_image_copy(),
|
||||
target.texture.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
if vsync {
|
||||
window.pre_present_notify();
|
||||
}
|
||||
queue.present(target);
|
||||
};
|
||||
while let Ok(pair) = receive.recv() {
|
||||
if let Some(first) = pair.first {
|
||||
let shown = Instant::now();
|
||||
show(&first);
|
||||
let _ = give.send(first);
|
||||
// ponytail: sleep pacing; a spin for the last half millisecond
|
||||
// if it shows uneven on some systems.
|
||||
let due = shown + pair.interval / 2;
|
||||
if let Some(wait) = due.checked_duration_since(Instant::now()) {
|
||||
thread::sleep(wait);
|
||||
}
|
||||
}
|
||||
show(&pair.second);
|
||||
let _ = give.send(pair.second);
|
||||
if let Some(spare) = pair.spare {
|
||||
let _ = give.send(spare);
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawn the frame generation present thread");
|
||||
Self {
|
||||
config: config.clone(),
|
||||
send: Some(send),
|
||||
free,
|
||||
failed,
|
||||
thread: Some(thread),
|
||||
}
|
||||
}
|
||||
|
||||
/// The surface failed it or the thread died (a panic).
|
||||
fn failed(&self) -> bool {
|
||||
self.failed.load(Ordering::Relaxed) || self.thread.as_ref().is_none_or(|t| t.is_finished())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Presenter {
|
||||
fn drop(&mut self) {
|
||||
self.send = None;
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mesher::Vertex;
|
||||
use glam::Vec3;
|
||||
|
||||
fn vertex(pos: Vec3) -> Vertex {
|
||||
Vertex {
|
||||
pos: pos.into(),
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
}
|
||||
}
|
||||
|
||||
/// A swing lands halfway; a hand still on screen while the camera turns stays still;
|
||||
/// another item shows as it is.
|
||||
#[test]
|
||||
fn the_made_frame_s_hand_is_halfway_on_screen() {
|
||||
let mut fg = FrameGen::new();
|
||||
let mut out = Vec::new();
|
||||
fg.halfway_hand(
|
||||
&[vertex(Vec3::new(0.0, 0.0, -1.0))],
|
||||
Mat4::IDENTITY,
|
||||
&mut out,
|
||||
);
|
||||
fg.halfway_hand(
|
||||
&[vertex(Vec3::new(0.4, 0.0, -1.0))],
|
||||
Mat4::IDENTITY,
|
||||
&mut out,
|
||||
);
|
||||
assert!((Vec3::from(out[1].pos) - Vec3::new(0.2, 0.0, -1.0)).length() < 1e-6);
|
||||
// The camera turned: the vertex turned with it, the same on screen.
|
||||
let turned = Mat4::from_rotation_y(0.3);
|
||||
let on_screen = Vec3::new(0.4, 0.0, -1.0);
|
||||
let pos = turned.inverse().transform_point3(on_screen);
|
||||
fg.halfway_hand(&[vertex(pos)], turned, &mut out);
|
||||
assert!((Vec3::from(out[2].pos) - pos).length() < 1e-6);
|
||||
let mut other = vertex(Vec3::ZERO);
|
||||
other.uv = [0.5, 0.5];
|
||||
fg.halfway_hand(&[other], turned, &mut out);
|
||||
assert_eq!(out[3].pos, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! The ICC profile of HDR screenshots, for programs that don't read PNG's `cICP`: BT.2020
|
||||
//! primaries and the PQ curve, with HDR reference white (203 nits) as the profile's white.
|
||||
//! An ICC profile has no values above white, so those programs show a correct SDR picture
|
||||
//! with its highlights clipped, where without it they took PQ's values for sRGB's and showed
|
||||
//! them washed out. Programs that read `cICP` ignore this (PNG's third edition).
|
||||
|
||||
/// HDR reference white in nits (ITU-R BT.2408): where the curve reaches the profile's white.
|
||||
const WHITE: f64 = 203.0;
|
||||
/// PQ's values from 0 to 1, in the curve's steps.
|
||||
const STEPS: usize = 4096;
|
||||
/// BT.2020's primaries, adapted to the profile connection space's D50 (Bradford).
|
||||
const RED: [f64; 3] = [0.67348, 0.27904, -0.00193];
|
||||
const GREEN: [f64; 3] = [0.16567, 0.67534, 0.02998];
|
||||
const BLUE: [f64; 3] = [0.12505, 0.04561, 0.79685];
|
||||
const D50: [f64; 3] = [0.9642, 1.0, 0.8249];
|
||||
|
||||
/// PQ's EOTF (SMPTE ST 2084): a signal from 0 to 1 in nits.
|
||||
fn pq_nits(signal: f64) -> f64 {
|
||||
let (m1, m2) = (2610.0 / 16384.0, 2523.0 / 32.0);
|
||||
let (c1, c2, c3) = (3424.0 / 4096.0, 2413.0 / 128.0, 2392.0 / 128.0);
|
||||
let p = signal.powf(1.0 / m2);
|
||||
10_000.0 * ((p - c1).max(0.0) / (c2 - c3 * p)).powf(1.0 / m1)
|
||||
}
|
||||
|
||||
fn s15_fixed16(v: f64) -> [u8; 4] {
|
||||
((v * 65536.0).round() as i32).to_be_bytes()
|
||||
}
|
||||
|
||||
fn xyz(v: [f64; 3]) -> Vec<u8> {
|
||||
let mut tag = b"XYZ \0\0\0\0".to_vec();
|
||||
tag.extend(v.iter().flat_map(|&c| s15_fixed16(c)));
|
||||
tag
|
||||
}
|
||||
|
||||
fn curve() -> Vec<u8> {
|
||||
let mut tag = b"curv\0\0\0\0".to_vec();
|
||||
tag.extend((STEPS as u32).to_be_bytes());
|
||||
for i in 0..STEPS {
|
||||
let y = (pq_nits(i as f64 / (STEPS - 1) as f64) / WHITE).min(1.0);
|
||||
tag.extend(((y * 65535.0).round() as u16).to_be_bytes());
|
||||
}
|
||||
tag
|
||||
}
|
||||
|
||||
/// `textDescriptionType` (ICC v2), with its empty Unicode and ScriptCode parts.
|
||||
fn description(text: &str) -> Vec<u8> {
|
||||
let mut tag = b"desc\0\0\0\0".to_vec();
|
||||
tag.extend((text.len() as u32 + 1).to_be_bytes());
|
||||
tag.extend(text.as_bytes());
|
||||
tag.extend([0; 1 + 4 + 4 + 2 + 1 + 67]);
|
||||
tag
|
||||
}
|
||||
|
||||
/// An ICC v2 matrix-shaper display profile.
|
||||
pub(super) fn profile() -> Vec<u8> {
|
||||
let trc = curve();
|
||||
let copyright = b"text\0\0\0\0Public domain\0".to_vec();
|
||||
let tags: [(&[u8; 4], &[u8]); 9] = [
|
||||
(b"desc", &description("BT.2020 PQ, 203 nits = white")),
|
||||
(b"cprt", ©right),
|
||||
(b"wtpt", &xyz(D50)),
|
||||
(b"rXYZ", &xyz(RED)),
|
||||
(b"gXYZ", &xyz(GREEN)),
|
||||
(b"bXYZ", &xyz(BLUE)),
|
||||
(b"rTRC", &trc),
|
||||
(b"gTRC", &trc),
|
||||
(b"bTRC", &trc),
|
||||
];
|
||||
let data_start = 128 + 4 + 12 * tags.len();
|
||||
let mut table = (tags.len() as u32).to_be_bytes().to_vec();
|
||||
let mut data = Vec::new();
|
||||
let mut trc_at = None;
|
||||
for (name, tag) in tags {
|
||||
// The three channels share one curve.
|
||||
let is_trc = tag == &trc[..];
|
||||
let at = match trc_at.filter(|_| is_trc) {
|
||||
Some(at) => at,
|
||||
None => {
|
||||
let at = data_start + data.len();
|
||||
data.extend(tag);
|
||||
data.resize(data.len().next_multiple_of(4), 0);
|
||||
at
|
||||
}
|
||||
};
|
||||
if is_trc {
|
||||
trc_at = Some(at);
|
||||
}
|
||||
table.extend(name);
|
||||
table.extend((at as u32).to_be_bytes());
|
||||
table.extend((tag.len() as u32).to_be_bytes());
|
||||
}
|
||||
let mut out = vec![0; 128];
|
||||
let size = data_start + data.len();
|
||||
out[0..4].copy_from_slice(&(size as u32).to_be_bytes());
|
||||
out[8..12].copy_from_slice(&[2, 0x40, 0, 0]);
|
||||
out[12..16].copy_from_slice(b"mntr");
|
||||
out[16..20].copy_from_slice(b"RGB ");
|
||||
out[20..24].copy_from_slice(b"XYZ ");
|
||||
out[36..40].copy_from_slice(b"acsp");
|
||||
// Relative colorimetric, and the connection space's illuminant.
|
||||
out[64..68].copy_from_slice(&1u32.to_be_bytes());
|
||||
for (i, &c) in D50.iter().enumerate() {
|
||||
out[68 + 4 * i..72 + 4 * i].copy_from_slice(&s15_fixed16(c));
|
||||
}
|
||||
out.extend(table);
|
||||
out.extend(data);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tag<'a>(profile: &'a [u8], name: &[u8; 4]) -> &'a [u8] {
|
||||
let u32_at = |i: usize| u32::from_be_bytes(profile[i..i + 4].try_into().unwrap()) as usize;
|
||||
let at = (0..u32_at(128))
|
||||
.map(|i| 132 + 12 * i)
|
||||
.find(|&e| &profile[e..e + 4] == name)
|
||||
.unwrap();
|
||||
&profile[u32_at(at + 4)..u32_at(at + 4) + u32_at(at + 8)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_profile_is_whole() {
|
||||
let p = profile();
|
||||
assert_eq!(
|
||||
u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize,
|
||||
p.len()
|
||||
);
|
||||
assert_eq!(&p[36..40], b"acsp");
|
||||
assert_eq!(p.len() % 4, 0);
|
||||
assert_eq!(&tag(&p, b"desc")[0..4], b"desc");
|
||||
// One curve, shared: the profile stays small.
|
||||
assert_eq!(tag(&p, b"rTRC").as_ptr(), tag(&p, b"bTRC").as_ptr());
|
||||
assert!(p.len() < 10_000, "{}", p.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_primaries_add_up_to_white() {
|
||||
for i in 0..3 {
|
||||
assert!((RED[i] + GREEN[i] + BLUE[i] - D50[i]).abs() < 0.0001);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_reaches_white_at_reference_white_and_stays() {
|
||||
let p = profile();
|
||||
let trc = tag(&p, b"rTRC");
|
||||
let value = |signal: f64| {
|
||||
let i = 12 + 2 * (signal * (STEPS - 1) as f64).round() as usize;
|
||||
u16::from_be_bytes([trc[i], trc[i + 1]])
|
||||
};
|
||||
assert_eq!(value(0.0), 0);
|
||||
// 203 nits in PQ is a signal of 0.5807.
|
||||
assert!(value(0.57) < 65535);
|
||||
assert_eq!(value(0.59), 65535);
|
||||
assert_eq!(value(1.0), 65535);
|
||||
}
|
||||
}
|
||||
@@ -2740,6 +2740,13 @@ impl PackPost {
|
||||
let rp = &mut open.as_mut().expect("open").0;
|
||||
rp.set_pipeline(pass.render());
|
||||
rp.set_bind_group(0, group, &[]);
|
||||
// A sky program that reads the pack's SSBOs (Noble's `gbuffers_textured`, the
|
||||
// sun's fallback) binds them, as the other passes do.
|
||||
if pass.buffers
|
||||
&& let Some(g) = &self.buffers.group
|
||||
{
|
||||
rp.set_bind_group(4, g, &[]);
|
||||
}
|
||||
sky.geometry.bind(rp, part.textured);
|
||||
open.as_mut().expect("open").1 = i;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,12 @@ pub struct PackInputs {
|
||||
pub cloud_height: f32,
|
||||
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
|
||||
pub mod_params: [[f32; 4]; 8],
|
||||
/// The player's state, for Iris's status uniforms.
|
||||
pub status: PlayerStatus,
|
||||
/// The End flash: its intensity, 0..1 (0 outside the End), and its direction from the
|
||||
/// camera in world space, of length 100 (`endFlashIntensity`, `endFlashPosition`).
|
||||
pub end_flash: f32,
|
||||
pub end_flash_direction: Vec3,
|
||||
}
|
||||
|
||||
/// The `cloudHeight` uniform. Vanilla reads `visual/cloud_height` only while the cloud color
|
||||
@@ -64,6 +70,35 @@ pub fn cloud_height(cloud_color_alpha: f32, height: f32) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The local player's state behind Iris's status uniforms (`currentPlayerHealth`,
|
||||
/// `isSpectator`, `is_burning`...).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct PlayerStatus {
|
||||
/// Survival or adventure: the vitals are -1 in creative and spectator.
|
||||
pub survival: bool,
|
||||
pub health: f32,
|
||||
pub max_health: f32,
|
||||
pub food: f32,
|
||||
/// Air supply ticks, of 300.
|
||||
pub air: f32,
|
||||
/// Armor points (the `armor` attribute).
|
||||
pub armor: f32,
|
||||
pub spectator: bool,
|
||||
pub riding: bool,
|
||||
pub feet_in_water: bool,
|
||||
pub swimming: bool,
|
||||
pub burning: bool,
|
||||
pub hurt: bool,
|
||||
pub invisible: bool,
|
||||
pub sneaking: bool,
|
||||
pub sprinting: bool,
|
||||
}
|
||||
|
||||
/// `maxPlayerHunger`, `maxPlayerAir` and `maxPlayerArmor`: Minecraft's own maximums.
|
||||
const MAX_HUNGER: f32 = 20.0;
|
||||
const MAX_AIR: f32 = 300.0;
|
||||
const MAX_ARMOR: f32 = 50.0;
|
||||
|
||||
/// One uniform's value: up to 16 floats or 3 integers.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Value {
|
||||
@@ -198,6 +233,25 @@ pub const NAMES: &[&str] = &[
|
||||
"playerLookVector",
|
||||
"playerBodyVector",
|
||||
"firstPersonCamera",
|
||||
"currentPlayerHealth",
|
||||
"maxPlayerHealth",
|
||||
"currentPlayerHunger",
|
||||
"maxPlayerHunger",
|
||||
"currentPlayerAir",
|
||||
"maxPlayerAir",
|
||||
"currentPlayerArmor",
|
||||
"maxPlayerArmor",
|
||||
"isSpectator",
|
||||
"isRiding",
|
||||
"feetInWater",
|
||||
"inSwimmingAnimation",
|
||||
"is_burning",
|
||||
"is_hurt",
|
||||
"is_invisible",
|
||||
"is_sneaking",
|
||||
"is_sprinting",
|
||||
"endFlashIntensity",
|
||||
"endFlashPosition",
|
||||
"isRightHanded",
|
||||
"is_on_ground",
|
||||
"bedrockLevel",
|
||||
@@ -424,6 +478,7 @@ impl FrameUniforms {
|
||||
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
|
||||
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
|
||||
"sunPosition" => v3(sun),
|
||||
"endFlashPosition" => v3(to_view(inputs.end_flash_direction)),
|
||||
"moonPosition" => v3(moon),
|
||||
"shadowLightPosition" => v3(if day { sun } else { moon }),
|
||||
"sunAngle" => Value::f(&[sun_angle]),
|
||||
@@ -483,7 +538,7 @@ impl FrameUniforms {
|
||||
"isElytraFlying" => Value::i(&[inputs.elytra_flying as i32]),
|
||||
"cloudHeight" => Value::f(&[inputs.cloud_height]),
|
||||
// renderStage is NONE outside gbuffers draws.
|
||||
_ => Value::i(&[0]),
|
||||
_ => status_value(name, inputs).unwrap_or(Value::i(&[0])),
|
||||
}
|
||||
};
|
||||
self.values.clear();
|
||||
@@ -529,6 +584,46 @@ impl FrameUniforms {
|
||||
}
|
||||
}
|
||||
|
||||
/// Iris's player status and dimension uniforms, from `inputs`: the vitals are fractions of
|
||||
/// their maximum, and -1 (with the maximums of health and air) in creative and spectator.
|
||||
fn status_value(name: &str, inputs: &PackInputs) -> Option<Value> {
|
||||
let status = inputs.status;
|
||||
let vital = |v: f32| {
|
||||
Value::f(&[if status.survival {
|
||||
v.clamp(0.0, 1.0)
|
||||
} else {
|
||||
-1.0
|
||||
}])
|
||||
};
|
||||
let flag = |b: bool| Value::i(&[i32::from(b)]);
|
||||
Some(match name {
|
||||
"currentPlayerHealth" => vital(status.health / status.max_health.max(1.0)),
|
||||
"maxPlayerHealth" => Value::f(&[if status.survival {
|
||||
status.max_health
|
||||
} else {
|
||||
-1.0
|
||||
}]),
|
||||
"currentPlayerHunger" => vital(status.food / MAX_HUNGER),
|
||||
"maxPlayerHunger" => Value::f(&[MAX_HUNGER]),
|
||||
"currentPlayerAir" => vital(status.air / MAX_AIR),
|
||||
"maxPlayerAir" => Value::f(&[if status.survival { MAX_AIR } else { -1.0 }]),
|
||||
"currentPlayerArmor" => vital(status.armor / MAX_ARMOR),
|
||||
"maxPlayerArmor" => Value::f(&[MAX_ARMOR]),
|
||||
"isSpectator" => flag(status.spectator),
|
||||
"isRiding" => flag(status.riding),
|
||||
"feetInWater" => flag(status.feet_in_water),
|
||||
"inSwimmingAnimation" => flag(status.swimming),
|
||||
"is_burning" => flag(status.burning),
|
||||
"is_hurt" => flag(status.hurt),
|
||||
// Spectators count as invisible.
|
||||
"is_invisible" => flag(status.invisible || status.spectator),
|
||||
"is_sneaking" => flag(status.sneaking),
|
||||
"is_sprinting" => flag(status.sprinting),
|
||||
"endFlashIntensity" => Value::f(&[inputs.end_flash]),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// How many components the uniform `name` has (16 for a matrix).
|
||||
fn components(name: &str) -> usize {
|
||||
match name {
|
||||
@@ -547,7 +642,8 @@ fn components(name: &str) -> usize {
|
||||
| "moonPosition"
|
||||
| "shadowLightPosition"
|
||||
| "fogColor"
|
||||
| "skyColor" => 3,
|
||||
| "skyColor"
|
||||
| "endFlashPosition" => 3,
|
||||
"eyeBrightness" | "eyeBrightnessSmooth" | "atlasSize" => 2,
|
||||
n if n.starts_with("gbuffer") || n.starts_with("shadow") && n != "shadowAngle" => 16,
|
||||
_ => 1,
|
||||
@@ -576,6 +672,77 @@ mod tests {
|
||||
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
|
||||
}
|
||||
|
||||
/// Iris's status uniforms as its documentation defines them (shaders.properties, "Status").
|
||||
#[test]
|
||||
fn status_uniforms_follow_iris() {
|
||||
let float = |name: &str, inputs: &PackInputs| match status_value(name, inputs) {
|
||||
Some(Value::F(f)) => f[0],
|
||||
other => panic!("{name}: {other:?}"),
|
||||
};
|
||||
let int = |name: &str, inputs: &PackInputs| match status_value(name, inputs) {
|
||||
Some(Value::I(i)) => i[0],
|
||||
other => panic!("{name}: {other:?}"),
|
||||
};
|
||||
let mut inputs = PackInputs {
|
||||
status: PlayerStatus {
|
||||
survival: true,
|
||||
health: 15.0,
|
||||
max_health: 20.0,
|
||||
food: 10.0,
|
||||
air: 150.0,
|
||||
armor: 20.0,
|
||||
sneaking: true,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
// The vitals are fractions of their maximums.
|
||||
assert_eq!(float("currentPlayerHealth", &inputs), 0.75);
|
||||
assert_eq!(float("maxPlayerHealth", &inputs), 20.0);
|
||||
assert_eq!(float("currentPlayerHunger", &inputs), 0.5);
|
||||
assert_eq!(float("maxPlayerHunger", &inputs), 20.0);
|
||||
assert_eq!(float("currentPlayerAir", &inputs), 0.5);
|
||||
assert_eq!(float("maxPlayerAir", &inputs), 300.0);
|
||||
assert_eq!(float("currentPlayerArmor", &inputs), 0.4);
|
||||
assert_eq!(float("maxPlayerArmor", &inputs), 50.0);
|
||||
assert_eq!(int("is_sneaking", &inputs), 1);
|
||||
assert_eq!(int("is_invisible", &inputs), 0);
|
||||
// Creative and spectator: -1 for the vitals and the maximums of health and air;
|
||||
// spectators are invisible.
|
||||
inputs.status.survival = false;
|
||||
inputs.status.spectator = true;
|
||||
for name in [
|
||||
"currentPlayerHealth",
|
||||
"maxPlayerHealth",
|
||||
"currentPlayerHunger",
|
||||
"currentPlayerAir",
|
||||
"maxPlayerAir",
|
||||
"currentPlayerArmor",
|
||||
] {
|
||||
assert_eq!(float(name, &inputs), -1.0, "{name}");
|
||||
}
|
||||
assert_eq!(float("maxPlayerHunger", &inputs), 20.0);
|
||||
assert_eq!(float("maxPlayerArmor", &inputs), 50.0);
|
||||
assert_eq!(int("isSpectator", &inputs), 1);
|
||||
assert_eq!(int("is_invisible", &inputs), 1);
|
||||
// Each is provided, not left at zero for the pack.
|
||||
for name in [
|
||||
"currentPlayerHealth",
|
||||
"isSpectator",
|
||||
"is_sprinting",
|
||||
"bedrockLevel",
|
||||
] {
|
||||
assert!(index(name).is_some(), "{name} is not provided");
|
||||
}
|
||||
assert!(
|
||||
NAMES
|
||||
.iter()
|
||||
.filter(|n| status_value(n, &inputs).is_some())
|
||||
.count()
|
||||
== 18
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloud_height_needs_a_cloud_color() {
|
||||
assert_eq!(cloud_height(0.8, 192.33), 192.33);
|
||||
|
||||
@@ -299,6 +299,16 @@ impl Post {
|
||||
Mat4::from_translation(jitter.extend(0.0))
|
||||
}
|
||||
|
||||
/// This frame's jitter in rendered pixels, y down (FSR's `jitterOffset`); zero
|
||||
/// without TAA.
|
||||
pub fn jitter_pixels(&self) -> [f32; 2] {
|
||||
let Some(size) = self.size().filter(|_| self.taa_on) else {
|
||||
return [0.0; 2];
|
||||
};
|
||||
let j = self.taa_this_frame.jitter;
|
||||
[j[0] * size[0] as f32 * 0.5, -j[1] * size[1] as f32 * 0.5]
|
||||
}
|
||||
|
||||
/// The world's colour and depth targets this frame, or `None` to draw
|
||||
/// it straight to the screen.
|
||||
/// `always`: a target even at scale 1 without anti-aliasing (a shader pack reads it).
|
||||
@@ -507,7 +517,12 @@ impl Post {
|
||||
|
||||
/// This frame's clip space to the previous frame's, for camera-relative projections:
|
||||
/// a point `camera - previous_camera` further from the previous camera than from this one.
|
||||
fn reprojection(previous: DMat4, previous_camera: DVec3, current: DMat4, camera: DVec3) -> DMat4 {
|
||||
pub(super) fn reprojection(
|
||||
previous: DMat4,
|
||||
previous_camera: DVec3,
|
||||
current: DMat4,
|
||||
camera: DVec3,
|
||||
) -> DMat4 {
|
||||
previous * DMat4::from_translation(camera - previous_camera) * current.inverse()
|
||||
}
|
||||
|
||||
|
||||
@@ -287,6 +287,36 @@ impl FarDetail {
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame generation: a frame made between each two real ones.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum FrameGeneration {
|
||||
#[default]
|
||||
Off,
|
||||
/// AMD FidelityFX Super Resolution 3.1's frame interpolation, ported to WGSL.
|
||||
Fsr31,
|
||||
}
|
||||
impl FrameGeneration {
|
||||
pub const ALL: [FrameGeneration; 2] = [FrameGeneration::Off, FrameGeneration::Fsr31];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
FrameGeneration::Off => "off",
|
||||
FrameGeneration::Fsr31 => "fsr3.1",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FrameGeneration::Off => "Off",
|
||||
FrameGeneration::Fsr31 => "FSR 3.1",
|
||||
}
|
||||
}
|
||||
fn parse(s: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|f| f.name() == s)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// What the section mesher does with leaf faces inside a canopy. Terrain
|
||||
/// geometry only: entities, particles and block entities are untouched.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -542,6 +572,8 @@ pub struct Graphics {
|
||||
pub upscaling: Upscaling,
|
||||
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
|
||||
pub screenshot_quality: u32,
|
||||
/// Frame generation (Horizon setting), off by default.
|
||||
pub frame_generation: FrameGeneration,
|
||||
/// HDR output (HDR10, or scRGB), where the display path offers it.
|
||||
pub hdr: bool,
|
||||
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
|
||||
@@ -604,6 +636,7 @@ impl Default for Graphics {
|
||||
taa: false,
|
||||
upscaling: Upscaling::Native,
|
||||
screenshot_quality: 1,
|
||||
frame_generation: FrameGeneration::Off,
|
||||
hdr: false,
|
||||
hdr_paper_white: 0,
|
||||
hdr_peak: 0,
|
||||
@@ -685,6 +718,7 @@ impl Graphics {
|
||||
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
|
||||
.unwrap_or(1)
|
||||
}
|
||||
"frameGeneration" => g.frame_generation = FrameGeneration::parse(value),
|
||||
"hdr" => g.hdr = flag(g.hdr),
|
||||
"hdrPaperWhite" => {
|
||||
g.hdr_paper_white = match number(g.hdr_paper_white) {
|
||||
@@ -722,7 +756,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -753,6 +787,7 @@ impl Graphics {
|
||||
bool_text(self.taa),
|
||||
self.upscaling.name(),
|
||||
self.screenshot_quality,
|
||||
self.frame_generation.name(),
|
||||
bool_text(self.hdr),
|
||||
self.hdr_paper_white,
|
||||
self.hdr_peak,
|
||||
@@ -903,6 +938,7 @@ mod tests {
|
||||
taa: true,
|
||||
upscaling: Upscaling::Balanced,
|
||||
screenshot_quality: 4,
|
||||
frame_generation: FrameGeneration::Fsr31,
|
||||
hdr: true,
|
||||
hdr_paper_white: 240,
|
||||
hdr_peak: 1400,
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! input. How they look comes from `graphics_style`.
|
||||
use crate::{
|
||||
graphics::{
|
||||
EntityCullingMode, FarDetail, GraphicsApi, LeafCullingMode, Overlay, Pacing, PresentMode,
|
||||
Preset, Upscaling,
|
||||
EntityCullingMode, FarDetail, FrameGeneration, GraphicsApi, LeafCullingMode, Overlay,
|
||||
Pacing, PresentMode, Preset, Upscaling,
|
||||
},
|
||||
graphics_style::{Cost, STYLE},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
@@ -46,6 +46,7 @@ pub enum H {
|
||||
Antialiasing,
|
||||
Upscaling,
|
||||
ScreenshotQuality,
|
||||
FrameGeneration,
|
||||
Hdr,
|
||||
/// Opens `Level::Hdr`.
|
||||
HdrSettings,
|
||||
@@ -301,6 +302,11 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
None,
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.",
|
||||
),
|
||||
h(
|
||||
H::FrameGeneration,
|
||||
Medium,
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.",
|
||||
),
|
||||
h(
|
||||
H::HdrSettings,
|
||||
None,
|
||||
@@ -581,6 +587,7 @@ impl H {
|
||||
H::ScreenshotQuality => {
|
||||
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
|
||||
}
|
||||
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
|
||||
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
|
||||
@@ -611,7 +618,7 @@ impl H {
|
||||
),
|
||||
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
|
||||
H::HdrKey => {
|
||||
Text::literal("Shortcut: ").append(crate::keybinds::display_name(&g.hdr_key))
|
||||
Text::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
|
||||
}
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
|
||||
@@ -704,6 +711,9 @@ impl H {
|
||||
back,
|
||||
)
|
||||
}
|
||||
H::FrameGeneration => {
|
||||
g.frame_generation = step(&FrameGeneration::ALL, g.frame_generation, back)
|
||||
}
|
||||
H::Hdr => g.hdr = !g.hdr,
|
||||
H::BatteryMode => g.battery = !g.battery,
|
||||
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
|
||||
@@ -829,6 +839,7 @@ impl Item {
|
||||
| H::Pacing
|
||||
| H::PresentMode
|
||||
| H::Workers
|
||||
| H::FrameGeneration
|
||||
| H::BatteryMode
|
||||
| H::ShadowCache
|
||||
| H::GraphicsApi
|
||||
@@ -1064,9 +1075,12 @@ impl GraphicsUi {
|
||||
let mut label = item.label(s);
|
||||
if item == Item::Horizon(H::HdrKey) && self.binding.is_some() {
|
||||
// KeyBindsScreen: "> key <" while it waits.
|
||||
label = Text::literal("Shortcut: > ")
|
||||
.append(crate::keybinds::display_name(&s.horizon.hdr_key))
|
||||
.append(" <");
|
||||
label = Text::translate(
|
||||
"Shortcut: %s",
|
||||
&[Text::literal("> ")
|
||||
.append(crate::keybinds::display_name(&s.horizon.hdr_key))
|
||||
.append(" <")],
|
||||
);
|
||||
}
|
||||
let blocked = item.blocked(s);
|
||||
let live = blocked.is_none();
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
//! or Escape leaves them as they were.
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
lang,
|
||||
settings::Settings,
|
||||
text::Text,
|
||||
ui,
|
||||
@@ -64,14 +65,19 @@ impl Step {
|
||||
Step::PaperWhite => (10.0, 480.0, 10.0),
|
||||
}
|
||||
}
|
||||
fn name(self) -> &'static str {
|
||||
/// The English name, which is also its key in the language table.
|
||||
fn name_key(self) -> &'static str {
|
||||
match self {
|
||||
Step::Black => "Black Level",
|
||||
Step::Peak => "Peak Brightness",
|
||||
Step::PaperWhite => "Paper White",
|
||||
}
|
||||
}
|
||||
fn hint(self) -> &'static str {
|
||||
fn name(self) -> String {
|
||||
lang::get(self.name_key())
|
||||
}
|
||||
/// The English hint, which is also its key in the language table.
|
||||
fn hint_key(self) -> &'static str {
|
||||
match self {
|
||||
Step::Black => "Lower it until the logo just disappears into the black.",
|
||||
Step::Peak => "Raise it until the logo just disappears into the bright square.",
|
||||
@@ -139,10 +145,16 @@ impl Calibration {
|
||||
}
|
||||
fn label(&self) -> Text {
|
||||
let v = self.values[self.step];
|
||||
Text::literal(match self.current() {
|
||||
Step::Black => format!("Black Level: {v:.2} nits"),
|
||||
step => format!("{}: {v:.0} nits", step.name()),
|
||||
})
|
||||
let step = self.current();
|
||||
let nits = match step {
|
||||
Step::Black => format!("{v:.2}"),
|
||||
_ => format!("{v:.0}"),
|
||||
};
|
||||
Text::literal(format!(
|
||||
"{}: {}",
|
||||
step.name(),
|
||||
lang::tr("%s nits", &[&nits])
|
||||
))
|
||||
}
|
||||
/// The step, its nits and where the controls start (a share of the height), for
|
||||
/// HDR output's test picture.
|
||||
@@ -160,14 +172,12 @@ impl Calibration {
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, rgba(0, 0, 0, 255));
|
||||
batch.fill(atlas, 0.0, l.top, w, h - l.top, rgba(24, 24, 24, 255));
|
||||
let step = self.current();
|
||||
let title = Text::literal(format!(
|
||||
"HDR Calibration {}/{}: {}",
|
||||
self.step + 1,
|
||||
STEPS.len(),
|
||||
step.name()
|
||||
let title = Text::literal(lang::tr(
|
||||
"HDR Calibration %s/%s: %s",
|
||||
&[&(self.step + 1), &STEPS.len(), &step.name()],
|
||||
));
|
||||
ui::centered_text(batch, atlas, &title, 0.0, l.top + 6.0, w, u32::MAX);
|
||||
let hint = Text::literal(step.hint());
|
||||
let hint = Text::literal(lang::get(step.hint_key()));
|
||||
ui::centered_text(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -192,10 +202,12 @@ impl Calibration {
|
||||
self.dragging,
|
||||
);
|
||||
let last = self.step + 1 == STEPS.len();
|
||||
let save = lang::get_or("selectWorld.edit.save", "Save");
|
||||
let next = lang::get("Next");
|
||||
for (rect, label, active) in [
|
||||
(l.cancel, "Cancel", true),
|
||||
(l.back, "Back", self.step > 0),
|
||||
(l.next, if last { "Save" } else { "Next" }, true),
|
||||
(l.cancel, lang::get_or("gui.cancel", "Cancel"), true),
|
||||
(l.back, lang::get_or("gui.back", "Back"), self.step > 0),
|
||||
(l.next, if last { save } else { next }, true),
|
||||
] {
|
||||
let hover = over(cursor, rect) && active;
|
||||
ui::button(
|
||||
|
||||
@@ -215,6 +215,28 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
|
||||
}
|
||||
(name.clone(), bw, bh, out)
|
||||
}
|
||||
/// Folders of `assets/minecraft/textures/` that [`HudAtlas::pack`] walks for
|
||||
/// the full atlas. A pack may add others through `atlases/blocks.json`.
|
||||
const WALKED_TEXTURE_FOLDERS: &[&str] = &[
|
||||
"gui",
|
||||
"item",
|
||||
"block",
|
||||
"font",
|
||||
"environment",
|
||||
"misc",
|
||||
"painting",
|
||||
"entity",
|
||||
"particle",
|
||||
"mob_effect",
|
||||
"map",
|
||||
];
|
||||
|
||||
fn walked_minecraft_texture(name: &str) -> bool {
|
||||
WALKED_TEXTURE_FOLDERS
|
||||
.iter()
|
||||
.any(|folder| name.starts_with(folder) && name.as_bytes().get(folder.len()) == Some(&b'/'))
|
||||
}
|
||||
|
||||
/// Sprites the atlas definitions (`atlases/*.json`) add beyond the texture
|
||||
/// files already packed: generated ones (`paletted_permutations` such as the
|
||||
/// armour trim sprites, `unstitch` regions) and files listed under another id,
|
||||
@@ -242,11 +264,13 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
|
||||
if have.contains(name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
// A vanilla file is packed under its own name already: only a
|
||||
// renamed one is new. Other namespaces' files (content mods) are
|
||||
// never in the walked directories.
|
||||
// A file under a walked folder is packed under its own name already:
|
||||
// only a renamed one is new. Other namespaces' files (content mods)
|
||||
// are never in those folders, nor are extra directories a pack adds
|
||||
// to the blocks atlas (`custom/...`).
|
||||
if let SpriteOrigin::File(texture) = &origin
|
||||
&& texture.strip_prefix("minecraft:") == Some(name.as_str())
|
||||
&& walked_minecraft_texture(name.as_str())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -349,24 +373,7 @@ impl HudAtlas {
|
||||
/// The atlas of a resource root that layers packs over `defaults` (the
|
||||
/// game's own resources), whose textures give the sprites' GUI sizes.
|
||||
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
|
||||
let mut atlas = Self::pack(
|
||||
root,
|
||||
defaults,
|
||||
&[
|
||||
"gui",
|
||||
"item",
|
||||
"block",
|
||||
"font",
|
||||
"environment",
|
||||
"misc",
|
||||
"painting",
|
||||
"entity",
|
||||
"particle",
|
||||
"mob_effect",
|
||||
"map",
|
||||
],
|
||||
true,
|
||||
)?;
|
||||
let mut atlas = Self::pack(root, defaults, WALKED_TEXTURE_FOLDERS, true)?;
|
||||
atlas.text_shader = pack_text::load(root, defaults);
|
||||
Ok(atlas)
|
||||
}
|
||||
@@ -2398,6 +2405,33 @@ mod tests {
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 255, 0, 255]);
|
||||
}
|
||||
#[test]
|
||||
fn a_pack_directory_outside_the_walked_folders_still_packs() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-custom-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let atlases = root.join("assets/minecraft/atlases");
|
||||
let custom = root.join("assets/minecraft/textures/custom");
|
||||
fs::create_dir_all(&atlases).unwrap();
|
||||
fs::create_dir_all(&custom).unwrap();
|
||||
fs::write(
|
||||
atlases.join("blocks.json"),
|
||||
r#"{"sources":[{"type":"directory","source":"custom","prefix":"custom/"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
write_strip(
|
||||
&custom.join("test.png"),
|
||||
16,
|
||||
16,
|
||||
&[[40, 180, 80, 255]],
|
||||
None,
|
||||
);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let rect = atlas.sprites["custom/test"];
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [40, 180, 80, 255]);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
#[test]
|
||||
fn another_namespaces_animated_item_texture_packs_its_first_frame_and_animates() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-ns-anim-{}", std::process::id()));
|
||||
|
||||
@@ -7,6 +7,7 @@ mod fluid;
|
||||
mod leaf_culling;
|
||||
mod lighting;
|
||||
mod sides;
|
||||
use crate::connected::{Pick, TileLayer};
|
||||
use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::Biomes,
|
||||
@@ -18,6 +19,7 @@ use crate::{
|
||||
use horizon_core::{
|
||||
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
|
||||
};
|
||||
use horizon_optifine::ctm::connect::Face as CtmFace;
|
||||
use horizon_world::{
|
||||
block_render::FaceVisibility,
|
||||
fluid::{FluidKind, FluidState},
|
||||
@@ -1017,6 +1019,32 @@ impl<'a> Tints<'a> {
|
||||
layers: Default::default(),
|
||||
}
|
||||
}
|
||||
/// 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 {
|
||||
let id = Self::biome_id(&self.zoom, self.region, self.plains, self.origin, [x, y, z]);
|
||||
&self.biomes.get(id).name
|
||||
}
|
||||
/// `BiomeManager.getBiome` over the loaded chunks (plains where there is none), for block
|
||||
/// `(x, y, z)` of the section at `origin`. Takes the fields it reads, so `get` can call it
|
||||
/// while it holds a layer.
|
||||
fn biome_id(
|
||||
zoom: &BiomeZoom,
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
plains: u16,
|
||||
[cx, cz, base_y]: [i32; 3],
|
||||
[x, y, z]: [i32; 3],
|
||||
) -> u16 {
|
||||
let q = zoom.quart_at_block(BlockPos::new(cx * 16 + x, base_y + y, cz * 16 + z));
|
||||
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
|
||||
if (0..3).contains(&rx) && (0..3).contains(&rz) {
|
||||
region[rz as usize][rx as usize].map_or(plains, |c| {
|
||||
c.biome((q[0] & 3) * 4, q[1] * 4, (q[2] & 3) * 4)
|
||||
})
|
||||
} else {
|
||||
plains
|
||||
}
|
||||
}
|
||||
/// The blended tint of block `(x, y, z)` (section-local x/z, y in 0..16).
|
||||
fn get(&mut self, resolver: Resolver, x: i32, y: i32, z: i32) -> u32 {
|
||||
let r = self.radius;
|
||||
@@ -1032,27 +1060,21 @@ impl<'a> Tints<'a> {
|
||||
if cached != UNSET {
|
||||
return cached;
|
||||
}
|
||||
let [cx, cz, base_y] = self.origin;
|
||||
let (bx, by, bz) = (cx * 16, base_y + y, cz * 16);
|
||||
let [cx, cz, _] = self.origin;
|
||||
let (bx, bz) = (cx * 16, cz * 16);
|
||||
let (mut red, mut green, mut blue) = (0, 0, 0);
|
||||
for dz in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let cell = ((z + dz + r) as usize) * dim + (x + dx + r) as usize;
|
||||
let mut biome = layer.zoomed[cell];
|
||||
if biome == NO_BIOME {
|
||||
// BiomeManager.getBiome over the loaded chunks (plains
|
||||
// where there is none).
|
||||
let q = self
|
||||
.zoom
|
||||
.quart_at_block(BlockPos::new(bx + x + dx, by, bz + z + dz));
|
||||
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
|
||||
biome = if (0..3).contains(&rx) && (0..3).contains(&rz) {
|
||||
self.region[rz as usize][rx as usize].map_or(self.plains, |c| {
|
||||
c.biome((q[0] & 3) * 4, q[1] * 4, (q[2] & 3) * 4)
|
||||
})
|
||||
} else {
|
||||
self.plains
|
||||
};
|
||||
biome = Self::biome_id(
|
||||
&self.zoom,
|
||||
self.region,
|
||||
self.plains,
|
||||
self.origin,
|
||||
[x + dx, y, z + dz],
|
||||
);
|
||||
layer.zoomed[cell] = biome;
|
||||
}
|
||||
let c = match resolver {
|
||||
@@ -1242,7 +1264,8 @@ impl Mesher<'_> {
|
||||
origin,
|
||||
face,
|
||||
ao,
|
||||
state,
|
||||
(id, state),
|
||||
world,
|
||||
interior_leaf,
|
||||
);
|
||||
}
|
||||
@@ -1281,7 +1304,8 @@ impl Mesher<'_> {
|
||||
origin: [f32; 3],
|
||||
face: &BakedFace,
|
||||
ao: bool,
|
||||
state: &Info,
|
||||
(id, state): (u32, &Info),
|
||||
world: BlockPos,
|
||||
interior_leaf: bool,
|
||||
) {
|
||||
let q = &face.quad;
|
||||
@@ -1309,50 +1333,265 @@ impl Mesher<'_> {
|
||||
([lighting::gray(directional); 4], [coords; 4])
|
||||
};
|
||||
let tint = Self::tint(state, q.tint_index, tints, pos);
|
||||
if self.tables.is_animated(face.layer as u16) {
|
||||
out.note_sprite_layer(face.layer as u16);
|
||||
}
|
||||
let translucent = face.translucent
|
||||
|| self
|
||||
.translucent_layers
|
||||
.get(face.layer as usize)
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
let target = if translucent {
|
||||
&mut out.translucent
|
||||
// OptiFine connected textures: another sprite, or one per quarter.
|
||||
let connected = &self.render.connected;
|
||||
let pick = if connected.is_empty() {
|
||||
Pick::Keep
|
||||
} else {
|
||||
&mut out.solid
|
||||
let tints = &*tints;
|
||||
connected.pick(
|
||||
id,
|
||||
face.layer as u16,
|
||||
CtmFace::ALL[q.facing as usize],
|
||||
[world.x, world.y, world.z],
|
||||
|[dx, dy, dz]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)],
|
||||
|| Some(tints.biome(pos[0], pos[1], pos[2])),
|
||||
)
|
||||
};
|
||||
// Opaque leaves (forceOpaque), quads over opaque texels and leaves a
|
||||
// leaf-culling mode proved internal draw in the solid layer: no alpha
|
||||
// cutout. All four vertices of the quad share the layer.
|
||||
let layer = if face.opaque && !translucent
|
||||
|| !self.cutout_leaves && state.leaves
|
||||
|| interior_leaf
|
||||
{
|
||||
face.layer as u16 | OPAQUE_LAYER
|
||||
} else {
|
||||
face.layer as u16
|
||||
};
|
||||
for i in 0..4 {
|
||||
let mut corners = [(TerrainVertex::new([0.0; 3], [0.0; 2], 0, 0, 0), [0.0; 2]); 4];
|
||||
for (i, corner) in corners.iter_mut().enumerate() {
|
||||
let g = gray[i] as u32;
|
||||
let rgb = match tint {
|
||||
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
|
||||
None => [g; 3],
|
||||
};
|
||||
let p = q.positions[i];
|
||||
target.push(TerrainVertex::new(
|
||||
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
|
||||
*corner = (
|
||||
TerrainVertex::new(
|
||||
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
|
||||
q.uv[i],
|
||||
0,
|
||||
emitting(pack_rgb(rgb), tier),
|
||||
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
|
||||
),
|
||||
q.uv[i],
|
||||
);
|
||||
}
|
||||
// Opaque leaves (forceOpaque) and leaves a leaf-culling mode proved internal draw in the
|
||||
// solid layer whatever their sprite.
|
||||
let solid_leaves = !self.cutout_leaves && state.leaves || interior_leaf;
|
||||
let own = TileLayer::Layer {
|
||||
layer: face.layer as u16,
|
||||
translucent: face.translucent,
|
||||
opaque: face.opaque,
|
||||
};
|
||||
// A tile keeps the quad's forced translucency; `<skip>` and `<default>` are its own.
|
||||
let tile = |t: TileLayer| match t {
|
||||
TileLayer::Layer {
|
||||
layer,
|
||||
emitting(pack_rgb(rgb), tier),
|
||||
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
|
||||
));
|
||||
translucent,
|
||||
opaque,
|
||||
} => TileLayer::Layer {
|
||||
layer,
|
||||
translucent: translucent || q.material.force_translucent,
|
||||
opaque,
|
||||
},
|
||||
TileLayer::Skip | TileLayer::Default => own,
|
||||
};
|
||||
match pick {
|
||||
Pick::Keep => self.quad(out, solid_leaves, own, corners.map(|c| c.0)),
|
||||
Pick::Tile(t) => self.quad(out, solid_leaves, tile(t), corners.map(|c| c.0)),
|
||||
Pick::Quarters(tiles) => {
|
||||
let cuts = cuts(&corners);
|
||||
for s in cuts.windows(2) {
|
||||
for t in cuts.windows(2) {
|
||||
let piece = [(s[0], t[0]), (s[1], t[0]), (s[1], t[1]), (s[0], t[1])]
|
||||
.map(|(s, t)| bilinear(&corners, s, t));
|
||||
let quarter = quarter_of(&piece);
|
||||
self.quad(out, solid_leaves, tile(tiles[quarter]), piece.map(|c| c.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// One terrain quad with sprite `tile`, in the pass its alpha needs (`solid_leaves`: no
|
||||
/// alpha cutout whatever the sprite).
|
||||
fn quad(
|
||||
&self,
|
||||
out: &mut SectionMesh,
|
||||
solid_leaves: bool,
|
||||
tile: TileLayer,
|
||||
mut v: [TerrainVertex; 4],
|
||||
) {
|
||||
let TileLayer::Layer {
|
||||
layer,
|
||||
translucent,
|
||||
opaque,
|
||||
} = tile
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.tables.is_animated(layer) {
|
||||
out.note_sprite_layer(layer);
|
||||
}
|
||||
let translucent = translucent
|
||||
|| self
|
||||
.translucent_layers
|
||||
.get(usize::from(layer))
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
// Quads over opaque texels draw in the solid layer: no alpha cutout (a dirt path's side
|
||||
// skips its clear top row). All four vertices share the layer.
|
||||
let layer = if opaque && !translucent || solid_leaves {
|
||||
layer | OPAQUE_LAYER
|
||||
} else {
|
||||
layer
|
||||
};
|
||||
let target = if translucent {
|
||||
&mut out.translucent
|
||||
} else {
|
||||
&mut out.solid
|
||||
};
|
||||
for vertex in &mut v {
|
||||
vertex.layer = layer;
|
||||
}
|
||||
target.extend(v);
|
||||
}
|
||||
}
|
||||
/// The cuts of a `ctm_compact` face, in quad parameters, without allocating: the first `.1`
|
||||
/// of `.0`.
|
||||
struct Cuts([f32; 4], usize);
|
||||
impl std::ops::Deref for Cuts {
|
||||
type Target = [f32];
|
||||
fn deref(&self) -> &[f32] {
|
||||
&self.0[..self.1]
|
||||
}
|
||||
}
|
||||
/// Where a quad's edge from corner 0 to corner `to` (1 for `s`, 3 for `t`) crosses the sprite's
|
||||
/// middle line, the line its quarters meet on; `None` for an edge that stays on one side (a
|
||||
/// slab's side).
|
||||
fn crossing(c: &[(TerrainVertex, [f32; 2]); 4], to: usize) -> Option<f32> {
|
||||
let (a, b) = (c[0].1, c[to].1);
|
||||
// The UV the edge runs along.
|
||||
let k = usize::from((b[1] - a[1]).abs() > (b[0] - a[0]).abs());
|
||||
let at = (0.5 - a[k]) / (b[k] - a[k]);
|
||||
(at > 0.0 && at < 1.0).then_some(at)
|
||||
}
|
||||
/// The cuts of a `ctm_compact` face, the same along `s` and `t`: both edges' crossings. Pieces
|
||||
/// on the quad's 0-2 diagonal are then square, so their own triangles lie on the quad's and a
|
||||
/// split face shades exactly like the whole one; the others lie on one side of it.
|
||||
fn cuts(c: &[(TerrainVertex, [f32; 2]); 4]) -> Cuts {
|
||||
let mut out = Cuts([0.0; 4], 1);
|
||||
let (mut s, mut t) = (crossing(c, 1), crossing(c, 3));
|
||||
if s > t {
|
||||
std::mem::swap(&mut s, &mut t);
|
||||
}
|
||||
for at in [s, t].into_iter().flatten().chain([1.0]) {
|
||||
if out.0[out.1 - 1] < at {
|
||||
out.0[out.1] = at;
|
||||
out.1 += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The quarter of the sprite (top-left, top-right, bottom-left, bottom-right) a piece of a
|
||||
/// quad lies in, by its UV centre (v grows downward).
|
||||
fn quarter_of(piece: &[(TerrainVertex, [f32; 2]); 4]) -> usize {
|
||||
let [u, v] = [0, 1].map(|k| piece.iter().map(|c| c.1[k]).sum::<f32>() / 4.0);
|
||||
usize::from(v >= 0.5) * 2 + usize::from(u >= 0.5)
|
||||
}
|
||||
/// The vertex at `(s, t)` across a quad's corners (`s` from corner 0 to 1, `t` from 0 to 3),
|
||||
/// with its UV in sprite units: a quarter of a `ctm_compact` face. Blended within the quad's
|
||||
/// two triangles (0-1-2 and 0-2-3), as the GPU blends the whole quad, so a split face shades
|
||||
/// like an unsplit one.
|
||||
fn bilinear(c: &[(TerrainVertex, [f32; 2]); 4], s: f32, t: f32) -> (TerrainVertex, [f32; 2]) {
|
||||
let w = if s >= t {
|
||||
[1.0 - s, s - t, t, 0.0]
|
||||
} else {
|
||||
[1.0 - t, 0.0, s, t - s]
|
||||
};
|
||||
let mix = |f: &dyn Fn(&(TerrainVertex, [f32; 2])) -> f32| {
|
||||
(0..4).map(|i| w[i] * f(&c[i])).sum::<f32>()
|
||||
};
|
||||
// Packed bytes (colour, light) blend one by one.
|
||||
let bytes = |field: fn(&TerrainVertex) -> u32, count: u32| {
|
||||
(0..count)
|
||||
.map(|k| (mix(&|c| (field(&c.0) >> (8 * k) & 255) as f32).round() as u32) << (8 * k))
|
||||
.sum::<u32>()
|
||||
};
|
||||
let color = bytes(|v| v.color, 4);
|
||||
let light = bytes(|v| u32::from(v.light), 2) as u16;
|
||||
let pos = [0, 1, 2].map(|k| mix(&|c| c.0.pos[k]));
|
||||
let uv = [0, 1].map(|k| mix(&|c| c.1[k]));
|
||||
(TerrainVertex::new(pos, uv, 0, color, light), uv)
|
||||
}
|
||||
/// Snapshot of the 3x3 chunk neighbourhood used by a worker job.
|
||||
pub type Region = [[Option<Arc<Chunk>>; 3]; 3];
|
||||
pub fn borrow(region: &Region) -> [[Option<&Chunk>; 3]; 3] {
|
||||
std::array::from_fn(|z| std::array::from_fn(|x| region[z][x].as_deref()))
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod quarter_tests {
|
||||
use super::*;
|
||||
|
||||
fn corner(pos: [f32; 3], uv: [f32; 2], red: u32, light: u16) -> (TerrainVertex, [f32; 2]) {
|
||||
(TerrainVertex::new(pos, uv, 0, 0xFF00_0000 | red, light), uv)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quarters_blend_the_corners_by_triangle() {
|
||||
// Only corner 2 is lit: the GPU's triangles (0-1-2, 0-2-3) put half of it in the
|
||||
// middle, where a bilinear blend would put a quarter and the other diagonal none.
|
||||
let c = [
|
||||
corner([0.0, 0.0, 0.0], [0.0, 0.0], 0, 0),
|
||||
corner([1.0, 0.0, 0.0], [1.0, 0.0], 0, 0),
|
||||
corner([1.0, 1.0, 0.0], [1.0, 1.0], 200, 0x2010),
|
||||
corner([0.0, 1.0, 0.0], [0.0, 1.0], 0, 0),
|
||||
];
|
||||
let (centre, uv) = bilinear(&c, 0.5, 0.5);
|
||||
assert_eq!(centre.pos, [0.5, 0.5, 0.0]);
|
||||
assert_eq!(uv, [0.5, 0.5]);
|
||||
assert_eq!(centre.color, 0xFF00_0000 | 100);
|
||||
assert_eq!(centre.light, 0x1008, "sky and block light blend apart");
|
||||
// Off the diagonal, on the 0-1-2 triangle: corner 2 weighs t.
|
||||
assert_eq!(bilinear(&c, 0.75, 0.25).0.color & 255, 50);
|
||||
assert_eq!(bilinear(&c, 0.5, 0.0).0.pos, [0.5, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_faces_cut_where_the_sprite_does() {
|
||||
// A full face: cut in the middle both ways.
|
||||
let full = [
|
||||
corner([0.0, 1.0, 0.0], [0.0, 0.0], 0, 0),
|
||||
corner([1.0, 1.0, 0.0], [1.0, 0.0], 0, 0),
|
||||
corner([1.0, 0.0, 0.0], [1.0, 1.0], 0, 0),
|
||||
corner([0.0, 0.0, 0.0], [0.0, 1.0], 0, 0),
|
||||
];
|
||||
assert_eq!(*cuts(&full), [0.0, 0.5, 1.0]);
|
||||
// A dirt path's side, v from 1/16: the sprite's middle is 7/15 of the way down; both
|
||||
// directions are cut there and at the middle of u.
|
||||
let mut path = full;
|
||||
path[0].1 = [0.0, 1.0 / 16.0];
|
||||
path[1].1 = [1.0, 1.0 / 16.0];
|
||||
assert_eq!(
|
||||
*cuts(&path),
|
||||
[0.0, (0.5 - 1.0 / 16.0) / (15.0 / 16.0), 0.5, 1.0]
|
||||
);
|
||||
// A slab's lower side (v 0.5..1) crosses the middle only along u.
|
||||
let mut slab = full;
|
||||
slab[0].1 = [0.0, 0.5];
|
||||
slab[1].1 = [1.0, 0.5];
|
||||
assert_eq!(crossing(&slab, 3), None);
|
||||
assert_eq!(*cuts(&slab), [0.0, 0.5, 1.0]);
|
||||
// Quarters by UV centre: top-left, top-right, bottom-left, bottom-right.
|
||||
let piece = |s0: f32, t0: f32| {
|
||||
[
|
||||
(s0, t0),
|
||||
(s0 + 0.5, t0),
|
||||
(s0 + 0.5, t0 + 0.5),
|
||||
(s0, t0 + 0.5),
|
||||
]
|
||||
.map(|(s, t)| bilinear(&full, s, t))
|
||||
};
|
||||
assert_eq!(
|
||||
[
|
||||
piece(0.0, 0.0),
|
||||
piece(0.5, 0.0),
|
||||
piece(0.0, 0.5),
|
||||
piece(0.5, 0.5)
|
||||
]
|
||||
.map(|p| quarter_of(&p)),
|
||||
[0, 1, 2, 3]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1801,7 +1801,7 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
|
||||
};
|
||||
let mut tip = None;
|
||||
if let Act::Extra(item) = act {
|
||||
tip = Some(Text::literal(item.description()));
|
||||
tip = Some(Text::literal(item.description_text()));
|
||||
}
|
||||
if let Act::Push(page) = act
|
||||
&& let Some(text) = horizon_page_tooltip(*page)
|
||||
@@ -1976,7 +1976,7 @@ fn pairs(opts: &[Opt]) -> Vec<Row> {
|
||||
impl OptionsUi {
|
||||
/// A switch of `horizon-extras.txt` as the Extras page draws it: "Caption: On".
|
||||
fn extra_button(&self, s: &Settings, item: crate::extras::Item) -> Widget {
|
||||
let label = format!("{}: {}", item.caption(), item.value(&s.extras));
|
||||
let label = format!("{}: {}", item.caption_text(), item.value(&s.extras));
|
||||
button(0.0, 0.0, 150.0, Text::literal(label), Act::Extra(item))
|
||||
}
|
||||
fn settings_search_active(&self) -> bool {
|
||||
@@ -2274,7 +2274,7 @@ impl OptionsUi {
|
||||
// Each section's switches two to a row, as vanilla's
|
||||
// OptionsList.addSmall lays out an options screen.
|
||||
let extra = |item: crate::extras::Item| {
|
||||
let value = format!("{}: {}", item.caption(), item.value(&s.extras));
|
||||
let value = format!("{}: {}", item.caption_text(), item.value(&s.extras));
|
||||
let label = if item.key_name().is_some() && self.selected_key == item.key_name()
|
||||
{
|
||||
Text::literal(format!("> {value} <")).with_color(text::YELLOW)
|
||||
|
||||
@@ -13,6 +13,11 @@ use crate::{
|
||||
ui,
|
||||
};
|
||||
|
||||
/// A string of this page: the language table's, or the English text.
|
||||
fn tr(key: &str, english: &str) -> String {
|
||||
lang::get_or(key, english)
|
||||
}
|
||||
|
||||
/// Leave room for navigation, creation and cancellation in a 240-pixel menu.
|
||||
const SHOWN: usize = 4;
|
||||
pub mod store;
|
||||
@@ -132,7 +137,12 @@ impl SplitProfiles {
|
||||
};
|
||||
if let Some(name) = &self.forgetting {
|
||||
return vec![
|
||||
button(120.0, "Forget".into(), self.editable(name), Target::Forget),
|
||||
button(
|
||||
120.0,
|
||||
tr("horizon.splitscreen.profiles.forget", "Forget"),
|
||||
self.editable(name),
|
||||
Target::Forget,
|
||||
),
|
||||
button(
|
||||
144.0,
|
||||
lang::get_or("gui.cancel", "Cancel"),
|
||||
@@ -147,7 +157,12 @@ impl SplitProfiles {
|
||||
let ok = valid_name(name) && !self.names.iter().any(|n| n.eq_ignore_ascii_case(name));
|
||||
let top = field_top(h);
|
||||
return vec![
|
||||
button(top + 25.0, "Create".into(), ok, Target::Create),
|
||||
button(
|
||||
top + 25.0,
|
||||
tr("horizon.splitscreen.profiles.create", "Create"),
|
||||
ok,
|
||||
Target::Create,
|
||||
),
|
||||
button(
|
||||
top + 50.0,
|
||||
lang::get_or("gui.back", "Back"),
|
||||
@@ -166,7 +181,7 @@ impl SplitProfiles {
|
||||
{
|
||||
let mut label = name.clone();
|
||||
if self.current.as_ref() == Some(name) {
|
||||
label.push_str(" - you");
|
||||
label.push_str(&tr("horizon.splitscreen.profiles.you", " - you"));
|
||||
}
|
||||
let y = 44.0 + (i % SHOWN) as f32 * 24.0;
|
||||
buttons.push((
|
||||
@@ -177,7 +192,8 @@ impl SplitProfiles {
|
||||
));
|
||||
buttons.push((
|
||||
[x + 180.0, y, 20.0, 20.0],
|
||||
format!("Forget {name}"),
|
||||
tr("horizon.splitscreen.profiles.forget_named", "Forget %s")
|
||||
.replacen("%s", name, 1),
|
||||
self.editable(name),
|
||||
Target::Remove(i),
|
||||
));
|
||||
@@ -186,19 +202,24 @@ impl SplitProfiles {
|
||||
if self.names.len() > SHOWN {
|
||||
buttons.push((
|
||||
[x, y, 98.0, 20.0],
|
||||
"Previous".into(),
|
||||
tr("horizon.splitscreen.profiles.previous", "Previous"),
|
||||
self.page() > 0,
|
||||
Target::Previous,
|
||||
));
|
||||
buttons.push((
|
||||
[x + 102.0, y, 98.0, 20.0],
|
||||
"Next".into(),
|
||||
tr("horizon.splitscreen.profiles.next", "Next"),
|
||||
(self.page() + 1) * SHOWN < self.names.len(),
|
||||
Target::Next,
|
||||
));
|
||||
y += 24.0;
|
||||
}
|
||||
buttons.push(button(y, "New Profile...".into(), true, Target::New));
|
||||
buttons.push(button(
|
||||
y,
|
||||
tr("horizon.splitscreen.profiles.new_button", "New Profile..."),
|
||||
true,
|
||||
Target::New,
|
||||
));
|
||||
let cancel = lang::get_or("gui.cancel", "Cancel");
|
||||
buttons.push(button(y + 24.0, cancel, true, Target::Cancel));
|
||||
buttons
|
||||
@@ -220,30 +241,71 @@ impl SplitProfiles {
|
||||
};
|
||||
let buttons = self.buttons(w, h);
|
||||
if let Some(name) = &self.forgetting {
|
||||
STYLE.title(batch, atlas, &Text::literal("Forget profile?"));
|
||||
STYLE.title(
|
||||
batch,
|
||||
atlas,
|
||||
&Text::literal(tr(
|
||||
"horizon.splitscreen.profiles.forget_title",
|
||||
"Forget profile?",
|
||||
)),
|
||||
);
|
||||
line(batch, name, 52.0, u32::MAX);
|
||||
line(batch, "It leaves this instance's list.", 76.0, grey);
|
||||
line(batch, "World progress and settings are kept.", 90.0, grey);
|
||||
line(
|
||||
batch,
|
||||
&tr(
|
||||
"horizon.splitscreen.profiles.forget_leaves",
|
||||
"It leaves this instance's list.",
|
||||
),
|
||||
76.0,
|
||||
grey,
|
||||
);
|
||||
line(
|
||||
batch,
|
||||
&tr(
|
||||
"horizon.splitscreen.profiles.forget_kept",
|
||||
"World progress and settings are kept.",
|
||||
),
|
||||
90.0,
|
||||
grey,
|
||||
);
|
||||
if let Some(why) = &self.refused {
|
||||
line(batch, why, 176.0, rgba(255, 85, 85, 255));
|
||||
}
|
||||
} else if let Some(field) = &mut self.new {
|
||||
STYLE.title(batch, atlas, &Text::literal("New Profile"));
|
||||
STYLE.title(
|
||||
batch,
|
||||
atlas,
|
||||
&Text::literal(tr("horizon.splitscreen.profiles.new_title", "New Profile")),
|
||||
);
|
||||
let (x, top) = ((w / 2.0).floor() - 100.0, field_top(h));
|
||||
let label = "Name (3 to 16 letters, digits, _)";
|
||||
batch.text(atlas, label, x, top - 14.0, grey, true);
|
||||
let label = tr(
|
||||
"horizon.splitscreen.profiles.name_hint",
|
||||
"Name (3 to 16 letters, digits, _)",
|
||||
);
|
||||
batch.text(atlas, &label, x, top - 14.0, grey, true);
|
||||
(field.x, field.y, field.width, field.height) = (x, top, 200.0, 20.0);
|
||||
field.draw(batch, atlas, None);
|
||||
} else {
|
||||
let title = match self.current {
|
||||
Some(_) => "Switch profile",
|
||||
None => "Who's playing?",
|
||||
Some(_) => tr(
|
||||
"horizon.splitscreen.profiles.switch_title",
|
||||
"Switch profile",
|
||||
),
|
||||
None => tr("horizon.pages.split_profiles", "Who's playing?"),
|
||||
};
|
||||
STYLE.title(batch, atlas, &Text::literal(title));
|
||||
self.draw_bumpers(batch, atlas, style);
|
||||
// Under Cancel: where offline profiles live.
|
||||
let y = buttons.last().map_or(0.0, |b| b.0[1] + 30.0);
|
||||
line(batch, "Offline profiles belong to this instance", y, grey);
|
||||
line(
|
||||
batch,
|
||||
&tr(
|
||||
"horizon.splitscreen.profiles.offline_note",
|
||||
"Offline profiles belong to this instance",
|
||||
),
|
||||
y,
|
||||
grey,
|
||||
);
|
||||
// Why the last pick can't play, wrapped to the view (a quarter of a window is narrow).
|
||||
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
|
||||
let why = self.refused.as_deref().unwrap_or_default();
|
||||
@@ -308,8 +370,12 @@ impl SplitProfiles {
|
||||
fn draw_bumpers(&self, batch: &mut HudBatch, atlas: &HudAtlas, style: GlyphStyle) {
|
||||
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
|
||||
let words = match &self.place {
|
||||
Some(place) => format!("Move where you play: {place}"),
|
||||
None => "Move where you play".to_owned(),
|
||||
Some(place) => tr(
|
||||
"horizon.splitscreen.profiles.move_place",
|
||||
"Move where you play: %s",
|
||||
)
|
||||
.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| width(b.label(style)) + 4.0;
|
||||
|
||||
@@ -6,8 +6,12 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
description = "Horizon native development client"
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
|
||||
[dependencies]
|
||||
horizon-client-lib = { path = "../horizon-client-lib", default-features = false }
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-client-input = { path = "../horizon-client-input" }
|
||||
@@ -15,6 +19,8 @@ horizon-client-render = { path = "../horizon-client-render" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# OptiFine-format pack files: custom entity models, random entity textures.
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
@@ -36,7 +42,6 @@ horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
# Their runtime is the `mods` feature.
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
@@ -104,20 +109,23 @@ winresource = "0.1"
|
||||
default = ["audio", "controllers"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio"]
|
||||
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 = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
windows-mimalloc = []
|
||||
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
|
||||
windows-segment-heap = []
|
||||
|
||||
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
|
||||
[[example]]
|
||||
name = "client_probe"
|
||||
path = "examples/probe/main.rs"
|
||||
[[bin]]
|
||||
name = "horizon-client"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[test]]
|
||||
name = "launch_api"
|
||||
path = "tests/launch_api.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
{
|
||||
"horizon.options.controller": "Controller",
|
||||
"horizon.options.vibration": "Vibration",
|
||||
"horizon.options.adaptive_triggers": "Adaptive Trigger",
|
||||
"horizon.options.lightbar": "Lightbar",
|
||||
"horizon.options.look_sensitivity": "Blickempfindlichkeit",
|
||||
"horizon.options.invert_x": "Blick-X-Achse umkehren",
|
||||
"horizon.options.invert_y": "Blick-Y-Achse umkehren",
|
||||
"horizon.options.sneak_button": "Schleichtaste",
|
||||
"horizon.options.move_deadzone": "Totzone der Bewegung",
|
||||
"horizon.options.look_deadzone": "Totzone des Blicks",
|
||||
"horizon.options.button_glyphs": "Tastensymbole",
|
||||
"horizon.options.menu_navigation": "Menünavigation",
|
||||
"horizon.options.on_screen_keyboard": "Bildschirmtastatur",
|
||||
"horizon.options.chat_delay": "Chatverzögerung",
|
||||
"horizon.options.lightbar.health": "Gesundheit",
|
||||
"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.menu_focus": "Fokus",
|
||||
"horizon.options.menu_pointer": "Zeiger",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Diese Funktion ist in Horizon noch nicht verfügbar.",
|
||||
"horizon.options.search": "Einstellungen durchsuchen …",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Shader-Pakete",
|
||||
"horizon.pages.shader_options": "Shader-Optionen",
|
||||
"horizon.pages.controller": "Controller-Einstellungen",
|
||||
"horizon.pages.controller_binds": "Controller-Tastenbelegung",
|
||||
"horizon.pages.extras": "Horizon-Extras",
|
||||
"horizon.pages.settings": "Horizon-Einstellungen",
|
||||
"horizon.pages.agent": "KI-Agent",
|
||||
"horizon.pages.split_profiles": "Wer spielt?",
|
||||
"horizon.pages.settings.tooltip": "Horizons eigene Einstellungen: Grafik, Controller, Extras und Mods.",
|
||||
"horizon.pages.graphics.tooltip": "Grafik, einfach: Jede Einstellung zeigt, was sie kostet.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Alle Grafikeinstellungen, auch die von Horizon, mit den Kosten jeder einzelnen.",
|
||||
"horizon.pages.controller.tooltip": "Controller: ihre Tasten, Sticks, Vibration und Menünavigation.",
|
||||
"horizon.pages.extras.tooltip": "Optionale Zusätze, alle standardmäßig aus: Das Spiel bleibt Vanilla, bis du einen einschaltest.",
|
||||
"horizon.pages.agent.tooltip": "Lass deinen eigenen KI-Agenten spielen: horizon-ctl und MCP-Clients auf diesem Computer.",
|
||||
"horizon.pages.split_profiles.tooltip": "Wer spielt: ein Profil, das in diesem Spielordner gespeichert ist.",
|
||||
"horizon.pages.shader_packs.tooltip": "Shader-Pakete aus dem Ordner shaderpacks: die von Horizon selbst und die im Format shaders/. Es läuft immer nur eines.",
|
||||
"horizon.pages.mods.tooltip": "Mods für Horizon aus dem Ordner horizon-mods. Jede läuft in einer Sandbox und bleibt aus, bis du sie einschaltest.",
|
||||
"horizon.pages.open.graphics": "Grafik …",
|
||||
"horizon.pages.open.graphics_advanced": "Erweiterte Grafik …",
|
||||
"horizon.pages.open.shader_packs": "Shader-Pakete …",
|
||||
"horizon.pages.open.controller": "Controller-Einstellungen …",
|
||||
"horizon.pages.open.extras": "Extras …",
|
||||
"horizon.pages.open.mods": "Mods …",
|
||||
"horizon.pages.open.agent": "KI-Agent …",
|
||||
"horizon.pages.open.horizon_graphics": "Horizon-Grafik …",
|
||||
"horizon.pages.open.controller_binds": "Controller-Belegung …",
|
||||
"horizon.pages.open.horizon_settings": "Horizon-Einstellungen …",
|
||||
"horizon.mods.empty": "Keine Mods: Lege .hmod-Dateien in den Ordner horizon-mods",
|
||||
"horizon.mods.stopped": " (angehalten)",
|
||||
"horizon.mods.not_built": " (nicht gebaut)",
|
||||
"horizon.mods.dev": " (Entwicklung)",
|
||||
"horizon.mods.settings": "Einstellungen",
|
||||
"horizon.mods.open_folder": "Modordner öffnen",
|
||||
"horizon.shader_packs.empty": "Keine Shader-Pakete: Lege sie in den Ordner shaderpacks",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.shader_options.open": "Shader-Optionen …",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"horizon.shader_options.loading": "Optionen des Shader-Pakets werden gelesen …",
|
||||
"horizon.agent.copy_details": "Verbindungsdaten kopieren",
|
||||
"horizon.multiplayer.join": "Beitreten",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Neuverbindung abbrechen",
|
||||
"horizon.multiplayer.join_friend": "Freund beitreten",
|
||||
"horizon.multiplayer.invite_code": "Einladungscode",
|
||||
"horizon.multiplayer.relay": "Relay (optional)",
|
||||
"Preset": "Preset",
|
||||
"LOD Distance": "LOD-Weite",
|
||||
"Far Detail": "Ferndetails",
|
||||
"Cave Culling": "Höhlen-Culling",
|
||||
"Entity Culling": "Objekt-Culling",
|
||||
"Chunk Updates": "Chunk-Updates",
|
||||
"Upload Budget": "Upload-Budget",
|
||||
"Sky": "Himmel",
|
||||
"Fog": "Nebel",
|
||||
"Weather": "Wetter",
|
||||
"Animated Textures": "Animierte Texturen",
|
||||
"Present Mode": "Ausgabemodus",
|
||||
"Frame Pacing": "Bildtakt",
|
||||
"Worker Threads": "Arbeitsthreads",
|
||||
"Resolution Scale": "Auflösungsskalierung",
|
||||
"Anti-aliasing": "Kantenglättung",
|
||||
"Upscaling": "Upscaling",
|
||||
"HDR Settings...": "HDR-Einstellungen …",
|
||||
"Paper White": "Papierweiß",
|
||||
"Peak Brightness": "Spitzenhelligkeit",
|
||||
"Black Level": "Schwarzwert",
|
||||
"UI Brightness": "GUI-Helligkeit",
|
||||
"Battery Mode": "Akkumodus",
|
||||
"Graphics API": "Grafik-API",
|
||||
"Performance Overlay": "Leistungsanzeige",
|
||||
"Full Detail Distance": "Detailweite",
|
||||
"On": "An",
|
||||
"Off": "Aus",
|
||||
"Unlimited": "Unbegrenzt",
|
||||
"Auto": "Auto",
|
||||
"Custom": "Benutzerdefiniert",
|
||||
"Performance": "Leistung",
|
||||
"Balanced": "Ausgewogen",
|
||||
"Quality": "Qualität",
|
||||
"Ultra": "Ultra",
|
||||
"Ultra Performance": "Ultra-Leistung",
|
||||
"Native": "Nativ",
|
||||
"Full": "Voll",
|
||||
"Reduced": "Reduziert",
|
||||
"Efficient": "Sparsam",
|
||||
"Precise": "Präzise",
|
||||
"Auto (VSync)": "Auto (VSync)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "Mailbox",
|
||||
"Immediate": "Immediate",
|
||||
"Vulkan": "Vulkan",
|
||||
"DirectX 12": "DirectX 12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS und Diagramm",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "Keine Kosten",
|
||||
"Low cost": "Geringe Kosten",
|
||||
"Medium cost": "Mittlere Kosten",
|
||||
"High cost": "Hohe Kosten",
|
||||
"Saves time": "Spart Zeit",
|
||||
"Rendering": "Rendering",
|
||||
"Display": "Anzeige",
|
||||
"Preferences": "Präferenzen",
|
||||
"Overlay": "Einblendung",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "Grafik",
|
||||
"Graphics: Advanced": "Grafik: Erweitert",
|
||||
"Graphics: HDR": "Grafik: HDR",
|
||||
"Advanced...": "Erweitert …",
|
||||
"Simple...": "Einfach …",
|
||||
"Done": "Fertig",
|
||||
"This feature is not yet available in Horizon.": "Diese Funktion ist in Horizon noch nicht verfügbar.",
|
||||
"Your display or system doesn't offer HDR output.": "Dein Bildschirm oder System unterstützt keine HDR-Ausgabe.",
|
||||
"Turn HDR on to use it.": "Schalte HDR ein, um es zu nutzen.",
|
||||
"%s chunks": "%s Chunks",
|
||||
"%s per frame": "%s pro Bild",
|
||||
"%s MiB/frame": "%s MiB/Bild",
|
||||
"%s KiB/frame": "%s KiB/Bild",
|
||||
"%s nits": "%s Nits",
|
||||
"Auto (%s)": "Auto (%s)",
|
||||
"Auto (%s nits)": "Auto (%s Nits)",
|
||||
"%.2f nits": "%.2f Nits",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Gelände mit vollen Details. Die LOD-Entfernung reicht weiter.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Grobes fernes Gelände in lokalen Welten. Integrierte GPUs: max. 128.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reduziert: fernes Gelände gröber, schneller auf dieser GPU.",
|
||||
"Reduced draws far terrain coarser, for GPUs it slows down.": "Reduziert: fernes Gelände gröber, für GPUs, die sonst bremsen.",
|
||||
"Field of view. Wider shows more terrain at once.": "Sichtfeld. Ein größeres zeigt mehr Gelände auf einmal.",
|
||||
"Caps frames per second; a cap saves power and heat.": "Begrenzt die Bilder pro Sekunde; spart Strom und Wärme.",
|
||||
"Waits for the display's refresh: no tearing, a little latency.": "Wartet auf den Bildaufbau: kein Tearing, etwas Latenz.",
|
||||
"Size of menus and the HUD.": "Größe der Menüs und des HUDs.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Stellt Optionen darunter ein. Änderung = „Benutzerdefiniert“.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, Feuer, Sonne heller als Weiß. Braucht HDR-Bildschirm.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Das dunkelste Schwarz deines Bildschirms. 0 für OLED; LCDs leuchten ein wenig.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Wie hell Lichter werden. Auto: der Spitzenwert, den dein System meldet.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Wie hell weißes Papier und Menüs aussehen. Auto: das Weiß deines Desktops.",
|
||||
"How bright the HUD and menus are, against paper white.": "Wie hell HUD und Menüs im Vergleich zu Papierweiß sind.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Wie weit Kreaturen, Pflanzen und Maschinen weiterarbeiten. Kostet CPU-Zeit.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Zeichenweite für Kreaturen und Gegenstände, anteilig zur Norm.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Überspringt Gelände hinter geschlossenen Abschnitten. Aus kostet viel.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Versteckt Modelle hinter Blöcken. Bei eigenen Paketen ausschalten.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Mesh-Abschnitte pro Bild. Weniger: flüssigeres Laden.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Weiche Schatten in Ecken. Kostet nur Zeit beim Chunk-Aufbau.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Kleinere Textur-Kopien für fernes Gelände: weniger Flimmern.",
|
||||
"Blends grass and water colours across biomes when building chunks.": "Mischt Gras- und Wasserfarben über Biome hinweg beim Chunk-Aufbau.",
|
||||
"Cloud layer: off, flat (fast) or 3D (fancy).": "Wolkenschicht: aus, flach (schnell) oder 3D (schön).",
|
||||
"How far clouds reach, in chunks.": "Wie weit Wolken reichen, in Chunks.",
|
||||
"Draws the sky, sun, moon and stars.": "Zeichnet Himmel, Sonne, Mond und Sterne.",
|
||||
"Distance fog. Off shows the edge of the loaded world.": "Nebel in der Ferne. Aus zeigt den Rand der geladenen Welt.",
|
||||
"Draws rain and snow.": "Zeichnet Regen und Schnee.",
|
||||
"How far rain and snow fall around you.": "Wie weit um dich herum Regen und Schnee fallen.",
|
||||
"How many particles effects spawn.": "Wie viele Partikel Effekte erzeugen.",
|
||||
"Round shadows under mobs and items.": "Runde Schatten unter Kreaturen und Gegenständen.",
|
||||
"Leaves with see-through gaps. Off draws them solid.": "Laub mit durchsichtigen Lücken. Aus zeichnet es massiv.",
|
||||
"Sorts water and glass more exactly behind each other.": "Sortiert Wasser und Glas hintereinander genauer.",
|
||||
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Welche Chunk-Neuaufbauten auf das Bild warten: keine, nahe oder deine.",
|
||||
"Animates water, lava, fire and other moving textures.": "Animiert Wasser, Lava, Feuer und andere bewegte Texturen.",
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Weltpixel pro Bildschirmpixel. Unter 100 % ist es auf der GPU schneller.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Glättet Kanten. FXAA: weicher. TAA: am glattesten, evtl. Schlieren.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Rendert die Welt kleiner und baut sie mit TAA wieder auf. Schneller, weicher.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-Ausgabe auf HDR-Bildschirm: Lava, Feuer, Sonne heller.",
|
||||
"How frames reach the screen while VSync is off.": "Wie Bilder auf den Bildschirm gelangen, wenn VSync aus ist.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Sparsam schläft bis zum nächsten Bild; Präzise wartet aktiv, wie in Vanilla.",
|
||||
"Fills the screen (borderless).": "Füllt den Bildschirm (randlos).",
|
||||
"Brightens dark places.": "Hellt dunkle Orte auf.",
|
||||
"Lowers the frame rate when minimised, or idle too.": "Senkt die Bildrate bei Minimierung oder auch bei Inaktivität.",
|
||||
"Sways the view while walking.": "Lässt die Ansicht beim Gehen schwanken.",
|
||||
"Fades newly loaded chunks in.": "Blendet neu geladene Chunks ein.",
|
||||
"Darkens the screen's edges.": "Dunkelt die Bildschirmränder ab.",
|
||||
"Shows when the world saves.": "Zeigt an, wann die Welt gespeichert wird.",
|
||||
"Shows attack cooldown.": "Zeigt die Angriffsabklingzeit an.",
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS (30 im Menü/ohne Fokus), halb so viele Worker: spart Strom.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Threads für Erzeugung, Licht und Mesh der Chunks. Nach Neustart.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sonst DirectX 12 (Windows) oder OpenGL (Linux). Nach Neustart.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Pro Bild an die GPU gesendete Mesh-Daten. Weniger ist flüssiger beim Laden.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Bildrate und Bildzeiten über dem Spiel, mit Diagramm.",
|
||||
"horizon.multiplayer.finding_friend": "Die Welt deines Freundes wird gesucht …",
|
||||
"horizon.loading.opening_world": "Welt wird geöffnet",
|
||||
"horizon.loading.cancelling_world": "Wird abgebrochen",
|
||||
"horizon.title.not_affiliated": "Nicht mit Mojang oder Microsoft verbunden",
|
||||
"horizon.extras.header.lighting": "Beleuchtung",
|
||||
"horizon.extras.header.third_person": "Dritte Person",
|
||||
"horizon.extras.header.inventory": "Inventar",
|
||||
"horizon.extras.header.multiplayer": "Mehrspieler",
|
||||
"horizon.extras.fullbright": "Maximale Helligkeit",
|
||||
"horizon.extras.dynamic_lights": "Dynamisches Licht",
|
||||
"horizon.extras.own_name_tag": "Eigenes Namensschild",
|
||||
"horizon.extras.auto_reconnect": "Neu verbinden",
|
||||
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
|
||||
"horizon.extras.mouse_tweaks_wheel": "Mausrad-Verschieben",
|
||||
"horizon.extras.sort": "Sortier-Schaltflächen",
|
||||
"horizon.extras.sort_key": "Sortiertaste",
|
||||
"horizon.extras.no_ai": "Keine KI",
|
||||
"horizon.extras.agent_control": "Agentensteuerung",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.fullbright.tooltip": "Zeigt Blöcke und Objekte bei vollem Licht, selbst an dunklen Orten.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Fackeln und andere Lichtquellen, die gehalten, fallen gelassen werden oder brennen, beleuchten die Blöcke um sich herum.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Zeigt deinen Namen in der dritten Person über deinem Kopf an, so wie andere Spieler ihn sehen. Versteckt, wenn der Server die Namen deines Teams verbirgt oder du unsichtbar bist.",
|
||||
"horizon.extras.auto_reconnect.tooltip": "Verbindet nach einem Verbindungsabbruch oder Neustart erneut mit dem Server, nie nach einem Rauswurf oder Bann.",
|
||||
"horizon.extras.mouse_tweaks.tooltip": "Mit Rechtsklick ziehen legt je einen pro Feld ab, mit Linksklick ziehen sammelt den gehaltenen Gegenstand, Umschalt + Linksklick ziehen verschiebt jedes Feld.",
|
||||
"horizon.extras.mouse_tweaks_wheel.tooltip": "Nach unten schickt einen Gegenstand des Feldes auf die andere Seite, nach oben holt einen zurück.",
|
||||
"horizon.extras.sort.tooltip": "Sortiert Inventar, Truhen, Fässer, Endertruhen und Shulker-Kisten in der Reihenfolge der Kreativ-Tabs und fasst Stapel zusammen.",
|
||||
"horizon.extras.sort_key.tooltip": "Sortiert den Bereich unter dem Mauszeiger. Klicke, dann drücke eine Taste; Esc hebt die Belegung auf.",
|
||||
"horizon.extras.no_ai.tooltip": "Blendet alle KI-Funktionen aus und schaltet sie ab: Agentensteuerung, horizon-ctl und die MCP-Server. Auch den Schalter des Launchers.",
|
||||
"horizon.extras.agent_control.tooltip": "Dein eigener KI-Agent kann dieses Spiel über horizon-ctl oder einen MCP-Client spielen, nur auf diesem Computer.",
|
||||
"horizon.extras.discord_presence.tooltip": "Zeigt „Spielt Horizon“ in deinem Discord-Profil an, solange Discord läuft: Menüs, Einzel- oder Mehrspielermodus, die Dimension und die Spielzeit. Nie den Namen einer Welt oder die Adresse eines Servers.",
|
||||
"Calibrate...": "Kalibrieren …",
|
||||
"Calibrate Anyway...": "Trotzdem kalibrieren …",
|
||||
"Use Automatic Setup": "Automatische Einrichtung verwenden",
|
||||
"Shortcut: %s": "Tastenkürzel: %s",
|
||||
"Next": "Weiter",
|
||||
"HDR Calibration %s/%s: %s": "HDR-Kalibrierung %s/%s: %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Stellt die drei Werte darunter im Vollbild ein, jeweils mit einem Testbild.",
|
||||
"A key that opens this screen from the game. Not bound at first.": "Eine Taste, die diesen Bildschirm im Spiel öffnet. Zunächst nicht belegt.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Bildschirm meldete keine Helligkeit: zum Einrichten kalibrieren.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Bereits eingerichtet. Nur kalibrieren, wenn HDR falsch aussieht.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Stellt Spitzenhelligkeit und Papierweiß des Bildschirms wieder her.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Für den Bildschirm eingerichtet. Auto lassen, wenn es passt.",
|
||||
"Lower it until the logo just disappears into the black.": "Verringere den Wert, bis das Logo gerade im Schwarz verschwindet.",
|
||||
"Raise it until the logo just disappears into the bright square.": "Erhöhe den Wert, bis das Logo gerade im hellen Quadrat verschwindet.",
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Wähle ein Weiß, das bei deinem Licht lange angenehm ist.",
|
||||
"horizon.splitscreen.profiles.forget": "Entfernen",
|
||||
"horizon.splitscreen.profiles.create": "Erstellen",
|
||||
"horizon.splitscreen.profiles.you": " - du",
|
||||
"horizon.splitscreen.profiles.forget_named": "%s entfernen",
|
||||
"horizon.splitscreen.profiles.previous": "Zurück",
|
||||
"horizon.splitscreen.profiles.next": "Weiter",
|
||||
"horizon.splitscreen.profiles.new_button": "Neues Profil …",
|
||||
"horizon.splitscreen.profiles.forget_title": "Profil entfernen?",
|
||||
"horizon.splitscreen.profiles.forget_leaves": "Es verschwindet aus der Liste dieser Instanz.",
|
||||
"horizon.splitscreen.profiles.forget_kept": "Weltfortschritt und Einstellungen bleiben erhalten.",
|
||||
"horizon.splitscreen.profiles.new_title": "Neues Profil",
|
||||
"horizon.splitscreen.profiles.name_hint": "Name (3 bis 16 Buchstaben, Ziffern, _)",
|
||||
"horizon.splitscreen.profiles.switch_title": "Profil wechseln",
|
||||
"horizon.splitscreen.profiles.offline_note": "Offline-Profile gehören zu dieser Instanz",
|
||||
"horizon.splitscreen.profiles.move": "Spielort wechseln",
|
||||
"horizon.splitscreen.profiles.move_place": "Spielort wechseln: %s",
|
||||
"horizon.friends.title": "Mit Freunden spielen",
|
||||
"horizon.friends.hint": "Freunde fügen den Code nach dem Freunde-Knopf des Titelbildschirms ein, bei „Direktverbindung“ oder auf der Spielen-Seite des Launchers.",
|
||||
"horizon.friends.copied": "Kopiert",
|
||||
"horizon.friends.copy_code": "Code kopieren",
|
||||
"horizon.friends.stop_sharing": "Teilen beenden",
|
||||
"horizon.friends.none_yet": "Noch keine Freunde hier.",
|
||||
"horizon.friends.here_now": "Gerade hier: %s",
|
||||
"horizon.friends.share_intro": "Teile diese Welt mit einem Einladungscode: Sie wird im LAN geöffnet, und Freunde im Internet treten mit dem Code bei. Es ist kein Server von uns beteiligt.",
|
||||
"horizon.friends.start_sharing": "Teilen starten",
|
||||
"horizon.friends.troubleshooting": "Fehlerbehebung …",
|
||||
"horizon.friends.stopped": "Das Teilen des Einladungscodes wurde beendet.",
|
||||
"horizon.friends.short_code_changed": "Jemand hat immer wieder einen falschen Kurzcode versucht, deshalb wurde er geändert.",
|
||||
"horizon.friends.wrong_code": "Jemand hat einen falschen Code versucht.",
|
||||
"horizon.friends.code_ready": "Dein Einladungscode ist bereit: Spielmenü, Mit Freunden spielen. Kopiere ihn und schicke ihn an deine Freunde.",
|
||||
"horizon.friends.share_failed": "Die Welt konnte nicht geteilt werden: %s",
|
||||
"horizon.friends.finding_route": "Es wird ermittelt, wie Freunde dich erreichen können …",
|
||||
"horizon.osk.shift": "Umschalt",
|
||||
"horizon.osk.space": "Leertaste",
|
||||
"horizon.osk.delete": "Löschen",
|
||||
"horizon.osk.type": "Eingeben",
|
||||
"horizon.osk.symbols": "Symbole",
|
||||
"horizon.osk.close": "Schließen",
|
||||
"horizon.multiplayer.not_signed_in": "Dieser Server läuft im Online-Modus. Melde dich im Horizon-Launcher mit deinem Microsoft-Konto an, um ihm beizutreten.",
|
||||
"horizon.multiplayer.invalid_address": "Ungültige Serveradresse: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Direkt mit deinem Freund verbunden …",
|
||||
"horizon.multiplayer.connected_friend_relay": "Über das Relay mit deinem Freund verbunden …"
|
||||
}
|
||||
@@ -213,5 +213,98 @@
|
||||
"horizon.multiplayer.not_signed_in": "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it.",
|
||||
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
|
||||
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay..."
|
||||
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay...",
|
||||
"horizon.title.not_affiliated": "Not affiliated with Mojang or Microsoft",
|
||||
"horizon.extras.header.lighting": "Lighting",
|
||||
"horizon.extras.header.third_person": "Third person",
|
||||
"horizon.extras.header.particles": "Particles",
|
||||
"horizon.extras.header.inventory": "Inventory",
|
||||
"horizon.extras.header.chat": "Chat",
|
||||
"horizon.extras.header.multiplayer": "Multiplayer",
|
||||
"horizon.extras.header.sound": "Sound",
|
||||
"horizon.extras.fullbright": "Fullbright",
|
||||
"horizon.extras.dynamic_lights": "Dynamic lights",
|
||||
"horizon.extras.own_name_tag": "Own name tag",
|
||||
"horizon.extras.hide_block_particles": "Hide block-break particles",
|
||||
"horizon.extras.auto_reconnect": "Auto-reconnect",
|
||||
"horizon.extras.ui_sound_reserve": "Keep channels for UI sounds",
|
||||
"horizon.extras.mouse_tweaks": "Mouse Tweaks controls",
|
||||
"horizon.extras.mouse_tweaks_wheel": "Scroll wheel moves items",
|
||||
"horizon.extras.sort": "Sort buttons",
|
||||
"horizon.extras.sort_key": "Sort key",
|
||||
"horizon.extras.no_ai": "No AI",
|
||||
"horizon.extras.agent_control": "Agent Control",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.splitscreen": "Splitscreen",
|
||||
"horizon.extras.chat_animation": "Smooth chat",
|
||||
"horizon.extras.chat_indicator": "Message indicator",
|
||||
"horizon.extras.fullbright.tooltip": "Show blocks and entities at full light, even in dark areas.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Torches and other light sources held, dropped or on fire light the blocks around them.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible.",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Hides the debris particles that burst from a broken block and crumble off the one you mine. Other particles stay.",
|
||||
"horizon.extras.auto_reconnect.tooltip": "Rejoins a server after a lost connection or a restart, never after a kick or ban.",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Sounds in the world leave 16 of the 247 sound channels free, so a flood of them (a big explosion) can't silence menu clicks.",
|
||||
"horizon.extras.mouse_tweaks.tooltip": "Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot.",
|
||||
"horizon.extras.mouse_tweaks_wheel.tooltip": "Down sends one item of the slot to the other side, up brings one back.",
|
||||
"horizon.extras.sort.tooltip": "Sorts the inventory, chests, barrels, ender chests and shulker boxes in creative tab order, merging stacks.",
|
||||
"horizon.extras.sort_key.tooltip": "Sorts the part under the cursor. Click, then press a key; Escape unbinds it.",
|
||||
"horizon.extras.no_ai.tooltip": "Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too.",
|
||||
"horizon.extras.agent_control.tooltip": "Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only.",
|
||||
"horizon.extras.discord_presence.tooltip": "Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address.",
|
||||
"Calibrate...": "Calibrate...",
|
||||
"Calibrate Anyway...": "Calibrate Anyway...",
|
||||
"Use Automatic Setup": "Use Automatic Setup",
|
||||
"Shortcut: %s": "Shortcut: %s",
|
||||
"Next": "Next",
|
||||
"HDR Calibration %s/%s: %s": "HDR Calibration %s/%s: %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Sets the three below full screen, one test picture at a time.",
|
||||
"A key that opens this screen from the game. Not bound at first.": "A key that opens this screen from the game. Not bound at first.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Your display didn't say how bright it gets: calibrate to set it up.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Already set up for your display. Only calibrate if HDR looks wrong.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Puts Peak Brightness and Paper White back to your display's setup.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Set up for your display. Leave on Auto unless it looks wrong.",
|
||||
"Lower it until the logo just disappears into the black.": "Lower it until the logo just disappears into the black.",
|
||||
"Raise it until the logo just disappears into the bright square.": "Raise it until the logo just disappears into the bright square.",
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Make the card a white that's comfortable to look at for a long time, in the light you play in.",
|
||||
"horizon.splitscreen.profiles.forget": "Forget",
|
||||
"horizon.splitscreen.profiles.create": "Create",
|
||||
"horizon.splitscreen.profiles.you": " - you",
|
||||
"horizon.splitscreen.profiles.forget_named": "Forget %s",
|
||||
"horizon.splitscreen.profiles.previous": "Previous",
|
||||
"horizon.splitscreen.profiles.next": "Next",
|
||||
"horizon.splitscreen.profiles.new_button": "New Profile...",
|
||||
"horizon.splitscreen.profiles.forget_title": "Forget profile?",
|
||||
"horizon.splitscreen.profiles.forget_leaves": "It leaves this instance's list.",
|
||||
"horizon.splitscreen.profiles.forget_kept": "World progress and settings are kept.",
|
||||
"horizon.splitscreen.profiles.new_title": "New Profile",
|
||||
"horizon.splitscreen.profiles.name_hint": "Name (3 to 16 letters, digits, _)",
|
||||
"horizon.splitscreen.profiles.switch_title": "Switch profile",
|
||||
"horizon.splitscreen.profiles.offline_note": "Offline profiles belong to this instance",
|
||||
"horizon.splitscreen.profiles.move": "Move where you play",
|
||||
"horizon.splitscreen.profiles.move_place": "Move where you play: %s",
|
||||
"horizon.friends.title": "Play with Friends",
|
||||
"horizon.friends.hint": "Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
|
||||
"horizon.friends.copied": "Copied",
|
||||
"horizon.friends.copy_code": "Copy Code",
|
||||
"horizon.friends.stop_sharing": "Stop Sharing",
|
||||
"horizon.friends.none_yet": "No friends here yet.",
|
||||
"horizon.friends.here_now": "Here now: %s",
|
||||
"horizon.friends.share_intro": "Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
|
||||
"horizon.friends.start_sharing": "Start Sharing",
|
||||
"horizon.friends.troubleshooting": "Troubleshooting...",
|
||||
"horizon.friends.stopped": "Stopped sharing the invite code.",
|
||||
"horizon.friends.short_code_changed": "Someone kept trying a wrong short code, so it changed.",
|
||||
"horizon.friends.wrong_code": "Someone tried a wrong code.",
|
||||
"horizon.friends.code_ready": "Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
|
||||
"horizon.friends.share_failed": "Couldn't share the world: %s",
|
||||
"horizon.friends.finding_route": "Finding how friends can reach you…",
|
||||
"horizon.osk.shift": "Shift",
|
||||
"horizon.osk.space": "Space",
|
||||
"horizon.osk.delete": "Delete",
|
||||
"horizon.osk.type": "Type",
|
||||
"horizon.osk.symbols": "Symbols",
|
||||
"horizon.osk.close": "Close",
|
||||
"horizon.extras.chat_animation.tooltip": "A new chat line slides in from the left while it fades in, instead of appearing whole.",
|
||||
"horizon.extras.chat_indicator.tooltip": "The coloured bar at the left of a chat line.",
|
||||
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
{
|
||||
"horizon.options.controller": "Manette",
|
||||
"horizon.options.vibration": "Vibrations",
|
||||
"horizon.options.adaptive_triggers": "Gâchettes adaptatives",
|
||||
"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.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.button_glyphs": "Symboles des boutons",
|
||||
"horizon.options.menu_navigation": "Navigation dans les menus",
|
||||
"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.menu_focus": "Focus",
|
||||
"horizon.options.menu_pointer": "Pointeur",
|
||||
"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.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.agent": "Agent IA",
|
||||
"horizon.pages.split_profiles": "Qui joue ?",
|
||||
"horizon.pages.settings.tooltip": "Les options propres à Horizon : graphismes, manettes, extras et mods.",
|
||||
"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.",
|
||||
"horizon.pages.extras.tooltip": "Ajouts facultatifs, tous désactivés par défaut : le jeu reste vanilla tant que vous n'en activez pas un.",
|
||||
"horizon.pages.agent.tooltip": "Laissez votre propre agent IA jouer : horizon-ctl et les clients MCP sur cet ordinateur.",
|
||||
"horizon.pages.split_profiles.tooltip": "Qui joue : un profil conservé dans ce dossier de jeu.",
|
||||
"horizon.pages.shader_packs.tooltip": "Packs de shaders du dossier shaderpacks : ceux d'Horizon et ceux au format shaders/. Un seul fonctionne à la fois.",
|
||||
"horizon.pages.mods.tooltip": "Mods conçus pour Horizon, du dossier horizon-mods. Chacun s'exécute dans un bac à sable et reste désactivé tant que vous ne l'activez pas.",
|
||||
"horizon.pages.open.graphics": "Graphismes...",
|
||||
"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.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.open_folder": "Ouvrir le dossier des mods",
|
||||
"horizon.shader_packs.empty": "Aucun pack de shaders : placez-les dans le dossier shaderpacks",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.shader_options.open": "Options des shaders...",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"horizon.shader_options.loading": "Lecture des options du pack de shaders...",
|
||||
"horizon.agent.copy_details": "Copier les détails de connexion",
|
||||
"horizon.multiplayer.join": "Rejoindre",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Annuler la reconnexion",
|
||||
"horizon.multiplayer.join_friend": "Rejoindre un ami",
|
||||
"horizon.multiplayer.invite_code": "Code d'invitation",
|
||||
"horizon.multiplayer.relay": "Relais (facultatif)",
|
||||
"Preset": "Préréglage",
|
||||
"LOD Distance": "Distance LOD",
|
||||
"Far Detail": "Détails lointains",
|
||||
"Cave Culling": "Occlusion des grottes",
|
||||
"Entity Culling": "Occlusion des entités",
|
||||
"Chunk Updates": "MàJ chunks",
|
||||
"Upload Budget": "Transfert",
|
||||
"Sky": "Ciel",
|
||||
"Fog": "Brouillard",
|
||||
"Weather": "Météo",
|
||||
"Animated Textures": "Textures animées",
|
||||
"Present Mode": "Présentation",
|
||||
"Frame Pacing": "Rythme des images",
|
||||
"Worker Threads": "Threads",
|
||||
"Resolution Scale": "Échelle de résolution",
|
||||
"Anti-aliasing": "Anticrénelage",
|
||||
"Upscaling": "Upscaling",
|
||||
"HDR Settings...": "Options HDR...",
|
||||
"Paper White": "Point blanc",
|
||||
"Peak Brightness": "Luminosité max.",
|
||||
"Black Level": "Niveau de noir",
|
||||
"UI Brightness": "Luminosité UI",
|
||||
"Battery Mode": "Mode batterie",
|
||||
"Graphics API": "API graphique",
|
||||
"Performance Overlay": "Infos de perfs",
|
||||
"Full Detail Distance": "Dist. détail",
|
||||
"On": "Oui",
|
||||
"Off": "Non",
|
||||
"Unlimited": "Illimité",
|
||||
"Auto": "Auto",
|
||||
"Custom": "Personnalisé",
|
||||
"Performance": "Performances",
|
||||
"Balanced": "Équilibré",
|
||||
"Quality": "Qualité",
|
||||
"Ultra": "Ultra",
|
||||
"Ultra Performance": "Ultra perfs",
|
||||
"Native": "Native",
|
||||
"Full": "Complet",
|
||||
"Reduced": "Réduit",
|
||||
"Efficient": "Économe",
|
||||
"Precise": "Précis",
|
||||
"Auto (VSync)": "Auto (VSync)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "Mailbox",
|
||||
"Immediate": "Immediate",
|
||||
"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",
|
||||
"Rendering": "Rendu",
|
||||
"Display": "Affichage",
|
||||
"Preferences": "Préférences",
|
||||
"Overlay": "Surimpression",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "Graphismes",
|
||||
"Graphics: Advanced": "Graphismes : avancés",
|
||||
"Graphics: HDR": "Graphismes : HDR",
|
||||
"Advanced...": "Avancé...",
|
||||
"Simple...": "Simple...",
|
||||
"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.",
|
||||
"Turn HDR on to use it.": "Activez le HDR pour l'utiliser.",
|
||||
"%s chunks": "%s chunks",
|
||||
"%s per frame": "%s par image",
|
||||
"%s MiB/frame": "%s MiB/image",
|
||||
"%s KiB/frame": "%s KiB/image",
|
||||
"%s nits": "%s nits",
|
||||
"Auto (%s)": "Auto (%s)",
|
||||
"Auto (%s nits)": "Auto (%s nits)",
|
||||
"%.2f nits": "%.2f nits",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Terrain en détail complet. La distance LOD va plus loin.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terrain lointain simplifié, mondes locaux. GPU intégrés : 128 max.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Réduit : terrain lointain simplifié, plus rapide sur ce GPU.",
|
||||
"Reduced draws far terrain coarser, for GPUs it slows down.": "Réduit : terrain simplifié, pour les GPU qui ralentissent.",
|
||||
"Field of view. Wider shows more terrain at once.": "Champ de vision. Plus large, il montre plus de terrain à la fois.",
|
||||
"Caps frames per second; a cap saves power and heat.": "Limite les images par seconde ; économise énergie et chaleur.",
|
||||
"Waits for the display's refresh: no tearing, a little latency.": "Attend l'écran : pas de déchirure, un peu de latence.",
|
||||
"Size of menus and the HUD.": "Taille des menus et de l'interface (HUD).",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Règle les options ci-dessous. Modifier une : « Personnalisé ».",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lave, feu et soleil plus vifs que le blanc. Écran HDR requis.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Noir le plus profond de l'écran. 0 pour OLED, les LCD brillent.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "La luminosité des hautes lumières. Auto : le pic indiqué par votre système.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Luminosité du blanc et des menus. Auto : blanc du bureau.",
|
||||
"How bright the HUD and menus are, against paper white.": "La luminosité du HUD et des menus, par rapport au blanc papier.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Portée d'activité des créatures, cultures, machines. Coût : CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Distance des créatures et objets, en part de la normale.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Ignore le terrain caché. Désactiver coûte cher.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Masque les modèles derrière les blocs. Éteindre avec packs.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Sections maillées par image. Moins : chargement plus fluide.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Ombres douces dans les coins. Coût : construction des chunks.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Textures réduites au loin : moins de scintillement.",
|
||||
"Blends grass and water colours across biomes when building chunks.": "Fond les couleurs d'herbe et d'eau entre les biomes.",
|
||||
"Cloud layer: off, flat (fast) or 3D (fancy).": "Couche de nuages : désactivée, plate (rapide) ou 3D (détaillée).",
|
||||
"How far clouds reach, in chunks.": "Jusqu'où s'étendent les nuages, en chunks.",
|
||||
"Draws the sky, sun, moon and stars.": "Dessine le ciel, le soleil, la lune et les étoiles.",
|
||||
"Distance fog. Off shows the edge of the loaded world.": "Brouillard lointain. Désactivé, il montre le bord du monde chargé.",
|
||||
"Draws rain and snow.": "Dessine la pluie et la neige.",
|
||||
"How far rain and snow fall around you.": "Jusqu'où la pluie et la neige tombent autour de vous.",
|
||||
"How many particles effects spawn.": "Combien de particules les effets génèrent.",
|
||||
"Round shadows under mobs and items.": "Ombres rondes sous les créatures et les objets.",
|
||||
"Leaves with see-through gaps. Off draws them solid.": "Feuilles avec des trous transparents. Désactivé, elles sont pleines.",
|
||||
"Sorts water and glass more exactly behind each other.": "Trie plus précisément l'eau et le verre les uns derrière les autres.",
|
||||
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Quels chunks attendent l'image : aucun, proches ou les vôtres.",
|
||||
"Animates water, lava, fire and other moving textures.": "Anime l'eau, la lave, le feu et les autres textures mobiles.",
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Pixels du monde par pixel d'écran. Sous 100 %, GPU plus rapide.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Lisse les bords. FXAA : doux. TAA : lisse, traînées possibles.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Rendu réduit, reconstruit par TAA. Plus rapide, plus doux.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Sortie HDR sur écran HDR : lave, feu et soleil plus vifs.",
|
||||
"How frames reach the screen while VSync is off.": "Comment les images arrivent à l'écran sans synchro verticale.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Économe attend l'image suivante ; Précis tourne en boucle.",
|
||||
"Fills the screen (borderless).": "Remplit l'écran (sans bordure).",
|
||||
"Brightens dark places.": "Éclaircit les endroits sombres.",
|
||||
"Lowers the frame rate when minimised, or idle too.": "Réduit les FPS si la fenêtre est réduite, ou aussi inactive.",
|
||||
"Sways the view while walking.": "Fait osciller la vue pendant la marche.",
|
||||
"Fades newly loaded chunks in.": "Fait apparaître en fondu les chunks nouvellement chargés.",
|
||||
"Darkens the screen's edges.": "Assombrit les bords de l'écran.",
|
||||
"Shows when the world saves.": "Indique quand le monde est sauvegardé.",
|
||||
"Shows attack cooldown.": "Affiche le délai de recharge de l'attaque.",
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS max (30 en menu/sans focus), moitié de threads : économie.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Threads de génération, lumière et maillage. Au redémarrage.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sinon DirectX 12 (Windows) ou OpenGL (Linux). Au redémarrage.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Données de maillage vers le GPU par image. Moins : plus fluide.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Fréquence et temps d'image par-dessus le jeu, avec un graphique.",
|
||||
"horizon.multiplayer.finding_friend": "Recherche du monde de votre ami...",
|
||||
"horizon.loading.opening_world": "Ouverture du monde",
|
||||
"horizon.loading.cancelling_world": "Annulation en cours",
|
||||
"horizon.title.not_affiliated": "Non affilié à Mojang ou à Microsoft",
|
||||
"horizon.extras.header.lighting": "Éclairage",
|
||||
"horizon.extras.header.third_person": "Troisième personne",
|
||||
"horizon.extras.header.inventory": "Inventaire",
|
||||
"horizon.extras.header.multiplayer": "Multijoueur",
|
||||
"horizon.extras.fullbright": "Pleine luminosité",
|
||||
"horizon.extras.dynamic_lights": "Lumières dynamiques",
|
||||
"horizon.extras.own_name_tag": "Mon pseudo",
|
||||
"horizon.extras.auto_reconnect": "Reconnexion auto",
|
||||
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
|
||||
"horizon.extras.mouse_tweaks_wheel": "Déplacer à la molette",
|
||||
"horizon.extras.sort": "Boutons de tri",
|
||||
"horizon.extras.sort_key": "Touche de tri",
|
||||
"horizon.extras.no_ai": "Sans IA",
|
||||
"horizon.extras.agent_control": "Contrôle par agent",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.fullbright.tooltip": "Affiche les blocs et les entités en pleine lumière, même dans les zones sombres.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Les torches et autres sources de lumière tenues en main, jetées ou en feu éclairent les blocs autour d'elles.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Affiche votre nom au-dessus de votre tête à la troisième personne, comme les autres joueurs le voient. Masqué quand le serveur masque les noms de votre équipe ou que vous êtes invisible.",
|
||||
"horizon.extras.auto_reconnect.tooltip": "Rejoint de nouveau un serveur après une perte de connexion ou un redémarrage, jamais après une expulsion ou un bannissement.",
|
||||
"horizon.extras.mouse_tweaks.tooltip": "Glisser avec le clic droit en place un par case, glisser avec le clic gauche rassemble l'objet tenu, Maj + glisser avec le clic gauche déplace chaque case.",
|
||||
"horizon.extras.mouse_tweaks_wheel.tooltip": "Vers le bas, envoie un objet de la case de l'autre côté ; vers le haut, en ramène un.",
|
||||
"horizon.extras.sort.tooltip": "Trie l'inventaire, les coffres, les tonneaux, les coffres de l'Ender et les boîtes de Shulker selon l'ordre des onglets du mode créatif, en fusionnant les piles.",
|
||||
"horizon.extras.sort_key.tooltip": "Trie la zone sous le curseur. Cliquez, puis appuyez sur une touche ; Échap retire l'assignation.",
|
||||
"horizon.extras.no_ai.tooltip": "Masque et désactive toutes les fonctions d'IA : contrôle par agent, horizon-ctl et les serveurs MCP. Le commutateur du lanceur aussi.",
|
||||
"horizon.extras.agent_control.tooltip": "Votre propre agent IA peut jouer à ce jeu via horizon-ctl ou un client MCP, uniquement sur cet ordinateur.",
|
||||
"horizon.extras.discord_presence.tooltip": "Affiche « Joue à Horizon » sur votre profil Discord tant que Discord fonctionne : menus, solo ou multijoueur, la dimension et le temps de jeu. Jamais le nom d'un monde ni l'adresse d'un serveur.",
|
||||
"Calibrate...": "Calibrer...",
|
||||
"Calibrate Anyway...": "Calibrer quand même...",
|
||||
"Use Automatic Setup": "Utiliser la configuration automatique",
|
||||
"Shortcut: %s": "Raccourci : %s",
|
||||
"Next": "Suivant",
|
||||
"HDR Calibration %s/%s: %s": "Calibrage HDR %s/%s : %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Règle les trois valeurs ci-dessous en plein écran, une image test à la fois.",
|
||||
"A key that opens this screen from the game. Not bound at first.": "Une touche qui ouvre cet écran depuis le jeu. Non assignée au départ.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Votre écran n'a pas indiqué sa luminosité : calibrez-le.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Déjà configuré. Calibrez seulement si le HDR semble incorrect.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Rétablit Luminosité max. et Point blanc de votre écran.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Configuré pour votre écran. Laissez Auto si tout va bien.",
|
||||
"Lower it until the logo just disappears into the black.": "Baissez-le jusqu'à ce que le logo disparaisse tout juste dans le noir.",
|
||||
"Raise it until the logo just disappears into the bright square.": "Augmentez jusqu'à ce que le logo se fonde dans le carré clair.",
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Un blanc agréable à regarder longtemps, selon votre éclairage.",
|
||||
"horizon.splitscreen.profiles.forget": "Oublier",
|
||||
"horizon.splitscreen.profiles.create": "Créer",
|
||||
"horizon.splitscreen.profiles.you": " - vous",
|
||||
"horizon.splitscreen.profiles.forget_named": "Oublier %s",
|
||||
"horizon.splitscreen.profiles.previous": "Précédent",
|
||||
"horizon.splitscreen.profiles.next": "Suivant",
|
||||
"horizon.splitscreen.profiles.new_button": "Nouveau profil...",
|
||||
"horizon.splitscreen.profiles.forget_title": "Oublier le profil ?",
|
||||
"horizon.splitscreen.profiles.forget_leaves": "Il quitte la liste de cette instance.",
|
||||
"horizon.splitscreen.profiles.forget_kept": "La progression des mondes et les options sont conservées.",
|
||||
"horizon.splitscreen.profiles.new_title": "Nouveau profil",
|
||||
"horizon.splitscreen.profiles.name_hint": "Nom (3 à 16 lettres, chiffres ou _)",
|
||||
"horizon.splitscreen.profiles.switch_title": "Changer de profil",
|
||||
"horizon.splitscreen.profiles.offline_note": "Les profils hors ligne appartiennent à cette instance",
|
||||
"horizon.splitscreen.profiles.move": "Changer d'emplacement de jeu",
|
||||
"horizon.splitscreen.profiles.move_place": "Changer d'emplacement de jeu : %s",
|
||||
"horizon.friends.title": "Jouer avec des amis",
|
||||
"horizon.friends.hint": "Vos amis collent le code après le bouton Amis de l'écran titre, dans « Connexion rapide » ou sur la page Jouer du lanceur.",
|
||||
"horizon.friends.copied": "Copié",
|
||||
"horizon.friends.copy_code": "Copier le code",
|
||||
"horizon.friends.stop_sharing": "Arrêter le partage",
|
||||
"horizon.friends.none_yet": "Aucun ami pour l'instant.",
|
||||
"horizon.friends.here_now": "Présents : %s",
|
||||
"horizon.friends.share_intro": "Partagez ce monde avec un code d'invitation : il s'ouvre au LAN, et vos amis sur internet le rejoignent avec le code. Aucun serveur de notre part n'est impliqué.",
|
||||
"horizon.friends.start_sharing": "Commencer le partage",
|
||||
"horizon.friends.troubleshooting": "Dépannage...",
|
||||
"horizon.friends.stopped": "Le partage du code d'invitation est arrêté.",
|
||||
"horizon.friends.short_code_changed": "Quelqu'un a essayé plusieurs fois un mauvais code court, il a donc été changé.",
|
||||
"horizon.friends.wrong_code": "Quelqu'un a essayé un mauvais code.",
|
||||
"horizon.friends.code_ready": "Votre code d'invitation est prêt : Menu du jeu, Jouer avec des amis. Copiez-le et envoyez-le à vos amis.",
|
||||
"horizon.friends.share_failed": "Impossible de partager le monde : %s",
|
||||
"horizon.friends.finding_route": "Recherche de la manière dont vos amis peuvent vous joindre…",
|
||||
"horizon.osk.shift": "Maj.",
|
||||
"horizon.osk.space": "Espace",
|
||||
"horizon.osk.delete": "Effacer",
|
||||
"horizon.osk.type": "Saisir",
|
||||
"horizon.osk.symbols": "Symboles",
|
||||
"horizon.osk.close": "Fermer",
|
||||
"horizon.multiplayer.not_signed_in": "Ce serveur est en mode en ligne. Connectez-vous avec votre compte Microsoft dans le lanceur Horizon pour le rejoindre.",
|
||||
"horizon.multiplayer.invalid_address": "Adresse de serveur invalide : %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Connecté directement à votre ami...",
|
||||
"horizon.multiplayer.connected_friend_relay": "Connecté à votre ami via le relais..."
|
||||
}
|
||||
@@ -12,6 +12,197 @@
|
||||
"horizon.options.button_glyphs": "Symbole przycisków",
|
||||
"horizon.options.menu_navigation": "Nawigacja po menu",
|
||||
"horizon.options.on_screen_keyboard": "Klawiatura ekranowa",
|
||||
"horizon.options.chat_delay": "Opóźnienie czatu",
|
||||
"horizon.options.lightbar.health": "Zdrowie",
|
||||
"horizon.options.lightbar.dimension": "Wymiar",
|
||||
"horizon.options.glyphs.auto": "Auto",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.menu_pointer": "Wskaźnik",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Ta funkcja nie jest jeszcze dostępna w Horizon.",
|
||||
"horizon.options.search": "Szukaj ustawień…",
|
||||
"horizon.pages.mods": "Mody",
|
||||
"horizon.pages.shader_packs": "Paczki shaderów",
|
||||
"horizon.pages.shader_options": "Ustawienia shaderów",
|
||||
"horizon.pages.controller": "Ustawienia kontrolera",
|
||||
"horizon.pages.controller_binds": "Przypisane przyciski kontrolera",
|
||||
"horizon.pages.extras": "Dodatki Horizon",
|
||||
"horizon.pages.settings": "Ustawienia Horizon",
|
||||
"horizon.pages.agent": "Agent AI",
|
||||
"horizon.pages.split_profiles": "Kto gra?",
|
||||
"horizon.pages.settings.tooltip": "Własne ustawienia Horizon: grafika, kontrolery, dodatki i mody.",
|
||||
"horizon.pages.graphics.tooltip": "Grafika w prostej formie: każde ustawienie podaje swój koszt.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Wszystkie ustawienia grafiki, także te z Horizon, wraz z kosztem każdego z nich.",
|
||||
"horizon.pages.controller.tooltip": "Kontrolery: ich przyciski, drążki, wibracje i nawigacja po menu.",
|
||||
"horizon.pages.extras.tooltip": "Opcjonalne dodatki, wszystkie domyślnie wyłączone: gra pozostaje vanilla, dopóki nie włączysz któregoś.",
|
||||
"horizon.pages.agent.tooltip": "Pozwól własnemu agentowi AI grać: horizon-ctl i klienci MCP na tym komputerze.",
|
||||
"horizon.pages.split_profiles.tooltip": "Kto gra: profil przechowywany w tym folderze gry.",
|
||||
"horizon.pages.shader_packs.tooltip": "Paczki shaderów z folderu shaderpacks: własne paczki Horizon i te w formacie shaders/. Naraz działa jedna.",
|
||||
"horizon.pages.mods.tooltip": "Mody stworzone dla Horizon, z folderu horizon-mods. Każdy działa w piaskownicy i pozostaje wyłączony, dopóki go nie włączysz.",
|
||||
"horizon.pages.open.graphics": "Grafika…",
|
||||
"horizon.pages.open.graphics_advanced": "Zaawansowana grafika…",
|
||||
"horizon.pages.open.shader_packs": "Paczki shaderów…",
|
||||
"horizon.pages.open.controller": "Ustawienia kontrolera…",
|
||||
"horizon.pages.open.extras": "Dodatki…",
|
||||
"horizon.pages.open.mods": "Mody…",
|
||||
"horizon.pages.open.agent": "Agent AI…",
|
||||
"horizon.pages.open.horizon_graphics": "Grafika Horizon…",
|
||||
"horizon.pages.open.controller_binds": "Przyciski kontrolera…",
|
||||
"horizon.pages.open.horizon_settings": "Ustawienia Horizon…",
|
||||
"horizon.mods.empty": "Brak modów: umieść pliki .hmod w folderze horizon-mods",
|
||||
"horizon.mods.stopped": " (zatrzymany)",
|
||||
"horizon.mods.not_built": " (niezbudowany)",
|
||||
"horizon.mods.dev": " (dev)",
|
||||
"horizon.mods.settings": "Ustawienia",
|
||||
"horizon.mods.open_folder": "Otwórz folder z modami",
|
||||
"horizon.shader_packs.empty": "Brak paczek shaderów: umieść je w folderze shaderpacks",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.shader_options.open": "Ustawienia shaderów…",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"horizon.shader_options.loading": "Wczytywanie ustawień paczki shaderów…",
|
||||
"horizon.agent.copy_details": "Kopiuj dane połączenia",
|
||||
"horizon.multiplayer.join": "Dołącz",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Anuluj ponowne łączenie",
|
||||
"horizon.multiplayer.join_friend": "Dołącz do znajomego",
|
||||
"horizon.multiplayer.invite_code": "Kod zaproszenia",
|
||||
"horizon.multiplayer.relay": "Przekaźnik (opcjonalnie)",
|
||||
"Preset": "Profil",
|
||||
"LOD Distance": "Zasięg LOD",
|
||||
"Far Detail": "Dalekie detale",
|
||||
"Cave Culling": "Pomijanie jaskiń",
|
||||
"Entity Culling": "Pomijanie bytów",
|
||||
"Chunk Updates": "Aktual. chunków",
|
||||
"Upload Budget": "Wysyłka",
|
||||
"Sky": "Niebo",
|
||||
"Fog": "Mgła",
|
||||
"Weather": "Pogoda",
|
||||
"Animated Textures": "Animowane tekstury",
|
||||
"Present Mode": "Prezentacja",
|
||||
"Frame Pacing": "Tempo klatek",
|
||||
"Worker Threads": "Wątki robocze",
|
||||
"Resolution Scale": "Skala rozdzielczości",
|
||||
"Anti-aliasing": "Antyaliasing",
|
||||
"Upscaling": "Skalowanie",
|
||||
"HDR Settings...": "Ustawienia HDR…",
|
||||
"Paper White": "Punkt bieli",
|
||||
"Peak Brightness": "Jasność maks.",
|
||||
"Black Level": "Poziom czerni",
|
||||
"UI Brightness": "Jasność interfejsu",
|
||||
"Battery Mode": "Tryb baterii",
|
||||
"Graphics API": "API grafiki",
|
||||
"Performance Overlay": "Nakładka wydajności",
|
||||
"Full Detail Distance": "Zasięg detali",
|
||||
"On": "Wł.",
|
||||
"Off": "Wył.",
|
||||
"Unlimited": "Bez limitu",
|
||||
"Auto": "Auto",
|
||||
"Custom": "Własne",
|
||||
"Performance": "Wydajność",
|
||||
"Balanced": "Zrównoważone",
|
||||
"Quality": "Jakość",
|
||||
"Ultra": "Ultra",
|
||||
"Ultra Performance": "Ultra wydajność",
|
||||
"Native": "Natywna",
|
||||
"Full": "Pełne",
|
||||
"Reduced": "Ograniczone",
|
||||
"Efficient": "Oszczędne",
|
||||
"Precise": "Dokładne",
|
||||
"Auto (VSync)": "Auto (VSync)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "Mailbox",
|
||||
"Immediate": "Immediate",
|
||||
"Vulkan": "Vulkan",
|
||||
"DirectX 12": "DirectX 12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS i wykres",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "Bez kosztu",
|
||||
"Low cost": "Niski koszt",
|
||||
"Medium cost": "Średni koszt",
|
||||
"High cost": "Wysoki koszt",
|
||||
"Saves time": "Oszczędza czas",
|
||||
"Rendering": "Renderowanie",
|
||||
"Display": "Wyświetlanie",
|
||||
"Preferences": "Preferencje",
|
||||
"Overlay": "Nakładka",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "Grafika",
|
||||
"Graphics: Advanced": "Grafika: zaawansowane",
|
||||
"Graphics: HDR": "Grafika: HDR",
|
||||
"Advanced...": "Zaawansowane…",
|
||||
"Simple...": "Proste…",
|
||||
"Done": "Gotowe",
|
||||
"This feature is not yet available in Horizon.": "Ta funkcja nie jest jeszcze dostępna w Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Twój wyświetlacz lub system nie obsługuje wyjścia HDR.",
|
||||
"Turn HDR on to use it.": "Włącz HDR, aby tego użyć.",
|
||||
"%s chunks": "%s chunków",
|
||||
"%s per frame": "%s na klatkę",
|
||||
"%s MiB/frame": "%s MiB/klatka",
|
||||
"%s KiB/frame": "%s KiB/klatka",
|
||||
"%s nits": "%s nitów",
|
||||
"Auto (%s)": "Auto (%s)",
|
||||
"Auto (%s nits)": "Auto (%s nitów)",
|
||||
"%.2f nits": "%.2f nitów",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Zasięg terenu w pełnych szczegółach. Zasięg LOD sięga dalej.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Uproszczony odległy teren w światach lokalnych. Zintegr. GPU: do 128.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Ograniczone: odległy teren rysowany prościej, szybciej na tym GPU.",
|
||||
"Reduced draws far terrain coarser, for GPUs it slows down.": "Ograniczone: odległy teren prościej, dla GPU, które zwalniają.",
|
||||
"Field of view. Wider shows more terrain at once.": "Pole widzenia. Szersze pokazuje naraz więcej terenu.",
|
||||
"Caps frames per second; a cap saves power and heat.": "Ogranicza klatki na sekundę; limit oszczędza energię i ciepło.",
|
||||
"Waits for the display's refresh: no tearing, a little latency.": "Czeka na odświeżenie ekranu: bez rozrywania obrazu, z niewielkim opóźnieniem.",
|
||||
"Size of menus and the HUD.": "Rozmiar menu i interfejsu HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Ustawia razem poniższe opcje. Zmiana jednej zmienia na „Własne”.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lawa, ogień i słońce świecą jaśniej niż biel. Wymaga ekranu HDR.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Najciemniejsza czerń twojego wyświetlacza. 0 dla OLED; LCD trochę się świecą.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Jak jasne mogą być światła. Auto: szczyt zgłaszany przez system.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Jak jasny wygląda biały papier i menu. Auto: biel twojego pulpitu.",
|
||||
"How bright the HUD and menus are, against paper white.": "Jak jasne są HUD i menu w stosunku do bieli papieru.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Jak daleko stworzenia, uprawy i maszyny nadal działają. Czas procesora.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Odległość rysowania stworzeń i przedmiotów, jako ułamek normy.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Pomija teren ukryty za zamkniętymi sekcjami. Wyłączone dużo kosztuje.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Ukrywa modele za pełnymi blokami. Wył. przy własnych paczkach.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Liczba sekcji siatki wysyłanych na klatkę. Mniej to płynniej podczas ładowania.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Miękkie cienie w narożnikach. Kosztuje tylko czas budowania chunków.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Mniejsze kopie tekstur bloków dla odległego terenu: mniej migotania.",
|
||||
"Blends grass and water colours across biomes when building chunks.": "Przenika kolory trawy i wody między biomami podczas budowania chunków.",
|
||||
"Cloud layer: off, flat (fast) or 3D (fancy).": "Warstwa chmur: wyłączona, płaska (szybka) lub 3D (dokładna).",
|
||||
"How far clouds reach, in chunks.": "Jak daleko sięgają chmury, w chunkach.",
|
||||
"Draws the sky, sun, moon and stars.": "Rysuje niebo, słońce, księżyc i gwiazdy.",
|
||||
"Distance fog. Off shows the edge of the loaded world.": "Mgła w oddali. Wyłączona pokazuje krawędź załadowanego świata.",
|
||||
"Draws rain and snow.": "Rysuje deszcz i śnieg.",
|
||||
"How far rain and snow fall around you.": "Jak daleko wokół ciebie pada deszcz i śnieg.",
|
||||
"How many particles effects spawn.": "Ile cząstek tworzą efekty.",
|
||||
"Round shadows under mobs and items.": "Okrągłe cienie pod stworzeniami i przedmiotami.",
|
||||
"Leaves with see-through gaps. Off draws them solid.": "Liście z prześwitami. Wyłączone rysuje je jako pełne.",
|
||||
"Sorts water and glass more exactly behind each other.": "Dokładniej sortuje wodę i szkło jedno za drugim.",
|
||||
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Które przebudowy chunków czekają na klatkę: żadne, pobliskie lub twoje.",
|
||||
"Animates water, lava, fire and other moving textures.": "Animuje wodę, lawę, ogień i inne ruchome tekstury.",
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Piksele świata na piksel ekranu. Poniżej 100% GPU działa szybciej.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Wygładza krawędzie. FXAA: miękko. TAA: najgładziej, możliwe smugi.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renderuje mniejszy świat, odtwarza przez TAA. Szybciej, miękko.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Wyjście HDR na ekranie HDR: lawa, ogień i słońce świecą jaśniej.",
|
||||
"How frames reach the screen while VSync is off.": "Jak klatki trafiają na ekran, gdy VSync jest wyłączony.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Oszczędne usypia do następnej klatki; Dokładne czeka aktywnie.",
|
||||
"Fills the screen (borderless).": "Wypełnia ekran (bez ramki).",
|
||||
"Brightens dark places.": "Rozjaśnia ciemne miejsca.",
|
||||
"Lowers the frame rate when minimised, or idle too.": "Obniża liczbę klatek po zminimalizowaniu, a także podczas bezczynności.",
|
||||
"Sways the view while walking.": "Kołysze widokiem podczas chodzenia.",
|
||||
"Fades newly loaded chunks in.": "Płynnie pokazuje nowo załadowane chunki.",
|
||||
"Darkens the screen's edges.": "Przyciemnia krawędzie ekranu.",
|
||||
"Shows when the world saves.": "Pokazuje, kiedy świat się zapisuje.",
|
||||
"Shows attack cooldown.": "Pokazuje czas odnowienia ataku.",
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Limit 60 FPS (30 w menu/bez fokusu), połowa wątków: mniej energii.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Wątki generujące, oświetlające i meshujące chunki. Po restarcie.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, awaryjnie DirectX 12 (Windows) / OpenGL (Linux). Po restarcie.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Dane siatki wysyłane do GPU na klatkę. Mniej to płynniej podczas ładowania.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Liczba klatek i czasy klatek nad grą, z wykresem.",
|
||||
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
|
||||
"horizon.loading.opening_world": "Otwieranie świata",
|
||||
"horizon.loading.cancelling_world": "Anulowanie",
|
||||
@@ -19,10 +210,84 @@
|
||||
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…",
|
||||
"horizon.pages.shader_options": "Ustawienia shaderów",
|
||||
"horizon.pages.split_profiles": "Kto gra?",
|
||||
"horizon.pages.split_profiles.tooltip": "Kto gra: profil przechowywany w tym folderze gry.",
|
||||
"horizon.shader_options.open": "Ustawienia shaderów…",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"horizon.shader_options.loading": "Wczytywanie ustawień paczki shaderów…"
|
||||
"horizon.title.not_affiliated": "Niepowiązane z Mojang ani Microsoft",
|
||||
"horizon.extras.header.lighting": "Oświetlenie",
|
||||
"horizon.extras.header.third_person": "Widok z trzeciej osoby",
|
||||
"horizon.extras.header.inventory": "Ekwipunek",
|
||||
"horizon.extras.header.multiplayer": "Tryb wieloosobowy",
|
||||
"horizon.extras.fullbright": "Pełna jasność",
|
||||
"horizon.extras.dynamic_lights": "Dynamiczne światła",
|
||||
"horizon.extras.own_name_tag": "Własny nick",
|
||||
"horizon.extras.auto_reconnect": "Ponowne łączenie",
|
||||
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
|
||||
"horizon.extras.mouse_tweaks_wheel": "Przenoszenie kółkiem",
|
||||
"horizon.extras.sort": "Przyciski sortowania",
|
||||
"horizon.extras.sort_key": "Klawisz sort.",
|
||||
"horizon.extras.no_ai": "Bez AI",
|
||||
"horizon.extras.agent_control": "Sterowanie agentem",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.fullbright.tooltip": "Pokazuje bloki i byty w pełnym oświetleniu, nawet w ciemnych miejscach.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Pochodnie i inne źródła światła trzymane w dłoni, upuszczone lub płonące oświetlają otaczające bloki.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Pokazuje twój nick nad głową w widoku z trzeciej osoby, tak jak widzą go inni gracze. Ukryty, gdy serwer ukrywa nazwy twojej drużyny lub jesteś niewidzialny.",
|
||||
"horizon.extras.auto_reconnect.tooltip": "Ponownie dołącza do serwera po utracie połączenia lub restarcie, nigdy po wyrzuceniu lub banie.",
|
||||
"horizon.extras.mouse_tweaks.tooltip": "Przeciąganie prawym przyciskiem układa po jednym na pole, lewym zbiera trzymany przedmiot, Shift + lewy przycisk przenosi każde pole.",
|
||||
"horizon.extras.mouse_tweaks_wheel.tooltip": "Przewinięcie w dół wysyła jeden przedmiot z pola na drugą stronę, w górę przywraca jeden.",
|
||||
"horizon.extras.sort.tooltip": "Sortuje ekwipunek, skrzynie, beczki, skrzynie Endu i shulkerowe skrzynie w kolejności kart trybu kreatywnego, łącząc stosy.",
|
||||
"horizon.extras.sort_key.tooltip": "Sortuje część pod kursorem. Kliknij, a potem naciśnij klawisz; Escape usuwa przypisanie.",
|
||||
"horizon.extras.no_ai.tooltip": "Ukrywa i wyłącza wszystkie funkcje AI: sterowanie agentem, horizon-ctl i serwery MCP. Także przełącznik w launcherze.",
|
||||
"horizon.extras.agent_control.tooltip": "Twój własny agent AI może grać w tę grę przez horizon-ctl lub klienta MCP, tylko na tym komputerze.",
|
||||
"horizon.extras.discord_presence.tooltip": "Pokazuje „Gra w Horizon” w twoim profilu Discord, gdy Discord jest uruchomiony: menu, tryb jednoosobowy lub wieloosobowy, wymiar i czas gry. Nigdy nazwy świata ani adresu serwera.",
|
||||
"Calibrate...": "Kalibruj…",
|
||||
"Calibrate Anyway...": "Kalibruj mimo to…",
|
||||
"Use Automatic Setup": "Użyj automatycznej konfiguracji",
|
||||
"Shortcut: %s": "Skrót: %s",
|
||||
"Next": "Dalej",
|
||||
"HDR Calibration %s/%s: %s": "Kalibracja HDR %s/%s: %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Ustawia trzy wartości poniżej na pełnym ekranie, na obrazie testowym.",
|
||||
"A key that opens this screen from the game. Not bound at first.": "Klawisz otwierający ten ekran z poziomu gry. Na początku nieprzypisany.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Wyświetlacz nie podał jasności: skalibruj go, aby go skonfigurować.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Już skonfigurowane. Kalibruj tylko, gdy HDR wygląda źle.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Przywraca Jasność maks. i Punkt bieli do ustawień wyświetlacza.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Skonfigurowane dla twojego wyświetlacza. Zostaw Auto, chyba że wygląda źle.",
|
||||
"Lower it until the logo just disappears into the black.": "Zmniejszaj, aż logo właśnie zniknie w czerni.",
|
||||
"Raise it until the logo just disappears into the bright square.": "Zwiększaj, aż logo właśnie zniknie w jasnym kwadracie.",
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Ustaw biel, na którą wygodnie patrzeć długo, w twoim oświetleniu.",
|
||||
"horizon.splitscreen.profiles.forget": "Zapomnij",
|
||||
"horizon.splitscreen.profiles.create": "Utwórz",
|
||||
"horizon.splitscreen.profiles.you": " - ty",
|
||||
"horizon.splitscreen.profiles.forget_named": "Zapomnij %s",
|
||||
"horizon.splitscreen.profiles.previous": "Poprzednia",
|
||||
"horizon.splitscreen.profiles.next": "Dalej",
|
||||
"horizon.splitscreen.profiles.new_button": "Nowy profil…",
|
||||
"horizon.splitscreen.profiles.forget_title": "Zapomnieć profil?",
|
||||
"horizon.splitscreen.profiles.forget_leaves": "Znika z listy tej instancji.",
|
||||
"horizon.splitscreen.profiles.forget_kept": "Postępy w świecie i ustawienia zostają zachowane.",
|
||||
"horizon.splitscreen.profiles.new_title": "Nowy profil",
|
||||
"horizon.splitscreen.profiles.name_hint": "Nazwa (3–16 liter, cyfr lub _)",
|
||||
"horizon.splitscreen.profiles.switch_title": "Zmień profil",
|
||||
"horizon.splitscreen.profiles.offline_note": "Profile offline należą do tej instancji",
|
||||
"horizon.splitscreen.profiles.move": "Zmień miejsce gry",
|
||||
"horizon.splitscreen.profiles.move_place": "Zmień miejsce gry: %s",
|
||||
"horizon.friends.title": "Graj ze znajomymi",
|
||||
"horizon.friends.hint": "Znajomi wklejają kod po kliknięciu przycisku Znajomi na ekranie tytułowym, w Połączeniu bezpośrednim lub na stronie Graj w launcherze.",
|
||||
"horizon.friends.copied": "Skopiowano",
|
||||
"horizon.friends.copy_code": "Kopiuj kod",
|
||||
"horizon.friends.stop_sharing": "Przestań udostępniać",
|
||||
"horizon.friends.none_yet": "Nie ma jeszcze żadnych znajomych.",
|
||||
"horizon.friends.here_now": "Obecnie: %s",
|
||||
"horizon.friends.share_intro": "Udostępnij ten świat za pomocą kodu zaproszenia: otwiera się w sieci LAN, a znajomi przez internet dołączają za pomocą kodu. Nie korzystamy z żadnego naszego serwera.",
|
||||
"horizon.friends.start_sharing": "Zacznij udostępniać",
|
||||
"horizon.friends.troubleshooting": "Rozwiązywanie problemów…",
|
||||
"horizon.friends.stopped": "Przestano udostępniać kod zaproszenia.",
|
||||
"horizon.friends.short_code_changed": "Ktoś wciąż wpisywał zły krótki kod, więc został zmieniony.",
|
||||
"horizon.friends.wrong_code": "Ktoś wpisał zły kod.",
|
||||
"horizon.friends.code_ready": "Twój kod zaproszenia jest gotowy: Menu gry, Graj ze znajomymi. Skopiuj go i wyślij znajomym.",
|
||||
"horizon.friends.share_failed": "Nie udało się udostępnić świata: %s",
|
||||
"horizon.friends.finding_route": "Sprawdzanie, jak znajomi mogą się z tobą połączyć…",
|
||||
"horizon.osk.shift": "Shift",
|
||||
"horizon.osk.space": "Spacja",
|
||||
"horizon.osk.delete": "Usuń",
|
||||
"horizon.osk.type": "Wpisz",
|
||||
"horizon.osk.symbols": "Symbole",
|
||||
"horizon.osk.close": "Zamknij"
|
||||
}
|
||||
@@ -12,6 +12,197 @@
|
||||
"horizon.options.button_glyphs": "Значки кнопок",
|
||||
"horizon.options.menu_navigation": "Навігація в меню",
|
||||
"horizon.options.on_screen_keyboard": "Екранна клавіатура",
|
||||
"horizon.options.chat_delay": "Затримка чату",
|
||||
"horizon.options.lightbar.health": "Здоров’я",
|
||||
"horizon.options.lightbar.dimension": "Вимір",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Курсор",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Ця функція ще недоступна в Horizon.",
|
||||
"horizon.options.search": "Шукати налаштування…",
|
||||
"horizon.pages.mods": "Моди",
|
||||
"horizon.pages.shader_packs": "Пакети шейдерів",
|
||||
"horizon.pages.shader_options": "Налаштування шейдерів",
|
||||
"horizon.pages.controller": "Налаштування геймпада",
|
||||
"horizon.pages.controller_binds": "Призначення кнопок геймпада",
|
||||
"horizon.pages.extras": "Додатки Horizon",
|
||||
"horizon.pages.settings": "Налаштування Horizon",
|
||||
"horizon.pages.agent": "ШІ-агент",
|
||||
"horizon.pages.split_profiles": "Хто грає?",
|
||||
"horizon.pages.settings.tooltip": "Власні налаштування Horizon: графіка, геймпади, додатки та моди.",
|
||||
"horizon.pages.graphics.tooltip": "Графіка просто: кожен параметр показує, чого він коштує.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Усі параметри графіки, зокрема з Horizon, із зазначенням витрат кожного.",
|
||||
"horizon.pages.controller.tooltip": "Геймпади: їхні кнопки, стіки, вібрація та навігація в меню.",
|
||||
"horizon.pages.extras.tooltip": "Необов’язкові додатки, усі типово вимкнені: гра лишається ванільною, доки ви не ввімкнете якийсь.",
|
||||
"horizon.pages.agent.tooltip": "Дозвольте власному ШІ-агентові грати: horizon-ctl і MCP-клієнти на цьому комп’ютері.",
|
||||
"horizon.pages.split_profiles.tooltip": "Хто грає: профіль, що зберігається в цій теці гри.",
|
||||
"horizon.pages.shader_packs.tooltip": "Пакети шейдерів із теки shaderpacks: власні пакети Horizon і пакети у форматі shaders/. Одночасно працює один.",
|
||||
"horizon.pages.mods.tooltip": "Моди, створені для Horizon, із теки horizon-mods. Кожен працює в ізольованому середовищі й залишається вимкненим, доки ви його не ввімкнете.",
|
||||
"horizon.pages.open.graphics": "Графіка…",
|
||||
"horizon.pages.open.graphics_advanced": "Розширена графіка…",
|
||||
"horizon.pages.open.shader_packs": "Пакети шейдерів…",
|
||||
"horizon.pages.open.controller": "Налаштування геймпада…",
|
||||
"horizon.pages.open.extras": "Додатки…",
|
||||
"horizon.pages.open.mods": "Моди…",
|
||||
"horizon.pages.open.agent": "ШІ-агент…",
|
||||
"horizon.pages.open.horizon_graphics": "Графіка Horizon…",
|
||||
"horizon.pages.open.controller_binds": "Кнопки геймпада…",
|
||||
"horizon.pages.open.horizon_settings": "Налаштування Horizon…",
|
||||
"horizon.mods.empty": "Немає модів: покладіть файли .hmod у теку horizon-mods",
|
||||
"horizon.mods.stopped": " (зупинено)",
|
||||
"horizon.mods.not_built": " (не зібрано)",
|
||||
"horizon.mods.dev": " (розробка)",
|
||||
"horizon.mods.settings": "Налаштування",
|
||||
"horizon.mods.open_folder": "Відкрити теку з модами",
|
||||
"horizon.shader_packs.empty": "Немає пакетів шейдерів: покладіть їх у теку shaderpacks",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.shader_options.open": "Налаштування шейдерів…",
|
||||
"horizon.shader_options.profile": "Профіль",
|
||||
"horizon.shader_options.loading": "Читання налаштувань набору шейдерів…",
|
||||
"horizon.agent.copy_details": "Копіювати дані з’єднання",
|
||||
"horizon.multiplayer.join": "Приєднатися",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Скасувати перепідключення",
|
||||
"horizon.multiplayer.join_friend": "Приєднатися до друга",
|
||||
"horizon.multiplayer.invite_code": "Код запрошення",
|
||||
"horizon.multiplayer.relay": "Ретранслятор (необов’язково)",
|
||||
"Preset": "Шаблон",
|
||||
"LOD Distance": "Дальність LOD",
|
||||
"Far Detail": "Деталі вдалині",
|
||||
"Cave Culling": "Відсікання печер",
|
||||
"Entity Culling": "Відсік. сутностей",
|
||||
"Chunk Updates": "Оновл. чанків",
|
||||
"Upload Budget": "Вивантаження",
|
||||
"Sky": "Небо",
|
||||
"Fog": "Туман",
|
||||
"Weather": "Погода",
|
||||
"Animated Textures": "Анімовані текстури",
|
||||
"Present Mode": "Виведення",
|
||||
"Frame Pacing": "Темп кадрів",
|
||||
"Worker Threads": "Робочі потоки",
|
||||
"Resolution Scale": "Масштаб зображення",
|
||||
"Anti-aliasing": "Згладжування",
|
||||
"Upscaling": "Апскейлінг",
|
||||
"HDR Settings...": "Налаштування HDR…",
|
||||
"Paper White": "Біла точка",
|
||||
"Peak Brightness": "Пік яскравості",
|
||||
"Black Level": "Рівень чорного",
|
||||
"UI Brightness": "Яскравість UI",
|
||||
"Battery Mode": "Режим батареї",
|
||||
"Graphics API": "API графіки",
|
||||
"Performance Overlay": "Накладка швидкодії",
|
||||
"Full Detail Distance": "Дальність деталей",
|
||||
"On": "Увімк.",
|
||||
"Off": "Вимк.",
|
||||
"Unlimited": "Без обмежень",
|
||||
"Auto": "Авто",
|
||||
"Custom": "Власний",
|
||||
"Performance": "Продуктивність",
|
||||
"Balanced": "Збалансований",
|
||||
"Quality": "Якість",
|
||||
"Ultra": "Ультра",
|
||||
"Ultra Performance": "Ультрапродукт.",
|
||||
"Native": "Нативна",
|
||||
"Full": "Повна",
|
||||
"Reduced": "Знижена",
|
||||
"Efficient": "Економний",
|
||||
"Precise": "Точний",
|
||||
"Auto (VSync)": "Авто (VSync)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "Mailbox",
|
||||
"Immediate": "Immediate",
|
||||
"Vulkan": "Vulkan",
|
||||
"DirectX 12": "DirectX 12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS і графік",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "Без витрат",
|
||||
"Low cost": "Низькі витрати",
|
||||
"Medium cost": "Середні витрати",
|
||||
"High cost": "Високі витрати",
|
||||
"Saves time": "Заощаджує час",
|
||||
"Rendering": "Промальовування",
|
||||
"Display": "Дисплей",
|
||||
"Preferences": "Уподобання",
|
||||
"Overlay": "Накладка",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "Графіка",
|
||||
"Graphics: Advanced": "Графіка: розширені",
|
||||
"Graphics: HDR": "Графіка: HDR",
|
||||
"Advanced...": "Розширені…",
|
||||
"Simple...": "Прості…",
|
||||
"Done": "Готово",
|
||||
"This feature is not yet available in Horizon.": "Ця функція ще недоступна в Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Ваш дисплей або система не підтримує вивід HDR.",
|
||||
"Turn HDR on to use it.": "Увімкніть HDR, щоб користуватися цим.",
|
||||
"%s chunks": "%s чанків",
|
||||
"%s per frame": "%s на кадр",
|
||||
"%s MiB/frame": "%s MiB/кадр",
|
||||
"%s KiB/frame": "%s KiB/кадр",
|
||||
"%s nits": "%s ніт",
|
||||
"Auto (%s)": "Авто (%s)",
|
||||
"Auto (%s nits)": "Авто (%s ніт)",
|
||||
"%.2f nits": "%.2f ніт",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Дальність повних деталей. Дальність LOD сягає далі.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Спрощена далека місцевість локальних світів. Інтегр. GPU: до 128.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Знижена: далека місцевість простіша, швидше на цьому GPU.",
|
||||
"Reduced draws far terrain coarser, for GPUs it slows down.": "Знижена: простіша далека місцевість для повільних GPU.",
|
||||
"Field of view. Wider shows more terrain at once.": "Поле зору. Ширше показує більше місцевості одночасно.",
|
||||
"Caps frames per second; a cap saves power and heat.": "Обмежує к/с; обмеження заощаджує енергію й зменшує нагрів.",
|
||||
"Waits for the display's refresh: no tearing, a little latency.": "Чекає на оновлення дисплея: без розривів, невелика затримка.",
|
||||
"Size of menus and the HUD.": "Розмір меню та HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Задає параметри нижче разом. Зміна одного дає «Власний».",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лава, вогонь і сонце світліші за білий. Потрібен HDR.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Найтемніший чорний вашого дисплея. 0 для OLED; LCD трохи світяться.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Наскільки яскравими бувають відблиски. Авто: пік, який повідомляє система.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Яскравість білого паперу та меню. Авто: білий вашого стола.",
|
||||
"How bright the HUD and menus are, against paper white.": "Наскільки яскраві HUD і меню відносно білого паперу.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Як далеко працюють моби, посіви й механізми. Витрачає CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Відстань малювання мобів і предметів, у частці від норми.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускає приховану місцевість. Вимкнення коштує дорого.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Ховає моделі за блоками. Вимкніть із власними пакетами.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Секцій сітки за кадр. Менше — плавніше під час завантаження.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "М’які тіні в кутах. Витрачає лише час на побудову чанків.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Менші копії текстур блоків для віддаленої місцевості: менше мерехтіння.",
|
||||
"Blends grass and water colours across biomes when building chunks.": "Змішує кольори трави та води між біомами під час побудови чанків.",
|
||||
"Cloud layer: off, flat (fast) or 3D (fancy).": "Шар хмар: вимкнено, пласкі (швидкі) або 3D (красиві).",
|
||||
"How far clouds reach, in chunks.": "Як далеко сягають хмари, у чанках.",
|
||||
"Draws the sky, sun, moon and stars.": "Малює небо, сонце, місяць і зірки.",
|
||||
"Distance fog. Off shows the edge of the loaded world.": "Туман на відстані. Вимкнений показує межу завантаженого світу.",
|
||||
"Draws rain and snow.": "Малює дощ і сніг.",
|
||||
"How far rain and snow fall around you.": "Як далеко навколо вас падають дощ і сніг.",
|
||||
"How many particles effects spawn.": "Скільки частинок створюють ефекти.",
|
||||
"Round shadows under mobs and items.": "Круглі тіні під мобами та предметами.",
|
||||
"Leaves with see-through gaps. Off draws them solid.": "Листя з просвітами. Вимкнено — малюється суцільним.",
|
||||
"Sorts water and glass more exactly behind each other.": "Точніше сортує воду та скло одне за одним.",
|
||||
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Які перебудови чанків чекають на кадр: жодні, найближчі чи ваші.",
|
||||
"Animates water, lava, fire and other moving textures.": "Анімує воду, лаву, вогонь та інші рухомі текстури.",
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Пікселі світу на піксель екрана. Нижче 100% — швидше для GPU.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Згладжує краї. FXAA: м’якше. TAA: найгладше, можливі шлейфи.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Менший рендер, відновлення через TAA. Швидше, м’якше.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Вивід HDR на HDR-дисплеї: лава, вогонь і сонце яскравіші.",
|
||||
"How frames reach the screen while VSync is off.": "Як кадри потрапляють на екран, коли VSync вимкнено.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "«Економний» спить до наступного кадру; «Точний» чекає активно.",
|
||||
"Fills the screen (borderless).": "Заповнює екран (без рамки).",
|
||||
"Brightens dark places.": "Освітлює темні місця.",
|
||||
"Lowers the frame rate when minimised, or idle too.": "Знижує частоту кадрів при згортанні, а також при бездіяльності.",
|
||||
"Sways the view while walking.": "Розхитує вид під час ходьби.",
|
||||
"Fades newly loaded chunks in.": "Плавно показує щойно завантажені чанки.",
|
||||
"Darkens the screen's edges.": "Затемнює краї екрана.",
|
||||
"Shows when the world saves.": "Показує, коли світ зберігається.",
|
||||
"Shows attack cooldown.": "Показує перезарядку атаки.",
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS (30 у меню/без фокуса), половина потоків: економія.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Потоки генерації, світла й сітки чанків. Після перезапуску.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, інакше DirectX 12 (Windows)/OpenGL (Linux). Після рестарту.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Дані сітки до GPU за кадр. Менше — плавніше завантаження.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота та час кадрів поверх гри, з графіком.",
|
||||
"horizon.multiplayer.finding_friend": "Шукаємо світ друга…",
|
||||
"horizon.loading.opening_world": "Відкриття світу",
|
||||
"horizon.loading.cancelling_world": "Скасування",
|
||||
@@ -19,10 +210,84 @@
|
||||
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
|
||||
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…",
|
||||
"horizon.pages.shader_options": "Налаштування шейдерів",
|
||||
"horizon.pages.split_profiles": "Хто грає?",
|
||||
"horizon.pages.split_profiles.tooltip": "Хто грає: профіль, що зберігається в цій теці гри.",
|
||||
"horizon.shader_options.open": "Налаштування шейдерів…",
|
||||
"horizon.shader_options.profile": "Профіль",
|
||||
"horizon.shader_options.loading": "Читання налаштувань набору шейдерів…"
|
||||
"horizon.title.not_affiliated": "Не пов’язано з Mojang чи Microsoft",
|
||||
"horizon.extras.header.lighting": "Освітлення",
|
||||
"horizon.extras.header.third_person": "Вигляд від третьої особи",
|
||||
"horizon.extras.header.inventory": "Інвентар",
|
||||
"horizon.extras.header.multiplayer": "Гра в мережі",
|
||||
"horizon.extras.fullbright": "Повна яскравість",
|
||||
"horizon.extras.dynamic_lights": "Динамічне світло",
|
||||
"horizon.extras.own_name_tag": "Власний нік",
|
||||
"horizon.extras.auto_reconnect": "Перепідключення",
|
||||
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
|
||||
"horizon.extras.mouse_tweaks_wheel": "Перенос коліщатком",
|
||||
"horizon.extras.sort": "Кнопки сортування",
|
||||
"horizon.extras.sort_key": "Сортувати",
|
||||
"horizon.extras.no_ai": "Без ШІ",
|
||||
"horizon.extras.agent_control": "Керування агентом",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.fullbright.tooltip": "Показує блоки та сутності при повному освітленні, навіть у темних місцях.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Смолоскипи та інші джерела світла, які тримають у руках, викинуті чи палаючі, освітлюють блоки навколо.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Показує ваше ім’я над головою від третьої особи, як його бачать інші гравці. Приховано, коли сервер ховає імена вашої команди або ви невидимі.",
|
||||
"horizon.extras.auto_reconnect.tooltip": "Знову приєднується до сервера після втрати з’єднання чи перезапуску, але ніколи після вигнання чи блокування.",
|
||||
"horizon.extras.mouse_tweaks.tooltip": "Перетягування ПКМ кладе по одному в комірку, ЛКМ збирає утримуваний предмет, Shift + ЛКМ переміщує кожну комірку.",
|
||||
"horizon.extras.mouse_tweaks_wheel.tooltip": "Прокрутка вниз надсилає один предмет із комірки на інший бік, вгору — повертає один назад.",
|
||||
"horizon.extras.sort.tooltip": "Сортує інвентар, скрині, діжки, скрині Енду та шалкерові коробки в порядку вкладок творчого режиму, об’єднуючи стеки.",
|
||||
"horizon.extras.sort_key.tooltip": "Сортує частину під курсором. Натисніть, а потім клавішу; Escape скасовує призначення.",
|
||||
"horizon.extras.no_ai.tooltip": "Приховує та вимикає всі функції ШІ: керування агентом, horizon-ctl і MCP-сервери. Також перемикач у лаунчері.",
|
||||
"horizon.extras.agent_control.tooltip": "Ваш власний ШІ-агент може грати в цю гру через horizon-ctl або MCP-клієнт, лише на цьому комп’ютері.",
|
||||
"horizon.extras.discord_presence.tooltip": "Показує «Грає в Horizon» у вашому профілі Discord, поки Discord запущено: меню, одиночна чи мережева гра, вимір і час гри. Ніколи не назву світу чи адресу сервера.",
|
||||
"Calibrate...": "Калібрувати…",
|
||||
"Calibrate Anyway...": "Все одно калібрувати…",
|
||||
"Use Automatic Setup": "Використати автоналаштування",
|
||||
"Shortcut: %s": "Гаряча клавіша: %s",
|
||||
"Next": "Далі",
|
||||
"HDR Calibration %s/%s: %s": "Калібрування HDR %s/%s: %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Три значення нижче налаштовуються на повному екрані.",
|
||||
"A key that opens this screen from the game. Not bound at first.": "Клавіша, що відкриває цей екран із гри. Спершу не призначена.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Дисплей не повідомив яскравість: відкалібруйте його.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Уже налаштовано. Калібруйте, лише якщо HDR виглядає погано.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Повертає Пік яскравості й Білу точку до налаштувань дисплея.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Налаштовано для дисплея. Залишайте «Авто», якщо все гаразд.",
|
||||
"Lower it until the logo just disappears into the black.": "Зменшуйте, доки логотип не зникне в чорному.",
|
||||
"Raise it until the logo just disappears into the bright square.": "Збільшуйте, доки логотип не зникне в яскравому квадраті.",
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Білий, на який комфортно довго дивитися за вашого світла.",
|
||||
"horizon.splitscreen.profiles.forget": "Забути",
|
||||
"horizon.splitscreen.profiles.create": "Створити",
|
||||
"horizon.splitscreen.profiles.you": " - ви",
|
||||
"horizon.splitscreen.profiles.forget_named": "Забути %s",
|
||||
"horizon.splitscreen.profiles.previous": "Назад",
|
||||
"horizon.splitscreen.profiles.next": "Далі",
|
||||
"horizon.splitscreen.profiles.new_button": "Новий профіль…",
|
||||
"horizon.splitscreen.profiles.forget_title": "Забути профіль?",
|
||||
"horizon.splitscreen.profiles.forget_leaves": "Він зникне зі списку цього екземпляра.",
|
||||
"horizon.splitscreen.profiles.forget_kept": "Прогрес у світі та налаштування зберігаються.",
|
||||
"horizon.splitscreen.profiles.new_title": "Новий профіль",
|
||||
"horizon.splitscreen.profiles.name_hint": "Ім’я (3–16 літер, цифр або _)",
|
||||
"horizon.splitscreen.profiles.switch_title": "Змінити профіль",
|
||||
"horizon.splitscreen.profiles.offline_note": "Офлайн-профілі належать цьому екземпляру",
|
||||
"horizon.splitscreen.profiles.move": "Змінити місце гри",
|
||||
"horizon.splitscreen.profiles.move_place": "Змінити місце гри: %s",
|
||||
"horizon.friends.title": "Грати з друзями",
|
||||
"horizon.friends.hint": "Друзі вставляють код після кнопки «Друзі» на головному екрані, у «Пряме з’єднання» або на сторінці «Грати» в лаунчері.",
|
||||
"horizon.friends.copied": "Скопійовано",
|
||||
"horizon.friends.copy_code": "Копіювати код",
|
||||
"horizon.friends.stop_sharing": "Припинити поширення",
|
||||
"horizon.friends.none_yet": "Друзів тут поки немає.",
|
||||
"horizon.friends.here_now": "Зараз тут: %s",
|
||||
"horizon.friends.share_intro": "Поділіться цим світом за допомогою коду запрошення: він відкривається для LAN, а друзі з інтернету приєднуються за кодом. Жоден наш сервер не залучається.",
|
||||
"horizon.friends.start_sharing": "Почати поширення",
|
||||
"horizon.friends.troubleshooting": "Усунення несправностей…",
|
||||
"horizon.friends.stopped": "Поширення коду запрошення припинено.",
|
||||
"horizon.friends.short_code_changed": "Хтось раз за разом вводив неправильний короткий код, тож його змінено.",
|
||||
"horizon.friends.wrong_code": "Хтось ввів неправильний код.",
|
||||
"horizon.friends.code_ready": "Ваш код запрошення готовий: Ігрове меню, Грати з друзями. Скопіюйте його та надішліть друзям.",
|
||||
"horizon.friends.share_failed": "Не вдалося поділитися світом: %s",
|
||||
"horizon.friends.finding_route": "З’ясовуємо, як друзі можуть до вас під’єднатися…",
|
||||
"horizon.osk.shift": "Shift",
|
||||
"horizon.osk.space": "Пробіл",
|
||||
"horizon.osk.delete": "Видалити",
|
||||
"horizon.osk.type": "Ввести",
|
||||
"horizon.osk.symbols": "Символи",
|
||||
"horizon.osk.close": "Закрити"
|
||||
}
|
||||
@@ -209,5 +209,102 @@
|
||||
"horizon.pages.split_profiles.tooltip": "谁在玩:保存在此游戏文件夹中的一份档案。",
|
||||
"horizon.shader_options.open": "光影选项…",
|
||||
"horizon.shader_options.profile": "档案",
|
||||
"horizon.shader_options.loading": "正在读取光影包的选项…"
|
||||
"horizon.shader_options.loading": "正在读取光影包的选项…",
|
||||
"horizon.pages.split_settings": "分屏设置",
|
||||
"horizon.pages.split_setup": "分屏",
|
||||
"horizon.pages.split_setup.tooltip": "在本机游玩的玩家:手柄可以加入你的世界。",
|
||||
"horizon.pages.open.split_setup": "分屏…",
|
||||
"horizon.title.not_affiliated": "与 Mojang 或 Microsoft 无关",
|
||||
"horizon.extras.header.lighting": "光照",
|
||||
"horizon.extras.header.third_person": "第三人称",
|
||||
"horizon.extras.header.particles": "粒子",
|
||||
"horizon.extras.header.inventory": "物品栏",
|
||||
"horizon.extras.header.chat": "聊天",
|
||||
"horizon.extras.header.multiplayer": "多人游戏",
|
||||
"horizon.extras.header.sound": "声音",
|
||||
"horizon.extras.fullbright": "全亮",
|
||||
"horizon.extras.dynamic_lights": "动态光源",
|
||||
"horizon.extras.own_name_tag": "自己的名字标签",
|
||||
"horizon.extras.hide_block_particles": "隐藏方块破坏粒子",
|
||||
"horizon.extras.auto_reconnect": "自动重连",
|
||||
"horizon.extras.ui_sound_reserve": "为界面音效保留声道",
|
||||
"horizon.extras.mouse_tweaks": "鼠标调整控制",
|
||||
"horizon.extras.mouse_tweaks_wheel": "滚轮移动物品",
|
||||
"horizon.extras.sort": "排序按钮",
|
||||
"horizon.extras.sort_key": "排序快捷键",
|
||||
"horizon.extras.no_ai": "关闭 AI",
|
||||
"horizon.extras.agent_control": "代理控制",
|
||||
"horizon.extras.discord_presence": "Discord 动态状态",
|
||||
"horizon.extras.splitscreen": "分屏",
|
||||
"horizon.extras.chat_animation": "平滑聊天",
|
||||
"horizon.extras.chat_indicator": "消息指示条",
|
||||
"horizon.extras.fullbright.tooltip": "即使在黑暗区域也以全亮显示方块和实体。",
|
||||
"horizon.extras.dynamic_lights.tooltip": "手持、掉落或燃烧中的火把等光源会照亮周围方块。",
|
||||
"horizon.extras.own_name_tag.tooltip": "在第三人称视角中把名字显示在头顶,与其它玩家看到的一致。当服务器隐藏团队名字或你处于隐身状态时不显示。",
|
||||
"horizon.extras.hide_block_particles.tooltip": "隐藏方块被破坏时迸出的碎屑粒子,以及挖掘时掉落的碎屑。其它粒子仍会显示。",
|
||||
"horizon.extras.auto_reconnect.tooltip": "连接中断或服务器重启后自动重新加入,被踢出或封禁后不会。",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "世界中的声音会为界面音效留出 247 个声道中的 16 个,这样大量声音(比如一次大爆炸)不会把菜单点击音挤掉。",
|
||||
"horizon.extras.mouse_tweaks.tooltip": "右键拖动每次放一个物品,左键拖动合并手持物品,Shift + 左键拖动移动所有槽位。",
|
||||
"horizon.extras.mouse_tweaks_wheel.tooltip": "向下把槽位中的一个物品送到另一侧,向上取回一个。",
|
||||
"horizon.extras.sort.tooltip": "按创造模式标签页顺序排序物品栏、箱子、木桶、末影箱和潜影盒,并合并堆叠。",
|
||||
"horizon.extras.sort_key.tooltip": "排序光标下的部分。点击后按一个键;按 Escape 取消绑定。",
|
||||
"horizon.extras.no_ai.tooltip": "隐藏并关闭所有 AI 功能:代理控制、horizon-ctl 和 MCP 服务器。启动器中的开关也一样。",
|
||||
"horizon.extras.agent_control.tooltip": "你自己的 AI 代理可以通过 horizon-ctl 或 MCP 客户端玩这个游戏,仅限本机。",
|
||||
"horizon.extras.discord_presence.tooltip": "Discord 运行时在你的 Discord 资料上显示“正在玩 Horizon”:菜单、单人、多人、维度以及游玩时长。绝不显示世界名称或服务器地址。",
|
||||
"Calibrate...": "校准…",
|
||||
"Calibrate Anyway...": "仍要校准…",
|
||||
"Use Automatic Setup": "使用自动设置",
|
||||
"Shortcut: %s": "快捷键:%s",
|
||||
"Next": "下一步",
|
||||
"HDR Calibration %s/%s: %s": "HDR 校准 %s/%s:%s",
|
||||
"Sets the three below full screen, one test picture at a time.": "以全屏方式逐张显示测试图,来设置下面三项。",
|
||||
"A key that opens this screen from the game. Not bound at first.": "在游戏中打开此界面的按键。初始未绑定。",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "显示器没有报告自身亮度:需要通过校准来设置。",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "已按你的显示器设置好。只有在 HDR 显示异常时才需要校准。",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "将峰值亮度和纸白亮度恢复为你的显示器设置。",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "已按你的显示器设置。除非显示异常,否则保持“自动”。",
|
||||
"Lower it until the logo just disappears into the black.": "调低,直到标志刚好没入黑色背景。",
|
||||
"Raise it until the logo just disappears into the bright square.": "调高,直到标志刚好没入亮色方块。",
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "在你游玩时的光照环境下,把卡片调成长时间观看也舒适的白色。",
|
||||
"horizon.splitscreen.profiles.forget": "忘记",
|
||||
"horizon.splitscreen.profiles.create": "创建",
|
||||
"horizon.splitscreen.profiles.you": " - 你",
|
||||
"horizon.splitscreen.profiles.forget_named": "忘记 %s",
|
||||
"horizon.splitscreen.profiles.previous": "上一个",
|
||||
"horizon.splitscreen.profiles.next": "下一个",
|
||||
"horizon.splitscreen.profiles.new_button": "新建档案…",
|
||||
"horizon.splitscreen.profiles.forget_title": "忘记档案?",
|
||||
"horizon.splitscreen.profiles.forget_leaves": "该档案将从此实例的列表中移除。",
|
||||
"horizon.splitscreen.profiles.forget_kept": "世界进度和设置会保留。",
|
||||
"horizon.splitscreen.profiles.new_title": "新建档案",
|
||||
"horizon.splitscreen.profiles.name_hint": "名称(3 到 16 个字母、数字或 _)",
|
||||
"horizon.splitscreen.profiles.switch_title": "切换档案",
|
||||
"horizon.splitscreen.profiles.offline_note": "离线档案属于此实例",
|
||||
"horizon.splitscreen.profiles.move": "移动游玩位置",
|
||||
"horizon.splitscreen.profiles.move_place": "把游玩位置移到:%s",
|
||||
"horizon.friends.title": "与好友一起玩",
|
||||
"horizon.friends.hint": "好友可以在标题画面的“好友”按钮后、直连界面或启动器的“开始游戏”页粘贴该代码。",
|
||||
"horizon.friends.copied": "已复制",
|
||||
"horizon.friends.copy_code": "复制代码",
|
||||
"horizon.friends.stop_sharing": "停止共享",
|
||||
"horizon.friends.none_yet": "还没有好友加入。",
|
||||
"horizon.friends.here_now": "当前在线:%s",
|
||||
"horizon.friends.share_intro": "通过邀请码共享这个世界:它会开放到局域网,互联网上的好友可以用代码加入。不经过我们的任何服务器。",
|
||||
"horizon.friends.start_sharing": "开始共享",
|
||||
"horizon.friends.troubleshooting": "疑难解答…",
|
||||
"horizon.friends.stopped": "已停止共享邀请码。",
|
||||
"horizon.friends.short_code_changed": "有人反复尝试错误的短代码,因此已更换。",
|
||||
"horizon.friends.wrong_code": "有人尝试了错误的代码。",
|
||||
"horizon.friends.code_ready": "你的邀请码已就绪:游戏菜单 → 与好友一起玩。复制后发给好友。",
|
||||
"horizon.friends.share_failed": "无法共享世界:%s",
|
||||
"horizon.friends.finding_route": "正在寻找好友连接你的方式…",
|
||||
"horizon.osk.shift": "Shift",
|
||||
"horizon.osk.space": "空格",
|
||||
"horizon.osk.delete": "删除",
|
||||
"horizon.osk.type": "输入",
|
||||
"horizon.osk.symbols": "符号",
|
||||
"horizon.osk.close": "关闭",
|
||||
"horizon.extras.chat_animation.tooltip": "新聊天行从左侧滑入并淡入,而不是整行直接出现。",
|
||||
"horizon.extras.chat_indicator.tooltip": "聊天行左侧的彩色竖条。",
|
||||
"horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。"
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
|
||||
//! work on the machine changes it less. The checksum of the meshes shows that an
|
||||
//! A/B pair of builds made the same ones.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Profile the two mandatory passes over gameplay-facts.json independently.
|
||||
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
|
||||
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
#[allow(dead_code)]
|
||||
#[path = "../../horizon-items/src/blocks/decode.rs"]
|
||||
mod decode;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Compare the read + Value parse stages in BlockFacts::load and Assets::load.
|
||||
//! Usage: json_load_bench RESOURCE_ROOT [REPETITIONS]; see tools/json_load_fixtures.py.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//!
|
||||
//! WORK is a `horizon-port-pack` work folder: `oracle/facts.json` and
|
||||
//! `mods/<id>/carry/`. The root is built in `WORK/horizon-root`.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
assets::Assets,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! for live requested heap bytes; leave it unset for latency measurements.
|
||||
//! cargo run --profile fast -p horizon-client --example model_library_bench -- ROOT [--parity]
|
||||
//! Run fresh processes, warm up twice, then compare multiple alternating baseline/candidate runs.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{assets::Assets, item_render::ItemRenderer};
|
||||
use std::{
|
||||
alloc::{GlobalAlloc, Layout, System},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Compare the old two-parse loading path with catalog construction from shared facts.
|
||||
//! Run: cargo run -p horizon-client --profile fast --example model_states_bench -- 15
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{Result, models::StateModels, sprites::Sprites};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::{
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! generated around them blend with them (`Blender.of`).
|
||||
//!
|
||||
//! old_world_fixture <source world> <destination world> <x0,z0,x1,z1>
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_world::{
|
||||
nbt::{Limits, Tag},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! entity reaches, with the per-tick palette map against the resident cache.
|
||||
//! Run with prepared client resources:
|
||||
//! cargo run --profile fast -p horizon-client --example portal_scan_bench -- RESOURCES
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, SECTIONS, Section},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//!
|
||||
//! This file only lists the probes, so that adding one reruns no harness: tools/parity.py leaves it out of
|
||||
//! their dependencies. How a probe runs is in dispatch.rs.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
#[macro_use]
|
||||
mod dispatch;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Selection cost only (not block simulation). Run with prepared client resources.
|
||||
//! cargo run --profile fast -p horizon-client --example random_tick_eligibility_bench -- RESOURCES
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, Section},
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! and the process RSS (what the allocator holds on to). On macOS it ends
|
||||
//! with `footprint`'s physical footprint, the number the OS charges: RSS also
|
||||
//! counts freed pages the allocator marked reusable.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
assets::Assets, biome::Biomes, blocks::BlockFacts, collisions, crafting::Recipes, items::Items,
|
||||
light::LightTables, mesher::MeshTables, models, motion::MotionResources,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! full chunk NBT encoder. Run with `cargo run --profile fast -p horizon-client
|
||||
//! --example save_batch_bench`. Add `-- --nbt` to also time serialization.
|
||||
//! Exact NBT equality is always validated. No GPU, compression or disk I/O timed.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
biome::Biomes,
|
||||
chunk::{Chunk, block_strategy},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Run each mode in a separate process to capture CPU usage and peak RSS.
|
||||
//! The deferred mode evaluates both phases contiguously for fair loader comparison;
|
||||
//! actual client startup performs other initialization between these phases.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{assets::Assets, blocks::BlockFacts};
|
||||
use horizon_world::level::Behaviours;
|
||||
use std::{path::Path, time::Instant};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! to the ones decoded from the PNGs. Deletes the cache file in ROOT, loads (decodes
|
||||
//! and writes it), loads again (reads it) and compares the sprite arrays.
|
||||
//! cargo run --profile fast -p horizon-client --example sprite_cache_parity -- ROOT
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::assets::{Assets, RenderAssets};
|
||||
use std::{
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! horizon-ctl: play and script a running Horizon client through its control
|
||||
//! socket, from a shell, a script or an AI agent (`horizon-ctl mcp`).
|
||||
#![forbid(unsafe_code)]
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
mod commands;
|
||||
mod launch;
|
||||
mod mcp;
|
||||
|
||||
@@ -325,10 +325,73 @@ pub fn interact(
|
||||
}
|
||||
}
|
||||
|
||||
/// What `interact` would answer for `held`, changing nothing: a client's
|
||||
/// `useItemOn`, where every interaction checks the same conditions and acts
|
||||
/// only `if (!level.isClientSide())`.
|
||||
pub fn outcome(
|
||||
cauldron: &BlockState,
|
||||
under_water: bool,
|
||||
removes_dye: bool,
|
||||
held: &ItemStack,
|
||||
) -> Outcome {
|
||||
struct Dry(Option<ItemStack>);
|
||||
impl Player for Dry {
|
||||
fn creative(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn hand(&mut self) -> &mut Option<ItemStack> {
|
||||
&mut self.0
|
||||
}
|
||||
fn add(&mut self, _: &mut ItemStack) -> bool {
|
||||
true
|
||||
}
|
||||
fn contains(&self, _: &ItemStack) -> bool {
|
||||
false
|
||||
}
|
||||
fn drop_stack(&mut self, _: ItemStack) {}
|
||||
}
|
||||
interact(
|
||||
cauldron,
|
||||
under_water,
|
||||
removes_dye,
|
||||
&mut Dry(Some(held.clone())),
|
||||
)
|
||||
.outcome
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_client_sees_the_bucket_taken_without_acting() {
|
||||
let empty = state("minecraft:cauldron", None);
|
||||
let water = |l| state("minecraft:water_cauldron", Some(l));
|
||||
let stack = |id| ItemStack::new(id, 1);
|
||||
let outcome = |c: &BlockState, under_water, id| outcome(c, under_water, false, &stack(id));
|
||||
assert_eq!(
|
||||
outcome(&empty, false, "minecraft:water_bucket"),
|
||||
Outcome::Success
|
||||
);
|
||||
assert_eq!(
|
||||
outcome(&empty, false, "minecraft:lava_bucket"),
|
||||
Outcome::Success
|
||||
);
|
||||
// fillLavaInteraction under water: CONSUME, the bucket kept.
|
||||
assert_eq!(
|
||||
outcome(&empty, true, "minecraft:lava_bucket"),
|
||||
Outcome::Consume
|
||||
);
|
||||
assert_eq!(
|
||||
outcome(&water(3), false, "minecraft:bucket"),
|
||||
Outcome::Success
|
||||
);
|
||||
// fillBucket needs a full cauldron.
|
||||
assert_eq!(outcome(&water(2), false, "minecraft:bucket"), Outcome::Pass);
|
||||
assert_eq!(outcome(&empty, false, "minecraft:bucket"), Outcome::Pass);
|
||||
assert_eq!(outcome(&empty, false, "minecraft:stone"), Outcome::Pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_burning_entity_lowers_water_and_melts_powder_snow() {
|
||||
let water = |l| state("minecraft:water_cauldron", Some(l));
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Horizon native client: resources, world streaming, rendering and the
|
||||
//! bindings between the validated simulation pieces and the game loop.
|
||||
#![forbid(unsafe_code)]
|
||||
extern crate self as horizon_client;
|
||||
pub mod advancements;
|
||||
pub mod ambience;
|
||||
pub mod animate_tick;
|
||||
pub mod arrows;
|
||||
pub mod block_contact;
|
||||
pub use horizon_client_render::{
|
||||
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities,
|
||||
block_entity_anim, block_entity_render, border_render, breaking, brewing, camera, chat, chunk,
|
||||
chunk_map, cit, clouds, collisions, command_block, containers, crash, datapack, dynamic_light,
|
||||
effects, environment, extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui,
|
||||
hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block,
|
||||
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
|
||||
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
|
||||
options_ui, overlay, pack_screen, packs, par, particles, poi_sections, post_chain,
|
||||
potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk, section_mask, settings,
|
||||
shader_options, shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview,
|
||||
spans, split_layout, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
|
||||
test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets,
|
||||
world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
pub mod auto_jump;
|
||||
pub mod banner;
|
||||
pub mod beehive;
|
||||
pub mod benchmark;
|
||||
pub mod block_entity_item;
|
||||
pub mod block_pattern;
|
||||
pub mod block_transform;
|
||||
pub mod boss_overlay;
|
||||
pub mod cauldron;
|
||||
pub mod chunk_io;
|
||||
pub mod clock;
|
||||
pub mod command_storage;
|
||||
pub mod confirm_screen;
|
||||
pub mod container_screen;
|
||||
pub mod content_identity;
|
||||
pub mod control;
|
||||
pub mod crafting;
|
||||
pub mod creative;
|
||||
pub mod cushion;
|
||||
pub mod data_cmd;
|
||||
pub mod debug_chart;
|
||||
pub mod debug_entries;
|
||||
pub mod debug_keys;
|
||||
pub mod debug_overlay;
|
||||
pub mod debugworld;
|
||||
pub mod dialog;
|
||||
pub mod dialog_screen;
|
||||
pub mod discord_presence;
|
||||
pub mod end_crystal;
|
||||
pub mod end_gateway;
|
||||
pub mod entities;
|
||||
pub mod entity_culling;
|
||||
pub mod entity_nbt;
|
||||
pub mod extras_hud;
|
||||
pub mod falling_block;
|
||||
pub mod fire_render;
|
||||
pub mod first_person;
|
||||
pub mod flatgen;
|
||||
pub mod friends_screen;
|
||||
pub mod furnace;
|
||||
pub mod game_mode_switcher;
|
||||
pub mod game_rules_screen;
|
||||
pub mod gizmos;
|
||||
pub mod gossip;
|
||||
pub mod health;
|
||||
pub mod heartbeat;
|
||||
pub mod hotbar;
|
||||
pub mod hunger;
|
||||
pub mod inv_sort;
|
||||
pub mod item_activation;
|
||||
pub mod item_render;
|
||||
pub mod item_sync;
|
||||
pub mod item_tooltip;
|
||||
pub mod lan;
|
||||
pub mod landing;
|
||||
pub mod locator_bar;
|
||||
pub mod lod_net;
|
||||
pub mod menu;
|
||||
pub mod merchant;
|
||||
pub mod mobs;
|
||||
pub mod mod_content;
|
||||
pub mod motion;
|
||||
pub mod multiplayer;
|
||||
pub mod music;
|
||||
pub mod native_data;
|
||||
pub mod net;
|
||||
pub mod nether_portal;
|
||||
pub mod no_resources;
|
||||
pub mod offline_name;
|
||||
pub mod old_world;
|
||||
pub mod osk;
|
||||
pub mod patrol;
|
||||
pub mod perf_overlay;
|
||||
pub mod pick_item;
|
||||
pub mod player_sync;
|
||||
pub mod portal_sections;
|
||||
pub mod pose;
|
||||
pub mod pregen;
|
||||
pub mod projectiles;
|
||||
pub mod raid;
|
||||
pub mod ram_limit;
|
||||
pub mod random_tick_sections;
|
||||
pub mod recipe_book;
|
||||
pub mod recipe_book_screen;
|
||||
pub mod reconnect;
|
||||
pub mod save;
|
||||
pub mod scoreboard_cmd;
|
||||
pub mod screen_layout;
|
||||
pub mod screen_narrator;
|
||||
pub mod social_screen;
|
||||
pub use horizon_server_files as server;
|
||||
pub mod server_packs;
|
||||
pub mod shadows;
|
||||
pub mod spear;
|
||||
pub mod spectator_menu;
|
||||
pub mod sprint;
|
||||
pub mod stations;
|
||||
pub mod stopwatches;
|
||||
pub mod structure_blocks;
|
||||
pub mod text_display;
|
||||
pub mod tick_chunk_membership;
|
||||
pub mod ticket_levels;
|
||||
pub mod title;
|
||||
pub mod toasts;
|
||||
pub mod ui_theme;
|
||||
pub mod vanillagen;
|
||||
pub mod win_screen;
|
||||
pub mod world_spawn;
|
||||
pub use horizon_client_core::Result;
|
||||
pub use horizon_client_core::block_entity_nbt;
|
||||
pub use horizon_client_core::bossbar;
|
||||
pub use horizon_client_core::component;
|
||||
pub use horizon_client_core::fastutil_order;
|
||||
pub use horizon_client_core::gamerules;
|
||||
pub use horizon_client_core::lang;
|
||||
pub use horizon_client_core::nbt_path;
|
||||
pub use horizon_client_core::scoreboard;
|
||||
pub use horizon_client_core::snbt;
|
||||
pub use horizon_client_core::text;
|
||||
pub use horizon_client_input::clipboard;
|
||||
pub use horizon_client_input::gamepad;
|
||||
pub use horizon_client_input::input;
|
||||
pub use horizon_client_input::interaction_input;
|
||||
pub use horizon_client_input::key_names;
|
||||
pub use horizon_client_input::keybinds;
|
||||
pub use horizon_client_input::keymapping;
|
||||
pub use horizon_client_input::mouse;
|
||||
pub use horizon_client_input::window;
|
||||
pub use horizon_command as command;
|
||||
pub use horizon_items::blocks;
|
||||
pub use horizon_items::bundle;
|
||||
pub use horizon_items::cooldowns;
|
||||
pub use horizon_items::enchant;
|
||||
pub use horizon_items::flowers;
|
||||
pub use horizon_items::item_data;
|
||||
pub use horizon_items::items;
|
||||
pub use horizon_items::random_sequences;
|
||||
@@ -896,31 +896,6 @@ fn towards_closest_space(
|
||||
_ => Vec3::new(v.x, v.y, push),
|
||||
}
|
||||
}
|
||||
/// `ExperienceOrb.scanForMerges`, run by each orb on `tickCount % 20 == 1`:
|
||||
/// it absorbs the orbs that `canMerge` within 0.5 blocks of its box,
|
||||
/// adding their counts and keeping the smaller age.
|
||||
pub fn merge_orbs(orbs: &mut Vec<ExperienceOrb>) {
|
||||
let mut removed = vec![false; orbs.len()];
|
||||
for i in 0..orbs.len() {
|
||||
if removed[i] || orbs[i].ticks % 20 != 1 {
|
||||
continue;
|
||||
}
|
||||
let area = orbs[i].bounds().inflate(Vec3::new(0.5, 0.5, 0.5));
|
||||
for j in 0..orbs.len() {
|
||||
if j != i
|
||||
&& !removed[j]
|
||||
&& orbs[j].can_merge(orbs[i].id, orbs[i].value)
|
||||
&& orbs[j].bounds().intersects(area)
|
||||
{
|
||||
orbs[i].count += orbs[j].count;
|
||||
orbs[i].age = orbs[i].age.min(orbs[j].age);
|
||||
removed[j] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut removed = removed.into_iter();
|
||||
orbs.retain(|_| !removed.next().unwrap_or(false));
|
||||
}
|
||||
/// Player experience (Player.giveExperiencePoints / getXpNeededForNextLevel).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Experience {
|
||||
@@ -2347,6 +2322,12 @@ mod armor_stand_motion_tests {
|
||||
let start = Vec3::new(20.5, 20.0, 20.5);
|
||||
let mut stand = ArmorStand::new(1, start, 0.0);
|
||||
stand.on_ground = false;
|
||||
// A summoned stand has no motion yet: the first tick only builds it
|
||||
// (vanilla moves by the velocity, then applies gravity), so it falls
|
||||
// from the second tick on (measured on the real server: drop 0, 0, 0.0784 blocks).
|
||||
tick_stand(&mut stand, &world, &facts, &collision);
|
||||
assert_eq!(stand.position, start);
|
||||
assert!((stand.velocity.y + 0.0784).abs() < 1e-6);
|
||||
tick_stand(&mut stand, &world, &facts, &collision);
|
||||
assert_eq!(stand.previous, start);
|
||||
assert!(
|
||||
|
||||
@@ -355,6 +355,7 @@ pub fn mob_to_nbt(
|
||||
),
|
||||
("Air", Tag::Short(m.air as i16)),
|
||||
("Health", Tag::Float(m.health.max(0.0).to_bits())),
|
||||
("AbsorptionAmount", Tag::Float(m.absorption.to_bits())),
|
||||
(
|
||||
"HurtTime",
|
||||
Tag::Short(m.hurt_time.clamp(0, i32::from(i16::MAX)) as i16),
|
||||
@@ -796,7 +797,24 @@ pub fn mob_to_nbt(
|
||||
put_memories(map, m);
|
||||
crate::mobs::villager::write_nbt(m, map);
|
||||
}
|
||||
"minecraft:wandering_trader" => put_offers(map, m),
|
||||
"minecraft:wandering_trader" => {
|
||||
put_offers(map, m);
|
||||
// WanderingTrader.addAdditionalSaveData: the visit's countdown and
|
||||
// the place it is walking to, a BlockPos codec (an int array).
|
||||
map.insert("DespawnDelay".into(), Tag::Int(m.extra.ints[5]));
|
||||
if let Some(p) = m.extra.point {
|
||||
// The trader keeps its target as a position; the tag is a
|
||||
// BlockPos, so only the block it is in is saved.
|
||||
map.insert(
|
||||
"wander_target".into(),
|
||||
Tag::IntArray(vec![
|
||||
p[0].floor() as i32,
|
||||
p[1].floor() as i32,
|
||||
p[2].floor() as i32,
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
"minecraft:axolotl" => {
|
||||
write_axolotl_memories(m, map);
|
||||
map.insert(
|
||||
@@ -1398,6 +1416,7 @@ pub fn mob_from_nbt(
|
||||
"minecraft:max_health" => m.attrs.max_health = v as f32,
|
||||
"minecraft:movement_speed" => m.attrs.speed = v,
|
||||
"minecraft:attack_damage" => m.attrs.attack_damage = v as f32,
|
||||
"minecraft:attack_knockback" => m.attrs.attack_knockback = v,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1408,6 +1427,9 @@ pub fn mob_from_nbt(
|
||||
add_modifier(&mut m, Attr::FollowRange, id, bonus, Op::AddMultipliedBase);
|
||||
}
|
||||
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
|
||||
m.absorption = float(tag.get("AbsorptionAmount"))
|
||||
.map_or(0.0, |h| h as f32)
|
||||
.max(0.0);
|
||||
// LivingEntity.setHealth clamps to the max health: a horse or llama is made
|
||||
// with the type's and saved before it drops to the one it rolled. (Only
|
||||
// the base is read here, so the clamp would cut a leader zombie's bonus.)
|
||||
@@ -2982,6 +3004,16 @@ fn read_villager(m: &mut Mob, tag: &Tag) {
|
||||
if m.species() == crate::mobs::Species::Villager {
|
||||
crate::mobs::villager::read_nbt(m, tag);
|
||||
}
|
||||
if m.species() == crate::mobs::Species::WanderingTrader {
|
||||
// WanderingTrader.readAdditionalSaveData: both default to idle.
|
||||
m.extra.ints[5] = number(tag.get("DespawnDelay")).unwrap_or(0) as i32;
|
||||
m.extra.point = match tag.get("wander_target") {
|
||||
Some(Tag::IntArray(a)) if a.len() == 3 => {
|
||||
Some([f64::from(a[0]), f64::from(a[1]), f64::from(a[2])])
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
let offers_tag = tag.get("Offers");
|
||||
let xp = number(tag.get("Xp")).unwrap_or(0) as i32;
|
||||
if offers_tag.is_none() && xp == 0 && tag.get("LastRestock").is_none() {
|
||||
@@ -3457,6 +3489,8 @@ pub fn apply_item_components(m: &mut Mob, stack: &crate::items::ItemStack) {
|
||||
Some("minecraft:pig/variant"),
|
||||
Some("minecraft:pig/sound_variant"),
|
||||
),
|
||||
// ZombieNautilus.applyImplicitComponents: the variant only.
|
||||
crate::mobs::Species::ZombieNautilus => (Some("minecraft:zombie_nautilus/variant"), None),
|
||||
_ => (None, None),
|
||||
};
|
||||
if let Some(name) = variant.and_then(|id| text(components.other(id))) {
|
||||
@@ -3965,6 +3999,49 @@ mod sheep_nbt_tests {
|
||||
assert_eq!(number(tag.get("VillagerDataFinalized")), Some(1));
|
||||
}
|
||||
|
||||
/// `WanderingTrader.addAdditionalSaveData`: the visit's countdown and the
|
||||
/// place it is walking to (a `BlockPos` codec, so an int array), which the
|
||||
/// despawn and stroll goals read back after a reload.
|
||||
#[test]
|
||||
fn a_wandering_traders_countdown_and_target_survive_a_save() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
types["minecraft:wandering_trader"].clone(),
|
||||
Vec3::new(0.5, 64.0, 0.5),
|
||||
0.0,
|
||||
);
|
||||
m.extra.ints[5] = 48000;
|
||||
m.extra.point = Some([12.0, 65.0, -30.0]);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
assert_eq!(number(tag.get("DespawnDelay")), Some(48000));
|
||||
let Some(Tag::IntArray(target)) = tag.get("wander_target") else {
|
||||
panic!("wander_target missing");
|
||||
};
|
||||
assert_eq!(target, &vec![12, 65, -30]);
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert_eq!(back.extra.ints[5], 48000);
|
||||
assert_eq!(back.extra.point, Some([12.0, 65.0, -30.0]));
|
||||
// A save with neither field: idle, and nothing to walk to.
|
||||
let bare = Tag::compound([
|
||||
("id", string("minecraft:wandering_trader")),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
]);
|
||||
let back = mob_from_nbt(&bare, &types, 3, None).unwrap();
|
||||
assert_eq!((back.extra.ints[5], back.extra.point), (0, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobs_keep_their_variant_puff_state_carried_block_and_conversion_times() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -4215,6 +4292,16 @@ mod item_component_tests {
|
||||
let mut sheep = spawn("minecraft:sheep", 2);
|
||||
apply_item_components(&mut sheep, &egg);
|
||||
assert_eq!(sheep.extra.color, 5, "lime, not the white it started as");
|
||||
// ZombieNautilus.applyImplicitComponents: the egg carries the coral
|
||||
// variant over the temperate one the biome picked.
|
||||
let mut egg = crate::items::ItemStack::new("minecraft:zombie_nautilus_spawn_egg", 1);
|
||||
egg.data_mut().set_other(
|
||||
"minecraft:zombie_nautilus/variant",
|
||||
Some(string("minecraft:warm")),
|
||||
);
|
||||
let mut nautilus = spawn("minecraft:zombie_nautilus", 4);
|
||||
apply_item_components(&mut nautilus, &egg);
|
||||
assert_eq!(nautilus.extra.variant, 1, "minecraft:warm, not temperate");
|
||||
}
|
||||
|
||||
/// `EntityType.updateCustomEntityTag`: the egg's `entity_data` on the mob it
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Startup shared by the gameplay library and its executable.
|
||||
use crate::{crash, game, logging, ram_limit};
|
||||
|
||||
/// Start the game. The executable supplies the allocator and retains the mod API marker.
|
||||
pub fn main() {
|
||||
horizon_threads::configure_allocator();
|
||||
horizon_client::telemetry::mark_start();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let game_args = args.iter().take_while(|a| *a != "--dedicated");
|
||||
let help = game_args
|
||||
.clone()
|
||||
.any(|a| a == "--help" || a == "-h" || a == "--launch-api");
|
||||
// --home before the log opens in it.
|
||||
if let Some(dir) = game_args.clone().skip_while(|a| *a != "--home").nth(1) {
|
||||
logging::set_game_home(std::path::absolute(dir).unwrap_or_else(|_| dir.into()));
|
||||
}
|
||||
// The dedicated server logs in its own folder (--game-dir's), as vanilla's does; the game in its home.
|
||||
let (home, side, thread) = if args.iter().any(|a| a == "--dedicated") {
|
||||
let folder = game_args
|
||||
.clone()
|
||||
.skip_while(|a| *a != "--game-dir" && *a != "--gameDir")
|
||||
.nth(1)
|
||||
.and_then(|dir| std::path::absolute(dir).ok())
|
||||
.unwrap_or_else(|| ".".into());
|
||||
(folder, "server", "Server thread")
|
||||
} else {
|
||||
// --player-dir too (a game's, not a server's): a player of a shared game folder keeps
|
||||
// its log and crash reports in its own; crash telemetry stays with the home's consent.
|
||||
if let Some(dir) = game_args
|
||||
.clone()
|
||||
.skip_while(|a| *a != "--player-dir")
|
||||
.nth(1)
|
||||
{
|
||||
logging::set_player_dir(std::path::absolute(dir).unwrap_or_else(|_| dir.into()));
|
||||
}
|
||||
(logging::game_home(), "client", "Render thread")
|
||||
};
|
||||
if !help {
|
||||
logging::init(
|
||||
logging::player_dir_override().unwrap_or(home.as_path()),
|
||||
thread,
|
||||
);
|
||||
// The log's first line names the build, so a bug report that carries the log says which one it was.
|
||||
log::info!("Horizon {}", crash::version());
|
||||
crash::install(home.clone(), side);
|
||||
ram_limit::start(&home, side);
|
||||
}
|
||||
raise_open_file_limit();
|
||||
if let Err(error) = game::run() {
|
||||
// A bad command line is the caller's mistake, not a crash: no report.
|
||||
let usage = error.is::<game::UsageError>();
|
||||
log::error!("Horizon: {error}");
|
||||
logging::flush();
|
||||
// The report's path is the last line on stderr (crash::SAVED_LINE).
|
||||
if !help && !usage {
|
||||
crash::report_error(&home, side, &*error);
|
||||
}
|
||||
// Started from Explorer, the console window closes with the game:
|
||||
// keep the message on screen until it is read.
|
||||
#[cfg(windows)]
|
||||
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
|
||||
eprintln!("Press Enter to close.");
|
||||
let _ = std::io::stdin().read_line(&mut String::new());
|
||||
}
|
||||
std::process::exit(if usage {
|
||||
game::exit_code::USAGE
|
||||
} else {
|
||||
game::exit_code::ERROR
|
||||
});
|
||||
}
|
||||
logging::flush();
|
||||
}
|
||||
|
||||
/// The JVM raises its open-file limit to the hard limit as it starts
|
||||
/// (`-XX:+MaxFDLimit`, on by default), so a vanilla server may hold as many
|
||||
/// sockets and region files as the host allows, not the soft default (often
|
||||
/// 1024 in a container). The same here; macOS caps it at `OPEN_MAX`, as the
|
||||
/// JVM does there.
|
||||
fn raise_open_file_limit() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use rustix::process::{Resource, Rlimit, getrlimit, setrlimit};
|
||||
let limit = getrlimit(Resource::Nofile);
|
||||
let hard = if cfg!(target_os = "macos") {
|
||||
Some(limit.maximum.map_or(10_240, |h| h.min(10_240)))
|
||||
} else {
|
||||
limit.maximum
|
||||
};
|
||||
let raised = match (limit.current, hard) {
|
||||
(Some(soft), Some(hard)) => hard > soft,
|
||||
(Some(_), None) => true,
|
||||
(None, _) => false,
|
||||
};
|
||||
if raised {
|
||||
let to = Rlimit {
|
||||
current: hard,
|
||||
maximum: limit.maximum,
|
||||
};
|
||||
match setrlimit(Resource::Nofile, to) {
|
||||
Ok(()) => log::info!(
|
||||
"Open file limit raised from {:?} to {hard:?}",
|
||||
limit.current
|
||||
),
|
||||
Err(e) => log::warn!("Open file limit stays at {:?}: {e}", limit.current),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,16 @@ use crate::{
|
||||
ui,
|
||||
};
|
||||
|
||||
/// The English title: the fallback of [`title`], and what the screen's widgets are named by.
|
||||
pub const TITLE: &str = "Play with Friends";
|
||||
/// A string of this screen: the language table's, or the English text.
|
||||
fn tr(key: &str, english: &str) -> String {
|
||||
crate::lang::get_or(key, english)
|
||||
}
|
||||
/// The title in the current language.
|
||||
pub fn title() -> String {
|
||||
tr("horizon.friends.title", TITLE)
|
||||
}
|
||||
/// What the screen shows (game/friends.rs fills it).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FriendsView {
|
||||
@@ -47,13 +56,18 @@ fn grey() -> u32 {
|
||||
rgba(160, 160, 160, 255)
|
||||
}
|
||||
const WHITE: u32 = u32::MAX;
|
||||
const HINT: &str = "Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.";
|
||||
fn hint() -> String {
|
||||
tr(
|
||||
"horizon.friends.hint",
|
||||
"Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
|
||||
)
|
||||
}
|
||||
|
||||
struct Button {
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
label: &'static str,
|
||||
label: String,
|
||||
action: Action,
|
||||
active: bool,
|
||||
}
|
||||
@@ -76,7 +90,11 @@ fn layout(
|
||||
};
|
||||
let mut buttons = Vec::new();
|
||||
if view.sharing {
|
||||
lines.push(("Invite Code".to_owned(), grey(), y));
|
||||
lines.push((
|
||||
tr("horizon.multiplayer.invite_code", "Invite Code"),
|
||||
grey(),
|
||||
y,
|
||||
));
|
||||
y += 12.0;
|
||||
match &view.code {
|
||||
Some(code) => {
|
||||
@@ -92,7 +110,11 @@ fn layout(
|
||||
x: cx - 154.0,
|
||||
y,
|
||||
w: 150.0,
|
||||
label: if view.copied { "Copied" } else { "Copy Code" },
|
||||
label: if view.copied {
|
||||
tr("horizon.friends.copied", "Copied")
|
||||
} else {
|
||||
tr("horizon.friends.copy_code", "Copy Code")
|
||||
},
|
||||
action: Action::Copy,
|
||||
active: view.code.is_some(),
|
||||
});
|
||||
@@ -100,7 +122,7 @@ fn layout(
|
||||
x: cx + 4.0,
|
||||
y,
|
||||
w: 150.0,
|
||||
label: "Stop Sharing",
|
||||
label: tr("horizon.friends.stop_sharing", "Stop Sharing"),
|
||||
action: Action::Stop,
|
||||
active: true,
|
||||
});
|
||||
@@ -113,32 +135,40 @@ fn layout(
|
||||
y += 4.0;
|
||||
}
|
||||
let here = if view.guests.is_empty() {
|
||||
"No friends here yet.".to_owned()
|
||||
tr("horizon.friends.none_yet", "No friends here yet.")
|
||||
} else {
|
||||
format!("Here now: {}", view.guests.join(", "))
|
||||
tr("horizon.friends.here_now", "Here now: %s").replacen(
|
||||
"%s",
|
||||
&view.guests.join(", "),
|
||||
1,
|
||||
)
|
||||
};
|
||||
add(&mut lines, &here, grey(), &mut y);
|
||||
if let Some(notice) = &view.notice {
|
||||
add(&mut lines, notice, WHITE, &mut y);
|
||||
}
|
||||
// Where friends paste the code, while it fits above the footer.
|
||||
if y + 10.0 * wrap(HINT, width, atlas).len() as f32 <= h - 33.0 {
|
||||
add(&mut lines, HINT, grey(), &mut y);
|
||||
let hint = hint();
|
||||
if y + 10.0 * wrap(&hint, width, atlas).len() as f32 <= h - 33.0 {
|
||||
add(&mut lines, &hint, grey(), &mut y);
|
||||
}
|
||||
} else {
|
||||
add(
|
||||
&mut lines,
|
||||
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
|
||||
&tr(
|
||||
"horizon.friends.share_intro",
|
||||
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
|
||||
),
|
||||
WHITE,
|
||||
&mut y,
|
||||
);
|
||||
y += 4.0;
|
||||
add(&mut lines, HINT, grey(), &mut y);
|
||||
add(&mut lines, &hint(), grey(), &mut y);
|
||||
buttons.push(Button {
|
||||
x: cx - 100.0,
|
||||
y: y + 8.0,
|
||||
w: 200.0,
|
||||
label: "Start Sharing",
|
||||
label: tr("horizon.friends.start_sharing", "Start Sharing"),
|
||||
action: Action::Share,
|
||||
active: view.shown,
|
||||
});
|
||||
@@ -150,7 +180,7 @@ fn layout(
|
||||
x: cx - 154.0,
|
||||
y: h - 27.0,
|
||||
w: 150.0,
|
||||
label: "Troubleshooting...",
|
||||
label: tr("horizon.friends.troubleshooting", "Troubleshooting..."),
|
||||
action: Action::Help,
|
||||
active: true,
|
||||
});
|
||||
@@ -158,7 +188,7 @@ fn layout(
|
||||
x: cx + 4.0,
|
||||
y: h - 27.0,
|
||||
w: 150.0,
|
||||
label: "gui.done",
|
||||
label: "gui.done".to_owned(),
|
||||
action: Action::Done,
|
||||
active: true,
|
||||
});
|
||||
@@ -167,7 +197,7 @@ fn layout(
|
||||
x: cx - 100.0,
|
||||
y: h - 27.0,
|
||||
w: 200.0,
|
||||
label: "gui.done",
|
||||
label: "gui.done".to_owned(),
|
||||
action: Action::Done,
|
||||
active: true,
|
||||
});
|
||||
@@ -204,7 +234,7 @@ pub fn draw(
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
let title = Text::literal(TITLE);
|
||||
let title = Text::literal(title());
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
|
||||
let (lines, buttons) = layout(atlas, (w, h), view);
|
||||
@@ -221,7 +251,7 @@ pub fn draw(
|
||||
b.y,
|
||||
b.w,
|
||||
20.0,
|
||||
&label(b.label),
|
||||
&label(&b.label),
|
||||
b.active,
|
||||
hover,
|
||||
);
|
||||
|
||||
@@ -61,6 +61,7 @@ mod beacon;
|
||||
mod beds;
|
||||
mod beehives;
|
||||
mod block_editor;
|
||||
mod block_edits;
|
||||
mod block_entity_anims;
|
||||
mod block_entity_ticks;
|
||||
mod block_items;
|
||||
@@ -199,6 +200,7 @@ mod native_entities;
|
||||
mod native_explosions;
|
||||
mod native_falling;
|
||||
mod native_hooks;
|
||||
mod native_inventories;
|
||||
mod native_lifecycle;
|
||||
mod native_menus;
|
||||
mod native_mob_draw;
|
||||
@@ -211,6 +213,8 @@ mod native_recipes;
|
||||
mod native_server;
|
||||
mod native_shaders;
|
||||
mod native_visuals;
|
||||
mod native_world_columns;
|
||||
mod native_world_edit;
|
||||
mod native_world_read;
|
||||
mod natural_spawning;
|
||||
mod nautili;
|
||||
@@ -1639,6 +1643,14 @@ impl LevelAccess for GameLevel<'_> {
|
||||
event => self.events.push(event),
|
||||
}
|
||||
}
|
||||
fn has_mod_block_entity(&self, pos: BlockPos) -> bool {
|
||||
// Asked on every block change: no lookup until a mod block entity exists.
|
||||
self.world.block_entities.has_mods()
|
||||
&& matches!(
|
||||
self.world.block_entities.get(&(pos.x, pos.y, pos.z)),
|
||||
Some(BlockEntity::Mod(_))
|
||||
)
|
||||
}
|
||||
fn remove_block_entity(&mut self, pos: BlockPos, old: BlockStateId, side_effects: bool) -> u8 {
|
||||
self.remove_entity(pos, old, side_effects)
|
||||
}
|
||||
@@ -1667,7 +1679,8 @@ impl LevelAccess for GameLevel<'_> {
|
||||
/// FallingBlockEntity carrying a block state.
|
||||
#[derive(Clone, Debug)]
|
||||
struct FallingBlock {
|
||||
/// The entity id a server tracks it by (0 until it is tracked).
|
||||
/// Its entity id: from the entity counter on the server, the server's on
|
||||
/// a client.
|
||||
id: u32,
|
||||
state: BlockStateId,
|
||||
/// `DATA_START_POS`: spawn position used to select weighted block models.
|
||||
@@ -2124,6 +2137,9 @@ struct Game<'a> {
|
||||
server_packs: server_packs::ServerPackState,
|
||||
/// The link a chat click asks to open (`ConfirmLinkScreen`).
|
||||
link_prompt: Option<String>,
|
||||
/// A command the player didn't type, waiting for their answer
|
||||
/// (game/remote_chat.rs).
|
||||
command_prompt: Option<remote_chat::CommandPrompt>,
|
||||
/// A server's Code of Conduct to accept (game/code_of_conduct.rs).
|
||||
code_of_conduct: Option<code_of_conduct::Prompt>,
|
||||
dialogs: dialogs::DialogState,
|
||||
@@ -2397,6 +2413,12 @@ struct Game<'a> {
|
||||
/// `getUseDuration` when that use started, as a mod left it
|
||||
/// (`game/item_use.rs`): consumables and mod items count to it.
|
||||
use_duration: i32,
|
||||
/// A mod's `Start` or `Tick` set that use's ticks left (`game/item_use.rs`).
|
||||
use_ticks_set: bool,
|
||||
/// The crossbow charge's start and middle loading sounds played so far
|
||||
/// (`game/crossbow.rs`): each charge plays each of them once, however
|
||||
/// far a mod's tick moved the charge at a time.
|
||||
crossbow_sounds: [bool; 2],
|
||||
/// Camera.oldFovModifier / fovModifier.
|
||||
/// `Camera` (eye height and field-of-view easing, view placement).
|
||||
view: horizon_client::camera::Camera,
|
||||
@@ -2836,9 +2858,10 @@ struct Game<'a> {
|
||||
/// own client plays it.
|
||||
guest_player_sounds: Vec<(String, [f64; 3], f32, f32)>,
|
||||
/// `broadcastToAdmins` lines a guest's command queued, sent once it
|
||||
/// returns: the line, whether operators get it and whether the console
|
||||
/// logs it.
|
||||
admin_feedback: Vec<(horizon_client::text::Text, bool, bool)>,
|
||||
/// returns: the line, whether operators get it, whether the console
|
||||
/// logs it and whether the world's own host is told too (a command
|
||||
/// block's or minecart's line has no player it came from).
|
||||
admin_feedback: Vec<(horizon_client::text::Text, bool, bool, bool)>,
|
||||
/// The server console is running a command: whether its feedback goes to
|
||||
/// the operators (`DedicatedServer.shouldInformAdmins`,
|
||||
/// `broadcast-console-to-ops`).
|
||||
@@ -3005,17 +3028,12 @@ fn view_vector(yaw: f32, pitch: f32) -> [f64; 3] {
|
||||
/// break with its next point of wear (`ItemStack.nextDamageWillBreak`).
|
||||
fn gliding_pieces(items: &Items, armor: &[Option<ItemStack>; 4]) -> [bool; 4] {
|
||||
const SLOTS: [&str; 4] = ["head", "chest", "legs", "feet"];
|
||||
// `canGlideUsing`: the stack's own `glider` and `equippable` (its
|
||||
// components, a server's patch), else its item's.
|
||||
std::array::from_fn(|i| {
|
||||
let Some(stack) = armor[i].as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let info = items.get(&stack.item);
|
||||
let glider = info.is_some_and(|d| d.glider)
|
||||
|| stack
|
||||
.data()
|
||||
.is_some_and(|d| d.other("minecraft:glider").is_some());
|
||||
let will_break = info.is_some_and(|d| d.max_damage > 0 && stack.damage + 1 >= d.max_damage);
|
||||
glider && !will_break && info.and_then(|d| d.equip_slot.as_deref()) == Some(SLOTS[i])
|
||||
armor[i]
|
||||
.as_ref()
|
||||
.is_some_and(|s| horizon_client::item_sync::can_glide_using(s, SLOTS[i], items))
|
||||
})
|
||||
}
|
||||
/// `Camera.setRotation` (`rotationYXZ(π - yaw, -pitch, 0)`) for the player's
|
||||
@@ -3256,9 +3274,17 @@ impl Game<'_> {
|
||||
self.stack_in_mut(self.held_slot())
|
||||
}
|
||||
/// The slot of the hand that acts: the selected one, or `OFFHAND_SLOT`.
|
||||
/// `LivingEntity.canGlide`: some worn piece `gliding_pieces` lets glide.
|
||||
/// `LivingEntity.canGlide`: some worn piece `gliding_pieces` lets glide,
|
||||
/// or a stack in a hand that glides there (`canGlideUsing` walks every
|
||||
/// `EquipmentSlot`).
|
||||
fn glider_worn(&self) -> bool {
|
||||
gliding_pieces(self.res.items(), &self.armor).contains(&true)
|
||||
let items = self.res.items();
|
||||
let hand = |stack: Option<&ItemStack>, slot: &str| {
|
||||
stack.is_some_and(|s| horizon_client::item_sync::can_glide_using(s, slot, items))
|
||||
};
|
||||
gliding_pieces(items, &self.armor).contains(&true)
|
||||
|| hand(self.inventory[self.selected].as_ref(), "mainhand")
|
||||
|| hand(self.offhand.as_ref(), "offhand")
|
||||
}
|
||||
/// `LivingEntity.updateFallFlying`: the glide's wear. `Util.getRandom`
|
||||
/// picks one of the gliders that will not break (from the entity's own
|
||||
@@ -4850,8 +4876,9 @@ impl Game<'_> {
|
||||
f64::from(pos.y),
|
||||
f64::from(pos.z) + 0.5,
|
||||
);
|
||||
self.next_entity += 1;
|
||||
self.falling.push(FallingBlock {
|
||||
id: 0,
|
||||
id: self.next_entity,
|
||||
state,
|
||||
start_pos: pos,
|
||||
position: p,
|
||||
@@ -6376,8 +6403,17 @@ impl Game<'_> {
|
||||
self.using = None;
|
||||
return;
|
||||
};
|
||||
// a mod's tick ran the use out: finished now, or frozen until let go
|
||||
// for an item used on release (CrossbowItem.useOnRelease).
|
||||
if !self.use_item_ticked(&stack, ticks) {
|
||||
if &*stack.item != "minecraft:crossbow" {
|
||||
self.complete_using_item(slot, &stack, &info);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.blocks_attacks(&stack).is_some() {
|
||||
// ShieldItem: used for 72000 ticks, i.e. until released.
|
||||
self.item_on_use_tick(&stack, ticks);
|
||||
self.using = Some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
@@ -6408,13 +6444,11 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if &*stack.item == "minecraft:spyglass" {
|
||||
// SpyglassItem.USE_DURATION, then finishUsingItem.
|
||||
if ticks + 1 >= 1200 {
|
||||
self.send_use_item_complete();
|
||||
self.stop_spyglass();
|
||||
self.using = None;
|
||||
} else {
|
||||
self.using = Some((slot, ticks + 1));
|
||||
// SpyglassItem.getUseDuration (1200, as a mod left it), then
|
||||
// completeUsingItem: finishUsingItem lowers it, and the finish hook.
|
||||
match item_use::count_down(ticks, self.use_duration) {
|
||||
Some(ticks) => self.using = Some((slot, ticks)),
|
||||
None => self.complete_using_item(slot, &stack, &info),
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6446,22 +6480,16 @@ impl Game<'_> {
|
||||
};
|
||||
// ItemStack.onUseTick: Consumable.shouldEmitParticlesAndSounds with
|
||||
// the remaining ticks before this tick counts them down.
|
||||
// the duration is getUseDuration's when the use started.
|
||||
// the duration is getUseDuration's when the use started, as a mod's
|
||||
// Start and Tick left it.
|
||||
let remaining = self.use_duration - ticks as i32;
|
||||
let done = item_use::count_down(ticks, self.use_duration).is_none();
|
||||
let ticks = ticks + 1;
|
||||
if consumable.should_emit(remaining) {
|
||||
self.emit_consume(consumable, &stack, 5);
|
||||
}
|
||||
if done {
|
||||
self.using = None;
|
||||
self.send_use_item_complete();
|
||||
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
|
||||
self.apply_use_cooldown(&stack);
|
||||
self.finish_consuming(&info, consumable, &stack);
|
||||
self.use_item_finished(&stack);
|
||||
} else {
|
||||
self.using = Some((slot, ticks));
|
||||
self.item_on_use_tick(&stack, ticks);
|
||||
match item_use::count_down(ticks, self.use_duration) {
|
||||
Some(ticks) => self.using = Some((slot, ticks)),
|
||||
None => self.complete_using_item(slot, &stack, &info),
|
||||
}
|
||||
}
|
||||
/// `BundleItem.onUseTick` and `getUseDuration` (200): the first tick and
|
||||
@@ -6513,14 +6541,20 @@ impl Game<'_> {
|
||||
}
|
||||
/// `LivingEntity.releaseUsingItem` (`Item.releaseUsing`) for `stack`,
|
||||
/// used for `ticks`, its hand acting: a bow or a trident lets go (not
|
||||
/// when its hand changed), a spyglass stops scoping.
|
||||
/// when its hand changed), a spyglass stops scoping, and a mod's own
|
||||
/// release follows. A mod's `Stop` may skip the release.
|
||||
pub(super) fn release_used_item(&mut self, stack: &ItemStack, ticks: u32, hand_changed: bool) {
|
||||
let canceled = !hand_changed && !self.use_item_stopped(stack, ticks);
|
||||
let (release, spyglass) = item_use::release_parts(canceled, hand_changed);
|
||||
match &*stack.item {
|
||||
"minecraft:bow" if !hand_changed => self.release_bow(ticks),
|
||||
"minecraft:trident" if !hand_changed => self.release_trident(ticks),
|
||||
"minecraft:spyglass" => self.stop_spyglass(),
|
||||
"minecraft:bow" if release => self.release_bow(self.ticks_held(stack, ticks)),
|
||||
"minecraft:trident" if release => self.release_trident(self.ticks_held(stack, ticks)),
|
||||
"minecraft:spyglass" if spyglass => self.stop_spyglass(),
|
||||
_ => {}
|
||||
}
|
||||
if release {
|
||||
self.mod_release_using(stack, ticks);
|
||||
}
|
||||
}
|
||||
/// `ServerPlayerGameMode.tick` while a player digs: level event 2020 every
|
||||
/// fourth tick since the START (the hit sound), else 2019 (the crack only),
|
||||
@@ -6810,13 +6844,28 @@ impl Game<'_> {
|
||||
/// Landing rules (farmland trampling, turtle eggs) wrote or removed the block
|
||||
/// raw: finish `Level.setBlock`'s neighbour updates, so a crop on trampled
|
||||
/// farmland breaks. The local player's and every server player's landings.
|
||||
/// The level events they send go out too: a turtle egg's break particles
|
||||
/// and sound (`decreaseEggs`' 2001, `Level.destroyBlock`'s for the last).
|
||||
fn landing_block_updates(&mut self, event: &horizon_client::landing::FallEvent) {
|
||||
use horizon_client::landing::FallEvent;
|
||||
let (position, flags) = match *event {
|
||||
FallEvent::Write {
|
||||
position, flags, ..
|
||||
} => (position, flags as i32),
|
||||
FallEvent::Destroy { position, .. } => (position, level_flags::UPDATE_ALL),
|
||||
FallEvent::Destroy {
|
||||
position, state, ..
|
||||
} => {
|
||||
self.level_event_at(2001, [position.x, position.y, position.z], state.0 as i32);
|
||||
(position, level_flags::UPDATE_ALL)
|
||||
}
|
||||
FallEvent::LevelEvent {
|
||||
event,
|
||||
position,
|
||||
data,
|
||||
} => {
|
||||
self.level_event_at(event, [position.x, position.y, position.z], data);
|
||||
return;
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
self.with_level(|b, level, ticks| b.notify_written(level, ticks, position, flags));
|
||||
@@ -7083,6 +7132,23 @@ impl Game<'_> {
|
||||
1,
|
||||
);
|
||||
}
|
||||
// A block's own sound plays at the block (a turtle egg
|
||||
// breaking, a shelf mushroom bouncing).
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Sound {
|
||||
sound,
|
||||
position: Some(pos),
|
||||
volume,
|
||||
pitch,
|
||||
}) if sound.block_sound().is_some() => {
|
||||
if let Some(name) = sound.block_sound() {
|
||||
let at = [
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y) + 0.5,
|
||||
f64::from(pos.z) + 0.5,
|
||||
];
|
||||
self.audio.play(name, Some(at), volume, pitch);
|
||||
}
|
||||
}
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Sound {
|
||||
sound,
|
||||
volume,
|
||||
@@ -7592,6 +7658,7 @@ impl Game<'_> {
|
||||
|| self.dialog_open()
|
||||
|| self.code_of_conduct.is_some()
|
||||
|| self.link_prompt.is_some()
|
||||
|| self.command_prompt.is_some()
|
||||
|| self.server_packs.prompt.is_some()
|
||||
}
|
||||
/// `Minecraft.pauseIfInactive`: a window without focus for more than 500 ms
|
||||
@@ -7708,9 +7775,16 @@ impl Game<'_> {
|
||||
// macOS warps by re-associating the mouse with the pointer,
|
||||
// which undoes a Locked grab and leaves the pointer free: warp
|
||||
// first there. Wayland moves a pointer only while it is locked,
|
||||
// so everywhere else warp after grabbing.
|
||||
// so everywhere else warp after grabbing (Windows does both).
|
||||
#[cfg(target_os = "macos")]
|
||||
warp(window);
|
||||
// Windows clips a Locked pointer to a 1x1 rectangle where it is, and `SetCursorPos`
|
||||
// stays inside that clip: warp first, or the pointer stays frozen wherever it was
|
||||
// (over another window, on another monitor) instead of the window's centre (#6076).
|
||||
#[cfg(windows)]
|
||||
if self.focused {
|
||||
warp(window);
|
||||
}
|
||||
self.captured = window
|
||||
.set_cursor_grab(CursorGrabMode::Locked)
|
||||
.or_else(|_| window.set_cursor_grab(CursorGrabMode::Confined))
|
||||
@@ -7937,6 +8011,7 @@ impl Game<'_> {
|
||||
self.draw_pack_prompt(&mut batch);
|
||||
self.draw_code_of_conduct(&mut batch);
|
||||
self.draw_link_prompt(&mut batch);
|
||||
self.draw_command_prompt(&mut batch);
|
||||
// The ToastManager draws over every screen, the title and the
|
||||
// options included: the narrator's toast follows the player there.
|
||||
self.draw_narrator_toast(&mut batch);
|
||||
@@ -10567,7 +10642,13 @@ impl Game<'_> {
|
||||
let mods = r
|
||||
.visible_block_entities(self.settings.chunk_fade)
|
||||
.filter(|(_, state)| !self.res.block_entity_render().renders(*state));
|
||||
self.mod_block_entity_geometry(mods, camera, alpha as f32, &mut out);
|
||||
let untemplated = self.untemplated_mod_block_entities(camera);
|
||||
self.mod_block_entity_geometry(
|
||||
mods.chain(untemplated.iter()),
|
||||
camera,
|
||||
alpha as f32,
|
||||
&mut out,
|
||||
);
|
||||
self.mark_stage(out.len(), "DESTROY");
|
||||
for &(pos, stage) in &cracks {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
@@ -10965,7 +11046,7 @@ impl Game<'_> {
|
||||
/// `Player.attack`'s sounds when a vehicle takes the hit (it always does,
|
||||
/// and can't be hit critically): knockback when sprinting at full
|
||||
/// strength, then strong or weak, or the sweep around the vehicle's
|
||||
/// box `area`. Before `swing_arm` resets the charge.
|
||||
/// box `area`. Before the hit resets the charge (`Player.onAttack`).
|
||||
pub(super) fn vehicle_attack_sounds(&mut self, area: Aabb, magic: Option<i32>) {
|
||||
self.attack_effects(area, None, mace::Spared::Nothing, magic);
|
||||
}
|
||||
@@ -11131,7 +11212,7 @@ impl Game<'_> {
|
||||
if landed {
|
||||
if knockback > 0.0 {
|
||||
let (xd, zd) = self.extra_knockback_direction();
|
||||
self.mobs[index].knockback(f64::from(knockback), xd, zd, &source);
|
||||
self.mobs[index].knockback_effect(f64::from(knockback), xd, zd, &source, amount);
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
let mob = &self.mobs[index];
|
||||
@@ -11700,6 +11781,8 @@ impl Game<'_> {
|
||||
self.streaming.sky_light = dim != Dim::Nether;
|
||||
self.streaming.nether_shading = dim == Dim::Nether;
|
||||
self.load_world_state();
|
||||
// The level the world opens in, before any of its chunks.
|
||||
self.native_level_opened(dim);
|
||||
Ok(())
|
||||
}
|
||||
/// Streaming and region storage for one dimension of a world.
|
||||
@@ -12275,6 +12358,46 @@ impl Game<'_> {
|
||||
hit.pos[2] + hit.face[2],
|
||||
]
|
||||
}
|
||||
/// The cauldron at `pos`, and whether water is above it
|
||||
/// (`CauldronInteractions.isUnderWater`).
|
||||
pub(super) fn cauldron_at(
|
||||
&self,
|
||||
pos: [i32; 3],
|
||||
) -> Option<(horizon_world::chunk::BlockState, bool)> {
|
||||
let registry = self.pack.assets.registry;
|
||||
let id = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let state = registry
|
||||
.state(id)
|
||||
.filter(|s| s.name.ends_with("cauldron"))
|
||||
.cloned()?;
|
||||
let above = self.world.block(pos[0], pos[1] + 1, pos[2]);
|
||||
let under_water = registry
|
||||
.state(above)
|
||||
.is_some_and(|s| s.name == "minecraft:water");
|
||||
Some((state, under_water))
|
||||
}
|
||||
/// What the cauldron at `pos` answers to `held` on a client, which only
|
||||
/// asks (`CauldronInteractions` acts `if (!level.isClientSide())`). A
|
||||
/// sneak with something in hand skips `useItemOn`
|
||||
/// (`Player.isSecondaryUseActive`).
|
||||
pub(super) fn cauldron_outcome(
|
||||
&self,
|
||||
pos: [i32; 3],
|
||||
held: Option<&ItemStack>,
|
||||
) -> horizon_client::cauldron::Outcome {
|
||||
use horizon_client::cauldron::{self, Outcome};
|
||||
let Some(held) = held else {
|
||||
return Outcome::Pass;
|
||||
};
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) {
|
||||
return Outcome::Pass;
|
||||
}
|
||||
let Some((state, under_water)) = self.cauldron_at(pos) else {
|
||||
return Outcome::Pass;
|
||||
};
|
||||
let removes_dye = cauldron::removes_dye(self.items(), &held.item);
|
||||
cauldron::outcome(&state, under_water, removes_dye, held)
|
||||
}
|
||||
/// `CauldronInteractions` for the held item aimed at a cauldron.
|
||||
fn use_on_cauldron(&mut self, pos: [i32; 3]) -> Result<bool> {
|
||||
use horizon_client::cauldron::{self, Outcome};
|
||||
@@ -12309,22 +12432,17 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let registry = self.pack.assets.registry;
|
||||
let id = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let Some(state) = registry
|
||||
.state(id)
|
||||
.filter(|s| s.name.ends_with("cauldron"))
|
||||
.cloned()
|
||||
else {
|
||||
let Some((state, under_water)) = self.cauldron_at(pos) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(held) = self.held() else {
|
||||
return Ok(false);
|
||||
};
|
||||
// Player.isSecondaryUseActive with an item in hand skips useItemOn.
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) {
|
||||
return Ok(false);
|
||||
}
|
||||
let removes_dye = cauldron::removes_dye(self.items(), &held.item);
|
||||
let above = self.world.block(pos[0], pos[1] + 1, pos[2]);
|
||||
let under_water = registry
|
||||
.state(above)
|
||||
.is_some_and(|s| s.name == "minecraft:water");
|
||||
let result = cauldron::interact(&state, under_water, removes_dye, &mut Hand(self));
|
||||
if result.outcome == Outcome::Pass {
|
||||
return Ok(false);
|
||||
@@ -12829,18 +12947,83 @@ impl Game<'_> {
|
||||
let half = Vec3::new(0.5, 0.5, 0.5);
|
||||
let area = Aabb::new(centre.subtract(half), centre.add_vector(half));
|
||||
// A server's orbs are its own; a LAN or server guest only shows them.
|
||||
let target = self.remote.is_none().then(|| {
|
||||
self.orbs
|
||||
.iter_mut()
|
||||
.find(|o| o.can_merge(merge_id as u32, value) && o.bounds().intersects(area))
|
||||
});
|
||||
if let Some(Some(orb)) = target {
|
||||
orb.count += 1;
|
||||
orb.age = 0;
|
||||
let mut target = None;
|
||||
if self.remote.is_none() {
|
||||
for i in 0..self.orbs.len() {
|
||||
let o = &self.orbs[i];
|
||||
if !o.bounds().intersects(area) {
|
||||
continue;
|
||||
}
|
||||
let (id, orb_value, vanilla) = (o.id, o.value, o.can_merge(merge_id as u32, value));
|
||||
if self.orb_merges(id, orb_value, 0, value, vanilla) {
|
||||
target = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(i) = target {
|
||||
self.orbs[i].count += 1;
|
||||
self.orbs[i].age = 0;
|
||||
} else {
|
||||
self.add_orb(at, value);
|
||||
}
|
||||
}
|
||||
/// Whether orb `into` takes orb `from` (0: a spawning orb of `from_value`),
|
||||
/// given vanilla's `canMerge` answer: the mods may flip it, then cancel the merge.
|
||||
fn orb_merges(
|
||||
&mut self,
|
||||
into: u32,
|
||||
into_value: u32,
|
||||
from: u32,
|
||||
from_value: u32,
|
||||
vanilla: bool,
|
||||
) -> bool {
|
||||
let dimension = self.dim.id();
|
||||
let mut can_merge = vanilla;
|
||||
self.native_experience_orb_can_merge(
|
||||
dimension,
|
||||
into,
|
||||
from,
|
||||
into_value,
|
||||
from_value,
|
||||
&mut can_merge,
|
||||
);
|
||||
if !can_merge {
|
||||
return false;
|
||||
}
|
||||
let mut canceled = false;
|
||||
self.native_experience_orb_merge(dimension, into, from, from_value, &mut canceled);
|
||||
!canceled
|
||||
}
|
||||
/// `ExperienceOrb.scanForMerges`, run by each orb on `tickCount % 20 == 1`:
|
||||
/// it absorbs the orbs that `canMerge` within 0.5 blocks of its box,
|
||||
/// adding their counts and keeping the smaller age. Server only.
|
||||
fn merge_orbs(&mut self) {
|
||||
let mut removed = vec![false; self.orbs.len()];
|
||||
for i in 0..self.orbs.len() {
|
||||
if removed[i] || self.orbs[i].ticks % 20 != 1 {
|
||||
continue;
|
||||
}
|
||||
let area = self.orbs[i].bounds().inflate(Vec3::new(0.5, 0.5, 0.5));
|
||||
for j in 0..self.orbs.len() {
|
||||
if j == i || removed[j] || !self.orbs[j].bounds().intersects(area) {
|
||||
continue;
|
||||
}
|
||||
let (a, b) = (&self.orbs[i], &self.orbs[j]);
|
||||
let vanilla = b.can_merge(a.id, a.value);
|
||||
let (into, into_value, from, from_value) = (a.id, a.value, b.id, b.value);
|
||||
if !self.orb_merges(into, into_value, from, from_value, vanilla) {
|
||||
continue;
|
||||
}
|
||||
let (count, age) = (self.orbs[j].count, self.orbs[j].age);
|
||||
self.orbs[i].count = self.orbs[i].count.saturating_add(count);
|
||||
self.orbs[i].age = self.orbs[i].age.min(age);
|
||||
removed[j] = true;
|
||||
}
|
||||
}
|
||||
let mut removed = removed.into_iter();
|
||||
self.orbs.retain(|_| !removed.next().unwrap_or(false));
|
||||
}
|
||||
/// `Entity.unstuckIfPossible`, which `ExperienceOrb`'s constructor runs on an
|
||||
/// orb whose box is in a block: the free spot nearest its box's centre. An
|
||||
/// orb in open air keeps its position.
|
||||
@@ -13001,10 +13184,12 @@ impl Game<'_> {
|
||||
&mut next_float,
|
||||
)
|
||||
});
|
||||
self.orbs = orbs;
|
||||
// ExperienceOrb.scanForMerges runs on the server only.
|
||||
if self.remote.is_none() {
|
||||
horizon_client::entities::merge_orbs(&mut orbs);
|
||||
self.merge_orbs();
|
||||
}
|
||||
let mut orbs = std::mem::take(&mut self.orbs);
|
||||
let mut gained = 0;
|
||||
let mut taken = Vec::new();
|
||||
if let Some((min, max)) = bounds {
|
||||
@@ -13028,18 +13213,24 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
self.orbs = orbs;
|
||||
for o in taken {
|
||||
self.add_pickup(pickups::Taken::Orb(Box::new(o)), pickups::Collector::Player);
|
||||
}
|
||||
if gained > 0 {
|
||||
gained = self.mending(gained);
|
||||
}
|
||||
if gained > 0 {
|
||||
let at = self.eye_position();
|
||||
for o in taken {
|
||||
// ClientPacketListener.handleTakeItemEntity: every orb taken sounds
|
||||
// at the orb, whether mending took its points or not.
|
||||
let pitch =
|
||||
(self.world.random.next_float() - self.world.random.next_float()) * 0.35 + 0.9;
|
||||
self.audio
|
||||
.play("minecraft:entity.experience_orb.pickup", at, 0.1, pitch);
|
||||
let at = [o.position.x, o.position.y, o.position.z];
|
||||
self.audio.play(
|
||||
"minecraft:entity.experience_orb.pickup",
|
||||
Some(at),
|
||||
0.1,
|
||||
pitch,
|
||||
);
|
||||
self.add_pickup(pickups::Taken::Orb(Box::new(o)), pickups::Collector::Player);
|
||||
}
|
||||
if gained > 0 {
|
||||
self.give_orb_points(gained);
|
||||
}
|
||||
}
|
||||
@@ -15441,6 +15632,16 @@ impl Game<'_> {
|
||||
Screen::DebugOptions => self.close_debug_options(),
|
||||
Screen::Mod => self.close_mod_screen(),
|
||||
},
|
||||
// `checkHotbarKeyPressed`: the keys the controls bind to the off
|
||||
// hand and the hotbar slots, so a rebound key swaps instead of
|
||||
// reaching the screen. The list below keeps Q and the defaults.
|
||||
_ if (self.screen == Screen::Inventory
|
||||
|| (self.screen == Screen::Creative && !self.creative_screen.tab().search))
|
||||
&& self
|
||||
.settings
|
||||
.keys
|
||||
.container_hotbar_key(key)
|
||||
.is_some_and(|key| self.inventory_hotkey(key)) => {}
|
||||
KeyCode::Digit1
|
||||
| KeyCode::Digit2
|
||||
| KeyCode::Digit3
|
||||
@@ -16690,6 +16891,11 @@ impl Game<'_> {
|
||||
self.advancements_scroll(scaled(raw_x) as f32, d);
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.command_prompt.is_some() => {
|
||||
if state == ElementState::Pressed && button == MouseButton::Left {
|
||||
self.command_prompt_click();
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.link_prompt.is_some() => {
|
||||
if state == ElementState::Pressed && button == MouseButton::Left {
|
||||
self.link_prompt_click();
|
||||
@@ -16950,6 +17156,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// ConfirmScreen.keyPressed: Escape answers no.
|
||||
WindowEvent::KeyboardInput { event, .. } if self.command_prompt.is_some() => {
|
||||
if event.state == ElementState::Pressed
|
||||
&& event.physical_key == PhysicalKey::Code(KeyCode::Escape)
|
||||
{
|
||||
self.answer_command_prompt(false);
|
||||
}
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.link_prompt.is_some() => {
|
||||
if event.state == ElementState::Pressed
|
||||
&& event.physical_key == PhysicalKey::Code(KeyCode::Escape)
|
||||
@@ -17594,6 +17807,7 @@ impl<'a> Game<'a> {
|
||||
pack_reload: None,
|
||||
server_packs: Default::default(),
|
||||
link_prompt: None,
|
||||
command_prompt: None,
|
||||
code_of_conduct: None,
|
||||
dialogs: Default::default(),
|
||||
integrated_starting: None,
|
||||
@@ -17766,6 +17980,8 @@ impl<'a> Game<'a> {
|
||||
},
|
||||
using: None,
|
||||
use_duration: 0,
|
||||
use_ticks_set: false,
|
||||
crossbow_sounds: [false; 2],
|
||||
view: Default::default(),
|
||||
limiter: horizon_client::window::FramerateLimiter::new(0),
|
||||
frame_clock: Default::default(),
|
||||
|
||||
@@ -617,6 +617,9 @@ impl Game<'_> {
|
||||
magic.then_some(id as i32),
|
||||
);
|
||||
});
|
||||
// Player.onAttack: the hit starts the attack strength again, hurt or
|
||||
// not (skipAttackInteraction only asks mayInteract).
|
||||
self.attack_ticks = 0;
|
||||
if hurt {
|
||||
self.swing_arm();
|
||||
}
|
||||
|
||||
@@ -344,7 +344,7 @@ impl Game<'_> {
|
||||
.map_or([1, 1], |r| [r.config.width, r.config.height]);
|
||||
let b = HudBatch::new(size);
|
||||
let cursor = self.cursor;
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let shift = self.has_shift_down();
|
||||
let alt = self.keys.contains(&KeyCode::AltLeft);
|
||||
let atlas_click = |game: &Game<'_>, edit: &mut EditBox| {
|
||||
edit.click(&game.gui.atlas, cursor.0, false, false);
|
||||
@@ -437,8 +437,8 @@ impl Game<'_> {
|
||||
shortcut: Option<horizon_client::clipboard::EditShortcut>,
|
||||
text: Option<&str>,
|
||||
) {
|
||||
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let ctrl = self.has_control_down_with_quirk();
|
||||
let shift = self.has_shift_down();
|
||||
let types_text = self.types_text();
|
||||
let size = self
|
||||
.renderer
|
||||
|
||||
@@ -0,0 +1,716 @@
|
||||
//! A server mod's bulk block edit (`native_edit_blocks`): its palette and
|
||||
//! filters read as commands read blocks, its heights read, and its runs made
|
||||
//! at once through `World::edit_runs`, with what `fill ... strict` does for
|
||||
//! the block entities and the watched blocks of each block changed.
|
||||
use super::Game;
|
||||
use super::native_world_columns::NativeColumnBlocks as ColumnBlocks;
|
||||
use super::native_world_edit::{NativeBlockEdit as BlockEdit, NativeHeightmap as Heightmap};
|
||||
use horizon_client::{
|
||||
block_edit::Run,
|
||||
chunk::ColumnLayout,
|
||||
command::{Reader, args::BlockInput, block},
|
||||
containers::BlockEntity,
|
||||
fast_hash::{FxHashMap, FxHashSet},
|
||||
world::World,
|
||||
};
|
||||
use horizon_world::{
|
||||
heightmap::HeightmapType, registry::BlockStateId, worldgen::terrain::TerrainBlockStates,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A change `World::edit_runs` reported: position, previous and new state.
|
||||
type Tracked = ((i32, i32, i32), u32, u32);
|
||||
|
||||
impl Game<'_> {
|
||||
/// Make a mod's block edit; how many blocks changed, or why none did.
|
||||
/// Every palette entry, filter and run is checked before anything
|
||||
/// changes: an entry or filter that doesn't read, one with NBT (`{...}`,
|
||||
/// refused: the bulk path sets states only), a run naming a palette entry
|
||||
/// or filter past the end, or more than 65536 filters, and nothing is
|
||||
/// made.
|
||||
pub(super) fn edit_blocks(&mut self, edit: &BlockEdit) -> Result<u64, String> {
|
||||
let parse = |name: &str, testing: bool| -> Result<BlockInput, String> {
|
||||
let mut reader = Reader::new(name);
|
||||
let b = block::parse(&mut reader, &self.ui.registries, testing)
|
||||
.map_err(|e| format!("{name:?}: {}", e.message.plain()))?;
|
||||
if reader.can_read() {
|
||||
return Err(format!("{name:?}: more after the block"));
|
||||
}
|
||||
if b.nbt.is_some() {
|
||||
return Err(format!("{name:?}: block NBT is not supported here"));
|
||||
}
|
||||
Ok(b)
|
||||
};
|
||||
let mut palette = Vec::with_capacity(edit.palette.len());
|
||||
for name in &edit.palette {
|
||||
let b = parse(name, false)?;
|
||||
let state = self
|
||||
.block_state(&b)
|
||||
.map_err(|e| format!("{name:?}: {}", e.message.plain()))?;
|
||||
palette.push(state.0);
|
||||
}
|
||||
let filters = edit
|
||||
.filters
|
||||
.iter()
|
||||
.map(|f| parse(f, true))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let runs = resolve_runs(edit, &palette, filters.len(), |h, x, z| {
|
||||
self.edit_height(h, x, z)
|
||||
})?;
|
||||
// (what the registry, the tags and the facts tell of a state, beside the world being changed)
|
||||
let registry = self.pack.assets.registry;
|
||||
// (the tags as `block_matches` reads them for commands)
|
||||
let world_items = match &self.world_tables {
|
||||
Some(t) => &t.items,
|
||||
None => self.res.items(),
|
||||
};
|
||||
let tags = [&self.res.items().block_tags, &world_items.block_tags];
|
||||
let facts = self.res.facts();
|
||||
let watched = std::mem::take(&mut self.world.watched);
|
||||
let out = self.world.edit_runs(
|
||||
&runs,
|
||||
|f, s| {
|
||||
super::commands::block_input_matches(
|
||||
registry,
|
||||
tags,
|
||||
&filters[f as usize],
|
||||
BlockStateId(s),
|
||||
)
|
||||
},
|
||||
|s| {
|
||||
let f = facts.get(BlockStateId(s));
|
||||
watched.get(s as usize).copied().unwrap_or(false)
|
||||
|| f.block_entity
|
||||
|| BlockEntity::for_block(&f.name).is_some()
|
||||
},
|
||||
);
|
||||
self.world.watched = watched;
|
||||
// The block entities the new blocks bring, as `native_set_block` makes them (a mod block's own, API 15,
|
||||
// included), in the order `replace_block_entities` takes them: the positions whose block changed.
|
||||
let name = |s: u32| self.res.facts().get(BlockStateId(s)).name.clone();
|
||||
let wanted: Vec<Arc<str>> = out
|
||||
.tracked
|
||||
.iter()
|
||||
.filter(|&&(_, old, new)| name(old) != name(new))
|
||||
.map(|&(_, _, new)| name(new))
|
||||
.collect();
|
||||
let mut made = make_block_entities(&wanted, |n| self.new_block_entity(n)).into_iter();
|
||||
let facts = self.res.facts();
|
||||
let removed = replace_block_entities(
|
||||
&mut self.world,
|
||||
&out.tracked,
|
||||
|s| facts.get(BlockStateId(s)).name.clone(),
|
||||
|_| made.next().flatten(),
|
||||
);
|
||||
for pos in removed {
|
||||
self.jukebox_removed(pos);
|
||||
}
|
||||
Ok(out.changed)
|
||||
}
|
||||
|
||||
/// Each column's height on `h` as `edit_height` reads it, none where its chunk is not loaded.
|
||||
pub(super) fn surface_heights(&self, columns: &[(i32, i32)], h: Heightmap) -> Vec<Option<i32>> {
|
||||
loaded_heights(&self.world, columns, |x, z| self.edit_height(h, x, z))
|
||||
}
|
||||
|
||||
/// Each column's height on `h` (none where its chunk is not loaded) and its blocks from `below` under it to
|
||||
/// `above` - 1 over it, bottom first (air outside the column and where it is not loaded), as indices into the
|
||||
/// names seen, with the biome's grass, foliage and water tints at the column's top (0 where it is not loaded;
|
||||
/// unblended). `Err`, reading nothing, when `below + above` is 0 or more than the level's height, or the read
|
||||
/// would be more than `MAX_READ` blocks.
|
||||
pub(super) fn read_columns(
|
||||
&self,
|
||||
columns: &[(i32, i32)],
|
||||
h: Heightmap,
|
||||
below: u32,
|
||||
above: u32,
|
||||
) -> Result<ColumnBlocks, String> {
|
||||
read_size(columns.len(), below, above, self.world.layout().height())?;
|
||||
let bases = self.surface_heights(columns, h);
|
||||
let facts = self.res.facts();
|
||||
let (palette, blocks) = column_blocks(&self.world, columns, &bases, below, above, |s| {
|
||||
facts.get(s).name.clone()
|
||||
})
|
||||
.ok_or("the read is too big")?;
|
||||
let biomes = self.res.biomes();
|
||||
let mut tints = Vec::with_capacity(columns.len() * 3);
|
||||
for (&(x, z), base) in columns.iter().zip(&bases) {
|
||||
// (the biome tints at the column's top, as the block there is tinted)
|
||||
match base.and_then(|b| {
|
||||
self.world
|
||||
.chunks
|
||||
.get(&(x >> 4, z >> 4))
|
||||
.map(|c| c.biome(x, b - 1, z))
|
||||
}) {
|
||||
Some(id) => {
|
||||
let b = biomes.get(id);
|
||||
tints.extend([
|
||||
biomes.grass(id, f64::from(x), f64::from(z)),
|
||||
b.foliage,
|
||||
b.water,
|
||||
]);
|
||||
}
|
||||
None => tints.extend([0, 0, 0]),
|
||||
}
|
||||
}
|
||||
Ok(ColumnBlocks {
|
||||
palette,
|
||||
bases,
|
||||
blocks,
|
||||
tints,
|
||||
})
|
||||
}
|
||||
|
||||
/// One above the highest block of column (x, z) that counts on heightmap `h`, or the bottom of the column (also
|
||||
/// where it is not loaded).
|
||||
fn edit_height(&self, h: Heightmap, x: i32, z: i32) -> i32 {
|
||||
let properties = &self.pack.assets.properties;
|
||||
column_height(
|
||||
&self.world,
|
||||
h,
|
||||
(x, z),
|
||||
|s| self.res.facts().is_air(s),
|
||||
|t, s| t.is_opaque(properties.heightmap_state(s)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The most blocks one `read_columns` reads (2 MiB of indices): it runs on the
|
||||
/// server tick, so a mod reads a big area in pieces.
|
||||
pub(super) const MAX_READ: usize = 1 << 20;
|
||||
/// The most columns one `surface_heights` reads.
|
||||
pub(super) const MAX_COLUMNS: usize = 1 << 16;
|
||||
|
||||
/// The blocks a column read of `columns` columns, `below` + `above` each,
|
||||
/// reads, or why it can't: a span of 0 or more than `height` (the level's),
|
||||
/// or the whole more than `MAX_READ`.
|
||||
fn read_size(columns: usize, below: u32, above: u32, height: i32) -> Result<usize, String> {
|
||||
let per = below
|
||||
.checked_add(above)
|
||||
.filter(|&per| per > 0 && i64::from(per) <= i64::from(height))
|
||||
.ok_or_else(|| format!("below + above must be 1 to {height}, the level's height"))?;
|
||||
columns
|
||||
.checked_mul(per as usize)
|
||||
.filter(|&n| n <= MAX_READ)
|
||||
.ok_or_else(|| {
|
||||
format!("the read is more than {MAX_READ} blocks: read fewer columns a call")
|
||||
})
|
||||
}
|
||||
|
||||
/// One above the highest block of column `(x, z)` on heightmap `h`, or the
|
||||
/// bottom of the column (also where it is not loaded): `air` says which
|
||||
/// blocks the world surface passes over, and `counts(kind, state)` which
|
||||
/// count on the other heightmaps.
|
||||
fn column_height(
|
||||
world: &World,
|
||||
h: Heightmap,
|
||||
(x, z): (i32, i32),
|
||||
air: impl Fn(BlockStateId) -> bool,
|
||||
counts: impl Fn(HeightmapType, BlockStateId) -> bool,
|
||||
) -> i32 {
|
||||
if h == Heightmap::WorldSurface {
|
||||
return world.world_surface(x, z, air);
|
||||
}
|
||||
// (as `rain_height` reads it: both others count only blocks that block motion, so they are at or under it)
|
||||
let top = world.motion_blocking(x, z, |s| counts(HeightmapType::MotionBlocking, s));
|
||||
let kind = match h {
|
||||
Heightmap::MotionBlockingNoLeaves => HeightmapType::MotionBlockingNoLeaves,
|
||||
Heightmap::OceanFloor => HeightmapType::OceanFloor,
|
||||
_ => return top,
|
||||
};
|
||||
let bottom = world
|
||||
.chunks
|
||||
.get(&(x >> 4, z >> 4))
|
||||
.map_or(world.layout().min_y(), |c| c.min_y());
|
||||
let mut y = top;
|
||||
while y > bottom && !counts(kind, world.block(x, y - 1, z)) {
|
||||
y -= 1;
|
||||
}
|
||||
y
|
||||
}
|
||||
|
||||
/// `height(x, z)` of each column, none where its chunk is not loaded. (A
|
||||
/// chunk is looked up once for a run of columns in it.)
|
||||
fn loaded_heights(
|
||||
world: &World,
|
||||
columns: &[(i32, i32)],
|
||||
height: impl Fn(i32, i32) -> i32,
|
||||
) -> Vec<Option<i32>> {
|
||||
let mut last: Option<((i32, i32), bool)> = None;
|
||||
columns
|
||||
.iter()
|
||||
.map(|&(x, z)| {
|
||||
let key = (x >> 4, z >> 4);
|
||||
let loaded = match last {
|
||||
Some((k, l)) if k == key => l,
|
||||
_ => {
|
||||
let l = world.chunks.get(&key).is_some();
|
||||
last = Some((key, l));
|
||||
l
|
||||
}
|
||||
};
|
||||
loaded.then(|| height(x, z))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The blocks of each column from `below` under its base to `above` - 1 over
|
||||
/// it, bottom first, as indices into the names (`name`) seen: air outside the
|
||||
/// column's chunk's own height and where there is no base. None, reading
|
||||
/// nothing, past `read_size`'s bound.
|
||||
fn column_blocks(
|
||||
world: &World,
|
||||
columns: &[(i32, i32)],
|
||||
bases: &[Option<i32>],
|
||||
below: u32,
|
||||
above: u32,
|
||||
name: impl Fn(BlockStateId) -> Arc<str>,
|
||||
) -> Option<(Vec<String>, Vec<u16>)> {
|
||||
let total = read_size(
|
||||
columns.len(),
|
||||
below,
|
||||
above,
|
||||
ColumnLayout::HIGHEST_Y + 1 - ColumnLayout::LOWEST_Y,
|
||||
)
|
||||
.ok()?;
|
||||
let per = (below + above) as usize;
|
||||
let mut blocks = Vec::with_capacity(total);
|
||||
let mut palette: Vec<String> = Vec::new();
|
||||
// (a state's index into the names, worked out once a state)
|
||||
let mut seen: FxHashMap<u32, u16> = FxHashMap::default();
|
||||
let mut index = |s: BlockStateId| -> u16 {
|
||||
*seen.entry(s.0).or_insert_with(|| {
|
||||
let name = name(s);
|
||||
let i = palette
|
||||
.iter()
|
||||
.position(|n| **n == *name)
|
||||
.unwrap_or_else(|| {
|
||||
palette.push(name.to_string());
|
||||
palette.len() - 1
|
||||
});
|
||||
i as u16
|
||||
})
|
||||
};
|
||||
for (&(x, z), base) in columns.iter().zip(bases) {
|
||||
let chunk = world.chunks.get(&(x >> 4, z >> 4));
|
||||
match (base, chunk) {
|
||||
(Some(b), Some(chunk)) => {
|
||||
let (bottom, top) = (chunk.min_y(), chunk.max_y());
|
||||
let y0 = i64::from(*b) - i64::from(below);
|
||||
for y in y0..y0 + per as i64 {
|
||||
let s = match i32::try_from(y) {
|
||||
Ok(y) if (bottom..top).contains(&y) => chunk.block(x & 15, y, z & 15),
|
||||
_ => world.air,
|
||||
};
|
||||
blocks.push(index(s));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let air = index(world.air);
|
||||
blocks.extend(std::iter::repeat_n(air, per));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((palette, blocks))
|
||||
}
|
||||
|
||||
/// `edit`'s runs as `World::edit_runs` takes them: each palette entry `block`
|
||||
/// as its state in `palette`, each `y` counted from `height(surface, x, z)`
|
||||
/// when the run names a heightmap (read before any change, once a column).
|
||||
/// Err, with nothing resolved, for a run naming a palette entry or a filter
|
||||
/// past the end, or for more filters than a run can name (65536).
|
||||
fn resolve_runs(
|
||||
edit: &BlockEdit,
|
||||
palette: &[u32],
|
||||
filters: usize,
|
||||
mut height: impl FnMut(Heightmap, i32, i32) -> i32,
|
||||
) -> Result<Vec<Run>, String> {
|
||||
if filters > usize::from(u16::MAX) + 1 {
|
||||
return Err(format!("{filters} filters: 65536 at most"));
|
||||
}
|
||||
let mut heights: FxHashMap<(i32, i32, u8), i32> = FxHashMap::default();
|
||||
let mut runs = Vec::with_capacity(edit.runs.len());
|
||||
for (i, r) in edit.runs.iter().enumerate() {
|
||||
let Some(&state) = usize::try_from(r.block).ok().and_then(|b| palette.get(b)) else {
|
||||
return Err(format!(
|
||||
"run {i}: palette entry {} of {}",
|
||||
r.block,
|
||||
palette.len()
|
||||
));
|
||||
};
|
||||
let only = match r.only {
|
||||
None => None,
|
||||
Some(f) if (f as usize) < filters => Some(f as u16),
|
||||
Some(f) => return Err(format!("run {i}: filter {f} of {filters}")),
|
||||
};
|
||||
if r.height == 0 {
|
||||
continue;
|
||||
}
|
||||
let base = match r.surface {
|
||||
None => i64::from(r.y),
|
||||
Some(h) => {
|
||||
let top = *heights
|
||||
.entry((r.x, r.z, h as u8))
|
||||
.or_insert_with(|| height(h, r.x, r.z));
|
||||
i64::from(top) + i64::from(r.y)
|
||||
}
|
||||
};
|
||||
let clamp = |v: i64| v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32;
|
||||
runs.push(Run {
|
||||
x: r.x,
|
||||
z: r.z,
|
||||
y0: clamp(base),
|
||||
y1: clamp(base + i64::from(r.height) - 1),
|
||||
state,
|
||||
only,
|
||||
});
|
||||
}
|
||||
Ok(runs)
|
||||
}
|
||||
|
||||
/// What `fill ... strict` does for the block entities and the watched blocks
|
||||
/// of `tracked`'s changes: the watched writes recorded, the block entities
|
||||
/// told of their new state, and where a different block replaced the block,
|
||||
/// its block entity removed (as `place_state` does) and `new_entity(name)`'s,
|
||||
/// if any, put in its place. Returns the positions whose block was replaced
|
||||
/// (a jukebox there stops).
|
||||
/// A block entity for each new block, in order: made for each position, as vanilla calls
|
||||
/// `EntityBlock.newBlockEntity` for each (a mod's may number its instances). A block that makes none (stone) is
|
||||
/// asked once.
|
||||
fn make_block_entities(
|
||||
names: &[Arc<str>],
|
||||
mut make: impl FnMut(&str) -> Option<BlockEntity>,
|
||||
) -> Vec<Option<BlockEntity>> {
|
||||
let mut none: FxHashSet<&str> = FxHashSet::default();
|
||||
names
|
||||
.iter()
|
||||
.map(|n| {
|
||||
if none.contains(&**n) {
|
||||
return None;
|
||||
}
|
||||
let entity = make(n);
|
||||
if entity.is_none() {
|
||||
none.insert(n);
|
||||
}
|
||||
entity
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn replace_block_entities(
|
||||
world: &mut World,
|
||||
tracked: &[Tracked],
|
||||
name: impl Fn(u32) -> Arc<str>,
|
||||
mut new_entity: impl FnMut(&str) -> Option<BlockEntity>,
|
||||
) -> Vec<[i32; 3]> {
|
||||
let mut replaced = Vec::new();
|
||||
for &((x, y, z), old, new) in tracked {
|
||||
let watches = |id: u32| world.watched.get(id as usize).copied().unwrap_or(false);
|
||||
if watches(old) || watches(new) {
|
||||
world.watched_writes.push(((x, y, z), old, new));
|
||||
}
|
||||
world.block_entities.state_changed((x, y, z), old, new);
|
||||
let (was, now) = (name(old), name(new));
|
||||
if was != now {
|
||||
replaced.push([x, y, z]);
|
||||
world.block_entities.remove(&(x, y, z));
|
||||
if let Some(entity) = new_entity(&now) {
|
||||
world.block_entities.insert((x, y, z), entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
replaced
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game::native_world_edit::NativeBlockRun as BlockRun;
|
||||
use horizon_client::chunk::Chunk;
|
||||
|
||||
fn run(block: u32, only: Option<u32>) -> BlockRun {
|
||||
BlockRun {
|
||||
x: 1,
|
||||
z: 2,
|
||||
y: 3,
|
||||
height: 4,
|
||||
surface: None,
|
||||
block,
|
||||
only,
|
||||
}
|
||||
}
|
||||
fn edit(runs: Vec<BlockRun>) -> BlockEdit {
|
||||
BlockEdit {
|
||||
palette: vec!["stone".into(), "air".into()],
|
||||
filters: vec!["#dirt".into()],
|
||||
runs,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_naming_entries_past_the_end_are_refused_whole() {
|
||||
let palette = [1, 0];
|
||||
let ok = resolve_runs(&edit(vec![run(1, Some(0))]), &palette, 1, |_, _, _| 0).unwrap();
|
||||
assert_eq!(
|
||||
ok,
|
||||
[Run {
|
||||
x: 1,
|
||||
z: 2,
|
||||
y0: 3,
|
||||
y1: 6,
|
||||
state: 0,
|
||||
only: Some(0),
|
||||
}]
|
||||
);
|
||||
// a bad run after a good one: nothing at all, and no height read
|
||||
let mut asked = 0;
|
||||
let mut bad = |runs| {
|
||||
resolve_runs(&edit(runs), &palette, 1, |_, _, _| {
|
||||
asked += 1;
|
||||
0
|
||||
})
|
||||
};
|
||||
assert!(bad(vec![run(0, None), run(2, None)]).is_err());
|
||||
assert!(bad(vec![run(0, None), run(u32::MAX, None)]).is_err());
|
||||
assert!(bad(vec![run(0, Some(1))]).is_err());
|
||||
assert!(bad(vec![run(0, Some(u32::MAX))]).is_err());
|
||||
assert_eq!(asked, 0);
|
||||
// more filters than a run's u16 names
|
||||
assert!(resolve_runs(&edit(vec![]), &palette, 65_537, |_, _, _| 0).is_err());
|
||||
assert!(resolve_runs(&edit(vec![]), &palette, 65_536, |_, _, _| 0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_counts_from_its_heightmap_read_once_a_column() {
|
||||
let mut r = run(0, None);
|
||||
r.surface = Some(Heightmap::OceanFloor);
|
||||
r.y = -2;
|
||||
let mut asked = Vec::new();
|
||||
let runs = resolve_runs(&edit(vec![r, r]), &[7], 0, |h, x, z| {
|
||||
asked.push((h, x, z));
|
||||
70
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(asked, [(Heightmap::OceanFloor, 1, 2)]);
|
||||
assert_eq!((runs[0].y0, runs[0].y1, runs[0].state), (68, 71, 7));
|
||||
assert_eq!(runs[1], runs[0]);
|
||||
// a run far past the world's height clamps rather than wraps
|
||||
let mut high = run(0, None);
|
||||
high.y = i32::MAX;
|
||||
high.height = u32::MAX;
|
||||
let runs = resolve_runs(&edit(vec![high]), &[7], 0, |_, _, _| 0).unwrap();
|
||||
assert_eq!((runs[0].y0, runs[0].y1), (i32::MAX, i32::MAX));
|
||||
}
|
||||
|
||||
fn world() -> World {
|
||||
let mut w = World::new(BlockStateId(0), 0);
|
||||
w.chunks.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
w
|
||||
}
|
||||
fn names(s: u32) -> Arc<str> {
|
||||
match s {
|
||||
0 => "minecraft:air",
|
||||
1 => "minecraft:stone",
|
||||
2 => "minecraft:chest",
|
||||
_ => "machines:press",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chest_replaced_by_stone_loses_its_block_entity() {
|
||||
let mut w = world();
|
||||
w.set(1, 5, 1, BlockStateId(2)).unwrap();
|
||||
let chest = BlockEntity::for_block("minecraft:chest").expect("a chest's");
|
||||
w.block_entities.insert((1, 5, 1), chest);
|
||||
let out = w.edit_runs(
|
||||
&[Run {
|
||||
x: 1,
|
||||
z: 1,
|
||||
y0: 4,
|
||||
y1: 6,
|
||||
state: 1,
|
||||
only: None,
|
||||
}],
|
||||
|_, _| true,
|
||||
|s| s == 2,
|
||||
);
|
||||
assert_eq!(out.tracked, [((1, 5, 1), 2, 1)]);
|
||||
let replaced = replace_block_entities(&mut w, &out.tracked, names, BlockEntity::for_block);
|
||||
assert_eq!(replaced, [[1, 5, 1]]);
|
||||
assert!(w.block_entities.get(&(1, 5, 1)).is_none());
|
||||
assert_eq!(w.block(1, 5, 1).0, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_block_gets_the_block_entity_made_for_it() {
|
||||
let mut w = world();
|
||||
let out = w.edit_runs(
|
||||
&[Run {
|
||||
x: 3,
|
||||
z: 3,
|
||||
y0: 10,
|
||||
y1: 10,
|
||||
state: 3,
|
||||
only: None,
|
||||
}],
|
||||
|_, _| true,
|
||||
|s| s == 3,
|
||||
);
|
||||
// a mod block's, as `Game::new_block_entity` makes it
|
||||
let made = |n: &str| {
|
||||
(n == "machines:press").then(|| {
|
||||
BlockEntity::Mod(Box::new(horizon_client::containers::ModBlockEntity {
|
||||
id: "machines:press".into(),
|
||||
..Default::default()
|
||||
}))
|
||||
})
|
||||
};
|
||||
replace_block_entities(&mut w, &out.tracked, names, made);
|
||||
assert!(matches!(
|
||||
w.block_entities.get(&(3, 10, 3)),
|
||||
Some(BlockEntity::Mod(e)) if e.id == "machines:press"
|
||||
));
|
||||
}
|
||||
|
||||
/// air 0, stone 1, leaves 2, water 3: column (1, 1) stone to 60, water, then leaves to 64; column (2, 2)
|
||||
/// stone to 60, leaves, then water to 64; at (3, 3) stone at the top of the world.
|
||||
fn columns_world() -> World {
|
||||
let mut w = world();
|
||||
for (x, z, low, high) in [(1, 1, 3, 2), (2, 2, 2, 3)] {
|
||||
for y in -64..=64 {
|
||||
let s = match y {
|
||||
..=60 => 1,
|
||||
61..=62 => low,
|
||||
_ => high,
|
||||
};
|
||||
w.set(x, y, z, BlockStateId(s)).unwrap();
|
||||
}
|
||||
}
|
||||
w.set(3, 319, 3, BlockStateId(1)).unwrap();
|
||||
w
|
||||
}
|
||||
fn height(w: &World, h: Heightmap, x: i32, z: i32) -> i32 {
|
||||
column_height(
|
||||
w,
|
||||
h,
|
||||
(x, z),
|
||||
|s| s.0 == 0,
|
||||
|kind, s| match kind {
|
||||
HeightmapType::MotionBlockingNoLeaves => matches!(s.0, 1 | 3),
|
||||
HeightmapType::OceanFloor => matches!(s.0, 1 | 2),
|
||||
_ => s.0 != 0,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_heightmap_has_its_own_height() {
|
||||
let w = columns_world();
|
||||
assert_eq!(height(&w, Heightmap::WorldSurface, 1, 1), 65);
|
||||
assert_eq!(height(&w, Heightmap::MotionBlocking, 1, 1), 65);
|
||||
assert_eq!(height(&w, Heightmap::MotionBlockingNoLeaves, 1, 1), 63);
|
||||
assert_eq!(height(&w, Heightmap::OceanFloor, 1, 1), 65);
|
||||
assert_eq!(height(&w, Heightmap::OceanFloor, 2, 2), 63);
|
||||
assert_eq!(height(&w, Heightmap::MotionBlockingNoLeaves, 2, 2), 65);
|
||||
// an empty column, and one not loaded: the bottom
|
||||
assert_eq!(height(&w, Heightmap::OceanFloor, 5, 5), -64);
|
||||
assert_eq!(height(&w, Heightmap::WorldSurface, 100, 100), -64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_not_loaded_have_no_height() {
|
||||
let w = columns_world();
|
||||
let heights = loaded_heights(&w, &[(1, 1), (100, 100), (2, 2), (-1, 0)], |x, z| {
|
||||
height(&w, Heightmap::OceanFloor, x, z)
|
||||
});
|
||||
assert_eq!(heights, [Some(65), None, Some(63), None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_blocks_read_air_past_the_column_and_where_not_loaded() {
|
||||
let w = columns_world();
|
||||
let (palette, blocks) = column_blocks(
|
||||
&w,
|
||||
&[(1, 1), (3, 3), (100, 100)],
|
||||
&[Some(65), Some(319), None],
|
||||
2,
|
||||
3,
|
||||
|s| names(s.0),
|
||||
)
|
||||
.unwrap();
|
||||
let read: Vec<&str> = blocks.iter().map(|&i| &*palette[i as usize]).collect();
|
||||
// (`names` calls state 2 a chest: here it stands for leaves)
|
||||
let (leaves, stone, air) = ("minecraft:chest", "minecraft:stone", "minecraft:air");
|
||||
assert_eq!(
|
||||
read,
|
||||
[
|
||||
leaves, leaves, air, air, air, // (1, 1) from 63
|
||||
air, air, stone, air, air, // (3, 3) from 317: stone at 319, nothing over 319
|
||||
air, air, air, air, air, // not loaded
|
||||
]
|
||||
);
|
||||
// names once each
|
||||
assert_eq!(palette.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_is_bounded() {
|
||||
assert_eq!(read_size(10, 200, 184, 384), Ok(3840));
|
||||
assert!(
|
||||
read_size(1, 200, 185, 384).is_err(),
|
||||
"more than the level's height"
|
||||
);
|
||||
assert!(read_size(1, 0, 0, 384).is_err(), "an empty span");
|
||||
assert!(read_size(1, u32::MAX, 1, 4064).is_err());
|
||||
assert!(read_size(1 << 14, 100, 0, 384).is_err(), "over MAX_READ");
|
||||
assert!(read_size(usize::MAX, 2, 0, 384).is_err());
|
||||
assert_eq!(read_size(1 << 12, 256, 0, 384), Ok(MAX_READ));
|
||||
let w = columns_world();
|
||||
assert!(column_blocks(&w, &[(1, 1)], &[Some(0)], u32::MAX, 2, |s| names(s.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_new_block_gets_its_own_block_entity() {
|
||||
// a mod's constructor numbering its instances; stone makes none
|
||||
let mut calls = Vec::new();
|
||||
let made = make_block_entities(
|
||||
&[
|
||||
"machines:press",
|
||||
"minecraft:stone",
|
||||
"machines:press",
|
||||
"minecraft:stone",
|
||||
]
|
||||
.map(Arc::<str>::from),
|
||||
|n| {
|
||||
calls.push(n.to_string());
|
||||
(n == "machines:press").then(|| {
|
||||
BlockEntity::Mod(Box::new(horizon_client::containers::ModBlockEntity {
|
||||
id: format!("press-{}", calls.len()),
|
||||
..Default::default()
|
||||
}))
|
||||
})
|
||||
},
|
||||
);
|
||||
let ids: Vec<Option<String>> = made
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
Some(BlockEntity::Mod(e)) => Some(e.id.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
[
|
||||
Some("press-1".to_string()),
|
||||
None,
|
||||
Some("press-3".to_string()),
|
||||
None
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
calls,
|
||||
["machines:press", "minecraft:stone", "machines:press"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1047,6 +1047,13 @@ impl Game<'_> {
|
||||
/// floor there, else on top of the boat.
|
||||
pub(super) fn dismount(&mut self, i: usize) {
|
||||
self.riding = None;
|
||||
// `AbstractBoat.tick`'s non-authoritative branch: the copy of a boat
|
||||
// that no player of this side drives drops its cached motion
|
||||
// (`setDeltaMovement(Vec3.ZERO)`, `deltaRotation = 0`) and is not
|
||||
// moved, so a boat is at a standstill when its rider gets out instead
|
||||
// of coasting on the velocity of the ride that just ended.
|
||||
self.boats[i].velocity = Vec3::ZERO;
|
||||
self.boats[i].delta_rotation = 0.0;
|
||||
let target = self.boat_dismount_target(i, 0.6, self.yaw);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = target;
|
||||
@@ -1300,6 +1307,8 @@ impl Game<'_> {
|
||||
let (damage, _, _, magic) = self.melee_damage(&kind, false);
|
||||
self.hurt_boat(i, damage, self.creative);
|
||||
self.vehicle_attack_sounds(area, magic.then_some(id));
|
||||
// Player.onAttack: the hit starts the attack strength again.
|
||||
self.attack_ticks = 0;
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1075,8 +1075,7 @@ impl Game<'_> {
|
||||
/// `Screen.clickCommandAction`: run a clicked command as the player
|
||||
/// without adding it to the chat history. A client mod's command goes
|
||||
/// first, as `ClientPacketListener.sendUnattendedCommand` lets client
|
||||
/// commands take it. (Vanilla asks first when the command fails to
|
||||
/// parse; here the server answers it.)
|
||||
/// commands take it.
|
||||
pub(super) fn click_command(&mut self, command: &str) {
|
||||
let command = command.strip_prefix('/').unwrap_or(command);
|
||||
if self.client_mod_command(command) {
|
||||
|
||||
@@ -9,7 +9,7 @@ use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
hud::{HudBatch, rgba},
|
||||
screen_draw::Measure,
|
||||
text::{self, Text},
|
||||
text::Text,
|
||||
ui::{self, EditBox},
|
||||
world::World,
|
||||
};
|
||||
@@ -546,9 +546,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `CommandSourceStack.sendSuccess` from a command block: its output when
|
||||
/// `send_command_feedback`, and `broadcastToAdmins` when
|
||||
/// `send_command_feedback`, and `broadcastToAdmins` when `broadcast` and
|
||||
/// `command_block_output`.
|
||||
pub(super) fn command_block_success(&mut self, pos: Pos, message: Text) {
|
||||
pub(super) fn command_block_success(&mut self, pos: Pos, message: Text, broadcast: bool) {
|
||||
let Some(name) = self
|
||||
.command_block(pos)
|
||||
.filter(|c| c.track_output)
|
||||
@@ -560,11 +560,8 @@ impl Game<'_> {
|
||||
if feedback {
|
||||
self.command_block_output(pos, message.clone());
|
||||
}
|
||||
if feedback && self.ui.rules.bool("command_block_output") {
|
||||
let broadcast = Text::translate("chat.type.admin", &[name, message])
|
||||
.with_color(text::GRAY)
|
||||
.italic();
|
||||
self.system_message(broadcast);
|
||||
if broadcast && feedback && self.ui.rules.bool("command_block_output") {
|
||||
self.broadcast_source_to_admins(name, message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -806,8 +803,8 @@ impl Game<'_> {
|
||||
shortcut: Option<horizon_client::clipboard::EditShortcut>,
|
||||
text: Option<&str>,
|
||||
) {
|
||||
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let ctrl = self.has_control_down_with_quirk();
|
||||
let shift = self.has_shift_down();
|
||||
// Windows' AltGr is Ctrl+Alt, and its characters still type.
|
||||
let types_text = self.types_text();
|
||||
let Some(mut s) = self.command_block_screen.take() else {
|
||||
@@ -920,7 +917,8 @@ impl Game<'_> {
|
||||
conditional: p.flags & 2 != 0,
|
||||
auto: p.flags & 4 != 0,
|
||||
};
|
||||
self.as_guest(host, conn, |game| {
|
||||
// The confirmation (`advMode.setCommand.success`) is the editing player's alone.
|
||||
self.as_guest_messaged(host, conn, |game| {
|
||||
if allowed {
|
||||
game.apply_command_block(&edit);
|
||||
// BaseCommandBlock.onUpdated: the clients get the new command.
|
||||
|
||||
@@ -9,10 +9,7 @@ use super::{
|
||||
lan::LanHost,
|
||||
minecarts::{CartKind, Minecart},
|
||||
};
|
||||
use horizon_client::{
|
||||
command_block::Mode,
|
||||
text::{self, Text},
|
||||
};
|
||||
use horizon_client::{command_block::Mode, text::Text};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -93,7 +90,7 @@ impl Game<'_> {
|
||||
/// `CommandSourceStack.sendSuccess` from a command minecart: its output
|
||||
/// when `send_command_feedback`, and `broadcastToAdmins` when
|
||||
/// `command_block_output`.
|
||||
pub(super) fn cart_command_success(&mut self, id: u32, message: Text) {
|
||||
pub(super) fn cart_command_success(&mut self, id: u32, message: Text, broadcast: bool) {
|
||||
let Some(name) = self
|
||||
.command_cart_mut(id)
|
||||
.filter(|c| c.command.track_output)
|
||||
@@ -105,11 +102,8 @@ impl Game<'_> {
|
||||
if feedback {
|
||||
self.cart_command_output(id, message.clone());
|
||||
}
|
||||
if feedback && self.ui.rules.bool("command_block_output") {
|
||||
let broadcast = Text::translate("chat.type.admin", &[name, message])
|
||||
.with_color(text::GRAY)
|
||||
.italic();
|
||||
self.system_message(broadcast);
|
||||
if broadcast && feedback && self.ui.rules.bool("command_block_output") {
|
||||
self.broadcast_source_to_admins(name, message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +158,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let allowed = g.creative() && g.permission >= 2;
|
||||
self.as_guest(host, conn, |game| {
|
||||
self.as_guest_messaged(host, conn, |game| {
|
||||
if allowed {
|
||||
game.apply_cart_command(p.entity as u32, p.command, p.track_output);
|
||||
} else {
|
||||
|
||||
@@ -63,6 +63,10 @@ pub(super) enum Target {
|
||||
Crystal(u32),
|
||||
/// The Ender Dragon, by its entity id (`minecraft:ender_dragon`).
|
||||
Dragon(u32),
|
||||
/// A primed TNT, by its entity id (`minecraft:tnt`).
|
||||
Tnt(u32),
|
||||
/// A falling block, by its entity id (`minecraft:falling_block`).
|
||||
Falling(u32),
|
||||
}
|
||||
/// An entity of a type Horizon does not simulate: its compound lives in the
|
||||
/// entity memory (`persist.raw`), its position and rotation here.
|
||||
@@ -135,7 +139,9 @@ fn non_player_entity_id(t: Target) -> Option<i32> {
|
||||
| Target::Cart(id)
|
||||
| Target::Other(id)
|
||||
| Target::Crystal(id)
|
||||
| Target::Dragon(id)) = t
|
||||
| Target::Dragon(id)
|
||||
| Target::Tnt(id)
|
||||
| Target::Falling(id)) = t
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
@@ -242,11 +248,11 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if let Some(block) = self.command_source.and_then(|s| s.block) {
|
||||
self.command_block_success(block, message);
|
||||
self.command_block_success(block, message, false);
|
||||
return;
|
||||
}
|
||||
if let Some(cart) = self.command_source.and_then(|s| s.cart) {
|
||||
self.cart_command_success(cart, message);
|
||||
self.cart_command_success(cart, message, false);
|
||||
return;
|
||||
}
|
||||
if self.ui.rules.bool("send_command_feedback") {
|
||||
@@ -259,11 +265,11 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if let Some(block) = self.command_source.and_then(|s| s.block) {
|
||||
self.command_block_success(block, message);
|
||||
self.command_block_success(block, message, true);
|
||||
return;
|
||||
}
|
||||
if let Some(cart) = self.command_source.and_then(|s| s.cart) {
|
||||
self.cart_command_success(cart, message);
|
||||
self.cart_command_success(cart, message, true);
|
||||
return;
|
||||
}
|
||||
if self.ui.rules.bool("send_command_feedback") {
|
||||
@@ -271,6 +277,36 @@ impl Game<'_> {
|
||||
}
|
||||
self.broadcast_to_admins(message);
|
||||
}
|
||||
/// `CommandSourceStack.broadcastToAdmins` for a command block or a command
|
||||
/// minecart called `name`: `[name: message]`, gray and italic, for every
|
||||
/// operator when `send_command_feedback` (the world's host too: no player
|
||||
/// ran the command) and for a dedicated server's console when
|
||||
/// `log_admin_commands`. Queued: `send_admin_feedback` sends it.
|
||||
pub(super) fn broadcast_source_to_admins(&mut self, name: Text, message: Text) {
|
||||
if message
|
||||
.translation_key()
|
||||
.is_some_and(|k| admin::QUIET.contains(&k))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let line = Text::translate("chat.type.admin", &[name, message])
|
||||
.with_color(text::GRAY)
|
||||
.italic();
|
||||
// Nobody else is in this game: its player is the only operator there is.
|
||||
if self.lan.is_none()
|
||||
&& self.lan_acting.is_none()
|
||||
&& self.serving.is_none()
|
||||
&& self.options.dedicated.is_none()
|
||||
{
|
||||
self.system_message(line);
|
||||
return;
|
||||
}
|
||||
let ops = self.ui.rules.bool("send_command_feedback");
|
||||
let console = self.options.dedicated.is_some() && self.ui.rules.bool("log_admin_commands");
|
||||
if ops || console {
|
||||
self.admin_feedback.push((line, ops, console, true));
|
||||
}
|
||||
}
|
||||
/// CommandSourceStack.broadcastToAdmins (`sendSuccess` with `broadcast`)
|
||||
/// for a guest's command, the host's in a world open to LAN and the
|
||||
/// dedicated server console's when `broadcast-console-to-ops` is on:
|
||||
@@ -310,7 +346,7 @@ impl Game<'_> {
|
||||
let line = Text::translate("chat.type.admin", &[who, message])
|
||||
.with_color(text::GRAY)
|
||||
.italic();
|
||||
self.admin_feedback.push((line, ops, console));
|
||||
self.admin_feedback.push((line, ops, console, false));
|
||||
}
|
||||
/// Player.getDisplayName: the name (team-formatted), suggesting a
|
||||
/// `/tell`, showing the player on hover, inserting the name.
|
||||
@@ -479,6 +515,16 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.filter(|d| d.id == id)
|
||||
.map(|d| [d.position.x, d.position.y, d.position.z]),
|
||||
Target::Tnt(id) => self
|
||||
.tnts
|
||||
.iter()
|
||||
.find(|t| t.id == id)
|
||||
.map(|t| [t.position.x, t.position.y, t.position.z]),
|
||||
Target::Falling(id) => self
|
||||
.falling
|
||||
.iter()
|
||||
.find(|f| f.id == id)
|
||||
.map(|f| [f.position.x, f.position.y, f.position.z]),
|
||||
}
|
||||
}
|
||||
pub(super) fn target_type(&self, t: Target) -> String {
|
||||
@@ -525,6 +571,8 @@ impl Game<'_> {
|
||||
.map_or_else(String::new, |o| o.kind.clone()),
|
||||
Target::Crystal(_) => "minecraft:end_crystal".into(),
|
||||
Target::Dragon(_) => "minecraft:ender_dragon".into(),
|
||||
Target::Tnt(_) => "minecraft:tnt".into(),
|
||||
Target::Falling(_) => "minecraft:falling_block".into(),
|
||||
}
|
||||
}
|
||||
/// The name of the player the game acts for: a guest's own while their
|
||||
@@ -559,6 +607,18 @@ impl Game<'_> {
|
||||
.unwrap_or_default();
|
||||
Text::literal(self.pack.names.display_item(&item))
|
||||
}
|
||||
// FallingBlockEntity.getTypeName: `entity.minecraft.falling_block_type`
|
||||
// with the block's name.
|
||||
Target::Falling(id) => {
|
||||
let block = self
|
||||
.falling
|
||||
.iter()
|
||||
.find(|f| f.id == id)
|
||||
.map(|f| self.res.facts().get(f.state).name.replace(':', "."))
|
||||
.unwrap_or_default();
|
||||
let block = Text::translate(&format!("block.{block}"), &[]);
|
||||
Text::translate("entity.minecraft.falling_block_type", &[block])
|
||||
}
|
||||
Target::Mob(id)
|
||||
if self
|
||||
.mobs
|
||||
@@ -779,6 +839,7 @@ impl Game<'_> {
|
||||
// The kinds /data can write (entity_nbt).
|
||||
.filter(|p| {
|
||||
p.is_arrow()
|
||||
|| horizon_client::entity_nbt::saved_with_chunk(p)
|
||||
|| matches!(
|
||||
p.kind,
|
||||
K::LargeFireball { .. }
|
||||
@@ -801,6 +862,8 @@ impl Game<'_> {
|
||||
if let Some(d) = &self.dragon {
|
||||
candidates.push(Target::Dragon(d.id));
|
||||
}
|
||||
candidates.extend(self.tnts.iter().map(|t| Target::Tnt(t.id)));
|
||||
candidates.extend(self.falling.iter().map(|f| Target::Falling(f.id)));
|
||||
candidates
|
||||
}
|
||||
/// The loaded levels, in the order the server holds them: the
|
||||
@@ -832,6 +895,8 @@ impl Game<'_> {
|
||||
Target::Other(id),
|
||||
Target::Crystal(id),
|
||||
Target::Dragon(id),
|
||||
Target::Tnt(id),
|
||||
Target::Falling(id),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|t| self.level_holds(*t))
|
||||
@@ -853,6 +918,8 @@ impl Game<'_> {
|
||||
Target::Other(id) => self.others.iter().any(|o| o.id == id),
|
||||
Target::Crystal(id) => self.crystals.iter().any(|c| c.id == id),
|
||||
Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id),
|
||||
Target::Tnt(id) => self.tnts.iter().any(|t| t.id == id),
|
||||
Target::Falling(id) => self.falling.iter().any(|f| f.id == id),
|
||||
}
|
||||
}
|
||||
/// Run `f` with the level that holds the entity a target names in place
|
||||
@@ -1463,6 +1530,18 @@ impl Game<'_> {
|
||||
d.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Tnt(id) => {
|
||||
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id) {
|
||||
t.position = v;
|
||||
t.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Falling(id) => {
|
||||
if let Some(f) = self.falling.iter_mut().find(|f| f.id == id) {
|
||||
f.position = v;
|
||||
f.previous = v;
|
||||
}
|
||||
}
|
||||
// Wall-mounted entities do not move.
|
||||
Target::Frame(_) | Target::Painting(_) => {}
|
||||
}
|
||||
@@ -1960,6 +2039,8 @@ impl Game<'_> {
|
||||
Target::Other(id) => self.others.retain(|o| o.id != id),
|
||||
Target::Crystal(id) => self.kill_crystal(id),
|
||||
Target::Dragon(id) => self.kill_dragon(id),
|
||||
Target::Tnt(id) => self.tnts.retain(|t| t.id != id),
|
||||
Target::Falling(id) => self.falling.retain(|f| f.id != id),
|
||||
}
|
||||
// The orbs marked for removal are gone (Entity.kill removes them).
|
||||
let before = self.orb_ids();
|
||||
@@ -2162,26 +2243,12 @@ impl Game<'_> {
|
||||
}
|
||||
/// BlockPredicateArgument test.
|
||||
fn block_matches(&self, b: &BlockInput, state: BlockStateId) -> bool {
|
||||
let Some(s) = self.pack.assets.registry.state(state) else {
|
||||
return false;
|
||||
};
|
||||
let name_ok = if b.tag {
|
||||
let tag = b.id.trim_start_matches("minecraft:");
|
||||
self.res
|
||||
.items()
|
||||
.block_tags
|
||||
.get(tag)
|
||||
.or_else(|| self.items().block_tags.get(&b.id))
|
||||
.is_some_and(|set| {
|
||||
set.contains(&s.name) || set.contains(s.name.trim_start_matches("minecraft:"))
|
||||
})
|
||||
} else {
|
||||
s.name == b.id
|
||||
};
|
||||
name_ok
|
||||
&& b.properties
|
||||
.iter()
|
||||
.all(|(k, v)| s.properties.get(k) == Some(v))
|
||||
block_input_matches(
|
||||
self.pack.assets.registry,
|
||||
[&self.res.items().block_tags, &self.items().block_tags],
|
||||
b,
|
||||
state,
|
||||
)
|
||||
}
|
||||
/// Place a state like BlockInput.place: false when nothing changed.
|
||||
pub(super) fn place_state(
|
||||
@@ -3587,6 +3654,35 @@ fn split_top_level(s: &str, sep: char) -> Vec<&str> {
|
||||
out
|
||||
}
|
||||
|
||||
/// BlockPredicateArgument test, against the registry and the block tags:
|
||||
/// a `#tag` is looked up by its path in `tags[0]`, then by its whole id in
|
||||
/// `tags[1]` (as `Game::block_matches` reads them).
|
||||
pub(super) fn block_input_matches(
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
tags: [&std::collections::HashMap<String, std::collections::HashSet<String>>; 2],
|
||||
b: &BlockInput,
|
||||
state: BlockStateId,
|
||||
) -> bool {
|
||||
let Some(s) = registry.state(state) else {
|
||||
return false;
|
||||
};
|
||||
let name_ok = if b.tag {
|
||||
let tag = b.id.trim_start_matches("minecraft:");
|
||||
tags[0]
|
||||
.get(tag)
|
||||
.or_else(|| tags[1].get(&b.id))
|
||||
.is_some_and(|set| {
|
||||
set.contains(&s.name) || set.contains(s.name.trim_start_matches("minecraft:"))
|
||||
})
|
||||
} else {
|
||||
s.name == b.id
|
||||
};
|
||||
name_ok
|
||||
&& b.properties
|
||||
.iter()
|
||||
.all(|(k, v)| s.properties.get(k) == Some(v))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod summon_tests {
|
||||
#[test]
|
||||
@@ -3616,6 +3712,8 @@ mod look_at_tests {
|
||||
Target::Cart(7),
|
||||
Target::Crystal(7),
|
||||
Target::Dragon(7),
|
||||
Target::Tnt(7),
|
||||
Target::Falling(7),
|
||||
] {
|
||||
assert_eq!(non_player_entity_id(t), Some(7), "{t:?}");
|
||||
}
|
||||
|
||||
@@ -164,7 +164,8 @@ impl Game<'_> {
|
||||
.and_then(|a| a.last_state)
|
||||
.map(|s| self.state_to_nbt(s));
|
||||
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)
|
||||
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))?
|
||||
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))
|
||||
.or_else(|| entity_nbt::throwable_to_nbt(p, random_uuid(), owner, None))?
|
||||
}
|
||||
Target::Crystal(id) => {
|
||||
let c = self.crystals.iter().find(|c| c.id == id)?;
|
||||
@@ -176,6 +177,36 @@ impl Game<'_> {
|
||||
let (uuid, raw) = self.persist_anon("minecraft:ender_dragon", d.position);
|
||||
d.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
|
||||
}
|
||||
Target::Tnt(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let t = self.tnts.iter().find(|t| t.id == id)?;
|
||||
entity_nbt::tnt_to_nbt(
|
||||
t.position,
|
||||
t.velocity,
|
||||
t.on_ground,
|
||||
t.fuse,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
)
|
||||
}
|
||||
Target::Falling(id) => {
|
||||
let f = self.falling.iter().find(|f| f.id == id)?.clone();
|
||||
let (uuid, raw) = match &f.saved {
|
||||
Some(saved) => (saved.0, Some(&saved.1)),
|
||||
None => (self.uuid_for(id), None),
|
||||
};
|
||||
let tag = entity_nbt::falling_block_to_nbt(
|
||||
self.state_to_nbt(f.state),
|
||||
f.position,
|
||||
f.velocity,
|
||||
f.age,
|
||||
f.drop_item,
|
||||
f.damage,
|
||||
f.fall_distance,
|
||||
uuid,
|
||||
);
|
||||
crate::game::persist::with_raw(tag, raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
/// `Entity.load` of new data (`EntityDataAccessor.setData`): the entity is
|
||||
@@ -776,6 +807,10 @@ impl Game<'_> {
|
||||
Some(Target::Cart(id))
|
||||
} else if self.others.iter().any(|o| o.id == id) {
|
||||
Some(Target::Other(id))
|
||||
} else if self.tnts.iter().any(|t| t.id == id) {
|
||||
Some(Target::Tnt(id))
|
||||
} else if self.falling.iter().any(|f| f.id == id) {
|
||||
Some(Target::Falling(id))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -407,6 +407,13 @@ impl Game<'_> {
|
||||
)
|
||||
.into());
|
||||
}
|
||||
// ConfirmScreen.keyPressed: Escape answers no, nothing else goes past it.
|
||||
if self.command_prompt.is_some() {
|
||||
if key == KeyCode::Escape && arg(2)? != "up" {
|
||||
self.answer_command_prompt(false);
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
// A server's dialog takes the keys, in configuration too.
|
||||
if self.dialog_open() {
|
||||
if arg(2)? != "up" {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
use super::{Game, creatures::seen};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
chunk_map::{ChunkMap, ChunkMembershipDelta},
|
||||
containers::{BlockEntity, CreakingHeart},
|
||||
mobs::{
|
||||
Mob, MobType,
|
||||
@@ -302,24 +303,30 @@ impl Game<'_> {
|
||||
&& self.ui.rules.bool("spawn_monsters")
|
||||
}
|
||||
/// Hearts that came with generated or loaded chunks get their block
|
||||
/// entity (`newBlockEntity`) the first time their chunk is seen.
|
||||
/// entity (`newBlockEntity`) the first time their chunk is seen lit.
|
||||
/// Only the chunks that arrived since are looked at: a server holds tens
|
||||
/// of thousands, and reading each one every tick cost milliseconds.
|
||||
pub(super) fn scan_for_hearts(&mut self) {
|
||||
let Some(heart) = self
|
||||
if self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:creaking_heart")
|
||||
else {
|
||||
.is_none()
|
||||
{
|
||||
return;
|
||||
};
|
||||
let keys: Vec<(i32, i32)> = self
|
||||
.world
|
||||
.chunks
|
||||
}
|
||||
self.creaking.follow_chunks(&self.world.chunks);
|
||||
let mut keys: Vec<(i32, i32)> = self
|
||||
.creaking
|
||||
.unscanned
|
||||
.iter()
|
||||
.filter(|(k, c)| c.lit && !self.creaking.scanned.contains(k))
|
||||
.map(|(k, _)| *k)
|
||||
.copied()
|
||||
.filter(|k| self.world.chunks.get(k).is_some_and(|c| c.lit))
|
||||
.collect();
|
||||
keys.sort_unstable();
|
||||
for key in keys {
|
||||
self.creaking.unscanned.remove(&key);
|
||||
self.creaking.scanned.insert(key);
|
||||
let Some(chunk) = self.world.chunks.get(&key).cloned() else {
|
||||
continue;
|
||||
@@ -334,7 +341,6 @@ impl Game<'_> {
|
||||
.collect();
|
||||
}
|
||||
let hearts = &self.creaking.hearts;
|
||||
let _ = heart;
|
||||
let is_heart = |id: u32| hearts.get(id as usize).copied().unwrap_or(false);
|
||||
for (si, section) in chunk.sections.iter().enumerate() {
|
||||
// Only a section whose palette holds a heart state can have
|
||||
@@ -359,8 +365,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let chunks = &self.world.chunks;
|
||||
self.creaking.scanned.retain(|k| chunks.contains_key(k));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,6 +373,10 @@ impl Game<'_> {
|
||||
pub(super) struct CreakingState {
|
||||
/// Chunks already scanned for heart blocks.
|
||||
scanned: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
|
||||
/// Resident chunks not scanned yet (not lit yet, or just arrived).
|
||||
unscanned: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
|
||||
/// The chunk map's membership revision the two sets follow.
|
||||
revision: Option<u64>,
|
||||
/// Which block states are creaking hearts, by state id.
|
||||
hearts: Vec<bool>,
|
||||
}
|
||||
@@ -378,5 +386,100 @@ impl CreakingState {
|
||||
/// (game/levels.rs).
|
||||
pub(super) fn swap_level(&mut self, other: &mut Self) {
|
||||
std::mem::swap(&mut self.scanned, &mut other.scanned);
|
||||
std::mem::swap(&mut self.unscanned, &mut other.unscanned);
|
||||
std::mem::swap(&mut self.revision, &mut other.revision);
|
||||
}
|
||||
/// Bring `scanned` and `unscanned` up to the chunks resident now: a
|
||||
/// chunk that left is forgotten (it is scanned again if it comes back
|
||||
/// later), one that arrived waits to be scanned. From the map's
|
||||
/// membership changes, or from all its keys when they can't be replayed.
|
||||
fn follow_chunks(&mut self, chunks: &ChunkMap) {
|
||||
let revision = chunks.membership_revision();
|
||||
if self.revision == Some(revision) {
|
||||
return;
|
||||
}
|
||||
let deltas = self.revision.and_then(|r| chunks.deltas_since(r));
|
||||
match deltas {
|
||||
Some(deltas) => {
|
||||
for (_, delta) in deltas {
|
||||
match delta {
|
||||
ChunkMembershipDelta::Added(key) => {
|
||||
if chunks.contains_key(&key) && !self.scanned.contains(&key) {
|
||||
self.unscanned.insert(key);
|
||||
}
|
||||
}
|
||||
ChunkMembershipDelta::Removed(key) => {
|
||||
if !chunks.contains_key(&key) {
|
||||
self.scanned.remove(&key);
|
||||
self.unscanned.remove(&key);
|
||||
}
|
||||
}
|
||||
ChunkMembershipDelta::Reset => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.scanned.retain(|k| chunks.contains_key(k));
|
||||
self.unscanned = chunks
|
||||
.keys()
|
||||
.filter(|k| !self.scanned.contains(k))
|
||||
.copied()
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
self.revision = Some(revision);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::CreakingState;
|
||||
use horizon_client::{chunk::Chunk, chunk_map::ChunkMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn add(chunks: &mut ChunkMap, key: (i32, i32)) {
|
||||
chunks.insert(key, Arc::new(Chunk::empty(key.0, key.1)));
|
||||
}
|
||||
fn sorted(set: &horizon_client::fast_hash::FxHashSet<(i32, i32)>) -> Vec<(i32, i32)> {
|
||||
let mut keys: Vec<_> = set.iter().copied().collect();
|
||||
keys.sort_unstable();
|
||||
keys
|
||||
}
|
||||
|
||||
/// The heart scan follows the chunks that come and go, as the scan of
|
||||
/// every resident chunk each tick did: an arrival waits, a scanned chunk
|
||||
/// that stayed is not scanned again, and one that left is forgotten.
|
||||
#[test]
|
||||
fn the_heart_scan_follows_arrivals_and_departures() {
|
||||
let mut chunks = ChunkMap::default();
|
||||
let mut state = CreakingState::default();
|
||||
add(&mut chunks, (0, 0));
|
||||
add(&mut chunks, (1, 0));
|
||||
state.follow_chunks(&chunks);
|
||||
assert_eq!(sorted(&state.unscanned), [(0, 0), (1, 0)]);
|
||||
// Scanned, as scan_for_hearts does once a chunk is lit.
|
||||
state.unscanned.remove(&(0, 0));
|
||||
state.scanned.insert((0, 0));
|
||||
add(&mut chunks, (2, 0));
|
||||
chunks.remove(&(1, 0));
|
||||
// Gone and back between two ticks: still scanned, as before.
|
||||
chunks.remove(&(0, 0));
|
||||
add(&mut chunks, (0, 0));
|
||||
state.follow_chunks(&chunks);
|
||||
assert_eq!(sorted(&state.scanned), [(0, 0)]);
|
||||
assert_eq!(sorted(&state.unscanned), [(2, 0)]);
|
||||
// Gone over a tick: forgotten, and scanned again when it returns.
|
||||
chunks.remove(&(0, 0));
|
||||
state.follow_chunks(&chunks);
|
||||
assert!(state.scanned.is_empty());
|
||||
add(&mut chunks, (0, 0));
|
||||
state.follow_chunks(&chunks);
|
||||
assert_eq!(sorted(&state.unscanned), [(0, 0), (2, 0)]);
|
||||
// A cleared map can't be replayed: rebuilt from its keys.
|
||||
chunks.clear();
|
||||
add(&mut chunks, (5, 5));
|
||||
state.follow_chunks(&chunks);
|
||||
assert_eq!(sorted(&state.unscanned), [(5, 5)]);
|
||||
assert!(state.scanned.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -3474,7 +3474,11 @@ impl Game<'_> {
|
||||
});
|
||||
let mut keep_flags = vec![true; projectiles.len()];
|
||||
for i in 0..projectiles.len() {
|
||||
let winds = if matches!(projectiles[i].kind, ProjectileKind::EnderPearl) {
|
||||
// A wind charge is pickable, so a pearl, an arrow or a trident can
|
||||
// strike it (`Projectile.isPickable`, `Projectile.onHit`).
|
||||
let winds = if matches!(projectiles[i].kind, ProjectileKind::EnderPearl)
|
||||
|| projectiles[i].arrow.is_some()
|
||||
{
|
||||
wind_charge_hitboxes(&projectiles, &keep_flags, i)
|
||||
} else {
|
||||
Vec::new()
|
||||
@@ -3502,10 +3506,19 @@ impl Game<'_> {
|
||||
p.owner = shooter;
|
||||
}
|
||||
let (keep, hit) = match (p.arrow.is_some(), mob_shot || pvp, winds.is_empty()) {
|
||||
(true, false, _) => horizon_client::arrows::tick(p, &scene, &arrow_targets),
|
||||
(true, true, _) => {
|
||||
(true, false, true) => horizon_client::arrows::tick(p, &scene, &arrow_targets),
|
||||
(true, true, true) => {
|
||||
horizon_client::arrows::tick(p, &scene, &arrow_targets_guests)
|
||||
}
|
||||
(true, with_guests, false) => {
|
||||
let mut targets = if with_guests {
|
||||
arrow_targets_guests.clone()
|
||||
} else {
|
||||
arrow_targets.clone()
|
||||
};
|
||||
targets.extend(winds);
|
||||
horizon_client::arrows::tick(p, &scene, &targets)
|
||||
}
|
||||
(false, false, true) => p.tick(&pw),
|
||||
(false, true, true) => p.tick(&pw_guests),
|
||||
(false, pvp_shot, false) => {
|
||||
@@ -4133,6 +4146,19 @@ impl Game<'_> {
|
||||
}
|
||||
// AbstractArrow.onHitBlock plays its sound itself (ArrowEvent).
|
||||
(ProjectileKind::Trident, Hit::Block { .. }) => {}
|
||||
// AbstractArrow.onHitEntity on a wind charge: `hurtOrSimulate` fails
|
||||
// (the charge turned in `Projectile.onHit`, which already ran), so the
|
||||
// arrow takes `deflect(REVERSE, 0.2)` instead of vanishing.
|
||||
(
|
||||
ProjectileKind::Arrow { .. },
|
||||
Hit::Entity {
|
||||
who: Who::Mob(id), ..
|
||||
},
|
||||
) if wind_charge_hit(id).is_some() => {
|
||||
let mut arrow = p.clone();
|
||||
arrow_damage(&mut arrow, &mut self.world.random);
|
||||
self.finish_arrow_hit(arrow, EntityHit::Refused);
|
||||
}
|
||||
(ProjectileKind::Arrow { .. }, Hit::Entity { who, .. }) => {
|
||||
let mut arrow = p.clone();
|
||||
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
|
||||
@@ -5882,7 +5908,9 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
let p = if fluid {
|
||||
// useOn: a clicked block without a collision shape (vines, grass)
|
||||
// takes the mob itself, any other the one on the clicked face.
|
||||
let p = if fluid || self.collision_empty(hit.pos) {
|
||||
hit.pos
|
||||
} else {
|
||||
[
|
||||
|
||||
@@ -43,6 +43,29 @@ fn projectile_slot(
|
||||
})
|
||||
}
|
||||
|
||||
/// `CrossbowItem.onUseTick`'s loading sounds for a charge now at `charge`
|
||||
/// (of `getChargeDuration`) that has played `played` so far: the start and
|
||||
/// middle sounds each play once the charge reaches their point, and both
|
||||
/// count as unheard again while the charge is under the first one. A tick
|
||||
/// that jumped the charge past a point still plays that point's sound, as
|
||||
/// vanilla's two flags do. Returns the flags for the next tick and the
|
||||
/// sounds to play now.
|
||||
fn charge_sounds(
|
||||
charge: f32,
|
||||
played: [bool; 2],
|
||||
sounds: [&'static str; 3],
|
||||
) -> ([bool; 2], Option<&'static str>, Option<&'static str>) {
|
||||
let mut played = if charge < 0.2 { [false; 2] } else { played };
|
||||
let mut play = [None; 2];
|
||||
for (i, point) in [0.2, 0.5].into_iter().enumerate() {
|
||||
if charge >= point && !played[i] && !sounds[i].is_empty() {
|
||||
played[i] = true;
|
||||
play[i] = Some(sounds[i]);
|
||||
}
|
||||
}
|
||||
(played, play[0], play[1])
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn charge_ticks(stack: &ItemStack) -> u32 {
|
||||
// crossbow_charge_time: 1.25 s, minus 0.25 s per Quick Charge level.
|
||||
@@ -96,20 +119,23 @@ impl Game<'_> {
|
||||
}
|
||||
/// `CrossbowItem.onUseTick` for the charge in progress.
|
||||
pub(super) fn tick_crossbow(&mut self, slot: usize, ticks: u32, stack: &ItemStack) {
|
||||
let ticks = ticks + 1;
|
||||
self.using = Some((slot, ticks));
|
||||
// getUseDuration - the ticks left, as a mod's Start or Tick left them.
|
||||
let held = self.ticks_held(stack, ticks);
|
||||
self.using = Some((slot, ticks + 1));
|
||||
let charge = Self::charge_ticks(stack).max(1) as f32;
|
||||
let (before, now) = ((ticks - 1) as f32 / charge, ticks as f32 / charge);
|
||||
let now = (held + 1) as f32 / charge;
|
||||
let sounds = Self::charging_sounds(stack);
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
if before < 0.2 && now >= 0.2 && !sounds[0].is_empty() {
|
||||
self.audio.play(sounds[0], at, 0.5, 1.0);
|
||||
let (played, start, middle) = charge_sounds(now, self.crossbow_sounds, sounds);
|
||||
self.crossbow_sounds = played;
|
||||
if let Some(sound) = start {
|
||||
self.audio.play(sound, at, 0.5, 1.0);
|
||||
}
|
||||
if before < 0.5 && now >= 0.5 && !sounds[1].is_empty() {
|
||||
self.audio.play(sounds[1], at, 0.5, 1.0);
|
||||
if let Some(sound) = middle {
|
||||
self.audio.play(sound, at, 0.5, 1.0);
|
||||
}
|
||||
let charged = stack.data().is_some_and(|d| !d.charged.is_empty());
|
||||
if now >= 1.0 && !charged {
|
||||
@@ -253,6 +279,44 @@ impl Game<'_> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A charge that crosses a point between two ticks plays that point's
|
||||
/// sound, and one a mod's tick jumps over (`native_living_entity_use_item_tick`
|
||||
/// cutting the ticks left) still plays every point it passed, as
|
||||
/// `CrossbowItem.onUseTick`'s two flags do.
|
||||
#[test]
|
||||
fn a_jumped_charge_still_plays_the_loading_sounds_it_passed() {
|
||||
let sounds = [
|
||||
"minecraft:item.crossbow.loading_start",
|
||||
"minecraft:item.crossbow.loading_middle",
|
||||
"minecraft:item.crossbow.loading_end",
|
||||
];
|
||||
// Each step: the charge and the sounds it plays, from the flags the
|
||||
// step before left.
|
||||
let mut played = [false; 2];
|
||||
let mut step = |charge: f32| {
|
||||
let (next, start, middle) = charge_sounds(charge, played, sounds);
|
||||
played = next;
|
||||
[start, middle]
|
||||
};
|
||||
assert_eq!(step(0.1), [None, None]);
|
||||
assert_eq!(step(0.25), [Some(sounds[0]), None]);
|
||||
assert_eq!(step(0.25), [None, None]);
|
||||
assert_eq!(step(0.55), [None, Some(sounds[1])]);
|
||||
assert_eq!(step(1.0), [None, None]);
|
||||
// A charge below the first point is a new charge: both sound again.
|
||||
assert_eq!(step(0.1), [None, None]);
|
||||
assert_eq!(step(0.6), [Some(sounds[0]), Some(sounds[1])]);
|
||||
// One tick from the start past every point, as a mod's tick makes it.
|
||||
assert_eq!(step(0.1), [None, None]);
|
||||
assert_eq!(step(0.9), [Some(sounds[0]), Some(sounds[1])]);
|
||||
// Quick Charge 1 and up have no middle sound.
|
||||
let quick = [sounds[0], "", sounds[2]];
|
||||
assert_eq!(
|
||||
charge_sounds(0.9, [false; 2], quick),
|
||||
([true, false], Some(sounds[0]), None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_projectiles_come_before_the_inventory() {
|
||||
let stack = |item: &str| Some(ItemStack::new(item, 1));
|
||||
|
||||
@@ -359,8 +359,10 @@ impl Game<'_> {
|
||||
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
|
||||
return false;
|
||||
}
|
||||
// `skipAttackInteraction` when `mayInteract` is false.
|
||||
// `Cushion.hurtServer` refuses a player who may not build, so
|
||||
// skipAttackInteraction is false and Player.attack goes on to onAttack.
|
||||
if !self.may_build() {
|
||||
self.attack_ticks = 0;
|
||||
return true;
|
||||
}
|
||||
self.break_cushion(i, true);
|
||||
|
||||
@@ -363,8 +363,19 @@ impl Game<'_> {
|
||||
.map_or("0".into(), |mhz| {
|
||||
debug_overlay::refresh_rate_label(f64::from(mhz) / 1000.0)
|
||||
});
|
||||
// Horizon: with frame generation running, the frames reaching the screen too.
|
||||
let generated = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.and_then(|r| r.frame_generation_share())
|
||||
.map_or(String::new(), |share| {
|
||||
format!(
|
||||
" ({} fps FSR 3.1)",
|
||||
(self.fps * (1.0 + share)).round() as i32
|
||||
)
|
||||
});
|
||||
d.priority(format!(
|
||||
"{} fps T: {}{present} @{refresh}Hz",
|
||||
"{} fps{generated} T: {}{present} @{refresh}Hz",
|
||||
self.fps.round() as i32,
|
||||
if limit == 260 {
|
||||
"inf".into()
|
||||
|
||||
@@ -330,8 +330,7 @@ impl Game<'_> {
|
||||
self.debug_options = Some(s);
|
||||
return;
|
||||
}
|
||||
let shift =
|
||||
self.keys.contains(&KeyCode::ShiftLeft) || self.keys.contains(&KeyCode::ShiftRight);
|
||||
let shift = self.has_shift_down();
|
||||
s.search.focused = ui::hovered(
|
||||
self.cursor,
|
||||
s.search.x,
|
||||
@@ -431,8 +430,8 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let shortcut = self.edit_shortcut(event);
|
||||
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let ctrl = self.has_control_down_with_quirk();
|
||||
let shift = self.has_shift_down();
|
||||
let types_text = self.types_text();
|
||||
let Some(s) = &mut self.debug_options else {
|
||||
return;
|
||||
|
||||
@@ -49,6 +49,27 @@ impl Game<'_> {
|
||||
let entries = body["entries"].as_array().cloned().unwrap_or_default();
|
||||
let lists = state.lists.clone();
|
||||
match body["method"].as_str().unwrap_or_default() {
|
||||
"serversettings/motd" => {
|
||||
return json!({
|
||||
"value": self.lan.as_ref().map(|lan| lan.hub.lan.shared.motd()).unwrap_or_default()
|
||||
});
|
||||
}
|
||||
"serversettings/motd/set" => {
|
||||
let Some(value) = body["value"].as_str() else {
|
||||
return json!({ "error": "MOTD must be a string" });
|
||||
};
|
||||
if let Some(lan) = &self.lan {
|
||||
rpc(format!(
|
||||
"Update MOTD from '{}' to '{}'",
|
||||
lan.hub.lan.shared.motd(),
|
||||
value
|
||||
));
|
||||
lan.hub.lan.shared.set_motd(value.to_owned());
|
||||
}
|
||||
// DedicatedServer.setMotd: server.properties keeps it too.
|
||||
self.store_property("motd", value.to_owned());
|
||||
return json!({ "value": value });
|
||||
}
|
||||
"serversettings/difficulty" => {
|
||||
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
|
||||
}
|
||||
|
||||
@@ -278,6 +278,7 @@ pub(super) struct Dragon {
|
||||
/// `lastHurtByPlayer` with its 100 tick memory: the player credited with
|
||||
/// the kill, and the dragon tick of their last hit.
|
||||
hurt_by_player: Option<(FightWho, i32)>,
|
||||
pub last_source: Option<(&'static str, Option<i32>, Option<i32>)>,
|
||||
pub death_time: i32,
|
||||
pub flap: f32,
|
||||
pub old_flap: f32,
|
||||
@@ -406,6 +407,7 @@ impl Dragon {
|
||||
invulnerable: 0,
|
||||
last_hurt: 0.0,
|
||||
hurt_by_player: None,
|
||||
last_source: None,
|
||||
death_time: 0,
|
||||
flap: 0.0,
|
||||
old_flap: 0.0,
|
||||
@@ -1182,6 +1184,15 @@ impl Game<'_> {
|
||||
let pitch = 0.8 + self.world.random.next_float() * 0.3;
|
||||
self.audio
|
||||
.play("minecraft:entity.ender_dragon.hurt", Some(at), 5.0, pitch);
|
||||
if let Some(s) = source {
|
||||
d.last_source = Some((
|
||||
s.damage_type,
|
||||
(s.causing != 0).then_some(s.causing),
|
||||
(s.direct != 0).then_some(s.direct),
|
||||
));
|
||||
} else {
|
||||
d.last_source = Some(("minecraft:generic", None, None));
|
||||
}
|
||||
}
|
||||
hurt
|
||||
}
|
||||
@@ -3587,9 +3598,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The remote dragon, if `id` is it.
|
||||
/// The remote dragon, if `id` is it or one of its parts.
|
||||
fn remote_dragon_mut(&mut self, id: i32) -> Option<&mut Dragon> {
|
||||
self.dragon.as_mut().filter(|d| d.id as i32 == id)
|
||||
let Ok(uid) = u32::try_from(id) else {
|
||||
return None;
|
||||
};
|
||||
self.dragon
|
||||
.as_mut()
|
||||
.filter(|d| d.id == uid || (d.id != 0 && (d.id + 1..=d.id + 8).contains(&uid)))
|
||||
}
|
||||
/// `handleSetEntityData`: LivingEntity.DATA_HEALTH_ID and
|
||||
/// EnderDragon.DATA_PHASE (`onSyncedDataUpdated` sets the phase).
|
||||
@@ -3614,13 +3630,15 @@ impl Game<'_> {
|
||||
/// `handleHurtAnimation` and `handleDamageEvent`: the red flash.
|
||||
pub(super) fn remote_dragon_hurt(&mut self, id: i32) {
|
||||
if let Some(d) = self.remote_dragon_mut(id) {
|
||||
// `LivingEntity.handleDamageEvent`: the red flash. Its sound is the
|
||||
// server's (`playHurtSound`); a client plays one only for its own player.
|
||||
d.hurt_time = 10;
|
||||
}
|
||||
}
|
||||
/// `handleRemoveEntities`, or a crystal removed: the healing beam
|
||||
/// keeps pointing at the same crystal.
|
||||
pub(super) fn remove_remote_dragon(&mut self, id: i32) {
|
||||
if self.remote_dragon_mut(id).is_some() {
|
||||
if self.dragon.as_ref().is_some_and(|d| d.id as i32 == id) {
|
||||
self.dragon = None;
|
||||
return;
|
||||
}
|
||||
@@ -4377,4 +4395,12 @@ mod tests {
|
||||
let t = nearest_enemy(&players, false, Vec3::new(9.0, 64.0, 0.0), None, |_| true);
|
||||
assert_eq!(t.map(|p| p.who), Some(FightWho::Host));
|
||||
}
|
||||
#[test]
|
||||
fn a_landed_hurt_sets_the_dragon_hurt_flash_time() {
|
||||
let mut d = dragon_as(42);
|
||||
assert_eq!(d.hurt_time, 0);
|
||||
let hurt = d.hurt(0, 5.0, HIT, false);
|
||||
assert_eq!(hurt, Hurt::Landed { full: true });
|
||||
assert_eq!(d.hurt_time, 10);
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,15 @@ impl EndFlash {
|
||||
fn intensity(&self, partial: f32) -> f32 {
|
||||
self.old_intensity + (self.intensity - self.old_intensity) * partial
|
||||
}
|
||||
/// SkyRenderer.renderEndFlash's pose: the sprite at height 100 turned to the flash.
|
||||
fn pose(&self) -> glam::Mat4 {
|
||||
flash_pose(self.x_angle, self.y_angle)
|
||||
}
|
||||
}
|
||||
|
||||
fn flash_pose(x_angle: f32, y_angle: f32) -> glam::Mat4 {
|
||||
glam::Mat4::from_rotation_y((180.0 - y_angle).to_radians())
|
||||
* glam::Mat4::from_rotation_x((-90.0 - x_angle).to_radians())
|
||||
}
|
||||
impl EndState {
|
||||
pub(super) fn locator(&self) -> Option<Arc<VanillaTerrain>> {
|
||||
@@ -316,9 +325,7 @@ impl Game<'_> {
|
||||
.get("environment/celestial/end_flash")
|
||||
.copied()
|
||||
{
|
||||
let flash = &self.end.flash;
|
||||
let pose = glam::Mat4::from_rotation_y((180.0 - flash.y_angle).to_radians())
|
||||
* glam::Mat4::from_rotation_x((-90.0 - flash.x_angle).to_radians());
|
||||
let pose = self.end.flash.pose();
|
||||
let level = (intensity.clamp(0.0, 1.0) * 255.0) as u32;
|
||||
let color = level << 24 | level << 16 | level << 8 | level;
|
||||
let corners = [
|
||||
@@ -344,6 +351,21 @@ impl Game<'_> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Iris's `endFlashIntensity` and the flash's direction (camera-relative, length 100: the
|
||||
/// sprite's centre), for shader packs; no flash outside the End.
|
||||
pub(super) fn end_flash_for_pack(&self, partial: f32) -> (f32, glam::Vec3) {
|
||||
if self.dim != Dim::End {
|
||||
return (0.0, glam::Vec3::ZERO);
|
||||
}
|
||||
let flash = &self.end.flash;
|
||||
(
|
||||
flash.intensity(partial).clamp(0.0, 1.0),
|
||||
flash
|
||||
.pose()
|
||||
.transform_point3(glam::Vec3::new(0.0, 100.0, 0.0)),
|
||||
)
|
||||
}
|
||||
|
||||
/// LightmapRenderStateExtractor: End flashes add to the sky light factor
|
||||
/// unless Hide Lightning Flashes is on (the sky sprite is not gated), a
|
||||
/// third of it under a boss bar's world fog.
|
||||
@@ -1859,4 +1881,17 @@ mod tests {
|
||||
let centre = corners.iter().copied().sum::<glam::Vec3>() / 4.0;
|
||||
assert!((centre - at).abs_diff_eq(up_dir * 0.125, 1e-5), "{centre}");
|
||||
}
|
||||
|
||||
/// `endFlashPosition`'s direction: the flash sprite's centre, 100 blocks away.
|
||||
#[test]
|
||||
fn the_end_flash_is_where_its_sprite_is() {
|
||||
let at =
|
||||
|x: f32, y: f32| flash_pose(x, y).transform_point3(glam::Vec3::new(0.0, 100.0, 0.0));
|
||||
// Straight up whatever the turn, and level toward the north at y_angle 180.
|
||||
assert!(at(-90.0, 37.0).abs_diff_eq(glam::Vec3::new(0.0, 100.0, 0.0), 1e-3));
|
||||
assert!(at(0.0, 180.0).abs_diff_eq(glam::Vec3::new(0.0, 0.0, -100.0), 1e-3));
|
||||
for (x, y) in [(-60.0, -180.0), (10.0, 95.0), (-25.0, 3.0)] {
|
||||
assert!((at(x, y).length() - 100.0).abs() < 1e-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,15 @@ pub(super) fn blast_reaches(center: [f64; 3], diameter: f64, at: [f64; 3]) -> bo
|
||||
d / diameter <= 1.0
|
||||
}
|
||||
|
||||
/// `ItemEntity.ignoreExplosion` through
|
||||
/// `ServerExplosion.shouldAffectBlocklikeEntities`: an explosion that does not
|
||||
/// affect blocklike entities is ignored entirely by a dropped item. With
|
||||
/// `mobGriefing` off that is every blast that keeps blocks; with it on, none
|
||||
/// (no wind charge goes through `server_explode`).
|
||||
fn items_ignore_explosion(mob_griefing: bool, interaction: BlockInteraction) -> bool {
|
||||
!mob_griefing && interaction == BlockInteraction::Keep
|
||||
}
|
||||
|
||||
/// Whether a blast leaves item frames, paintings, cushions and armour stands
|
||||
/// alone. Without `mobGriefing`, a blast that keeps blocks is ignored
|
||||
/// (`ServerExplosion.shouldAffectBlocklikeEntities` through their
|
||||
@@ -593,9 +602,15 @@ impl Game<'_> {
|
||||
}
|
||||
self.mobs = mobs;
|
||||
// Dropped items: 5 health, destroyed by the explosion damage (nether stars resist).
|
||||
// `ItemEntity.ignoreExplosion` asks `shouldAffectBlocklikeEntities`
|
||||
// first, so a blast that keeps blocks leaves every item untouched.
|
||||
let items_ignore = items_ignore_explosion(self.ui.rules.bool("mob_griefing"), interaction);
|
||||
let before = self.dropped_ids();
|
||||
let mut items = std::mem::take(&mut self.dropped);
|
||||
items.retain_mut(|item| {
|
||||
if items_ignore {
|
||||
return true;
|
||||
}
|
||||
let feet = [item.position.x, item.position.y, item.position.z];
|
||||
let dist = distance(feet) / diameter;
|
||||
if dist > 1.0 {
|
||||
@@ -1168,6 +1183,20 @@ fn add_or_append_stack(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A dropped item survives a mob's blast untouched while `mobGriefing` is
|
||||
/// off, and is thrown and hurt as usual by one that destroys blocks or by
|
||||
/// any blast with the rule back on.
|
||||
#[test]
|
||||
fn items_ignore_a_blast_that_keeps_blocks_with_griefing_off() {
|
||||
assert!(items_ignore_explosion(false, BlockInteraction::Keep));
|
||||
assert!(!items_ignore_explosion(false, BlockInteraction::Destroy));
|
||||
assert!(!items_ignore_explosion(
|
||||
false,
|
||||
BlockInteraction::DestroyWithDecay
|
||||
));
|
||||
assert!(!items_ignore_explosion(true, BlockInteraction::Keep));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blasts_reach_entities_within_their_diameter() {
|
||||
// A TNT blast: radius 4, so the entity pass reaches 8 blocks out.
|
||||
|
||||
@@ -189,12 +189,12 @@ impl Game<'_> {
|
||||
self.avatar.add_walked(dx, dz);
|
||||
}
|
||||
}
|
||||
/// `SpyglassItem.use`: start scoping.
|
||||
/// `SpyglassItem.use`: the sound, then start scoping (a mod's `Start`
|
||||
/// may refuse that, not the sound).
|
||||
pub(super) fn use_spyglass(&mut self) {
|
||||
if self.start_using_item() {
|
||||
let at = self.eye_position();
|
||||
self.audio.play("minecraft:item.spyglass.use", at, 1.0, 1.0);
|
||||
}
|
||||
let at = self.eye_position();
|
||||
self.audio.play("minecraft:item.spyglass.use", at, 1.0, 1.0);
|
||||
self.start_using_item();
|
||||
}
|
||||
/// `SpyglassItem.releaseUsing` / `finishUsingItem` (after 1200 ticks).
|
||||
pub(super) fn stop_spyglass(&mut self) {
|
||||
|
||||
@@ -255,6 +255,12 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
if self.item_frames[i].fixed {
|
||||
// A fixed frame refuses the hit of a player who isn't in creative
|
||||
// (canHurtWhenFixed), so skipAttackInteraction is false and
|
||||
// Player.attack goes on to onAttack.
|
||||
if !self.creative {
|
||||
self.attack_ticks = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let with_frame = self.item_frames[i].item.is_none();
|
||||
|
||||
@@ -11,6 +11,7 @@ use horizon_client::{
|
||||
Result,
|
||||
friends_screen::{self, Action, FriendsView},
|
||||
hud::HudBatch,
|
||||
lang,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
|
||||
@@ -121,7 +122,10 @@ impl Game<'_> {
|
||||
if sharing.opened_lan {
|
||||
self.lan_close(true);
|
||||
} else {
|
||||
self.system_message(Text::literal("Stopped sharing the invite code."));
|
||||
self.system_message(Text::literal(lang::get_or(
|
||||
"horizon.friends.stopped",
|
||||
"Stopped sharing the invite code.",
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,9 +146,10 @@ impl Game<'_> {
|
||||
match event {
|
||||
HostEvent::Code(code) => {
|
||||
if sharing.code.is_some() && sharing.wrong >= horizon_friends::host::MAX_WRONG {
|
||||
sharing.notice = Some(
|
||||
"Someone kept trying a wrong short code, so it changed.".to_owned(),
|
||||
);
|
||||
sharing.notice = Some(lang::get_or(
|
||||
"horizon.friends.short_code_changed",
|
||||
"Someone kept trying a wrong short code, so it changed.",
|
||||
));
|
||||
sharing.wrong = 0;
|
||||
}
|
||||
announce = sharing.code.is_none();
|
||||
@@ -152,7 +157,10 @@ impl Game<'_> {
|
||||
}
|
||||
HostEvent::WrongCode { count } => {
|
||||
sharing.wrong = count;
|
||||
sharing.notice = Some("Someone tried a wrong code.".to_owned());
|
||||
sharing.notice = Some(lang::get_or(
|
||||
"horizon.friends.wrong_code",
|
||||
"Someone tried a wrong code.",
|
||||
));
|
||||
}
|
||||
HostEvent::Connected { direct: true, .. } => sharing.paths.0 += 1,
|
||||
HostEvent::Connected { direct: false, .. } => sharing.paths.1 += 1,
|
||||
@@ -160,9 +168,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if announce {
|
||||
self.system_message(Text::literal(
|
||||
self.system_message(Text::literal(lang::get_or(
|
||||
"horizon.friends.code_ready",
|
||||
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
|
||||
));
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +207,13 @@ impl Game<'_> {
|
||||
match action {
|
||||
Action::Share => {
|
||||
if let Err(e) = self.friends_start() {
|
||||
self.system_message(Text::literal(format!("Couldn't share the world: {e}")));
|
||||
self.system_message(Text::literal(
|
||||
lang::get_or(
|
||||
"horizon.friends.share_failed",
|
||||
"Couldn't share the world: %s",
|
||||
)
|
||||
.replacen("%s", &e.to_string(), 1),
|
||||
));
|
||||
}
|
||||
}
|
||||
Action::Copy => self.friends_copy(),
|
||||
@@ -234,7 +249,10 @@ impl Game<'_> {
|
||||
};
|
||||
let state = s.host.state();
|
||||
let mut status = if s.code.is_none() {
|
||||
"Finding how friends can reach you…".to_owned()
|
||||
lang::get_or(
|
||||
"horizon.friends.finding_route",
|
||||
"Finding how friends can reach you…",
|
||||
)
|
||||
} else {
|
||||
state.summary.clone()
|
||||
};
|
||||
|
||||
@@ -207,7 +207,7 @@ impl Game<'_> {
|
||||
self.process_level_events()
|
||||
}
|
||||
/// The block at `pos` has no collision shape.
|
||||
fn collision_empty(&self, pos: [i32; 3]) -> bool {
|
||||
pub(super) fn collision_empty(&self, pos: [i32; 3]) -> bool {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
|
||||
@@ -869,7 +869,12 @@ impl Game<'_> {
|
||||
self.lan = Some(host);
|
||||
let lines = self.as_console(|g| {
|
||||
g.chat_capture = Some(Vec::new());
|
||||
g.run_command(&command);
|
||||
// A command a native mod registered goes to the mods
|
||||
// first, as the owner's own chat does (the console is
|
||||
// level 4, so every registered usage is allowed).
|
||||
if !g.perform_native_command(conn, 4, command.trim()) {
|
||||
g.run_command(&command);
|
||||
}
|
||||
g.chat_capture.take().unwrap_or_default()
|
||||
});
|
||||
host = self.lan.take().expect("a command puts the hub back");
|
||||
|
||||
@@ -273,7 +273,7 @@ impl Game<'_> {
|
||||
owner.using_this_item = true;
|
||||
owner.use_duration = duration;
|
||||
// getUseItemRemainingTicks: the duration the use started with,
|
||||
// as LivingEntityUseItemEvent.Start left it (`start_using_item`).
|
||||
// as LivingEntityUseItemEvent.Start and .Tick left it (`game/item_use.rs`).
|
||||
owner.use_item_remaining_ticks = self.use_duration - ticks as i32;
|
||||
owner.crossbow_charge_duration = Self::charge_ticks(stack).max(1) as i32;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
//! `Item` subclasses don't know: what `Item.getUseDuration` answers, a
|
||||
//! native mod's own item counted down to its `finishUsingItem`, and the hand
|
||||
//! pose of a `getUseAnimation` (`native_use_duration`, `native_use_animation`,
|
||||
//! `native_living_entity_use_item_start`, `native_living_entity_use_item_finish`).
|
||||
//! `native_living_entity_use_item_start`, `native_living_entity_use_item_finish`),
|
||||
//! and each tick and the release of a use (`native_living_entity_use_item_tick`,
|
||||
//! `native_on_use_tick`, `native_living_entity_use_item_stop`, `native_release_using`).
|
||||
use super::Game;
|
||||
use horizon_client::{items::ItemStack, spear::Kinetic};
|
||||
use horizon_client::{
|
||||
items::{ItemInfo, ItemStack},
|
||||
spear::Kinetic,
|
||||
};
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -17,15 +22,19 @@ impl Game<'_> {
|
||||
let Some(stack) = self.held().cloned().filter(|_| self.using.is_none()) else {
|
||||
return false;
|
||||
};
|
||||
let mut duration = self.use_duration(&stack);
|
||||
let mut canceled = false;
|
||||
let own = self.use_duration(&stack);
|
||||
let (mut duration, mut canceled) = (own, false);
|
||||
let hand = self.hand;
|
||||
self.native_living_entity_use_item_start(&stack, None, hand, &mut duration, &mut canceled);
|
||||
if canceled {
|
||||
let Some(ticks_set) = start_of(own, duration, canceled) else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
self.use_duration = duration;
|
||||
self.use_ticks_set = ticks_set;
|
||||
// A new charge hears the crossbow's loading sounds again
|
||||
// (`game/crossbow.rs`).
|
||||
self.crossbow_sounds = [false; 2];
|
||||
true
|
||||
}
|
||||
/// `ItemStack.getUseDuration`: a consumable's time, 72000 for what is
|
||||
@@ -72,14 +81,35 @@ impl Game<'_> {
|
||||
/// `LivingEntity.updateUsingItem` for a `mod_use` item used `ticks` so far:
|
||||
/// `completeUsingItem` once its duration ran out.
|
||||
pub(super) fn tick_mod_use(&mut self, slot: usize, ticks: u32, stack: &ItemStack) {
|
||||
self.item_on_use_tick(stack, ticks);
|
||||
if let Some(ticks) = count_down(ticks, self.use_duration) {
|
||||
self.using = Some((slot, ticks));
|
||||
return;
|
||||
}
|
||||
let Some(info) = self.items().get(&stack.item).cloned() else {
|
||||
self.using = None;
|
||||
return;
|
||||
};
|
||||
self.complete_using_item(slot, stack, &info);
|
||||
}
|
||||
/// `ServerPlayer.completeUsingItem` of `stack` in `slot`: the client is
|
||||
/// told, `ItemStack.finishUsingItem` (a consumable is eaten, a potion
|
||||
/// drunk, a spyglass lowered, anything else given back) with its use
|
||||
/// cooldown, then `LivingEntityUseItemEvent.Finish`.
|
||||
pub(super) fn complete_using_item(&mut self, slot: usize, stack: &ItemStack, info: &ItemInfo) {
|
||||
// a potion is drunk (no last tick of its own: a mod's tick ran it out).
|
||||
if self.finish_drinking(slot, stack) {
|
||||
self.use_item_finished(stack);
|
||||
return;
|
||||
}
|
||||
self.using = None;
|
||||
self.send_use_item_complete();
|
||||
// in ItemStack.finishUsingItem, Item's own gives the stack back.
|
||||
self.apply_use_cooldown(stack);
|
||||
if let Some(consumable) = &info.consumable {
|
||||
self.finish_consuming(info, consumable, stack);
|
||||
} else if &*stack.item == "minecraft:spyglass" {
|
||||
self.stop_spyglass();
|
||||
}
|
||||
self.use_item_finished(stack);
|
||||
}
|
||||
/// `LivingEntityUseItemEvent.Finish` after `finishUsingItem` of `stack`
|
||||
@@ -93,6 +123,56 @@ impl Game<'_> {
|
||||
*self.held_mut() = result.filter(|s| s.count > 0);
|
||||
}
|
||||
}
|
||||
/// `LivingEntityUseItemEvent.Tick` for `stack` in use `ticks` so far, as
|
||||
/// `updatingUsingItem` starts: a mod may change the ticks left, which
|
||||
/// moves the end. False when it ran the use out (`tick_ran_out`): the
|
||||
/// item's tick and countdown are skipped.
|
||||
pub(super) fn use_item_ticked(&mut self, stack: &ItemStack, ticks: u32) -> bool {
|
||||
let before = ticks_left(self.use_duration, ticks);
|
||||
let (mut remaining, mut canceled) = (before, false);
|
||||
let hand = self.used_hand().unwrap_or(self.hand);
|
||||
self.native_living_entity_use_item_tick(stack, None, hand, &mut remaining, &mut canceled);
|
||||
let (left, set) = tick_left(before, remaining, canceled, self.use_ticks_set);
|
||||
self.use_ticks_set = set;
|
||||
self.use_duration = duration_leaving(ticks, left);
|
||||
left > 0 || !set
|
||||
}
|
||||
/// A mod's `Item.onUseTick` of `stack` used `ticks`, while ticks are left.
|
||||
pub(super) fn item_on_use_tick(&mut self, stack: &ItemStack, ticks: u32) {
|
||||
let remaining = ticks_left(self.use_duration, ticks);
|
||||
if remaining > 0 {
|
||||
self.native_on_use_tick(stack, None, remaining);
|
||||
}
|
||||
}
|
||||
/// How long `stack`, used `ticks`, counts as held: `getUseDuration` less
|
||||
/// the ticks left (`BowItem.releaseUsing`, `CrossbowItem.onUseTick`,
|
||||
/// `TridentItem.releaseUsing`), `ticks` unless a mod's Start or Tick
|
||||
/// moved the ticks left.
|
||||
pub(super) fn ticks_held(&self, stack: &ItemStack, ticks: u32) -> u32 {
|
||||
held(
|
||||
self.use_duration(stack),
|
||||
ticks_left(self.use_duration, ticks),
|
||||
)
|
||||
}
|
||||
/// `LivingEntityUseItemEvent.Stop` as `releaseUsingItem` lets go of
|
||||
/// `stack` used `ticks`: false when a mod skips its `releaseUsing`.
|
||||
pub(super) fn use_item_stopped(&mut self, stack: &ItemStack, ticks: u32) -> bool {
|
||||
let mut canceled = false;
|
||||
let remaining = ticks_left(self.use_duration, ticks);
|
||||
let hand = self.hand;
|
||||
self.native_living_entity_use_item_stop(stack, None, hand, remaining, &mut canceled);
|
||||
!canceled
|
||||
}
|
||||
/// A mod's `Item.releaseUsing` of `stack` used `ticks`: when it says it
|
||||
/// was used, `ItemStack.releaseUsing` applies the use cooldown.
|
||||
pub(super) fn mod_release_using(&mut self, stack: &ItemStack, ticks: u32) {
|
||||
let mut used = false;
|
||||
let remaining = ticks_left(self.use_duration, ticks);
|
||||
self.native_release_using(stack, None, remaining, &mut used);
|
||||
if used {
|
||||
self.apply_use_cooldown(stack);
|
||||
}
|
||||
}
|
||||
/// `Player.canEat(canAlwaysEat)` for `stack`, food that is eaten only
|
||||
/// when hungry, then a mod's answer.
|
||||
pub(super) fn can_eat(&self, stack: &ItemStack, can_always_eat: bool) -> bool {
|
||||
@@ -107,6 +187,49 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ticks left after a mod's `Tick`, a cancel being NeoForge's -1, and
|
||||
/// whether a mod ever set this use's: once one did, the use goes on only
|
||||
/// while some are left, else it ran out. A use no mod touched goes on as
|
||||
/// vanilla's own do (an eye of ender's has none from the start).
|
||||
fn tick_left(before: i32, remaining: i32, canceled: bool, set: bool) -> (i32, bool) {
|
||||
let left = if canceled { -1 } else { remaining };
|
||||
(left, set || canceled || remaining != before)
|
||||
}
|
||||
|
||||
/// What `releaseUsingItem` runs of a release: the item's own and a mod's
|
||||
/// `releaseUsing` (not when a mod's `Stop` canceled, nor when the hand
|
||||
/// changed item, which stops the use without one), and the spyglass's
|
||||
/// sound (not when canceled).
|
||||
pub(super) fn release_parts(canceled: bool, hand_changed: bool) -> (bool, bool) {
|
||||
(!canceled && !hand_changed, !canceled)
|
||||
}
|
||||
|
||||
/// `useItemRemaining` of a use of `duration` ticks, `ticks` old.
|
||||
fn ticks_left(duration: i32, ticks: u32) -> i32 {
|
||||
duration.saturating_sub_unsigned(ticks)
|
||||
}
|
||||
|
||||
/// `getUseDuration(stack) - remainingTime` of a use of `own` ticks with
|
||||
/// `remaining` left, none below 0.
|
||||
fn held(own: i32, remaining: i32) -> u32 {
|
||||
u32::try_from(i64::from(own) - i64::from(remaining)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The duration that leaves `remaining` ticks of a use `ticks` old, as a
|
||||
/// mod's `Tick` set them.
|
||||
fn duration_leaving(ticks: u32, remaining: i32) -> i32 {
|
||||
remaining.saturating_add_unsigned(ticks)
|
||||
}
|
||||
|
||||
/// What a `Start` left of a use of `own` ticks: None refused (a cancel, or
|
||||
/// a time below 0, as NeoForge's `startUsingItem` returns), else whether a
|
||||
/// mod set the ticks left to none. Such a use runs out on its first tick
|
||||
/// (`tick_left`), as NeoForge's `--useItemRemaining <= 0` completes it, while
|
||||
/// an item's own 0 (an eye of ender) is held until let go, as vanilla's.
|
||||
fn start_of(own: i32, duration: i32, canceled: bool) -> Option<bool> {
|
||||
(!canceled && duration >= 0).then_some(duration == 0 && own != 0)
|
||||
}
|
||||
|
||||
/// `Consumable.startConsuming`: a time of 0 or less is no use, the item is
|
||||
/// consumed at once (`onConsume`).
|
||||
pub(super) fn consumes_at_once(consume_ticks: i32) -> bool {
|
||||
@@ -148,7 +271,10 @@ pub(super) fn use_pose(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{consumes_at_once, count_down, use_pose};
|
||||
use super::{
|
||||
consumes_at_once, count_down, duration_leaving, held, release_parts, start_of, tick_left,
|
||||
ticks_left, use_pose,
|
||||
};
|
||||
use horizon_client::first_person::ItemPose;
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
|
||||
@@ -179,6 +305,91 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_below_0_refuses_the_use_and_a_mods_0_runs_out() {
|
||||
// a cancel or a time below 0 refuses it.
|
||||
assert_eq!(start_of(32, 32, true), None);
|
||||
assert_eq!(start_of(32, -1, false), None);
|
||||
assert_eq!(start_of(0, -1, false), None);
|
||||
// a mod's 0 starts it, and its first tick runs it out.
|
||||
assert_eq!(start_of(32, 0, false), Some(true));
|
||||
let (left, set) = tick_left(0, 0, false, true);
|
||||
assert!(!(left > 0 || !set));
|
||||
// an item's own 0 (an eye of ender) goes on, as a changed time does.
|
||||
assert_eq!(start_of(0, 0, false), Some(false));
|
||||
let (left, set) = tick_left(0, 0, false, false);
|
||||
assert!(left > 0 || !set);
|
||||
assert_eq!(start_of(32, 64, false), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tick_moves_the_end_of_a_use() {
|
||||
// a 32 tick use, 10 ticks in: 22 left.
|
||||
assert_eq!(ticks_left(32, 10), 22);
|
||||
// a mod leaves 1 tick: the next tick finishes it.
|
||||
let duration = duration_leaving(10, 1);
|
||||
assert_eq!(count_down(10, duration), None);
|
||||
// or 30 more: it goes on to tick 40.
|
||||
let duration = duration_leaving(10, 30);
|
||||
assert_eq!(duration, 40);
|
||||
assert_eq!(count_down(38, duration), Some(39));
|
||||
assert_eq!(count_down(39, duration), None);
|
||||
// left as it was, nothing moves.
|
||||
assert_eq!(duration_leaving(10, ticks_left(32, 10)), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tick_of_no_time_left_runs_the_use_out() {
|
||||
let goes_on = |(left, set): (i32, bool)| left > 0 || !set;
|
||||
// untouched, a use goes on, even one of no time (an eye of ender).
|
||||
assert_eq!(tick_left(20, 20, false, false), (20, false));
|
||||
assert!(goes_on(tick_left(-3, -3, false, false)));
|
||||
// a mod's ticks left go on while above 0.
|
||||
assert_eq!(tick_left(20, 5, false, false), (5, true));
|
||||
assert!(goes_on(tick_left(20, 5, false, false)));
|
||||
// a cancel is -1 left, and it or 0 or less set runs the use out.
|
||||
assert_eq!(tick_left(20, 20, true, false), (-1, true));
|
||||
assert!(!goes_on(tick_left(20, 0, false, false)));
|
||||
assert!(!goes_on(tick_left(20, -1, false, false)));
|
||||
// a use a mod ran out stays out on the next ticks (a crossbow frozen).
|
||||
assert!(!goes_on(tick_left(-1, -1, false, true)));
|
||||
// and goes on again if a mod gives it ticks back, as NeoForge's.
|
||||
assert!(goes_on(tick_left(-1, 10, false, true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_runs_what_vanilla_runs() {
|
||||
// let go: the item's release and the spyglass's sound.
|
||||
assert_eq!(release_parts(false, false), (true, true));
|
||||
// a mod's Stop canceled: neither.
|
||||
assert_eq!(release_parts(true, false), (false, false));
|
||||
// another item in the hand: no release (Horizon still lowers a spyglass).
|
||||
assert_eq!(release_parts(false, true), (false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moved_end_moves_the_charge() {
|
||||
// a bow held 10 ticks of 72000: 10, as vanilla's.
|
||||
assert_eq!(held(72_000, ticks_left(72_000, 10)), 10);
|
||||
// a mod's Tick leaves 71000 at tick 10: it counts as held 1000.
|
||||
assert_eq!(
|
||||
held(72_000, ticks_left(duration_leaving(10, 71_000), 10)),
|
||||
1000
|
||||
);
|
||||
// more ticks left than the item's own time: not held at all.
|
||||
assert_eq!(held(72_000, 80_000), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_tells_how_long_the_item_was_held() {
|
||||
// a bow-like item held 25 ticks of 72000: the time left gives the charge back.
|
||||
let left = ticks_left(72_000, 25);
|
||||
assert_eq!(left, 71_975);
|
||||
assert_eq!(72_000 - left, 25);
|
||||
// released at once.
|
||||
assert_eq!(ticks_left(72_000, 0), 72_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_food_of_no_time_is_eaten_at_once() {
|
||||
assert!(consumes_at_once(0));
|
||||
|
||||
@@ -129,6 +129,16 @@ fn edge((w, h): (f32, f32), button: Button) -> Rect {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the screen still gets the key after `act`. `Screen.keyPressed`
|
||||
/// moves the focus on Tab or an arrow and returns false, so a container
|
||||
/// screen goes on to `checkHotbarKeyPressed` (`AbstractContainerScreen
|
||||
/// .keyPressed`): Tab bound to the off hand both moves the focus and swaps.
|
||||
/// Horizon's other screens handle these keys themselves, so only containers
|
||||
/// pass them on.
|
||||
pub(super) fn passes_on(act: Act, container: bool) -> bool {
|
||||
container && matches!(act, Act::Focus(_) | Act::Step(_))
|
||||
}
|
||||
|
||||
/// `AbstractSliderButton.setFocused`: the arrows change a slider's value
|
||||
/// when the focus came from the mouse or Tab, not from an arrow.
|
||||
pub(super) fn slider_edit_on_focus(nav: Nav) -> bool {
|
||||
@@ -331,6 +341,19 @@ mod tests {
|
||||
assert_eq!(nav_of(KeyCode::Escape, false), None);
|
||||
}
|
||||
|
||||
/// Tab bound to key.swapOffhand in an inventory: the focus moves and the
|
||||
/// screen still checks its hotbar keys; a press stays the widget's.
|
||||
#[test]
|
||||
fn containers_still_get_tab_and_arrows_after_the_focus_moves() {
|
||||
assert!(passes_on(Act::Focus(0), true));
|
||||
assert!(passes_on(Act::Step(Button::DPadDown), true));
|
||||
assert!(!passes_on(Act::Press, true));
|
||||
assert!(!passes_on(Act::ToggleSlider, true));
|
||||
assert!(!passes_on(Act::Nudge(1.0), true));
|
||||
assert!(!passes_on(Act::Focus(0), false));
|
||||
assert!(!passes_on(Act::Step(Button::DPadDown), false));
|
||||
}
|
||||
|
||||
/// The title screen's rows (TitleScreen.init's add order).
|
||||
#[test]
|
||||
fn tab_order_reads_rows_top_to_bottom_then_left_to_right() {
|
||||
|
||||
@@ -2725,6 +2725,22 @@ impl Game<'_> {
|
||||
}
|
||||
seen.insert(m.id, (m.hurt_time, dead, swing));
|
||||
}
|
||||
if let Some(d) = self.dragon.as_ref().filter(|d| d.id != 0) {
|
||||
let id = d.id;
|
||||
let dead = !d.alive();
|
||||
let old = host
|
||||
.mob_state
|
||||
.get(&self.dim)
|
||||
.and_then(|s| s.get(&id))
|
||||
.copied();
|
||||
let at = [d.position.x, d.position.y, d.position.z];
|
||||
if d.hurt_time > old.map_or(0, |o| o.0) && !dead {
|
||||
let (kind, cause, direct) =
|
||||
d.last_source.unwrap_or(("minecraft:generic", None, None));
|
||||
host.hub.damage_event(id as i32, kind, cause, direct, at);
|
||||
}
|
||||
seen.insert(id, (d.hurt_time, dead, None));
|
||||
}
|
||||
// LivingEntity.hurtServer for the in-frame LAN host's player (guests
|
||||
// broadcast directly from HealthEvent::DamageBroadcast): a hit that
|
||||
// restarted the hurt time and was not blocked by a shield broadcasts
|
||||
|
||||
@@ -43,6 +43,7 @@ pub(super) struct GuestPlay {
|
||||
destroy_delay: u32,
|
||||
using: Option<(usize, u32)>,
|
||||
use_duration: i32,
|
||||
use_ticks_set: bool,
|
||||
use_delay: u32,
|
||||
/// The shield's ItemCooldowns entry: an axe disabled it for this many ticks.
|
||||
pub(super) shield_cooldown: u32,
|
||||
@@ -174,6 +175,7 @@ pub(super) struct GuestSwap {
|
||||
destroy_delay: u32,
|
||||
using: Option<(usize, u32)>,
|
||||
use_duration: i32,
|
||||
use_ticks_set: bool,
|
||||
use_delay: u32,
|
||||
pub(super) shield_cooldown: u32,
|
||||
/// The guest's item cooldowns (`Game::cooldowns` for its turn).
|
||||
@@ -473,6 +475,7 @@ impl Game<'_> {
|
||||
let destroy_delay = std::mem::replace(&mut self.destroy_delay, play.destroy_delay);
|
||||
let using = std::mem::replace(&mut self.using, play.using.take());
|
||||
let use_duration = std::mem::replace(&mut self.use_duration, play.use_duration);
|
||||
let use_ticks_set = std::mem::replace(&mut self.use_ticks_set, play.use_ticks_set);
|
||||
let use_delay = std::mem::replace(&mut self.use_delay, play.use_delay);
|
||||
let shield_cooldown = std::mem::replace(&mut self.shield_cooldown, play.shield_cooldown);
|
||||
let cooldowns = std::mem::replace(&mut self.cooldowns, std::mem::take(&mut play.cooldowns));
|
||||
@@ -587,6 +590,7 @@ impl Game<'_> {
|
||||
destroy_delay,
|
||||
using,
|
||||
use_duration,
|
||||
use_ticks_set,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
cooldowns,
|
||||
@@ -663,6 +667,7 @@ impl Game<'_> {
|
||||
destroy_delay,
|
||||
using,
|
||||
use_duration,
|
||||
use_ticks_set,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
cooldowns,
|
||||
@@ -781,6 +786,7 @@ impl Game<'_> {
|
||||
play.destroy_delay = std::mem::replace(&mut self.destroy_delay, destroy_delay);
|
||||
play.using = std::mem::replace(&mut self.using, using);
|
||||
play.use_duration = std::mem::replace(&mut self.use_duration, use_duration);
|
||||
play.use_ticks_set = std::mem::replace(&mut self.use_ticks_set, use_ticks_set);
|
||||
play.use_delay = std::mem::replace(&mut self.use_delay, use_delay);
|
||||
play.shield_cooldown = std::mem::replace(&mut self.shield_cooldown, shield_cooldown);
|
||||
play.cooldowns = std::mem::replace(&mut self.cooldowns, cooldowns);
|
||||
@@ -2969,12 +2975,12 @@ impl Game<'_> {
|
||||
pub(super) fn send_admin_feedback(&mut self, host: &mut LanHost, source: Option<u32>) {
|
||||
// PlayerList.isOp for the host of a world open to LAN: the owner when
|
||||
// the world allows commands, everyone when the LAN does.
|
||||
let host_op = source.is_some()
|
||||
&& !host.hub.config.dedicated
|
||||
&& (self.allow_commands() || host.hub.config.guest_commands);
|
||||
let host_ops =
|
||||
!host.hub.config.dedicated && (self.allow_commands() || host.hub.config.guest_commands);
|
||||
let host_op = source.is_some() && host_ops;
|
||||
// server.toml's `admin_feedback_to_ops` off: the console's log only.
|
||||
let to_ops = host.hub.admin_feedback_to_ops;
|
||||
for (line, ops, console) in std::mem::take(&mut self.admin_feedback) {
|
||||
for (line, ops, console, no_player) in std::mem::take(&mut self.admin_feedback) {
|
||||
if ops && to_ops {
|
||||
let content = horizon_client::lan::data::component_nbt(&line.component());
|
||||
for g in host
|
||||
@@ -2988,7 +2994,8 @@ impl Game<'_> {
|
||||
overlay: false,
|
||||
}));
|
||||
}
|
||||
if host_op {
|
||||
// A line no player's command made (a command block's) reaches the host too.
|
||||
if host_op || (no_player && host_ops) {
|
||||
self.system_message(line.clone());
|
||||
}
|
||||
}
|
||||
@@ -3912,6 +3919,24 @@ impl Game<'_> {
|
||||
count,
|
||||
speed,
|
||||
}) => self.landing_dust(Some(host), state, position, count, speed),
|
||||
// A block's own sound names no player: the guest hears it too.
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Sound {
|
||||
sound,
|
||||
position: Some(pos),
|
||||
volume,
|
||||
pitch,
|
||||
}) if sound.block_sound().is_some() => {
|
||||
if let Some(name) = sound.block_sound() {
|
||||
let source = horizon_client::settings::sound_source(name) as i32;
|
||||
let at = [
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y) + 0.5,
|
||||
f64::from(pos.z) + 0.5,
|
||||
];
|
||||
self.audio.play_unshared(name, Some(at), volume, pitch);
|
||||
host.hub.sound(name, at, volume, pitch, source, None);
|
||||
}
|
||||
}
|
||||
// LivingEntity.causeFallDamage: Player.playSound skips the
|
||||
// guest, whose own client plays it; everyone else hears it.
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Sound {
|
||||
|
||||
@@ -208,8 +208,9 @@ impl Game<'_> {
|
||||
|
||||
/// A click on the guest's open furnace, blast furnace, smoker or brewing stand:
|
||||
/// the host's `AbstractFurnaceMenu` (fuel slot, take-only result, quick moves by
|
||||
/// recipe and fuel) or `BrewingStandMenu` (one bottle per slot, ingredient and
|
||||
/// blaze powder slots) over the block entity's slots. False for other windows.
|
||||
/// recipe and fuel), `BrewingStandMenu` (one bottle per slot, ingredient and
|
||||
/// blaze powder slots) or a content mod's menu type (`Kind::Mod`) over the
|
||||
/// block entity's slots. False for other windows.
|
||||
pub(super) fn lan_machine_click(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
@@ -219,12 +220,12 @@ impl Game<'_> {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return false;
|
||||
};
|
||||
let Some(pos) = host.hub.guests[gi]
|
||||
let Some((pos, mod_type, hidden)) = host.hub.guests[gi]
|
||||
.menu
|
||||
.as_ref()
|
||||
.filter(|m| p.container != 0 && m.window == p.container)
|
||||
.and_then(|m| match m.slots {
|
||||
Slots::Block(pos) => Some(pos),
|
||||
Slots::Block(pos) => Some((pos, m.mod_type, m.hidden.clone())),
|
||||
_ => None,
|
||||
})
|
||||
else {
|
||||
@@ -241,6 +242,15 @@ impl Game<'_> {
|
||||
}
|
||||
Some(BlockEntity::Brewing(b)) => (Kind::Brewing, b.items.to_vec()),
|
||||
Some(BlockEntity::Crafter(c)) => (Kind::CrafterBlock, c.items.to_vec()),
|
||||
// A content mod's menu type, with its slots' rules.
|
||||
Some(BlockEntity::Mod(m)) => match mod_type {
|
||||
Some(t) => {
|
||||
let mut items = m.items.clone();
|
||||
items.resize(t.slots, None);
|
||||
(Kind::Mod(t), items)
|
||||
}
|
||||
None => return false,
|
||||
},
|
||||
_ => return false,
|
||||
};
|
||||
let furnace = matches!(kind, Kind::Furnace(_));
|
||||
@@ -262,8 +272,11 @@ impl Game<'_> {
|
||||
.crafting
|
||||
.machine
|
||||
.take()
|
||||
.filter(|(window, _)| *window == p.container)
|
||||
.filter(|(window, m)| *window == p.container && m.kind() == kind)
|
||||
.map_or_else(|| Box::new(Menu::new(kind)), |(_, m)| m);
|
||||
for &slot in &hidden {
|
||||
menu.hide_slot(slot);
|
||||
}
|
||||
let changed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
for (k, s) in items.iter().enumerate() {
|
||||
menu.set_slot(env, k, s.clone());
|
||||
@@ -297,6 +310,16 @@ impl Game<'_> {
|
||||
b.items[k] = menu.slot(k).cloned();
|
||||
}
|
||||
}
|
||||
// The mod's items past its menu's slots stay as they were.
|
||||
Some(BlockEntity::Mod(m)) => {
|
||||
let n = items.len();
|
||||
if m.items.len() < n {
|
||||
m.items.resize(n, None);
|
||||
}
|
||||
for (k, slot) in m.items.iter_mut().take(n).enumerate() {
|
||||
*slot = menu.slot(k).cloned();
|
||||
}
|
||||
}
|
||||
// CrafterBlockEntity.setItem: filling a disabled slot enables it.
|
||||
Some(BlockEntity::Crafter(c)) => {
|
||||
for k in 0..9 {
|
||||
|
||||
@@ -1188,7 +1188,7 @@ impl Game<'_> {
|
||||
let PhysicalKey::Code(key) = event.physical_key else {
|
||||
return;
|
||||
};
|
||||
let ctrl = self.keys.contains(&winit::keyboard::KeyCode::ControlLeft);
|
||||
let ctrl = self.has_control_down_with_quirk();
|
||||
let shortcut = self.edit_shortcut(event);
|
||||
let types_text = self.types_text();
|
||||
let shift = self.has_shift_down();
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
|
||||
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
|
||||
use horizon_client::collisions::SceneCollisions;
|
||||
use horizon_client::lan::hub::no_blocks_in;
|
||||
use horizon_client::mobs::equine;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_core::geometry::{Aabb, Axis, Vec3};
|
||||
use horizon_protocol::{
|
||||
packets::play::{self, Clientbound as C},
|
||||
text::translatable,
|
||||
@@ -367,7 +368,31 @@ impl Game<'_> {
|
||||
back(host, old, yaw);
|
||||
return;
|
||||
}
|
||||
host.hub.guests[gi].vehicle_good = Some([target.x, target.y, target.z]);
|
||||
// clientVehicleIsFloating: the move didn't go down a step, the vehicle hadn't come down onto
|
||||
// something on its last move (`verticalCollisionBelow` before this one), and nothing but air is
|
||||
// around its box grown by 0.0625 and stretched 0.55 down (water holds a boat up). A flying or
|
||||
// no-gravity mount, and a server that allows flight, never float.
|
||||
let (gravity, no_gravity) = match mount {
|
||||
Some(i) => (0.08, self.mobs[i].no_gravity),
|
||||
None => (0.04, false),
|
||||
};
|
||||
let around = moved_box
|
||||
.inflate(Vec3::new(0.0625, 0.0625, 0.0625))
|
||||
.expand_towards(Vec3::new(0.0, -0.55, 0.0));
|
||||
let corner = |max: bool| {
|
||||
[Axis::X, Axis::Y, Axis::Z].map(|a| if max { around.max(a) } else { around.min(a) })
|
||||
};
|
||||
let no_blocks = no_blocks_in(&self.world, corner(false), corner(true));
|
||||
let allow_flight = host.hub.config.allow_flight;
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.vehicle_good = Some([target.x, target.y, target.z]);
|
||||
let rested = std::mem::replace(
|
||||
&mut g.vehicle_rests,
|
||||
wanted.y < 0.0 && resolved.y != wanted.y,
|
||||
);
|
||||
g.vehicle_floating =
|
||||
wanted.y >= -0.03125 && !rested && !allow_flight && !no_gravity && no_blocks;
|
||||
g.vehicle_gravity = gravity;
|
||||
if let Some(i) = mount {
|
||||
// absSnapTo: where and how its rider's client turned it; the
|
||||
// rider sits on it from its own ride tick.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The game's side of the Launch API (docs/api/launch.md): the options another launcher may pass,
|
||||
//! `--launch-api`'s reply, the exit codes, and docs/api/launch-reference.md, generated from them.
|
||||
//! Update it with: `HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api`.
|
||||
//! Update it with: `HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api`.
|
||||
|
||||
/// The Launch API's version (docs/api/stability.md): an addition makes the next patch.
|
||||
pub const VERSION: &str = "0.1.0";
|
||||
@@ -155,7 +155,7 @@ pub fn markdown() -> String {
|
||||
let w = &mut out;
|
||||
let _ = writeln!(
|
||||
w,
|
||||
"<!-- Generated from crates/horizon-client/src/game/launch_api.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api -->"
|
||||
"<!-- Generated from crates/horizon-client/src/game/launch_api.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api -->"
|
||||
);
|
||||
let _ = writeln!(w, "# Launch API reference\n");
|
||||
let _ = writeln!(
|
||||
@@ -265,7 +265,7 @@ mod tests {
|
||||
.replace("\r\n", "\n");
|
||||
assert!(
|
||||
have == want,
|
||||
"docs/api/launch-reference.md is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api"
|
||||
"docs/api/launch-reference.md is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,9 @@ pub(super) struct Parked {
|
||||
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
|
||||
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
|
||||
lan_critical_hits: Vec<(Option<u32>, i32, bool)>,
|
||||
/// The hub's tracked entities of this level (`LanHost::states`), as its last tick described
|
||||
/// them: what its players' moves are checked against before this tick describes them again.
|
||||
lan_states: horizon_client::lan::entity::States,
|
||||
/// Only the per-level parts of these are used (`swap_level`).
|
||||
anims: BlockEntityAnims,
|
||||
raiding: Raiding,
|
||||
@@ -118,6 +121,9 @@ impl Default for Pass {
|
||||
#[derive(Default)]
|
||||
pub(super) struct Levels {
|
||||
parked: HashMap<Dim, Parked>,
|
||||
/// The levels whose open the mods heard (`native_level_load`), each to
|
||||
/// be told closed once.
|
||||
heard_open: Vec<Dim>,
|
||||
}
|
||||
|
||||
impl Levels {
|
||||
@@ -213,6 +219,7 @@ impl Game<'_> {
|
||||
lan_block_events: Vec::new(),
|
||||
lan_entity_events: Vec::new(),
|
||||
lan_critical_hits: Vec::new(),
|
||||
lan_states: Default::default(),
|
||||
anims: Default::default(),
|
||||
raiding: Default::default(),
|
||||
sculk: Default::default(),
|
||||
@@ -291,7 +298,11 @@ impl Game<'_> {
|
||||
/// The level that was in place is kept with the others; the shared
|
||||
/// clock and weather go with the swap. `host` is the hub when the caller
|
||||
/// holds it (`self.lan` is taken during the network tick).
|
||||
pub(super) fn enter_level(&mut self, host: Option<&mut LanHost>, target: Dim) -> Result<()> {
|
||||
pub(super) fn enter_level(
|
||||
&mut self,
|
||||
mut host: Option<&mut LanHost>,
|
||||
target: Dim,
|
||||
) -> Result<()> {
|
||||
// The in-frame game keeps its one level (`enter_dimension`).
|
||||
if self.options.execution_role.is_client() {
|
||||
return Ok(());
|
||||
@@ -310,12 +321,23 @@ impl Game<'_> {
|
||||
// The sculk tables' watched blocks are built once per game.
|
||||
p.world.watched = std::mem::take(&mut self.world.watched);
|
||||
self.swap_level(&mut p);
|
||||
// The tracked entities go with their level: a level's moves, read before its tick
|
||||
// describes its entities again, would otherwise meet the last level's (the anticheat
|
||||
// missed the boat under a player while anyone was in another dimension).
|
||||
let states = match host.as_deref_mut() {
|
||||
Some(h) => Some(&mut h.states),
|
||||
None => self.lan.as_mut().map(|h| &mut h.states),
|
||||
};
|
||||
if let Some(states) = states {
|
||||
std::mem::swap(states, &mut p.lan_states);
|
||||
}
|
||||
p.idle = 0;
|
||||
let left = std::mem::replace(&mut self.dim, target);
|
||||
self.levels.parked.insert(left, p);
|
||||
if opened {
|
||||
// The new streaming's radius and meshing options.
|
||||
self.apply_settings();
|
||||
self.native_level_opened(target);
|
||||
}
|
||||
}
|
||||
let host = match host {
|
||||
@@ -617,10 +639,42 @@ impl Game<'_> {
|
||||
self.save_level(true)?;
|
||||
self.enter_level(None, back)?;
|
||||
self.levels.parked.remove(&dim);
|
||||
self.native_level_closed(dim);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The mods hear `dim`'s level open (`LevelEvent.Load`), on a server.
|
||||
pub(super) fn native_level_opened(&mut self, dim: Dim) {
|
||||
if self.remote.is_some()
|
||||
|| self.options.execution_role.is_client()
|
||||
|| self.levels.heard_open.contains(&dim)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.levels.heard_open.push(dim);
|
||||
self.native_level_load(dim.id());
|
||||
}
|
||||
|
||||
/// The mods hear `dim`'s level close (`LevelEvent.Unload`), once for
|
||||
/// each open they heard.
|
||||
fn native_level_closed(&mut self, dim: Dim) {
|
||||
let Some(i) = self.levels.heard_open.iter().position(|d| *d == dim) else {
|
||||
return;
|
||||
};
|
||||
self.levels.heard_open.remove(i);
|
||||
self.native_level_unload(dim.id());
|
||||
}
|
||||
|
||||
/// Every level still open closes with the server, however it ends
|
||||
/// (`MinecraftServer.stopServer`'s `level.close()` loop), in vanilla's
|
||||
/// order of its levels: the Overworld, the Nether, the End.
|
||||
pub(super) fn native_levels_closing(&mut self) {
|
||||
for dim in [Dim::Overworld, Dim::Nether, Dim::End] {
|
||||
self.native_level_closed(dim);
|
||||
}
|
||||
}
|
||||
|
||||
/// Save every parked level (`MinecraftServer.saveAllChunks`); the level
|
||||
/// in place is the caller's to save.
|
||||
pub(super) fn save_parked_levels(&mut self, wait: bool) -> Result<()> {
|
||||
|
||||
@@ -532,10 +532,21 @@ impl Game<'_> {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `Entity.isVehicle` for a cart: the local player, a LAN guest or a mob
|
||||
/// sits in it. `getSlowdownFactor` (0.997) and the 0.75 speed scale key on
|
||||
/// this, not on who rides.
|
||||
fn cart_is_vehicle(&self, id: u32) -> bool {
|
||||
self.riding == Some(id)
|
||||
|| self.ridden_by_guest(id)
|
||||
|| self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| !m.removed && m.extra.mount == Some(id))
|
||||
}
|
||||
/// `OldMinecartBehavior.moveAlongTrack`.
|
||||
fn move_along_track(&mut self, c: &mut Minecart, pos: [i32; 3], rail: &Rail, intent: [f64; 2]) {
|
||||
c.fall_distance = 0.0;
|
||||
let ridden = Some(c.id) == self.riding;
|
||||
let ridden = self.cart_is_vehicle(c.id);
|
||||
let (x, z) = (c.position.x, c.position.z);
|
||||
let old = self.rail_pos(x, c.position.y, z);
|
||||
let mut y = f64::from(pos[1]);
|
||||
@@ -700,7 +711,7 @@ impl Game<'_> {
|
||||
/// recomputed per rail (slope, halt, boost).
|
||||
fn move_along_track_new(&mut self, c: &mut Minecart, _rail: &Rail, intent: [f64; 2]) {
|
||||
c.fall_distance = 0.0;
|
||||
let ridden = Some(c.id) == self.riding;
|
||||
let ridden = self.cart_is_vehicle(c.id);
|
||||
// `TrackIteration`: the slope, halt and boost speeds each apply once
|
||||
// per tick, and the iteration keeps moving while distance is left.
|
||||
let mut first = true;
|
||||
@@ -1730,6 +1741,8 @@ impl Game<'_> {
|
||||
let (damage, _, _, magic) = self.melee_damage(self.carts[i].kind.id(), false);
|
||||
self.hurt_cart(i, damage, self.creative, burning);
|
||||
self.vehicle_attack_sounds(area, magic.then_some(id));
|
||||
// Player.onAttack: the hit starts the attack strength again.
|
||||
self.attack_ticks = 0;
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -631,7 +631,15 @@ impl Game<'_> {
|
||||
horizon_assets::item_model::SpecialKind::Head {
|
||||
kind: kind.to_owned(),
|
||||
texture: None,
|
||||
animation: 0.0,
|
||||
animation: worn_head_animation(
|
||||
mob,
|
||||
// The local player's synthetic mob carries no `mount`.
|
||||
mob.extra
|
||||
.mount
|
||||
.or(if local_player { self.riding } else { None }),
|
||||
&self.mobs,
|
||||
alpha as f32,
|
||||
),
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -685,11 +693,63 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntityRenderer.extractRenderState`'s `wornHeadAnimationPos`: the
|
||||
/// walk position of the living mob `mount` carries the wearer on (a boat or
|
||||
/// minecart is no mob, so it is not found), else the wearer's own.
|
||||
fn worn_head_animation(wearer: &Mob, mount: Option<u32>, mobs: &[Mob], alpha: f32) -> f32 {
|
||||
mount
|
||||
.and_then(|id| mobs.iter().find(|m| m.id == id))
|
||||
.map_or_else(
|
||||
|| horizon_client::mobs::anim::state(wearer, alpha).pos,
|
||||
|v| horizon_client::mobs::anim::walk_position(v, alpha),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use glam::{Mat4, Vec3};
|
||||
|
||||
/// A mob of the prepared resources' type `kind` (None without them).
|
||||
fn mob(id: u32, kind: &str) -> Option<horizon_client::mobs::Mob> {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
let types = horizon_client::mobs::MobTypes::load(&root).ok()?;
|
||||
let t = std::sync::Arc::new(types.types.get(kind)?.clone());
|
||||
Some(horizon_client::mobs::Mob::new(
|
||||
id,
|
||||
t,
|
||||
horizon_core::geometry::Vec3::new(0.0, 0.0, 0.0),
|
||||
0.0,
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_worn_head_follows_the_living_vehicle() {
|
||||
let (Some(mut wearer), Some(mut horse)) =
|
||||
(mob(1, "minecraft:zombie"), mob(2, "minecraft:horse"))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
wearer.limb_position = 1.0;
|
||||
wearer.limb_speed = 0.5;
|
||||
horse.limb_position = 4.0;
|
||||
horse.limb_speed = 1.0;
|
||||
let mobs = [horse];
|
||||
// Walking on its own: its own position (1 - 0.5 * 0.5).
|
||||
assert_eq!(worn_head_animation(&wearer, None, &mobs, 0.5), 0.75);
|
||||
// A mount that is no mob (boat, minecart) or unknown: still its own.
|
||||
assert_eq!(worn_head_animation(&wearer, Some(99), &mobs, 0.5), 0.75);
|
||||
// Riding the horse (a passenger's own position is 0): the horse's.
|
||||
wearer.extra.passenger = true;
|
||||
assert_eq!(worn_head_animation(&wearer, None, &mobs, 0.5), 0.0);
|
||||
assert_eq!(worn_head_animation(&wearer, Some(2), &mobs, 0.5), 3.5);
|
||||
// Dead wearer, living vehicle: the vehicle's.
|
||||
wearer.health = 0.0;
|
||||
assert_eq!(worn_head_animation(&wearer, Some(2), &mobs, 0.5), 3.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn carried_block_vertices_match_vanilla_block_coordinates() {
|
||||
// Captured from 26.3 CarriedBlockLayer.submit with an identity PoseStack:
|
||||
|
||||
@@ -314,12 +314,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A mod gave `block` block entity `id`, but the block's template has none,
|
||||
/// so breaking it would never remove it: the block gets none.
|
||||
pub(super) fn warn_no_block_entity(block: &str, id: &str) {
|
||||
log::warn!("{block}: no block entity {id}, its template has none (copy a chest or furnace)");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -280,6 +280,19 @@ impl KeptGeometry {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The mod block entities within view distance of `camera` whose block's
|
||||
/// template has none, which the mesher's block entity lists (made from
|
||||
/// the template) leave out: where they are and their block's state, for
|
||||
/// `mod_block_entity_geometry`. From the per-chunk list of
|
||||
/// `BlockEntities::mods_near`, not a scan of every block entity.
|
||||
pub(super) fn untemplated_mod_block_entities(&self, camera: DVec3) -> Vec<([i32; 3], u32)> {
|
||||
self.world
|
||||
.block_entities
|
||||
.mods_near(camera.x, camera.z, VIEW_DISTANCE)
|
||||
.map(|p| ([p.0, p.1, p.2], self.world.block(p.0, p.1, p.2).0))
|
||||
.filter(|&(_, state)| !self.res.facts().get(BlockStateId(state)).block_entity)
|
||||
.collect()
|
||||
}
|
||||
/// `native_block_entity_submit` for each of `blocks` (in drawn sections,
|
||||
/// drawn by no vanilla renderer) whose block entity is a mod's, closer than
|
||||
/// `BlockEntityRenderer.getViewDistance` to the camera (`shouldRender`)
|
||||
|
||||
@@ -113,7 +113,9 @@ fn target_id(t: Target) -> Option<u32> {
|
||||
| Target::Cart(id)
|
||||
| Target::Other(id)
|
||||
| Target::Crystal(id)
|
||||
| Target::Dragon(id) => Some(id),
|
||||
| Target::Dragon(id)
|
||||
| Target::Tnt(id)
|
||||
| Target::Falling(id) => Some(id),
|
||||
Target::Player | Target::Guest(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +91,9 @@ impl Game<'_> {
|
||||
damage.max = 40;
|
||||
}
|
||||
let at = Vec3::new(pos[0], pos[1], pos[2]);
|
||||
self.next_entity += 1;
|
||||
self.falling.push(FallingBlock {
|
||||
id: 0,
|
||||
id: self.next_entity,
|
||||
state,
|
||||
start_pos: cell,
|
||||
position: at,
|
||||
|
||||
@@ -1146,6 +1146,24 @@ impl Game<'_> {
|
||||
/// Atlas, models, renderer and client caches already refer to the new resources.
|
||||
/// Selection/F3+T and server-pack reloads share this point; initial startup does not.
|
||||
pub(super) fn native_client_resources_reloaded(&mut self) {}
|
||||
/// The client made a new level for `dimension` (NeoForge's
|
||||
/// `LevelEvent.Load` from the `ClientLevel` constructor): on joining a
|
||||
/// server, single player's own included, and on changing dimension.
|
||||
/// Its chunks come after. When the dimension changes, the old level's
|
||||
/// `native_client_level_unload` follows this, as `Minecraft.setLevel`
|
||||
/// tells it after the new level is made. Client-side, on the window; the
|
||||
/// server's levels have `native_level_load`. API @API:client_level_events@.
|
||||
pub(super) fn native_client_level_load(&mut self, dimension: &str) {
|
||||
let _ = dimension;
|
||||
}
|
||||
/// The client dropped its level for `dimension` (`LevelEvent.Unload`
|
||||
/// from `Minecraft.setLevel` and `disconnect`): replaced by another
|
||||
/// dimension's, or on leaving the server. Once for each
|
||||
/// `native_client_level_load`. Not told when the game itself quits.
|
||||
/// Client-side. API @API:client_level_events@.
|
||||
pub(super) fn native_client_level_unload(&mut self, dimension: &str) {
|
||||
let _ = dimension;
|
||||
}
|
||||
/// Once a tick, in every `Game`: the window's (in single player, on LAN
|
||||
/// and on servers), and the server's own (`native_window` is false
|
||||
/// there: the single player server thread, a dedicated server).
|
||||
@@ -1551,9 +1569,12 @@ impl Game<'_> {
|
||||
}
|
||||
/// Block `block` (`mymod:machine`) gets a block entity
|
||||
/// (`EntityBlock.newBlockEntity`): set `entity` to its id and saved
|
||||
/// compound when the block is yours and `entity` is none yet. The block's
|
||||
/// template must have a block entity (a chest, a furnace...), so that
|
||||
/// breaking the block removes it; on another template it gets none. API 15.
|
||||
/// compound when the block is yours and `entity` is none yet. It works on
|
||||
/// any template: the engine removes the block entity when its block is
|
||||
/// broken or replaced, no piston moves it, and a mod that draws it
|
||||
/// (`native_block_entity_submit`) is asked for it. API 15; before
|
||||
/// @API:mod_block_entity_any_template@ the block's template had to have a
|
||||
/// block entity of its own (a chest, a furnace...), or the block got none.
|
||||
pub(super) fn native_block_entity(
|
||||
&mut self,
|
||||
block: &str,
|
||||
@@ -2170,7 +2191,9 @@ impl Game<'_> {
|
||||
/// without its slash, for a command a mod registered and the player's
|
||||
/// level allows: set `ran` when it is yours. The player's state is
|
||||
/// swapped in for the call, so `native_player` and the other helpers
|
||||
/// read that player; `native_feedback` answers them. API 8.
|
||||
/// read that player; `native_feedback` answers them. A single player's
|
||||
/// owner console (`horizon-ctl command`) reaches it too, as the owner at
|
||||
/// level 4. API 8.
|
||||
pub(super) fn native_perform_command(
|
||||
&mut self,
|
||||
player: &NativeServerPlayer,
|
||||
@@ -2363,9 +2386,11 @@ impl Game<'_> {
|
||||
/// `stack` in `hand` (`LivingEntity.startUsingItem`), for `duration`
|
||||
/// ticks (`native_use_duration`'s answer). `entity` is a mob's
|
||||
/// `Entity.getId`, None for a player (`native_acting_player` says who);
|
||||
/// only players fire it so far. Change `duration` (consumables other
|
||||
/// than potions, and mods' items, count to it; 0 or less lasts until let
|
||||
/// go), or set `canceled` and the use doesn't start. Both sides, as
|
||||
/// only players fire it so far. Change `duration` (consumables, potions,
|
||||
/// spyglasses and mods' items count to it; below 0 refuses the use, and
|
||||
/// 0 runs it out on its first tick, as `native_living_entity_use_item_tick`
|
||||
/// running it out, unless the item's own is 0 already), or set
|
||||
/// `canceled` and the use doesn't start. Both sides, as
|
||||
/// vanilla's: wherever the world runs (the server, single player
|
||||
/// in-frame, a LAN host) for every player of the world, and on a
|
||||
/// server's client for the uses it predicts. Answer the same on both,
|
||||
@@ -2388,7 +2413,7 @@ impl Game<'_> {
|
||||
/// `native_living_entity_use_item_start`. `result` arrives with what
|
||||
/// `finishUsingItem` left in the hand (the bowl of a stew, none for the
|
||||
/// last apple; a mod's own item is kept) and may be replaced. Fired for
|
||||
/// consumables, potions and mods' items. Server-side, for every player
|
||||
/// consumables, potions, spyglasses and mods' items. Server-side, for every player
|
||||
/// of the world (the helpers read the acting one). API @API:item_use_over_time@.
|
||||
pub(super) fn native_living_entity_use_item_finish(
|
||||
&mut self,
|
||||
@@ -2400,6 +2425,90 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (stack, entity, hand, duration, result);
|
||||
}
|
||||
/// NeoForge's `LivingEntityUseItemEvent.Tick`: each tick `entity` uses
|
||||
/// `stack` in `hand`, where `LivingEntity.updatingUsingItem` starts,
|
||||
/// `duration` the ticks left. `entity` is as in
|
||||
/// `native_living_entity_use_item_start`. Change `duration` to move the
|
||||
/// end (consumables, potions, spyglasses and mods' items count to it,
|
||||
/// and so do a client's eating crumbs and first person pose, as the
|
||||
/// started duration of `native_living_entity_use_item_start` does). A
|
||||
/// cancel sets it to -1. Once a mod set it, the use goes on
|
||||
/// only while it is above 0: at 0 or less, by a cancel or set, the
|
||||
/// item's tick is skipped and the server finishes the use
|
||||
/// (`completeUsingItem`: `finishUsingItem`, the food eaten,
|
||||
/// `native_living_entity_use_item_finish`), while an item used on
|
||||
/// release (a crossbow) stays frozen until let go. NeoForge's javadoc
|
||||
/// says a cancel stops the use, but its code finishes it; this follows
|
||||
/// the code. On a server's client a run-out use stays in use, frozen,
|
||||
/// so letting go still tells the server. Both sides, as vanilla's
|
||||
/// `updatingUsingItem`: answer the same on both, and change the world
|
||||
/// only where this `Game` runs the world, not on a server's client (no
|
||||
/// helper tells the two apart yet; `native_window` is also true
|
||||
/// in-frame and on an in-frame LAN host). Runs every tick of every use:
|
||||
/// check the item first. API @API:item_use_tick_release@.
|
||||
pub(super) fn native_living_entity_use_item_tick(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
entity: Option<i32>,
|
||||
hand: Hand,
|
||||
duration: &mut i32,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (stack, entity, hand, duration, canceled);
|
||||
}
|
||||
/// `Item.onUseTick` of `stack`, used by `entity` with `remaining` ticks
|
||||
/// left, always above 0: what a mod's own item does each tick it is used
|
||||
/// (cook, charge, drain). It comes where `ItemStack.onUseTick` calls the
|
||||
/// item, once `native_living_entity_use_item_tick` let the use go on:
|
||||
/// after a consumable's crumbs, for consumables, shields and mods' items
|
||||
/// (vanilla's other items do their own, and a kinetic weapon has none on
|
||||
/// the server). Both sides, as `native_living_entity_use_item_tick`:
|
||||
/// change the world only where this `Game` runs the world, not on a
|
||||
/// server's client. Runs every tick of every use: check the item first.
|
||||
/// API @API:item_use_tick_release@.
|
||||
pub(super) fn native_on_use_tick(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
entity: Option<i32>,
|
||||
remaining: i32,
|
||||
) {
|
||||
let _ = (stack, entity, remaining);
|
||||
}
|
||||
/// NeoForge's `LivingEntityUseItemEvent.Stop`: `entity` lets go of
|
||||
/// `stack` in `hand` before its use ran its time
|
||||
/// (`LivingEntity.releaseUsingItem`: the use button let go, a screen
|
||||
/// opened), `duration` the ticks left. Set `canceled` to skip the item's
|
||||
/// release (a bow doesn't shoot, a spyglass makes no sound,
|
||||
/// `native_release_using` isn't called); the use stops anyway. Not when
|
||||
/// the hand changed item, which stops the use without a release. Both
|
||||
/// sides, as `native_living_entity_use_item_tick`. API @API:item_use_tick_release@.
|
||||
pub(super) fn native_living_entity_use_item_stop(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
entity: Option<i32>,
|
||||
hand: Hand,
|
||||
duration: i32,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (stack, entity, hand, duration, canceled);
|
||||
}
|
||||
/// `Item.releaseUsing` of `stack`: `entity` let it go with `time_left`
|
||||
/// ticks of its `native_use_duration` left, so it was held for
|
||||
/// `duration - time_left` (a bow-like item shoots by that charge). Set
|
||||
/// `used` as vanilla's return, and the stack's use cooldown starts
|
||||
/// (`ItemStack.releaseUsing`). After the vanilla item's own release, once
|
||||
/// `native_living_entity_use_item_stop` let it through. Both sides, as
|
||||
/// vanilla's: change the world only where this `Game` runs the world,
|
||||
/// not on a server's client. API @API:item_use_tick_release@.
|
||||
pub(super) fn native_release_using(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
entity: Option<i32>,
|
||||
time_left: i32,
|
||||
used: &mut bool,
|
||||
) {
|
||||
let _ = (stack, entity, time_left, used);
|
||||
}
|
||||
/// A player breaks the block `state` at `pos`, once the game allowed it
|
||||
/// (the held item can break blocks, the debug stick didn't take the
|
||||
/// click): where `ServerPlayerGameMode.destroyBlock` starts. Set
|
||||
@@ -2494,6 +2603,28 @@ impl Game<'_> {
|
||||
pub(super) fn native_net_from_server(&mut self, channel: &str, data: &[u8]) {
|
||||
let _ = (channel, data);
|
||||
}
|
||||
/// `dimension`'s level opened on the server (NeoForge's `LevelEvent.Load`,
|
||||
/// posted in `MinecraftServer.createLevels`): before any of its chunks
|
||||
/// (`native_chunk_load`), so a mod sets up what it keeps for the level
|
||||
/// here. The level the world opens in comes before `WorldOpenReady`; the
|
||||
/// others open when someone first goes there (a player, a portal, an
|
||||
/// `execute in` command), with the level in place. Once for each open:
|
||||
/// `native_level_unload` follows. Server-side. API @API:level_events@.
|
||||
pub(super) fn native_level_load(&mut self, dimension: &str) {
|
||||
let _ = dimension;
|
||||
}
|
||||
/// `dimension`'s level closed (NeoForge's `LevelEvent.Unload`, posted after
|
||||
/// `level.close()` in `MinecraftServer.stopServer`), after its last save:
|
||||
/// drop what the mod kept for it. A level other than the Overworld closes
|
||||
/// once nobody has been in it for 300 ticks, after its chunks' unloads;
|
||||
/// every level still open closes when the server stops, before
|
||||
/// `WorldCloseFinal`, and when its open or its loop fails, before
|
||||
/// `WorldOpenFailed` or `WorldCloseFailed`. Once for each
|
||||
/// `native_level_load`. Don't read its blocks. Server-side. API
|
||||
/// @API:level_events@.
|
||||
pub(super) fn native_level_unload(&mut self, dimension: &str) {
|
||||
let _ = dimension;
|
||||
}
|
||||
/// Chunk `chunk` (`[x, z]`, in chunks) of `dimension` came into the world
|
||||
/// (NeoForge's `ChunkEvent.Load`): `new` when it was just generated, false
|
||||
/// when read from the save. A flat world's chunk that leaves before it's
|
||||
@@ -2617,6 +2748,41 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (dimension, entity, kind, pos, loaded_from_disk, canceled);
|
||||
}
|
||||
/// Whether the experience orb `into` takes the orb `from` in (vanilla's
|
||||
/// `ExperienceOrb.canMerge`): the same value, and ids a multiple of 40 apart. Asked
|
||||
/// for each pair whose boxes meet, when an orb spawns (`tryMergeToExisting`: `from` is
|
||||
/// 0, the new orb isn't an entity yet, and only `from_value` is known) and every 20
|
||||
/// ticks as an orb scans for neighbours (`scanForMerges`). `can_merge` holds vanilla's
|
||||
/// answer; change it either way. A merged orb counts as `count` orbs of `into`'s
|
||||
/// value, so forcing orbs of different values together drops points unless the mod
|
||||
/// raises `into`'s value with `native_set_experience_orb` in `native_experience_orb_merge`.
|
||||
/// Server-side. API @API:experience_orb_merge@.
|
||||
pub(super) fn native_experience_orb_can_merge(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
into: u32,
|
||||
from: u32,
|
||||
into_value: u32,
|
||||
from_value: u32,
|
||||
can_merge: &mut bool,
|
||||
) {
|
||||
let _ = (dimension, into, from, into_value, from_value, can_merge);
|
||||
}
|
||||
/// The experience orb `into` is about to take `from` in, after
|
||||
/// `native_experience_orb_can_merge` said yes (`from` is 0 and `from_value` the
|
||||
/// points of a new orb that is spawning). Set `canceled` to stop it: both orbs stay, and
|
||||
/// a spawning orb tries the next orb in reach, then spawns on its own. Server-side.
|
||||
/// API @API:experience_orb_merge@.
|
||||
pub(super) fn native_experience_orb_merge(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
into: u32,
|
||||
from: u32,
|
||||
from_value: u32,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (dimension, into, from, from_value, canceled);
|
||||
}
|
||||
/// `player`'s melee blow killed the mob `entity` of `kind` (other deaths
|
||||
/// aren't told yet). It only hears, after the blow landed. Server-side.
|
||||
/// API 40.
|
||||
@@ -4304,12 +4470,6 @@ impl Game<'_> {
|
||||
let mut entity = None;
|
||||
self.native_block_entity(name, &mut entity);
|
||||
let (id, nbt) = entity?;
|
||||
// A template with no block entity (stone) never removes one, so it would stay in the save.
|
||||
let state = self.pack.assets.registry.default_state(name)?;
|
||||
if !self.res.facts().get(state).block_entity {
|
||||
super::mods::warn_no_block_entity(name, &id);
|
||||
return None;
|
||||
}
|
||||
Some(BlockEntity::Mod(Box::new(ModBlockEntity {
|
||||
id,
|
||||
nbt,
|
||||
@@ -5312,8 +5472,29 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loot_table_load_stays_hookable() {
|
||||
fn native_loot_kinds_stay_hookable() {
|
||||
let source = include_str!("../../../horizon-world/src/loot.rs");
|
||||
for hook in [
|
||||
"pub fn native_loot_function(",
|
||||
"pub fn native_loot_condition(",
|
||||
] {
|
||||
let at = source
|
||||
.find(hook)
|
||||
.unwrap_or_else(|| panic!("{hook} not found"));
|
||||
let start = source[..at].rfind("///").unwrap();
|
||||
let body = &source[start..start + source[start..].find("\n}\n").unwrap()];
|
||||
for line in body.lines() {
|
||||
assert!(!line.contains("cfg"), "a cfg in a loot hook point: {line}");
|
||||
for mac in ["!(", "![", "! {", "!{"] {
|
||||
assert!(!line.contains(mac), "a macro in a loot hook point: {line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loot_table_load_stays_hookable() {
|
||||
let source = include_str!("../../../horizon-world/src/loot.rs").replace("\r\n", "\n");
|
||||
// From its doc comment's last line, so an attribute above it counts.
|
||||
let hook = source.find("pub fn native_loot_table_load(").unwrap();
|
||||
let start = source[..hook].rfind("///").unwrap();
|
||||
@@ -5409,6 +5590,8 @@ mod tests {
|
||||
let item_hooks = free_hooks(include_str!("../../../horizon-world/src/item_defaults.rs"));
|
||||
// The loot tables' hook point (`horizon_world::loot`).
|
||||
let loot_hooks = free_hooks(include_str!("../../../horizon-world/src/loot.rs"));
|
||||
// The menu engine's (`horizon_client::menu`).
|
||||
let menu_hooks = free_hooks(include_str!("../menu.rs"));
|
||||
let declared: BTreeSet<_> = section
|
||||
.lines()
|
||||
.chain(level_section.lines())
|
||||
@@ -5421,6 +5604,7 @@ mod tests {
|
||||
.chain(render_hooks.iter().copied())
|
||||
.chain(item_hooks.iter().copied())
|
||||
.chain(loot_hooks.iter().copied())
|
||||
.chain(menu_hooks.iter().copied())
|
||||
.collect();
|
||||
let mut names = BTreeSet::new();
|
||||
let mut contracts = BTreeSet::new();
|
||||
@@ -5438,6 +5622,8 @@ mod tests {
|
||||
format!("horizon_world::item_defaults::native_{name}")
|
||||
} else if loot_hooks.contains(name) {
|
||||
format!("horizon_world::loot::native_{name}")
|
||||
} else if menu_hooks.contains(name) {
|
||||
format!("horizon_client::menu::native_{name}")
|
||||
} else if level_hooks.contains(name) {
|
||||
format!("horizon_client::game::native_hooks::GameLevel<'_>::native_{name}")
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
//! Another player's inventories for server mods (API @API:native_inventories@):
|
||||
//! what an operator's `/invsee` or `/endersee` needs. A player online is read
|
||||
//! and changed live; a player who isn't on the server is read from, and
|
||||
//! written to, their file (`players/data/<uuid>.dat`, as the server saves
|
||||
//! it), never while they are logging in, dead or swapped in. One stack moves
|
||||
//! at a time, checked against what the mod last showed, so a menu built on
|
||||
//! these never copies or loses an item. Server menus are in `native_menus.rs`;
|
||||
//! maps a mod draws in `native_send_map` below.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
lan::{guest::Guest, playerdata},
|
||||
save::{Level, read_dat, write_dat},
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
/// Which of a player's inventories (`native_inventory`).
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum NativeInventoryKind {
|
||||
/// 41 slots: 0..9 the hotbar, 9..36 the rest (as `Inventory`), 36..40
|
||||
/// the armour (feet, legs, chest, head), 40 the off hand.
|
||||
Player,
|
||||
/// The ender chest's 27 slots.
|
||||
Ender,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl NativeInventoryKind {
|
||||
pub(crate) fn size(self) -> usize {
|
||||
match self {
|
||||
Self::Player => 41,
|
||||
Self::Ender => 27,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A player's inventory as `native_inventory` read it: its slots, and
|
||||
/// whether it is the live one (the player is on the server) or their file's.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct NativeInventory {
|
||||
pub slots: Vec<Option<ItemStack>>,
|
||||
pub online: bool,
|
||||
}
|
||||
|
||||
/// `Level.inventory`'s slot numbers (the file's) for our 41.
|
||||
fn file_slot(i: usize) -> Option<u8> {
|
||||
match i {
|
||||
0..36 => Some(i as u8),
|
||||
36..40 => Some(100 + (i - 36) as u8),
|
||||
40 => Some(150),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn guest_slots(g: &Guest, kind: NativeInventoryKind) -> Vec<Option<ItemStack>> {
|
||||
match kind {
|
||||
NativeInventoryKind::Player => {
|
||||
let mut s = g.inventory.clone();
|
||||
s.resize(36, None);
|
||||
// `armor` is head first.
|
||||
s.extend([3, 2, 1, 0].map(|i| g.armor[i].clone()));
|
||||
s.push(g.offhand.clone());
|
||||
s
|
||||
}
|
||||
NativeInventoryKind::Ender => {
|
||||
let mut s = g.ender_items.clone();
|
||||
s.resize(27, None);
|
||||
s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_guest_slots(g: &mut Guest, kind: NativeInventoryKind, mut s: Vec<Option<ItemStack>>) {
|
||||
match kind {
|
||||
NativeInventoryKind::Player => {
|
||||
g.offhand = s.pop().flatten();
|
||||
for (k, i) in [3, 2, 1, 0].into_iter().enumerate() {
|
||||
g.armor[i] = s[36 + k].take();
|
||||
}
|
||||
s.truncate(36);
|
||||
g.inventory = s;
|
||||
}
|
||||
NativeInventoryKind::Ender => g.ender_items = s,
|
||||
}
|
||||
}
|
||||
|
||||
fn level_slots(level: &Level, kind: NativeInventoryKind) -> Vec<Option<ItemStack>> {
|
||||
match kind {
|
||||
NativeInventoryKind::Player => {
|
||||
let mut s = vec![None; 41];
|
||||
for (slot, item, count, damage) in &level.inventory {
|
||||
if let Some(i) = (0..41).find(|&i| file_slot(i) == Some(*slot)) {
|
||||
s[i] = Some(ItemStack {
|
||||
item: item.as_str().into(),
|
||||
count: (*count).max(1),
|
||||
damage: *damage,
|
||||
extra: level.inventory_components.get(slot).cloned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
NativeInventoryKind::Ender => {
|
||||
let mut s = level.ender_items.clone();
|
||||
s.resize(27, None);
|
||||
s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_level_slots(level: &mut Level, kind: NativeInventoryKind, s: Vec<Option<ItemStack>>) {
|
||||
match kind {
|
||||
NativeInventoryKind::Player => {
|
||||
level.inventory.clear();
|
||||
level.inventory_components.clear();
|
||||
for (i, stack) in s.into_iter().enumerate() {
|
||||
if let (Some(slot), Some(st)) = (file_slot(i), stack) {
|
||||
level
|
||||
.inventory
|
||||
.push((slot, st.item.to_string(), st.count, st.damage));
|
||||
if let Some(extra) = st.extra {
|
||||
level.inventory_components.insert(slot, extra);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
NativeInventoryKind::Ender => level.ender_items = s,
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved player's file read as `playerdata::load` reads it, kept whole
|
||||
/// (`player_extra`) so writing it back changes only what was edited.
|
||||
fn read_level(world: &Path, uuid: u128) -> Option<Level> {
|
||||
let file = playerdata::path(world, uuid);
|
||||
let tag = read_dat(&file)?;
|
||||
let mut level = Level {
|
||||
health: 20.0,
|
||||
food: 20,
|
||||
saturation: 5.0,
|
||||
..Level::default()
|
||||
};
|
||||
level.read_player(&tag);
|
||||
level.player_extra = Some(tag);
|
||||
Some(level)
|
||||
}
|
||||
|
||||
/// Where one side of a move lives while it is made.
|
||||
enum Side {
|
||||
/// A guest's index in the hub.
|
||||
Live(usize),
|
||||
/// A player's file, read.
|
||||
File(Box<Level>),
|
||||
}
|
||||
|
||||
/// Another player's inventories, for server mods. API @API:native_inventories@.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// The player `uuid`'s inventory `kind`: the live one when they are on
|
||||
/// the server, else their file's. Err while they are logging in, dead,
|
||||
/// swapped in (inside a player's hook: call from `native_menu_clicked` or
|
||||
/// `native_server_tick`), or when they have never played here.
|
||||
pub(super) fn native_inventory(
|
||||
&self,
|
||||
uuid: u128,
|
||||
kind: NativeInventoryKind,
|
||||
) -> Result<NativeInventory, String> {
|
||||
match self.native_inventory_side(uuid)? {
|
||||
Side::Live(i) => {
|
||||
let host = self.lan.as_ref().expect("live");
|
||||
Ok(NativeInventory {
|
||||
slots: guest_slots(&host.hub.guests[i], kind),
|
||||
online: true,
|
||||
})
|
||||
}
|
||||
Side::File(level) => Ok(NativeInventory {
|
||||
slots: level_slots(&level, kind),
|
||||
online: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn native_inventory_side(&self, uuid: u128) -> Result<Side, String> {
|
||||
if self.lan_acting.is_some() {
|
||||
return Err("inside a player's turn".into());
|
||||
}
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return Err("no server".into());
|
||||
};
|
||||
if let Some(i) = host.hub.guests.iter().position(|g| g.uuid == uuid) {
|
||||
let g = &host.hub.guests[i];
|
||||
return if !g.placed {
|
||||
Err("the player is logging in".into())
|
||||
} else if g.dead {
|
||||
Err("the player is dead".into())
|
||||
} else {
|
||||
Ok(Side::Live(i))
|
||||
};
|
||||
}
|
||||
if self.world_root.as_os_str().is_empty() {
|
||||
return Err("no world".into());
|
||||
}
|
||||
read_level(&self.world_root, uuid)
|
||||
.map(|l| Side::File(Box::new(l)))
|
||||
.ok_or_else(|| "no saved player".into())
|
||||
}
|
||||
|
||||
/// Moves the stack in slot `from_slot` of `from`'s inventory `from_kind`,
|
||||
/// which must still be exactly `expected`, into `to`'s inventory
|
||||
/// `to_kind` where it fits whole (onto equal stacks first, then empty
|
||||
/// slots; for `Player`, the 36 main slots, never armour or off hand), or
|
||||
/// nothing moves and the Err says why. Each side is live or its file
|
||||
/// (`native_inventory`); at most one may be a file, which is written
|
||||
/// before the live side changes. Their screens follow with the tick's
|
||||
/// sync.
|
||||
pub(super) fn native_move_stack(
|
||||
&mut self,
|
||||
from: (u128, NativeInventoryKind, usize),
|
||||
expected: &ItemStack,
|
||||
to: (u128, NativeInventoryKind),
|
||||
) -> Result<(), String> {
|
||||
let (from_uuid, from_kind, from_slot) = from;
|
||||
let (to_uuid, to_kind) = to;
|
||||
if from_uuid == to_uuid && from_kind == to_kind {
|
||||
return Err("the same inventory".into());
|
||||
}
|
||||
let a = self.native_inventory_side(from_uuid)?;
|
||||
let b = if from_uuid == to_uuid {
|
||||
None
|
||||
} else {
|
||||
Some(self.native_inventory_side(to_uuid)?)
|
||||
};
|
||||
if matches!(a, Side::File(_)) && matches!(b, Some(Side::File(_))) {
|
||||
return Err("both players are away".into());
|
||||
}
|
||||
let items = self.res.items();
|
||||
let max = |s: &ItemStack| items.get(&s.item).map_or(64, |i| i.max_stack);
|
||||
let read = |side: &Side, kind| match side {
|
||||
Side::Live(i) => guest_slots(&self.lan.as_ref().expect("live").hub.guests[*i], kind),
|
||||
Side::File(level) => level_slots(level, kind),
|
||||
};
|
||||
let mut src = read(&a, from_kind);
|
||||
let mut dst = read(b.as_ref().unwrap_or(&a), to_kind);
|
||||
match src.get_mut(from_slot) {
|
||||
Some(s) if s.as_ref() == Some(expected) => *s = None,
|
||||
_ => return Err("that stack changed".into()),
|
||||
}
|
||||
let room = match to_kind {
|
||||
NativeInventoryKind::Player => 36,
|
||||
NativeInventoryKind::Ender => 27,
|
||||
};
|
||||
if !super::native_menus::fit(&mut dst[..room], std::slice::from_ref(expected), &max) {
|
||||
return Err("no room".into());
|
||||
}
|
||||
// The file first: when it can't be written, nothing has moved.
|
||||
let world = self.world_root.clone();
|
||||
let write =
|
||||
|side: Side, kind, slots: Vec<Option<ItemStack>>, uuid| -> Result<Side, String> {
|
||||
if let Side::File(mut level) = side {
|
||||
set_level_slots(&mut level, kind, slots);
|
||||
write_dat(&playerdata::path(&world, uuid), level.player_tag())
|
||||
.map_err(|e| format!("the player's file: {e}"))?;
|
||||
return Ok(Side::File(level));
|
||||
}
|
||||
Ok(side)
|
||||
};
|
||||
let (a, b) = match b {
|
||||
None => {
|
||||
// Two inventories of one player: their file or live.
|
||||
let mut both = a;
|
||||
if let Side::File(level) = &mut both {
|
||||
set_level_slots(level, from_kind, src.clone());
|
||||
}
|
||||
let both = write(both, to_kind, dst.clone(), from_uuid)?;
|
||||
(both, None)
|
||||
}
|
||||
Some(b) if matches!(b, Side::File(_)) => {
|
||||
let b = write(b, to_kind, dst.clone(), to_uuid)?;
|
||||
(a, Some(b))
|
||||
}
|
||||
Some(b) => {
|
||||
let a = write(a, from_kind, src.clone(), from_uuid)?;
|
||||
(a, Some(b))
|
||||
}
|
||||
};
|
||||
let host = self.lan.as_mut().expect("checked");
|
||||
if let Side::Live(i) = a {
|
||||
set_guest_slots(&mut host.hub.guests[i], from_kind, src);
|
||||
if b.is_none() {
|
||||
set_guest_slots(&mut host.hub.guests[i], to_kind, dst);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if let Some(Side::Live(i)) = b {
|
||||
set_guest_slots(&mut host.hub.guests[i], to_kind, dst);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends the player `uuid` the whole picture of map `map_id`: 128 x 128
|
||||
/// colours (`MapColor` ids times 4 plus the shade, row by row), scale 0,
|
||||
/// locked, no decorations, as `ClientboundMapItemDataPacket`. A filled
|
||||
/// map the mod gives with that `minecraft:map_id` then shows it in hand
|
||||
/// and in item frames; the server keeps nothing (a map id with no saved
|
||||
/// map is never sent again by the game), so send it again after the
|
||||
/// player joins. Use ids far from the world's own (they count up from 0).
|
||||
/// False for a player not here, or `colors` not 16384 long.
|
||||
/// API @API:native_inventories@.
|
||||
pub(super) fn native_send_map(&mut self, uuid: u128, map_id: i32, colors: &[u8]) -> bool {
|
||||
use horizon_protocol::packets::play::{Clientbound, MapItemData, MapPatch};
|
||||
if colors.len() != 128 * 128 {
|
||||
return false;
|
||||
}
|
||||
self.native_send(
|
||||
uuid,
|
||||
Clientbound::MapItemData(MapItemData {
|
||||
map_id,
|
||||
scale: 0,
|
||||
locked: true,
|
||||
decorations: None,
|
||||
patch: Some(MapPatch {
|
||||
width: 128,
|
||||
height: 128,
|
||||
start_x: 0,
|
||||
start_y: 0,
|
||||
colors: colors.to_vec(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn stack(item: &str, count: u32) -> ItemStack {
|
||||
ItemStack {
|
||||
item: item.into(),
|
||||
count,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_files_slots_map_to_the_41_and_back() {
|
||||
let mut level = Level::default();
|
||||
let mut s = vec![None; 41];
|
||||
s[0] = Some(stack("minecraft:stone", 5));
|
||||
s[35] = Some(stack("minecraft:dirt", 1));
|
||||
s[36] = Some(stack("minecraft:iron_boots", 1));
|
||||
s[39] = Some(stack("minecraft:iron_helmet", 1));
|
||||
s[40] = Some(stack("minecraft:shield", 1));
|
||||
set_level_slots(&mut level, NativeInventoryKind::Player, s.clone());
|
||||
let slots: Vec<u8> = level.inventory.iter().map(|e| e.0).collect();
|
||||
assert_eq!(slots, [0, 35, 100, 103, 150]);
|
||||
assert_eq!(level_slots(&level, NativeInventoryKind::Player), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_player_file_round_trips_its_inventory_and_keeps_the_rest() {
|
||||
let dir = std::env::temp_dir().join(format!("native-inv-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let uuid = 0x1234_u128;
|
||||
let file = playerdata::path(&dir, uuid);
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
let mut level = Level {
|
||||
health: 13.0,
|
||||
xp_level: 7,
|
||||
..Level::default()
|
||||
};
|
||||
level.ender_items = vec![None; 27];
|
||||
level.ender_items[2] = Some(stack("minecraft:diamond", 3));
|
||||
let mut s = vec![None; 41];
|
||||
s[4] = Some(stack("minecraft:bread", 12));
|
||||
set_level_slots(&mut level, NativeInventoryKind::Player, s);
|
||||
write_dat(&file, level.player_tag()).unwrap();
|
||||
let mut back = read_level(&dir, uuid).unwrap();
|
||||
assert_eq!(back.health, 13.0);
|
||||
assert_eq!(back.xp_level, 7);
|
||||
assert_eq!(
|
||||
level_slots(&back, NativeInventoryKind::Ender)[2],
|
||||
Some(stack("minecraft:diamond", 3))
|
||||
);
|
||||
let mut inv = level_slots(&back, NativeInventoryKind::Player);
|
||||
assert_eq!(inv[4], Some(stack("minecraft:bread", 12)));
|
||||
inv[4] = None;
|
||||
set_level_slots(&mut back, NativeInventoryKind::Player, inv);
|
||||
write_dat(&file, back.player_tag()).unwrap();
|
||||
let again = read_level(&dir, uuid).unwrap();
|
||||
assert!(
|
||||
level_slots(&again, NativeInventoryKind::Player)
|
||||
.iter()
|
||||
.all(Option::is_none)
|
||||
);
|
||||
assert_eq!(again.xp_level, 7);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -239,6 +239,8 @@ impl Game<'_> {
|
||||
|
||||
/// `error_text` is the engine's own error text, unchanged.
|
||||
pub(super) fn native_lifecycle_world_failed(&mut self, error_text: &str) {
|
||||
// The levels that opened before the failure close first.
|
||||
self.native_levels_closing();
|
||||
if let Some(context) = self.native_lifecycle.world_open_failed() {
|
||||
self.native_world_lifecycle(&context, WorldPhase::WorldOpenFailed, error_text);
|
||||
}
|
||||
@@ -246,6 +248,7 @@ impl Game<'_> {
|
||||
|
||||
/// Report a server-loop or shutdown failure once and retire its epoch.
|
||||
pub(super) fn native_lifecycle_close_failed(&mut self, error_text: &str) {
|
||||
self.native_levels_closing();
|
||||
if let Some(context) = self.native_lifecycle.world_close_failed() {
|
||||
self.native_world_lifecycle(&context, WorldPhase::WorldCloseFailed, error_text);
|
||||
}
|
||||
@@ -256,6 +259,8 @@ impl Game<'_> {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
// Each level closes after the final save (`LevelEvent.Unload`).
|
||||
self.native_levels_closing();
|
||||
if let Some(context) = self.native_lifecycle.world_close_final() {
|
||||
self.native_world_lifecycle(&context, WorldPhase::WorldCloseFinal, "");
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ pub(crate) fn native_menu_inventory_slot(size: usize, slot: i32) -> Option<usize
|
||||
/// Adds every stack of `gets` to the 36 slots of `inventory`, onto matching
|
||||
/// stacks first, then into empty slots, hotbar first; false when one doesn't
|
||||
/// fit whole. `max` is an item's stack size.
|
||||
fn fit(
|
||||
pub(super) fn fit(
|
||||
inventory: &mut [Option<ItemStack>],
|
||||
gets: &[ItemStack],
|
||||
max: &impl Fn(&ItemStack) -> u32,
|
||||
|
||||
Loaded 100 of 316 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user