Merge remote-tracking branch 'origin/main' into fix/items-stuck-in-web

This commit is contained in:
HrafnaValkyrja committed 2026-11-01 14:54:08 -05:00
commit ee3f1787ff
347 files changed
+17316 -1856

No files matched your search

+1 -1
View File
@@ -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
+17 -9
View File
@@ -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
View File
@@ -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"
+1
View File
@@ -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",
+3 -5
View File
@@ -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")]
+41 -4
View File
@@ -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();
+169
View File
@@ -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();
+221
View File
@@ -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"
@@ -23,7 +23,7 @@
//! Which states tick is a table by state id, built by the game from the block
//! registry; whether a slot is made is decided when the slot is promoted at
//! the start of the next tick, from the block state then.
use crate::chunk::block_entity::{BlockEntity, ModContainer, ModFluidTanks};
use crate::chunk::block_entity::{BlockEntity, ModContainer, ModEnergyBuffer, ModFluidTanks};
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
use std::sync::Arc;
@@ -76,6 +76,29 @@ 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 energy buffers of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_energy: FxHashMap<u32, Arc<ModEnergyBuffer>>,
/// 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 {
@@ -172,6 +195,13 @@ impl BlockEntities {
pub fn mod_fluids(&self, state: u32) -> Option<&Arc<ModFluidTanks>> {
self.mod_fluids.get(&state)
}
pub fn set_mod_energy(&mut self, energy: FxHashMap<u32, Arc<ModEnergyBuffer>>) {
self.mod_energy = energy;
}
/// The energy buffer of a mod block's block entity in block state `state`.
pub fn mod_energy(&self, state: u32) -> Option<&Arc<ModEnergyBuffer>> {
self.mod_energy.get(&state)
}
fn next(&mut self) -> u32 {
let n = self.next_slot;
@@ -190,6 +220,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 +249,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 +267,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 +298,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 +312,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 +342,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 +444,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();
@@ -135,6 +135,23 @@ pub struct ModFluidTanks {
/// The tanks a drain may take from (`canTakeItemThroughFace`).
pub outputs: Vec<usize>,
}
/// The energy buffer of a mod block's block entity (NeoForge's
/// `SimpleEnergyHandler`): its capacity, the most one insert and one extract
/// moves, and the faces a fill may come through and a drain may leave by.
/// The amount is not here: it lives in the entity's saved compound, under
/// `energy`, so it survives restarts and the block's own tick reads it.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ModEnergyBuffer {
pub capacity: i32,
/// The most one insert moves; 0 takes none.
pub max_insert: i32,
/// The most one extract moves; 0 gives none.
pub max_extract: i32,
/// The faces, by `Direction` number, a fill may come through.
pub insert_faces: [bool; 6],
/// The faces a drain may leave by.
pub extract_faces: [bool; 6],
}
/// The vanilla menus a mod container can open, with the slot count each
/// shows: slot grids, and the furnaces' (input, fuel, result), whose four
/// data slots draw the flame (`data[0]` of `data[1]`) and the arrow
@@ -4,7 +4,7 @@
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer,
ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin,
ModEnergyBuffer, ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
+2 -7
View File
@@ -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) {
+13 -3
View File
@@ -575,7 +575,7 @@ pub struct DataStore {
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `data_maps/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
@@ -584,7 +584,7 @@ pub struct DataStore {
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/` or `tags/<sub>/`, where the id keeps the sub
/// `villager_trade/`, `dialog/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
/// path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
@@ -592,7 +592,7 @@ fn content_id(file: &str) -> Option<String> {
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
| "villager_trade" | "dialog" => kind,
| "villager_trade" | "dialog" | "data_maps" => kind,
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
// test:sub/mytag`), the whole path after the namespace, so nested
// registries keep their directory (`TagLoader`'s id shape).
@@ -623,6 +623,7 @@ impl DataStore {
let builtin = pack.id == "vanilla";
for f in pack.list_kinds(&[
"advancement",
"data_maps",
"dialog",
"function",
"item_modifier",
@@ -882,6 +883,15 @@ pub fn worldgen_overrides(
mod tests {
use super::*;
#[test]
fn data_map_files_are_content_files_keyed_by_map_namespace_and_registry() {
assert_eq!(
content_id("data/mymod/data_maps/item/fuel.json").as_deref(),
Some("data_maps/mymod:item/fuel")
);
assert_eq!(content_id("data/mymod/data_maps/item/fuel.txt"), None);
}
#[test]
fn enabled_packs_override_worldgen_in_order() {
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
+63 -11
View File
@@ -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"),
},
}
}
+400 -203
View File
@@ -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;
@@ -126,6 +127,7 @@ mod occlusion;
mod oit;
mod oit_features;
mod pack_atlas;
mod pack_center_depth;
pub mod pack_draw;
mod pack_entities;
mod pack_hand;
@@ -629,8 +631,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 +881,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 +1780,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 +1817,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 +3440,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 +3594,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 +3689,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 +3711,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 +3730,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 +4081,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 +4424,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 +4515,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 +4565,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 +5126,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 +5140,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 +5211,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 +5282,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 +6107,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 +6339,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 +6384,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 +6400,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 +7603,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 +7676,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 +7696,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 +7738,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
@@ -7819,6 +7795,9 @@ impl Renderer {
if let Some(timer) = &mut self.own_timing {
timer.submitted(&self.queue);
}
if let Some((_, Some(post))) = &mut self.pack_post {
post.submitted();
}
let failed = checks.and_then(GpuChecks::pop);
if let Some((request, buffer)) = readback {
// Out of memory on the way: the PNG would be black; taken again at half the size.
@@ -7869,7 +7848,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 +7867,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 +8255,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 +8404,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", &copyright),
(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);
}
}
@@ -0,0 +1,296 @@
//! `centerDepthSmooth`'s input: `depthtex0` at the centre of the screen. A depth texture can
//! only be copied whole, so each frame a one-pixel pass loads that texel into a 1x1 `R32Float`
//! target, copied to one of a few small buffers and read back frames later without waiting for
//! the GPU (as `timing.rs` reads its timestamps); `FrameUniforms` smooths the latest value.
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
const SLOTS: usize = 3;
const FREE: u8 = 0;
/// Copied this frame: mapped once the frame is submitted.
const COPIED: u8 = 1;
const PENDING: u8 = 2;
const MAPPED: u8 = 3;
/// One row of a texture copy, the alignment wgpu asks of `bytes_per_row`.
const ROW: u32 = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
const SHADER: &str = "
@group(0) @binding(0) var depth: texture_depth_2d;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4<f32> {
let centre = vec2<i32>(textureDimensions(depth) / 2u);
return vec4<f32>(textureLoad(depth, centre, 0), 0.0, 0.0, 1.0);
}
";
struct Slot {
buffer: wgpu::Buffer,
state: Arc<AtomicU8>,
}
pub struct CenterDepth {
slots: Vec<Slot>,
pipeline: wgpu::RenderPipeline,
layout: wgpu::BindGroupLayout,
/// The 1x1 target the centre texel is loaded into.
texel: wgpu::Texture,
texel_view: wgpu::TextureView,
/// The bind group of the depth texture it reads, made again when that texture changes.
group: Option<(wgpu::Texture, wgpu::BindGroup)>,
/// The depth last read back: GL window depth, 0..1.
latest: Option<f32>,
}
impl CenterDepth {
pub fn new(device: &wgpu::Device) -> Self {
let slots = (0..SLOTS)
.map(|_| Slot {
buffer: device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack centre depth"),
size: u64::from(ROW),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
}),
state: Arc::new(AtomicU8::new(FREE)),
})
.collect();
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack centre depth"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}],
});
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Shader pack centre depth"),
source: wgpu::ShaderSource::Wgsl(SHADER.into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Shader pack centre depth"),
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
let format = wgpu::TextureFormat::R32Float;
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Shader pack centre depth"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(format.into())],
}),
multiview_mask: None,
cache: None,
});
let texel = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack centre depth"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let texel_view = texel.create_view(&Default::default());
Self {
slots,
pipeline,
layout,
texel,
texel_view,
group: None,
latest: None,
}
}
/// Loads the centre texel of `depth` (a `Depth32Float` texture that can be bound) and
/// copies it into a free slot; with none free (the GPU is behind), this frame is skipped.
pub fn copy(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
depth: &wgpu::Texture,
) {
let Some(slot) = self
.slots
.iter()
.find(|s| s.state.load(Ordering::Acquire) == FREE)
else {
return;
};
if self.group.as_ref().is_none_or(|(t, _)| t != depth) {
let view = depth.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack centre depth"),
layout: &self.layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
}],
});
self.group = Some((depth.clone(), group));
}
let Some((_, group)) = &self.group else {
return;
};
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack centre depth"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.texel_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, group, &[]);
pass.draw(0..3, 0..1);
}
encoder.copy_texture_to_buffer(
self.texel.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &slot.buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(ROW),
rows_per_image: Some(1),
},
},
self.texel.size(),
);
slot.state.store(COPIED, Ordering::Release);
}
/// After the frame's submission: start reading back what it copied.
pub fn submitted(&mut self) {
for slot in &self.slots {
if slot.state.load(Ordering::Acquire) != COPIED {
continue;
}
slot.state.store(PENDING, Ordering::Release);
let state = slot.state.clone();
slot.buffer
.slice(..)
.map_async(wgpu::MapMode::Read, move |result| {
let next = if result.is_ok() { MAPPED } else { FREE };
state.store(next, Ordering::Release);
});
}
}
/// The newest depth read back so far, `None` before the first arrives.
pub fn collect(&mut self, device: &wgpu::Device) -> Option<f32> {
let _ = device.poll(wgpu::PollType::Poll);
for slot in &self.slots {
if slot.state.load(Ordering::Acquire) != MAPPED {
continue;
}
let depth = slot
.buffer
.slice(..4)
.get_mapped_range()
.ok()
.map(|bytes| bytemuck::pod_read_unaligned::<f32>(&bytes[..4]));
slot.buffer.unmap();
slot.state.store(FREE, Ordering::Release);
if let Some(depth) = depth.filter(|d| d.is_finite()) {
self.latest = Some(depth.clamp(0.0, 1.0));
}
}
self.latest
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance =
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// A depth texture cleared to a known depth comes back as that depth, a frame or more
/// later, without the frame waiting for it.
#[test]
fn the_centre_depth_comes_back() {
let Some((device, queue)) = test_device() else {
return;
};
let depth = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 9,
height: 7,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = depth.create_view(&Default::default());
let mut center = CenterDepth::new(&device);
assert_eq!(center.collect(&device), None);
let mut encoder = device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.375),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
center.copy(&device, &mut encoder, &depth);
queue.submit([encoder.finish()]);
center.submitted();
let _ = device.poll(wgpu::PollType::wait_indefinitely());
assert_eq!(center.collect(&device), Some(0.375));
// Its slot is free again: every slot can be used once more.
for _ in 0..SLOTS {
let mut encoder = device.create_command_encoder(&Default::default());
center.copy(&device, &mut encoder, &depth);
queue.submit([encoder.finish()]);
center.submitted();
}
let _ = device.poll(wgpu::PollType::wait_indefinitely());
assert_eq!(center.collect(&device), Some(0.375));
}
}
@@ -512,6 +512,8 @@ pub struct PackPost {
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<super::pack_timing::PassTiming>,
/// `depthtex0` at the screen's centre, read back for `centerDepthSmooth`.
center_depth: Option<super::pack_center_depth::CenterDepth>,
/// `shaders.properties`' custom uniforms, after the frame's in `uniforms.values`.
custom: horizon_shaders::Custom,
atlas: Option<wgpu::TextureView>,
@@ -2242,6 +2244,7 @@ impl PackPost {
block_ids,
page_groups: HashMap::new(),
timing: None,
center_depth: None,
custom,
atlas: world.atlas.as_ref().map(|a| a.0.clone()),
lightmap: world.lightmap,
@@ -2740,6 +2743,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;
}
@@ -2896,6 +2906,13 @@ impl PackPost {
}
}
/// After the frame's submission: the centre depth it copied starts its way back.
pub fn submitted(&mut self) {
if let Some(center) = &mut self.center_depth {
center.submitted();
}
}
/// Whether the pack draws entities or block entities itself.
pub fn has_entities(&self) -> bool {
self.entities.is_some()
@@ -3613,6 +3630,10 @@ impl PackPost {
if let Some(t) = &mut self.timing {
t.begin(device, queue);
}
self.uniforms.center_depth_raw = self
.center_depth
.get_or_insert_with(|| super::pack_center_depth::CenterDepth::new(device))
.collect(device);
self.uniforms.update(frame, inputs, size);
// Until the camera's biome is known, `smooth` waits for the world's values.
if !inputs.biome_known {
@@ -3851,6 +3872,10 @@ impl PackPost {
self.draw_translucent(device, queue, encoder, size, terrain);
}
let targets = self.targets.as_ref().expect("resized");
// depthtex0 as the frame leaves it, for next frames' `centerDepthSmooth`.
if let Some(center) = &mut self.center_depth {
center.copy(device, encoder, &targets.depth_texture);
}
let view = |m: u16, i: usize, alt: bool| {
targets.color[i]
.as_ref()
@@ -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,26 @@ 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",
"centerDepthSmooth",
"isRightHanded",
"is_on_ground",
"bedrockLevel",
@@ -220,6 +275,9 @@ pub struct FrameUniforms {
previous: Option<(glam::DVec3, Mat4, Mat4)>,
wetness: f32,
eye_smooth: [f32; 2],
/// `depthtex0` at the screen's centre as last read back, and `centerDepthSmooth`.
pub center_depth_raw: Option<f32>,
center_depth: Option<f32>,
/// The pack's shadow settings (7.1).
pub shadow: Shadow,
}
@@ -235,6 +293,8 @@ impl Default for FrameUniforms {
previous: None,
wetness: 0.0,
eye_smooth: [0.0; 2],
center_depth_raw: None,
center_depth: None,
shadow: Shadow::default(),
}
}
@@ -379,6 +439,15 @@ impl FrameUniforms {
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
*smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt);
}
// `centerDepthHalflife`'s default, 1 tick (OptiFine's shaders.txt); the first value
// read back is taken as it is.
if let Some(raw) = self.center_depth_raw {
self.center_depth = Some(match self.center_depth {
Some(v) => smooth(v, raw, 1.0 / 20.0, dt),
None => raw,
});
}
let center_depth = self.center_depth.unwrap_or(1.0);
// Vanilla fades by the stronger of two linear fogs; a pack gets one: the pair (start
// and end together) of the one that is complete nearer (OQ-36). Mixing the nearer start
// of one with the nearer end of the other made fog begin at the camera on clear days.
@@ -424,6 +493,8 @@ 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)),
"centerDepthSmooth" => Value::f(&[center_depth]),
"moonPosition" => v3(moon),
"shadowLightPosition" => v3(if day { sun } else { moon }),
"sunAngle" => Value::f(&[sun_angle]),
@@ -483,7 +554,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 +600,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 +658,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 +688,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()
}
+37 -1
View File
@@ -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(
+55 -21
View File
@@ -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()));
+285 -46
View File
@@ -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;
+16 -8
View File
@@ -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},
+35 -4
View File
@@ -505,6 +505,24 @@ fn signum(v: f64) -> f64 {
/// entity stops there and is not kept: the game hands it to
/// `finish_entity_hit` once it has hurt the entity.
pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) -> (bool, Hit) {
tick_with(p, scene, targets, true)
}
/// One arrow tick whose hit is canceled (`ProjectileImpactEvent`): the arrow
/// moves on to where it would have hit, as `AbstractArrow.stepMoveAndHit` leaves
/// it when the event is canceled, and flies on: not stuck, nothing hurt, no hit.
pub fn tick_through(
p: &mut Projectile,
scene: &ArrowScene,
targets: &[(Who, Aabb)],
) -> (bool, Hit) {
tick_with(p, scene, targets, false)
}
fn tick_with(
p: &mut Projectile,
scene: &ArrowScene,
targets: &[(Who, Aabb)],
impact: bool,
) -> (bool, Hit) {
let Some(mut st) = p.arrow.take() else {
return (false, Hit::None);
};
@@ -517,7 +535,7 @@ pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) ->
st: &mut st,
scene,
}
.tick(targets);
.tick(targets, impact);
p.stuck = st.in_ground;
let keep = !st.removed && !matches!(hit, Hit::Entity { .. });
p.arrow = Some(st);
@@ -600,7 +618,7 @@ impl Ticker<'_, '_, '_, '_> {
fn trident(&self) -> bool {
matches!(self.p.kind, ProjectileKind::Trident)
}
fn tick(&mut self, targets: &[(Who, Aabb)]) -> Hit {
fn tick(&mut self, targets: &[(Who, Aabb)], impact: bool) -> Hit {
// ThrownTrident.tick.
if self.trident() && self.st.in_ground_time > 4 {
self.st.dealt_damage = true;
@@ -711,7 +729,7 @@ impl Ticker<'_, '_, '_, '_> {
original.z + movement.z,
);
if physics {
hit = self.step_move_and_hit(original, end, targets);
hit = self.step_move_and_hit(original, end, targets, impact);
if matches!(hit, Hit::Entity { .. }) {
return hit;
}
@@ -795,7 +813,13 @@ impl Ticker<'_, '_, '_, '_> {
/// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the
/// block. A piercing arrow that hits an entity here goes on from it next
/// tick (`finish_entity_hit`); vanilla's loop goes on within the same tick.
fn step_move_and_hit(&mut self, from: Vec3, to: Vec3, targets: &[(Who, Aabb)]) -> Hit {
fn step_move_and_hit(
&mut self,
from: Vec3,
to: Vec3,
targets: &[(Who, Aabb)],
impact: bool,
) -> Hit {
let block = self.scene.clip(from, to);
let end = block.map_or(to, |b| b.0);
// findHitEntities: ProjectileUtil.computeMargin and the search box.
@@ -828,6 +852,10 @@ impl Ticker<'_, '_, '_, '_> {
self.p.position = next;
self.apply_effects_from_blocks(from, next);
if let Some((who, at, _)) = best {
// A canceled impact ends the search, the arrow where it would have hit.
if !impact {
return Hit::None;
}
// AbstractArrow.onHitEntity: past pierce level + 1 entities the
// arrow is discarded before it hurts the next one.
if self.st.pierce > 0 {
@@ -849,6 +877,9 @@ impl Ticker<'_, '_, '_, '_> {
if self.scene.is_air(state) {
return Hit::None;
}
if !impact {
return Hit::None;
}
self.on_hit_block(pos, state);
Hit::Block {
pos: [pos.x, pos.y, pos.z],
@@ -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;
+63
View File
@@ -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));
+163
View File
@@ -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;
+6 -25
View File
@@ -933,31 +933,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 {
@@ -2438,6 +2413,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!(
+88 -1
View File
@@ -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),
@@ -795,7 +796,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(
@@ -1397,6 +1415,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,
_ => {}
}
}
@@ -1407,6 +1426,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() {
@@ -3458,6 +3490,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))) {
@@ -3933,6 +3967,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"))
@@ -4183,6 +4260,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
+108
View File
@@ -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),
}
}
}
}
+47 -17
View File
@@ -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,
);
+414 -147
View File
@@ -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;
@@ -195,22 +196,28 @@ mod native_block_entity_draw;
mod native_claimed_items;
mod native_components;
mod native_criteria;
mod native_data_maps;
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;
mod native_mobs;
mod native_motion;
mod native_mounts;
mod native_net;
mod native_panel;
mod native_panel_recipes;
mod native_projectiles;
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 +1646,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 +1682,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.
@@ -1777,6 +1793,9 @@ struct WorldTables {
items: Items,
recipes: Recipes,
stations: horizon_client::stations::StationData,
/// The values of the data map types native mods registered
/// (`native_data_map_types`).
data_maps: native_data_maps::DataMaps,
}
impl Resources {
@@ -2124,6 +2143,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 +2419,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 +2864,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 +3034,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 +3280,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
@@ -4851,8 +4883,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,
@@ -6377,8 +6410,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;
}
@@ -6409,13 +6451,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;
}
@@ -6447,22 +6487,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
@@ -6514,14 +6548,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),
@@ -6811,13 +6851,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));
@@ -7084,6 +7139,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,
@@ -7593,6 +7665,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
@@ -7709,9 +7782,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))
@@ -7938,6 +8018,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);
@@ -8059,42 +8140,50 @@ impl Game<'_> {
hud_layout::full_attack_indicator(a, delay, self.crosshair_living())
});
let draws = hud_layout::crosshair(wi, hi, attack, full);
hud_layout::paint(&mut batch, &self.gui.atlas, &draws);
self.gui_layer("minecraft:crosshair", &mut batch, |g, batch| {
hud_layout::paint(batch, &g.gui.atlas, &draws);
});
}
if spectator {
self.draw_spectator_hotbar(&mut batch);
} else {
let partial = self.clock.fraction() as f32;
let inputs = hud_layout::HotbarInputs {
selected: self.selected as i32,
right_handed: !self.settings.main_hand_left,
offhand: self.offhand.is_some(),
slots: std::array::from_fn(|i| {
self.inventory[i]
.as_ref()
.map(|_| (f32::from(self.hotbar_pop[i]) - partial).max(0.0))
}),
offhand_pop: (f32::from(self.hotbar_pop[9]) - partial).max(0.0),
attack: (indicator == 2).then_some(strength),
};
let draws = hud_layout::hotbar(wi, hi, &inputs);
hud_layout::paint(&mut batch, &self.gui.atlas, &draws);
for d in &draws {
if let hud_layout::GuiDraw::Item { slot, x, y, .. } = *d {
let stack = if slot == 40 {
self.offhand.as_ref()
} else {
self.inventory[slot as usize].as_ref()
};
if let Some(stack) = stack {
self.draw_item(&mut batch, stack, x as f32, y as f32);
self.gui_layer("minecraft:hotbar", &mut batch, |g, batch| {
if spectator {
g.draw_spectator_hotbar(batch);
} else {
let partial = g.clock.fraction() as f32;
let inputs = hud_layout::HotbarInputs {
selected: g.selected as i32,
right_handed: !g.settings.main_hand_left,
offhand: g.offhand.is_some(),
slots: std::array::from_fn(|i| {
g.inventory[i]
.as_ref()
.map(|_| (f32::from(g.hotbar_pop[i]) - partial).max(0.0))
}),
offhand_pop: (f32::from(g.hotbar_pop[9]) - partial).max(0.0),
attack: (indicator == 2).then_some(strength),
};
let draws = hud_layout::hotbar(wi, hi, &inputs);
hud_layout::paint(batch, &g.gui.atlas, &draws);
for d in &draws {
if let hud_layout::GuiDraw::Item { slot, x, y, .. } = *d {
let stack = if slot == 40 {
g.offhand.as_ref()
} else {
g.inventory[slot as usize].as_ref()
};
if let Some(stack) = stack {
g.draw_item(batch, stack, x as f32, y as f32);
}
}
}
}
}
self.draw_effect_icons(&mut batch);
});
self.gui_layer("minecraft:effects", &mut batch, |g, batch| {
g.draw_effect_icons(batch);
});
// Hud: the action bar and titles draw over the hotbar (nextStratum).
self.draw_boss_bars(&mut batch);
self.gui_layer("minecraft:boss_overlay", &mut batch, |g, batch| {
g.draw_boss_bars(batch);
});
self.draw_extras(&mut batch);
}
if show_hud {
@@ -8109,47 +8198,56 @@ impl Game<'_> {
self.experience_recent(),
);
use horizon_client::locator_bar::ContextualBar;
// The mount's hearts (and the jump bar).
// The mount's hearts.
let vehicle_hearts = if spectator {
0
} else {
self.draw_mount_hud(
&mut batch,
wi,
hi,
contextual == ContextualBar::JumpableVehicle,
)
self.draw_mount_hud(&mut batch, wi, hi)
};
// Without the survival bars only the mount's hearts raise it.
bar_height = 39 + 10 * ((vehicle_hearts + 9) / 10);
if contextual == ContextualBar::Locator {
let (sprite, [x, y, w, h]) = horizon_client::locator_bar::background(wi, hi);
horizon_client::ui::nine_slice(
&mut batch,
&self.gui.atlas,
sprite,
x as f32,
y as f32,
w as f32,
h as f32,
u32::MAX,
);
}
// The one bar above the hotbar: the mount's jump bar, the locator
// bar (with its background) or the experience bar, with the level
// number that a survival player always has.
self.gui_layer("minecraft:contextual_info_bar", &mut batch, |g, batch| {
if contextual == ContextualBar::JumpableVehicle {
g.draw_jump_bar(batch, wi, hi);
}
if contextual == ContextualBar::Locator {
let (sprite, [x, y, w, h]) = horizon_client::locator_bar::background(wi, hi);
horizon_client::ui::nine_slice(
batch,
&g.gui.atlas,
sprite,
x as f32,
y as f32,
w as f32,
h as f32,
u32::MAX,
);
}
if has_experience && g.player.is_some() {
let atlas = &g.gui.atlas;
let width = |s: &str| HudBatch::text_width(atlas, s) as i32;
let draws = hud_layout::experience(
wi,
hi,
g.experience.progress,
g.experience.level,
contextual == ContextualBar::Experience,
&width,
);
hud_layout::paint(batch, atlas, &draws);
}
if contextual == ContextualBar::Locator {
let draws = g.locator_bar_draws(wi, hi);
hud_layout::paint(batch, &g.gui.atlas, &draws);
}
});
if !self.creative
&& !spectator
&& let Some(p) = &mut self.player
{
let atlas = &self.gui.atlas;
let width = |s: &str| HudBatch::text_width(atlas, s) as i32;
let draws = hud_layout::experience(
wi,
hi,
self.experience.progress,
self.experience.level,
contextual == ContextualBar::Experience,
&width,
);
hud_layout::paint(&mut batch, atlas, &draws);
let effects = &self.potions.effects;
let kind = if effects.get("poison").is_some() {
hud_layout::HeartKind::Poisoned
@@ -8194,9 +8292,11 @@ impl Game<'_> {
Some(draws) => draws,
None => self.health_hud.layout(&inputs, wi, hi),
};
// A fresh borrow: the hook ran with all of `self`.
let atlas = &self.gui.atlas;
hud_layout::paint(&mut batch, atlas, &draws);
// The rows draw in one pass here, so a mod's `player_health`
// layer stands for the armor, food and air rows too.
self.gui_layer("minecraft:player_health", &mut batch, |g, batch| {
hud_layout::paint(batch, &g.gui.atlas, &draws);
});
bar_height = self
.health_hud
.left_height
@@ -8214,21 +8314,30 @@ impl Game<'_> {
self.audio.capture = capture;
}
}
if contextual == ContextualBar::Locator {
let draws = self.locator_bar_draws(wi, hi);
hud_layout::paint(&mut batch, &self.gui.atlas, &draws);
}
if !spectator {
self.draw_selected_name(&mut batch, bar_height, !self.creative);
self.gui_layer("minecraft:selected_item_name", &mut batch, |g, batch| {
g.draw_selected_name(batch, bar_height, !g.creative);
});
}
}
// Hud.extractSleepOverlay: over all health/food bars, under titles and chat.
let draws = hud_overlays::sleep_overlay(wi, hi, self.sleep_timer as i32);
hud_layout::paint(&mut batch, &self.gui.atlas, &draws);
self.gui_layer("minecraft:sleep_overlay", &mut batch, |g, batch| {
hud_layout::paint(batch, &g.gui.atlas, &draws);
});
if show_hud {
self.draw_titles(&mut batch, bar_height);
self.draw_scoreboard(&mut batch);
self.draw_subtitles(&mut batch);
self.gui_layer("minecraft:overlay_message", &mut batch, |g, batch| {
g.draw_overlay_message(batch, bar_height);
});
self.gui_layer("minecraft:title", &mut batch, |g, batch| {
g.draw_title(batch);
});
self.gui_layer("minecraft:scoreboard_sidebar", &mut batch, |g, batch| {
g.draw_scoreboard(batch);
});
self.gui_layer("minecraft:subtitle_overlay", &mut batch, |g, batch| {
g.draw_subtitles(batch);
});
}
let atlas = &self.gui.atlas;
if show_hud
@@ -8238,8 +8347,16 @@ impl Game<'_> {
let tw = HudBatch::text_width(atlas, text);
batch.text(atlas, text, (w - tw) / 2.0, h - 59.0, u32::MAX, true);
}
self.draw_chat(&mut batch);
self.draw_tab_list(&mut batch);
// The chat screen draws the focused chat itself; F1 hides the rest.
if show_hud && self.screen != Screen::Chat {
self.gui_layer("minecraft:chat", &mut batch, |g, batch| g.draw_chat(batch));
self.gui_layer("minecraft:tab_list", &mut batch, |g, batch| {
g.draw_tab_list(batch);
});
} else {
self.draw_chat(&mut batch);
self.draw_tab_list(&mut batch);
}
// Screen.extractBlurredBackground blurs the world and HUD, while the
// screen's background, widgets, toasts and debug overlay stay sharp.
batch.blur_at = batch.vertices.len() as u32;
@@ -10568,7 +10685,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]);
@@ -10966,7 +11089,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);
}
@@ -11132,7 +11255,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];
@@ -11701,6 +11824,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.
@@ -12276,6 +12401,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};
@@ -12310,22 +12475,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);
@@ -12830,18 +12990,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.
@@ -13002,10 +13227,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 {
@@ -13029,18 +13256,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);
}
}
@@ -15442,6 +15675,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
@@ -16691,6 +16934,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();
@@ -16951,6 +17199,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)
@@ -17595,6 +17850,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,
@@ -17767,6 +18023,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(),
@@ -18383,11 +18641,20 @@ impl Game<'_> {
}
};
let stations = horizon_client::stations::StationData::load(root, &items, &overlay);
let mut data_map_types = Vec::new();
self.native_data_map_types(&mut data_map_types);
let data_maps = native_data_maps::DataMaps::load(
&data_map_types,
&files,
&items.item_tags,
&items.block_tags,
);
Some(WorldTables {
files,
items,
recipes,
stations,
data_maps,
})
}
/// Reload the content tables with the world's packs, as
@@ -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"]
);
}
}
@@ -28,6 +28,8 @@ impl Game<'_> {
self.world.block_entities.set_mod_containers(containers);
let fluids = self.native_fluid_tables();
self.world.block_entities.set_mod_fluids(fluids);
let energy = self.native_energy_tables();
self.world.block_entities.set_mod_energy(energy);
}
// Only lingering cooldowns are dropped; a hopper that ticks checks
// its own block. Once a second is soon enough for the rest.
+16
View File
@@ -1046,7 +1046,18 @@ impl Game<'_> {
/// `getDismountLocationForPassenger`: in front of the player if there is
/// floor there, else on top of the boat.
pub(super) fn dismount(&mut self, i: usize) {
let id = self.boats[i].id;
if self.riding == Some(id) && !self.native_may_leave(id) {
return;
}
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;
@@ -1235,6 +1246,9 @@ impl Game<'_> {
{
return false;
}
if !self.native_may_board(self.boats[i].id) {
return true;
}
self.riding = Some(self.boats[i].id);
self.yaw = self.boats[i].yaw;
// ServerPlayer.startRiding.
@@ -1300,6 +1314,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
}
+22 -1
View File
@@ -162,6 +162,22 @@ impl Game<'_> {
u32::MAX,
);
}
// A native mod's own icons (`native_map_decorations`).
for d in self.mod_map_decorations(id, true) {
let Some(s) = self.gui.atlas.sprites.get(d.sprite.as_str()).copied() else {
continue;
};
batch.quad(
pose_corners(
super::maps::decoration_pose_at(d.x, d.y, d.rot, 0.0),
x,
y,
scale,
),
[[s.u0, s.v0], [s.u1, s.v0], [s.u1, s.v1], [s.u0, s.v1]],
u32::MAX,
);
}
}
}
@@ -169,9 +185,14 @@ impl Game<'_> {
/// `scale` GUI pixels per map pixel: (-1, 1), (1, 1), (1, -1), (-1, -1)
/// under `MapRenderer.render`'s pose.
fn decoration_corners(d: &super::maps::Decoration, x: f32, y: f32, scale: f32) -> [[f32; 2]; 4] {
pose_corners(super::maps::decoration_pose(d, 0.0), x, y, scale)
}
/// The corners of a decoration drawn under `pose` (`decoration_pose`), like
/// [`decoration_corners`].
fn pose_corners(pose: glam::Mat4, x: f32, y: f32, scale: f32) -> [[f32; 2]; 4] {
let m = glam::Mat4::from_translation(glam::Vec3::new(x, y, 0.0))
* glam::Mat4::from_scale(glam::Vec3::new(scale, scale, 1.0))
* super::maps::decoration_pose(d, 0.0);
* pose;
[(-1.0, 1.0), (1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)].map(|(cx, cy)| {
let p = m.transform_point3(glam::Vec3::new(cx, cy, 0.0));
[p.x, p.y]
+1 -2
View File
@@ -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 {
+124 -26
View File
@@ -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
}
@@ -1234,8 +1234,13 @@ impl Game<'_> {
return Err(generic());
}
if let Some(rider) = rider {
self.mount_mob(rider, id);
if !self.try_mount_mob(rider, id) {
return Err(generic());
}
} else {
if !self.native_may_board(id) {
return Err(generic());
}
self.riding = Some(id);
self.yaw = self.boats[i].yaw;
// sendEventAndTriggers: START_RIDING_TRIGGER.
@@ -1248,7 +1253,10 @@ impl Game<'_> {
return Ok(1);
}
Target::Cart(id) => {
if target != Target::Player || self.ridden_by_guest(id) {
if target != Target::Player
|| self.ridden_by_guest(id)
|| !self.native_may_board(id)
{
return Err(generic());
}
self.riding = Some(id);
@@ -1270,13 +1278,18 @@ impl Game<'_> {
}
if let Some(rider) = rider {
// Entity.startRiding.
self.mount_mob(rider, mob_id);
if !self.try_mount_mob(rider, mob_id) {
return Err(generic());
}
self.feedback(Text::translate(
"commands.ride.mount.success",
&[name, vehicle_name],
));
return Ok(1);
}
if !self.native_may_board(mob_id) {
return Err(generic());
}
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob_id) {
m.extra.flag = false;
m.extra.counter = 0;
@@ -1314,9 +1327,13 @@ impl Game<'_> {
let Some(i) = self.mobs.iter().position(|m| m.id == rider) else {
return;
};
let Some(v) = self.mobs[i].extra.mount.take() else {
let Some(v) = self.mobs[i].extra.mount else {
return;
};
if !self.native_mob_may_ride(rider, v, false) {
return;
}
self.mobs[i].extra.mount = None;
let (width, yaw) = (f64::from(self.mobs[i].width()), self.mobs[i].yaw);
let at = if let Some(b) = self.boats.iter().position(|b| b.id == v) {
self.boat_dismount_target(b, width, yaw)
@@ -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" {
+115 -12
View File
@@ -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());
}
}
+90 -32
View File
@@ -2,7 +2,7 @@
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
use super::{Dim, Game, InteractTarget, dispenser, view_vector};
use super::{Dim, Game, InteractTarget, dispenser, native_projectiles::Impact, view_vector};
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
Result,
@@ -147,7 +147,7 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
const WIND_CHARGE_IDS: u32 = super::dragon::SPECIAL - 0x10000;
/// The index of the wind charge a `wind_charge_hitboxes` id stands for.
fn wind_charge_hit(id: u32) -> Option<usize> {
pub(super) fn wind_charge_hit(id: u32) -> Option<usize> {
(WIND_CHARGE_IDS..super::dragon::SPECIAL)
.contains(&id)
.then(|| (id - WIND_CHARGE_IDS) as usize)
@@ -3411,24 +3411,12 @@ impl Game<'_> {
let mut deflections: Vec<[f64; 3]> = Vec::new();
let mut arrow_events = Vec::new();
let mut trails = Vec::new();
let mut keep_flags = vec![true; projectiles.len()];
// The impacts the mods may cancel (`native_projectile_impact`), on a server.
let hear = self.native_hears_impacts();
let mut impacts: Vec<Impact> = Vec::new();
{
let mut targets: Vec<(Who, Aabb)> = self
.mobs
.iter()
.filter(|m| m.alive())
.map(|m| (Who::Mob(m.id), m.bounds()))
.collect();
if let Some(p) = self.player_view()
&& p.alive
{
targets.push((Who::Player, p.bounds));
}
// Dragon parts and end crystals under reserved ids (game/dragon.rs).
targets.extend(
self.special_targets()
.into_iter()
.map(|(id, b)| (Who::Mob(id), b)),
);
let targets = self.projectile_targets();
let breezes: Vec<u32> = self
.mobs
.iter()
@@ -3472,9 +3460,12 @@ impl Game<'_> {
.map(|g| g.conn)
.collect()
});
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()
@@ -3488,6 +3479,8 @@ impl Game<'_> {
keep_flags[i] = false;
continue;
}
// The projectile as it was, to fly on if a mod cancels its impact.
let before = (hear && !p.stuck).then(|| p.clone());
let mob_shot = !matches!(p.owner, Some(Who::Player));
// A player's arrows, snowballs and eggs meet every player; a
// guest's flies from that guest (Projectile.getOwner), not
@@ -3502,10 +3495,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) => {
@@ -3543,6 +3545,7 @@ impl Game<'_> {
&mut p.velocity,
);
}
let events_from = arrow_events.len();
if let Some(a) = p.arrow.as_mut() {
arrow_events.extend(a.events.drain(..).map(|e| (p.position, e)));
}
@@ -3581,6 +3584,15 @@ impl Game<'_> {
continue;
}
if hit != Hit::None {
if let Some(before) = before {
impacts.push(Impact {
index: i,
before,
hit,
hit_slot: hits.len(),
events: events_from..arrow_events.len(),
});
}
hits.push((p.clone(), hit));
}
keep_flags[i] = keep;
@@ -3596,12 +3608,22 @@ impl Game<'_> {
super::deflect::aim_deflect(wind, look, owner, owner_uuid, guest);
}
}
let mut idx = 0;
projectiles.retain(|_| {
let k = keep_flags[idx];
idx += 1;
k
});
}
let listed = keep_flags.clone();
let mut idx = 0;
projectiles.retain(|_| {
let k = keep_flags[idx];
idx += 1;
k
});
if !impacts.is_empty() {
self.native_settle_impacts(
&mut projectiles,
&listed,
impacts,
&mut hits,
&mut arrow_events,
);
}
self.smoke_trails(trails);
for at in deflections {
@@ -3872,11 +3894,32 @@ impl Game<'_> {
}
}
/// `Level.isRainingAt` for arrows.
fn arrow_rain(&self) -> impl Fn(BlockPos) -> bool + '_ {
/// What a projectile can hit: the mobs, the player, the dragon's parts and
/// the End crystals (under reserved ids, game/dragon.rs).
pub(super) fn projectile_targets(&self) -> Vec<(Who, Aabb)> {
let mut targets: Vec<(Who, Aabb)> = self
.mobs
.iter()
.filter(|m| m.alive())
.map(|m| (Who::Mob(m.id), m.bounds()))
.collect();
if let Some(p) = self.player_view()
&& p.alive
{
targets.push((Who::Player, p.bounds));
}
targets.extend(
self.special_targets()
.into_iter()
.map(|(id, b)| (Who::Mob(id), b)),
);
targets
}
pub(super) fn arrow_rain(&self) -> impl Fn(BlockPos) -> bool + '_ {
move |p: BlockPos| self.raining_at(p)
}
/// The level as arrows see it.
fn arrow_scene<'s>(
pub(super) fn arrow_scene<'s>(
&'s self,
rain: &'s dyn Fn(BlockPos) -> bool,
) -> horizon_client::arrows::ArrowScene<'s, 's> {
@@ -4133,6 +4176,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 +5938,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 {
[
+71 -7
View File
@@ -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));
+9 -1
View File
@@ -338,6 +338,9 @@ impl Game<'_> {
}
// `Entity.startRiding`: a passenger first gets off what it rides
// (`stopRiding`), so a seated player moves to this cushion.
if !self.native_may_board(id) {
return true;
}
self.stop_riding();
self.riding = Some(id);
let p = self.cushions[i].position;
@@ -359,8 +362,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);
@@ -452,6 +457,9 @@ impl Game<'_> {
/// and `Cushion.removePassenger`'s get-up sound for a rider that left
/// rather than one whose cushion was killed.
pub(super) fn get_up_from_cushion(&mut self, c: &Cushion) {
if self.riding == Some(c.id) && !self.native_may_leave(c.id) {
return;
}
let at = c.dismount();
self.riding = None;
if let Some(p) = self.player.as_mut() {
+12 -1
View File
@@ -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] });
}
+30 -4
View File
@@ -27,7 +27,7 @@ use horizon_world::nbt::Tag;
/// Mob ids from here on are dragon parts (`SPECIAL + part`) and crystals
/// (`SPECIAL + 16 + crystal`), so projectiles can target them.
pub(super) const SPECIAL: u32 = u32::MAX - 4096;
const CRYSTAL_IDS: u32 = SPECIAL + 16;
pub(super) const CRYSTAL_IDS: u32 = SPECIAL + 16;
const FIGHT_FILE: &str = "data/minecraft/ender_dragon_fight.dat";
const MAX_HEALTH: f32 = 200.0;
/// `Mob.getAmbientSoundInterval`.
@@ -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);
}
}
+38 -3
View File
@@ -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.
+5 -5
View File
@@ -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) {
+6
View File
@@ -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();
+27 -9
View File
@@ -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()
};
+3
View File
@@ -90,6 +90,9 @@ impl Game<'_> {
if harnessed && !sneaking {
// doPlayerRide.
let id = self.mobs[index].id;
if !self.native_may_board(id) {
return true;
}
self.break_leash(id, true, false);
let was_empty = self.mobs[index].rider.is_none();
// addPassenger: the rider is not standing on top, so no still
@@ -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,
+6 -1
View File
@@ -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");
+1 -1
View File
@@ -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;
}
+219 -8
View File
@@ -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() {
+25
View File
@@ -2334,6 +2334,15 @@ impl Game<'_> {
.take()
.expect("the hub is back after the menu events");
}
host.hub.collect_tracking();
if !host.hub.native_tracking.is_empty() {
self.lan = Some(host);
self.run_native_tracking();
host = self
.lan
.take()
.expect("the hub is back after the tracking events");
}
if !paused {
self.tick_guests(&mut host);
}
@@ -2725,6 +2734,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
+30 -5
View File
@@ -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 {
+28 -5
View File
@@ -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 {
@@ -83,6 +83,42 @@ fn stand_poses(a: &horizon_client::entities::ArmorStand) -> Vec<(u8, i32, Value)
}
impl Game<'_> {
/// The entities players started and stopped tracking since the last
/// tick, told to the mods in order with the hub back in `self.lan` and
/// no player's state swapped in (`ServerEntity.addPairing` and
/// `removePairing`).
pub(super) fn run_native_tracking(&mut self) {
let batches = match self.lan.as_mut() {
Some(host) => std::mem::take(&mut host.hub.native_tracking),
None => return,
};
if self.remote.is_some() || self.options.execution_role.is_client() {
return;
}
for batch in batches {
for e in batch.events {
let (id, kind) = (e.entity as u32, &*e.tracked.kind);
if e.start {
self.native_player_start_tracking(
batch.player,
&batch.name,
id,
e.tracked.uuid,
kind,
);
} else {
self.native_player_stop_tracking(
batch.player,
&batch.name,
id,
e.tracked.uuid,
kind,
);
}
}
}
}
/// Who rides what (`Entity.getPassengers`): mobs on mobs, and the
/// host in its boat or minecart.
pub(super) fn lan_riding(
+1 -1
View File
@@ -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();
+27 -2
View File
@@ -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.
+3 -3
View File
@@ -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"
);
}
}
+55 -1
View File
@@ -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<()> {
Loaded 100 of 347 files, more files were not shown because too many files have changed in this diff. Show more