mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge remote-tracking branch 'origin/main' into fix/items-stuck-in-web
This commit is contained in:
commit
ee3f1787ff
347 files changed
+17316
-1856
No files matched your search
@@ -150,7 +150,7 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
|
||||
@@ -30,6 +30,7 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
|
||||
6 hours ([docs/release-flow.md](docs/release-flow.md)).
|
||||
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
|
||||
- **No hardcoded user-facing strings.** Keep UI translation-ready from the start. In the launcher (`launcher/`), wrap visible text with `tr("...")` or `trf("... {arg} ...", &[("arg", &val)])` from `crate::i18n` instead of raw string literals or `format!`. In the game, use translation keys (`Text::tr`, `lang::get`), never hardcoded English strings.
|
||||
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
|
||||
issue is taken: pick another. `/unclaim` when you stop; a claim with no open PR linking it is released after 5 days.
|
||||
|
||||
@@ -39,7 +40,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
|
||||
| Crate | Owns |
|
||||
|---|---|
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~100 flat modules: gameplay, networking, title screens) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`) and thin executable (`src/main.rs`). Also the dedicated server (`--dedicated`). Re-exports `horizon-client-lib` for existing mod paths. |
|
||||
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
|
||||
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
|
||||
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
|
||||
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
|
||||
@@ -84,8 +86,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
|
||||
```sh
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
@@ -102,8 +104,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
|
||||
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
|
||||
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
|
||||
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
|
||||
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
|
||||
- Harness selection uses Cargo dep-info and reachable-code narrowing (`tools/parity_reach.py`).
|
||||
`horizon-client-lib` builds the control tool and client probes without gameplay; their dep-info
|
||||
excludes `game.rs` and `game/`, so gameplay edits do not rebuild or select those probes.
|
||||
Source paths remain in `crates/horizon-client/`. Measure actual harness selection on refreshed
|
||||
records with `tools/parity.py --changed-files paths.txt --list`; historical counts are not a guarantee.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
@@ -128,8 +133,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
## horizon-client: where things live
|
||||
|
||||
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
|
||||
the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
|
||||
`Game`; bin code reaches the lib as `horizon_client::<module>`.
|
||||
the same `horizon_client::<module>` paths), `G:` = gameplay (`crates/horizon-client/src/game/`), also in the lib in normal builds.
|
||||
Gameplay keeps its `horizon_client::<module>` imports through a self-crate alias. The executable
|
||||
contains only the allocator, mod API marker and a call to `entry::main`. For unit tests, the lib
|
||||
excludes `entry` and `game`, and the executable test target owns `game` so its process-wide test
|
||||
state stays separate from the library tests. This compiles gameplay twice for executable tests.
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
@@ -151,7 +159,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
@@ -160,7 +168,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
|
||||
|
||||
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
|
||||
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
|
||||
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
|
||||
`impl Game<'_>` block for one feature, with its own state types. Fields stay private to the gameplay module and its descendants. Gameplay tests run in the gameplay library, separate from reusable-library tests.
|
||||
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
|
||||
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
|
||||
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
|
||||
|
||||
Generated
+57
@@ -1314,6 +1314,7 @@ dependencies = [
|
||||
"horizon-chat-filter",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-client-lib",
|
||||
"horizon-client-render",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
@@ -1374,6 +1375,62 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-client-lib"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"flate2",
|
||||
"fs4",
|
||||
"gilrs",
|
||||
"glam",
|
||||
"horizon-anticheat",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-chat-filter",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-client-render",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-dualsense",
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-mods",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
"horizon-world",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"naga",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"open",
|
||||
"png",
|
||||
"pollster",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"unicode-bidi",
|
||||
"ureq",
|
||||
"wgpu",
|
||||
"winit",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-client-render"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -8,6 +8,7 @@ members = [
|
||||
"crates/horizon-hdr",
|
||||
"crates/horizon-world",
|
||||
"crates/horizon-client",
|
||||
"crates/horizon-client-lib",
|
||||
"crates/horizon-client-core",
|
||||
"crates/horizon-client-input",
|
||||
"crates/horizon-client-render",
|
||||
|
||||
@@ -202,10 +202,8 @@ fn os_version() -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cpu() -> Option<String> {
|
||||
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
|
||||
// x86 names the CPU per core; ARM boards name the machine once.
|
||||
key_value(&info, "model name", ':')
|
||||
.or_else(|| key_value(&info, "Model", ':'))
|
||||
.map(str::to_owned)
|
||||
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
|
||||
horizon_telemetry::system::linux_cpu_name(&info)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -227,7 +225,7 @@ fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn os_version() -> Option<String> {
|
||||
output("cmd", &["/C", "ver"])
|
||||
horizon_telemetry::system::windows_version()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -73,8 +73,11 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
|
||||
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
|
||||
in the same tick (a multi-target kill aura, for the anticheat's tests);
|
||||
the report gives each bot's last health
|
||||
the report gives each bot's last health. A UUID instead of a NAME: the
|
||||
entity with that UUID (a boat summoned with it, say)
|
||||
--attack-after S start attacking S seconds after joining; default 5
|
||||
--attack-rounds N stop after N rounds of attacks (each hits every target seen so far once);
|
||||
default no limit
|
||||
--no-swing attack without swinging (a kill aura hiding its swing, for the anticheat's tests)
|
||||
--no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
|
||||
ignores the player's moves and actions for its first 3 seconds
|
||||
@@ -153,9 +156,11 @@ struct Options {
|
||||
seed: u64,
|
||||
explore_speed: f64,
|
||||
pvp: bool,
|
||||
/// The player to attack, and how long after joining to start.
|
||||
/// The players or entity UUIDs to attack, how long after joining to start,
|
||||
/// and how many rounds.
|
||||
attack: Option<String>,
|
||||
attack_after: f64,
|
||||
attack_rounds: u32,
|
||||
no_swing: bool,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
@@ -190,6 +195,7 @@ fn options() -> Result<Options, String> {
|
||||
pvp: true,
|
||||
attack: None,
|
||||
attack_after: 5.0,
|
||||
attack_rounds: u32::MAX,
|
||||
no_swing: false,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
@@ -247,6 +253,7 @@ fn options() -> Result<Options, String> {
|
||||
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
|
||||
"--attack" => o.attack = Some(value),
|
||||
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
|
||||
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
"--brand" => o.brand = Some(value),
|
||||
@@ -401,6 +408,16 @@ fn join_summary(mut times: Vec<u64>) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// An `--attack` target's UUID: a UUID as written (`8-4-4-4-12` hex), else
|
||||
/// the offline UUID of the player with that name (a name has no `-`).
|
||||
fn target_uuid(name: &str) -> u128 {
|
||||
let hex: String = name.chars().filter(|&c| c != '-').collect();
|
||||
match u128::from_str_radix(&hex, 16) {
|
||||
Ok(uuid) if name.contains('-') && hex.len() == 32 => uuid,
|
||||
_ => offline_uuid(name),
|
||||
}
|
||||
}
|
||||
|
||||
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
|
||||
struct Random(u64);
|
||||
impl Random {
|
||||
@@ -984,7 +1001,7 @@ fn run_bot(
|
||||
.attack
|
||||
.iter()
|
||||
.flat_map(|names| names.split(','))
|
||||
.map(|name| (offline_uuid(name), None))
|
||||
.map(|name| (target_uuid(name), None))
|
||||
.collect();
|
||||
bot.online.store(true, Ordering::SeqCst);
|
||||
{
|
||||
@@ -1039,6 +1056,7 @@ fn run_bot(
|
||||
send(play::Serverbound::SetCarriedItem(play::SetCarriedItem { slot: 0 }).encode());
|
||||
}
|
||||
let mut sequence = 0;
|
||||
let mut rounds = 0;
|
||||
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
|
||||
let mut pos = spawn;
|
||||
// Fight bots stay at their spawn height; the others climb to fly above the terrain.
|
||||
@@ -1202,8 +1220,13 @@ fn run_bot(
|
||||
send(body);
|
||||
}
|
||||
}
|
||||
if joined.elapsed().as_secs_f64() >= o.attack_after && tick % 10 == 0 {
|
||||
if joined.elapsed().as_secs_f64() >= o.attack_after
|
||||
&& tick % 10 == 0
|
||||
&& rounds < o.attack_rounds
|
||||
{
|
||||
let targets: Vec<i32> = lock(&bot.target).iter().filter_map(|t| t.1).collect();
|
||||
// A round counts once a target is known.
|
||||
rounds += u32::from(!targets.is_empty());
|
||||
for id in targets {
|
||||
send(play::Serverbound::Attack(play::Attack { id }).encode());
|
||||
// Minecraft.startAttack swings right after the attack.
|
||||
@@ -1446,6 +1469,20 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
/// `--attack`: a UUID names that entity, anything else a player.
|
||||
#[test]
|
||||
fn attack_targets_a_uuid_or_a_player() {
|
||||
assert_eq!(target_uuid("00000000-0000-0000-0000-00000000000a"), 10);
|
||||
assert_eq!(
|
||||
target_uuid("0000000a-0000-0001-0000-000000000002"),
|
||||
0x0000000a_0000_0001_0000_000000000002
|
||||
);
|
||||
assert_eq!(target_uuid("V0"), offline_uuid("V0"));
|
||||
// A name of 32 hex digits is a name: a UUID is written with dashes.
|
||||
let name = "0123456789abcdef0123456789abcdef";
|
||||
assert_eq!(target_uuid(name), offline_uuid(name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_living_entities_only() {
|
||||
let mut e = Entities::default();
|
||||
|
||||
@@ -72,6 +72,19 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/tr_tr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
|
||||
}
|
||||
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
|
||||
}
|
||||
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
|
||||
"assets/horizon/lang/de_ch.json" => {
|
||||
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
|
||||
return Some(de.replace('ß', "ss").into_bytes());
|
||||
}
|
||||
"assets/horizon/lang/fr_fr.json"
|
||||
| "assets/horizon/lang/fr_ca.json"
|
||||
| "assets/horizon/lang/fr_ch.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
|
||||
}
|
||||
"assets/horizon/lang/es_es.json"
|
||||
| "assets/horizon/lang/es_ar.json"
|
||||
| "assets/horizon/lang/es_cl.json"
|
||||
@@ -316,6 +329,20 @@ mod tests {
|
||||
assert_eq!(tr.get("horizon.options.controller"), Some("Kontrolcü"));
|
||||
let es = Language::load(&[&empty], "es_es");
|
||||
assert_eq!(es.get("horizon.options.controller"), Some("Mando"));
|
||||
// German and French, and their regional variants on the same file
|
||||
// (Swiss German without "ß").
|
||||
let de = Language::load(&[&empty], "de_de");
|
||||
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
|
||||
let fr = Language::load(&[&empty], "fr_fr");
|
||||
assert_eq!(fr.get("horizon.osk.close"), Some("Fermer"));
|
||||
for (code, base) in [("de_at", &de), ("fr_ca", &fr), ("fr_ch", &fr)] {
|
||||
let variant = Language::load(&[&empty], code);
|
||||
assert_eq!(variant.map(), base.map(), "{code}");
|
||||
}
|
||||
let de_ch = Language::load(&[&empty], "de_ch");
|
||||
assert_eq!(de_ch.get("horizon.osk.close"), Some("Schliessen"));
|
||||
assert!(de_ch.map().values().all(|v| !v.contains('ß')));
|
||||
assert_eq!(de_ch.len(), de.len());
|
||||
let es_ve = Language::load(&[&empty], "es_ve");
|
||||
assert_eq!(
|
||||
es_ve.get("horizon.options.on_screen_keyboard"),
|
||||
@@ -323,6 +350,148 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `crates/horizon-client/assets/horizon/lang`, where Horizon's own strings live.
|
||||
fn bundled_dir() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-client/assets/horizon/lang")
|
||||
}
|
||||
/// The keys of a flat bundled file in file order, duplicates included (a map
|
||||
/// would keep only the last of two equal keys). One `"key": "value"` per line.
|
||||
fn file_keys(body: &str) -> Vec<String> {
|
||||
body.lines()
|
||||
.filter(|line| line.starts_with(" \""))
|
||||
.map(|line| {
|
||||
serde_json::Deserializer::from_str(line.trim_start())
|
||||
.into_iter::<String>()
|
||||
.next()
|
||||
.and_then(Result::ok)
|
||||
.unwrap_or_else(|| panic!("no key on line {line}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The fields the code fills in (`%s`, `%d`, `%1$s`, `%.2f`, `%%`), sorted. A
|
||||
/// percent sign that starts none of them ("100% is") is plain text.
|
||||
fn placeholders(text: &str) -> Vec<String> {
|
||||
let mut found = Vec::new();
|
||||
let mut rest = text;
|
||||
while let Some(at) = rest.find('%') {
|
||||
rest = &rest[at + 1..];
|
||||
let spec = rest
|
||||
.find(|c: char| !(c.is_ascii_digit() || c == '$' || c == '.'))
|
||||
.unwrap_or(rest.len());
|
||||
let after = rest[spec..].chars().next();
|
||||
if after.is_some_and(|c| matches!(c, 's' | 'd' | 'f' | '%')) {
|
||||
let end = spec + 1;
|
||||
found.push(format!("%{}", &rest[..end]));
|
||||
rest = &rest[end..];
|
||||
}
|
||||
}
|
||||
found.sort();
|
||||
found
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
|
||||
let dir = bundled_dir();
|
||||
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
|
||||
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
|
||||
let mut seen = 0;
|
||||
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
let Some(code) = name.strip_suffix(".json") else {
|
||||
continue;
|
||||
};
|
||||
seen += 1;
|
||||
// A file that Bundled::read does not name is never loaded.
|
||||
let path = format!("assets/horizon/lang/{name}");
|
||||
assert!(
|
||||
Bundled.read(&path).is_some(),
|
||||
"{name} is not served by Bundled"
|
||||
);
|
||||
let body = std::fs::read_to_string(entry.path()).unwrap();
|
||||
let map: HashMap<String, String> = serde_json::from_str(&body)
|
||||
.unwrap_or_else(|e| panic!("{name} is not a flat string map: {e}"));
|
||||
let keys = file_keys(&body);
|
||||
assert_eq!(
|
||||
keys.len(),
|
||||
map.len(),
|
||||
"{name} repeats a key or has a multi-line entry"
|
||||
);
|
||||
for (key, value) in &map {
|
||||
// Stale: the code no longer asks for it, or it was renamed in en_us only.
|
||||
let english = en
|
||||
.get(key)
|
||||
.unwrap_or_else(|| panic!("{code}: {key} is not in en_us.json"));
|
||||
// A translation keeps the fields the code fills in.
|
||||
assert_eq!(
|
||||
placeholders(value),
|
||||
placeholders(english),
|
||||
"{code}: {key} changes the placeholders of the English text"
|
||||
);
|
||||
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
seen >= 7,
|
||||
"found {seen} bundled lang files in {}",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_horizon_key_the_code_names_is_in_en_us() {
|
||||
fn rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
rust_files(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let en: HashMap<String, String> = serde_json::from_str(
|
||||
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let mut files = Vec::new();
|
||||
for krate in [
|
||||
"horizon-client",
|
||||
"horizon-client-render",
|
||||
"horizon-client-core",
|
||||
] {
|
||||
rust_files(&crates.join(krate).join("src"), &mut files);
|
||||
}
|
||||
assert!(files.len() > 100, "found only {} sources", files.len());
|
||||
let mut found = 0;
|
||||
for file in files {
|
||||
let text = std::fs::read_to_string(&file).unwrap();
|
||||
for (at, _) in text.match_indices("\"horizon.") {
|
||||
let rest = &text[at + 1..];
|
||||
let Some(end) = rest.find('"') else { continue };
|
||||
let key = &rest[..end];
|
||||
// A key is `horizon.<group>.<name>`: lower-case letters, digits and `_`
|
||||
// between the dots. A key built at run time has a brace; a file name
|
||||
// has one dot; the server addresses that look like keys are listed.
|
||||
const NOT_KEYS: &[&str] = &["horizon.bunchy7s.dev"];
|
||||
let key_like = key
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_.".contains(&b));
|
||||
if key.matches('.').count() < 2 || !key_like || NOT_KEYS.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
found += 1;
|
||||
assert!(
|
||||
en.contains_key(key),
|
||||
"{} names {key}, which is not in en_us.json",
|
||||
file.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(found > 50, "found only {found} key names");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_selected_language_over_english() {
|
||||
let base = Mem(HashMap::from([
|
||||
|
||||
@@ -483,6 +483,21 @@ impl KeyBinds {
|
||||
.find(|mapping| self.get(mapping) == name)
|
||||
.and_then(|mapping| key_code(Self::default_of(mapping)))
|
||||
}
|
||||
/// `AbstractContainerScreen.checkHotbarKeyPressed`: the off hand and the
|
||||
/// hotbar slots, as the controls bind them now. Answers the key the menu
|
||||
/// acts on (`KeyF` for the off hand, `DigitN` for a slot), so a rebound key
|
||||
/// swaps instead of reaching the screen.
|
||||
pub fn container_hotbar_key(&self, key: KeyCode) -> Option<KeyCode> {
|
||||
let name = key_name(key)?;
|
||||
if self.get("key.swapOffhand") == name {
|
||||
return Some(KeyCode::KeyF);
|
||||
}
|
||||
MAPPINGS
|
||||
.iter()
|
||||
.filter(|m| m.0.starts_with("key.hotbar."))
|
||||
.find(|m| self.get(m.0) == name)
|
||||
.and_then(|m| key_code(Self::default_of(m.0)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -519,6 +534,29 @@ mod tests {
|
||||
assert_eq!(keys.container_mouse_key(MouseButton::Forward, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_hotkeys_follow_the_bound_keys() {
|
||||
let mut keys = KeyBinds::default();
|
||||
assert_eq!(
|
||||
keys.container_hotbar_key(KeyCode::KeyF),
|
||||
Some(KeyCode::KeyF)
|
||||
);
|
||||
assert_eq!(
|
||||
keys.container_hotbar_key(KeyCode::Digit3),
|
||||
Some(KeyCode::Digit3)
|
||||
);
|
||||
assert_eq!(keys.container_hotbar_key(KeyCode::Tab), None);
|
||||
// The report's key: bound to the off hand, the menu takes it first.
|
||||
keys.set("key.swapOffhand", "key.keyboard.tab");
|
||||
assert_eq!(keys.container_hotbar_key(KeyCode::Tab), Some(KeyCode::KeyF));
|
||||
assert_eq!(keys.container_hotbar_key(KeyCode::KeyF), None);
|
||||
keys.set("key.hotbar.3", "key.keyboard.t");
|
||||
assert_eq!(
|
||||
keys.container_hotbar_key(KeyCode::KeyT),
|
||||
Some(KeyCode::Digit3)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebinding_translates_to_defaults() {
|
||||
let mut k = KeyBinds::default();
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
[package]
|
||||
name = "horizon-client-lib"
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
description = "Reusable client systems, control tool and parity probes"
|
||||
|
||||
[lib]
|
||||
path = "../horizon-client/src/client_lib.rs"
|
||||
|
||||
[dependencies]
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-client-input = { path = "../horizon-client-input" }
|
||||
horizon-client-render = { path = "../horizon-client-render" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
# server.properties, eula.txt and the user lists, shared with horizon-server.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
|
||||
horizon-anticheat = { path = "../horizon-anticheat" }
|
||||
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
|
||||
horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
|
||||
horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
# Their runtime is the `mods` feature.
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
bytemuck = { version = "1.24", features = ["derive"] }
|
||||
glam = "0.30"
|
||||
png = "0.18"
|
||||
sha2 = "0.10.9"
|
||||
# Server resource packs are checked and cached by their SHA-1, as vanilla does.
|
||||
sha1 = "0.10"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
|
||||
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
miniz_oxide = "0.8"
|
||||
ab_glyph = "0.2.32"
|
||||
log = "0.4"
|
||||
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
|
||||
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
|
||||
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
gilrs = { version = "0.11", optional = true }
|
||||
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
|
||||
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
|
||||
# HTTPS to the Minecraft services (online-mode session join).
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
unicode-bidi = "0.3.18"
|
||||
# `LevelStorageAccess.estimateDiskSpace`: usable disk space, without unsafe code on Windows.
|
||||
fs4 = { version = "1.1.0", default-features = false, features = ["sync"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Use the registered URI handler without routing links through Explorer/cmd.
|
||||
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
|
||||
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
|
||||
# on the PATH.
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
|
||||
# `above_menu_bar`); all already in the tree through winit and wgpu.
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
|
||||
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
|
||||
# DirectX 12 driver (`graphicsApi`, graphics.rs).
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
windows-mimalloc = []
|
||||
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
|
||||
windows-segment-heap = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
|
||||
|
||||
[[bin]]
|
||||
name = "horizon-ctl"
|
||||
path = "../horizon-client/src/bin/horizon-ctl/main.rs"
|
||||
|
||||
[[example]]
|
||||
name = "chunk_load_bench"
|
||||
path = "../horizon-client/examples/chunk_load_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "gameplay_decode_profile"
|
||||
path = "../horizon-client/examples/gameplay_decode_profile.rs"
|
||||
|
||||
[[example]]
|
||||
name = "json_load_bench"
|
||||
path = "../horizon-client/examples/json_load_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "mod_port_probe"
|
||||
path = "../horizon-client/examples/mod_port_probe.rs"
|
||||
|
||||
[[example]]
|
||||
name = "model_library_bench"
|
||||
path = "../horizon-client/examples/model_library_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "model_states_bench"
|
||||
path = "../horizon-client/examples/model_states_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "old_world_fixture"
|
||||
path = "../horizon-client/examples/old_world_fixture.rs"
|
||||
|
||||
[[example]]
|
||||
name = "portal_scan_bench"
|
||||
path = "../horizon-client/examples/portal_scan_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "random_tick_eligibility_bench"
|
||||
path = "../horizon-client/examples/random_tick_eligibility_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "resource_memory"
|
||||
path = "../horizon-client/examples/resource_memory.rs"
|
||||
|
||||
[[example]]
|
||||
name = "save_batch_bench"
|
||||
path = "../horizon-client/examples/save_batch_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "shared_json_startup_profile"
|
||||
path = "../horizon-client/examples/shared_json_startup_profile.rs"
|
||||
|
||||
[[example]]
|
||||
name = "sprite_cache_parity"
|
||||
path = "../horizon-client/examples/sprite_cache_parity.rs"
|
||||
|
||||
[[example]]
|
||||
name = "client_probe"
|
||||
path = "../horizon-client/examples/probe/main.rs"
|
||||
|
||||
[[test]]
|
||||
name = "custom_component_api"
|
||||
path = "../horizon-client/tests/custom_component_api.rs"
|
||||
|
||||
[[test]]
|
||||
name = "custom_component_unregistered"
|
||||
path = "../horizon-client/tests/custom_component_unregistered.rs"
|
||||
|
||||
[[test]]
|
||||
name = "custom_component_wire"
|
||||
path = "../horizon-client/tests/custom_component_wire.rs"
|
||||
|
||||
[[test]]
|
||||
name = "forced_chunks"
|
||||
path = "../horizon-client/tests/forced_chunks.rs"
|
||||
|
||||
[[test]]
|
||||
name = "generation_saves"
|
||||
path = "../horizon-client/tests/generation_saves.rs"
|
||||
|
||||
[[test]]
|
||||
name = "lan_bot"
|
||||
path = "../horizon-client/tests/lan_bot.rs"
|
||||
|
||||
[[test]]
|
||||
name = "streaming_meshes"
|
||||
path = "../horizon-client/tests/streaming_meshes.rs"
|
||||
|
||||
[[test]]
|
||||
name = "tall_column"
|
||||
path = "../horizon-client/tests/tall_column.rs"
|
||||
|
||||
[[test]]
|
||||
name = "wide_states"
|
||||
path = "../horizon-client/tests/wide_states.rs"
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -428,32 +428,84 @@ pub enum Item {
|
||||
/// On the Splitscreen settings page.
|
||||
Splitscreen,
|
||||
}
|
||||
/// The Extras page: headers and items.
|
||||
/// The Extras page: headers (language keys) and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
use Item::*;
|
||||
vec![
|
||||
Err("Lighting"),
|
||||
Err("horizon.extras.header.lighting"),
|
||||
Ok(Fullbright),
|
||||
Ok(DynamicLights),
|
||||
Err("Third person"),
|
||||
Err("horizon.extras.header.third_person"),
|
||||
Ok(OwnNameTag),
|
||||
Err("Particles"),
|
||||
Err("horizon.extras.header.particles"),
|
||||
Ok(HideBlockParticles),
|
||||
Err("Inventory"),
|
||||
Err("horizon.extras.header.inventory"),
|
||||
Ok(MouseTweaks),
|
||||
Ok(MouseTweaksWheel),
|
||||
Ok(Sort),
|
||||
Ok(SortKey),
|
||||
Err("Chat"),
|
||||
Err("horizon.extras.header.chat"),
|
||||
Ok(ChatAnimation),
|
||||
Ok(ChatIndicator),
|
||||
Err("Multiplayer"),
|
||||
Err("horizon.extras.header.multiplayer"),
|
||||
Ok(AutoReconnect),
|
||||
Err("Sound"),
|
||||
Err("horizon.extras.header.sound"),
|
||||
Ok(UiSoundReserve),
|
||||
]
|
||||
}
|
||||
impl Item {
|
||||
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
|
||||
pub fn caption_key(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "horizon.extras.fullbright",
|
||||
Item::DynamicLights => "horizon.extras.dynamic_lights",
|
||||
Item::OwnNameTag => "horizon.extras.own_name_tag",
|
||||
Item::HideBlockParticles => "horizon.extras.hide_block_particles",
|
||||
Item::AutoReconnect => "horizon.extras.auto_reconnect",
|
||||
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve",
|
||||
Item::MouseTweaks => "horizon.extras.mouse_tweaks",
|
||||
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel",
|
||||
Item::Sort => "horizon.extras.sort",
|
||||
Item::SortKey => "horizon.extras.sort_key",
|
||||
Item::NoAi => "horizon.extras.no_ai",
|
||||
Item::AgentControl => "horizon.extras.agent_control",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator",
|
||||
}
|
||||
}
|
||||
/// The language key of the description shown as the row's tooltip.
|
||||
pub fn description_key(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "horizon.extras.fullbright.tooltip",
|
||||
Item::DynamicLights => "horizon.extras.dynamic_lights.tooltip",
|
||||
Item::OwnNameTag => "horizon.extras.own_name_tag.tooltip",
|
||||
Item::HideBlockParticles => "horizon.extras.hide_block_particles.tooltip",
|
||||
Item::AutoReconnect => "horizon.extras.auto_reconnect.tooltip",
|
||||
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve.tooltip",
|
||||
Item::MouseTweaks => "horizon.extras.mouse_tweaks.tooltip",
|
||||
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel.tooltip",
|
||||
Item::Sort => "horizon.extras.sort.tooltip",
|
||||
Item::SortKey => "horizon.extras.sort_key.tooltip",
|
||||
Item::NoAi => "horizon.extras.no_ai.tooltip",
|
||||
Item::AgentControl => "horizon.extras.agent_control.tooltip",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
|
||||
}
|
||||
}
|
||||
/// The caption in the current language (English when the key is missing).
|
||||
pub fn caption_text(self) -> String {
|
||||
crate::lang::get_or(self.caption_key(), self.caption())
|
||||
}
|
||||
/// The description in the current language (English when the key is missing).
|
||||
pub fn description_text(self) -> String {
|
||||
crate::lang::get_or(self.description_key(), self.description())
|
||||
}
|
||||
/// The English caption: the fallback of [`Item::caption_text`], and the text of
|
||||
/// `horizon.extras.*` in en_us.json.
|
||||
pub fn caption(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "Fullbright",
|
||||
@@ -489,7 +541,7 @@ impl Item {
|
||||
}
|
||||
})
|
||||
}
|
||||
/// What the row says under its caption.
|
||||
/// The English description: the fallback of [`Item::description_text`].
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "Show blocks and entities at full light, even in dark areas.",
|
||||
@@ -581,8 +633,8 @@ impl Item {
|
||||
match self {
|
||||
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
|
||||
other => match other.switch(e) {
|
||||
Some(true) => "On".into(),
|
||||
_ => "Off".into(),
|
||||
Some(true) => crate::lang::get_or("On", "On"),
|
||||
_ => crate::lang::get_or("Off", "Off"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ mod end_sky;
|
||||
mod flat;
|
||||
mod frame_gen;
|
||||
mod hdr;
|
||||
mod hdr_icc;
|
||||
mod hdr_stats;
|
||||
pub use hdr::Calibration as HdrCalibration;
|
||||
pub mod block_light;
|
||||
@@ -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", ©right),
|
||||
(b"wtpt", &xyz(D50)),
|
||||
(b"rXYZ", &xyz(RED)),
|
||||
(b"gXYZ", &xyz(GREEN)),
|
||||
(b"bXYZ", &xyz(BLUE)),
|
||||
(b"rTRC", &trc),
|
||||
(b"gTRC", &trc),
|
||||
(b"bTRC", &trc),
|
||||
];
|
||||
let data_start = 128 + 4 + 12 * tags.len();
|
||||
let mut table = (tags.len() as u32).to_be_bytes().to_vec();
|
||||
let mut data = Vec::new();
|
||||
let mut trc_at = None;
|
||||
for (name, tag) in tags {
|
||||
// The three channels share one curve.
|
||||
let is_trc = tag == &trc[..];
|
||||
let at = match trc_at.filter(|_| is_trc) {
|
||||
Some(at) => at,
|
||||
None => {
|
||||
let at = data_start + data.len();
|
||||
data.extend(tag);
|
||||
data.resize(data.len().next_multiple_of(4), 0);
|
||||
at
|
||||
}
|
||||
};
|
||||
if is_trc {
|
||||
trc_at = Some(at);
|
||||
}
|
||||
table.extend(name);
|
||||
table.extend((at as u32).to_be_bytes());
|
||||
table.extend((tag.len() as u32).to_be_bytes());
|
||||
}
|
||||
let mut out = vec![0; 128];
|
||||
let size = data_start + data.len();
|
||||
out[0..4].copy_from_slice(&(size as u32).to_be_bytes());
|
||||
out[8..12].copy_from_slice(&[2, 0x40, 0, 0]);
|
||||
out[12..16].copy_from_slice(b"mntr");
|
||||
out[16..20].copy_from_slice(b"RGB ");
|
||||
out[20..24].copy_from_slice(b"XYZ ");
|
||||
out[36..40].copy_from_slice(b"acsp");
|
||||
// Relative colorimetric, and the connection space's illuminant.
|
||||
out[64..68].copy_from_slice(&1u32.to_be_bytes());
|
||||
for (i, &c) in D50.iter().enumerate() {
|
||||
out[68 + 4 * i..72 + 4 * i].copy_from_slice(&s15_fixed16(c));
|
||||
}
|
||||
out.extend(table);
|
||||
out.extend(data);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tag<'a>(profile: &'a [u8], name: &[u8; 4]) -> &'a [u8] {
|
||||
let u32_at = |i: usize| u32::from_be_bytes(profile[i..i + 4].try_into().unwrap()) as usize;
|
||||
let at = (0..u32_at(128))
|
||||
.map(|i| 132 + 12 * i)
|
||||
.find(|&e| &profile[e..e + 4] == name)
|
||||
.unwrap();
|
||||
&profile[u32_at(at + 4)..u32_at(at + 4) + u32_at(at + 8)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_profile_is_whole() {
|
||||
let p = profile();
|
||||
assert_eq!(
|
||||
u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize,
|
||||
p.len()
|
||||
);
|
||||
assert_eq!(&p[36..40], b"acsp");
|
||||
assert_eq!(p.len() % 4, 0);
|
||||
assert_eq!(&tag(&p, b"desc")[0..4], b"desc");
|
||||
// One curve, shared: the profile stays small.
|
||||
assert_eq!(tag(&p, b"rTRC").as_ptr(), tag(&p, b"bTRC").as_ptr());
|
||||
assert!(p.len() < 10_000, "{}", p.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_primaries_add_up_to_white() {
|
||||
for i in 0..3 {
|
||||
assert!((RED[i] + GREEN[i] + BLUE[i] - D50[i]).abs() < 0.0001);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_reaches_white_at_reference_white_and_stays() {
|
||||
let p = profile();
|
||||
let trc = tag(&p, b"rTRC");
|
||||
let value = |signal: f64| {
|
||||
let i = 12 + 2 * (signal * (STEPS - 1) as f64).round() as usize;
|
||||
u16::from_be_bytes([trc[i], trc[i + 1]])
|
||||
};
|
||||
assert_eq!(value(0.0), 0);
|
||||
// 203 nits in PQ is a signal of 0.5807.
|
||||
assert!(value(0.57) < 65535);
|
||||
assert_eq!(value(0.59), 65535);
|
||||
assert_eq!(value(1.0), 65535);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -287,6 +287,36 @@ impl FarDetail {
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame generation: a frame made between each two real ones.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum FrameGeneration {
|
||||
#[default]
|
||||
Off,
|
||||
/// AMD FidelityFX Super Resolution 3.1's frame interpolation, ported to WGSL.
|
||||
Fsr31,
|
||||
}
|
||||
impl FrameGeneration {
|
||||
pub const ALL: [FrameGeneration; 2] = [FrameGeneration::Off, FrameGeneration::Fsr31];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
FrameGeneration::Off => "off",
|
||||
FrameGeneration::Fsr31 => "fsr3.1",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FrameGeneration::Off => "Off",
|
||||
FrameGeneration::Fsr31 => "FSR 3.1",
|
||||
}
|
||||
}
|
||||
fn parse(s: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|f| f.name() == s)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// What the section mesher does with leaf faces inside a canopy. Terrain
|
||||
/// geometry only: entities, particles and block entities are untouched.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -542,6 +572,8 @@ pub struct Graphics {
|
||||
pub upscaling: Upscaling,
|
||||
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
|
||||
pub screenshot_quality: u32,
|
||||
/// Frame generation (Horizon setting), off by default.
|
||||
pub frame_generation: FrameGeneration,
|
||||
/// HDR output (HDR10, or scRGB), where the display path offers it.
|
||||
pub hdr: bool,
|
||||
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
|
||||
@@ -604,6 +636,7 @@ impl Default for Graphics {
|
||||
taa: false,
|
||||
upscaling: Upscaling::Native,
|
||||
screenshot_quality: 1,
|
||||
frame_generation: FrameGeneration::Off,
|
||||
hdr: false,
|
||||
hdr_paper_white: 0,
|
||||
hdr_peak: 0,
|
||||
@@ -685,6 +718,7 @@ impl Graphics {
|
||||
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
|
||||
.unwrap_or(1)
|
||||
}
|
||||
"frameGeneration" => g.frame_generation = FrameGeneration::parse(value),
|
||||
"hdr" => g.hdr = flag(g.hdr),
|
||||
"hdrPaperWhite" => {
|
||||
g.hdr_paper_white = match number(g.hdr_paper_white) {
|
||||
@@ -722,7 +756,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -753,6 +787,7 @@ impl Graphics {
|
||||
bool_text(self.taa),
|
||||
self.upscaling.name(),
|
||||
self.screenshot_quality,
|
||||
self.frame_generation.name(),
|
||||
bool_text(self.hdr),
|
||||
self.hdr_paper_white,
|
||||
self.hdr_peak,
|
||||
@@ -903,6 +938,7 @@ mod tests {
|
||||
taa: true,
|
||||
upscaling: Upscaling::Balanced,
|
||||
screenshot_quality: 4,
|
||||
frame_generation: FrameGeneration::Fsr31,
|
||||
hdr: true,
|
||||
hdr_paper_white: 240,
|
||||
hdr_peak: 1400,
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! input. How they look comes from `graphics_style`.
|
||||
use crate::{
|
||||
graphics::{
|
||||
EntityCullingMode, FarDetail, GraphicsApi, LeafCullingMode, Overlay, Pacing, PresentMode,
|
||||
Preset, Upscaling,
|
||||
EntityCullingMode, FarDetail, FrameGeneration, GraphicsApi, LeafCullingMode, Overlay,
|
||||
Pacing, PresentMode, Preset, Upscaling,
|
||||
},
|
||||
graphics_style::{Cost, STYLE},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
@@ -46,6 +46,7 @@ pub enum H {
|
||||
Antialiasing,
|
||||
Upscaling,
|
||||
ScreenshotQuality,
|
||||
FrameGeneration,
|
||||
Hdr,
|
||||
/// Opens `Level::Hdr`.
|
||||
HdrSettings,
|
||||
@@ -301,6 +302,11 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
None,
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.",
|
||||
),
|
||||
h(
|
||||
H::FrameGeneration,
|
||||
Medium,
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.",
|
||||
),
|
||||
h(
|
||||
H::HdrSettings,
|
||||
None,
|
||||
@@ -581,6 +587,7 @@ impl H {
|
||||
H::ScreenshotQuality => {
|
||||
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
|
||||
}
|
||||
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
|
||||
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
|
||||
@@ -611,7 +618,7 @@ impl H {
|
||||
),
|
||||
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
|
||||
H::HdrKey => {
|
||||
Text::literal("Shortcut: ").append(crate::keybinds::display_name(&g.hdr_key))
|
||||
Text::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
|
||||
}
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
|
||||
@@ -704,6 +711,9 @@ impl H {
|
||||
back,
|
||||
)
|
||||
}
|
||||
H::FrameGeneration => {
|
||||
g.frame_generation = step(&FrameGeneration::ALL, g.frame_generation, back)
|
||||
}
|
||||
H::Hdr => g.hdr = !g.hdr,
|
||||
H::BatteryMode => g.battery = !g.battery,
|
||||
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
|
||||
@@ -829,6 +839,7 @@ impl Item {
|
||||
| H::Pacing
|
||||
| H::PresentMode
|
||||
| H::Workers
|
||||
| H::FrameGeneration
|
||||
| H::BatteryMode
|
||||
| H::ShadowCache
|
||||
| H::GraphicsApi
|
||||
@@ -1064,9 +1075,12 @@ impl GraphicsUi {
|
||||
let mut label = item.label(s);
|
||||
if item == Item::Horizon(H::HdrKey) && self.binding.is_some() {
|
||||
// KeyBindsScreen: "> key <" while it waits.
|
||||
label = Text::literal("Shortcut: > ")
|
||||
.append(crate::keybinds::display_name(&s.horizon.hdr_key))
|
||||
.append(" <");
|
||||
label = Text::translate(
|
||||
"Shortcut: %s",
|
||||
&[Text::literal("> ")
|
||||
.append(crate::keybinds::display_name(&s.horizon.hdr_key))
|
||||
.append(" <")],
|
||||
);
|
||||
}
|
||||
let blocked = item.blocked(s);
|
||||
let live = blocked.is_none();
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
//! or Escape leaves them as they were.
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
lang,
|
||||
settings::Settings,
|
||||
text::Text,
|
||||
ui,
|
||||
@@ -64,14 +65,19 @@ impl Step {
|
||||
Step::PaperWhite => (10.0, 480.0, 10.0),
|
||||
}
|
||||
}
|
||||
fn name(self) -> &'static str {
|
||||
/// The English name, which is also its key in the language table.
|
||||
fn name_key(self) -> &'static str {
|
||||
match self {
|
||||
Step::Black => "Black Level",
|
||||
Step::Peak => "Peak Brightness",
|
||||
Step::PaperWhite => "Paper White",
|
||||
}
|
||||
}
|
||||
fn hint(self) -> &'static str {
|
||||
fn name(self) -> String {
|
||||
lang::get(self.name_key())
|
||||
}
|
||||
/// The English hint, which is also its key in the language table.
|
||||
fn hint_key(self) -> &'static str {
|
||||
match self {
|
||||
Step::Black => "Lower it until the logo just disappears into the black.",
|
||||
Step::Peak => "Raise it until the logo just disappears into the bright square.",
|
||||
@@ -139,10 +145,16 @@ impl Calibration {
|
||||
}
|
||||
fn label(&self) -> Text {
|
||||
let v = self.values[self.step];
|
||||
Text::literal(match self.current() {
|
||||
Step::Black => format!("Black Level: {v:.2} nits"),
|
||||
step => format!("{}: {v:.0} nits", step.name()),
|
||||
})
|
||||
let step = self.current();
|
||||
let nits = match step {
|
||||
Step::Black => format!("{v:.2}"),
|
||||
_ => format!("{v:.0}"),
|
||||
};
|
||||
Text::literal(format!(
|
||||
"{}: {}",
|
||||
step.name(),
|
||||
lang::tr("%s nits", &[&nits])
|
||||
))
|
||||
}
|
||||
/// The step, its nits and where the controls start (a share of the height), for
|
||||
/// HDR output's test picture.
|
||||
@@ -160,14 +172,12 @@ impl Calibration {
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, rgba(0, 0, 0, 255));
|
||||
batch.fill(atlas, 0.0, l.top, w, h - l.top, rgba(24, 24, 24, 255));
|
||||
let step = self.current();
|
||||
let title = Text::literal(format!(
|
||||
"HDR Calibration {}/{}: {}",
|
||||
self.step + 1,
|
||||
STEPS.len(),
|
||||
step.name()
|
||||
let title = Text::literal(lang::tr(
|
||||
"HDR Calibration %s/%s: %s",
|
||||
&[&(self.step + 1), &STEPS.len(), &step.name()],
|
||||
));
|
||||
ui::centered_text(batch, atlas, &title, 0.0, l.top + 6.0, w, u32::MAX);
|
||||
let hint = Text::literal(step.hint());
|
||||
let hint = Text::literal(lang::get(step.hint_key()));
|
||||
ui::centered_text(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -192,10 +202,12 @@ impl Calibration {
|
||||
self.dragging,
|
||||
);
|
||||
let last = self.step + 1 == STEPS.len();
|
||||
let save = lang::get_or("selectWorld.edit.save", "Save");
|
||||
let next = lang::get("Next");
|
||||
for (rect, label, active) in [
|
||||
(l.cancel, "Cancel", true),
|
||||
(l.back, "Back", self.step > 0),
|
||||
(l.next, if last { "Save" } else { "Next" }, true),
|
||||
(l.cancel, lang::get_or("gui.cancel", "Cancel"), true),
|
||||
(l.back, lang::get_or("gui.back", "Back"), self.step > 0),
|
||||
(l.next, if last { save } else { next }, true),
|
||||
] {
|
||||
let hover = over(cursor, rect) && active;
|
||||
ui::button(
|
||||
|
||||
@@ -215,6 +215,28 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
|
||||
}
|
||||
(name.clone(), bw, bh, out)
|
||||
}
|
||||
/// Folders of `assets/minecraft/textures/` that [`HudAtlas::pack`] walks for
|
||||
/// the full atlas. A pack may add others through `atlases/blocks.json`.
|
||||
const WALKED_TEXTURE_FOLDERS: &[&str] = &[
|
||||
"gui",
|
||||
"item",
|
||||
"block",
|
||||
"font",
|
||||
"environment",
|
||||
"misc",
|
||||
"painting",
|
||||
"entity",
|
||||
"particle",
|
||||
"mob_effect",
|
||||
"map",
|
||||
];
|
||||
|
||||
fn walked_minecraft_texture(name: &str) -> bool {
|
||||
WALKED_TEXTURE_FOLDERS
|
||||
.iter()
|
||||
.any(|folder| name.starts_with(folder) && name.as_bytes().get(folder.len()) == Some(&b'/'))
|
||||
}
|
||||
|
||||
/// Sprites the atlas definitions (`atlases/*.json`) add beyond the texture
|
||||
/// files already packed: generated ones (`paletted_permutations` such as the
|
||||
/// armour trim sprites, `unstitch` regions) and files listed under another id,
|
||||
@@ -242,11 +264,13 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
|
||||
if have.contains(name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
// A vanilla file is packed under its own name already: only a
|
||||
// renamed one is new. Other namespaces' files (content mods) are
|
||||
// never in the walked directories.
|
||||
// A file under a walked folder is packed under its own name already:
|
||||
// only a renamed one is new. Other namespaces' files (content mods)
|
||||
// are never in those folders, nor are extra directories a pack adds
|
||||
// to the blocks atlas (`custom/...`).
|
||||
if let SpriteOrigin::File(texture) = &origin
|
||||
&& texture.strip_prefix("minecraft:") == Some(name.as_str())
|
||||
&& walked_minecraft_texture(name.as_str())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -349,24 +373,7 @@ impl HudAtlas {
|
||||
/// The atlas of a resource root that layers packs over `defaults` (the
|
||||
/// game's own resources), whose textures give the sprites' GUI sizes.
|
||||
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
|
||||
let mut atlas = Self::pack(
|
||||
root,
|
||||
defaults,
|
||||
&[
|
||||
"gui",
|
||||
"item",
|
||||
"block",
|
||||
"font",
|
||||
"environment",
|
||||
"misc",
|
||||
"painting",
|
||||
"entity",
|
||||
"particle",
|
||||
"mob_effect",
|
||||
"map",
|
||||
],
|
||||
true,
|
||||
)?;
|
||||
let mut atlas = Self::pack(root, defaults, WALKED_TEXTURE_FOLDERS, true)?;
|
||||
atlas.text_shader = pack_text::load(root, defaults);
|
||||
Ok(atlas)
|
||||
}
|
||||
@@ -2398,6 +2405,33 @@ mod tests {
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 255, 0, 255]);
|
||||
}
|
||||
#[test]
|
||||
fn a_pack_directory_outside_the_walked_folders_still_packs() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-custom-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let atlases = root.join("assets/minecraft/atlases");
|
||||
let custom = root.join("assets/minecraft/textures/custom");
|
||||
fs::create_dir_all(&atlases).unwrap();
|
||||
fs::create_dir_all(&custom).unwrap();
|
||||
fs::write(
|
||||
atlases.join("blocks.json"),
|
||||
r#"{"sources":[{"type":"directory","source":"custom","prefix":"custom/"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
write_strip(
|
||||
&custom.join("test.png"),
|
||||
16,
|
||||
16,
|
||||
&[[40, 180, 80, 255]],
|
||||
None,
|
||||
);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let rect = atlas.sprites["custom/test"];
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [40, 180, 80, 255]);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
#[test]
|
||||
fn another_namespaces_animated_item_texture_packs_its_first_frame_and_animates() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-ns-anim-{}", std::process::id()));
|
||||
|
||||
@@ -7,6 +7,7 @@ mod fluid;
|
||||
mod leaf_culling;
|
||||
mod lighting;
|
||||
mod sides;
|
||||
use crate::connected::{Pick, TileLayer};
|
||||
use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::Biomes,
|
||||
@@ -18,6 +19,7 @@ use crate::{
|
||||
use horizon_core::{
|
||||
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
|
||||
};
|
||||
use horizon_optifine::ctm::connect::Face as CtmFace;
|
||||
use horizon_world::{
|
||||
block_render::FaceVisibility,
|
||||
fluid::{FluidKind, FluidState},
|
||||
@@ -1017,6 +1019,32 @@ impl<'a> Tints<'a> {
|
||||
layers: Default::default(),
|
||||
}
|
||||
}
|
||||
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
|
||||
/// picks it.
|
||||
fn biome(&self, x: i32, y: i32, z: i32) -> &'a str {
|
||||
let id = Self::biome_id(&self.zoom, self.region, self.plains, self.origin, [x, y, z]);
|
||||
&self.biomes.get(id).name
|
||||
}
|
||||
/// `BiomeManager.getBiome` over the loaded chunks (plains where there is none), for block
|
||||
/// `(x, y, z)` of the section at `origin`. Takes the fields it reads, so `get` can call it
|
||||
/// while it holds a layer.
|
||||
fn biome_id(
|
||||
zoom: &BiomeZoom,
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
plains: u16,
|
||||
[cx, cz, base_y]: [i32; 3],
|
||||
[x, y, z]: [i32; 3],
|
||||
) -> u16 {
|
||||
let q = zoom.quart_at_block(BlockPos::new(cx * 16 + x, base_y + y, cz * 16 + z));
|
||||
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
|
||||
if (0..3).contains(&rx) && (0..3).contains(&rz) {
|
||||
region[rz as usize][rx as usize].map_or(plains, |c| {
|
||||
c.biome((q[0] & 3) * 4, q[1] * 4, (q[2] & 3) * 4)
|
||||
})
|
||||
} else {
|
||||
plains
|
||||
}
|
||||
}
|
||||
/// The blended tint of block `(x, y, z)` (section-local x/z, y in 0..16).
|
||||
fn get(&mut self, resolver: Resolver, x: i32, y: i32, z: i32) -> u32 {
|
||||
let r = self.radius;
|
||||
@@ -1032,27 +1060,21 @@ impl<'a> Tints<'a> {
|
||||
if cached != UNSET {
|
||||
return cached;
|
||||
}
|
||||
let [cx, cz, base_y] = self.origin;
|
||||
let (bx, by, bz) = (cx * 16, base_y + y, cz * 16);
|
||||
let [cx, cz, _] = self.origin;
|
||||
let (bx, bz) = (cx * 16, cz * 16);
|
||||
let (mut red, mut green, mut blue) = (0, 0, 0);
|
||||
for dz in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let cell = ((z + dz + r) as usize) * dim + (x + dx + r) as usize;
|
||||
let mut biome = layer.zoomed[cell];
|
||||
if biome == NO_BIOME {
|
||||
// BiomeManager.getBiome over the loaded chunks (plains
|
||||
// where there is none).
|
||||
let q = self
|
||||
.zoom
|
||||
.quart_at_block(BlockPos::new(bx + x + dx, by, bz + z + dz));
|
||||
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
|
||||
biome = if (0..3).contains(&rx) && (0..3).contains(&rz) {
|
||||
self.region[rz as usize][rx as usize].map_or(self.plains, |c| {
|
||||
c.biome((q[0] & 3) * 4, q[1] * 4, (q[2] & 3) * 4)
|
||||
})
|
||||
} else {
|
||||
self.plains
|
||||
};
|
||||
biome = Self::biome_id(
|
||||
&self.zoom,
|
||||
self.region,
|
||||
self.plains,
|
||||
self.origin,
|
||||
[x + dx, y, z + dz],
|
||||
);
|
||||
layer.zoomed[cell] = biome;
|
||||
}
|
||||
let c = match resolver {
|
||||
@@ -1242,7 +1264,8 @@ impl Mesher<'_> {
|
||||
origin,
|
||||
face,
|
||||
ao,
|
||||
state,
|
||||
(id, state),
|
||||
world,
|
||||
interior_leaf,
|
||||
);
|
||||
}
|
||||
@@ -1281,7 +1304,8 @@ impl Mesher<'_> {
|
||||
origin: [f32; 3],
|
||||
face: &BakedFace,
|
||||
ao: bool,
|
||||
state: &Info,
|
||||
(id, state): (u32, &Info),
|
||||
world: BlockPos,
|
||||
interior_leaf: bool,
|
||||
) {
|
||||
let q = &face.quad;
|
||||
@@ -1309,50 +1333,265 @@ impl Mesher<'_> {
|
||||
([lighting::gray(directional); 4], [coords; 4])
|
||||
};
|
||||
let tint = Self::tint(state, q.tint_index, tints, pos);
|
||||
if self.tables.is_animated(face.layer as u16) {
|
||||
out.note_sprite_layer(face.layer as u16);
|
||||
}
|
||||
let translucent = face.translucent
|
||||
|| self
|
||||
.translucent_layers
|
||||
.get(face.layer as usize)
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
let target = if translucent {
|
||||
&mut out.translucent
|
||||
// OptiFine connected textures: another sprite, or one per quarter.
|
||||
let connected = &self.render.connected;
|
||||
let pick = if connected.is_empty() {
|
||||
Pick::Keep
|
||||
} else {
|
||||
&mut out.solid
|
||||
let tints = &*tints;
|
||||
connected.pick(
|
||||
id,
|
||||
face.layer as u16,
|
||||
CtmFace::ALL[q.facing as usize],
|
||||
[world.x, world.y, world.z],
|
||||
|[dx, dy, dz]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)],
|
||||
|| Some(tints.biome(pos[0], pos[1], pos[2])),
|
||||
)
|
||||
};
|
||||
// Opaque leaves (forceOpaque), quads over opaque texels and leaves a
|
||||
// leaf-culling mode proved internal draw in the solid layer: no alpha
|
||||
// cutout. All four vertices of the quad share the layer.
|
||||
let layer = if face.opaque && !translucent
|
||||
|| !self.cutout_leaves && state.leaves
|
||||
|| interior_leaf
|
||||
{
|
||||
face.layer as u16 | OPAQUE_LAYER
|
||||
} else {
|
||||
face.layer as u16
|
||||
};
|
||||
for i in 0..4 {
|
||||
let mut corners = [(TerrainVertex::new([0.0; 3], [0.0; 2], 0, 0, 0), [0.0; 2]); 4];
|
||||
for (i, corner) in corners.iter_mut().enumerate() {
|
||||
let g = gray[i] as u32;
|
||||
let rgb = match tint {
|
||||
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
|
||||
None => [g; 3],
|
||||
};
|
||||
let p = q.positions[i];
|
||||
target.push(TerrainVertex::new(
|
||||
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
|
||||
*corner = (
|
||||
TerrainVertex::new(
|
||||
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
|
||||
q.uv[i],
|
||||
0,
|
||||
emitting(pack_rgb(rgb), tier),
|
||||
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
|
||||
),
|
||||
q.uv[i],
|
||||
);
|
||||
}
|
||||
// Opaque leaves (forceOpaque) and leaves a leaf-culling mode proved internal draw in the
|
||||
// solid layer whatever their sprite.
|
||||
let solid_leaves = !self.cutout_leaves && state.leaves || interior_leaf;
|
||||
let own = TileLayer::Layer {
|
||||
layer: face.layer as u16,
|
||||
translucent: face.translucent,
|
||||
opaque: face.opaque,
|
||||
};
|
||||
// A tile keeps the quad's forced translucency; `<skip>` and `<default>` are its own.
|
||||
let tile = |t: TileLayer| match t {
|
||||
TileLayer::Layer {
|
||||
layer,
|
||||
emitting(pack_rgb(rgb), tier),
|
||||
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
|
||||
));
|
||||
translucent,
|
||||
opaque,
|
||||
} => TileLayer::Layer {
|
||||
layer,
|
||||
translucent: translucent || q.material.force_translucent,
|
||||
opaque,
|
||||
},
|
||||
TileLayer::Skip | TileLayer::Default => own,
|
||||
};
|
||||
match pick {
|
||||
Pick::Keep => self.quad(out, solid_leaves, own, corners.map(|c| c.0)),
|
||||
Pick::Tile(t) => self.quad(out, solid_leaves, tile(t), corners.map(|c| c.0)),
|
||||
Pick::Quarters(tiles) => {
|
||||
let cuts = cuts(&corners);
|
||||
for s in cuts.windows(2) {
|
||||
for t in cuts.windows(2) {
|
||||
let piece = [(s[0], t[0]), (s[1], t[0]), (s[1], t[1]), (s[0], t[1])]
|
||||
.map(|(s, t)| bilinear(&corners, s, t));
|
||||
let quarter = quarter_of(&piece);
|
||||
self.quad(out, solid_leaves, tile(tiles[quarter]), piece.map(|c| c.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// One terrain quad with sprite `tile`, in the pass its alpha needs (`solid_leaves`: no
|
||||
/// alpha cutout whatever the sprite).
|
||||
fn quad(
|
||||
&self,
|
||||
out: &mut SectionMesh,
|
||||
solid_leaves: bool,
|
||||
tile: TileLayer,
|
||||
mut v: [TerrainVertex; 4],
|
||||
) {
|
||||
let TileLayer::Layer {
|
||||
layer,
|
||||
translucent,
|
||||
opaque,
|
||||
} = tile
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.tables.is_animated(layer) {
|
||||
out.note_sprite_layer(layer);
|
||||
}
|
||||
let translucent = translucent
|
||||
|| self
|
||||
.translucent_layers
|
||||
.get(usize::from(layer))
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
// Quads over opaque texels draw in the solid layer: no alpha cutout (a dirt path's side
|
||||
// skips its clear top row). All four vertices share the layer.
|
||||
let layer = if opaque && !translucent || solid_leaves {
|
||||
layer | OPAQUE_LAYER
|
||||
} else {
|
||||
layer
|
||||
};
|
||||
let target = if translucent {
|
||||
&mut out.translucent
|
||||
} else {
|
||||
&mut out.solid
|
||||
};
|
||||
for vertex in &mut v {
|
||||
vertex.layer = layer;
|
||||
}
|
||||
target.extend(v);
|
||||
}
|
||||
}
|
||||
/// The cuts of a `ctm_compact` face, in quad parameters, without allocating: the first `.1`
|
||||
/// of `.0`.
|
||||
struct Cuts([f32; 4], usize);
|
||||
impl std::ops::Deref for Cuts {
|
||||
type Target = [f32];
|
||||
fn deref(&self) -> &[f32] {
|
||||
&self.0[..self.1]
|
||||
}
|
||||
}
|
||||
/// Where a quad's edge from corner 0 to corner `to` (1 for `s`, 3 for `t`) crosses the sprite's
|
||||
/// middle line, the line its quarters meet on; `None` for an edge that stays on one side (a
|
||||
/// slab's side).
|
||||
fn crossing(c: &[(TerrainVertex, [f32; 2]); 4], to: usize) -> Option<f32> {
|
||||
let (a, b) = (c[0].1, c[to].1);
|
||||
// The UV the edge runs along.
|
||||
let k = usize::from((b[1] - a[1]).abs() > (b[0] - a[0]).abs());
|
||||
let at = (0.5 - a[k]) / (b[k] - a[k]);
|
||||
(at > 0.0 && at < 1.0).then_some(at)
|
||||
}
|
||||
/// The cuts of a `ctm_compact` face, the same along `s` and `t`: both edges' crossings. Pieces
|
||||
/// on the quad's 0-2 diagonal are then square, so their own triangles lie on the quad's and a
|
||||
/// split face shades exactly like the whole one; the others lie on one side of it.
|
||||
fn cuts(c: &[(TerrainVertex, [f32; 2]); 4]) -> Cuts {
|
||||
let mut out = Cuts([0.0; 4], 1);
|
||||
let (mut s, mut t) = (crossing(c, 1), crossing(c, 3));
|
||||
if s > t {
|
||||
std::mem::swap(&mut s, &mut t);
|
||||
}
|
||||
for at in [s, t].into_iter().flatten().chain([1.0]) {
|
||||
if out.0[out.1 - 1] < at {
|
||||
out.0[out.1] = at;
|
||||
out.1 += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The quarter of the sprite (top-left, top-right, bottom-left, bottom-right) a piece of a
|
||||
/// quad lies in, by its UV centre (v grows downward).
|
||||
fn quarter_of(piece: &[(TerrainVertex, [f32; 2]); 4]) -> usize {
|
||||
let [u, v] = [0, 1].map(|k| piece.iter().map(|c| c.1[k]).sum::<f32>() / 4.0);
|
||||
usize::from(v >= 0.5) * 2 + usize::from(u >= 0.5)
|
||||
}
|
||||
/// The vertex at `(s, t)` across a quad's corners (`s` from corner 0 to 1, `t` from 0 to 3),
|
||||
/// with its UV in sprite units: a quarter of a `ctm_compact` face. Blended within the quad's
|
||||
/// two triangles (0-1-2 and 0-2-3), as the GPU blends the whole quad, so a split face shades
|
||||
/// like an unsplit one.
|
||||
fn bilinear(c: &[(TerrainVertex, [f32; 2]); 4], s: f32, t: f32) -> (TerrainVertex, [f32; 2]) {
|
||||
let w = if s >= t {
|
||||
[1.0 - s, s - t, t, 0.0]
|
||||
} else {
|
||||
[1.0 - t, 0.0, s, t - s]
|
||||
};
|
||||
let mix = |f: &dyn Fn(&(TerrainVertex, [f32; 2])) -> f32| {
|
||||
(0..4).map(|i| w[i] * f(&c[i])).sum::<f32>()
|
||||
};
|
||||
// Packed bytes (colour, light) blend one by one.
|
||||
let bytes = |field: fn(&TerrainVertex) -> u32, count: u32| {
|
||||
(0..count)
|
||||
.map(|k| (mix(&|c| (field(&c.0) >> (8 * k) & 255) as f32).round() as u32) << (8 * k))
|
||||
.sum::<u32>()
|
||||
};
|
||||
let color = bytes(|v| v.color, 4);
|
||||
let light = bytes(|v| u32::from(v.light), 2) as u16;
|
||||
let pos = [0, 1, 2].map(|k| mix(&|c| c.0.pos[k]));
|
||||
let uv = [0, 1].map(|k| mix(&|c| c.1[k]));
|
||||
(TerrainVertex::new(pos, uv, 0, color, light), uv)
|
||||
}
|
||||
/// Snapshot of the 3x3 chunk neighbourhood used by a worker job.
|
||||
pub type Region = [[Option<Arc<Chunk>>; 3]; 3];
|
||||
pub fn borrow(region: &Region) -> [[Option<&Chunk>; 3]; 3] {
|
||||
std::array::from_fn(|z| std::array::from_fn(|x| region[z][x].as_deref()))
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod quarter_tests {
|
||||
use super::*;
|
||||
|
||||
fn corner(pos: [f32; 3], uv: [f32; 2], red: u32, light: u16) -> (TerrainVertex, [f32; 2]) {
|
||||
(TerrainVertex::new(pos, uv, 0, 0xFF00_0000 | red, light), uv)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quarters_blend_the_corners_by_triangle() {
|
||||
// Only corner 2 is lit: the GPU's triangles (0-1-2, 0-2-3) put half of it in the
|
||||
// middle, where a bilinear blend would put a quarter and the other diagonal none.
|
||||
let c = [
|
||||
corner([0.0, 0.0, 0.0], [0.0, 0.0], 0, 0),
|
||||
corner([1.0, 0.0, 0.0], [1.0, 0.0], 0, 0),
|
||||
corner([1.0, 1.0, 0.0], [1.0, 1.0], 200, 0x2010),
|
||||
corner([0.0, 1.0, 0.0], [0.0, 1.0], 0, 0),
|
||||
];
|
||||
let (centre, uv) = bilinear(&c, 0.5, 0.5);
|
||||
assert_eq!(centre.pos, [0.5, 0.5, 0.0]);
|
||||
assert_eq!(uv, [0.5, 0.5]);
|
||||
assert_eq!(centre.color, 0xFF00_0000 | 100);
|
||||
assert_eq!(centre.light, 0x1008, "sky and block light blend apart");
|
||||
// Off the diagonal, on the 0-1-2 triangle: corner 2 weighs t.
|
||||
assert_eq!(bilinear(&c, 0.75, 0.25).0.color & 255, 50);
|
||||
assert_eq!(bilinear(&c, 0.5, 0.0).0.pos, [0.5, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_faces_cut_where_the_sprite_does() {
|
||||
// A full face: cut in the middle both ways.
|
||||
let full = [
|
||||
corner([0.0, 1.0, 0.0], [0.0, 0.0], 0, 0),
|
||||
corner([1.0, 1.0, 0.0], [1.0, 0.0], 0, 0),
|
||||
corner([1.0, 0.0, 0.0], [1.0, 1.0], 0, 0),
|
||||
corner([0.0, 0.0, 0.0], [0.0, 1.0], 0, 0),
|
||||
];
|
||||
assert_eq!(*cuts(&full), [0.0, 0.5, 1.0]);
|
||||
// A dirt path's side, v from 1/16: the sprite's middle is 7/15 of the way down; both
|
||||
// directions are cut there and at the middle of u.
|
||||
let mut path = full;
|
||||
path[0].1 = [0.0, 1.0 / 16.0];
|
||||
path[1].1 = [1.0, 1.0 / 16.0];
|
||||
assert_eq!(
|
||||
*cuts(&path),
|
||||
[0.0, (0.5 - 1.0 / 16.0) / (15.0 / 16.0), 0.5, 1.0]
|
||||
);
|
||||
// A slab's lower side (v 0.5..1) crosses the middle only along u.
|
||||
let mut slab = full;
|
||||
slab[0].1 = [0.0, 0.5];
|
||||
slab[1].1 = [1.0, 0.5];
|
||||
assert_eq!(crossing(&slab, 3), None);
|
||||
assert_eq!(*cuts(&slab), [0.0, 0.5, 1.0]);
|
||||
// Quarters by UV centre: top-left, top-right, bottom-left, bottom-right.
|
||||
let piece = |s0: f32, t0: f32| {
|
||||
[
|
||||
(s0, t0),
|
||||
(s0 + 0.5, t0),
|
||||
(s0 + 0.5, t0 + 0.5),
|
||||
(s0, t0 + 0.5),
|
||||
]
|
||||
.map(|(s, t)| bilinear(&full, s, t))
|
||||
};
|
||||
assert_eq!(
|
||||
[
|
||||
piece(0.0, 0.0),
|
||||
piece(0.5, 0.0),
|
||||
piece(0.0, 0.5),
|
||||
piece(0.5, 0.5)
|
||||
]
|
||||
.map(|p| quarter_of(&p)),
|
||||
[0, 1, 2, 3]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1801,7 +1801,7 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
|
||||
};
|
||||
let mut tip = None;
|
||||
if let Act::Extra(item) = act {
|
||||
tip = Some(Text::literal(item.description()));
|
||||
tip = Some(Text::literal(item.description_text()));
|
||||
}
|
||||
if let Act::Push(page) = act
|
||||
&& let Some(text) = horizon_page_tooltip(*page)
|
||||
@@ -1976,7 +1976,7 @@ fn pairs(opts: &[Opt]) -> Vec<Row> {
|
||||
impl OptionsUi {
|
||||
/// A switch of `horizon-extras.txt` as the Extras page draws it: "Caption: On".
|
||||
fn extra_button(&self, s: &Settings, item: crate::extras::Item) -> Widget {
|
||||
let label = format!("{}: {}", item.caption(), item.value(&s.extras));
|
||||
let label = format!("{}: {}", item.caption_text(), item.value(&s.extras));
|
||||
button(0.0, 0.0, 150.0, Text::literal(label), Act::Extra(item))
|
||||
}
|
||||
fn settings_search_active(&self) -> bool {
|
||||
@@ -2274,7 +2274,7 @@ impl OptionsUi {
|
||||
// Each section's switches two to a row, as vanilla's
|
||||
// OptionsList.addSmall lays out an options screen.
|
||||
let extra = |item: crate::extras::Item| {
|
||||
let value = format!("{}: {}", item.caption(), item.value(&s.extras));
|
||||
let value = format!("{}: {}", item.caption_text(), item.value(&s.extras));
|
||||
let label = if item.key_name().is_some() && self.selected_key == item.key_name()
|
||||
{
|
||||
Text::literal(format!("> {value} <")).with_color(text::YELLOW)
|
||||
|
||||
@@ -13,6 +13,11 @@ use crate::{
|
||||
ui,
|
||||
};
|
||||
|
||||
/// A string of this page: the language table's, or the English text.
|
||||
fn tr(key: &str, english: &str) -> String {
|
||||
lang::get_or(key, english)
|
||||
}
|
||||
|
||||
/// Leave room for navigation, creation and cancellation in a 240-pixel menu.
|
||||
const SHOWN: usize = 4;
|
||||
pub mod store;
|
||||
@@ -132,7 +137,12 @@ impl SplitProfiles {
|
||||
};
|
||||
if let Some(name) = &self.forgetting {
|
||||
return vec![
|
||||
button(120.0, "Forget".into(), self.editable(name), Target::Forget),
|
||||
button(
|
||||
120.0,
|
||||
tr("horizon.splitscreen.profiles.forget", "Forget"),
|
||||
self.editable(name),
|
||||
Target::Forget,
|
||||
),
|
||||
button(
|
||||
144.0,
|
||||
lang::get_or("gui.cancel", "Cancel"),
|
||||
@@ -147,7 +157,12 @@ impl SplitProfiles {
|
||||
let ok = valid_name(name) && !self.names.iter().any(|n| n.eq_ignore_ascii_case(name));
|
||||
let top = field_top(h);
|
||||
return vec![
|
||||
button(top + 25.0, "Create".into(), ok, Target::Create),
|
||||
button(
|
||||
top + 25.0,
|
||||
tr("horizon.splitscreen.profiles.create", "Create"),
|
||||
ok,
|
||||
Target::Create,
|
||||
),
|
||||
button(
|
||||
top + 50.0,
|
||||
lang::get_or("gui.back", "Back"),
|
||||
@@ -166,7 +181,7 @@ impl SplitProfiles {
|
||||
{
|
||||
let mut label = name.clone();
|
||||
if self.current.as_ref() == Some(name) {
|
||||
label.push_str(" - you");
|
||||
label.push_str(&tr("horizon.splitscreen.profiles.you", " - you"));
|
||||
}
|
||||
let y = 44.0 + (i % SHOWN) as f32 * 24.0;
|
||||
buttons.push((
|
||||
@@ -177,7 +192,8 @@ impl SplitProfiles {
|
||||
));
|
||||
buttons.push((
|
||||
[x + 180.0, y, 20.0, 20.0],
|
||||
format!("Forget {name}"),
|
||||
tr("horizon.splitscreen.profiles.forget_named", "Forget %s")
|
||||
.replacen("%s", name, 1),
|
||||
self.editable(name),
|
||||
Target::Remove(i),
|
||||
));
|
||||
@@ -186,19 +202,24 @@ impl SplitProfiles {
|
||||
if self.names.len() > SHOWN {
|
||||
buttons.push((
|
||||
[x, y, 98.0, 20.0],
|
||||
"Previous".into(),
|
||||
tr("horizon.splitscreen.profiles.previous", "Previous"),
|
||||
self.page() > 0,
|
||||
Target::Previous,
|
||||
));
|
||||
buttons.push((
|
||||
[x + 102.0, y, 98.0, 20.0],
|
||||
"Next".into(),
|
||||
tr("horizon.splitscreen.profiles.next", "Next"),
|
||||
(self.page() + 1) * SHOWN < self.names.len(),
|
||||
Target::Next,
|
||||
));
|
||||
y += 24.0;
|
||||
}
|
||||
buttons.push(button(y, "New Profile...".into(), true, Target::New));
|
||||
buttons.push(button(
|
||||
y,
|
||||
tr("horizon.splitscreen.profiles.new_button", "New Profile..."),
|
||||
true,
|
||||
Target::New,
|
||||
));
|
||||
let cancel = lang::get_or("gui.cancel", "Cancel");
|
||||
buttons.push(button(y + 24.0, cancel, true, Target::Cancel));
|
||||
buttons
|
||||
@@ -220,30 +241,71 @@ impl SplitProfiles {
|
||||
};
|
||||
let buttons = self.buttons(w, h);
|
||||
if let Some(name) = &self.forgetting {
|
||||
STYLE.title(batch, atlas, &Text::literal("Forget profile?"));
|
||||
STYLE.title(
|
||||
batch,
|
||||
atlas,
|
||||
&Text::literal(tr(
|
||||
"horizon.splitscreen.profiles.forget_title",
|
||||
"Forget profile?",
|
||||
)),
|
||||
);
|
||||
line(batch, name, 52.0, u32::MAX);
|
||||
line(batch, "It leaves this instance's list.", 76.0, grey);
|
||||
line(batch, "World progress and settings are kept.", 90.0, grey);
|
||||
line(
|
||||
batch,
|
||||
&tr(
|
||||
"horizon.splitscreen.profiles.forget_leaves",
|
||||
"It leaves this instance's list.",
|
||||
),
|
||||
76.0,
|
||||
grey,
|
||||
);
|
||||
line(
|
||||
batch,
|
||||
&tr(
|
||||
"horizon.splitscreen.profiles.forget_kept",
|
||||
"World progress and settings are kept.",
|
||||
),
|
||||
90.0,
|
||||
grey,
|
||||
);
|
||||
if let Some(why) = &self.refused {
|
||||
line(batch, why, 176.0, rgba(255, 85, 85, 255));
|
||||
}
|
||||
} else if let Some(field) = &mut self.new {
|
||||
STYLE.title(batch, atlas, &Text::literal("New Profile"));
|
||||
STYLE.title(
|
||||
batch,
|
||||
atlas,
|
||||
&Text::literal(tr("horizon.splitscreen.profiles.new_title", "New Profile")),
|
||||
);
|
||||
let (x, top) = ((w / 2.0).floor() - 100.0, field_top(h));
|
||||
let label = "Name (3 to 16 letters, digits, _)";
|
||||
batch.text(atlas, label, x, top - 14.0, grey, true);
|
||||
let label = tr(
|
||||
"horizon.splitscreen.profiles.name_hint",
|
||||
"Name (3 to 16 letters, digits, _)",
|
||||
);
|
||||
batch.text(atlas, &label, x, top - 14.0, grey, true);
|
||||
(field.x, field.y, field.width, field.height) = (x, top, 200.0, 20.0);
|
||||
field.draw(batch, atlas, None);
|
||||
} else {
|
||||
let title = match self.current {
|
||||
Some(_) => "Switch profile",
|
||||
None => "Who's playing?",
|
||||
Some(_) => tr(
|
||||
"horizon.splitscreen.profiles.switch_title",
|
||||
"Switch profile",
|
||||
),
|
||||
None => tr("horizon.pages.split_profiles", "Who's playing?"),
|
||||
};
|
||||
STYLE.title(batch, atlas, &Text::literal(title));
|
||||
self.draw_bumpers(batch, atlas, style);
|
||||
// Under Cancel: where offline profiles live.
|
||||
let y = buttons.last().map_or(0.0, |b| b.0[1] + 30.0);
|
||||
line(batch, "Offline profiles belong to this instance", y, grey);
|
||||
line(
|
||||
batch,
|
||||
&tr(
|
||||
"horizon.splitscreen.profiles.offline_note",
|
||||
"Offline profiles belong to this instance",
|
||||
),
|
||||
y,
|
||||
grey,
|
||||
);
|
||||
// Why the last pick can't play, wrapped to the view (a quarter of a window is narrow).
|
||||
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
|
||||
let why = self.refused.as_deref().unwrap_or_default();
|
||||
@@ -308,8 +370,12 @@ impl SplitProfiles {
|
||||
fn draw_bumpers(&self, batch: &mut HudBatch, atlas: &HudAtlas, style: GlyphStyle) {
|
||||
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
|
||||
let words = match &self.place {
|
||||
Some(place) => format!("Move where you play: {place}"),
|
||||
None => "Move where you play".to_owned(),
|
||||
Some(place) => tr(
|
||||
"horizon.splitscreen.profiles.move_place",
|
||||
"Move where you play: %s",
|
||||
)
|
||||
.replacen("%s", place, 1),
|
||||
None => tr("horizon.splitscreen.profiles.move", "Move where you play"),
|
||||
};
|
||||
let keys = [PadButton::LeftBumper, PadButton::RightBumper];
|
||||
let key_width = |b: PadButton| width(b.label(style)) + 4.0;
|
||||
|
||||
@@ -6,8 +6,12 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
description = "Horizon native development client"
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
|
||||
[dependencies]
|
||||
horizon-client-lib = { path = "../horizon-client-lib", default-features = false }
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-client-input = { path = "../horizon-client-input" }
|
||||
@@ -15,6 +19,8 @@ horizon-client-render = { path = "../horizon-client-render" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# OptiFine-format pack files: custom entity models, random entity textures.
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
@@ -36,7 +42,6 @@ horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
# Their runtime is the `mods` feature.
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
@@ -104,20 +109,23 @@ winresource = "0.1"
|
||||
default = ["audio", "controllers"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio"]
|
||||
audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
windows-mimalloc = []
|
||||
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
|
||||
windows-segment-heap = []
|
||||
|
||||
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
|
||||
[[example]]
|
||||
name = "client_probe"
|
||||
path = "examples/probe/main.rs"
|
||||
[[bin]]
|
||||
name = "horizon-client"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[test]]
|
||||
name = "launch_api"
|
||||
path = "tests/launch_api.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
{
|
||||
"horizon.options.controller": "Controller",
|
||||
"horizon.options.vibration": "Vibration",
|
||||
"horizon.options.adaptive_triggers": "Adaptive Trigger",
|
||||
"horizon.options.lightbar": "Lightbar",
|
||||
"horizon.options.look_sensitivity": "Blickempfindlichkeit",
|
||||
"horizon.options.invert_x": "Blick-X-Achse umkehren",
|
||||
"horizon.options.invert_y": "Blick-Y-Achse umkehren",
|
||||
"horizon.options.sneak_button": "Schleichtaste",
|
||||
"horizon.options.move_deadzone": "Totzone der Bewegung",
|
||||
"horizon.options.look_deadzone": "Totzone des Blicks",
|
||||
"horizon.options.button_glyphs": "Tastensymbole",
|
||||
"horizon.options.menu_navigation": "Menünavigation",
|
||||
"horizon.options.on_screen_keyboard": "Bildschirmtastatur",
|
||||
"horizon.options.chat_delay": "Chatverzögerung",
|
||||
"horizon.options.lightbar.health": "Gesundheit",
|
||||
"horizon.options.lightbar.dimension": "Dimension",
|
||||
"horizon.options.glyphs.auto": "Auto",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.menu_pointer": "Zeiger",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Diese Funktion ist in Horizon noch nicht verfügbar.",
|
||||
"horizon.options.search": "Einstellungen durchsuchen …",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Shader-Pakete",
|
||||
"horizon.pages.shader_options": "Shader-Optionen",
|
||||
"horizon.pages.controller": "Controller-Einstellungen",
|
||||
"horizon.pages.controller_binds": "Controller-Tastenbelegung",
|
||||
"horizon.pages.extras": "Horizon-Extras",
|
||||
"horizon.pages.settings": "Horizon-Einstellungen",
|
||||
"horizon.pages.agent": "KI-Agent",
|
||||
"horizon.pages.split_profiles": "Wer spielt?",
|
||||
"horizon.pages.settings.tooltip": "Horizons eigene Einstellungen: Grafik, Controller, Extras und Mods.",
|
||||
"horizon.pages.graphics.tooltip": "Grafik, einfach: Jede Einstellung zeigt, was sie kostet.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Alle Grafikeinstellungen, auch die von Horizon, mit den Kosten jeder einzelnen.",
|
||||
"horizon.pages.controller.tooltip": "Controller: ihre Tasten, Sticks, Vibration und Menünavigation.",
|
||||
"horizon.pages.extras.tooltip": "Optionale Zusätze, alle standardmäßig aus: Das Spiel bleibt Vanilla, bis du einen einschaltest.",
|
||||
"horizon.pages.agent.tooltip": "Lass deinen eigenen KI-Agenten spielen: horizon-ctl und MCP-Clients auf diesem Computer.",
|
||||
"horizon.pages.split_profiles.tooltip": "Wer spielt: ein Profil, das in diesem Spielordner gespeichert ist.",
|
||||
"horizon.pages.shader_packs.tooltip": "Shader-Pakete aus dem Ordner shaderpacks: die von Horizon selbst und die im Format shaders/. Es läuft immer nur eines.",
|
||||
"horizon.pages.mods.tooltip": "Mods für Horizon aus dem Ordner horizon-mods. Jede läuft in einer Sandbox und bleibt aus, bis du sie einschaltest.",
|
||||
"horizon.pages.open.graphics": "Grafik …",
|
||||
"horizon.pages.open.graphics_advanced": "Erweiterte Grafik …",
|
||||
"horizon.pages.open.shader_packs": "Shader-Pakete …",
|
||||
"horizon.pages.open.controller": "Controller-Einstellungen …",
|
||||
"horizon.pages.open.extras": "Extras …",
|
||||
"horizon.pages.open.mods": "Mods …",
|
||||
"horizon.pages.open.agent": "KI-Agent …",
|
||||
"horizon.pages.open.horizon_graphics": "Horizon-Grafik …",
|
||||
"horizon.pages.open.controller_binds": "Controller-Belegung …",
|
||||
"horizon.pages.open.horizon_settings": "Horizon-Einstellungen …",
|
||||
"horizon.mods.empty": "Keine Mods: Lege .hmod-Dateien in den Ordner horizon-mods",
|
||||
"horizon.mods.stopped": " (angehalten)",
|
||||
"horizon.mods.not_built": " (nicht gebaut)",
|
||||
"horizon.mods.dev": " (Entwicklung)",
|
||||
"horizon.mods.settings": "Einstellungen",
|
||||
"horizon.mods.open_folder": "Modordner öffnen",
|
||||
"horizon.shader_packs.empty": "Keine Shader-Pakete: Lege sie in den Ordner shaderpacks",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.shader_options.open": "Shader-Optionen …",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"horizon.shader_options.loading": "Optionen des Shader-Pakets werden gelesen …",
|
||||
"horizon.agent.copy_details": "Verbindungsdaten kopieren",
|
||||
"horizon.multiplayer.join": "Beitreten",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Neuverbindung abbrechen",
|
||||
"horizon.multiplayer.join_friend": "Freund beitreten",
|
||||
"horizon.multiplayer.invite_code": "Einladungscode",
|
||||
"horizon.multiplayer.relay": "Relay (optional)",
|
||||
"Preset": "Preset",
|
||||
"LOD Distance": "LOD-Weite",
|
||||
"Far Detail": "Ferndetails",
|
||||
"Cave Culling": "Höhlen-Culling",
|
||||
"Entity Culling": "Objekt-Culling",
|
||||
"Chunk Updates": "Chunk-Updates",
|
||||
"Upload Budget": "Upload-Budget",
|
||||
"Sky": "Himmel",
|
||||
"Fog": "Nebel",
|
||||
"Weather": "Wetter",
|
||||
"Animated Textures": "Animierte Texturen",
|
||||
"Present Mode": "Ausgabemodus",
|
||||
"Frame Pacing": "Bildtakt",
|
||||
"Worker Threads": "Arbeitsthreads",
|
||||
"Resolution Scale": "Auflösungsskalierung",
|
||||
"Anti-aliasing": "Kantenglättung",
|
||||
"Upscaling": "Upscaling",
|
||||
"HDR Settings...": "HDR-Einstellungen …",
|
||||
"Paper White": "Papierweiß",
|
||||
"Peak Brightness": "Spitzenhelligkeit",
|
||||
"Black Level": "Schwarzwert",
|
||||
"UI Brightness": "GUI-Helligkeit",
|
||||
"Battery Mode": "Akkumodus",
|
||||
"Graphics API": "Grafik-API",
|
||||
"Performance Overlay": "Leistungsanzeige",
|
||||
"Full Detail Distance": "Detailweite",
|
||||
"On": "An",
|
||||
"Off": "Aus",
|
||||
"Unlimited": "Unbegrenzt",
|
||||
"Auto": "Auto",
|
||||
"Custom": "Benutzerdefiniert",
|
||||
"Performance": "Leistung",
|
||||
"Balanced": "Ausgewogen",
|
||||
"Quality": "Qualität",
|
||||
"Ultra": "Ultra",
|
||||
"Ultra Performance": "Ultra-Leistung",
|
||||
"Native": "Nativ",
|
||||
"Full": "Voll",
|
||||
"Reduced": "Reduziert",
|
||||
"Efficient": "Sparsam",
|
||||
"Precise": "Präzise",
|
||||
"Auto (VSync)": "Auto (VSync)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "Mailbox",
|
||||
"Immediate": "Immediate",
|
||||
"Vulkan": "Vulkan",
|
||||
"DirectX 12": "DirectX 12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS und Diagramm",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "Keine Kosten",
|
||||
"Low cost": "Geringe Kosten",
|
||||
"Medium cost": "Mittlere Kosten",
|
||||
"High cost": "Hohe Kosten",
|
||||
"Saves time": "Spart Zeit",
|
||||
"Rendering": "Rendering",
|
||||
"Display": "Anzeige",
|
||||
"Preferences": "Präferenzen",
|
||||
"Overlay": "Einblendung",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "Grafik",
|
||||
"Graphics: Advanced": "Grafik: Erweitert",
|
||||
"Graphics: HDR": "Grafik: HDR",
|
||||
"Advanced...": "Erweitert …",
|
||||
"Simple...": "Einfach …",
|
||||
"Done": "Fertig",
|
||||
"This feature is not yet available in Horizon.": "Diese Funktion ist in Horizon noch nicht verfügbar.",
|
||||
"Your display or system doesn't offer HDR output.": "Dein Bildschirm oder System unterstützt keine HDR-Ausgabe.",
|
||||
"Turn HDR on to use it.": "Schalte HDR ein, um es zu nutzen.",
|
||||
"%s chunks": "%s Chunks",
|
||||
"%s per frame": "%s pro Bild",
|
||||
"%s MiB/frame": "%s MiB/Bild",
|
||||
"%s KiB/frame": "%s KiB/Bild",
|
||||
"%s nits": "%s Nits",
|
||||
"Auto (%s)": "Auto (%s)",
|
||||
"Auto (%s nits)": "Auto (%s Nits)",
|
||||
"%.2f nits": "%.2f Nits",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Gelände mit vollen Details. Die LOD-Entfernung reicht weiter.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Grobes fernes Gelände in lokalen Welten. Integrierte GPUs: max. 128.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reduziert: fernes Gelände gröber, schneller auf dieser GPU.",
|
||||
"Reduced draws far terrain coarser, for GPUs it slows down.": "Reduziert: fernes Gelände gröber, für GPUs, die sonst bremsen.",
|
||||
"Field of view. Wider shows more terrain at once.": "Sichtfeld. Ein größeres zeigt mehr Gelände auf einmal.",
|
||||
"Caps frames per second; a cap saves power and heat.": "Begrenzt die Bilder pro Sekunde; spart Strom und Wärme.",
|
||||
"Waits for the display's refresh: no tearing, a little latency.": "Wartet auf den Bildaufbau: kein Tearing, etwas Latenz.",
|
||||
"Size of menus and the HUD.": "Größe der Menüs und des HUDs.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Stellt Optionen darunter ein. Änderung = „Benutzerdefiniert“.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, Feuer, Sonne heller als Weiß. Braucht HDR-Bildschirm.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Das dunkelste Schwarz deines Bildschirms. 0 für OLED; LCDs leuchten ein wenig.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Wie hell Lichter werden. Auto: der Spitzenwert, den dein System meldet.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Wie hell weißes Papier und Menüs aussehen. Auto: das Weiß deines Desktops.",
|
||||
"How bright the HUD and menus are, against paper white.": "Wie hell HUD und Menüs im Vergleich zu Papierweiß sind.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Wie weit Kreaturen, Pflanzen und Maschinen weiterarbeiten. Kostet CPU-Zeit.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Zeichenweite für Kreaturen und Gegenstände, anteilig zur Norm.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Überspringt Gelände hinter geschlossenen Abschnitten. Aus kostet viel.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Versteckt Modelle hinter Blöcken. Bei eigenen Paketen ausschalten.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Mesh-Abschnitte pro Bild. Weniger: flüssigeres Laden.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Weiche Schatten in Ecken. Kostet nur Zeit beim Chunk-Aufbau.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Kleinere Textur-Kopien für fernes Gelände: weniger Flimmern.",
|
||||
"Blends grass and water colours across biomes when building chunks.": "Mischt Gras- und Wasserfarben über Biome hinweg beim Chunk-Aufbau.",
|
||||
"Cloud layer: off, flat (fast) or 3D (fancy).": "Wolkenschicht: aus, flach (schnell) oder 3D (schön).",
|
||||
"How far clouds reach, in chunks.": "Wie weit Wolken reichen, in Chunks.",
|
||||
"Draws the sky, sun, moon and stars.": "Zeichnet Himmel, Sonne, Mond und Sterne.",
|
||||
"Distance fog. Off shows the edge of the loaded world.": "Nebel in der Ferne. Aus zeigt den Rand der geladenen Welt.",
|
||||
"Draws rain and snow.": "Zeichnet Regen und Schnee.",
|
||||
"How far rain and snow fall around you.": "Wie weit um dich herum Regen und Schnee fallen.",
|
||||
"How many particles effects spawn.": "Wie viele Partikel Effekte erzeugen.",
|
||||
"Round shadows under mobs and items.": "Runde Schatten unter Kreaturen und Gegenständen.",
|
||||
"Leaves with see-through gaps. Off draws them solid.": "Laub mit durchsichtigen Lücken. Aus zeichnet es massiv.",
|
||||
"Sorts water and glass more exactly behind each other.": "Sortiert Wasser und Glas hintereinander genauer.",
|
||||
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Welche Chunk-Neuaufbauten auf das Bild warten: keine, nahe oder deine.",
|
||||
"Animates water, lava, fire and other moving textures.": "Animiert Wasser, Lava, Feuer und andere bewegte Texturen.",
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Weltpixel pro Bildschirmpixel. Unter 100 % ist es auf der GPU schneller.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Glättet Kanten. FXAA: weicher. TAA: am glattesten, evtl. Schlieren.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Rendert die Welt kleiner und baut sie mit TAA wieder auf. Schneller, weicher.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-Ausgabe auf HDR-Bildschirm: Lava, Feuer, Sonne heller.",
|
||||
"How frames reach the screen while VSync is off.": "Wie Bilder auf den Bildschirm gelangen, wenn VSync aus ist.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Sparsam schläft bis zum nächsten Bild; Präzise wartet aktiv, wie in Vanilla.",
|
||||
"Fills the screen (borderless).": "Füllt den Bildschirm (randlos).",
|
||||
"Brightens dark places.": "Hellt dunkle Orte auf.",
|
||||
"Lowers the frame rate when minimised, or idle too.": "Senkt die Bildrate bei Minimierung oder auch bei Inaktivität.",
|
||||
"Sways the view while walking.": "Lässt die Ansicht beim Gehen schwanken.",
|
||||
"Fades newly loaded chunks in.": "Blendet neu geladene Chunks ein.",
|
||||
"Darkens the screen's edges.": "Dunkelt die Bildschirmränder ab.",
|
||||
"Shows when the world saves.": "Zeigt an, wann die Welt gespeichert wird.",
|
||||
"Shows attack cooldown.": "Zeigt die Angriffsabklingzeit an.",
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS (30 im Menü/ohne Fokus), halb so viele Worker: spart Strom.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Threads für Erzeugung, Licht und Mesh der Chunks. Nach Neustart.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sonst DirectX 12 (Windows) oder OpenGL (Linux). Nach Neustart.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Pro Bild an die GPU gesendete Mesh-Daten. Weniger ist flüssiger beim Laden.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Bildrate und Bildzeiten über dem Spiel, mit Diagramm.",
|
||||
"horizon.multiplayer.finding_friend": "Die Welt deines Freundes wird gesucht …",
|
||||
"horizon.loading.opening_world": "Welt wird geöffnet",
|
||||
"horizon.loading.cancelling_world": "Wird abgebrochen",
|
||||
"horizon.title.not_affiliated": "Nicht mit Mojang oder Microsoft verbunden",
|
||||
"horizon.extras.header.lighting": "Beleuchtung",
|
||||
"horizon.extras.header.third_person": "Dritte Person",
|
||||
"horizon.extras.header.inventory": "Inventar",
|
||||
"horizon.extras.header.multiplayer": "Mehrspieler",
|
||||
"horizon.extras.fullbright": "Maximale Helligkeit",
|
||||
"horizon.extras.dynamic_lights": "Dynamisches Licht",
|
||||
"horizon.extras.own_name_tag": "Eigenes Namensschild",
|
||||
"horizon.extras.auto_reconnect": "Neu verbinden",
|
||||
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
|
||||
"horizon.extras.mouse_tweaks_wheel": "Mausrad-Verschieben",
|
||||
"horizon.extras.sort": "Sortier-Schaltflächen",
|
||||
"horizon.extras.sort_key": "Sortiertaste",
|
||||
"horizon.extras.no_ai": "Keine KI",
|
||||
"horizon.extras.agent_control": "Agentensteuerung",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.fullbright.tooltip": "Zeigt Blöcke und Objekte bei vollem Licht, selbst an dunklen Orten.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Fackeln und andere Lichtquellen, die gehalten, fallen gelassen werden oder brennen, beleuchten die Blöcke um sich herum.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Zeigt deinen Namen in der dritten Person über deinem Kopf an, so wie andere Spieler ihn sehen. Versteckt, wenn der Server die Namen deines Teams verbirgt oder du unsichtbar bist.",
|
||||
"horizon.extras.auto_reconnect.tooltip": "Verbindet nach einem Verbindungsabbruch oder Neustart erneut mit dem Server, nie nach einem Rauswurf oder Bann.",
|
||||
"horizon.extras.mouse_tweaks.tooltip": "Mit Rechtsklick ziehen legt je einen pro Feld ab, mit Linksklick ziehen sammelt den gehaltenen Gegenstand, Umschalt + Linksklick ziehen verschiebt jedes Feld.",
|
||||
"horizon.extras.mouse_tweaks_wheel.tooltip": "Nach unten schickt einen Gegenstand des Feldes auf die andere Seite, nach oben holt einen zurück.",
|
||||
"horizon.extras.sort.tooltip": "Sortiert Inventar, Truhen, Fässer, Endertruhen und Shulker-Kisten in der Reihenfolge der Kreativ-Tabs und fasst Stapel zusammen.",
|
||||
"horizon.extras.sort_key.tooltip": "Sortiert den Bereich unter dem Mauszeiger. Klicke, dann drücke eine Taste; Esc hebt die Belegung auf.",
|
||||
"horizon.extras.no_ai.tooltip": "Blendet alle KI-Funktionen aus und schaltet sie ab: Agentensteuerung, horizon-ctl und die MCP-Server. Auch den Schalter des Launchers.",
|
||||
"horizon.extras.agent_control.tooltip": "Dein eigener KI-Agent kann dieses Spiel über horizon-ctl oder einen MCP-Client spielen, nur auf diesem Computer.",
|
||||
"horizon.extras.discord_presence.tooltip": "Zeigt „Spielt Horizon“ in deinem Discord-Profil an, solange Discord läuft: Menüs, Einzel- oder Mehrspielermodus, die Dimension und die Spielzeit. Nie den Namen einer Welt oder die Adresse eines Servers.",
|
||||
"Calibrate...": "Kalibrieren …",
|
||||
"Calibrate Anyway...": "Trotzdem kalibrieren …",
|
||||
"Use Automatic Setup": "Automatische Einrichtung verwenden",
|
||||
"Shortcut: %s": "Tastenkürzel: %s",
|
||||
"Next": "Weiter",
|
||||
"HDR Calibration %s/%s: %s": "HDR-Kalibrierung %s/%s: %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Stellt die drei Werte darunter im Vollbild ein, jeweils mit einem Testbild.",
|
||||
"A key that opens this screen from the game. Not bound at first.": "Eine Taste, die diesen Bildschirm im Spiel öffnet. Zunächst nicht belegt.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Bildschirm meldete keine Helligkeit: zum Einrichten kalibrieren.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Bereits eingerichtet. Nur kalibrieren, wenn HDR falsch aussieht.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Stellt Spitzenhelligkeit und Papierweiß des Bildschirms wieder her.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Für den Bildschirm eingerichtet. Auto lassen, wenn es passt.",
|
||||
"Lower it until the logo just disappears into the black.": "Verringere den Wert, bis das Logo gerade im Schwarz verschwindet.",
|
||||
"Raise it until the logo just disappears into the bright square.": "Erhöhe den Wert, bis das Logo gerade im hellen Quadrat verschwindet.",
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Wähle ein Weiß, das bei deinem Licht lange angenehm ist.",
|
||||
"horizon.splitscreen.profiles.forget": "Entfernen",
|
||||
"horizon.splitscreen.profiles.create": "Erstellen",
|
||||
"horizon.splitscreen.profiles.you": " - du",
|
||||
"horizon.splitscreen.profiles.forget_named": "%s entfernen",
|
||||
"horizon.splitscreen.profiles.previous": "Zurück",
|
||||
"horizon.splitscreen.profiles.next": "Weiter",
|
||||
"horizon.splitscreen.profiles.new_button": "Neues Profil …",
|
||||
"horizon.splitscreen.profiles.forget_title": "Profil entfernen?",
|
||||
"horizon.splitscreen.profiles.forget_leaves": "Es verschwindet aus der Liste dieser Instanz.",
|
||||
"horizon.splitscreen.profiles.forget_kept": "Weltfortschritt und Einstellungen bleiben erhalten.",
|
||||
"horizon.splitscreen.profiles.new_title": "Neues Profil",
|
||||
"horizon.splitscreen.profiles.name_hint": "Name (3 bis 16 Buchstaben, Ziffern, _)",
|
||||
"horizon.splitscreen.profiles.switch_title": "Profil wechseln",
|
||||
"horizon.splitscreen.profiles.offline_note": "Offline-Profile gehören zu dieser Instanz",
|
||||
"horizon.splitscreen.profiles.move": "Spielort wechseln",
|
||||
"horizon.splitscreen.profiles.move_place": "Spielort wechseln: %s",
|
||||
"horizon.friends.title": "Mit Freunden spielen",
|
||||
"horizon.friends.hint": "Freunde fügen den Code nach dem Freunde-Knopf des Titelbildschirms ein, bei „Direktverbindung“ oder auf der Spielen-Seite des Launchers.",
|
||||
"horizon.friends.copied": "Kopiert",
|
||||
"horizon.friends.copy_code": "Code kopieren",
|
||||
"horizon.friends.stop_sharing": "Teilen beenden",
|
||||
"horizon.friends.none_yet": "Noch keine Freunde hier.",
|
||||
"horizon.friends.here_now": "Gerade hier: %s",
|
||||
"horizon.friends.share_intro": "Teile diese Welt mit einem Einladungscode: Sie wird im LAN geöffnet, und Freunde im Internet treten mit dem Code bei. Es ist kein Server von uns beteiligt.",
|
||||
"horizon.friends.start_sharing": "Teilen starten",
|
||||
"horizon.friends.troubleshooting": "Fehlerbehebung …",
|
||||
"horizon.friends.stopped": "Das Teilen des Einladungscodes wurde beendet.",
|
||||
"horizon.friends.short_code_changed": "Jemand hat immer wieder einen falschen Kurzcode versucht, deshalb wurde er geändert.",
|
||||
"horizon.friends.wrong_code": "Jemand hat einen falschen Code versucht.",
|
||||
"horizon.friends.code_ready": "Dein Einladungscode ist bereit: Spielmenü, Mit Freunden spielen. Kopiere ihn und schicke ihn an deine Freunde.",
|
||||
"horizon.friends.share_failed": "Die Welt konnte nicht geteilt werden: %s",
|
||||
"horizon.friends.finding_route": "Es wird ermittelt, wie Freunde dich erreichen können …",
|
||||
"horizon.osk.shift": "Umschalt",
|
||||
"horizon.osk.space": "Leertaste",
|
||||
"horizon.osk.delete": "Löschen",
|
||||
"horizon.osk.type": "Eingeben",
|
||||
"horizon.osk.symbols": "Symbole",
|
||||
"horizon.osk.close": "Schließen",
|
||||
"horizon.multiplayer.not_signed_in": "Dieser Server läuft im Online-Modus. Melde dich im Horizon-Launcher mit deinem Microsoft-Konto an, um ihm beizutreten.",
|
||||
"horizon.multiplayer.invalid_address": "Ungültige Serveradresse: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Direkt mit deinem Freund verbunden …",
|
||||
"horizon.multiplayer.connected_friend_relay": "Über das Relay mit deinem Freund verbunden …"
|
||||
}
|
||||
@@ -213,5 +213,98 @@
|
||||
"horizon.multiplayer.not_signed_in": "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it.",
|
||||
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
|
||||
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay..."
|
||||
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay...",
|
||||
"horizon.title.not_affiliated": "Not affiliated with Mojang or Microsoft",
|
||||
"horizon.extras.header.lighting": "Lighting",
|
||||
"horizon.extras.header.third_person": "Third person",
|
||||
"horizon.extras.header.particles": "Particles",
|
||||
"horizon.extras.header.inventory": "Inventory",
|
||||
"horizon.extras.header.chat": "Chat",
|
||||
"horizon.extras.header.multiplayer": "Multiplayer",
|
||||
"horizon.extras.header.sound": "Sound",
|
||||
"horizon.extras.fullbright": "Fullbright",
|
||||
"horizon.extras.dynamic_lights": "Dynamic lights",
|
||||
"horizon.extras.own_name_tag": "Own name tag",
|
||||
"horizon.extras.hide_block_particles": "Hide block-break particles",
|
||||
"horizon.extras.auto_reconnect": "Auto-reconnect",
|
||||
"horizon.extras.ui_sound_reserve": "Keep channels for UI sounds",
|
||||
"horizon.extras.mouse_tweaks": "Mouse Tweaks controls",
|
||||
"horizon.extras.mouse_tweaks_wheel": "Scroll wheel moves items",
|
||||
"horizon.extras.sort": "Sort buttons",
|
||||
"horizon.extras.sort_key": "Sort key",
|
||||
"horizon.extras.no_ai": "No AI",
|
||||
"horizon.extras.agent_control": "Agent Control",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.splitscreen": "Splitscreen",
|
||||
"horizon.extras.chat_animation": "Smooth chat",
|
||||
"horizon.extras.chat_indicator": "Message indicator",
|
||||
"horizon.extras.fullbright.tooltip": "Show blocks and entities at full light, even in dark areas.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Torches and other light sources held, dropped or on fire light the blocks around them.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible.",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Hides the debris particles that burst from a broken block and crumble off the one you mine. Other particles stay.",
|
||||
"horizon.extras.auto_reconnect.tooltip": "Rejoins a server after a lost connection or a restart, never after a kick or ban.",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Sounds in the world leave 16 of the 247 sound channels free, so a flood of them (a big explosion) can't silence menu clicks.",
|
||||
"horizon.extras.mouse_tweaks.tooltip": "Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot.",
|
||||
"horizon.extras.mouse_tweaks_wheel.tooltip": "Down sends one item of the slot to the other side, up brings one back.",
|
||||
"horizon.extras.sort.tooltip": "Sorts the inventory, chests, barrels, ender chests and shulker boxes in creative tab order, merging stacks.",
|
||||
"horizon.extras.sort_key.tooltip": "Sorts the part under the cursor. Click, then press a key; Escape unbinds it.",
|
||||
"horizon.extras.no_ai.tooltip": "Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too.",
|
||||
"horizon.extras.agent_control.tooltip": "Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only.",
|
||||
"horizon.extras.discord_presence.tooltip": "Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address.",
|
||||
"Calibrate...": "Calibrate...",
|
||||
"Calibrate Anyway...": "Calibrate Anyway...",
|
||||
"Use Automatic Setup": "Use Automatic Setup",
|
||||
"Shortcut: %s": "Shortcut: %s",
|
||||
"Next": "Next",
|
||||
"HDR Calibration %s/%s: %s": "HDR Calibration %s/%s: %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Sets the three below full screen, one test picture at a time.",
|
||||
"A key that opens this screen from the game. Not bound at first.": "A key that opens this screen from the game. Not bound at first.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Your display didn't say how bright it gets: calibrate to set it up.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Already set up for your display. Only calibrate if HDR looks wrong.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Puts Peak Brightness and Paper White back to your display's setup.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Set up for your display. Leave on Auto unless it looks wrong.",
|
||||
"Lower it until the logo just disappears into the black.": "Lower it until the logo just disappears into the black.",
|
||||
"Raise it until the logo just disappears into the bright square.": "Raise it until the logo just disappears into the bright square.",
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Make the card a white that's comfortable to look at for a long time, in the light you play in.",
|
||||
"horizon.splitscreen.profiles.forget": "Forget",
|
||||
"horizon.splitscreen.profiles.create": "Create",
|
||||
"horizon.splitscreen.profiles.you": " - you",
|
||||
"horizon.splitscreen.profiles.forget_named": "Forget %s",
|
||||
"horizon.splitscreen.profiles.previous": "Previous",
|
||||
"horizon.splitscreen.profiles.next": "Next",
|
||||
"horizon.splitscreen.profiles.new_button": "New Profile...",
|
||||
"horizon.splitscreen.profiles.forget_title": "Forget profile?",
|
||||
"horizon.splitscreen.profiles.forget_leaves": "It leaves this instance's list.",
|
||||
"horizon.splitscreen.profiles.forget_kept": "World progress and settings are kept.",
|
||||
"horizon.splitscreen.profiles.new_title": "New Profile",
|
||||
"horizon.splitscreen.profiles.name_hint": "Name (3 to 16 letters, digits, _)",
|
||||
"horizon.splitscreen.profiles.switch_title": "Switch profile",
|
||||
"horizon.splitscreen.profiles.offline_note": "Offline profiles belong to this instance",
|
||||
"horizon.splitscreen.profiles.move": "Move where you play",
|
||||
"horizon.splitscreen.profiles.move_place": "Move where you play: %s",
|
||||
"horizon.friends.title": "Play with Friends",
|
||||
"horizon.friends.hint": "Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
|
||||
"horizon.friends.copied": "Copied",
|
||||
"horizon.friends.copy_code": "Copy Code",
|
||||
"horizon.friends.stop_sharing": "Stop Sharing",
|
||||
"horizon.friends.none_yet": "No friends here yet.",
|
||||
"horizon.friends.here_now": "Here now: %s",
|
||||
"horizon.friends.share_intro": "Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
|
||||
"horizon.friends.start_sharing": "Start Sharing",
|
||||
"horizon.friends.troubleshooting": "Troubleshooting...",
|
||||
"horizon.friends.stopped": "Stopped sharing the invite code.",
|
||||
"horizon.friends.short_code_changed": "Someone kept trying a wrong short code, so it changed.",
|
||||
"horizon.friends.wrong_code": "Someone tried a wrong code.",
|
||||
"horizon.friends.code_ready": "Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
|
||||
"horizon.friends.share_failed": "Couldn't share the world: %s",
|
||||
"horizon.friends.finding_route": "Finding how friends can reach you…",
|
||||
"horizon.osk.shift": "Shift",
|
||||
"horizon.osk.space": "Space",
|
||||
"horizon.osk.delete": "Delete",
|
||||
"horizon.osk.type": "Type",
|
||||
"horizon.osk.symbols": "Symbols",
|
||||
"horizon.osk.close": "Close",
|
||||
"horizon.extras.chat_animation.tooltip": "A new chat line slides in from the left while it fades in, instead of appearing whole.",
|
||||
"horizon.extras.chat_indicator.tooltip": "The coloured bar at the left of a chat line.",
|
||||
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
{
|
||||
"horizon.options.controller": "Manette",
|
||||
"horizon.options.vibration": "Vibrations",
|
||||
"horizon.options.adaptive_triggers": "Gâchettes adaptatives",
|
||||
"horizon.options.lightbar": "Barre lumineuse",
|
||||
"horizon.options.look_sensitivity": "Sensibilité de la vue",
|
||||
"horizon.options.invert_x": "Inverser l'axe X de la vue",
|
||||
"horizon.options.invert_y": "Inverser l'axe Y de la vue",
|
||||
"horizon.options.sneak_button": "Bouton pour s'accroupir",
|
||||
"horizon.options.move_deadzone": "Zone morte du déplacement",
|
||||
"horizon.options.look_deadzone": "Zone morte de la vue",
|
||||
"horizon.options.button_glyphs": "Symboles des boutons",
|
||||
"horizon.options.menu_navigation": "Navigation dans les menus",
|
||||
"horizon.options.on_screen_keyboard": "Clavier à l'écran",
|
||||
"horizon.options.chat_delay": "Délai du tchat",
|
||||
"horizon.options.lightbar.health": "Santé",
|
||||
"horizon.options.lightbar.dimension": "Dimension",
|
||||
"horizon.options.glyphs.auto": "Auto",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Focus",
|
||||
"horizon.options.menu_pointer": "Pointeur",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Cette fonction n'est pas encore disponible dans Horizon.",
|
||||
"horizon.options.search": "Rechercher une option...",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Packs de shaders",
|
||||
"horizon.pages.shader_options": "Options des shaders",
|
||||
"horizon.pages.controller": "Options de la manette",
|
||||
"horizon.pages.controller_binds": "Assignation des boutons de la manette",
|
||||
"horizon.pages.extras": "Extras Horizon",
|
||||
"horizon.pages.settings": "Options Horizon",
|
||||
"horizon.pages.agent": "Agent IA",
|
||||
"horizon.pages.split_profiles": "Qui joue ?",
|
||||
"horizon.pages.settings.tooltip": "Les options propres à Horizon : graphismes, manettes, extras et mods.",
|
||||
"horizon.pages.graphics.tooltip": "Les graphismes, simplement : chaque réglage indique son coût.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Tous les réglages graphiques, y compris ceux d'Horizon, avec le coût de chacun.",
|
||||
"horizon.pages.controller.tooltip": "Les manettes : leurs boutons, leurs sticks, les vibrations et la navigation dans les menus.",
|
||||
"horizon.pages.extras.tooltip": "Ajouts facultatifs, tous désactivés par défaut : le jeu reste vanilla tant que vous n'en activez pas un.",
|
||||
"horizon.pages.agent.tooltip": "Laissez votre propre agent IA jouer : horizon-ctl et les clients MCP sur cet ordinateur.",
|
||||
"horizon.pages.split_profiles.tooltip": "Qui joue : un profil conservé dans ce dossier de jeu.",
|
||||
"horizon.pages.shader_packs.tooltip": "Packs de shaders du dossier shaderpacks : ceux d'Horizon et ceux au format shaders/. Un seul fonctionne à la fois.",
|
||||
"horizon.pages.mods.tooltip": "Mods conçus pour Horizon, du dossier horizon-mods. Chacun s'exécute dans un bac à sable et reste désactivé tant que vous ne l'activez pas.",
|
||||
"horizon.pages.open.graphics": "Graphismes...",
|
||||
"horizon.pages.open.graphics_advanced": "Graphismes avancés...",
|
||||
"horizon.pages.open.shader_packs": "Packs de shaders...",
|
||||
"horizon.pages.open.controller": "Options de la manette...",
|
||||
"horizon.pages.open.extras": "Extras...",
|
||||
"horizon.pages.open.mods": "Mods...",
|
||||
"horizon.pages.open.agent": "Agent IA...",
|
||||
"horizon.pages.open.horizon_graphics": "Graphismes Horizon...",
|
||||
"horizon.pages.open.controller_binds": "Boutons manette...",
|
||||
"horizon.pages.open.horizon_settings": "Options Horizon...",
|
||||
"horizon.mods.empty": "Aucun mod : placez des fichiers .hmod dans le dossier horizon-mods",
|
||||
"horizon.mods.stopped": " (arrêté)",
|
||||
"horizon.mods.not_built": " (non compilé)",
|
||||
"horizon.mods.dev": " (dév.)",
|
||||
"horizon.mods.settings": "Options",
|
||||
"horizon.mods.open_folder": "Ouvrir le dossier des mods",
|
||||
"horizon.shader_packs.empty": "Aucun pack de shaders : placez-les dans le dossier shaderpacks",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.shader_options.open": "Options des shaders...",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"horizon.shader_options.loading": "Lecture des options du pack de shaders...",
|
||||
"horizon.agent.copy_details": "Copier les détails de connexion",
|
||||
"horizon.multiplayer.join": "Rejoindre",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Annuler la reconnexion",
|
||||
"horizon.multiplayer.join_friend": "Rejoindre un ami",
|
||||
"horizon.multiplayer.invite_code": "Code d'invitation",
|
||||
"horizon.multiplayer.relay": "Relais (facultatif)",
|
||||
"Preset": "Préréglage",
|
||||
"LOD Distance": "Distance LOD",
|
||||
"Far Detail": "Détails lointains",
|
||||
"Cave Culling": "Occlusion des grottes",
|
||||
"Entity Culling": "Occlusion des entités",
|
||||
"Chunk Updates": "MàJ chunks",
|
||||
"Upload Budget": "Transfert",
|
||||
"Sky": "Ciel",
|
||||
"Fog": "Brouillard",
|
||||
"Weather": "Météo",
|
||||
"Animated Textures": "Textures animées",
|
||||
"Present Mode": "Présentation",
|
||||
"Frame Pacing": "Rythme des images",
|
||||
"Worker Threads": "Threads",
|
||||
"Resolution Scale": "Échelle de résolution",
|
||||
"Anti-aliasing": "Anticrénelage",
|
||||
"Upscaling": "Upscaling",
|
||||
"HDR Settings...": "Options HDR...",
|
||||
"Paper White": "Point blanc",
|
||||
"Peak Brightness": "Luminosité max.",
|
||||
"Black Level": "Niveau de noir",
|
||||
"UI Brightness": "Luminosité UI",
|
||||
"Battery Mode": "Mode batterie",
|
||||
"Graphics API": "API graphique",
|
||||
"Performance Overlay": "Infos de perfs",
|
||||
"Full Detail Distance": "Dist. détail",
|
||||
"On": "Oui",
|
||||
"Off": "Non",
|
||||
"Unlimited": "Illimité",
|
||||
"Auto": "Auto",
|
||||
"Custom": "Personnalisé",
|
||||
"Performance": "Performances",
|
||||
"Balanced": "Équilibré",
|
||||
"Quality": "Qualité",
|
||||
"Ultra": "Ultra",
|
||||
"Ultra Performance": "Ultra perfs",
|
||||
"Native": "Native",
|
||||
"Full": "Complet",
|
||||
"Reduced": "Réduit",
|
||||
"Efficient": "Économe",
|
||||
"Precise": "Précis",
|
||||
"Auto (VSync)": "Auto (VSync)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "Mailbox",
|
||||
"Immediate": "Immediate",
|
||||
"Vulkan": "Vulkan",
|
||||
"DirectX 12": "DirectX 12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS et graphique",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "Aucun coût",
|
||||
"Low cost": "Coût faible",
|
||||
"Medium cost": "Coût moyen",
|
||||
"High cost": "Coût élevé",
|
||||
"Saves time": "Fait gagner du temps",
|
||||
"Rendering": "Rendu",
|
||||
"Display": "Affichage",
|
||||
"Preferences": "Préférences",
|
||||
"Overlay": "Surimpression",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "Graphismes",
|
||||
"Graphics: Advanced": "Graphismes : avancés",
|
||||
"Graphics: HDR": "Graphismes : HDR",
|
||||
"Advanced...": "Avancé...",
|
||||
"Simple...": "Simple...",
|
||||
"Done": "Terminé",
|
||||
"This feature is not yet available in Horizon.": "Cette fonction n'est pas encore disponible dans Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Votre écran ou votre système ne propose pas de sortie HDR.",
|
||||
"Turn HDR on to use it.": "Activez le HDR pour l'utiliser.",
|
||||
"%s chunks": "%s chunks",
|
||||
"%s per frame": "%s par image",
|
||||
"%s MiB/frame": "%s MiB/image",
|
||||
"%s KiB/frame": "%s KiB/image",
|
||||
"%s nits": "%s nits",
|
||||
"Auto (%s)": "Auto (%s)",
|
||||
"Auto (%s nits)": "Auto (%s nits)",
|
||||
"%.2f nits": "%.2f nits",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Terrain en détail complet. La distance LOD va plus loin.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terrain lointain simplifié, mondes locaux. GPU intégrés : 128 max.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Réduit : terrain lointain simplifié, plus rapide sur ce GPU.",
|
||||
"Reduced draws far terrain coarser, for GPUs it slows down.": "Réduit : terrain simplifié, pour les GPU qui ralentissent.",
|
||||
"Field of view. Wider shows more terrain at once.": "Champ de vision. Plus large, il montre plus de terrain à la fois.",
|
||||
"Caps frames per second; a cap saves power and heat.": "Limite les images par seconde ; économise énergie et chaleur.",
|
||||
"Waits for the display's refresh: no tearing, a little latency.": "Attend l'écran : pas de déchirure, un peu de latence.",
|
||||
"Size of menus and the HUD.": "Taille des menus et de l'interface (HUD).",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Règle les options ci-dessous. Modifier une : « Personnalisé ».",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lave, feu et soleil plus vifs que le blanc. Écran HDR requis.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Noir le plus profond de l'écran. 0 pour OLED, les LCD brillent.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "La luminosité des hautes lumières. Auto : le pic indiqué par votre système.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Luminosité du blanc et des menus. Auto : blanc du bureau.",
|
||||
"How bright the HUD and menus are, against paper white.": "La luminosité du HUD et des menus, par rapport au blanc papier.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Portée d'activité des créatures, cultures, machines. Coût : CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Distance des créatures et objets, en part de la normale.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Ignore le terrain caché. Désactiver coûte cher.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Masque les modèles derrière les blocs. Éteindre avec packs.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Sections maillées par image. Moins : chargement plus fluide.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Ombres douces dans les coins. Coût : construction des chunks.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Textures réduites au loin : moins de scintillement.",
|
||||
"Blends grass and water colours across biomes when building chunks.": "Fond les couleurs d'herbe et d'eau entre les biomes.",
|
||||
"Cloud layer: off, flat (fast) or 3D (fancy).": "Couche de nuages : désactivée, plate (rapide) ou 3D (détaillée).",
|
||||
"How far clouds reach, in chunks.": "Jusqu'où s'étendent les nuages, en chunks.",
|
||||
"Draws the sky, sun, moon and stars.": "Dessine le ciel, le soleil, la lune et les étoiles.",
|
||||
"Distance fog. Off shows the edge of the loaded world.": "Brouillard lointain. Désactivé, il montre le bord du monde chargé.",
|
||||
"Draws rain and snow.": "Dessine la pluie et la neige.",
|
||||
"How far rain and snow fall around you.": "Jusqu'où la pluie et la neige tombent autour de vous.",
|
||||
"How many particles effects spawn.": "Combien de particules les effets génèrent.",
|
||||
"Round shadows under mobs and items.": "Ombres rondes sous les créatures et les objets.",
|
||||
"Leaves with see-through gaps. Off draws them solid.": "Feuilles avec des trous transparents. Désactivé, elles sont pleines.",
|
||||
"Sorts water and glass more exactly behind each other.": "Trie plus précisément l'eau et le verre les uns derrière les autres.",
|
||||
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Quels chunks attendent l'image : aucun, proches ou les vôtres.",
|
||||
"Animates water, lava, fire and other moving textures.": "Anime l'eau, la lave, le feu et les autres textures mobiles.",
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Pixels du monde par pixel d'écran. Sous 100 %, GPU plus rapide.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Lisse les bords. FXAA : doux. TAA : lisse, traînées possibles.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Rendu réduit, reconstruit par TAA. Plus rapide, plus doux.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Sortie HDR sur écran HDR : lave, feu et soleil plus vifs.",
|
||||
"How frames reach the screen while VSync is off.": "Comment les images arrivent à l'écran sans synchro verticale.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Économe attend l'image suivante ; Précis tourne en boucle.",
|
||||
"Fills the screen (borderless).": "Remplit l'écran (sans bordure).",
|
||||
"Brightens dark places.": "Éclaircit les endroits sombres.",
|
||||
"Lowers the frame rate when minimised, or idle too.": "Réduit les FPS si la fenêtre est réduite, ou aussi inactive.",
|
||||
"Sways the view while walking.": "Fait osciller la vue pendant la marche.",
|
||||
"Fades newly loaded chunks in.": "Fait apparaître en fondu les chunks nouvellement chargés.",
|
||||
"Darkens the screen's edges.": "Assombrit les bords de l'écran.",
|
||||
"Shows when the world saves.": "Indique quand le monde est sauvegardé.",
|
||||
"Shows attack cooldown.": "Affiche le délai de recharge de l'attaque.",
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS max (30 en menu/sans focus), moitié de threads : économie.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Threads de génération, lumière et maillage. Au redémarrage.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sinon DirectX 12 (Windows) ou OpenGL (Linux). Au redémarrage.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Données de maillage vers le GPU par image. Moins : plus fluide.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Fréquence et temps d'image par-dessus le jeu, avec un graphique.",
|
||||
"horizon.multiplayer.finding_friend": "Recherche du monde de votre ami...",
|
||||
"horizon.loading.opening_world": "Ouverture du monde",
|
||||
"horizon.loading.cancelling_world": "Annulation en cours",
|
||||
"horizon.title.not_affiliated": "Non affilié à Mojang ou à Microsoft",
|
||||
"horizon.extras.header.lighting": "Éclairage",
|
||||
"horizon.extras.header.third_person": "Troisième personne",
|
||||
"horizon.extras.header.inventory": "Inventaire",
|
||||
"horizon.extras.header.multiplayer": "Multijoueur",
|
||||
"horizon.extras.fullbright": "Pleine luminosité",
|
||||
"horizon.extras.dynamic_lights": "Lumières dynamiques",
|
||||
"horizon.extras.own_name_tag": "Mon pseudo",
|
||||
"horizon.extras.auto_reconnect": "Reconnexion auto",
|
||||
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
|
||||
"horizon.extras.mouse_tweaks_wheel": "Déplacer à la molette",
|
||||
"horizon.extras.sort": "Boutons de tri",
|
||||
"horizon.extras.sort_key": "Touche de tri",
|
||||
"horizon.extras.no_ai": "Sans IA",
|
||||
"horizon.extras.agent_control": "Contrôle par agent",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.fullbright.tooltip": "Affiche les blocs et les entités en pleine lumière, même dans les zones sombres.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Les torches et autres sources de lumière tenues en main, jetées ou en feu éclairent les blocs autour d'elles.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Affiche votre nom au-dessus de votre tête à la troisième personne, comme les autres joueurs le voient. Masqué quand le serveur masque les noms de votre équipe ou que vous êtes invisible.",
|
||||
"horizon.extras.auto_reconnect.tooltip": "Rejoint de nouveau un serveur après une perte de connexion ou un redémarrage, jamais après une expulsion ou un bannissement.",
|
||||
"horizon.extras.mouse_tweaks.tooltip": "Glisser avec le clic droit en place un par case, glisser avec le clic gauche rassemble l'objet tenu, Maj + glisser avec le clic gauche déplace chaque case.",
|
||||
"horizon.extras.mouse_tweaks_wheel.tooltip": "Vers le bas, envoie un objet de la case de l'autre côté ; vers le haut, en ramène un.",
|
||||
"horizon.extras.sort.tooltip": "Trie l'inventaire, les coffres, les tonneaux, les coffres de l'Ender et les boîtes de Shulker selon l'ordre des onglets du mode créatif, en fusionnant les piles.",
|
||||
"horizon.extras.sort_key.tooltip": "Trie la zone sous le curseur. Cliquez, puis appuyez sur une touche ; Échap retire l'assignation.",
|
||||
"horizon.extras.no_ai.tooltip": "Masque et désactive toutes les fonctions d'IA : contrôle par agent, horizon-ctl et les serveurs MCP. Le commutateur du lanceur aussi.",
|
||||
"horizon.extras.agent_control.tooltip": "Votre propre agent IA peut jouer à ce jeu via horizon-ctl ou un client MCP, uniquement sur cet ordinateur.",
|
||||
"horizon.extras.discord_presence.tooltip": "Affiche « Joue à Horizon » sur votre profil Discord tant que Discord fonctionne : menus, solo ou multijoueur, la dimension et le temps de jeu. Jamais le nom d'un monde ni l'adresse d'un serveur.",
|
||||
"Calibrate...": "Calibrer...",
|
||||
"Calibrate Anyway...": "Calibrer quand même...",
|
||||
"Use Automatic Setup": "Utiliser la configuration automatique",
|
||||
"Shortcut: %s": "Raccourci : %s",
|
||||
"Next": "Suivant",
|
||||
"HDR Calibration %s/%s: %s": "Calibrage HDR %s/%s : %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Règle les trois valeurs ci-dessous en plein écran, une image test à la fois.",
|
||||
"A key that opens this screen from the game. Not bound at first.": "Une touche qui ouvre cet écran depuis le jeu. Non assignée au départ.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Votre écran n'a pas indiqué sa luminosité : calibrez-le.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Déjà configuré. Calibrez seulement si le HDR semble incorrect.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Rétablit Luminosité max. et Point blanc de votre écran.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Configuré pour votre écran. Laissez Auto si tout va bien.",
|
||||
"Lower it until the logo just disappears into the black.": "Baissez-le jusqu'à ce que le logo disparaisse tout juste dans le noir.",
|
||||
"Raise it until the logo just disappears into the bright square.": "Augmentez jusqu'à ce que le logo se fonde dans le carré clair.",
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Un blanc agréable à regarder longtemps, selon votre éclairage.",
|
||||
"horizon.splitscreen.profiles.forget": "Oublier",
|
||||
"horizon.splitscreen.profiles.create": "Créer",
|
||||
"horizon.splitscreen.profiles.you": " - vous",
|
||||
"horizon.splitscreen.profiles.forget_named": "Oublier %s",
|
||||
"horizon.splitscreen.profiles.previous": "Précédent",
|
||||
"horizon.splitscreen.profiles.next": "Suivant",
|
||||
"horizon.splitscreen.profiles.new_button": "Nouveau profil...",
|
||||
"horizon.splitscreen.profiles.forget_title": "Oublier le profil ?",
|
||||
"horizon.splitscreen.profiles.forget_leaves": "Il quitte la liste de cette instance.",
|
||||
"horizon.splitscreen.profiles.forget_kept": "La progression des mondes et les options sont conservées.",
|
||||
"horizon.splitscreen.profiles.new_title": "Nouveau profil",
|
||||
"horizon.splitscreen.profiles.name_hint": "Nom (3 à 16 lettres, chiffres ou _)",
|
||||
"horizon.splitscreen.profiles.switch_title": "Changer de profil",
|
||||
"horizon.splitscreen.profiles.offline_note": "Les profils hors ligne appartiennent à cette instance",
|
||||
"horizon.splitscreen.profiles.move": "Changer d'emplacement de jeu",
|
||||
"horizon.splitscreen.profiles.move_place": "Changer d'emplacement de jeu : %s",
|
||||
"horizon.friends.title": "Jouer avec des amis",
|
||||
"horizon.friends.hint": "Vos amis collent le code après le bouton Amis de l'écran titre, dans « Connexion rapide » ou sur la page Jouer du lanceur.",
|
||||
"horizon.friends.copied": "Copié",
|
||||
"horizon.friends.copy_code": "Copier le code",
|
||||
"horizon.friends.stop_sharing": "Arrêter le partage",
|
||||
"horizon.friends.none_yet": "Aucun ami pour l'instant.",
|
||||
"horizon.friends.here_now": "Présents : %s",
|
||||
"horizon.friends.share_intro": "Partagez ce monde avec un code d'invitation : il s'ouvre au LAN, et vos amis sur internet le rejoignent avec le code. Aucun serveur de notre part n'est impliqué.",
|
||||
"horizon.friends.start_sharing": "Commencer le partage",
|
||||
"horizon.friends.troubleshooting": "Dépannage...",
|
||||
"horizon.friends.stopped": "Le partage du code d'invitation est arrêté.",
|
||||
"horizon.friends.short_code_changed": "Quelqu'un a essayé plusieurs fois un mauvais code court, il a donc été changé.",
|
||||
"horizon.friends.wrong_code": "Quelqu'un a essayé un mauvais code.",
|
||||
"horizon.friends.code_ready": "Votre code d'invitation est prêt : Menu du jeu, Jouer avec des amis. Copiez-le et envoyez-le à vos amis.",
|
||||
"horizon.friends.share_failed": "Impossible de partager le monde : %s",
|
||||
"horizon.friends.finding_route": "Recherche de la manière dont vos amis peuvent vous joindre…",
|
||||
"horizon.osk.shift": "Maj.",
|
||||
"horizon.osk.space": "Espace",
|
||||
"horizon.osk.delete": "Effacer",
|
||||
"horizon.osk.type": "Saisir",
|
||||
"horizon.osk.symbols": "Symboles",
|
||||
"horizon.osk.close": "Fermer",
|
||||
"horizon.multiplayer.not_signed_in": "Ce serveur est en mode en ligne. Connectez-vous avec votre compte Microsoft dans le lanceur Horizon pour le rejoindre.",
|
||||
"horizon.multiplayer.invalid_address": "Adresse de serveur invalide : %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Connecté directement à votre ami...",
|
||||
"horizon.multiplayer.connected_friend_relay": "Connecté à votre ami via le relais..."
|
||||
}
|
||||
@@ -12,6 +12,197 @@
|
||||
"horizon.options.button_glyphs": "Symbole przycisków",
|
||||
"horizon.options.menu_navigation": "Nawigacja po menu",
|
||||
"horizon.options.on_screen_keyboard": "Klawiatura ekranowa",
|
||||
"horizon.options.chat_delay": "Opóźnienie czatu",
|
||||
"horizon.options.lightbar.health": "Zdrowie",
|
||||
"horizon.options.lightbar.dimension": "Wymiar",
|
||||
"horizon.options.glyphs.auto": "Auto",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.menu_pointer": "Wskaźnik",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Ta funkcja nie jest jeszcze dostępna w Horizon.",
|
||||
"horizon.options.search": "Szukaj ustawień…",
|
||||
"horizon.pages.mods": "Mody",
|
||||
"horizon.pages.shader_packs": "Paczki shaderów",
|
||||
"horizon.pages.shader_options": "Ustawienia shaderów",
|
||||
"horizon.pages.controller": "Ustawienia kontrolera",
|
||||
"horizon.pages.controller_binds": "Przypisane przyciski kontrolera",
|
||||
"horizon.pages.extras": "Dodatki Horizon",
|
||||
"horizon.pages.settings": "Ustawienia Horizon",
|
||||
"horizon.pages.agent": "Agent AI",
|
||||
"horizon.pages.split_profiles": "Kto gra?",
|
||||
"horizon.pages.settings.tooltip": "Własne ustawienia Horizon: grafika, kontrolery, dodatki i mody.",
|
||||
"horizon.pages.graphics.tooltip": "Grafika w prostej formie: każde ustawienie podaje swój koszt.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Wszystkie ustawienia grafiki, także te z Horizon, wraz z kosztem każdego z nich.",
|
||||
"horizon.pages.controller.tooltip": "Kontrolery: ich przyciski, drążki, wibracje i nawigacja po menu.",
|
||||
"horizon.pages.extras.tooltip": "Opcjonalne dodatki, wszystkie domyślnie wyłączone: gra pozostaje vanilla, dopóki nie włączysz któregoś.",
|
||||
"horizon.pages.agent.tooltip": "Pozwól własnemu agentowi AI grać: horizon-ctl i klienci MCP na tym komputerze.",
|
||||
"horizon.pages.split_profiles.tooltip": "Kto gra: profil przechowywany w tym folderze gry.",
|
||||
"horizon.pages.shader_packs.tooltip": "Paczki shaderów z folderu shaderpacks: własne paczki Horizon i te w formacie shaders/. Naraz działa jedna.",
|
||||
"horizon.pages.mods.tooltip": "Mody stworzone dla Horizon, z folderu horizon-mods. Każdy działa w piaskownicy i pozostaje wyłączony, dopóki go nie włączysz.",
|
||||
"horizon.pages.open.graphics": "Grafika…",
|
||||
"horizon.pages.open.graphics_advanced": "Zaawansowana grafika…",
|
||||
"horizon.pages.open.shader_packs": "Paczki shaderów…",
|
||||
"horizon.pages.open.controller": "Ustawienia kontrolera…",
|
||||
"horizon.pages.open.extras": "Dodatki…",
|
||||
"horizon.pages.open.mods": "Mody…",
|
||||
"horizon.pages.open.agent": "Agent AI…",
|
||||
"horizon.pages.open.horizon_graphics": "Grafika Horizon…",
|
||||
"horizon.pages.open.controller_binds": "Przyciski kontrolera…",
|
||||
"horizon.pages.open.horizon_settings": "Ustawienia Horizon…",
|
||||
"horizon.mods.empty": "Brak modów: umieść pliki .hmod w folderze horizon-mods",
|
||||
"horizon.mods.stopped": " (zatrzymany)",
|
||||
"horizon.mods.not_built": " (niezbudowany)",
|
||||
"horizon.mods.dev": " (dev)",
|
||||
"horizon.mods.settings": "Ustawienia",
|
||||
"horizon.mods.open_folder": "Otwórz folder z modami",
|
||||
"horizon.shader_packs.empty": "Brak paczek shaderów: umieść je w folderze shaderpacks",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.shader_options.open": "Ustawienia shaderów…",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"horizon.shader_options.loading": "Wczytywanie ustawień paczki shaderów…",
|
||||
"horizon.agent.copy_details": "Kopiuj dane połączenia",
|
||||
"horizon.multiplayer.join": "Dołącz",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Anuluj ponowne łączenie",
|
||||
"horizon.multiplayer.join_friend": "Dołącz do znajomego",
|
||||
"horizon.multiplayer.invite_code": "Kod zaproszenia",
|
||||
"horizon.multiplayer.relay": "Przekaźnik (opcjonalnie)",
|
||||
"Preset": "Profil",
|
||||
"LOD Distance": "Zasięg LOD",
|
||||
"Far Detail": "Dalekie detale",
|
||||
"Cave Culling": "Pomijanie jaskiń",
|
||||
"Entity Culling": "Pomijanie bytów",
|
||||
"Chunk Updates": "Aktual. chunków",
|
||||
"Upload Budget": "Wysyłka",
|
||||
"Sky": "Niebo",
|
||||
"Fog": "Mgła",
|
||||
"Weather": "Pogoda",
|
||||
"Animated Textures": "Animowane tekstury",
|
||||
"Present Mode": "Prezentacja",
|
||||
"Frame Pacing": "Tempo klatek",
|
||||
"Worker Threads": "Wątki robocze",
|
||||
"Resolution Scale": "Skala rozdzielczości",
|
||||
"Anti-aliasing": "Antyaliasing",
|
||||
"Upscaling": "Skalowanie",
|
||||
"HDR Settings...": "Ustawienia HDR…",
|
||||
"Paper White": "Punkt bieli",
|
||||
"Peak Brightness": "Jasność maks.",
|
||||
"Black Level": "Poziom czerni",
|
||||
"UI Brightness": "Jasność interfejsu",
|
||||
"Battery Mode": "Tryb baterii",
|
||||
"Graphics API": "API grafiki",
|
||||
"Performance Overlay": "Nakładka wydajności",
|
||||
"Full Detail Distance": "Zasięg detali",
|
||||
"On": "Wł.",
|
||||
"Off": "Wył.",
|
||||
"Unlimited": "Bez limitu",
|
||||
"Auto": "Auto",
|
||||
"Custom": "Własne",
|
||||
"Performance": "Wydajność",
|
||||
"Balanced": "Zrównoważone",
|
||||
"Quality": "Jakość",
|
||||
"Ultra": "Ultra",
|
||||
"Ultra Performance": "Ultra wydajność",
|
||||
"Native": "Natywna",
|
||||
"Full": "Pełne",
|
||||
"Reduced": "Ograniczone",
|
||||
"Efficient": "Oszczędne",
|
||||
"Precise": "Dokładne",
|
||||
"Auto (VSync)": "Auto (VSync)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "Mailbox",
|
||||
"Immediate": "Immediate",
|
||||
"Vulkan": "Vulkan",
|
||||
"DirectX 12": "DirectX 12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS i wykres",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "Bez kosztu",
|
||||
"Low cost": "Niski koszt",
|
||||
"Medium cost": "Średni koszt",
|
||||
"High cost": "Wysoki koszt",
|
||||
"Saves time": "Oszczędza czas",
|
||||
"Rendering": "Renderowanie",
|
||||
"Display": "Wyświetlanie",
|
||||
"Preferences": "Preferencje",
|
||||
"Overlay": "Nakładka",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "Grafika",
|
||||
"Graphics: Advanced": "Grafika: zaawansowane",
|
||||
"Graphics: HDR": "Grafika: HDR",
|
||||
"Advanced...": "Zaawansowane…",
|
||||
"Simple...": "Proste…",
|
||||
"Done": "Gotowe",
|
||||
"This feature is not yet available in Horizon.": "Ta funkcja nie jest jeszcze dostępna w Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Twój wyświetlacz lub system nie obsługuje wyjścia HDR.",
|
||||
"Turn HDR on to use it.": "Włącz HDR, aby tego użyć.",
|
||||
"%s chunks": "%s chunków",
|
||||
"%s per frame": "%s na klatkę",
|
||||
"%s MiB/frame": "%s MiB/klatka",
|
||||
"%s KiB/frame": "%s KiB/klatka",
|
||||
"%s nits": "%s nitów",
|
||||
"Auto (%s)": "Auto (%s)",
|
||||
"Auto (%s nits)": "Auto (%s nitów)",
|
||||
"%.2f nits": "%.2f nitów",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Zasięg terenu w pełnych szczegółach. Zasięg LOD sięga dalej.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Uproszczony odległy teren w światach lokalnych. Zintegr. GPU: do 128.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Ograniczone: odległy teren rysowany prościej, szybciej na tym GPU.",
|
||||
"Reduced draws far terrain coarser, for GPUs it slows down.": "Ograniczone: odległy teren prościej, dla GPU, które zwalniają.",
|
||||
"Field of view. Wider shows more terrain at once.": "Pole widzenia. Szersze pokazuje naraz więcej terenu.",
|
||||
"Caps frames per second; a cap saves power and heat.": "Ogranicza klatki na sekundę; limit oszczędza energię i ciepło.",
|
||||
"Waits for the display's refresh: no tearing, a little latency.": "Czeka na odświeżenie ekranu: bez rozrywania obrazu, z niewielkim opóźnieniem.",
|
||||
"Size of menus and the HUD.": "Rozmiar menu i interfejsu HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Ustawia razem poniższe opcje. Zmiana jednej zmienia na „Własne”.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lawa, ogień i słońce świecą jaśniej niż biel. Wymaga ekranu HDR.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Najciemniejsza czerń twojego wyświetlacza. 0 dla OLED; LCD trochę się świecą.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Jak jasne mogą być światła. Auto: szczyt zgłaszany przez system.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Jak jasny wygląda biały papier i menu. Auto: biel twojego pulpitu.",
|
||||
"How bright the HUD and menus are, against paper white.": "Jak jasne są HUD i menu w stosunku do bieli papieru.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Jak daleko stworzenia, uprawy i maszyny nadal działają. Czas procesora.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Odległość rysowania stworzeń i przedmiotów, jako ułamek normy.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Pomija teren ukryty za zamkniętymi sekcjami. Wyłączone dużo kosztuje.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Ukrywa modele za pełnymi blokami. Wył. przy własnych paczkach.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Liczba sekcji siatki wysyłanych na klatkę. Mniej to płynniej podczas ładowania.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Miękkie cienie w narożnikach. Kosztuje tylko czas budowania chunków.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Mniejsze kopie tekstur bloków dla odległego terenu: mniej migotania.",
|
||||
"Blends grass and water colours across biomes when building chunks.": "Przenika kolory trawy i wody między biomami podczas budowania chunków.",
|
||||
"Cloud layer: off, flat (fast) or 3D (fancy).": "Warstwa chmur: wyłączona, płaska (szybka) lub 3D (dokładna).",
|
||||
"How far clouds reach, in chunks.": "Jak daleko sięgają chmury, w chunkach.",
|
||||
"Draws the sky, sun, moon and stars.": "Rysuje niebo, słońce, księżyc i gwiazdy.",
|
||||
"Distance fog. Off shows the edge of the loaded world.": "Mgła w oddali. Wyłączona pokazuje krawędź załadowanego świata.",
|
||||
"Draws rain and snow.": "Rysuje deszcz i śnieg.",
|
||||
"How far rain and snow fall around you.": "Jak daleko wokół ciebie pada deszcz i śnieg.",
|
||||
"How many particles effects spawn.": "Ile cząstek tworzą efekty.",
|
||||
"Round shadows under mobs and items.": "Okrągłe cienie pod stworzeniami i przedmiotami.",
|
||||
"Leaves with see-through gaps. Off draws them solid.": "Liście z prześwitami. Wyłączone rysuje je jako pełne.",
|
||||
"Sorts water and glass more exactly behind each other.": "Dokładniej sortuje wodę i szkło jedno za drugim.",
|
||||
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Które przebudowy chunków czekają na klatkę: żadne, pobliskie lub twoje.",
|
||||
"Animates water, lava, fire and other moving textures.": "Animuje wodę, lawę, ogień i inne ruchome tekstury.",
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Piksele świata na piksel ekranu. Poniżej 100% GPU działa szybciej.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Wygładza krawędzie. FXAA: miękko. TAA: najgładziej, możliwe smugi.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renderuje mniejszy świat, odtwarza przez TAA. Szybciej, miękko.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Wyjście HDR na ekranie HDR: lawa, ogień i słońce świecą jaśniej.",
|
||||
"How frames reach the screen while VSync is off.": "Jak klatki trafiają na ekran, gdy VSync jest wyłączony.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Oszczędne usypia do następnej klatki; Dokładne czeka aktywnie.",
|
||||
"Fills the screen (borderless).": "Wypełnia ekran (bez ramki).",
|
||||
"Brightens dark places.": "Rozjaśnia ciemne miejsca.",
|
||||
"Lowers the frame rate when minimised, or idle too.": "Obniża liczbę klatek po zminimalizowaniu, a także podczas bezczynności.",
|
||||
"Sways the view while walking.": "Kołysze widokiem podczas chodzenia.",
|
||||
"Fades newly loaded chunks in.": "Płynnie pokazuje nowo załadowane chunki.",
|
||||
"Darkens the screen's edges.": "Przyciemnia krawędzie ekranu.",
|
||||
"Shows when the world saves.": "Pokazuje, kiedy świat się zapisuje.",
|
||||
"Shows attack cooldown.": "Pokazuje czas odnowienia ataku.",
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Limit 60 FPS (30 w menu/bez fokusu), połowa wątków: mniej energii.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Wątki generujące, oświetlające i meshujące chunki. Po restarcie.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, awaryjnie DirectX 12 (Windows) / OpenGL (Linux). Po restarcie.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Dane siatki wysyłane do GPU na klatkę. Mniej to płynniej podczas ładowania.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Liczba klatek i czasy klatek nad grą, z wykresem.",
|
||||
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
|
||||
"horizon.loading.opening_world": "Otwieranie świata",
|
||||
"horizon.loading.cancelling_world": "Anulowanie",
|
||||
@@ -19,10 +210,84 @@
|
||||
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…",
|
||||
"horizon.pages.shader_options": "Ustawienia shaderów",
|
||||
"horizon.pages.split_profiles": "Kto gra?",
|
||||
"horizon.pages.split_profiles.tooltip": "Kto gra: profil przechowywany w tym folderze gry.",
|
||||
"horizon.shader_options.open": "Ustawienia shaderów…",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"horizon.shader_options.loading": "Wczytywanie ustawień paczki shaderów…"
|
||||
"horizon.title.not_affiliated": "Niepowiązane z Mojang ani Microsoft",
|
||||
"horizon.extras.header.lighting": "Oświetlenie",
|
||||
"horizon.extras.header.third_person": "Widok z trzeciej osoby",
|
||||
"horizon.extras.header.inventory": "Ekwipunek",
|
||||
"horizon.extras.header.multiplayer": "Tryb wieloosobowy",
|
||||
"horizon.extras.fullbright": "Pełna jasność",
|
||||
"horizon.extras.dynamic_lights": "Dynamiczne światła",
|
||||
"horizon.extras.own_name_tag": "Własny nick",
|
||||
"horizon.extras.auto_reconnect": "Ponowne łączenie",
|
||||
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
|
||||
"horizon.extras.mouse_tweaks_wheel": "Przenoszenie kółkiem",
|
||||
"horizon.extras.sort": "Przyciski sortowania",
|
||||
"horizon.extras.sort_key": "Klawisz sort.",
|
||||
"horizon.extras.no_ai": "Bez AI",
|
||||
"horizon.extras.agent_control": "Sterowanie agentem",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.fullbright.tooltip": "Pokazuje bloki i byty w pełnym oświetleniu, nawet w ciemnych miejscach.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Pochodnie i inne źródła światła trzymane w dłoni, upuszczone lub płonące oświetlają otaczające bloki.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Pokazuje twój nick nad głową w widoku z trzeciej osoby, tak jak widzą go inni gracze. Ukryty, gdy serwer ukrywa nazwy twojej drużyny lub jesteś niewidzialny.",
|
||||
"horizon.extras.auto_reconnect.tooltip": "Ponownie dołącza do serwera po utracie połączenia lub restarcie, nigdy po wyrzuceniu lub banie.",
|
||||
"horizon.extras.mouse_tweaks.tooltip": "Przeciąganie prawym przyciskiem układa po jednym na pole, lewym zbiera trzymany przedmiot, Shift + lewy przycisk przenosi każde pole.",
|
||||
"horizon.extras.mouse_tweaks_wheel.tooltip": "Przewinięcie w dół wysyła jeden przedmiot z pola na drugą stronę, w górę przywraca jeden.",
|
||||
"horizon.extras.sort.tooltip": "Sortuje ekwipunek, skrzynie, beczki, skrzynie Endu i shulkerowe skrzynie w kolejności kart trybu kreatywnego, łącząc stosy.",
|
||||
"horizon.extras.sort_key.tooltip": "Sortuje część pod kursorem. Kliknij, a potem naciśnij klawisz; Escape usuwa przypisanie.",
|
||||
"horizon.extras.no_ai.tooltip": "Ukrywa i wyłącza wszystkie funkcje AI: sterowanie agentem, horizon-ctl i serwery MCP. Także przełącznik w launcherze.",
|
||||
"horizon.extras.agent_control.tooltip": "Twój własny agent AI może grać w tę grę przez horizon-ctl lub klienta MCP, tylko na tym komputerze.",
|
||||
"horizon.extras.discord_presence.tooltip": "Pokazuje „Gra w Horizon” w twoim profilu Discord, gdy Discord jest uruchomiony: menu, tryb jednoosobowy lub wieloosobowy, wymiar i czas gry. Nigdy nazwy świata ani adresu serwera.",
|
||||
"Calibrate...": "Kalibruj…",
|
||||
"Calibrate Anyway...": "Kalibruj mimo to…",
|
||||
"Use Automatic Setup": "Użyj automatycznej konfiguracji",
|
||||
"Shortcut: %s": "Skrót: %s",
|
||||
"Next": "Dalej",
|
||||
"HDR Calibration %s/%s: %s": "Kalibracja HDR %s/%s: %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Ustawia trzy wartości poniżej na pełnym ekranie, na obrazie testowym.",
|
||||
"A key that opens this screen from the game. Not bound at first.": "Klawisz otwierający ten ekran z poziomu gry. Na początku nieprzypisany.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Wyświetlacz nie podał jasności: skalibruj go, aby go skonfigurować.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Już skonfigurowane. Kalibruj tylko, gdy HDR wygląda źle.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Przywraca Jasność maks. i Punkt bieli do ustawień wyświetlacza.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Skonfigurowane dla twojego wyświetlacza. Zostaw Auto, chyba że wygląda źle.",
|
||||
"Lower it until the logo just disappears into the black.": "Zmniejszaj, aż logo właśnie zniknie w czerni.",
|
||||
"Raise it until the logo just disappears into the bright square.": "Zwiększaj, aż logo właśnie zniknie w jasnym kwadracie.",
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Ustaw biel, na którą wygodnie patrzeć długo, w twoim oświetleniu.",
|
||||
"horizon.splitscreen.profiles.forget": "Zapomnij",
|
||||
"horizon.splitscreen.profiles.create": "Utwórz",
|
||||
"horizon.splitscreen.profiles.you": " - ty",
|
||||
"horizon.splitscreen.profiles.forget_named": "Zapomnij %s",
|
||||
"horizon.splitscreen.profiles.previous": "Poprzednia",
|
||||
"horizon.splitscreen.profiles.next": "Dalej",
|
||||
"horizon.splitscreen.profiles.new_button": "Nowy profil…",
|
||||
"horizon.splitscreen.profiles.forget_title": "Zapomnieć profil?",
|
||||
"horizon.splitscreen.profiles.forget_leaves": "Znika z listy tej instancji.",
|
||||
"horizon.splitscreen.profiles.forget_kept": "Postępy w świecie i ustawienia zostają zachowane.",
|
||||
"horizon.splitscreen.profiles.new_title": "Nowy profil",
|
||||
"horizon.splitscreen.profiles.name_hint": "Nazwa (3–16 liter, cyfr lub _)",
|
||||
"horizon.splitscreen.profiles.switch_title": "Zmień profil",
|
||||
"horizon.splitscreen.profiles.offline_note": "Profile offline należą do tej instancji",
|
||||
"horizon.splitscreen.profiles.move": "Zmień miejsce gry",
|
||||
"horizon.splitscreen.profiles.move_place": "Zmień miejsce gry: %s",
|
||||
"horizon.friends.title": "Graj ze znajomymi",
|
||||
"horizon.friends.hint": "Znajomi wklejają kod po kliknięciu przycisku Znajomi na ekranie tytułowym, w Połączeniu bezpośrednim lub na stronie Graj w launcherze.",
|
||||
"horizon.friends.copied": "Skopiowano",
|
||||
"horizon.friends.copy_code": "Kopiuj kod",
|
||||
"horizon.friends.stop_sharing": "Przestań udostępniać",
|
||||
"horizon.friends.none_yet": "Nie ma jeszcze żadnych znajomych.",
|
||||
"horizon.friends.here_now": "Obecnie: %s",
|
||||
"horizon.friends.share_intro": "Udostępnij ten świat za pomocą kodu zaproszenia: otwiera się w sieci LAN, a znajomi przez internet dołączają za pomocą kodu. Nie korzystamy z żadnego naszego serwera.",
|
||||
"horizon.friends.start_sharing": "Zacznij udostępniać",
|
||||
"horizon.friends.troubleshooting": "Rozwiązywanie problemów…",
|
||||
"horizon.friends.stopped": "Przestano udostępniać kod zaproszenia.",
|
||||
"horizon.friends.short_code_changed": "Ktoś wciąż wpisywał zły krótki kod, więc został zmieniony.",
|
||||
"horizon.friends.wrong_code": "Ktoś wpisał zły kod.",
|
||||
"horizon.friends.code_ready": "Twój kod zaproszenia jest gotowy: Menu gry, Graj ze znajomymi. Skopiuj go i wyślij znajomym.",
|
||||
"horizon.friends.share_failed": "Nie udało się udostępnić świata: %s",
|
||||
"horizon.friends.finding_route": "Sprawdzanie, jak znajomi mogą się z tobą połączyć…",
|
||||
"horizon.osk.shift": "Shift",
|
||||
"horizon.osk.space": "Spacja",
|
||||
"horizon.osk.delete": "Usuń",
|
||||
"horizon.osk.type": "Wpisz",
|
||||
"horizon.osk.symbols": "Symbole",
|
||||
"horizon.osk.close": "Zamknij"
|
||||
}
|
||||
@@ -12,6 +12,197 @@
|
||||
"horizon.options.button_glyphs": "Значки кнопок",
|
||||
"horizon.options.menu_navigation": "Навігація в меню",
|
||||
"horizon.options.on_screen_keyboard": "Екранна клавіатура",
|
||||
"horizon.options.chat_delay": "Затримка чату",
|
||||
"horizon.options.lightbar.health": "Здоров’я",
|
||||
"horizon.options.lightbar.dimension": "Вимір",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Курсор",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Ця функція ще недоступна в Horizon.",
|
||||
"horizon.options.search": "Шукати налаштування…",
|
||||
"horizon.pages.mods": "Моди",
|
||||
"horizon.pages.shader_packs": "Пакети шейдерів",
|
||||
"horizon.pages.shader_options": "Налаштування шейдерів",
|
||||
"horizon.pages.controller": "Налаштування геймпада",
|
||||
"horizon.pages.controller_binds": "Призначення кнопок геймпада",
|
||||
"horizon.pages.extras": "Додатки Horizon",
|
||||
"horizon.pages.settings": "Налаштування Horizon",
|
||||
"horizon.pages.agent": "ШІ-агент",
|
||||
"horizon.pages.split_profiles": "Хто грає?",
|
||||
"horizon.pages.settings.tooltip": "Власні налаштування Horizon: графіка, геймпади, додатки та моди.",
|
||||
"horizon.pages.graphics.tooltip": "Графіка просто: кожен параметр показує, чого він коштує.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Усі параметри графіки, зокрема з Horizon, із зазначенням витрат кожного.",
|
||||
"horizon.pages.controller.tooltip": "Геймпади: їхні кнопки, стіки, вібрація та навігація в меню.",
|
||||
"horizon.pages.extras.tooltip": "Необов’язкові додатки, усі типово вимкнені: гра лишається ванільною, доки ви не ввімкнете якийсь.",
|
||||
"horizon.pages.agent.tooltip": "Дозвольте власному ШІ-агентові грати: horizon-ctl і MCP-клієнти на цьому комп’ютері.",
|
||||
"horizon.pages.split_profiles.tooltip": "Хто грає: профіль, що зберігається в цій теці гри.",
|
||||
"horizon.pages.shader_packs.tooltip": "Пакети шейдерів із теки shaderpacks: власні пакети Horizon і пакети у форматі shaders/. Одночасно працює один.",
|
||||
"horizon.pages.mods.tooltip": "Моди, створені для Horizon, із теки horizon-mods. Кожен працює в ізольованому середовищі й залишається вимкненим, доки ви його не ввімкнете.",
|
||||
"horizon.pages.open.graphics": "Графіка…",
|
||||
"horizon.pages.open.graphics_advanced": "Розширена графіка…",
|
||||
"horizon.pages.open.shader_packs": "Пакети шейдерів…",
|
||||
"horizon.pages.open.controller": "Налаштування геймпада…",
|
||||
"horizon.pages.open.extras": "Додатки…",
|
||||
"horizon.pages.open.mods": "Моди…",
|
||||
"horizon.pages.open.agent": "ШІ-агент…",
|
||||
"horizon.pages.open.horizon_graphics": "Графіка Horizon…",
|
||||
"horizon.pages.open.controller_binds": "Кнопки геймпада…",
|
||||
"horizon.pages.open.horizon_settings": "Налаштування Horizon…",
|
||||
"horizon.mods.empty": "Немає модів: покладіть файли .hmod у теку horizon-mods",
|
||||
"horizon.mods.stopped": " (зупинено)",
|
||||
"horizon.mods.not_built": " (не зібрано)",
|
||||
"horizon.mods.dev": " (розробка)",
|
||||
"horizon.mods.settings": "Налаштування",
|
||||
"horizon.mods.open_folder": "Відкрити теку з модами",
|
||||
"horizon.shader_packs.empty": "Немає пакетів шейдерів: покладіть їх у теку shaderpacks",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.shader_options.open": "Налаштування шейдерів…",
|
||||
"horizon.shader_options.profile": "Профіль",
|
||||
"horizon.shader_options.loading": "Читання налаштувань набору шейдерів…",
|
||||
"horizon.agent.copy_details": "Копіювати дані з’єднання",
|
||||
"horizon.multiplayer.join": "Приєднатися",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Скасувати перепідключення",
|
||||
"horizon.multiplayer.join_friend": "Приєднатися до друга",
|
||||
"horizon.multiplayer.invite_code": "Код запрошення",
|
||||
"horizon.multiplayer.relay": "Ретранслятор (необов’язково)",
|
||||
"Preset": "Шаблон",
|
||||
"LOD Distance": "Дальність LOD",
|
||||
"Far Detail": "Деталі вдалині",
|
||||
"Cave Culling": "Відсікання печер",
|
||||
"Entity Culling": "Відсік. сутностей",
|
||||
"Chunk Updates": "Оновл. чанків",
|
||||
"Upload Budget": "Вивантаження",
|
||||
"Sky": "Небо",
|
||||
"Fog": "Туман",
|
||||
"Weather": "Погода",
|
||||
"Animated Textures": "Анімовані текстури",
|
||||
"Present Mode": "Виведення",
|
||||
"Frame Pacing": "Темп кадрів",
|
||||
"Worker Threads": "Робочі потоки",
|
||||
"Resolution Scale": "Масштаб зображення",
|
||||
"Anti-aliasing": "Згладжування",
|
||||
"Upscaling": "Апскейлінг",
|
||||
"HDR Settings...": "Налаштування HDR…",
|
||||
"Paper White": "Біла точка",
|
||||
"Peak Brightness": "Пік яскравості",
|
||||
"Black Level": "Рівень чорного",
|
||||
"UI Brightness": "Яскравість UI",
|
||||
"Battery Mode": "Режим батареї",
|
||||
"Graphics API": "API графіки",
|
||||
"Performance Overlay": "Накладка швидкодії",
|
||||
"Full Detail Distance": "Дальність деталей",
|
||||
"On": "Увімк.",
|
||||
"Off": "Вимк.",
|
||||
"Unlimited": "Без обмежень",
|
||||
"Auto": "Авто",
|
||||
"Custom": "Власний",
|
||||
"Performance": "Продуктивність",
|
||||
"Balanced": "Збалансований",
|
||||
"Quality": "Якість",
|
||||
"Ultra": "Ультра",
|
||||
"Ultra Performance": "Ультрапродукт.",
|
||||
"Native": "Нативна",
|
||||
"Full": "Повна",
|
||||
"Reduced": "Знижена",
|
||||
"Efficient": "Економний",
|
||||
"Precise": "Точний",
|
||||
"Auto (VSync)": "Авто (VSync)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "Mailbox",
|
||||
"Immediate": "Immediate",
|
||||
"Vulkan": "Vulkan",
|
||||
"DirectX 12": "DirectX 12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS і графік",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "Без витрат",
|
||||
"Low cost": "Низькі витрати",
|
||||
"Medium cost": "Середні витрати",
|
||||
"High cost": "Високі витрати",
|
||||
"Saves time": "Заощаджує час",
|
||||
"Rendering": "Промальовування",
|
||||
"Display": "Дисплей",
|
||||
"Preferences": "Уподобання",
|
||||
"Overlay": "Накладка",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "Графіка",
|
||||
"Graphics: Advanced": "Графіка: розширені",
|
||||
"Graphics: HDR": "Графіка: HDR",
|
||||
"Advanced...": "Розширені…",
|
||||
"Simple...": "Прості…",
|
||||
"Done": "Готово",
|
||||
"This feature is not yet available in Horizon.": "Ця функція ще недоступна в Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Ваш дисплей або система не підтримує вивід HDR.",
|
||||
"Turn HDR on to use it.": "Увімкніть HDR, щоб користуватися цим.",
|
||||
"%s chunks": "%s чанків",
|
||||
"%s per frame": "%s на кадр",
|
||||
"%s MiB/frame": "%s MiB/кадр",
|
||||
"%s KiB/frame": "%s KiB/кадр",
|
||||
"%s nits": "%s ніт",
|
||||
"Auto (%s)": "Авто (%s)",
|
||||
"Auto (%s nits)": "Авто (%s ніт)",
|
||||
"%.2f nits": "%.2f ніт",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Дальність повних деталей. Дальність LOD сягає далі.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Спрощена далека місцевість локальних світів. Інтегр. GPU: до 128.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Знижена: далека місцевість простіша, швидше на цьому GPU.",
|
||||
"Reduced draws far terrain coarser, for GPUs it slows down.": "Знижена: простіша далека місцевість для повільних GPU.",
|
||||
"Field of view. Wider shows more terrain at once.": "Поле зору. Ширше показує більше місцевості одночасно.",
|
||||
"Caps frames per second; a cap saves power and heat.": "Обмежує к/с; обмеження заощаджує енергію й зменшує нагрів.",
|
||||
"Waits for the display's refresh: no tearing, a little latency.": "Чекає на оновлення дисплея: без розривів, невелика затримка.",
|
||||
"Size of menus and the HUD.": "Розмір меню та HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Задає параметри нижче разом. Зміна одного дає «Власний».",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лава, вогонь і сонце світліші за білий. Потрібен HDR.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Найтемніший чорний вашого дисплея. 0 для OLED; LCD трохи світяться.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Наскільки яскравими бувають відблиски. Авто: пік, який повідомляє система.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Яскравість білого паперу та меню. Авто: білий вашого стола.",
|
||||
"How bright the HUD and menus are, against paper white.": "Наскільки яскраві HUD і меню відносно білого паперу.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Як далеко працюють моби, посіви й механізми. Витрачає CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Відстань малювання мобів і предметів, у частці від норми.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускає приховану місцевість. Вимкнення коштує дорого.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Ховає моделі за блоками. Вимкніть із власними пакетами.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Секцій сітки за кадр. Менше — плавніше під час завантаження.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "М’які тіні в кутах. Витрачає лише час на побудову чанків.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Менші копії текстур блоків для віддаленої місцевості: менше мерехтіння.",
|
||||
"Blends grass and water colours across biomes when building chunks.": "Змішує кольори трави та води між біомами під час побудови чанків.",
|
||||
"Cloud layer: off, flat (fast) or 3D (fancy).": "Шар хмар: вимкнено, пласкі (швидкі) або 3D (красиві).",
|
||||
"How far clouds reach, in chunks.": "Як далеко сягають хмари, у чанках.",
|
||||
"Draws the sky, sun, moon and stars.": "Малює небо, сонце, місяць і зірки.",
|
||||
"Distance fog. Off shows the edge of the loaded world.": "Туман на відстані. Вимкнений показує межу завантаженого світу.",
|
||||
"Draws rain and snow.": "Малює дощ і сніг.",
|
||||
"How far rain and snow fall around you.": "Як далеко навколо вас падають дощ і сніг.",
|
||||
"How many particles effects spawn.": "Скільки частинок створюють ефекти.",
|
||||
"Round shadows under mobs and items.": "Круглі тіні під мобами та предметами.",
|
||||
"Leaves with see-through gaps. Off draws them solid.": "Листя з просвітами. Вимкнено — малюється суцільним.",
|
||||
"Sorts water and glass more exactly behind each other.": "Точніше сортує воду та скло одне за одним.",
|
||||
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Які перебудови чанків чекають на кадр: жодні, найближчі чи ваші.",
|
||||
"Animates water, lava, fire and other moving textures.": "Анімує воду, лаву, вогонь та інші рухомі текстури.",
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Пікселі світу на піксель екрана. Нижче 100% — швидше для GPU.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Згладжує краї. FXAA: м’якше. TAA: найгладше, можливі шлейфи.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Менший рендер, відновлення через TAA. Швидше, м’якше.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Вивід HDR на HDR-дисплеї: лава, вогонь і сонце яскравіші.",
|
||||
"How frames reach the screen while VSync is off.": "Як кадри потрапляють на екран, коли VSync вимкнено.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "«Економний» спить до наступного кадру; «Точний» чекає активно.",
|
||||
"Fills the screen (borderless).": "Заповнює екран (без рамки).",
|
||||
"Brightens dark places.": "Освітлює темні місця.",
|
||||
"Lowers the frame rate when minimised, or idle too.": "Знижує частоту кадрів при згортанні, а також при бездіяльності.",
|
||||
"Sways the view while walking.": "Розхитує вид під час ходьби.",
|
||||
"Fades newly loaded chunks in.": "Плавно показує щойно завантажені чанки.",
|
||||
"Darkens the screen's edges.": "Затемнює краї екрана.",
|
||||
"Shows when the world saves.": "Показує, коли світ зберігається.",
|
||||
"Shows attack cooldown.": "Показує перезарядку атаки.",
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS (30 у меню/без фокуса), половина потоків: економія.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Потоки генерації, світла й сітки чанків. Після перезапуску.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, інакше DirectX 12 (Windows)/OpenGL (Linux). Після рестарту.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Дані сітки до GPU за кадр. Менше — плавніше завантаження.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота та час кадрів поверх гри, з графіком.",
|
||||
"horizon.multiplayer.finding_friend": "Шукаємо світ друга…",
|
||||
"horizon.loading.opening_world": "Відкриття світу",
|
||||
"horizon.loading.cancelling_world": "Скасування",
|
||||
@@ -19,10 +210,84 @@
|
||||
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
|
||||
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…",
|
||||
"horizon.pages.shader_options": "Налаштування шейдерів",
|
||||
"horizon.pages.split_profiles": "Хто грає?",
|
||||
"horizon.pages.split_profiles.tooltip": "Хто грає: профіль, що зберігається в цій теці гри.",
|
||||
"horizon.shader_options.open": "Налаштування шейдерів…",
|
||||
"horizon.shader_options.profile": "Профіль",
|
||||
"horizon.shader_options.loading": "Читання налаштувань набору шейдерів…"
|
||||
"horizon.title.not_affiliated": "Не пов’язано з Mojang чи Microsoft",
|
||||
"horizon.extras.header.lighting": "Освітлення",
|
||||
"horizon.extras.header.third_person": "Вигляд від третьої особи",
|
||||
"horizon.extras.header.inventory": "Інвентар",
|
||||
"horizon.extras.header.multiplayer": "Гра в мережі",
|
||||
"horizon.extras.fullbright": "Повна яскравість",
|
||||
"horizon.extras.dynamic_lights": "Динамічне світло",
|
||||
"horizon.extras.own_name_tag": "Власний нік",
|
||||
"horizon.extras.auto_reconnect": "Перепідключення",
|
||||
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
|
||||
"horizon.extras.mouse_tweaks_wheel": "Перенос коліщатком",
|
||||
"horizon.extras.sort": "Кнопки сортування",
|
||||
"horizon.extras.sort_key": "Сортувати",
|
||||
"horizon.extras.no_ai": "Без ШІ",
|
||||
"horizon.extras.agent_control": "Керування агентом",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.fullbright.tooltip": "Показує блоки та сутності при повному освітленні, навіть у темних місцях.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Смолоскипи та інші джерела світла, які тримають у руках, викинуті чи палаючі, освітлюють блоки навколо.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Показує ваше ім’я над головою від третьої особи, як його бачать інші гравці. Приховано, коли сервер ховає імена вашої команди або ви невидимі.",
|
||||
"horizon.extras.auto_reconnect.tooltip": "Знову приєднується до сервера після втрати з’єднання чи перезапуску, але ніколи після вигнання чи блокування.",
|
||||
"horizon.extras.mouse_tweaks.tooltip": "Перетягування ПКМ кладе по одному в комірку, ЛКМ збирає утримуваний предмет, Shift + ЛКМ переміщує кожну комірку.",
|
||||
"horizon.extras.mouse_tweaks_wheel.tooltip": "Прокрутка вниз надсилає один предмет із комірки на інший бік, вгору — повертає один назад.",
|
||||
"horizon.extras.sort.tooltip": "Сортує інвентар, скрині, діжки, скрині Енду та шалкерові коробки в порядку вкладок творчого режиму, об’єднуючи стеки.",
|
||||
"horizon.extras.sort_key.tooltip": "Сортує частину під курсором. Натисніть, а потім клавішу; Escape скасовує призначення.",
|
||||
"horizon.extras.no_ai.tooltip": "Приховує та вимикає всі функції ШІ: керування агентом, horizon-ctl і MCP-сервери. Також перемикач у лаунчері.",
|
||||
"horizon.extras.agent_control.tooltip": "Ваш власний ШІ-агент може грати в цю гру через horizon-ctl або MCP-клієнт, лише на цьому комп’ютері.",
|
||||
"horizon.extras.discord_presence.tooltip": "Показує «Грає в Horizon» у вашому профілі Discord, поки Discord запущено: меню, одиночна чи мережева гра, вимір і час гри. Ніколи не назву світу чи адресу сервера.",
|
||||
"Calibrate...": "Калібрувати…",
|
||||
"Calibrate Anyway...": "Все одно калібрувати…",
|
||||
"Use Automatic Setup": "Використати автоналаштування",
|
||||
"Shortcut: %s": "Гаряча клавіша: %s",
|
||||
"Next": "Далі",
|
||||
"HDR Calibration %s/%s: %s": "Калібрування HDR %s/%s: %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Три значення нижче налаштовуються на повному екрані.",
|
||||
"A key that opens this screen from the game. Not bound at first.": "Клавіша, що відкриває цей екран із гри. Спершу не призначена.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Дисплей не повідомив яскравість: відкалібруйте його.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Уже налаштовано. Калібруйте, лише якщо HDR виглядає погано.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Повертає Пік яскравості й Білу точку до налаштувань дисплея.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Налаштовано для дисплея. Залишайте «Авто», якщо все гаразд.",
|
||||
"Lower it until the logo just disappears into the black.": "Зменшуйте, доки логотип не зникне в чорному.",
|
||||
"Raise it until the logo just disappears into the bright square.": "Збільшуйте, доки логотип не зникне в яскравому квадраті.",
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Білий, на який комфортно довго дивитися за вашого світла.",
|
||||
"horizon.splitscreen.profiles.forget": "Забути",
|
||||
"horizon.splitscreen.profiles.create": "Створити",
|
||||
"horizon.splitscreen.profiles.you": " - ви",
|
||||
"horizon.splitscreen.profiles.forget_named": "Забути %s",
|
||||
"horizon.splitscreen.profiles.previous": "Назад",
|
||||
"horizon.splitscreen.profiles.next": "Далі",
|
||||
"horizon.splitscreen.profiles.new_button": "Новий профіль…",
|
||||
"horizon.splitscreen.profiles.forget_title": "Забути профіль?",
|
||||
"horizon.splitscreen.profiles.forget_leaves": "Він зникне зі списку цього екземпляра.",
|
||||
"horizon.splitscreen.profiles.forget_kept": "Прогрес у світі та налаштування зберігаються.",
|
||||
"horizon.splitscreen.profiles.new_title": "Новий профіль",
|
||||
"horizon.splitscreen.profiles.name_hint": "Ім’я (3–16 літер, цифр або _)",
|
||||
"horizon.splitscreen.profiles.switch_title": "Змінити профіль",
|
||||
"horizon.splitscreen.profiles.offline_note": "Офлайн-профілі належать цьому екземпляру",
|
||||
"horizon.splitscreen.profiles.move": "Змінити місце гри",
|
||||
"horizon.splitscreen.profiles.move_place": "Змінити місце гри: %s",
|
||||
"horizon.friends.title": "Грати з друзями",
|
||||
"horizon.friends.hint": "Друзі вставляють код після кнопки «Друзі» на головному екрані, у «Пряме з’єднання» або на сторінці «Грати» в лаунчері.",
|
||||
"horizon.friends.copied": "Скопійовано",
|
||||
"horizon.friends.copy_code": "Копіювати код",
|
||||
"horizon.friends.stop_sharing": "Припинити поширення",
|
||||
"horizon.friends.none_yet": "Друзів тут поки немає.",
|
||||
"horizon.friends.here_now": "Зараз тут: %s",
|
||||
"horizon.friends.share_intro": "Поділіться цим світом за допомогою коду запрошення: він відкривається для LAN, а друзі з інтернету приєднуються за кодом. Жоден наш сервер не залучається.",
|
||||
"horizon.friends.start_sharing": "Почати поширення",
|
||||
"horizon.friends.troubleshooting": "Усунення несправностей…",
|
||||
"horizon.friends.stopped": "Поширення коду запрошення припинено.",
|
||||
"horizon.friends.short_code_changed": "Хтось раз за разом вводив неправильний короткий код, тож його змінено.",
|
||||
"horizon.friends.wrong_code": "Хтось ввів неправильний код.",
|
||||
"horizon.friends.code_ready": "Ваш код запрошення готовий: Ігрове меню, Грати з друзями. Скопіюйте його та надішліть друзям.",
|
||||
"horizon.friends.share_failed": "Не вдалося поділитися світом: %s",
|
||||
"horizon.friends.finding_route": "З’ясовуємо, як друзі можуть до вас під’єднатися…",
|
||||
"horizon.osk.shift": "Shift",
|
||||
"horizon.osk.space": "Пробіл",
|
||||
"horizon.osk.delete": "Видалити",
|
||||
"horizon.osk.type": "Ввести",
|
||||
"horizon.osk.symbols": "Символи",
|
||||
"horizon.osk.close": "Закрити"
|
||||
}
|
||||
@@ -209,5 +209,102 @@
|
||||
"horizon.pages.split_profiles.tooltip": "谁在玩:保存在此游戏文件夹中的一份档案。",
|
||||
"horizon.shader_options.open": "光影选项…",
|
||||
"horizon.shader_options.profile": "档案",
|
||||
"horizon.shader_options.loading": "正在读取光影包的选项…"
|
||||
"horizon.shader_options.loading": "正在读取光影包的选项…",
|
||||
"horizon.pages.split_settings": "分屏设置",
|
||||
"horizon.pages.split_setup": "分屏",
|
||||
"horizon.pages.split_setup.tooltip": "在本机游玩的玩家:手柄可以加入你的世界。",
|
||||
"horizon.pages.open.split_setup": "分屏…",
|
||||
"horizon.title.not_affiliated": "与 Mojang 或 Microsoft 无关",
|
||||
"horizon.extras.header.lighting": "光照",
|
||||
"horizon.extras.header.third_person": "第三人称",
|
||||
"horizon.extras.header.particles": "粒子",
|
||||
"horizon.extras.header.inventory": "物品栏",
|
||||
"horizon.extras.header.chat": "聊天",
|
||||
"horizon.extras.header.multiplayer": "多人游戏",
|
||||
"horizon.extras.header.sound": "声音",
|
||||
"horizon.extras.fullbright": "全亮",
|
||||
"horizon.extras.dynamic_lights": "动态光源",
|
||||
"horizon.extras.own_name_tag": "自己的名字标签",
|
||||
"horizon.extras.hide_block_particles": "隐藏方块破坏粒子",
|
||||
"horizon.extras.auto_reconnect": "自动重连",
|
||||
"horizon.extras.ui_sound_reserve": "为界面音效保留声道",
|
||||
"horizon.extras.mouse_tweaks": "鼠标调整控制",
|
||||
"horizon.extras.mouse_tweaks_wheel": "滚轮移动物品",
|
||||
"horizon.extras.sort": "排序按钮",
|
||||
"horizon.extras.sort_key": "排序快捷键",
|
||||
"horizon.extras.no_ai": "关闭 AI",
|
||||
"horizon.extras.agent_control": "代理控制",
|
||||
"horizon.extras.discord_presence": "Discord 动态状态",
|
||||
"horizon.extras.splitscreen": "分屏",
|
||||
"horizon.extras.chat_animation": "平滑聊天",
|
||||
"horizon.extras.chat_indicator": "消息指示条",
|
||||
"horizon.extras.fullbright.tooltip": "即使在黑暗区域也以全亮显示方块和实体。",
|
||||
"horizon.extras.dynamic_lights.tooltip": "手持、掉落或燃烧中的火把等光源会照亮周围方块。",
|
||||
"horizon.extras.own_name_tag.tooltip": "在第三人称视角中把名字显示在头顶,与其它玩家看到的一致。当服务器隐藏团队名字或你处于隐身状态时不显示。",
|
||||
"horizon.extras.hide_block_particles.tooltip": "隐藏方块被破坏时迸出的碎屑粒子,以及挖掘时掉落的碎屑。其它粒子仍会显示。",
|
||||
"horizon.extras.auto_reconnect.tooltip": "连接中断或服务器重启后自动重新加入,被踢出或封禁后不会。",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "世界中的声音会为界面音效留出 247 个声道中的 16 个,这样大量声音(比如一次大爆炸)不会把菜单点击音挤掉。",
|
||||
"horizon.extras.mouse_tweaks.tooltip": "右键拖动每次放一个物品,左键拖动合并手持物品,Shift + 左键拖动移动所有槽位。",
|
||||
"horizon.extras.mouse_tweaks_wheel.tooltip": "向下把槽位中的一个物品送到另一侧,向上取回一个。",
|
||||
"horizon.extras.sort.tooltip": "按创造模式标签页顺序排序物品栏、箱子、木桶、末影箱和潜影盒,并合并堆叠。",
|
||||
"horizon.extras.sort_key.tooltip": "排序光标下的部分。点击后按一个键;按 Escape 取消绑定。",
|
||||
"horizon.extras.no_ai.tooltip": "隐藏并关闭所有 AI 功能:代理控制、horizon-ctl 和 MCP 服务器。启动器中的开关也一样。",
|
||||
"horizon.extras.agent_control.tooltip": "你自己的 AI 代理可以通过 horizon-ctl 或 MCP 客户端玩这个游戏,仅限本机。",
|
||||
"horizon.extras.discord_presence.tooltip": "Discord 运行时在你的 Discord 资料上显示“正在玩 Horizon”:菜单、单人、多人、维度以及游玩时长。绝不显示世界名称或服务器地址。",
|
||||
"Calibrate...": "校准…",
|
||||
"Calibrate Anyway...": "仍要校准…",
|
||||
"Use Automatic Setup": "使用自动设置",
|
||||
"Shortcut: %s": "快捷键:%s",
|
||||
"Next": "下一步",
|
||||
"HDR Calibration %s/%s: %s": "HDR 校准 %s/%s:%s",
|
||||
"Sets the three below full screen, one test picture at a time.": "以全屏方式逐张显示测试图,来设置下面三项。",
|
||||
"A key that opens this screen from the game. Not bound at first.": "在游戏中打开此界面的按键。初始未绑定。",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "显示器没有报告自身亮度:需要通过校准来设置。",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "已按你的显示器设置好。只有在 HDR 显示异常时才需要校准。",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "将峰值亮度和纸白亮度恢复为你的显示器设置。",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "已按你的显示器设置。除非显示异常,否则保持“自动”。",
|
||||
"Lower it until the logo just disappears into the black.": "调低,直到标志刚好没入黑色背景。",
|
||||
"Raise it until the logo just disappears into the bright square.": "调高,直到标志刚好没入亮色方块。",
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "在你游玩时的光照环境下,把卡片调成长时间观看也舒适的白色。",
|
||||
"horizon.splitscreen.profiles.forget": "忘记",
|
||||
"horizon.splitscreen.profiles.create": "创建",
|
||||
"horizon.splitscreen.profiles.you": " - 你",
|
||||
"horizon.splitscreen.profiles.forget_named": "忘记 %s",
|
||||
"horizon.splitscreen.profiles.previous": "上一个",
|
||||
"horizon.splitscreen.profiles.next": "下一个",
|
||||
"horizon.splitscreen.profiles.new_button": "新建档案…",
|
||||
"horizon.splitscreen.profiles.forget_title": "忘记档案?",
|
||||
"horizon.splitscreen.profiles.forget_leaves": "该档案将从此实例的列表中移除。",
|
||||
"horizon.splitscreen.profiles.forget_kept": "世界进度和设置会保留。",
|
||||
"horizon.splitscreen.profiles.new_title": "新建档案",
|
||||
"horizon.splitscreen.profiles.name_hint": "名称(3 到 16 个字母、数字或 _)",
|
||||
"horizon.splitscreen.profiles.switch_title": "切换档案",
|
||||
"horizon.splitscreen.profiles.offline_note": "离线档案属于此实例",
|
||||
"horizon.splitscreen.profiles.move": "移动游玩位置",
|
||||
"horizon.splitscreen.profiles.move_place": "把游玩位置移到:%s",
|
||||
"horizon.friends.title": "与好友一起玩",
|
||||
"horizon.friends.hint": "好友可以在标题画面的“好友”按钮后、直连界面或启动器的“开始游戏”页粘贴该代码。",
|
||||
"horizon.friends.copied": "已复制",
|
||||
"horizon.friends.copy_code": "复制代码",
|
||||
"horizon.friends.stop_sharing": "停止共享",
|
||||
"horizon.friends.none_yet": "还没有好友加入。",
|
||||
"horizon.friends.here_now": "当前在线:%s",
|
||||
"horizon.friends.share_intro": "通过邀请码共享这个世界:它会开放到局域网,互联网上的好友可以用代码加入。不经过我们的任何服务器。",
|
||||
"horizon.friends.start_sharing": "开始共享",
|
||||
"horizon.friends.troubleshooting": "疑难解答…",
|
||||
"horizon.friends.stopped": "已停止共享邀请码。",
|
||||
"horizon.friends.short_code_changed": "有人反复尝试错误的短代码,因此已更换。",
|
||||
"horizon.friends.wrong_code": "有人尝试了错误的代码。",
|
||||
"horizon.friends.code_ready": "你的邀请码已就绪:游戏菜单 → 与好友一起玩。复制后发给好友。",
|
||||
"horizon.friends.share_failed": "无法共享世界:%s",
|
||||
"horizon.friends.finding_route": "正在寻找好友连接你的方式…",
|
||||
"horizon.osk.shift": "Shift",
|
||||
"horizon.osk.space": "空格",
|
||||
"horizon.osk.delete": "删除",
|
||||
"horizon.osk.type": "输入",
|
||||
"horizon.osk.symbols": "符号",
|
||||
"horizon.osk.close": "关闭",
|
||||
"horizon.extras.chat_animation.tooltip": "新聊天行从左侧滑入并淡入,而不是整行直接出现。",
|
||||
"horizon.extras.chat_indicator.tooltip": "聊天行左侧的彩色竖条。",
|
||||
"horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。"
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
|
||||
//! work on the machine changes it less. The checksum of the meshes shows that an
|
||||
//! A/B pair of builds made the same ones.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Profile the two mandatory passes over gameplay-facts.json independently.
|
||||
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
|
||||
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
#[allow(dead_code)]
|
||||
#[path = "../../horizon-items/src/blocks/decode.rs"]
|
||||
mod decode;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Compare the read + Value parse stages in BlockFacts::load and Assets::load.
|
||||
//! Usage: json_load_bench RESOURCE_ROOT [REPETITIONS]; see tools/json_load_fixtures.py.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//!
|
||||
//! WORK is a `horizon-port-pack` work folder: `oracle/facts.json` and
|
||||
//! `mods/<id>/carry/`. The root is built in `WORK/horizon-root`.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
assets::Assets,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! for live requested heap bytes; leave it unset for latency measurements.
|
||||
//! cargo run --profile fast -p horizon-client --example model_library_bench -- ROOT [--parity]
|
||||
//! Run fresh processes, warm up twice, then compare multiple alternating baseline/candidate runs.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{assets::Assets, item_render::ItemRenderer};
|
||||
use std::{
|
||||
alloc::{GlobalAlloc, Layout, System},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Compare the old two-parse loading path with catalog construction from shared facts.
|
||||
//! Run: cargo run -p horizon-client --profile fast --example model_states_bench -- 15
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{Result, models::StateModels, sprites::Sprites};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::{
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! generated around them blend with them (`Blender.of`).
|
||||
//!
|
||||
//! old_world_fixture <source world> <destination world> <x0,z0,x1,z1>
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_world::{
|
||||
nbt::{Limits, Tag},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! entity reaches, with the per-tick palette map against the resident cache.
|
||||
//! Run with prepared client resources:
|
||||
//! cargo run --profile fast -p horizon-client --example portal_scan_bench -- RESOURCES
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, SECTIONS, Section},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//!
|
||||
//! This file only lists the probes, so that adding one reruns no harness: tools/parity.py leaves it out of
|
||||
//! their dependencies. How a probe runs is in dispatch.rs.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
#[macro_use]
|
||||
mod dispatch;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Selection cost only (not block simulation). Run with prepared client resources.
|
||||
//! cargo run --profile fast -p horizon-client --example random_tick_eligibility_bench -- RESOURCES
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, Section},
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! and the process RSS (what the allocator holds on to). On macOS it ends
|
||||
//! with `footprint`'s physical footprint, the number the OS charges: RSS also
|
||||
//! counts freed pages the allocator marked reusable.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
assets::Assets, biome::Biomes, blocks::BlockFacts, collisions, crafting::Recipes, items::Items,
|
||||
light::LightTables, mesher::MeshTables, models, motion::MotionResources,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! full chunk NBT encoder. Run with `cargo run --profile fast -p horizon-client
|
||||
//! --example save_batch_bench`. Add `-- --nbt` to also time serialization.
|
||||
//! Exact NBT equality is always validated. No GPU, compression or disk I/O timed.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
biome::Biomes,
|
||||
chunk::{Chunk, block_strategy},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Run each mode in a separate process to capture CPU usage and peak RSS.
|
||||
//! The deferred mode evaluates both phases contiguously for fair loader comparison;
|
||||
//! actual client startup performs other initialization between these phases.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{assets::Assets, blocks::BlockFacts};
|
||||
use horizon_world::level::Behaviours;
|
||||
use std::{path::Path, time::Instant};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! to the ones decoded from the PNGs. Deletes the cache file in ROOT, loads (decodes
|
||||
//! and writes it), loads again (reads it) and compares the sprite arrays.
|
||||
//! cargo run --profile fast -p horizon-client --example sprite_cache_parity -- ROOT
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::assets::{Assets, RenderAssets};
|
||||
use std::{
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -325,10 +325,73 @@ pub fn interact(
|
||||
}
|
||||
}
|
||||
|
||||
/// What `interact` would answer for `held`, changing nothing: a client's
|
||||
/// `useItemOn`, where every interaction checks the same conditions and acts
|
||||
/// only `if (!level.isClientSide())`.
|
||||
pub fn outcome(
|
||||
cauldron: &BlockState,
|
||||
under_water: bool,
|
||||
removes_dye: bool,
|
||||
held: &ItemStack,
|
||||
) -> Outcome {
|
||||
struct Dry(Option<ItemStack>);
|
||||
impl Player for Dry {
|
||||
fn creative(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn hand(&mut self) -> &mut Option<ItemStack> {
|
||||
&mut self.0
|
||||
}
|
||||
fn add(&mut self, _: &mut ItemStack) -> bool {
|
||||
true
|
||||
}
|
||||
fn contains(&self, _: &ItemStack) -> bool {
|
||||
false
|
||||
}
|
||||
fn drop_stack(&mut self, _: ItemStack) {}
|
||||
}
|
||||
interact(
|
||||
cauldron,
|
||||
under_water,
|
||||
removes_dye,
|
||||
&mut Dry(Some(held.clone())),
|
||||
)
|
||||
.outcome
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_client_sees_the_bucket_taken_without_acting() {
|
||||
let empty = state("minecraft:cauldron", None);
|
||||
let water = |l| state("minecraft:water_cauldron", Some(l));
|
||||
let stack = |id| ItemStack::new(id, 1);
|
||||
let outcome = |c: &BlockState, under_water, id| outcome(c, under_water, false, &stack(id));
|
||||
assert_eq!(
|
||||
outcome(&empty, false, "minecraft:water_bucket"),
|
||||
Outcome::Success
|
||||
);
|
||||
assert_eq!(
|
||||
outcome(&empty, false, "minecraft:lava_bucket"),
|
||||
Outcome::Success
|
||||
);
|
||||
// fillLavaInteraction under water: CONSUME, the bucket kept.
|
||||
assert_eq!(
|
||||
outcome(&empty, true, "minecraft:lava_bucket"),
|
||||
Outcome::Consume
|
||||
);
|
||||
assert_eq!(
|
||||
outcome(&water(3), false, "minecraft:bucket"),
|
||||
Outcome::Success
|
||||
);
|
||||
// fillBucket needs a full cauldron.
|
||||
assert_eq!(outcome(&water(2), false, "minecraft:bucket"), Outcome::Pass);
|
||||
assert_eq!(outcome(&empty, false, "minecraft:bucket"), Outcome::Pass);
|
||||
assert_eq!(outcome(&empty, false, "minecraft:stone"), Outcome::Pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_burning_entity_lowers_water_and_melts_powder_snow() {
|
||||
let water = |l| state("minecraft:water_cauldron", Some(l));
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Horizon native client: resources, world streaming, rendering and the
|
||||
//! bindings between the validated simulation pieces and the game loop.
|
||||
#![forbid(unsafe_code)]
|
||||
extern crate self as horizon_client;
|
||||
pub mod advancements;
|
||||
pub mod ambience;
|
||||
pub mod animate_tick;
|
||||
pub mod arrows;
|
||||
pub mod block_contact;
|
||||
pub use horizon_client_render::{
|
||||
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities,
|
||||
block_entity_anim, block_entity_render, border_render, breaking, brewing, camera, chat, chunk,
|
||||
chunk_map, cit, clouds, collisions, command_block, containers, crash, datapack, dynamic_light,
|
||||
effects, environment, extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui,
|
||||
hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block,
|
||||
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
|
||||
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
|
||||
options_ui, overlay, pack_screen, packs, par, particles, poi_sections, post_chain,
|
||||
potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk, section_mask, settings,
|
||||
shader_options, shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview,
|
||||
spans, split_layout, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
|
||||
test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets,
|
||||
world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
pub mod auto_jump;
|
||||
pub mod banner;
|
||||
pub mod beehive;
|
||||
pub mod benchmark;
|
||||
pub mod block_entity_item;
|
||||
pub mod block_pattern;
|
||||
pub mod block_transform;
|
||||
pub mod boss_overlay;
|
||||
pub mod cauldron;
|
||||
pub mod chunk_io;
|
||||
pub mod clock;
|
||||
pub mod command_storage;
|
||||
pub mod confirm_screen;
|
||||
pub mod container_screen;
|
||||
pub mod content_identity;
|
||||
pub mod control;
|
||||
pub mod crafting;
|
||||
pub mod creative;
|
||||
pub mod cushion;
|
||||
pub mod data_cmd;
|
||||
pub mod debug_chart;
|
||||
pub mod debug_entries;
|
||||
pub mod debug_keys;
|
||||
pub mod debug_overlay;
|
||||
pub mod debugworld;
|
||||
pub mod dialog;
|
||||
pub mod dialog_screen;
|
||||
pub mod discord_presence;
|
||||
pub mod end_crystal;
|
||||
pub mod end_gateway;
|
||||
pub mod entities;
|
||||
pub mod entity_culling;
|
||||
pub mod entity_nbt;
|
||||
pub mod extras_hud;
|
||||
pub mod falling_block;
|
||||
pub mod fire_render;
|
||||
pub mod first_person;
|
||||
pub mod flatgen;
|
||||
pub mod friends_screen;
|
||||
pub mod furnace;
|
||||
pub mod game_mode_switcher;
|
||||
pub mod game_rules_screen;
|
||||
pub mod gizmos;
|
||||
pub mod gossip;
|
||||
pub mod health;
|
||||
pub mod heartbeat;
|
||||
pub mod hotbar;
|
||||
pub mod hunger;
|
||||
pub mod inv_sort;
|
||||
pub mod item_activation;
|
||||
pub mod item_render;
|
||||
pub mod item_sync;
|
||||
pub mod item_tooltip;
|
||||
pub mod lan;
|
||||
pub mod landing;
|
||||
pub mod locator_bar;
|
||||
pub mod lod_net;
|
||||
pub mod menu;
|
||||
pub mod merchant;
|
||||
pub mod mobs;
|
||||
pub mod mod_content;
|
||||
pub mod motion;
|
||||
pub mod multiplayer;
|
||||
pub mod music;
|
||||
pub mod native_data;
|
||||
pub mod net;
|
||||
pub mod nether_portal;
|
||||
pub mod no_resources;
|
||||
pub mod offline_name;
|
||||
pub mod old_world;
|
||||
pub mod osk;
|
||||
pub mod patrol;
|
||||
pub mod perf_overlay;
|
||||
pub mod pick_item;
|
||||
pub mod player_sync;
|
||||
pub mod portal_sections;
|
||||
pub mod pose;
|
||||
pub mod pregen;
|
||||
pub mod projectiles;
|
||||
pub mod raid;
|
||||
pub mod ram_limit;
|
||||
pub mod random_tick_sections;
|
||||
pub mod recipe_book;
|
||||
pub mod recipe_book_screen;
|
||||
pub mod reconnect;
|
||||
pub mod save;
|
||||
pub mod scoreboard_cmd;
|
||||
pub mod screen_layout;
|
||||
pub mod screen_narrator;
|
||||
pub mod social_screen;
|
||||
pub use horizon_server_files as server;
|
||||
pub mod server_packs;
|
||||
pub mod shadows;
|
||||
pub mod spear;
|
||||
pub mod spectator_menu;
|
||||
pub mod sprint;
|
||||
pub mod stations;
|
||||
pub mod stopwatches;
|
||||
pub mod structure_blocks;
|
||||
pub mod text_display;
|
||||
pub mod tick_chunk_membership;
|
||||
pub mod ticket_levels;
|
||||
pub mod title;
|
||||
pub mod toasts;
|
||||
pub mod ui_theme;
|
||||
pub mod vanillagen;
|
||||
pub mod win_screen;
|
||||
pub mod world_spawn;
|
||||
pub use horizon_client_core::Result;
|
||||
pub use horizon_client_core::block_entity_nbt;
|
||||
pub use horizon_client_core::bossbar;
|
||||
pub use horizon_client_core::component;
|
||||
pub use horizon_client_core::fastutil_order;
|
||||
pub use horizon_client_core::gamerules;
|
||||
pub use horizon_client_core::lang;
|
||||
pub use horizon_client_core::nbt_path;
|
||||
pub use horizon_client_core::scoreboard;
|
||||
pub use horizon_client_core::snbt;
|
||||
pub use horizon_client_core::text;
|
||||
pub use horizon_client_input::clipboard;
|
||||
pub use horizon_client_input::gamepad;
|
||||
pub use horizon_client_input::input;
|
||||
pub use horizon_client_input::interaction_input;
|
||||
pub use horizon_client_input::key_names;
|
||||
pub use horizon_client_input::keybinds;
|
||||
pub use horizon_client_input::keymapping;
|
||||
pub use horizon_client_input::mouse;
|
||||
pub use horizon_client_input::window;
|
||||
pub use horizon_command as command;
|
||||
pub use horizon_items::blocks;
|
||||
pub use horizon_items::bundle;
|
||||
pub use horizon_items::cooldowns;
|
||||
pub use horizon_items::enchant;
|
||||
pub use horizon_items::flowers;
|
||||
pub use horizon_items::item_data;
|
||||
pub use horizon_items::items;
|
||||
pub use horizon_items::random_sequences;
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Startup shared by the gameplay library and its executable.
|
||||
use crate::{crash, game, logging, ram_limit};
|
||||
|
||||
/// Start the game. The executable supplies the allocator and retains the mod API marker.
|
||||
pub fn main() {
|
||||
horizon_threads::configure_allocator();
|
||||
horizon_client::telemetry::mark_start();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let game_args = args.iter().take_while(|a| *a != "--dedicated");
|
||||
let help = game_args
|
||||
.clone()
|
||||
.any(|a| a == "--help" || a == "-h" || a == "--launch-api");
|
||||
// --home before the log opens in it.
|
||||
if let Some(dir) = game_args.clone().skip_while(|a| *a != "--home").nth(1) {
|
||||
logging::set_game_home(std::path::absolute(dir).unwrap_or_else(|_| dir.into()));
|
||||
}
|
||||
// The dedicated server logs in its own folder (--game-dir's), as vanilla's does; the game in its home.
|
||||
let (home, side, thread) = if args.iter().any(|a| a == "--dedicated") {
|
||||
let folder = game_args
|
||||
.clone()
|
||||
.skip_while(|a| *a != "--game-dir" && *a != "--gameDir")
|
||||
.nth(1)
|
||||
.and_then(|dir| std::path::absolute(dir).ok())
|
||||
.unwrap_or_else(|| ".".into());
|
||||
(folder, "server", "Server thread")
|
||||
} else {
|
||||
// --player-dir too (a game's, not a server's): a player of a shared game folder keeps
|
||||
// its log and crash reports in its own; crash telemetry stays with the home's consent.
|
||||
if let Some(dir) = game_args
|
||||
.clone()
|
||||
.skip_while(|a| *a != "--player-dir")
|
||||
.nth(1)
|
||||
{
|
||||
logging::set_player_dir(std::path::absolute(dir).unwrap_or_else(|_| dir.into()));
|
||||
}
|
||||
(logging::game_home(), "client", "Render thread")
|
||||
};
|
||||
if !help {
|
||||
logging::init(
|
||||
logging::player_dir_override().unwrap_or(home.as_path()),
|
||||
thread,
|
||||
);
|
||||
// The log's first line names the build, so a bug report that carries the log says which one it was.
|
||||
log::info!("Horizon {}", crash::version());
|
||||
crash::install(home.clone(), side);
|
||||
ram_limit::start(&home, side);
|
||||
}
|
||||
raise_open_file_limit();
|
||||
if let Err(error) = game::run() {
|
||||
// A bad command line is the caller's mistake, not a crash: no report.
|
||||
let usage = error.is::<game::UsageError>();
|
||||
log::error!("Horizon: {error}");
|
||||
logging::flush();
|
||||
// The report's path is the last line on stderr (crash::SAVED_LINE).
|
||||
if !help && !usage {
|
||||
crash::report_error(&home, side, &*error);
|
||||
}
|
||||
// Started from Explorer, the console window closes with the game:
|
||||
// keep the message on screen until it is read.
|
||||
#[cfg(windows)]
|
||||
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
|
||||
eprintln!("Press Enter to close.");
|
||||
let _ = std::io::stdin().read_line(&mut String::new());
|
||||
}
|
||||
std::process::exit(if usage {
|
||||
game::exit_code::USAGE
|
||||
} else {
|
||||
game::exit_code::ERROR
|
||||
});
|
||||
}
|
||||
logging::flush();
|
||||
}
|
||||
|
||||
/// The JVM raises its open-file limit to the hard limit as it starts
|
||||
/// (`-XX:+MaxFDLimit`, on by default), so a vanilla server may hold as many
|
||||
/// sockets and region files as the host allows, not the soft default (often
|
||||
/// 1024 in a container). The same here; macOS caps it at `OPEN_MAX`, as the
|
||||
/// JVM does there.
|
||||
fn raise_open_file_limit() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use rustix::process::{Resource, Rlimit, getrlimit, setrlimit};
|
||||
let limit = getrlimit(Resource::Nofile);
|
||||
let hard = if cfg!(target_os = "macos") {
|
||||
Some(limit.maximum.map_or(10_240, |h| h.min(10_240)))
|
||||
} else {
|
||||
limit.maximum
|
||||
};
|
||||
let raised = match (limit.current, hard) {
|
||||
(Some(soft), Some(hard)) => hard > soft,
|
||||
(Some(_), None) => true,
|
||||
(None, _) => false,
|
||||
};
|
||||
if raised {
|
||||
let to = Rlimit {
|
||||
current: hard,
|
||||
maximum: limit.maximum,
|
||||
};
|
||||
match setrlimit(Resource::Nofile, to) {
|
||||
Ok(()) => log::info!(
|
||||
"Open file limit raised from {:?} to {hard:?}",
|
||||
limit.current
|
||||
),
|
||||
Err(e) => log::warn!("Open file limit stays at {:?}: {e}", limit.current),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,16 @@ use crate::{
|
||||
ui,
|
||||
};
|
||||
|
||||
/// The English title: the fallback of [`title`], and what the screen's widgets are named by.
|
||||
pub const TITLE: &str = "Play with Friends";
|
||||
/// A string of this screen: the language table's, or the English text.
|
||||
fn tr(key: &str, english: &str) -> String {
|
||||
crate::lang::get_or(key, english)
|
||||
}
|
||||
/// The title in the current language.
|
||||
pub fn title() -> String {
|
||||
tr("horizon.friends.title", TITLE)
|
||||
}
|
||||
/// What the screen shows (game/friends.rs fills it).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FriendsView {
|
||||
@@ -47,13 +56,18 @@ fn grey() -> u32 {
|
||||
rgba(160, 160, 160, 255)
|
||||
}
|
||||
const WHITE: u32 = u32::MAX;
|
||||
const HINT: &str = "Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.";
|
||||
fn hint() -> String {
|
||||
tr(
|
||||
"horizon.friends.hint",
|
||||
"Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
|
||||
)
|
||||
}
|
||||
|
||||
struct Button {
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
label: &'static str,
|
||||
label: String,
|
||||
action: Action,
|
||||
active: bool,
|
||||
}
|
||||
@@ -76,7 +90,11 @@ fn layout(
|
||||
};
|
||||
let mut buttons = Vec::new();
|
||||
if view.sharing {
|
||||
lines.push(("Invite Code".to_owned(), grey(), y));
|
||||
lines.push((
|
||||
tr("horizon.multiplayer.invite_code", "Invite Code"),
|
||||
grey(),
|
||||
y,
|
||||
));
|
||||
y += 12.0;
|
||||
match &view.code {
|
||||
Some(code) => {
|
||||
@@ -92,7 +110,11 @@ fn layout(
|
||||
x: cx - 154.0,
|
||||
y,
|
||||
w: 150.0,
|
||||
label: if view.copied { "Copied" } else { "Copy Code" },
|
||||
label: if view.copied {
|
||||
tr("horizon.friends.copied", "Copied")
|
||||
} else {
|
||||
tr("horizon.friends.copy_code", "Copy Code")
|
||||
},
|
||||
action: Action::Copy,
|
||||
active: view.code.is_some(),
|
||||
});
|
||||
@@ -100,7 +122,7 @@ fn layout(
|
||||
x: cx + 4.0,
|
||||
y,
|
||||
w: 150.0,
|
||||
label: "Stop Sharing",
|
||||
label: tr("horizon.friends.stop_sharing", "Stop Sharing"),
|
||||
action: Action::Stop,
|
||||
active: true,
|
||||
});
|
||||
@@ -113,32 +135,40 @@ fn layout(
|
||||
y += 4.0;
|
||||
}
|
||||
let here = if view.guests.is_empty() {
|
||||
"No friends here yet.".to_owned()
|
||||
tr("horizon.friends.none_yet", "No friends here yet.")
|
||||
} else {
|
||||
format!("Here now: {}", view.guests.join(", "))
|
||||
tr("horizon.friends.here_now", "Here now: %s").replacen(
|
||||
"%s",
|
||||
&view.guests.join(", "),
|
||||
1,
|
||||
)
|
||||
};
|
||||
add(&mut lines, &here, grey(), &mut y);
|
||||
if let Some(notice) = &view.notice {
|
||||
add(&mut lines, notice, WHITE, &mut y);
|
||||
}
|
||||
// Where friends paste the code, while it fits above the footer.
|
||||
if y + 10.0 * wrap(HINT, width, atlas).len() as f32 <= h - 33.0 {
|
||||
add(&mut lines, HINT, grey(), &mut y);
|
||||
let hint = hint();
|
||||
if y + 10.0 * wrap(&hint, width, atlas).len() as f32 <= h - 33.0 {
|
||||
add(&mut lines, &hint, grey(), &mut y);
|
||||
}
|
||||
} else {
|
||||
add(
|
||||
&mut lines,
|
||||
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
|
||||
&tr(
|
||||
"horizon.friends.share_intro",
|
||||
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
|
||||
),
|
||||
WHITE,
|
||||
&mut y,
|
||||
);
|
||||
y += 4.0;
|
||||
add(&mut lines, HINT, grey(), &mut y);
|
||||
add(&mut lines, &hint(), grey(), &mut y);
|
||||
buttons.push(Button {
|
||||
x: cx - 100.0,
|
||||
y: y + 8.0,
|
||||
w: 200.0,
|
||||
label: "Start Sharing",
|
||||
label: tr("horizon.friends.start_sharing", "Start Sharing"),
|
||||
action: Action::Share,
|
||||
active: view.shown,
|
||||
});
|
||||
@@ -150,7 +180,7 @@ fn layout(
|
||||
x: cx - 154.0,
|
||||
y: h - 27.0,
|
||||
w: 150.0,
|
||||
label: "Troubleshooting...",
|
||||
label: tr("horizon.friends.troubleshooting", "Troubleshooting..."),
|
||||
action: Action::Help,
|
||||
active: true,
|
||||
});
|
||||
@@ -158,7 +188,7 @@ fn layout(
|
||||
x: cx + 4.0,
|
||||
y: h - 27.0,
|
||||
w: 150.0,
|
||||
label: "gui.done",
|
||||
label: "gui.done".to_owned(),
|
||||
action: Action::Done,
|
||||
active: true,
|
||||
});
|
||||
@@ -167,7 +197,7 @@ fn layout(
|
||||
x: cx - 100.0,
|
||||
y: h - 27.0,
|
||||
w: 200.0,
|
||||
label: "gui.done",
|
||||
label: "gui.done".to_owned(),
|
||||
action: Action::Done,
|
||||
active: true,
|
||||
});
|
||||
@@ -204,7 +234,7 @@ pub fn draw(
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
let title = Text::literal(TITLE);
|
||||
let title = Text::literal(title());
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
|
||||
let (lines, buttons) = layout(atlas, (w, h), view);
|
||||
@@ -221,7 +251,7 @@ pub fn draw(
|
||||
b.y,
|
||||
b.w,
|
||||
20.0,
|
||||
&label(b.label),
|
||||
&label(&b.label),
|
||||
b.active,
|
||||
hover,
|
||||
);
|
||||
|
||||
+414
-147
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1075,8 +1075,7 @@ impl Game<'_> {
|
||||
/// `Screen.clickCommandAction`: run a clicked command as the player
|
||||
/// without adding it to the chat history. A client mod's command goes
|
||||
/// first, as `ClientPacketListener.sendUnattendedCommand` lets client
|
||||
/// commands take it. (Vanilla asks first when the command fails to
|
||||
/// parse; here the server answers it.)
|
||||
/// commands take it.
|
||||
pub(super) fn click_command(&mut self, command: &str) {
|
||||
let command = command.strip_prefix('/').unwrap_or(command);
|
||||
if self.client_mod_command(command) {
|
||||
|
||||
@@ -9,7 +9,7 @@ use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
hud::{HudBatch, rgba},
|
||||
screen_draw::Measure,
|
||||
text::{self, Text},
|
||||
text::Text,
|
||||
ui::{self, EditBox},
|
||||
world::World,
|
||||
};
|
||||
@@ -546,9 +546,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `CommandSourceStack.sendSuccess` from a command block: its output when
|
||||
/// `send_command_feedback`, and `broadcastToAdmins` when
|
||||
/// `send_command_feedback`, and `broadcastToAdmins` when `broadcast` and
|
||||
/// `command_block_output`.
|
||||
pub(super) fn command_block_success(&mut self, pos: Pos, message: Text) {
|
||||
pub(super) fn command_block_success(&mut self, pos: Pos, message: Text, broadcast: bool) {
|
||||
let Some(name) = self
|
||||
.command_block(pos)
|
||||
.filter(|c| c.track_output)
|
||||
@@ -560,11 +560,8 @@ impl Game<'_> {
|
||||
if feedback {
|
||||
self.command_block_output(pos, message.clone());
|
||||
}
|
||||
if feedback && self.ui.rules.bool("command_block_output") {
|
||||
let broadcast = Text::translate("chat.type.admin", &[name, message])
|
||||
.with_color(text::GRAY)
|
||||
.italic();
|
||||
self.system_message(broadcast);
|
||||
if broadcast && feedback && self.ui.rules.bool("command_block_output") {
|
||||
self.broadcast_source_to_admins(name, message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -806,8 +803,8 @@ impl Game<'_> {
|
||||
shortcut: Option<horizon_client::clipboard::EditShortcut>,
|
||||
text: Option<&str>,
|
||||
) {
|
||||
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let ctrl = self.has_control_down_with_quirk();
|
||||
let shift = self.has_shift_down();
|
||||
// Windows' AltGr is Ctrl+Alt, and its characters still type.
|
||||
let types_text = self.types_text();
|
||||
let Some(mut s) = self.command_block_screen.take() else {
|
||||
@@ -920,7 +917,8 @@ impl Game<'_> {
|
||||
conditional: p.flags & 2 != 0,
|
||||
auto: p.flags & 4 != 0,
|
||||
};
|
||||
self.as_guest(host, conn, |game| {
|
||||
// The confirmation (`advMode.setCommand.success`) is the editing player's alone.
|
||||
self.as_guest_messaged(host, conn, |game| {
|
||||
if allowed {
|
||||
game.apply_command_block(&edit);
|
||||
// BaseCommandBlock.onUpdated: the clients get the new command.
|
||||
|
||||
@@ -9,10 +9,7 @@ use super::{
|
||||
lan::LanHost,
|
||||
minecarts::{CartKind, Minecart},
|
||||
};
|
||||
use horizon_client::{
|
||||
command_block::Mode,
|
||||
text::{self, Text},
|
||||
};
|
||||
use horizon_client::{command_block::Mode, text::Text};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -93,7 +90,7 @@ impl Game<'_> {
|
||||
/// `CommandSourceStack.sendSuccess` from a command minecart: its output
|
||||
/// when `send_command_feedback`, and `broadcastToAdmins` when
|
||||
/// `command_block_output`.
|
||||
pub(super) fn cart_command_success(&mut self, id: u32, message: Text) {
|
||||
pub(super) fn cart_command_success(&mut self, id: u32, message: Text, broadcast: bool) {
|
||||
let Some(name) = self
|
||||
.command_cart_mut(id)
|
||||
.filter(|c| c.command.track_output)
|
||||
@@ -105,11 +102,8 @@ impl Game<'_> {
|
||||
if feedback {
|
||||
self.cart_command_output(id, message.clone());
|
||||
}
|
||||
if feedback && self.ui.rules.bool("command_block_output") {
|
||||
let broadcast = Text::translate("chat.type.admin", &[name, message])
|
||||
.with_color(text::GRAY)
|
||||
.italic();
|
||||
self.system_message(broadcast);
|
||||
if broadcast && feedback && self.ui.rules.bool("command_block_output") {
|
||||
self.broadcast_source_to_admins(name, message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +158,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let allowed = g.creative() && g.permission >= 2;
|
||||
self.as_guest(host, conn, |game| {
|
||||
self.as_guest_messaged(host, conn, |game| {
|
||||
if allowed {
|
||||
game.apply_cart_command(p.entity as u32, p.command, p.track_output);
|
||||
} else {
|
||||
|
||||
@@ -63,6 +63,10 @@ pub(super) enum Target {
|
||||
Crystal(u32),
|
||||
/// The Ender Dragon, by its entity id (`minecraft:ender_dragon`).
|
||||
Dragon(u32),
|
||||
/// A primed TNT, by its entity id (`minecraft:tnt`).
|
||||
Tnt(u32),
|
||||
/// A falling block, by its entity id (`minecraft:falling_block`).
|
||||
Falling(u32),
|
||||
}
|
||||
/// An entity of a type Horizon does not simulate: its compound lives in the
|
||||
/// entity memory (`persist.raw`), its position and rotation here.
|
||||
@@ -135,7 +139,9 @@ fn non_player_entity_id(t: Target) -> Option<i32> {
|
||||
| Target::Cart(id)
|
||||
| Target::Other(id)
|
||||
| Target::Crystal(id)
|
||||
| Target::Dragon(id)) = t
|
||||
| Target::Dragon(id)
|
||||
| Target::Tnt(id)
|
||||
| Target::Falling(id)) = t
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
@@ -242,11 +248,11 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if let Some(block) = self.command_source.and_then(|s| s.block) {
|
||||
self.command_block_success(block, message);
|
||||
self.command_block_success(block, message, false);
|
||||
return;
|
||||
}
|
||||
if let Some(cart) = self.command_source.and_then(|s| s.cart) {
|
||||
self.cart_command_success(cart, message);
|
||||
self.cart_command_success(cart, message, false);
|
||||
return;
|
||||
}
|
||||
if self.ui.rules.bool("send_command_feedback") {
|
||||
@@ -259,11 +265,11 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if let Some(block) = self.command_source.and_then(|s| s.block) {
|
||||
self.command_block_success(block, message);
|
||||
self.command_block_success(block, message, true);
|
||||
return;
|
||||
}
|
||||
if let Some(cart) = self.command_source.and_then(|s| s.cart) {
|
||||
self.cart_command_success(cart, message);
|
||||
self.cart_command_success(cart, message, true);
|
||||
return;
|
||||
}
|
||||
if self.ui.rules.bool("send_command_feedback") {
|
||||
@@ -271,6 +277,36 @@ impl Game<'_> {
|
||||
}
|
||||
self.broadcast_to_admins(message);
|
||||
}
|
||||
/// `CommandSourceStack.broadcastToAdmins` for a command block or a command
|
||||
/// minecart called `name`: `[name: message]`, gray and italic, for every
|
||||
/// operator when `send_command_feedback` (the world's host too: no player
|
||||
/// ran the command) and for a dedicated server's console when
|
||||
/// `log_admin_commands`. Queued: `send_admin_feedback` sends it.
|
||||
pub(super) fn broadcast_source_to_admins(&mut self, name: Text, message: Text) {
|
||||
if message
|
||||
.translation_key()
|
||||
.is_some_and(|k| admin::QUIET.contains(&k))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let line = Text::translate("chat.type.admin", &[name, message])
|
||||
.with_color(text::GRAY)
|
||||
.italic();
|
||||
// Nobody else is in this game: its player is the only operator there is.
|
||||
if self.lan.is_none()
|
||||
&& self.lan_acting.is_none()
|
||||
&& self.serving.is_none()
|
||||
&& self.options.dedicated.is_none()
|
||||
{
|
||||
self.system_message(line);
|
||||
return;
|
||||
}
|
||||
let ops = self.ui.rules.bool("send_command_feedback");
|
||||
let console = self.options.dedicated.is_some() && self.ui.rules.bool("log_admin_commands");
|
||||
if ops || console {
|
||||
self.admin_feedback.push((line, ops, console, true));
|
||||
}
|
||||
}
|
||||
/// CommandSourceStack.broadcastToAdmins (`sendSuccess` with `broadcast`)
|
||||
/// for a guest's command, the host's in a world open to LAN and the
|
||||
/// dedicated server console's when `broadcast-console-to-ops` is on:
|
||||
@@ -310,7 +346,7 @@ impl Game<'_> {
|
||||
let line = Text::translate("chat.type.admin", &[who, message])
|
||||
.with_color(text::GRAY)
|
||||
.italic();
|
||||
self.admin_feedback.push((line, ops, console));
|
||||
self.admin_feedback.push((line, ops, console, false));
|
||||
}
|
||||
/// Player.getDisplayName: the name (team-formatted), suggesting a
|
||||
/// `/tell`, showing the player on hover, inserting the name.
|
||||
@@ -479,6 +515,16 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.filter(|d| d.id == id)
|
||||
.map(|d| [d.position.x, d.position.y, d.position.z]),
|
||||
Target::Tnt(id) => self
|
||||
.tnts
|
||||
.iter()
|
||||
.find(|t| t.id == id)
|
||||
.map(|t| [t.position.x, t.position.y, t.position.z]),
|
||||
Target::Falling(id) => self
|
||||
.falling
|
||||
.iter()
|
||||
.find(|f| f.id == id)
|
||||
.map(|f| [f.position.x, f.position.y, f.position.z]),
|
||||
}
|
||||
}
|
||||
pub(super) fn target_type(&self, t: Target) -> String {
|
||||
@@ -525,6 +571,8 @@ impl Game<'_> {
|
||||
.map_or_else(String::new, |o| o.kind.clone()),
|
||||
Target::Crystal(_) => "minecraft:end_crystal".into(),
|
||||
Target::Dragon(_) => "minecraft:ender_dragon".into(),
|
||||
Target::Tnt(_) => "minecraft:tnt".into(),
|
||||
Target::Falling(_) => "minecraft:falling_block".into(),
|
||||
}
|
||||
}
|
||||
/// The name of the player the game acts for: a guest's own while their
|
||||
@@ -559,6 +607,18 @@ impl Game<'_> {
|
||||
.unwrap_or_default();
|
||||
Text::literal(self.pack.names.display_item(&item))
|
||||
}
|
||||
// FallingBlockEntity.getTypeName: `entity.minecraft.falling_block_type`
|
||||
// with the block's name.
|
||||
Target::Falling(id) => {
|
||||
let block = self
|
||||
.falling
|
||||
.iter()
|
||||
.find(|f| f.id == id)
|
||||
.map(|f| self.res.facts().get(f.state).name.replace(':', "."))
|
||||
.unwrap_or_default();
|
||||
let block = Text::translate(&format!("block.{block}"), &[]);
|
||||
Text::translate("entity.minecraft.falling_block_type", &[block])
|
||||
}
|
||||
Target::Mob(id)
|
||||
if self
|
||||
.mobs
|
||||
@@ -779,6 +839,7 @@ impl Game<'_> {
|
||||
// The kinds /data can write (entity_nbt).
|
||||
.filter(|p| {
|
||||
p.is_arrow()
|
||||
|| horizon_client::entity_nbt::saved_with_chunk(p)
|
||||
|| matches!(
|
||||
p.kind,
|
||||
K::LargeFireball { .. }
|
||||
@@ -801,6 +862,8 @@ impl Game<'_> {
|
||||
if let Some(d) = &self.dragon {
|
||||
candidates.push(Target::Dragon(d.id));
|
||||
}
|
||||
candidates.extend(self.tnts.iter().map(|t| Target::Tnt(t.id)));
|
||||
candidates.extend(self.falling.iter().map(|f| Target::Falling(f.id)));
|
||||
candidates
|
||||
}
|
||||
/// The loaded levels, in the order the server holds them: the
|
||||
@@ -832,6 +895,8 @@ impl Game<'_> {
|
||||
Target::Other(id),
|
||||
Target::Crystal(id),
|
||||
Target::Dragon(id),
|
||||
Target::Tnt(id),
|
||||
Target::Falling(id),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|t| self.level_holds(*t))
|
||||
@@ -853,6 +918,8 @@ impl Game<'_> {
|
||||
Target::Other(id) => self.others.iter().any(|o| o.id == id),
|
||||
Target::Crystal(id) => self.crystals.iter().any(|c| c.id == id),
|
||||
Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id),
|
||||
Target::Tnt(id) => self.tnts.iter().any(|t| t.id == id),
|
||||
Target::Falling(id) => self.falling.iter().any(|f| f.id == id),
|
||||
}
|
||||
}
|
||||
/// Run `f` with the level that holds the entity a target names in place
|
||||
@@ -1463,6 +1530,18 @@ impl Game<'_> {
|
||||
d.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Tnt(id) => {
|
||||
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id) {
|
||||
t.position = v;
|
||||
t.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Falling(id) => {
|
||||
if let Some(f) = self.falling.iter_mut().find(|f| f.id == id) {
|
||||
f.position = v;
|
||||
f.previous = v;
|
||||
}
|
||||
}
|
||||
// Wall-mounted entities do not move.
|
||||
Target::Frame(_) | Target::Painting(_) => {}
|
||||
}
|
||||
@@ -1960,6 +2039,8 @@ impl Game<'_> {
|
||||
Target::Other(id) => self.others.retain(|o| o.id != id),
|
||||
Target::Crystal(id) => self.kill_crystal(id),
|
||||
Target::Dragon(id) => self.kill_dragon(id),
|
||||
Target::Tnt(id) => self.tnts.retain(|t| t.id != id),
|
||||
Target::Falling(id) => self.falling.retain(|f| f.id != id),
|
||||
}
|
||||
// The orbs marked for removal are gone (Entity.kill removes them).
|
||||
let before = self.orb_ids();
|
||||
@@ -2162,26 +2243,12 @@ impl Game<'_> {
|
||||
}
|
||||
/// BlockPredicateArgument test.
|
||||
fn block_matches(&self, b: &BlockInput, state: BlockStateId) -> bool {
|
||||
let Some(s) = self.pack.assets.registry.state(state) else {
|
||||
return false;
|
||||
};
|
||||
let name_ok = if b.tag {
|
||||
let tag = b.id.trim_start_matches("minecraft:");
|
||||
self.res
|
||||
.items()
|
||||
.block_tags
|
||||
.get(tag)
|
||||
.or_else(|| self.items().block_tags.get(&b.id))
|
||||
.is_some_and(|set| {
|
||||
set.contains(&s.name) || set.contains(s.name.trim_start_matches("minecraft:"))
|
||||
})
|
||||
} else {
|
||||
s.name == b.id
|
||||
};
|
||||
name_ok
|
||||
&& b.properties
|
||||
.iter()
|
||||
.all(|(k, v)| s.properties.get(k) == Some(v))
|
||||
block_input_matches(
|
||||
self.pack.assets.registry,
|
||||
[&self.res.items().block_tags, &self.items().block_tags],
|
||||
b,
|
||||
state,
|
||||
)
|
||||
}
|
||||
/// Place a state like BlockInput.place: false when nothing changed.
|
||||
pub(super) fn place_state(
|
||||
@@ -3587,6 +3654,35 @@ fn split_top_level(s: &str, sep: char) -> Vec<&str> {
|
||||
out
|
||||
}
|
||||
|
||||
/// BlockPredicateArgument test, against the registry and the block tags:
|
||||
/// a `#tag` is looked up by its path in `tags[0]`, then by its whole id in
|
||||
/// `tags[1]` (as `Game::block_matches` reads them).
|
||||
pub(super) fn block_input_matches(
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
tags: [&std::collections::HashMap<String, std::collections::HashSet<String>>; 2],
|
||||
b: &BlockInput,
|
||||
state: BlockStateId,
|
||||
) -> bool {
|
||||
let Some(s) = registry.state(state) else {
|
||||
return false;
|
||||
};
|
||||
let name_ok = if b.tag {
|
||||
let tag = b.id.trim_start_matches("minecraft:");
|
||||
tags[0]
|
||||
.get(tag)
|
||||
.or_else(|| tags[1].get(&b.id))
|
||||
.is_some_and(|set| {
|
||||
set.contains(&s.name) || set.contains(s.name.trim_start_matches("minecraft:"))
|
||||
})
|
||||
} else {
|
||||
s.name == b.id
|
||||
};
|
||||
name_ok
|
||||
&& b.properties
|
||||
.iter()
|
||||
.all(|(k, v)| s.properties.get(k) == Some(v))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod summon_tests {
|
||||
#[test]
|
||||
@@ -3616,6 +3712,8 @@ mod look_at_tests {
|
||||
Target::Cart(7),
|
||||
Target::Crystal(7),
|
||||
Target::Dragon(7),
|
||||
Target::Tnt(7),
|
||||
Target::Falling(7),
|
||||
] {
|
||||
assert_eq!(non_player_entity_id(t), Some(7), "{t:?}");
|
||||
}
|
||||
|
||||
@@ -164,7 +164,8 @@ impl Game<'_> {
|
||||
.and_then(|a| a.last_state)
|
||||
.map(|s| self.state_to_nbt(s));
|
||||
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)
|
||||
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))?
|
||||
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))
|
||||
.or_else(|| entity_nbt::throwable_to_nbt(p, random_uuid(), owner, None))?
|
||||
}
|
||||
Target::Crystal(id) => {
|
||||
let c = self.crystals.iter().find(|c| c.id == id)?;
|
||||
@@ -176,6 +177,36 @@ impl Game<'_> {
|
||||
let (uuid, raw) = self.persist_anon("minecraft:ender_dragon", d.position);
|
||||
d.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
|
||||
}
|
||||
Target::Tnt(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let t = self.tnts.iter().find(|t| t.id == id)?;
|
||||
entity_nbt::tnt_to_nbt(
|
||||
t.position,
|
||||
t.velocity,
|
||||
t.on_ground,
|
||||
t.fuse,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
)
|
||||
}
|
||||
Target::Falling(id) => {
|
||||
let f = self.falling.iter().find(|f| f.id == id)?.clone();
|
||||
let (uuid, raw) = match &f.saved {
|
||||
Some(saved) => (saved.0, Some(&saved.1)),
|
||||
None => (self.uuid_for(id), None),
|
||||
};
|
||||
let tag = entity_nbt::falling_block_to_nbt(
|
||||
self.state_to_nbt(f.state),
|
||||
f.position,
|
||||
f.velocity,
|
||||
f.age,
|
||||
f.drop_item,
|
||||
f.damage,
|
||||
f.fall_distance,
|
||||
uuid,
|
||||
);
|
||||
crate::game::persist::with_raw(tag, raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
/// `Entity.load` of new data (`EntityDataAccessor.setData`): the entity is
|
||||
@@ -776,6 +807,10 @@ impl Game<'_> {
|
||||
Some(Target::Cart(id))
|
||||
} else if self.others.iter().any(|o| o.id == id) {
|
||||
Some(Target::Other(id))
|
||||
} else if self.tnts.iter().any(|t| t.id == id) {
|
||||
Some(Target::Tnt(id))
|
||||
} else if self.falling.iter().any(|f| f.id == id) {
|
||||
Some(Target::Falling(id))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
[
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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] });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,15 @@ impl EndFlash {
|
||||
fn intensity(&self, partial: f32) -> f32 {
|
||||
self.old_intensity + (self.intensity - self.old_intensity) * partial
|
||||
}
|
||||
/// SkyRenderer.renderEndFlash's pose: the sprite at height 100 turned to the flash.
|
||||
fn pose(&self) -> glam::Mat4 {
|
||||
flash_pose(self.x_angle, self.y_angle)
|
||||
}
|
||||
}
|
||||
|
||||
fn flash_pose(x_angle: f32, y_angle: f32) -> glam::Mat4 {
|
||||
glam::Mat4::from_rotation_y((180.0 - y_angle).to_radians())
|
||||
* glam::Mat4::from_rotation_x((-90.0 - x_angle).to_radians())
|
||||
}
|
||||
impl EndState {
|
||||
pub(super) fn locator(&self) -> Option<Arc<VanillaTerrain>> {
|
||||
@@ -316,9 +325,7 @@ impl Game<'_> {
|
||||
.get("environment/celestial/end_flash")
|
||||
.copied()
|
||||
{
|
||||
let flash = &self.end.flash;
|
||||
let pose = glam::Mat4::from_rotation_y((180.0 - flash.y_angle).to_radians())
|
||||
* glam::Mat4::from_rotation_x((-90.0 - flash.x_angle).to_radians());
|
||||
let pose = self.end.flash.pose();
|
||||
let level = (intensity.clamp(0.0, 1.0) * 255.0) as u32;
|
||||
let color = level << 24 | level << 16 | level << 8 | level;
|
||||
let corners = [
|
||||
@@ -344,6 +351,21 @@ impl Game<'_> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Iris's `endFlashIntensity` and the flash's direction (camera-relative, length 100: the
|
||||
/// sprite's centre), for shader packs; no flash outside the End.
|
||||
pub(super) fn end_flash_for_pack(&self, partial: f32) -> (f32, glam::Vec3) {
|
||||
if self.dim != Dim::End {
|
||||
return (0.0, glam::Vec3::ZERO);
|
||||
}
|
||||
let flash = &self.end.flash;
|
||||
(
|
||||
flash.intensity(partial).clamp(0.0, 1.0),
|
||||
flash
|
||||
.pose()
|
||||
.transform_point3(glam::Vec3::new(0.0, 100.0, 0.0)),
|
||||
)
|
||||
}
|
||||
|
||||
/// LightmapRenderStateExtractor: End flashes add to the sky light factor
|
||||
/// unless Hide Lightning Flashes is on (the sky sprite is not gated), a
|
||||
/// third of it under a boss bar's world fog.
|
||||
@@ -1859,4 +1881,17 @@ mod tests {
|
||||
let centre = corners.iter().copied().sum::<glam::Vec3>() / 4.0;
|
||||
assert!((centre - at).abs_diff_eq(up_dir * 0.125, 1e-5), "{centre}");
|
||||
}
|
||||
|
||||
/// `endFlashPosition`'s direction: the flash sprite's centre, 100 blocks away.
|
||||
#[test]
|
||||
fn the_end_flash_is_where_its_sprite_is() {
|
||||
let at =
|
||||
|x: f32, y: f32| flash_pose(x, y).transform_point3(glam::Vec3::new(0.0, 100.0, 0.0));
|
||||
// Straight up whatever the turn, and level toward the north at y_angle 180.
|
||||
assert!(at(-90.0, 37.0).abs_diff_eq(glam::Vec3::new(0.0, 100.0, 0.0), 1e-3));
|
||||
assert!(at(0.0, 180.0).abs_diff_eq(glam::Vec3::new(0.0, 0.0, -100.0), 1e-3));
|
||||
for (x, y) in [(-60.0, -180.0), (10.0, 95.0), (-25.0, 3.0)] {
|
||||
assert!((at(x, y).length() - 100.0).abs() < 1e-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,15 @@ pub(super) fn blast_reaches(center: [f64; 3], diameter: f64, at: [f64; 3]) -> bo
|
||||
d / diameter <= 1.0
|
||||
}
|
||||
|
||||
/// `ItemEntity.ignoreExplosion` through
|
||||
/// `ServerExplosion.shouldAffectBlocklikeEntities`: an explosion that does not
|
||||
/// affect blocklike entities is ignored entirely by a dropped item. With
|
||||
/// `mobGriefing` off that is every blast that keeps blocks; with it on, none
|
||||
/// (no wind charge goes through `server_explode`).
|
||||
fn items_ignore_explosion(mob_griefing: bool, interaction: BlockInteraction) -> bool {
|
||||
!mob_griefing && interaction == BlockInteraction::Keep
|
||||
}
|
||||
|
||||
/// Whether a blast leaves item frames, paintings, cushions and armour stands
|
||||
/// alone. Without `mobGriefing`, a blast that keeps blocks is ignored
|
||||
/// (`ServerExplosion.shouldAffectBlocklikeEntities` through their
|
||||
@@ -593,9 +602,15 @@ impl Game<'_> {
|
||||
}
|
||||
self.mobs = mobs;
|
||||
// Dropped items: 5 health, destroyed by the explosion damage (nether stars resist).
|
||||
// `ItemEntity.ignoreExplosion` asks `shouldAffectBlocklikeEntities`
|
||||
// first, so a blast that keeps blocks leaves every item untouched.
|
||||
let items_ignore = items_ignore_explosion(self.ui.rules.bool("mob_griefing"), interaction);
|
||||
let before = self.dropped_ids();
|
||||
let mut items = std::mem::take(&mut self.dropped);
|
||||
items.retain_mut(|item| {
|
||||
if items_ignore {
|
||||
return true;
|
||||
}
|
||||
let feet = [item.position.x, item.position.y, item.position.z];
|
||||
let dist = distance(feet) / diameter;
|
||||
if dist > 1.0 {
|
||||
@@ -1168,6 +1183,20 @@ fn add_or_append_stack(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A dropped item survives a mob's blast untouched while `mobGriefing` is
|
||||
/// off, and is thrown and hurt as usual by one that destroys blocks or by
|
||||
/// any blast with the rule back on.
|
||||
#[test]
|
||||
fn items_ignore_a_blast_that_keeps_blocks_with_griefing_off() {
|
||||
assert!(items_ignore_explosion(false, BlockInteraction::Keep));
|
||||
assert!(!items_ignore_explosion(false, BlockInteraction::Destroy));
|
||||
assert!(!items_ignore_explosion(
|
||||
false,
|
||||
BlockInteraction::DestroyWithDecay
|
||||
));
|
||||
assert!(!items_ignore_explosion(true, BlockInteraction::Keep));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blasts_reach_entities_within_their_diameter() {
|
||||
// A TNT blast: radius 4, so the entity pass reaches 8 blocks out.
|
||||
|
||||
@@ -189,12 +189,12 @@ impl Game<'_> {
|
||||
self.avatar.add_walked(dx, dz);
|
||||
}
|
||||
}
|
||||
/// `SpyglassItem.use`: start scoping.
|
||||
/// `SpyglassItem.use`: the sound, then start scoping (a mod's `Start`
|
||||
/// may refuse that, not the sound).
|
||||
pub(super) fn use_spyglass(&mut self) {
|
||||
if self.start_using_item() {
|
||||
let at = self.eye_position();
|
||||
self.audio.play("minecraft:item.spyglass.use", at, 1.0, 1.0);
|
||||
}
|
||||
let at = self.eye_position();
|
||||
self.audio.play("minecraft:item.spyglass.use", at, 1.0, 1.0);
|
||||
self.start_using_item();
|
||||
}
|
||||
/// `SpyglassItem.releaseUsing` / `finishUsingItem` (after 1200 ticks).
|
||||
pub(super) fn stop_spyglass(&mut self) {
|
||||
|
||||
@@ -255,6 +255,12 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
if self.item_frames[i].fixed {
|
||||
// A fixed frame refuses the hit of a player who isn't in creative
|
||||
// (canHurtWhenFixed), so skipAttackInteraction is false and
|
||||
// Player.attack goes on to onAttack.
|
||||
if !self.creative {
|
||||
self.attack_ticks = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let with_frame = self.item_frames[i].item.is_none();
|
||||
|
||||
@@ -11,6 +11,7 @@ use horizon_client::{
|
||||
Result,
|
||||
friends_screen::{self, Action, FriendsView},
|
||||
hud::HudBatch,
|
||||
lang,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
|
||||
@@ -121,7 +122,10 @@ impl Game<'_> {
|
||||
if sharing.opened_lan {
|
||||
self.lan_close(true);
|
||||
} else {
|
||||
self.system_message(Text::literal("Stopped sharing the invite code."));
|
||||
self.system_message(Text::literal(lang::get_or(
|
||||
"horizon.friends.stopped",
|
||||
"Stopped sharing the invite code.",
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,9 +146,10 @@ impl Game<'_> {
|
||||
match event {
|
||||
HostEvent::Code(code) => {
|
||||
if sharing.code.is_some() && sharing.wrong >= horizon_friends::host::MAX_WRONG {
|
||||
sharing.notice = Some(
|
||||
"Someone kept trying a wrong short code, so it changed.".to_owned(),
|
||||
);
|
||||
sharing.notice = Some(lang::get_or(
|
||||
"horizon.friends.short_code_changed",
|
||||
"Someone kept trying a wrong short code, so it changed.",
|
||||
));
|
||||
sharing.wrong = 0;
|
||||
}
|
||||
announce = sharing.code.is_none();
|
||||
@@ -152,7 +157,10 @@ impl Game<'_> {
|
||||
}
|
||||
HostEvent::WrongCode { count } => {
|
||||
sharing.wrong = count;
|
||||
sharing.notice = Some("Someone tried a wrong code.".to_owned());
|
||||
sharing.notice = Some(lang::get_or(
|
||||
"horizon.friends.wrong_code",
|
||||
"Someone tried a wrong code.",
|
||||
));
|
||||
}
|
||||
HostEvent::Connected { direct: true, .. } => sharing.paths.0 += 1,
|
||||
HostEvent::Connected { direct: false, .. } => sharing.paths.1 += 1,
|
||||
@@ -160,9 +168,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if announce {
|
||||
self.system_message(Text::literal(
|
||||
self.system_message(Text::literal(lang::get_or(
|
||||
"horizon.friends.code_ready",
|
||||
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
|
||||
));
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +207,13 @@ impl Game<'_> {
|
||||
match action {
|
||||
Action::Share => {
|
||||
if let Err(e) = self.friends_start() {
|
||||
self.system_message(Text::literal(format!("Couldn't share the world: {e}")));
|
||||
self.system_message(Text::literal(
|
||||
lang::get_or(
|
||||
"horizon.friends.share_failed",
|
||||
"Couldn't share the world: %s",
|
||||
)
|
||||
.replacen("%s", &e.to_string(), 1),
|
||||
));
|
||||
}
|
||||
}
|
||||
Action::Copy => self.friends_copy(),
|
||||
@@ -234,7 +249,10 @@ impl Game<'_> {
|
||||
};
|
||||
let state = s.host.state();
|
||||
let mut status = if s.code.is_none() {
|
||||
"Finding how friends can reach you…".to_owned()
|
||||
lang::get_or(
|
||||
"horizon.friends.finding_route",
|
||||
"Finding how friends can reach you…",
|
||||
)
|
||||
} else {
|
||||
state.summary.clone()
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -869,7 +869,12 @@ impl Game<'_> {
|
||||
self.lan = Some(host);
|
||||
let lines = self.as_console(|g| {
|
||||
g.chat_capture = Some(Vec::new());
|
||||
g.run_command(&command);
|
||||
// A command a native mod registered goes to the mods
|
||||
// first, as the owner's own chat does (the console is
|
||||
// level 4, so every registered usage is allowed).
|
||||
if !g.perform_native_command(conn, 4, command.trim()) {
|
||||
g.run_command(&command);
|
||||
}
|
||||
g.chat_capture.take().unwrap_or_default()
|
||||
});
|
||||
host = self.lan.take().expect("a command puts the hub back");
|
||||
|
||||
@@ -273,7 +273,7 @@ impl Game<'_> {
|
||||
owner.using_this_item = true;
|
||||
owner.use_duration = duration;
|
||||
// getUseItemRemainingTicks: the duration the use started with,
|
||||
// as LivingEntityUseItemEvent.Start left it (`start_using_item`).
|
||||
// as LivingEntityUseItemEvent.Start and .Tick left it (`game/item_use.rs`).
|
||||
owner.use_item_remaining_ticks = self.use_duration - ticks as i32;
|
||||
owner.crossbow_charge_duration = Self::charge_ticks(stack).max(1) as i32;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
//! `Item` subclasses don't know: what `Item.getUseDuration` answers, a
|
||||
//! native mod's own item counted down to its `finishUsingItem`, and the hand
|
||||
//! pose of a `getUseAnimation` (`native_use_duration`, `native_use_animation`,
|
||||
//! `native_living_entity_use_item_start`, `native_living_entity_use_item_finish`).
|
||||
//! `native_living_entity_use_item_start`, `native_living_entity_use_item_finish`),
|
||||
//! and each tick and the release of a use (`native_living_entity_use_item_tick`,
|
||||
//! `native_on_use_tick`, `native_living_entity_use_item_stop`, `native_release_using`).
|
||||
use super::Game;
|
||||
use horizon_client::{items::ItemStack, spear::Kinetic};
|
||||
use horizon_client::{
|
||||
items::{ItemInfo, ItemStack},
|
||||
spear::Kinetic,
|
||||
};
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -17,15 +22,19 @@ impl Game<'_> {
|
||||
let Some(stack) = self.held().cloned().filter(|_| self.using.is_none()) else {
|
||||
return false;
|
||||
};
|
||||
let mut duration = self.use_duration(&stack);
|
||||
let mut canceled = false;
|
||||
let own = self.use_duration(&stack);
|
||||
let (mut duration, mut canceled) = (own, false);
|
||||
let hand = self.hand;
|
||||
self.native_living_entity_use_item_start(&stack, None, hand, &mut duration, &mut canceled);
|
||||
if canceled {
|
||||
let Some(ticks_set) = start_of(own, duration, canceled) else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
self.use_duration = duration;
|
||||
self.use_ticks_set = ticks_set;
|
||||
// A new charge hears the crossbow's loading sounds again
|
||||
// (`game/crossbow.rs`).
|
||||
self.crossbow_sounds = [false; 2];
|
||||
true
|
||||
}
|
||||
/// `ItemStack.getUseDuration`: a consumable's time, 72000 for what is
|
||||
@@ -72,14 +81,35 @@ impl Game<'_> {
|
||||
/// `LivingEntity.updateUsingItem` for a `mod_use` item used `ticks` so far:
|
||||
/// `completeUsingItem` once its duration ran out.
|
||||
pub(super) fn tick_mod_use(&mut self, slot: usize, ticks: u32, stack: &ItemStack) {
|
||||
self.item_on_use_tick(stack, ticks);
|
||||
if let Some(ticks) = count_down(ticks, self.use_duration) {
|
||||
self.using = Some((slot, ticks));
|
||||
return;
|
||||
}
|
||||
let Some(info) = self.items().get(&stack.item).cloned() else {
|
||||
self.using = None;
|
||||
return;
|
||||
};
|
||||
self.complete_using_item(slot, stack, &info);
|
||||
}
|
||||
/// `ServerPlayer.completeUsingItem` of `stack` in `slot`: the client is
|
||||
/// told, `ItemStack.finishUsingItem` (a consumable is eaten, a potion
|
||||
/// drunk, a spyglass lowered, anything else given back) with its use
|
||||
/// cooldown, then `LivingEntityUseItemEvent.Finish`.
|
||||
pub(super) fn complete_using_item(&mut self, slot: usize, stack: &ItemStack, info: &ItemInfo) {
|
||||
// a potion is drunk (no last tick of its own: a mod's tick ran it out).
|
||||
if self.finish_drinking(slot, stack) {
|
||||
self.use_item_finished(stack);
|
||||
return;
|
||||
}
|
||||
self.using = None;
|
||||
self.send_use_item_complete();
|
||||
// in ItemStack.finishUsingItem, Item's own gives the stack back.
|
||||
self.apply_use_cooldown(stack);
|
||||
if let Some(consumable) = &info.consumable {
|
||||
self.finish_consuming(info, consumable, stack);
|
||||
} else if &*stack.item == "minecraft:spyglass" {
|
||||
self.stop_spyglass();
|
||||
}
|
||||
self.use_item_finished(stack);
|
||||
}
|
||||
/// `LivingEntityUseItemEvent.Finish` after `finishUsingItem` of `stack`
|
||||
@@ -93,6 +123,56 @@ impl Game<'_> {
|
||||
*self.held_mut() = result.filter(|s| s.count > 0);
|
||||
}
|
||||
}
|
||||
/// `LivingEntityUseItemEvent.Tick` for `stack` in use `ticks` so far, as
|
||||
/// `updatingUsingItem` starts: a mod may change the ticks left, which
|
||||
/// moves the end. False when it ran the use out (`tick_ran_out`): the
|
||||
/// item's tick and countdown are skipped.
|
||||
pub(super) fn use_item_ticked(&mut self, stack: &ItemStack, ticks: u32) -> bool {
|
||||
let before = ticks_left(self.use_duration, ticks);
|
||||
let (mut remaining, mut canceled) = (before, false);
|
||||
let hand = self.used_hand().unwrap_or(self.hand);
|
||||
self.native_living_entity_use_item_tick(stack, None, hand, &mut remaining, &mut canceled);
|
||||
let (left, set) = tick_left(before, remaining, canceled, self.use_ticks_set);
|
||||
self.use_ticks_set = set;
|
||||
self.use_duration = duration_leaving(ticks, left);
|
||||
left > 0 || !set
|
||||
}
|
||||
/// A mod's `Item.onUseTick` of `stack` used `ticks`, while ticks are left.
|
||||
pub(super) fn item_on_use_tick(&mut self, stack: &ItemStack, ticks: u32) {
|
||||
let remaining = ticks_left(self.use_duration, ticks);
|
||||
if remaining > 0 {
|
||||
self.native_on_use_tick(stack, None, remaining);
|
||||
}
|
||||
}
|
||||
/// How long `stack`, used `ticks`, counts as held: `getUseDuration` less
|
||||
/// the ticks left (`BowItem.releaseUsing`, `CrossbowItem.onUseTick`,
|
||||
/// `TridentItem.releaseUsing`), `ticks` unless a mod's Start or Tick
|
||||
/// moved the ticks left.
|
||||
pub(super) fn ticks_held(&self, stack: &ItemStack, ticks: u32) -> u32 {
|
||||
held(
|
||||
self.use_duration(stack),
|
||||
ticks_left(self.use_duration, ticks),
|
||||
)
|
||||
}
|
||||
/// `LivingEntityUseItemEvent.Stop` as `releaseUsingItem` lets go of
|
||||
/// `stack` used `ticks`: false when a mod skips its `releaseUsing`.
|
||||
pub(super) fn use_item_stopped(&mut self, stack: &ItemStack, ticks: u32) -> bool {
|
||||
let mut canceled = false;
|
||||
let remaining = ticks_left(self.use_duration, ticks);
|
||||
let hand = self.hand;
|
||||
self.native_living_entity_use_item_stop(stack, None, hand, remaining, &mut canceled);
|
||||
!canceled
|
||||
}
|
||||
/// A mod's `Item.releaseUsing` of `stack` used `ticks`: when it says it
|
||||
/// was used, `ItemStack.releaseUsing` applies the use cooldown.
|
||||
pub(super) fn mod_release_using(&mut self, stack: &ItemStack, ticks: u32) {
|
||||
let mut used = false;
|
||||
let remaining = ticks_left(self.use_duration, ticks);
|
||||
self.native_release_using(stack, None, remaining, &mut used);
|
||||
if used {
|
||||
self.apply_use_cooldown(stack);
|
||||
}
|
||||
}
|
||||
/// `Player.canEat(canAlwaysEat)` for `stack`, food that is eaten only
|
||||
/// when hungry, then a mod's answer.
|
||||
pub(super) fn can_eat(&self, stack: &ItemStack, can_always_eat: bool) -> bool {
|
||||
@@ -107,6 +187,49 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ticks left after a mod's `Tick`, a cancel being NeoForge's -1, and
|
||||
/// whether a mod ever set this use's: once one did, the use goes on only
|
||||
/// while some are left, else it ran out. A use no mod touched goes on as
|
||||
/// vanilla's own do (an eye of ender's has none from the start).
|
||||
fn tick_left(before: i32, remaining: i32, canceled: bool, set: bool) -> (i32, bool) {
|
||||
let left = if canceled { -1 } else { remaining };
|
||||
(left, set || canceled || remaining != before)
|
||||
}
|
||||
|
||||
/// What `releaseUsingItem` runs of a release: the item's own and a mod's
|
||||
/// `releaseUsing` (not when a mod's `Stop` canceled, nor when the hand
|
||||
/// changed item, which stops the use without one), and the spyglass's
|
||||
/// sound (not when canceled).
|
||||
pub(super) fn release_parts(canceled: bool, hand_changed: bool) -> (bool, bool) {
|
||||
(!canceled && !hand_changed, !canceled)
|
||||
}
|
||||
|
||||
/// `useItemRemaining` of a use of `duration` ticks, `ticks` old.
|
||||
fn ticks_left(duration: i32, ticks: u32) -> i32 {
|
||||
duration.saturating_sub_unsigned(ticks)
|
||||
}
|
||||
|
||||
/// `getUseDuration(stack) - remainingTime` of a use of `own` ticks with
|
||||
/// `remaining` left, none below 0.
|
||||
fn held(own: i32, remaining: i32) -> u32 {
|
||||
u32::try_from(i64::from(own) - i64::from(remaining)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The duration that leaves `remaining` ticks of a use `ticks` old, as a
|
||||
/// mod's `Tick` set them.
|
||||
fn duration_leaving(ticks: u32, remaining: i32) -> i32 {
|
||||
remaining.saturating_add_unsigned(ticks)
|
||||
}
|
||||
|
||||
/// What a `Start` left of a use of `own` ticks: None refused (a cancel, or
|
||||
/// a time below 0, as NeoForge's `startUsingItem` returns), else whether a
|
||||
/// mod set the ticks left to none. Such a use runs out on its first tick
|
||||
/// (`tick_left`), as NeoForge's `--useItemRemaining <= 0` completes it, while
|
||||
/// an item's own 0 (an eye of ender) is held until let go, as vanilla's.
|
||||
fn start_of(own: i32, duration: i32, canceled: bool) -> Option<bool> {
|
||||
(!canceled && duration >= 0).then_some(duration == 0 && own != 0)
|
||||
}
|
||||
|
||||
/// `Consumable.startConsuming`: a time of 0 or less is no use, the item is
|
||||
/// consumed at once (`onConsume`).
|
||||
pub(super) fn consumes_at_once(consume_ticks: i32) -> bool {
|
||||
@@ -148,7 +271,10 @@ pub(super) fn use_pose(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{consumes_at_once, count_down, use_pose};
|
||||
use super::{
|
||||
consumes_at_once, count_down, duration_leaving, held, release_parts, start_of, tick_left,
|
||||
ticks_left, use_pose,
|
||||
};
|
||||
use horizon_client::first_person::ItemPose;
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
|
||||
@@ -179,6 +305,91 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_below_0_refuses_the_use_and_a_mods_0_runs_out() {
|
||||
// a cancel or a time below 0 refuses it.
|
||||
assert_eq!(start_of(32, 32, true), None);
|
||||
assert_eq!(start_of(32, -1, false), None);
|
||||
assert_eq!(start_of(0, -1, false), None);
|
||||
// a mod's 0 starts it, and its first tick runs it out.
|
||||
assert_eq!(start_of(32, 0, false), Some(true));
|
||||
let (left, set) = tick_left(0, 0, false, true);
|
||||
assert!(!(left > 0 || !set));
|
||||
// an item's own 0 (an eye of ender) goes on, as a changed time does.
|
||||
assert_eq!(start_of(0, 0, false), Some(false));
|
||||
let (left, set) = tick_left(0, 0, false, false);
|
||||
assert!(left > 0 || !set);
|
||||
assert_eq!(start_of(32, 64, false), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tick_moves_the_end_of_a_use() {
|
||||
// a 32 tick use, 10 ticks in: 22 left.
|
||||
assert_eq!(ticks_left(32, 10), 22);
|
||||
// a mod leaves 1 tick: the next tick finishes it.
|
||||
let duration = duration_leaving(10, 1);
|
||||
assert_eq!(count_down(10, duration), None);
|
||||
// or 30 more: it goes on to tick 40.
|
||||
let duration = duration_leaving(10, 30);
|
||||
assert_eq!(duration, 40);
|
||||
assert_eq!(count_down(38, duration), Some(39));
|
||||
assert_eq!(count_down(39, duration), None);
|
||||
// left as it was, nothing moves.
|
||||
assert_eq!(duration_leaving(10, ticks_left(32, 10)), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tick_of_no_time_left_runs_the_use_out() {
|
||||
let goes_on = |(left, set): (i32, bool)| left > 0 || !set;
|
||||
// untouched, a use goes on, even one of no time (an eye of ender).
|
||||
assert_eq!(tick_left(20, 20, false, false), (20, false));
|
||||
assert!(goes_on(tick_left(-3, -3, false, false)));
|
||||
// a mod's ticks left go on while above 0.
|
||||
assert_eq!(tick_left(20, 5, false, false), (5, true));
|
||||
assert!(goes_on(tick_left(20, 5, false, false)));
|
||||
// a cancel is -1 left, and it or 0 or less set runs the use out.
|
||||
assert_eq!(tick_left(20, 20, true, false), (-1, true));
|
||||
assert!(!goes_on(tick_left(20, 0, false, false)));
|
||||
assert!(!goes_on(tick_left(20, -1, false, false)));
|
||||
// a use a mod ran out stays out on the next ticks (a crossbow frozen).
|
||||
assert!(!goes_on(tick_left(-1, -1, false, true)));
|
||||
// and goes on again if a mod gives it ticks back, as NeoForge's.
|
||||
assert!(goes_on(tick_left(-1, 10, false, true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_runs_what_vanilla_runs() {
|
||||
// let go: the item's release and the spyglass's sound.
|
||||
assert_eq!(release_parts(false, false), (true, true));
|
||||
// a mod's Stop canceled: neither.
|
||||
assert_eq!(release_parts(true, false), (false, false));
|
||||
// another item in the hand: no release (Horizon still lowers a spyglass).
|
||||
assert_eq!(release_parts(false, true), (false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moved_end_moves_the_charge() {
|
||||
// a bow held 10 ticks of 72000: 10, as vanilla's.
|
||||
assert_eq!(held(72_000, ticks_left(72_000, 10)), 10);
|
||||
// a mod's Tick leaves 71000 at tick 10: it counts as held 1000.
|
||||
assert_eq!(
|
||||
held(72_000, ticks_left(duration_leaving(10, 71_000), 10)),
|
||||
1000
|
||||
);
|
||||
// more ticks left than the item's own time: not held at all.
|
||||
assert_eq!(held(72_000, 80_000), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_tells_how_long_the_item_was_held() {
|
||||
// a bow-like item held 25 ticks of 72000: the time left gives the charge back.
|
||||
let left = ticks_left(72_000, 25);
|
||||
assert_eq!(left, 71_975);
|
||||
assert_eq!(72_000 - left, 25);
|
||||
// released at once.
|
||||
assert_eq!(ticks_left(72_000, 0), 72_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_food_of_no_time_is_eaten_at_once() {
|
||||
assert!(consumes_at_once(0));
|
||||
|
||||
@@ -129,6 +129,16 @@ fn edge((w, h): (f32, f32), button: Button) -> Rect {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the screen still gets the key after `act`. `Screen.keyPressed`
|
||||
/// moves the focus on Tab or an arrow and returns false, so a container
|
||||
/// screen goes on to `checkHotbarKeyPressed` (`AbstractContainerScreen
|
||||
/// .keyPressed`): Tab bound to the off hand both moves the focus and swaps.
|
||||
/// Horizon's other screens handle these keys themselves, so only containers
|
||||
/// pass them on.
|
||||
pub(super) fn passes_on(act: Act, container: bool) -> bool {
|
||||
container && matches!(act, Act::Focus(_) | Act::Step(_))
|
||||
}
|
||||
|
||||
/// `AbstractSliderButton.setFocused`: the arrows change a slider's value
|
||||
/// when the focus came from the mouse or Tab, not from an arrow.
|
||||
pub(super) fn slider_edit_on_focus(nav: Nav) -> bool {
|
||||
@@ -331,6 +341,19 @@ mod tests {
|
||||
assert_eq!(nav_of(KeyCode::Escape, false), None);
|
||||
}
|
||||
|
||||
/// Tab bound to key.swapOffhand in an inventory: the focus moves and the
|
||||
/// screen still checks its hotbar keys; a press stays the widget's.
|
||||
#[test]
|
||||
fn containers_still_get_tab_and_arrows_after_the_focus_moves() {
|
||||
assert!(passes_on(Act::Focus(0), true));
|
||||
assert!(passes_on(Act::Step(Button::DPadDown), true));
|
||||
assert!(!passes_on(Act::Press, true));
|
||||
assert!(!passes_on(Act::ToggleSlider, true));
|
||||
assert!(!passes_on(Act::Nudge(1.0), true));
|
||||
assert!(!passes_on(Act::Focus(0), false));
|
||||
assert!(!passes_on(Act::Step(Button::DPadDown), false));
|
||||
}
|
||||
|
||||
/// The title screen's rows (TitleScreen.init's add order).
|
||||
#[test]
|
||||
fn tab_order_reads_rows_top_to_bottom_then_left_to_right() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -43,6 +43,7 @@ pub(super) struct GuestPlay {
|
||||
destroy_delay: u32,
|
||||
using: Option<(usize, u32)>,
|
||||
use_duration: i32,
|
||||
use_ticks_set: bool,
|
||||
use_delay: u32,
|
||||
/// The shield's ItemCooldowns entry: an axe disabled it for this many ticks.
|
||||
pub(super) shield_cooldown: u32,
|
||||
@@ -174,6 +175,7 @@ pub(super) struct GuestSwap {
|
||||
destroy_delay: u32,
|
||||
using: Option<(usize, u32)>,
|
||||
use_duration: i32,
|
||||
use_ticks_set: bool,
|
||||
use_delay: u32,
|
||||
pub(super) shield_cooldown: u32,
|
||||
/// The guest's item cooldowns (`Game::cooldowns` for its turn).
|
||||
@@ -473,6 +475,7 @@ impl Game<'_> {
|
||||
let destroy_delay = std::mem::replace(&mut self.destroy_delay, play.destroy_delay);
|
||||
let using = std::mem::replace(&mut self.using, play.using.take());
|
||||
let use_duration = std::mem::replace(&mut self.use_duration, play.use_duration);
|
||||
let use_ticks_set = std::mem::replace(&mut self.use_ticks_set, play.use_ticks_set);
|
||||
let use_delay = std::mem::replace(&mut self.use_delay, play.use_delay);
|
||||
let shield_cooldown = std::mem::replace(&mut self.shield_cooldown, play.shield_cooldown);
|
||||
let cooldowns = std::mem::replace(&mut self.cooldowns, std::mem::take(&mut play.cooldowns));
|
||||
@@ -587,6 +590,7 @@ impl Game<'_> {
|
||||
destroy_delay,
|
||||
using,
|
||||
use_duration,
|
||||
use_ticks_set,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
cooldowns,
|
||||
@@ -663,6 +667,7 @@ impl Game<'_> {
|
||||
destroy_delay,
|
||||
using,
|
||||
use_duration,
|
||||
use_ticks_set,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
cooldowns,
|
||||
@@ -781,6 +786,7 @@ impl Game<'_> {
|
||||
play.destroy_delay = std::mem::replace(&mut self.destroy_delay, destroy_delay);
|
||||
play.using = std::mem::replace(&mut self.using, using);
|
||||
play.use_duration = std::mem::replace(&mut self.use_duration, use_duration);
|
||||
play.use_ticks_set = std::mem::replace(&mut self.use_ticks_set, use_ticks_set);
|
||||
play.use_delay = std::mem::replace(&mut self.use_delay, use_delay);
|
||||
play.shield_cooldown = std::mem::replace(&mut self.shield_cooldown, shield_cooldown);
|
||||
play.cooldowns = std::mem::replace(&mut self.cooldowns, cooldowns);
|
||||
@@ -2969,12 +2975,12 @@ impl Game<'_> {
|
||||
pub(super) fn send_admin_feedback(&mut self, host: &mut LanHost, source: Option<u32>) {
|
||||
// PlayerList.isOp for the host of a world open to LAN: the owner when
|
||||
// the world allows commands, everyone when the LAN does.
|
||||
let host_op = source.is_some()
|
||||
&& !host.hub.config.dedicated
|
||||
&& (self.allow_commands() || host.hub.config.guest_commands);
|
||||
let host_ops =
|
||||
!host.hub.config.dedicated && (self.allow_commands() || host.hub.config.guest_commands);
|
||||
let host_op = source.is_some() && host_ops;
|
||||
// server.toml's `admin_feedback_to_ops` off: the console's log only.
|
||||
let to_ops = host.hub.admin_feedback_to_ops;
|
||||
for (line, ops, console) in std::mem::take(&mut self.admin_feedback) {
|
||||
for (line, ops, console, no_player) in std::mem::take(&mut self.admin_feedback) {
|
||||
if ops && to_ops {
|
||||
let content = horizon_client::lan::data::component_nbt(&line.component());
|
||||
for g in host
|
||||
@@ -2988,7 +2994,8 @@ impl Game<'_> {
|
||||
overlay: false,
|
||||
}));
|
||||
}
|
||||
if host_op {
|
||||
// A line no player's command made (a command block's) reaches the host too.
|
||||
if host_op || (no_player && host_ops) {
|
||||
self.system_message(line.clone());
|
||||
}
|
||||
}
|
||||
@@ -3912,6 +3919,24 @@ impl Game<'_> {
|
||||
count,
|
||||
speed,
|
||||
}) => self.landing_dust(Some(host), state, position, count, speed),
|
||||
// A block's own sound names no player: the guest hears it too.
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Sound {
|
||||
sound,
|
||||
position: Some(pos),
|
||||
volume,
|
||||
pitch,
|
||||
}) if sound.block_sound().is_some() => {
|
||||
if let Some(name) = sound.block_sound() {
|
||||
let source = horizon_client::settings::sound_source(name) as i32;
|
||||
let at = [
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y) + 0.5,
|
||||
f64::from(pos.z) + 0.5,
|
||||
];
|
||||
self.audio.play_unshared(name, Some(at), volume, pitch);
|
||||
host.hub.sound(name, at, volume, pitch, source, None);
|
||||
}
|
||||
}
|
||||
// LivingEntity.causeFallDamage: Player.playSound skips the
|
||||
// guest, whose own client plays it; everyone else hears it.
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Sound {
|
||||
|
||||
@@ -208,8 +208,9 @@ impl Game<'_> {
|
||||
|
||||
/// A click on the guest's open furnace, blast furnace, smoker or brewing stand:
|
||||
/// the host's `AbstractFurnaceMenu` (fuel slot, take-only result, quick moves by
|
||||
/// recipe and fuel) or `BrewingStandMenu` (one bottle per slot, ingredient and
|
||||
/// blaze powder slots) over the block entity's slots. False for other windows.
|
||||
/// recipe and fuel), `BrewingStandMenu` (one bottle per slot, ingredient and
|
||||
/// blaze powder slots) or a content mod's menu type (`Kind::Mod`) over the
|
||||
/// block entity's slots. False for other windows.
|
||||
pub(super) fn lan_machine_click(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
@@ -219,12 +220,12 @@ impl Game<'_> {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return false;
|
||||
};
|
||||
let Some(pos) = host.hub.guests[gi]
|
||||
let Some((pos, mod_type, hidden)) = host.hub.guests[gi]
|
||||
.menu
|
||||
.as_ref()
|
||||
.filter(|m| p.container != 0 && m.window == p.container)
|
||||
.and_then(|m| match m.slots {
|
||||
Slots::Block(pos) => Some(pos),
|
||||
Slots::Block(pos) => Some((pos, m.mod_type, m.hidden.clone())),
|
||||
_ => None,
|
||||
})
|
||||
else {
|
||||
@@ -241,6 +242,15 @@ impl Game<'_> {
|
||||
}
|
||||
Some(BlockEntity::Brewing(b)) => (Kind::Brewing, b.items.to_vec()),
|
||||
Some(BlockEntity::Crafter(c)) => (Kind::CrafterBlock, c.items.to_vec()),
|
||||
// A content mod's menu type, with its slots' rules.
|
||||
Some(BlockEntity::Mod(m)) => match mod_type {
|
||||
Some(t) => {
|
||||
let mut items = m.items.clone();
|
||||
items.resize(t.slots, None);
|
||||
(Kind::Mod(t), items)
|
||||
}
|
||||
None => return false,
|
||||
},
|
||||
_ => return false,
|
||||
};
|
||||
let furnace = matches!(kind, Kind::Furnace(_));
|
||||
@@ -262,8 +272,11 @@ impl Game<'_> {
|
||||
.crafting
|
||||
.machine
|
||||
.take()
|
||||
.filter(|(window, _)| *window == p.container)
|
||||
.filter(|(window, m)| *window == p.container && m.kind() == kind)
|
||||
.map_or_else(|| Box::new(Menu::new(kind)), |(_, m)| m);
|
||||
for &slot in &hidden {
|
||||
menu.hide_slot(slot);
|
||||
}
|
||||
let changed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
for (k, s) in items.iter().enumerate() {
|
||||
menu.set_slot(env, k, s.clone());
|
||||
@@ -297,6 +310,16 @@ impl Game<'_> {
|
||||
b.items[k] = menu.slot(k).cloned();
|
||||
}
|
||||
}
|
||||
// The mod's items past its menu's slots stay as they were.
|
||||
Some(BlockEntity::Mod(m)) => {
|
||||
let n = items.len();
|
||||
if m.items.len() < n {
|
||||
m.items.resize(n, None);
|
||||
}
|
||||
for (k, slot) in m.items.iter_mut().take(n).enumerate() {
|
||||
*slot = menu.slot(k).cloned();
|
||||
}
|
||||
}
|
||||
// CrafterBlockEntity.setItem: filling a disabled slot enables it.
|
||||
Some(BlockEntity::Crafter(c)) => {
|
||||
for k in 0..9 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1188,7 +1188,7 @@ impl Game<'_> {
|
||||
let PhysicalKey::Code(key) = event.physical_key else {
|
||||
return;
|
||||
};
|
||||
let ctrl = self.keys.contains(&winit::keyboard::KeyCode::ControlLeft);
|
||||
let ctrl = self.has_control_down_with_quirk();
|
||||
let shortcut = self.edit_shortcut(event);
|
||||
let types_text = self.types_text();
|
||||
let shift = self.has_shift_down();
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
|
||||
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
|
||||
use horizon_client::collisions::SceneCollisions;
|
||||
use horizon_client::lan::hub::no_blocks_in;
|
||||
use horizon_client::mobs::equine;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_core::geometry::{Aabb, Axis, Vec3};
|
||||
use horizon_protocol::{
|
||||
packets::play::{self, Clientbound as C},
|
||||
text::translatable,
|
||||
@@ -367,7 +368,31 @@ impl Game<'_> {
|
||||
back(host, old, yaw);
|
||||
return;
|
||||
}
|
||||
host.hub.guests[gi].vehicle_good = Some([target.x, target.y, target.z]);
|
||||
// clientVehicleIsFloating: the move didn't go down a step, the vehicle hadn't come down onto
|
||||
// something on its last move (`verticalCollisionBelow` before this one), and nothing but air is
|
||||
// around its box grown by 0.0625 and stretched 0.55 down (water holds a boat up). A flying or
|
||||
// no-gravity mount, and a server that allows flight, never float.
|
||||
let (gravity, no_gravity) = match mount {
|
||||
Some(i) => (0.08, self.mobs[i].no_gravity),
|
||||
None => (0.04, false),
|
||||
};
|
||||
let around = moved_box
|
||||
.inflate(Vec3::new(0.0625, 0.0625, 0.0625))
|
||||
.expand_towards(Vec3::new(0.0, -0.55, 0.0));
|
||||
let corner = |max: bool| {
|
||||
[Axis::X, Axis::Y, Axis::Z].map(|a| if max { around.max(a) } else { around.min(a) })
|
||||
};
|
||||
let no_blocks = no_blocks_in(&self.world, corner(false), corner(true));
|
||||
let allow_flight = host.hub.config.allow_flight;
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.vehicle_good = Some([target.x, target.y, target.z]);
|
||||
let rested = std::mem::replace(
|
||||
&mut g.vehicle_rests,
|
||||
wanted.y < 0.0 && resolved.y != wanted.y,
|
||||
);
|
||||
g.vehicle_floating =
|
||||
wanted.y >= -0.03125 && !rested && !allow_flight && !no_gravity && no_blocks;
|
||||
g.vehicle_gravity = gravity;
|
||||
if let Some(i) = mount {
|
||||
// absSnapTo: where and how its rider's client turned it; the
|
||||
// rider sits on it from its own ride tick.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The game's side of the Launch API (docs/api/launch.md): the options another launcher may pass,
|
||||
//! `--launch-api`'s reply, the exit codes, and docs/api/launch-reference.md, generated from them.
|
||||
//! Update it with: `HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api`.
|
||||
//! Update it with: `HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api`.
|
||||
|
||||
/// The Launch API's version (docs/api/stability.md): an addition makes the next patch.
|
||||
pub const VERSION: &str = "0.1.0";
|
||||
@@ -155,7 +155,7 @@ pub fn markdown() -> String {
|
||||
let w = &mut out;
|
||||
let _ = writeln!(
|
||||
w,
|
||||
"<!-- Generated from crates/horizon-client/src/game/launch_api.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api -->"
|
||||
"<!-- Generated from crates/horizon-client/src/game/launch_api.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api -->"
|
||||
);
|
||||
let _ = writeln!(w, "# Launch API reference\n");
|
||||
let _ = writeln!(
|
||||
@@ -265,7 +265,7 @@ mod tests {
|
||||
.replace("\r\n", "\n");
|
||||
assert!(
|
||||
have == want,
|
||||
"docs/api/launch-reference.md is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api"
|
||||
"docs/api/launch-reference.md is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,9 @@ pub(super) struct Parked {
|
||||
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
|
||||
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
|
||||
lan_critical_hits: Vec<(Option<u32>, i32, bool)>,
|
||||
/// The hub's tracked entities of this level (`LanHost::states`), as its last tick described
|
||||
/// them: what its players' moves are checked against before this tick describes them again.
|
||||
lan_states: horizon_client::lan::entity::States,
|
||||
/// Only the per-level parts of these are used (`swap_level`).
|
||||
anims: BlockEntityAnims,
|
||||
raiding: Raiding,
|
||||
@@ -118,6 +121,9 @@ impl Default for Pass {
|
||||
#[derive(Default)]
|
||||
pub(super) struct Levels {
|
||||
parked: HashMap<Dim, Parked>,
|
||||
/// The levels whose open the mods heard (`native_level_load`), each to
|
||||
/// be told closed once.
|
||||
heard_open: Vec<Dim>,
|
||||
}
|
||||
|
||||
impl Levels {
|
||||
@@ -213,6 +219,7 @@ impl Game<'_> {
|
||||
lan_block_events: Vec::new(),
|
||||
lan_entity_events: Vec::new(),
|
||||
lan_critical_hits: Vec::new(),
|
||||
lan_states: Default::default(),
|
||||
anims: Default::default(),
|
||||
raiding: Default::default(),
|
||||
sculk: Default::default(),
|
||||
@@ -291,7 +298,11 @@ impl Game<'_> {
|
||||
/// The level that was in place is kept with the others; the shared
|
||||
/// clock and weather go with the swap. `host` is the hub when the caller
|
||||
/// holds it (`self.lan` is taken during the network tick).
|
||||
pub(super) fn enter_level(&mut self, host: Option<&mut LanHost>, target: Dim) -> Result<()> {
|
||||
pub(super) fn enter_level(
|
||||
&mut self,
|
||||
mut host: Option<&mut LanHost>,
|
||||
target: Dim,
|
||||
) -> Result<()> {
|
||||
// The in-frame game keeps its one level (`enter_dimension`).
|
||||
if self.options.execution_role.is_client() {
|
||||
return Ok(());
|
||||
@@ -310,12 +321,23 @@ impl Game<'_> {
|
||||
// The sculk tables' watched blocks are built once per game.
|
||||
p.world.watched = std::mem::take(&mut self.world.watched);
|
||||
self.swap_level(&mut p);
|
||||
// The tracked entities go with their level: a level's moves, read before its tick
|
||||
// describes its entities again, would otherwise meet the last level's (the anticheat
|
||||
// missed the boat under a player while anyone was in another dimension).
|
||||
let states = match host.as_deref_mut() {
|
||||
Some(h) => Some(&mut h.states),
|
||||
None => self.lan.as_mut().map(|h| &mut h.states),
|
||||
};
|
||||
if let Some(states) = states {
|
||||
std::mem::swap(states, &mut p.lan_states);
|
||||
}
|
||||
p.idle = 0;
|
||||
let left = std::mem::replace(&mut self.dim, target);
|
||||
self.levels.parked.insert(left, p);
|
||||
if opened {
|
||||
// The new streaming's radius and meshing options.
|
||||
self.apply_settings();
|
||||
self.native_level_opened(target);
|
||||
}
|
||||
}
|
||||
let host = match host {
|
||||
@@ -617,10 +639,42 @@ impl Game<'_> {
|
||||
self.save_level(true)?;
|
||||
self.enter_level(None, back)?;
|
||||
self.levels.parked.remove(&dim);
|
||||
self.native_level_closed(dim);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The mods hear `dim`'s level open (`LevelEvent.Load`), on a server.
|
||||
pub(super) fn native_level_opened(&mut self, dim: Dim) {
|
||||
if self.remote.is_some()
|
||||
|| self.options.execution_role.is_client()
|
||||
|| self.levels.heard_open.contains(&dim)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.levels.heard_open.push(dim);
|
||||
self.native_level_load(dim.id());
|
||||
}
|
||||
|
||||
/// The mods hear `dim`'s level close (`LevelEvent.Unload`), once for
|
||||
/// each open they heard.
|
||||
fn native_level_closed(&mut self, dim: Dim) {
|
||||
let Some(i) = self.levels.heard_open.iter().position(|d| *d == dim) else {
|
||||
return;
|
||||
};
|
||||
self.levels.heard_open.remove(i);
|
||||
self.native_level_unload(dim.id());
|
||||
}
|
||||
|
||||
/// Every level still open closes with the server, however it ends
|
||||
/// (`MinecraftServer.stopServer`'s `level.close()` loop), in vanilla's
|
||||
/// order of its levels: the Overworld, the Nether, the End.
|
||||
pub(super) fn native_levels_closing(&mut self) {
|
||||
for dim in [Dim::Overworld, Dim::Nether, Dim::End] {
|
||||
self.native_level_closed(dim);
|
||||
}
|
||||
}
|
||||
|
||||
/// Save every parked level (`MinecraftServer.saveAllChunks`); the level
|
||||
/// in place is the caller's to save.
|
||||
pub(super) fn save_parked_levels(&mut self, wait: bool) -> Result<()> {
|
||||
|
||||
Loaded 100 of 347 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user