mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
178
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6424564e6 | ||
|
|
0871e93d49 | ||
|
|
cdb64fbc9b | ||
|
|
c86e861455 | ||
|
|
11427c2960 | ||
|
|
1600ce6fa9 | ||
|
|
9ddc5e7498 | ||
|
|
ff518b90c1 | ||
|
|
46ba16b36a | ||
|
|
74672ea0e9 | ||
|
|
ac18068674 | ||
|
|
ab0c403b75 | ||
|
|
816ebe9925 | ||
|
|
58eaf93f13 | ||
|
|
e076162fb3 | ||
|
|
9b391d7c80 | ||
|
|
eea324d489 | ||
|
|
983eec0256 | ||
|
|
88d3609268 | ||
|
|
2b01586564 | ||
|
|
be35f0cd18 | ||
|
|
1c47bf7fde | ||
|
|
3fa0d50e2c | ||
|
|
cb24175ff3 | ||
|
|
fdd8066b77 | ||
|
|
07c41af9db | ||
|
|
2691d2eb82 | ||
|
|
3dc0154745 | ||
|
|
e5d3a0ec6c | ||
|
|
962d50bffa | ||
|
|
abbfe4996b | ||
|
|
484f7f74bb | ||
|
|
59773fcece | ||
|
|
67797d1884 | ||
|
|
ca15b06a8c | ||
|
|
856de4c13d | ||
|
|
dc262543a6 | ||
|
|
d2a39539be | ||
|
|
4684962b73 | ||
|
|
85a65f9469 | ||
|
|
7a5563f90f | ||
|
|
810da289d6 | ||
|
|
ac29461e82 | ||
|
|
7e87aefe15 | ||
|
|
1affe879c9 | ||
|
|
39737da855 | ||
|
|
26dc0d815f | ||
|
|
1252c14927 | ||
|
|
3b68c85316 | ||
|
|
f6512a3f49 | ||
|
|
bc59771299 | ||
|
|
65ef1000fa | ||
|
|
ff91172d22 | ||
|
|
aec1d411f7 | ||
|
|
f4d7d4d59d | ||
|
|
0aa6a87d5e | ||
|
|
74a8a866f8 | ||
|
|
45e95666d2 | ||
|
|
f6d35e00e2 | ||
|
|
75e2c36a9d | ||
|
|
74404c28fc | ||
|
|
fcbfd000ee | ||
|
|
6bfd219e30 | ||
|
|
79e9d1b044 | ||
|
|
d6ce584b6a | ||
|
|
516927b342 | ||
|
|
21936c6298 | ||
|
|
87266190f4 | ||
|
|
f4283f7c82 | ||
|
|
75efcb360a | ||
|
|
c25499d504 | ||
|
|
54cbbadbb7 | ||
|
|
6472b1af4c | ||
|
|
79b92a3f8f | ||
|
|
3073eed985 | ||
|
|
9a696bd74b | ||
|
|
a4742538b2 | ||
|
|
5501103883 | ||
|
|
c2ea13fc7c | ||
|
|
52da60975d | ||
|
|
31d53c8b47 | ||
|
|
b0fee0d30d | ||
|
|
b84758065f | ||
|
|
ac5f51226b | ||
|
|
07002b127b | ||
|
|
79e783ec84 | ||
|
|
58a3d74122 | ||
|
|
6b1f5be9ec | ||
|
|
5c9bc076ac | ||
|
|
e37e2f163f | ||
|
|
b773fa45c1 | ||
|
|
51643e956a | ||
|
|
c481ffedf2 | ||
|
|
326c36879b | ||
|
|
8bde063ed6 | ||
|
|
c80ab3bbcd | ||
|
|
ebdd6a9d9a | ||
|
|
1ed2aa47d4 | ||
|
|
5f52402370 | ||
|
|
f6dc590d3e | ||
|
|
d1787596da | ||
|
|
e111cb947a | ||
|
|
e4bbe6055b | ||
|
|
5d01df7504 | ||
|
|
a2ddc9f681 | ||
|
|
4ea4cd559b | ||
|
|
2ae7c880c4 | ||
|
|
e30ff91de7 | ||
|
|
c65cb2b929 | ||
|
|
7594506012 | ||
|
|
03c7c60808 | ||
|
|
c92f0357c9 | ||
|
|
a02d561d49 | ||
|
|
3f6221b268 | ||
|
|
337c3c2bb4 | ||
|
|
42e6769488 | ||
|
|
3244c41556 | ||
|
|
c865a354f2 | ||
|
|
3d26d4ccfa | ||
|
|
43f96dda2b | ||
|
|
aec9421cf3 | ||
|
|
ba68d1505e | ||
|
|
9e1f8431fa | ||
|
|
536b8462a7 | ||
|
|
58055f7214 | ||
|
|
065e7b8efb | ||
|
|
8ce6505397 | ||
|
|
c507ec4730 | ||
|
|
d4da2469cc | ||
|
|
d3fdb8d19c | ||
|
|
45161095d6 | ||
|
|
596b47850d | ||
|
|
a94150a97c | ||
|
|
47b5a77bce | ||
|
|
2947ff96b2 | ||
|
|
9f3a88a41d | ||
|
|
c41d1ee7b4 | ||
|
|
47f85029fe | ||
|
|
724d94d0dd | ||
|
|
dc4a9f2494 | ||
|
|
c03ef707ed | ||
|
|
bcf52293d9 | ||
|
|
4b57957180 | ||
|
|
3073cb461e | ||
|
|
1d34a59372 | ||
|
|
c50935983a | ||
|
|
a34c63b88c | ||
|
|
7a2ea363ce | ||
|
|
f27e5c5796 | ||
|
|
0a1dd1fe1f | ||
|
|
b808a05dd9 | ||
|
|
ce05d13f46 | ||
|
|
c43496c5e8 | ||
|
|
41ae4e9ceb | ||
|
|
78d6f69e90 | ||
|
|
53b34ed9b6 | ||
|
|
51b8552e70 | ||
|
|
37d4b92459 | ||
|
|
2f31408502 | ||
|
|
6aa7f4c227 | ||
|
|
178ce862a7 | ||
|
|
e1ba0bf533 | ||
|
|
ee55ff3d67 | ||
|
|
15b1dbaa14 | ||
|
|
0e64bf0818 | ||
|
|
fd7a47175c | ||
|
|
cddfdcea34 | ||
|
|
570e3c9656 | ||
|
|
b3209dd9b9 | ||
|
|
3d685f468f | ||
|
|
ace8c981f5 | ||
|
|
1606f86e4c | ||
|
|
baabab598f | ||
|
|
33fd9148b0 | ||
|
|
1bfb26017b | ||
|
|
8967e6abf1 | ||
|
|
a9c0d90d2d | ||
|
|
be5d818a58 |
No files matched your search
@@ -232,7 +232,7 @@ fn os_version() -> Option<String> {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn cpu() -> Option<String> {
|
||||
std::env::var("PROCESSOR_IDENTIFIER").ok()
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -281,4 +281,16 @@ mod tests {
|
||||
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `hardware.cpu` is the brand name, not the family and model `PROCESSOR_IDENTIFIER` holds.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_reports_the_cpu_brand() {
|
||||
let cpu = Hardware::detect().cpu.expect("a CPU name");
|
||||
assert!(
|
||||
!cpu.is_empty() && !cpu.contains(" Family ") && !cpu.contains("Stepping"),
|
||||
"family and model, not a name: {cpu:?}"
|
||||
);
|
||||
assert_eq!(cpu, cpu.trim());
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -213,6 +213,48 @@ impl Registries {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Only what [`Self::fill_from_export`] takes from an export, to keep
|
||||
/// in place of the export itself (a third of its memory).
|
||||
pub fn from_export(r: &serde_json::Value) -> Self {
|
||||
let mut out = Self::default();
|
||||
out.fill_from_export(r);
|
||||
out
|
||||
}
|
||||
/// [`Self::fill_from_export`] from an export already read with
|
||||
/// [`Self::from_export`]: each field the game left empty, each registry
|
||||
/// it did not list.
|
||||
pub fn fill_from_exported(&mut self, e: &Self) {
|
||||
let fill = |field: &mut Vec<String>, from: &Vec<String>| {
|
||||
if field.is_empty() {
|
||||
field.clone_from(from);
|
||||
}
|
||||
};
|
||||
fill(&mut self.effects, &e.effects);
|
||||
fill(&mut self.enchantments, &e.enchantments);
|
||||
fill(&mut self.attributes, &e.attributes);
|
||||
fill(&mut self.damage_types, &e.damage_types);
|
||||
fill(&mut self.templates, &e.templates);
|
||||
fill(&mut self.persistent_components, &e.persistent_components);
|
||||
fill(&mut self.learnable_recipes, &e.learnable_recipes);
|
||||
for (name, ids) in &e.by_registry {
|
||||
if !self.by_registry.contains_key(name) {
|
||||
self.by_registry.insert(name.clone(), ids.clone());
|
||||
}
|
||||
}
|
||||
if self.block_entities.is_empty() {
|
||||
self.block_entities.clone_from(&e.block_entities);
|
||||
}
|
||||
if self.block_tag_members.is_empty() {
|
||||
self.block_tag_members.clone_from(&e.block_tag_members);
|
||||
}
|
||||
if self.clock_time_markers.is_empty() {
|
||||
self.clock_time_markers.clone_from(&e.clock_time_markers);
|
||||
}
|
||||
if self.advancement_criteria.is_empty() {
|
||||
self.advancement_criteria
|
||||
.clone_from(&e.advancement_criteria);
|
||||
}
|
||||
}
|
||||
/// The element ids of a registry (sorted).
|
||||
pub fn ids(&self, registry: &str) -> &[String] {
|
||||
if let Some(r) = self.by_registry.get(registry) {
|
||||
|
||||
@@ -549,6 +549,7 @@ pub fn fullscreen_request(
|
||||
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
|
||||
pub fn icon() -> Option<winit::window::Icon> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
let mut bytes = vec![0; reader.output_buffer_size()?];
|
||||
|
||||
@@ -172,6 +172,7 @@ fn color(v: &Value) -> Option<u32> {
|
||||
fn colormap(root: &Path, name: &str) -> Result<Vec<u32>> {
|
||||
let path = root.join(format!("assets/minecraft/textures/colormap/{name}.png"));
|
||||
let mut decoder = png::Decoder::new(std::io::BufReader::new(fs::File::open(path)?));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info()?;
|
||||
let mut bytes = vec![0; reader.output_buffer_size().ok_or("colormap too large")?];
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Bulk block edits: runs of blocks up columns written straight into the
|
||||
//! resident sections, a section unpacked and packed once however many of its
|
||||
//! blocks change, instead of a `World::set` per block. Nothing round them is
|
||||
//! told (as `fill ... strict`); the light of each chunk changed is worked out
|
||||
//! again whole, off the game thread, rather than block by block, and each
|
||||
//! section changed is remeshed once.
|
||||
use crate::chunk::index;
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use crate::world::World;
|
||||
|
||||
/// A run of one column, resolved: blocks `y0..=y1` of column (x, z) set to
|
||||
/// `state` where filter `only` (if any) passes the block there.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Run {
|
||||
pub x: i32,
|
||||
pub z: i32,
|
||||
pub y0: i32,
|
||||
pub y1: i32,
|
||||
pub state: u32,
|
||||
pub only: Option<u16>,
|
||||
}
|
||||
|
||||
/// What an edit changed.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Outcome {
|
||||
/// Blocks changed.
|
||||
pub changed: u64,
|
||||
/// Chunks with a change.
|
||||
pub chunks: usize,
|
||||
/// Changes of the states `tracked` asked for (block entities, watched
|
||||
/// blocks): position, previous and new state.
|
||||
pub tracked: Vec<((i32, i32, i32), u32, u32)>,
|
||||
}
|
||||
|
||||
/// Per state answers, worked out once each.
|
||||
struct Memo(Vec<u8>);
|
||||
impl Memo {
|
||||
#[inline]
|
||||
fn get(&mut self, state: u32, ask: impl FnOnce(u32) -> bool) -> bool {
|
||||
let i = state as usize;
|
||||
if i >= self.0.len() {
|
||||
self.0.resize(i + 1, 0);
|
||||
}
|
||||
if self.0[i] == 0 {
|
||||
self.0[i] = 1 + u8::from(ask(state));
|
||||
}
|
||||
self.0[i] == 2
|
||||
}
|
||||
}
|
||||
|
||||
impl World {
|
||||
/// Make `runs` in order, each seeing the changes of those before it.
|
||||
/// `matches(filter, state)` says whether a filter passes a state, and
|
||||
/// `tracked(state)` whether its changes are reported; each is asked once
|
||||
/// per state. Runs in unloaded chunks and the parts of runs outside each
|
||||
/// chunk's own column (`Chunk::layout`) are left out.
|
||||
///
|
||||
/// Unlike `World::set`, this records nothing per block: no
|
||||
/// `block_entities.state_changed` (a caller adds or removes block
|
||||
/// entities itself, from `Outcome::tracked`), no `watched_writes` (ask
|
||||
/// for those states through `tracked`), no `block_log` entry (the chunk
|
||||
/// goes in `bulk_log` instead, sent whole), no `path_writes` (mobs'
|
||||
/// paths are not recomputed for these blocks) and no `light_edits` (the
|
||||
/// chunk and its neighbours are lit again whole, through `dirty_light`).
|
||||
pub fn edit_runs(
|
||||
&mut self,
|
||||
runs: &[Run],
|
||||
mut matches: impl FnMut(u16, u32) -> bool,
|
||||
mut tracked: impl FnMut(u32) -> bool,
|
||||
) -> Outcome {
|
||||
let mut out = Outcome::default();
|
||||
// the runs of each chunk, in order, and the chunks in the order they were first named
|
||||
let mut by_chunk: FxHashMap<(i32, i32), Vec<u32>> = FxHashMap::default();
|
||||
let mut order = Vec::new();
|
||||
for (i, r) in runs.iter().enumerate() {
|
||||
if r.y1 < r.y0 {
|
||||
continue;
|
||||
}
|
||||
let key = (r.x >> 4, r.z >> 4);
|
||||
by_chunk
|
||||
.entry(key)
|
||||
.or_insert_with(|| {
|
||||
order.push(key);
|
||||
Vec::new()
|
||||
})
|
||||
.push(i as u32);
|
||||
}
|
||||
let mut filter_memo: Vec<Memo> = Vec::new();
|
||||
let mut tracked_memo = Memo(Vec::new());
|
||||
let mut values: Vec<Option<Box<[u32; 4096]>>> = Vec::new();
|
||||
let mut changed: Vec<bool> = Vec::new();
|
||||
for key in order {
|
||||
let Some(chunk) = self.chunks.get(&key) else {
|
||||
continue;
|
||||
};
|
||||
let (min_y, sections) = (chunk.min_y(), chunk.sections.len());
|
||||
let max_y = chunk.max_y();
|
||||
let list = &by_chunk[&key];
|
||||
changed.clear();
|
||||
changed.resize(sections, false);
|
||||
values.clear();
|
||||
values.resize_with(sections, || None);
|
||||
let mut any = 0u64;
|
||||
for &i in list {
|
||||
let r = runs[i as usize];
|
||||
let (lx, lz) = (r.x & 15, r.z & 15);
|
||||
let (y0, y1) = (r.y0.max(min_y), r.y1.min(max_y - 1));
|
||||
let mut y = y0;
|
||||
while y <= y1 {
|
||||
let s = ((y - min_y) >> 4) as usize;
|
||||
let top = (min_y + (s as i32 + 1) * 16 - 1).min(y1);
|
||||
let vals = values[s].get_or_insert_with(|| {
|
||||
let mut v = Box::new([0u32; 4096]);
|
||||
chunk.sections[s].unpack(&mut v);
|
||||
v
|
||||
});
|
||||
for yy in y..=top {
|
||||
let at = index(lx, yy, lz);
|
||||
let old = vals[at];
|
||||
if old == r.state {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = r.only {
|
||||
let f = f as usize;
|
||||
if filter_memo.len() <= f {
|
||||
filter_memo.resize_with(f + 1, || Memo(Vec::new()));
|
||||
}
|
||||
if !filter_memo[f].get(old, |s| matches(f as u16, s)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
vals[at] = r.state;
|
||||
changed[s] = true;
|
||||
any += 1;
|
||||
if tracked_memo.get(old, &mut tracked)
|
||||
|| tracked_memo.get(r.state, &mut tracked)
|
||||
{
|
||||
out.tracked.push(((r.x, yy, r.z), old, r.state));
|
||||
}
|
||||
}
|
||||
y = top + 1;
|
||||
}
|
||||
}
|
||||
if any == 0 {
|
||||
continue;
|
||||
}
|
||||
out.changed += any;
|
||||
out.chunks += 1;
|
||||
let chunk =
|
||||
std::sync::Arc::make_mut(self.chunks.get_mut(&key).expect("looked up above"));
|
||||
for s in 0..sections {
|
||||
if changed[s]
|
||||
&& let Some(v) = &values[s]
|
||||
{
|
||||
chunk.replace_blocks(s, v);
|
||||
}
|
||||
}
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
self.modified.insert(key);
|
||||
if self.block_log.is_some() {
|
||||
self.bulk_log.insert(key);
|
||||
}
|
||||
// remeshed once (with the neighbours whose faces and light it touches), lit again whole
|
||||
for (s, _) in changed.iter().enumerate().filter(|c| *c.1) {
|
||||
for ds in -1..=1 {
|
||||
let sy = s as i32 + ds;
|
||||
if !(0..sections as i32).contains(&sy) {
|
||||
continue;
|
||||
}
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
self.dirty_sections.insert((key.0 + dx, sy, key.1 + dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
self.dirty_light.insert((key.0 + dx, key.1 + dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chunk::Chunk;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn world() -> World {
|
||||
let mut w = World::new(BlockStateId(0), 0);
|
||||
for cz in -1..=1 {
|
||||
for cx in -1..=1 {
|
||||
w.chunks.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
|
||||
}
|
||||
}
|
||||
// stone up to y 63
|
||||
for z in -16..32 {
|
||||
for x in -16..32 {
|
||||
for y in -64..64 {
|
||||
w.set(x, y, z, BlockStateId(1)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
w.dirty_light.clear();
|
||||
w.dirty_sections.clear();
|
||||
w.block_log = Some(Vec::new());
|
||||
w
|
||||
}
|
||||
fn run(x: i32, z: i32, y0: i32, y1: i32, state: u32, only: Option<u16>) -> Run {
|
||||
Run {
|
||||
x,
|
||||
z,
|
||||
y0,
|
||||
y1,
|
||||
state,
|
||||
only,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_change_their_blocks_in_order() {
|
||||
let mut w = world();
|
||||
let runs = [
|
||||
run(3, 4, 10, 40, 0, None),
|
||||
run(3, 4, 10, 12, 5, None),
|
||||
// only where it is air: over the stone, and the three blocks of 5 stay
|
||||
run(3, 4, 0, 80, 7, Some(0)),
|
||||
run(-1, 20, -70, 400, 9, None),
|
||||
];
|
||||
let out = w.edit_runs(&runs, |_, s| s == 0, |_| false);
|
||||
assert_eq!(w.block(3, 9, 4).0, 1);
|
||||
assert_eq!(w.block(3, 11, 4).0, 5);
|
||||
assert_eq!(w.block(3, 13, 4).0, 7);
|
||||
assert_eq!(w.block(3, 70, 4).0, 7);
|
||||
assert_eq!(w.block(3, 81, 4).0, 0);
|
||||
assert_eq!(w.block(3, 0, 4).0, 1);
|
||||
assert_eq!(w.block(-1, -64, 20).0, 9);
|
||||
assert_eq!(w.block(-1, 319, 20).0, 9);
|
||||
assert_eq!(w.block(0, 30, 20).0, 1);
|
||||
// 31 dug, 3 set again, the air over them filled (28, and 17 over the stone), the whole of the other column
|
||||
assert_eq!(out.changed, 31 + 3 + 45 + 384);
|
||||
assert_eq!(out.chunks, 2);
|
||||
assert!(w.bulk_log.contains(&(0, 0)) && w.bulk_log.contains(&(-1, 1)));
|
||||
assert!(w.dirty_light.contains(&(1, 1)) && w.dirty_light.contains(&(-2, 2)));
|
||||
assert!(w.dirty_sections.contains(&(1, 4, 1)));
|
||||
// the per-block log is left to the blocks set one at a time
|
||||
assert!(w.block_log.as_ref().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unloaded_chunks_and_unchanged_blocks_are_left_out() {
|
||||
let mut w = world();
|
||||
let out = w.edit_runs(
|
||||
&[run(100, 100, 0, 10, 0, None), run(0, 0, 0, 10, 1, None)],
|
||||
|_, _| true,
|
||||
|_| false,
|
||||
);
|
||||
assert_eq!(out.changed, 0);
|
||||
assert_eq!(out.chunks, 0);
|
||||
assert!(w.bulk_log.is_empty() && w.dirty_light.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_states_are_reported() {
|
||||
let mut w = world();
|
||||
let out = w.edit_runs(&[run(1, 1, 60, 65, 3, None)], |_, _| true, |s| s == 3);
|
||||
assert_eq!(out.tracked.len(), 6);
|
||||
assert_eq!(out.tracked[0], ((1, 60, 1), 1, 3));
|
||||
assert_eq!(out.tracked[5], ((1, 65, 1), 0, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heightmaps_follow_the_edit() {
|
||||
let mut w = world();
|
||||
let air = |s: u32| s == 0;
|
||||
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 64);
|
||||
w.edit_runs(&[run(5, 5, 20, 63, 0, None)], |_, _| true, |_| false);
|
||||
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 20);
|
||||
w.edit_runs(&[run(5, 5, 90, 90, 2, None)], |_, _| true, |_| false);
|
||||
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 91);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_follow_each_chunks_own_layout() {
|
||||
use crate::chunk::ColumnLayout;
|
||||
// a data pack's taller dimension: -128..=511
|
||||
let tall = ColumnLayout::new(-128, 640).unwrap();
|
||||
let mut w = World::new(BlockStateId(0), 0);
|
||||
w.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::with_layout(0, 0, tall)));
|
||||
let out = w.edit_runs(&[run(2, 3, -500, 900, 4, None)], |_, _| true, |_| false);
|
||||
assert_eq!(out.changed, 640);
|
||||
let chunk = &w.chunks[&(0, 0)];
|
||||
assert_eq!(chunk.block(2, -128, 3).0, 4);
|
||||
assert_eq!(chunk.block(2, -65, 3).0, 4);
|
||||
assert_eq!(chunk.block(2, 320, 3).0, 4);
|
||||
assert_eq!(chunk.block(2, 511, 3).0, 4);
|
||||
assert!(w.dirty_sections.contains(&(0, 39, 0)));
|
||||
assert!(!w.dirty_sections.contains(&(0, 40, 0)));
|
||||
}
|
||||
}
|
||||
@@ -508,6 +508,15 @@ impl Chunk {
|
||||
}
|
||||
previous
|
||||
}
|
||||
/// Section `s`'s block states replaced whole (a bulk edit): its storage
|
||||
/// compacted, and the heightmaps built again on their next read.
|
||||
pub fn replace_blocks(&mut self, s: usize, values: &[u32; 4096]) {
|
||||
let section = Arc::make_mut(&mut self.sections[s]);
|
||||
section.blocks = Section::from_blocks(values);
|
||||
section.compact();
|
||||
self.surface = OnceLock::new();
|
||||
self.motion_blocking = OnceLock::new();
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
|
||||
/// chunk): one above the highest block that isn't `air`, or the column's
|
||||
/// minimum Y.
|
||||
|
||||
@@ -199,8 +199,9 @@ where
|
||||
}
|
||||
impl SceneCollisions<'_, '_> {
|
||||
/// Position contexts permit geometric movement probes without inventing a
|
||||
/// player, inventory, effects or fluid-standing behavior; a lava walker
|
||||
/// collides as the living entity it is, empty-handed.
|
||||
/// player, inventory, effects or fluid-standing behavior; a lava walker or
|
||||
/// a living entity in leather boots collides as the living entity it is,
|
||||
/// empty-handed.
|
||||
pub fn resolve(
|
||||
&mut self,
|
||||
bounds: Aabb,
|
||||
@@ -236,7 +237,10 @@ impl SceneCollisions<'_, '_> {
|
||||
}
|
||||
fn context(&self, bounds: Aabb) -> CollisionContext<'static> {
|
||||
let y = bounds.min_position().y;
|
||||
if !self.subject.is_some_and(|s| s.lava_walker) {
|
||||
if !self
|
||||
.subject
|
||||
.is_some_and(|s| s.lava_walker || s.leather_boots)
|
||||
{
|
||||
return CollisionContext::position(y);
|
||||
}
|
||||
CollisionContext::for_entity(
|
||||
|
||||
@@ -195,14 +195,6 @@ fn read(path: &str) -> Option<String> {
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
fn windows_processor_name(query: &str) -> Option<String> {
|
||||
query.lines().find_map(|line| {
|
||||
let (_, value) = line.split_once("REG_SZ")?;
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
|
||||
fn operating_system() -> String {
|
||||
let arch = std::env::consts::ARCH;
|
||||
@@ -244,17 +236,7 @@ pub fn cpu() -> String {
|
||||
} else if cfg!(target_os = "macos") {
|
||||
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
} else if cfg!(windows) {
|
||||
command_output(
|
||||
"reg",
|
||||
&[
|
||||
"query",
|
||||
r"HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0",
|
||||
"/v",
|
||||
"ProcessorNameString",
|
||||
],
|
||||
)
|
||||
.and_then(|query| windows_processor_name(&query))
|
||||
.or_else(|| std::env::var("PROCESSOR_IDENTIFIER").ok())
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
} else {
|
||||
std::env::var("PROCESSOR_IDENTIFIER").ok()
|
||||
};
|
||||
@@ -681,22 +663,6 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn windows_registry_cpu_name_is_read_without_the_registry_columns() {
|
||||
let query = r#"
|
||||
HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0
|
||||
ProcessorNameString REG_SZ 13th Gen Intel(R) Core(TM) i7-13700F
|
||||
"#;
|
||||
assert_eq!(
|
||||
windows_processor_name(query).as_deref(),
|
||||
Some("13th Gen Intel(R) Core(TM) i7-13700F")
|
||||
);
|
||||
assert_eq!(
|
||||
windows_processor_name("ProcessorNameString REG_SZ "),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_reports_its_built_in_version() {
|
||||
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,8 +629,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 +879,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 +1778,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 +1815,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 +3438,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 +3592,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 +3687,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 +3709,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 +3728,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.
|
||||
@@ -4402,7 +4418,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 +4509,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 +4559,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();
|
||||
@@ -4949,6 +4978,11 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
pub fn render(&mut self, frame: &Frame) -> Result<bool> {
|
||||
// A lost device cannot accept uploads either. Leave recovery to the
|
||||
// owner instead of submitting atlas patches to the invalid device.
|
||||
if self.device_lost() {
|
||||
return Err("the graphics device was lost".into());
|
||||
}
|
||||
let scaled = self.screenshot_size();
|
||||
let rendered = match scaled {
|
||||
Some(size) => self.draw_scaled_frame(frame, size),
|
||||
@@ -4956,6 +4990,9 @@ impl Renderer {
|
||||
};
|
||||
// A frame that was not drawn still submits the section copies and
|
||||
// atlas patches (glyphs, skins, maps), which the caller already took.
|
||||
if self.device_lost() {
|
||||
return Err("the graphics device was lost".into());
|
||||
}
|
||||
if !matches!(rendered, Ok(true)) && !frame.hud.patches.is_empty() {
|
||||
let section_copies = self.section_copies.take();
|
||||
let has_section_copies = section_copies.is_some();
|
||||
@@ -4979,6 +5016,11 @@ impl Renderer {
|
||||
// A scaled screenshot's frame never reached the window.
|
||||
rendered.map(|drawn| drawn && scaled.is_none())
|
||||
}
|
||||
/// Whether the device's loss callback has fired. A surface reconfigure
|
||||
/// alone cannot restore resources belonging to a lost device.
|
||||
pub fn device_lost(&self) -> bool {
|
||||
self.device_lost.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
/// The frame size of the pending `scaled` screenshot when it is larger than the
|
||||
/// window: the window's times the Resolution Scale (when above 100%) and
|
||||
/// `screenshot_quality`, cut down to what one texture and one readback buffer hold,
|
||||
@@ -5078,6 +5120,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,
|
||||
@@ -5091,8 +5134,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),
|
||||
@@ -5128,9 +5205,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.
|
||||
@@ -5197,6 +5276,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());
|
||||
@@ -6020,6 +6101,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;
|
||||
@@ -6239,7 +6333,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.
|
||||
@@ -6283,7 +6378,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),
|
||||
@@ -6299,6 +6394,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
|
||||
@@ -7500,6 +7597,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,
|
||||
@@ -7532,178 +7670,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);
|
||||
}
|
||||
@@ -7714,10 +7690,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 {
|
||||
@@ -7751,7 +7732,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
|
||||
@@ -7856,7 +7839,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();
|
||||
@@ -7871,6 +7858,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,
|
||||
@@ -8210,8 +8395,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`:
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ struct HorizonMaterialOut {
|
||||
}
|
||||
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
|
||||
let pos = position + origin;
|
||||
let pos = horizon_material_displace(position + origin, uv, layer, color, light);
|
||||
var out: HorizonMaterialOut;
|
||||
out.clip = horizon_terrain_globals.view_proj * vec4(pos, 1.0);
|
||||
out.uv = uv;
|
||||
@@ -220,7 +220,7 @@ fn vanilla_terrain() -> String {
|
||||
/// The function `name(layer)`, the value of each sprite layer in `table` and 0 for the rest:
|
||||
/// `horizon_sprite_tags` (`MaterialInput.tags`) and `horizon_sprite_material`
|
||||
/// (`MaterialInput.material`).
|
||||
fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
|
||||
pub(super) fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
|
||||
let mut out = format!("fn {name}(layer: u32) -> u32 {{\n switch layer {{\n");
|
||||
let mut table = table.to_vec();
|
||||
table.sort_unstable_by_key(|&(layer, value)| (value, layer));
|
||||
@@ -235,16 +235,47 @@ fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
|
||||
out + " default: { return 0u; }\n }\n}\n"
|
||||
}
|
||||
|
||||
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
|
||||
/// `materials`.
|
||||
/// Where a corner of terrain is drawn without a vertex stage.
|
||||
pub(super) const DISPLACE_VANILLA: &str = "
|
||||
fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>) -> vec3<f32> {
|
||||
return pos;
|
||||
}
|
||||
";
|
||||
|
||||
/// The vertex stage's call, with its inputs from terrain's vertex.
|
||||
pub(super) const DISPLACE_PACK: &str = "
|
||||
fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>) -> vec3<f32> {
|
||||
let flags = u32(round(color.a * 255.0));
|
||||
let high = flags >> 4u;
|
||||
let fluid = high >= 8u && (high & 7u) < 6u;
|
||||
let lit = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
|
||||
return vertex(VertexInput(pos, uv, horizon_sprite_material(layer & 0x7fffu), lit,
|
||||
vec4(color.rgb, 1.0), select(0u, high & 7u, fluid), u32(fluid)));
|
||||
}
|
||||
";
|
||||
|
||||
/// The surface target (the post stage's `scene_surface`): x the normal (two snorm16 in its
|
||||
/// bits), y smoothness, z the pack's data, w the fragment's depth.
|
||||
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
|
||||
|
||||
fn shader_source(source: &str, tags: &[(u32, u32)], materials: &[(u32, u32)]) -> String {
|
||||
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
|
||||
/// `materials`, and the vertex stage in its module when `vertex`.
|
||||
fn shader_source(
|
||||
source: &str,
|
||||
tags: &[(u32, u32)],
|
||||
materials: &[(u32, u32)],
|
||||
vertex: bool,
|
||||
) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{ENTRIES}",
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}",
|
||||
vanilla_terrain(),
|
||||
if vertex {
|
||||
DISPLACE_PACK
|
||||
} else {
|
||||
DISPLACE_VANILLA
|
||||
},
|
||||
layer_table("horizon_sprite_tags", tags),
|
||||
layer_table("horizon_sprite_material", materials)
|
||||
)
|
||||
@@ -255,8 +286,9 @@ fn module(
|
||||
stage: &CheckedStage,
|
||||
tags: &[(u32, u32)],
|
||||
materials: &[(u32, u32)],
|
||||
vertex: bool,
|
||||
) -> Result<naga::Module, String> {
|
||||
let text = shader_source(&stage.source, tags, materials);
|
||||
let text = shader_source(&stage.source, tags, materials, vertex);
|
||||
naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
|
||||
}
|
||||
@@ -270,9 +302,10 @@ impl NativeMaterial {
|
||||
stage: &CheckedStage,
|
||||
constants: &[(String, f64)],
|
||||
fog: bool,
|
||||
vertex: bool,
|
||||
) -> Result<Self, String> {
|
||||
let writes_surface = horizon_shader_api::stages::calls_set_surface(&stage.source);
|
||||
let module = module(stage, shape.sprite_tags, shape.sprite_materials)?;
|
||||
let module = module(stage, shape.sprite_tags, shape.sprite_materials, vertex)?;
|
||||
let mut constants = present_constants(&module, constants);
|
||||
let sprite_names = [
|
||||
"horizon_terrain_SPRITE_ARRAYS",
|
||||
@@ -459,7 +492,7 @@ mod tests {
|
||||
let tags = [(7, 1), (3, 1), (9, 2)];
|
||||
let materials = [(5, 1000)];
|
||||
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
|
||||
let module = module(&stage, &tags, &materials).unwrap();
|
||||
let module = module(&stage, &tags, &materials, false).unwrap();
|
||||
let info = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
@@ -468,7 +501,7 @@ mod tests {
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"{}",
|
||||
e.emit_to_string(&shader_source(&stage.source, &tags, &materials))
|
||||
e.emit_to_string(&shader_source(&stage.source, &tags, &materials, false))
|
||||
)
|
||||
});
|
||||
let entries: Vec<&str> = module
|
||||
|
||||
@@ -48,7 +48,17 @@ impl NativePack {
|
||||
let shadow = report
|
||||
.shadow
|
||||
.as_ref()
|
||||
.map(|s| NativeShadow::new(device, terrain, &frame, &s.solid_materials))
|
||||
.map(|s| {
|
||||
let vertex = report.stages.iter().find(|s| s.kind == StageKind::Vertex);
|
||||
NativeShadow::new(
|
||||
device,
|
||||
terrain,
|
||||
&frame,
|
||||
&s.solid_materials,
|
||||
vertex,
|
||||
constants,
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
// The mirror is drawn for a material that reads it, while `[reflection] option` is on.
|
||||
let option = report.reflection.as_ref().and_then(|r| r.option.as_ref());
|
||||
@@ -91,6 +101,7 @@ impl NativePack {
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Weather)
|
||||
.map(|s| s.label.clone());
|
||||
let has_vertex = report.stages.iter().any(|s| s.kind == StageKind::Vertex);
|
||||
let (post, others): (Vec<_>, Vec<_>) = report
|
||||
.stages
|
||||
.into_iter()
|
||||
@@ -103,7 +114,9 @@ impl NativePack {
|
||||
let material = others
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Material)
|
||||
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants, fog))
|
||||
.map(|stage| {
|
||||
NativeMaterial::new(device, terrain, &frame, stage, constants, fog, has_vertex)
|
||||
})
|
||||
.transpose()?;
|
||||
let post = (!post.is_empty())
|
||||
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
|
||||
|
||||
@@ -41,17 +41,21 @@ fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<
|
||||
}
|
||||
@vertex fn horizon_shadow_vs(
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
|
||||
@location(5) origin: vec4<f32>) -> HorizonShadowOut {
|
||||
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
|
||||
let atlas = horizon_terrain_terrain_uv(uv, origin.w);
|
||||
let pos = horizon_material_displace(position + origin.xyz, atlas, layer, color, light);
|
||||
return horizon_shadow_vertex(pos, atlas, layer, color);
|
||||
}
|
||||
@vertex fn horizon_shadow_vs_flat(
|
||||
@builtin(vertex_index) index: u32,
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>, @location(2) layer: u32,
|
||||
@location(3) color: vec4<f32>
|
||||
@location(3) color: vec4<f32>, @location(4) light: vec2<u32>
|
||||
) -> HorizonShadowOut {
|
||||
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[index >> 8u]];
|
||||
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
|
||||
let atlas = horizon_terrain_terrain_uv(uv, origin.w);
|
||||
let pos = horizon_material_displace(position + origin.xyz, atlas, layer, color, light);
|
||||
return horizon_shadow_vertex(pos, atlas, layer, color);
|
||||
}
|
||||
@fragment fn horizon_shadow_fs(in: HorizonShadowOut) {
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
@@ -415,12 +419,24 @@ fn shadow_solid(materials: &[(u32, u32)], solid: &[u32]) -> String {
|
||||
|
||||
/// The shadow pass's shader: the frame, Horizon's terrain, and the entries, with the layers
|
||||
/// `solid` of `shadow_solid`.
|
||||
fn shader_source(solid: &str) -> String {
|
||||
fn shader_source(solid: &str, vertex: Option<&str>, materials: &[(u32, u32)]) -> String {
|
||||
let (head, displace) = match vertex {
|
||||
Some(stage) => (
|
||||
stage.to_owned()
|
||||
+ &super::native_material::layer_table("horizon_sprite_material", materials),
|
||||
super::native_material::DISPLACE_PACK,
|
||||
),
|
||||
None => (
|
||||
format!(
|
||||
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}",
|
||||
FrameInputs::WGSL.wgsl(),
|
||||
horizon_shader_api::stages::SHADOW_HELPERS,
|
||||
),
|
||||
super::native_material::DISPLACE_VANILLA,
|
||||
),
|
||||
};
|
||||
format!(
|
||||
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}\
|
||||
// Horizon's terrain (terrain.wgsl)\n{}{solid}{ENTRIES}",
|
||||
FrameInputs::WGSL.wgsl(),
|
||||
horizon_shader_api::stages::SHADOW_HELPERS,
|
||||
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{displace}{ENTRIES}",
|
||||
prefixed(
|
||||
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
|
||||
PREFIX
|
||||
@@ -434,14 +450,21 @@ impl NativeShadow {
|
||||
shape: &TerrainShape<'_>,
|
||||
frame: &super::native_post::NativeFrame,
|
||||
solid_materials: &[u32],
|
||||
vertex: Option<&horizon_shader_api::check::CheckedStage>,
|
||||
constants: &[(String, f64)],
|
||||
) -> Result<Self, String> {
|
||||
let text = shader_source(&shadow_solid(shape.sprite_materials, solid_materials));
|
||||
let text = shader_source(
|
||||
&shadow_solid(shape.sprite_materials, solid_materials),
|
||||
vertex.map(|s| s.source.as_str()),
|
||||
shape.sprite_materials,
|
||||
);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
|
||||
let constants = [
|
||||
let mut constants = super::native_post::present_constants(&module, constants);
|
||||
constants.extend([
|
||||
("horizon_terrain_SPRITE_ARRAYS", shape.sprites[0]),
|
||||
("horizon_terrain_SPRITE_SPLIT", shape.sprites[1]),
|
||||
];
|
||||
]);
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Native shader pack shadow"),
|
||||
@@ -593,7 +616,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_shadow_shader_validates() {
|
||||
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
|
||||
let text = shader_source(&shadow_solid(materials, &[7]));
|
||||
let text = shader_source(&shadow_solid(materials, &[7]), None, materials);
|
||||
let module = naga::front::wgsl::parse_str(&text).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -120,6 +120,7 @@ pub fn decode(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
/// A PNG in memory as RGBA pixels.
|
||||
pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(data));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info()?;
|
||||
let mut bytes = vec![0; reader.output_buffer_size().ok_or("image too large")?];
|
||||
@@ -214,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,
|
||||
@@ -241,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;
|
||||
}
|
||||
@@ -348,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)
|
||||
}
|
||||
@@ -2151,6 +2159,40 @@ impl HudRenderer {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decode_bytes_ignores_crc_checksum_error() {
|
||||
let mut encoded = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut encoded, 2, 2);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer
|
||||
.write_image_data(&[
|
||||
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
|
||||
])
|
||||
.unwrap();
|
||||
writer.finish().unwrap();
|
||||
}
|
||||
let idat_pos = encoded
|
||||
.windows(4)
|
||||
.position(|w| w == b"IDAT")
|
||||
.expect("IDAT chunk");
|
||||
let len = u32::from_be_bytes([
|
||||
encoded[idat_pos - 4],
|
||||
encoded[idat_pos - 3],
|
||||
encoded[idat_pos - 2],
|
||||
encoded[idat_pos - 1],
|
||||
]) as usize;
|
||||
let crc_pos = idat_pos + 4 + len;
|
||||
encoded[crc_pos] ^= 0xFF;
|
||||
|
||||
let (w, h, rgba) = decode_bytes(&encoded).expect("should decode despite bad CRC");
|
||||
assert_eq!(w, 2);
|
||||
assert_eq!(h, 2);
|
||||
assert_eq!(rgba.len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_since_keeps_the_visible_part_of_a_straddling_triangle() {
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
@@ -2363,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()));
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod auth;
|
||||
pub mod ban_notice;
|
||||
pub mod bidi;
|
||||
pub mod biome;
|
||||
pub mod block_edit;
|
||||
pub mod block_entities;
|
||||
pub mod block_entity_anim;
|
||||
pub mod block_entity_render;
|
||||
|
||||
@@ -592,18 +592,21 @@ pub fn apply_relighting(chunk: &mut Chunk, lights: Vec<Light>, changed: &mut Lig
|
||||
changed.keyed(chunk.layout());
|
||||
for (sy, (section, light)) in chunk.sections.iter_mut().zip(lights).enumerate() {
|
||||
let old = §ion.light;
|
||||
if let (Light::Uniform(a), Light::Uniform(b)) = (old, &light)
|
||||
&& a == b
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut classes = 0;
|
||||
for i in 0..4096 {
|
||||
if old.get(i) != light.get(i) {
|
||||
let (x, y, z) = (i as i32 & 15, (i >> 8) as i32, (i >> 4) as i32 & 15);
|
||||
classes |= 1 << LightChanges::class(x, y, z);
|
||||
let classes = match (old, &light) {
|
||||
(Light::Uniform(a), Light::Uniform(b)) if a == b => continue,
|
||||
// (most sections of a chunk relit for a neighbour's edit keep their light: one comparison of the whole)
|
||||
(Light::Full(a), Light::Full(b)) => LightChanges::classes_differing(a, b),
|
||||
_ => {
|
||||
let mut classes = 0;
|
||||
for i in 0..4096 {
|
||||
if old.get(i) != light.get(i) {
|
||||
let (x, y, z) = (i as i32 & 15, (i >> 8) as i32, (i >> 4) as i32 & 15);
|
||||
classes |= 1 << LightChanges::class(x, y, z);
|
||||
}
|
||||
}
|
||||
classes
|
||||
}
|
||||
}
|
||||
};
|
||||
if classes != 0 {
|
||||
changed.add((chunk.x, sy as i32, chunk.z), classes);
|
||||
Arc::make_mut(section).light = light;
|
||||
@@ -847,7 +850,7 @@ pub struct LightChanges {
|
||||
impl LightChanges {
|
||||
/// Changes in chunks of `layout`: what the relighter or a light job adds
|
||||
/// after this (they all come from one level).
|
||||
fn keyed(&mut self, layout: ColumnLayout) {
|
||||
pub fn keyed(&mut self, layout: ColumnLayout) {
|
||||
debug_assert!(self.sections.is_empty() || self.layout == layout);
|
||||
self.layout = layout;
|
||||
}
|
||||
@@ -858,7 +861,7 @@ impl LightChanges {
|
||||
*self.sections.entry(key).or_default() |= 1 << Self::class(x, y, z);
|
||||
}
|
||||
/// Cells of section `key` (x, section index, z) in the given classes.
|
||||
fn add(&mut self, key: Pos, classes: u32) {
|
||||
pub fn add(&mut self, key: Pos, classes: u32) {
|
||||
*self.sections.entry(key).or_default() |= classes;
|
||||
}
|
||||
/// The class of a cell in its section: per axis 0 on the low face, 1
|
||||
@@ -871,6 +874,25 @@ impl LightChanges {
|
||||
};
|
||||
axis(x) + 3 * axis(y) + 9 * axis(z)
|
||||
}
|
||||
/// The classes (bit `class`) of the cells, in a section's index order (`y << 8 | z << 4 | x`), where `a` and
|
||||
/// `b` differ.
|
||||
pub fn classes_differing<T: PartialEq>(a: &[T; 4096], b: &[T; 4096]) -> u32 {
|
||||
let mut classes = 0;
|
||||
// (a row of 16 along x at a time: the rows alike, most of them, cost one comparison)
|
||||
let (rows_a, rows_b) = (a.as_chunks::<16>().0, b.as_chunks::<16>().0);
|
||||
for (row, (ra, rb)) in rows_a.iter().zip(rows_b).enumerate() {
|
||||
if ra == rb {
|
||||
continue;
|
||||
}
|
||||
let (y, z) = ((row >> 4) as i32, (row & 15) as i32);
|
||||
for x in 0..16 {
|
||||
if ra[x] != rb[x] {
|
||||
classes |= 1 << Self::class(x as i32, y, z);
|
||||
}
|
||||
}
|
||||
}
|
||||
classes
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.sections.is_empty()
|
||||
}
|
||||
@@ -1156,6 +1178,31 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::chunk::{HEIGHT, MIN_Y, SECTIONS};
|
||||
|
||||
#[test]
|
||||
fn changed_cells_dirty_only_the_sections_within_a_block() {
|
||||
let dirty = |cells: &[(usize, usize, usize)]| {
|
||||
let a = [0u8; 4096];
|
||||
let mut b = a;
|
||||
for &(x, y, z) in cells {
|
||||
b[y << 8 | z << 4 | x] = 1;
|
||||
}
|
||||
let mut changes = LightChanges::default();
|
||||
changes.add((0, 5, 0), LightChanges::classes_differing(&a, &b));
|
||||
let mut out = Vec::new();
|
||||
changes.dirty_sections(|p| out.push(p));
|
||||
out.sort_unstable();
|
||||
out
|
||||
};
|
||||
assert_eq!(dirty(&[]), Vec::<Pos>::new());
|
||||
assert_eq!(dirty(&[(5, 5, 5), (9, 1, 14)]), vec![(0, 5, 0)]);
|
||||
assert_eq!(dirty(&[(0, 5, 5)]), vec![(-1, 5, 0), (0, 5, 0)]);
|
||||
assert_eq!(
|
||||
dirty(&[(15, 15, 5)]),
|
||||
vec![(0, 5, 0), (0, 6, 0), (1, 5, 0), (1, 6, 0)]
|
||||
);
|
||||
assert_eq!(dirty(&[(0, 0, 0)]).len(), 8);
|
||||
}
|
||||
|
||||
const AIR: u32 = 0;
|
||||
const STONE: u32 = 1;
|
||||
const TORCH: u32 = 2;
|
||||
|
||||
@@ -1102,6 +1102,8 @@ pub struct Mesher<'a> {
|
||||
pub smooth_lighting: bool,
|
||||
/// The `cutoutLeaves` option (fancy graphics); off makes leaves opaque.
|
||||
pub cutout_leaves: bool,
|
||||
/// Sprite layers a shader pack moves into the translucent pass, by layer.
|
||||
pub translucent_layers: &'a [bool],
|
||||
/// What the worker does with leaf faces inside a canopy.
|
||||
pub leaf_culling: LeafCullingMode,
|
||||
}
|
||||
@@ -1310,7 +1312,13 @@ impl Mesher<'_> {
|
||||
if self.tables.is_animated(face.layer as u16) {
|
||||
out.note_sprite_layer(face.layer as u16);
|
||||
}
|
||||
let target = if face.translucent {
|
||||
let translucent = face.translucent
|
||||
|| self
|
||||
.translucent_layers
|
||||
.get(face.layer as usize)
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
let target = if translucent {
|
||||
&mut out.translucent
|
||||
} else {
|
||||
&mut out.solid
|
||||
@@ -1318,7 +1326,7 @@ impl Mesher<'_> {
|
||||
// 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 && !face.translucent
|
||||
let layer = if face.opaque && !translucent
|
||||
|| !self.cutout_leaves && state.leaves
|
||||
|| interior_leaf
|
||||
{
|
||||
|
||||
@@ -81,6 +81,7 @@ fn mesh(
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves: true,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: mode,
|
||||
};
|
||||
mesher.mesh(®ion, sy)
|
||||
|
||||
@@ -81,6 +81,7 @@ fn a_shader_pack_lights_the_faces_itself() {
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves: true,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
let mut colours: Vec<u32> = mesher
|
||||
@@ -157,6 +158,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
|
||||
blend_radius: 2,
|
||||
smooth_lighting: reload == 0,
|
||||
cutout_leaves: reload == 0,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
for (case, (center, east)) in [
|
||||
|
||||
@@ -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)
|
||||
@@ -4941,6 +4941,24 @@ impl OptionsUi {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The window allows the input method only while `typing` says a search
|
||||
/// box has the focus (`Game::text_field_focused`), so a page without one
|
||||
/// must not claim the keys: the movement and menu keys keep working there.
|
||||
#[test]
|
||||
fn only_a_focused_search_box_takes_text() {
|
||||
let mut ui = OptionsUi::default();
|
||||
assert!(!ui.typing(), "the main page has no text box");
|
||||
ui.show(Page::Language);
|
||||
assert!(ui.typing(), "the language list's search takes the keys");
|
||||
ui.show(Page::Video);
|
||||
assert!(!ui.typing(), "the settings search is not focused");
|
||||
ui.settings_search.focused = true;
|
||||
assert!(ui.typing(), "the settings search, once focused, does");
|
||||
ui.settings_search.focused = false;
|
||||
ui.show(Page::Packs);
|
||||
assert!(!ui.typing(), "the pack screen's search is not open yet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessibility_sliders_and_toggles_survive_options_round_trip() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -926,6 +926,8 @@ impl PostEffects {
|
||||
let mut decoder = png::Decoder::new(std::io::BufReader::new(
|
||||
std::fs::File::open(&file).map_err(|e| format!("{}: {e}", file.display()))?,
|
||||
));
|
||||
// As the other resource-pack images: stb_image never checks chunk CRCs.
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
|
||||
let mut data = vec![0; reader.output_buffer_size().ok_or("a huge image")?];
|
||||
|
||||
@@ -827,6 +827,14 @@ impl TextField {
|
||||
);
|
||||
}
|
||||
}
|
||||
if self.focused {
|
||||
// `TextInputManager.setTextInputArea`, in GUI pixels: the caret
|
||||
// the input method's composition and its candidate window go to.
|
||||
// The chat and in-bed chat boxes and the pack screen's search are
|
||||
// `TextField`s; the other fields are an `EditBox` or a multiline
|
||||
// box, which report their own.
|
||||
crate::preedit::report_caret(cursor_x as f32, ty as f32, 10.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -528,6 +528,7 @@ impl Sprites {
|
||||
/// A PNG's width, height and pixels as RGBA.
|
||||
fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info()?;
|
||||
let mut bytes = vec![
|
||||
|
||||
@@ -197,7 +197,7 @@ impl<G: ChunkGenerator> Saves for G {
|
||||
}
|
||||
}
|
||||
/// Per-job mesher inputs that change with the dimension or the options.
|
||||
#[derive(Clone, Copy)]
|
||||
#[derive(Clone)]
|
||||
struct MeshSettings {
|
||||
nether: bool,
|
||||
shader_pack_lighting: bool,
|
||||
@@ -205,6 +205,7 @@ struct MeshSettings {
|
||||
blend_radius: i32,
|
||||
smooth_lighting: bool,
|
||||
cutout_leaves: bool,
|
||||
translucent_layers: Arc<[bool]>,
|
||||
leaf_culling: LeafCullingMode,
|
||||
}
|
||||
enum Job {
|
||||
@@ -440,6 +441,9 @@ pub struct Streaming {
|
||||
/// The `ao` and `cutoutLeaves` options.
|
||||
pub smooth_lighting: bool,
|
||||
pub cutout_leaves: bool,
|
||||
/// Sprite layers a shader pack draws in the translucent pass (`[[materials]]
|
||||
/// translucent`), by layer; empty without one.
|
||||
pub translucent_layers: Arc<[bool]>,
|
||||
/// What the mesher does with leaf faces inside a canopy (`LeafCullingMode`).
|
||||
pub leaf_culling: LeafCullingMode,
|
||||
/// Incremental relighting of block edits on lit chunks (see `light::relight`).
|
||||
@@ -776,6 +780,7 @@ impl Streaming {
|
||||
blend_radius: settings.blend_radius,
|
||||
smooth_lighting: settings.smooth_lighting,
|
||||
cutout_leaves: settings.cutout_leaves,
|
||||
translucent_layers: &settings.translucent_layers,
|
||||
leaf_culling: settings.leaf_culling,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
@@ -858,6 +863,7 @@ impl Streaming {
|
||||
biome_blend: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
translucent_layers: Arc::from([]),
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
light_tables,
|
||||
light_log: Vec::new(),
|
||||
@@ -910,9 +916,56 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A resident column a server sent again whole (it changed it in bulk):
|
||||
/// its new blocks taken, the sections that did not change kept as they
|
||||
/// were, and its light and its neighbours' worked out again here, off
|
||||
/// the game thread, as light is for the blocks a server changes one at a
|
||||
/// time; the sections that changed remesh (with theirs) once lit. Until
|
||||
/// then the sections that changed show the light the packet carried (a
|
||||
/// Horizon server sends the light from before its relight, then the
|
||||
/// new light; another server's is already right, which matters where
|
||||
/// the neighbours this light needs aren't all here).
|
||||
pub fn replace_remote(&mut self, world: &mut World, mut chunk: Chunk) {
|
||||
let key = (chunk.x, chunk.z);
|
||||
let Some(old) = world.chunks.get(&key).cloned() else {
|
||||
return self.insert_remote(world, chunk);
|
||||
};
|
||||
if !self.lit.contains(&key) {
|
||||
return self.insert_remote(world, chunk);
|
||||
}
|
||||
// The cells whose blocks changed, by section: they remesh with the neighbours within a block of them (as
|
||||
// vanilla's `setBlocksDirty`), not every section round each section changed (which, for the few blocks a
|
||||
// section often changes in a bulk edit, is mostly sections that did not).
|
||||
let mut cells = crate::light::LightChanges::default();
|
||||
cells.keyed(world.layout());
|
||||
let kept = keep_unchanged_sections(&old, &mut chunk, &mut cells);
|
||||
chunk.light_loaded = old.light_loaded;
|
||||
chunk.lit = true;
|
||||
world.chunks.insert(key, Arc::new(chunk));
|
||||
world.revision = world.revision.wrapping_add(1);
|
||||
// A section with new biomes and the same blocks remeshes now (its tint), with its neighbours, whose tint
|
||||
// blends across the border: above and below too, as a block's tint can sample the biome quart of the
|
||||
// section next to it (`BiomeManager.getBiome`'s zoom reaches a quart beyond the block).
|
||||
Self::light_updated(world, key.0, key.1, &kept.biomes);
|
||||
// A section with the same blocks and new light takes the packet's light (vanilla's
|
||||
// `handleLevelChunkWithLight` always applies it) and remeshes as a light update would.
|
||||
Self::light_updated(world, key.0, key.1, &kept.light);
|
||||
if !kept.blocks {
|
||||
return;
|
||||
}
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
world.dirty_light.insert((key.0 + dx, key.1 + dz));
|
||||
}
|
||||
}
|
||||
cells.dirty_sections(|section| {
|
||||
world.dirty_sections.insert(section);
|
||||
});
|
||||
}
|
||||
/// A server's light update for a resident column: the sections whose
|
||||
/// light changed and their neighbours remesh
|
||||
/// (`setSectionDirtyWithNeighbors`).
|
||||
/// (`setSectionDirtyWithNeighbors`). A resent column's sections with new
|
||||
/// biomes remesh the same way.
|
||||
pub fn light_updated(world: &mut World, x: i32, z: i32, sections: &[usize]) {
|
||||
for &sy in sections {
|
||||
for dz in -1..=1 {
|
||||
@@ -1718,6 +1771,7 @@ impl Streaming {
|
||||
blend_radius: self.biome_blend,
|
||||
smooth_lighting: self.smooth_lighting,
|
||||
cutout_leaves: self.cutout_leaves,
|
||||
translucent_layers: self.translucent_layers.clone(),
|
||||
leaf_culling: self.leaf_culling,
|
||||
},
|
||||
),
|
||||
@@ -2370,10 +2424,136 @@ fn nearest_first<T: Ord>(candidates: &mut Vec<T>, room: usize) {
|
||||
}
|
||||
candidates.sort_unstable();
|
||||
}
|
||||
/// What `keep_unchanged_sections` found changed.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
struct Resent {
|
||||
/// Any section's blocks changed (in a way light or the mesher sees).
|
||||
blocks: bool,
|
||||
/// The sections whose blocks did not change but whose biomes did.
|
||||
biomes: Vec<usize>,
|
||||
/// The sections whose blocks did not change but whose light the packet changes.
|
||||
light: Vec<usize>,
|
||||
}
|
||||
/// `new`, a resident column `old` sent again, takes `old`'s sections whose
|
||||
/// blocks it holds unchanged, with `new`'s biomes and light; the sections whose blocks
|
||||
/// changed keep `old`'s light (until lit again) and their cells' classes go in
|
||||
/// `cells`.
|
||||
fn keep_unchanged_sections(
|
||||
old: &Chunk,
|
||||
new: &mut Chunk,
|
||||
cells: &mut crate::light::LightChanges,
|
||||
) -> Resent {
|
||||
let (mut a, mut b) = (Box::new([0u32; 4096]), Box::new([0u32; 4096]));
|
||||
let mut out = Resent::default();
|
||||
let (x, z) = (new.x, new.z);
|
||||
for s in 0..new.sections.len().min(old.sections.len()) {
|
||||
// (sections stored alike hold the same blocks: most of a column sent again, found without unpacking)
|
||||
let same = old.sections[s].blocks.same_storage(&new.sections[s].blocks) || {
|
||||
old.sections[s].unpack(&mut a);
|
||||
new.sections[s].unpack(&mut b);
|
||||
let classes = crate::light::LightChanges::classes_differing(&a, &b);
|
||||
if classes != 0 {
|
||||
cells.add((x, s as i32, z), classes);
|
||||
}
|
||||
classes == 0
|
||||
};
|
||||
if !same {
|
||||
out.blocks = true;
|
||||
continue;
|
||||
}
|
||||
let biomes = new.sections[s].biomes.clone();
|
||||
let changed = !same_values(&old.sections[s].biomes, &biomes);
|
||||
let light = (!same_light(&old.sections[s].light, &new.sections[s].light))
|
||||
.then(|| new.sections[s].light.clone());
|
||||
new.sections[s] = old.sections[s].clone();
|
||||
if changed {
|
||||
Arc::make_mut(&mut new.sections[s]).biomes = biomes;
|
||||
out.biomes.push(s);
|
||||
}
|
||||
if let Some(light) = light {
|
||||
Arc::make_mut(&mut new.sections[s]).light = light;
|
||||
out.light.push(s);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Whether two sections' packed light (4,096 cells) holds the same values.
|
||||
fn same_light(a: &crate::chunk::Light, b: &crate::chunk::Light) -> bool {
|
||||
use crate::chunk::Light;
|
||||
match (a, b) {
|
||||
(Light::Uniform(x), Light::Uniform(y)) => x == y,
|
||||
(Light::Full(x), Light::Full(y)) => x == y,
|
||||
(Light::Uniform(v), Light::Full(f)) | (Light::Full(f), Light::Uniform(v)) => {
|
||||
f.iter().all(|c| c == v)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether two biome containers (64 cells) hold the same values.
|
||||
fn same_values(
|
||||
a: &horizon_core::paletted::PalettedContainer,
|
||||
b: &horizon_core::paletted::PalettedContainer,
|
||||
) -> bool {
|
||||
if a.same_storage(b) {
|
||||
return true;
|
||||
}
|
||||
let (mut x, mut y) = ([0u32; 64], [0u32; 64]);
|
||||
a.unpack(&mut x);
|
||||
b.unpack(&mut y);
|
||||
x == y
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_resent_column_keeps_its_sections_but_takes_new_biomes() {
|
||||
let mut old = Chunk::empty(2, -3);
|
||||
old.set(1, 10, 1, 1);
|
||||
old.set(1, 40, 1, 1);
|
||||
old.set_biome_cell(0, 0, 0, 1);
|
||||
let mut new = old.clone();
|
||||
// a new biome under the same blocks (section 0), and a block changed (section 6)
|
||||
new.set_biome_cell(0, 0, 0, 3);
|
||||
new.set(1, 40, 1, 2);
|
||||
let mut cells = crate::light::LightChanges::default();
|
||||
cells.keyed(crate::chunk::ColumnLayout::VANILLA);
|
||||
let kept = keep_unchanged_sections(&old, &mut new, &mut cells);
|
||||
assert_eq!(
|
||||
kept,
|
||||
Resent {
|
||||
blocks: true,
|
||||
biomes: vec![0],
|
||||
light: vec![],
|
||||
}
|
||||
);
|
||||
assert_eq!(new.biome(0, -64, 0), 3);
|
||||
assert_eq!(new.block(1, -54, 1).0, 0);
|
||||
assert_eq!(new.block(1, 40, 1).0, 2);
|
||||
// the sections alike are shared with the old column
|
||||
assert!(Arc::ptr_eq(&new.sections[1], &old.sections[1]));
|
||||
assert!(!Arc::ptr_eq(&new.sections[0], &old.sections[0]));
|
||||
// nothing changed: nothing to do
|
||||
let mut same = old.clone();
|
||||
let kept = keep_unchanged_sections(&old, &mut same, &mut cells);
|
||||
assert_eq!(kept, Resent::default());
|
||||
assert!(Arc::ptr_eq(&same.sections[0], &old.sections[0]));
|
||||
// the same blocks under new light: the packet's light, as vanilla applies it
|
||||
let mut lit = old.clone();
|
||||
Arc::make_mut(&mut lit.sections[2]).light = crate::chunk::Light::Uniform(0xF0);
|
||||
Arc::make_mut(&mut old.sections[2]).light = crate::chunk::Light::Uniform(0x50);
|
||||
let kept = keep_unchanged_sections(&old, &mut lit, &mut cells);
|
||||
assert_eq!(
|
||||
kept,
|
||||
Resent {
|
||||
light: vec![2],
|
||||
..Resent::default()
|
||||
}
|
||||
);
|
||||
assert_eq!(lit.sections[2].light.get(0), 0xF0);
|
||||
assert!(Arc::ptr_eq(&lit.sections[1], &old.sections[1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_pool_reuses_capacity_with_clean_geometry_state() {
|
||||
let mut mesh = SectionMesh::default();
|
||||
|
||||
@@ -100,6 +100,9 @@ pub struct World {
|
||||
/// for `ServerLevel.sendBlockUpdated`'s path recomputing; kept only while
|
||||
/// the mobs of this world tick (`game/creatures.rs`).
|
||||
pub path_writes: Option<Vec<((i32, i32, i32), u32, u32)>>,
|
||||
/// The chunks changed in bulk (`edit_runs`) since the log was last
|
||||
/// drained, kept while `block_log` is: sent to the players whole.
|
||||
pub bulk_log: FxHashSet<(i32, i32)>,
|
||||
/// The sections whose light changed since the log was last drained, as
|
||||
/// a bit per section of each column, kept while `block_log` is
|
||||
/// (`ChunkHolder.sectionLightChanged`).
|
||||
@@ -208,6 +211,7 @@ impl World {
|
||||
weather: Weather::default(),
|
||||
block_log: None,
|
||||
path_writes: None,
|
||||
bulk_log: Default::default(),
|
||||
light_log: None,
|
||||
moving_piston_fallback: Vec::new(),
|
||||
moving_pistons: Vec::new(),
|
||||
|
||||
@@ -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": "Закрити"
|
||||
}
|
||||
@@ -140,6 +140,7 @@ fn main() -> Result<(), Error> {
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
|
||||
@@ -138,6 +138,7 @@ fn scene(
|
||||
blend_radius,
|
||||
smooth_lighting,
|
||||
cutout_leaves,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
let section_y = int(&row["section_y"])?;
|
||||
|
||||
@@ -32,8 +32,8 @@ use horizon_world::{
|
||||
use std::{collections::HashSet, fmt};
|
||||
|
||||
/// `EntityType.ARROW` (and tipped/spectral arrows): 0.5 x 0.5, eyes at 0.13.
|
||||
const WIDTH: f32 = 0.5;
|
||||
const HEIGHT: f32 = 0.5;
|
||||
pub const WIDTH: f32 = 0.5;
|
||||
pub const HEIGHT: f32 = 0.5;
|
||||
const EYE_HEIGHT: f32 = 0.13;
|
||||
|
||||
/// `AbstractArrow.Pickup`.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -278,6 +278,16 @@ impl ServerTickRateManager {
|
||||
const SPRINT_PER_FRAME: i64 = 40;
|
||||
const SAMPLES: usize = 100;
|
||||
|
||||
/// A step command's broadcast value and the ticks still owed to a new client.
|
||||
/// `ServerTickRateManager.updateStepTicks` sends the former immediately;
|
||||
/// `ServerTickRateManager.updateJoiningPlayer` sends the latter on a join.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct StepState {
|
||||
pub revision: u32,
|
||||
pub requested: i32,
|
||||
pub remaining: i32,
|
||||
}
|
||||
|
||||
/// The game's clock: `DeltaTracker.Timer` pacing driven by the
|
||||
/// `ServerTickRateManager` the `/tick` command controls, over a virtual
|
||||
/// millisecond clock built from the frame times it is fed. The integrated
|
||||
@@ -300,6 +310,7 @@ pub struct FixedStep {
|
||||
/// How many times `/tick step` started or stopped a step
|
||||
/// (`ServerTickRateManager.updateStepTicks` broadcasts each one).
|
||||
step_changes: u32,
|
||||
step_requested: i32,
|
||||
/// In-frame ticks dropped since the last overload warning (`drop_ticks`).
|
||||
dropped: u32,
|
||||
/// When the last overload warning was due, on the frame clock.
|
||||
@@ -320,6 +331,7 @@ impl Default for FixedStep {
|
||||
tick_count: 0,
|
||||
client: false,
|
||||
step_changes: 0,
|
||||
step_requested: 0,
|
||||
dropped: 0,
|
||||
last_overload_warning: std::time::Duration::ZERO,
|
||||
}
|
||||
@@ -488,18 +500,29 @@ impl FixedStep {
|
||||
let started = self
|
||||
.manager
|
||||
.step_game_if_paused(ticks.min(i32::MAX as u32) as i32);
|
||||
self.step_changes += u32::from(started);
|
||||
if started {
|
||||
self.step_changes += 1;
|
||||
self.step_requested = self.manager.base.frozen_ticks_to_run();
|
||||
}
|
||||
started
|
||||
}
|
||||
pub fn stop_step(&mut self) -> bool {
|
||||
let stopped = self.manager.stop_stepping();
|
||||
self.step_changes += u32::from(stopped);
|
||||
if stopped {
|
||||
self.step_changes += 1;
|
||||
self.step_requested = 0;
|
||||
}
|
||||
stopped
|
||||
}
|
||||
/// What `ClientboundTickingStepPacket` carries (`frozenTicksToRun`), and
|
||||
/// a count that changes each time `updateStepTicks` would broadcast it.
|
||||
pub fn step_state(&self) -> (u32, i32) {
|
||||
(self.step_changes, self.manager.base.frozen_ticks_to_run())
|
||||
/// Preserve the command's count until the network pass, even if a world
|
||||
/// tick has already consumed it.
|
||||
/// New arrivals instead receive the current remaining ticks.
|
||||
pub fn step_state(&self) -> StepState {
|
||||
StepState {
|
||||
revision: self.step_changes,
|
||||
requested: self.step_requested,
|
||||
remaining: self.manager.base.frozen_ticks_to_run(),
|
||||
}
|
||||
}
|
||||
/// Record how long a tick took (`MinecraftServer.tickServer` bookkeeping
|
||||
/// and `endTickWork`).
|
||||
@@ -607,15 +630,55 @@ mod tests {
|
||||
fn only_a_started_or_stopped_step_is_broadcast() {
|
||||
let mut clock = FixedStep::default();
|
||||
assert!(!clock.start_step(5), "not frozen");
|
||||
assert_eq!(clock.step_state(), (0, 0));
|
||||
assert_eq!(clock.step_state(), StepState::default());
|
||||
clock.set_frozen(true);
|
||||
assert!(clock.start_step(5));
|
||||
assert_eq!(clock.step_state(), (1, 5));
|
||||
assert_eq!(
|
||||
clock.step_state(),
|
||||
StepState {
|
||||
revision: 1,
|
||||
requested: 5,
|
||||
remaining: 5
|
||||
}
|
||||
);
|
||||
clock.world_runs();
|
||||
assert_eq!(clock.step_state(), (1, 4), "a step tick is not broadcast");
|
||||
assert_eq!(
|
||||
clock.step_state(),
|
||||
StepState {
|
||||
revision: 1,
|
||||
requested: 5,
|
||||
remaining: 4
|
||||
},
|
||||
"consuming a tick preserves the command broadcast"
|
||||
);
|
||||
assert!(clock.stop_step());
|
||||
assert!(!clock.stop_step());
|
||||
assert_eq!(clock.step_state(), (2, 0));
|
||||
assert_eq!(
|
||||
clock.step_state(),
|
||||
StepState {
|
||||
revision: 2,
|
||||
requested: 0,
|
||||
remaining: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn a_consumed_single_step_still_advances_the_client_once() {
|
||||
let mut server = FixedStep::default();
|
||||
server.set_frozen(true);
|
||||
assert!(server.start_step(1));
|
||||
assert!(server.world_runs());
|
||||
assert!(!server.world_runs());
|
||||
let state = server.step_state();
|
||||
assert_eq!(state.remaining, 0, "a late join has no pending ticks");
|
||||
let mut client = FixedStep::client();
|
||||
client.set_frozen(true);
|
||||
client.set_frozen_ticks_to_run(state.requested);
|
||||
assert!(
|
||||
client.world_runs(),
|
||||
"the connected client runs the command's tick"
|
||||
);
|
||||
assert!(!client.world_runs(), "then remains frozen");
|
||||
}
|
||||
#[test]
|
||||
fn a_servers_tick_rate_only_slows_its_client_down() {
|
||||
|
||||
@@ -896,31 +896,6 @@ fn towards_closest_space(
|
||||
_ => Vec3::new(v.x, v.y, push),
|
||||
}
|
||||
}
|
||||
/// `ExperienceOrb.scanForMerges`, run by each orb on `tickCount % 20 == 1`:
|
||||
/// it absorbs the orbs that `canMerge` within 0.5 blocks of its box,
|
||||
/// adding their counts and keeping the smaller age.
|
||||
pub fn merge_orbs(orbs: &mut Vec<ExperienceOrb>) {
|
||||
let mut removed = vec![false; orbs.len()];
|
||||
for i in 0..orbs.len() {
|
||||
if removed[i] || orbs[i].ticks % 20 != 1 {
|
||||
continue;
|
||||
}
|
||||
let area = orbs[i].bounds().inflate(Vec3::new(0.5, 0.5, 0.5));
|
||||
for j in 0..orbs.len() {
|
||||
if j != i
|
||||
&& !removed[j]
|
||||
&& orbs[j].can_merge(orbs[i].id, orbs[i].value)
|
||||
&& orbs[j].bounds().intersects(area)
|
||||
{
|
||||
orbs[i].count += orbs[j].count;
|
||||
orbs[i].age = orbs[i].age.min(orbs[j].age);
|
||||
removed[j] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut removed = removed.into_iter();
|
||||
orbs.retain(|_| !removed.next().unwrap_or(false));
|
||||
}
|
||||
/// Player experience (Player.giveExperiencePoints / getXpNeededForNextLevel).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Experience {
|
||||
@@ -1316,6 +1291,12 @@ impl ArmorStand {
|
||||
Vec3::new(p.x + w, p.y + h, p.z + w),
|
||||
)
|
||||
}
|
||||
/// Leather boots on its feet: it walks on powder snow (`PowderSnowBlock.canEntityWalkOnPowderSnow`).
|
||||
fn feet_are_leather_boots(&self) -> bool {
|
||||
self.equipment[2]
|
||||
.as_ref()
|
||||
.is_some_and(|s| &*s.item == "minecraft:leather_boots")
|
||||
}
|
||||
/// LivingEntity.travel (armor stands have no other movement): clamp tiny
|
||||
/// velocity components (`LivingEntity.aiStep`), move by the velocity (a
|
||||
/// landing stops the fall, `Block.updateEntityMovementAfterFallOn`, and a
|
||||
@@ -1331,9 +1312,16 @@ impl ArmorStand {
|
||||
in_water: bool,
|
||||
push: &mut dyn FnMut(Aabb, &mut Vec3),
|
||||
) {
|
||||
self.previous = self.position;
|
||||
if self.marker {
|
||||
return;
|
||||
}
|
||||
scene.subject =
|
||||
self.feet_are_leather_boots()
|
||||
.then_some(crate::collisions::PowderSnowSubject {
|
||||
leather_boots: true,
|
||||
..Default::default()
|
||||
});
|
||||
let v = self.velocity;
|
||||
let wanted = Vec3::new(
|
||||
if v.x.abs() < 0.003 { 0.0 } else { v.x },
|
||||
@@ -2291,3 +2279,107 @@ mod entity_collision_tests {
|
||||
println!("scan {scan:?}, grid {binned:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod armor_stand_motion_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_new_stand_starts_with_matching_previous() {
|
||||
let at = Vec3::new(1.0, 2.0, 3.0);
|
||||
let stand = ArmorStand::new(1, at, 90.0);
|
||||
assert_eq!(stand.previous, at);
|
||||
assert_eq!(stand.position, at);
|
||||
}
|
||||
|
||||
fn tick_stand(
|
||||
stand: &mut ArmorStand,
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
) {
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
stand.tick(&mut scene, facts, false, &mut |_, _| {});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tick_keeps_the_previous_position_for_interpolation() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let start = Vec3::new(20.5, 20.0, 20.5);
|
||||
let mut stand = ArmorStand::new(1, start, 0.0);
|
||||
stand.on_ground = false;
|
||||
tick_stand(&mut stand, &world, &facts, &collision);
|
||||
assert_eq!(stand.previous, start);
|
||||
assert!(
|
||||
stand.position.y < start.y,
|
||||
"an airborne stand must fall, y={}",
|
||||
stand.position.y
|
||||
);
|
||||
let mid = stand.previous.y + (stand.position.y - stand.previous.y) * 0.5;
|
||||
assert!(
|
||||
mid < start.y && mid > stand.position.y,
|
||||
"alpha 0.5 must sit between previous and current"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leather_boots_walk_on_powder_snow() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let snow = assets
|
||||
.registry
|
||||
.default_state("minecraft:powder_snow")
|
||||
.unwrap();
|
||||
for x in 19..=21 {
|
||||
for z in 19..=21 {
|
||||
world.set(x, 10, z, snow).unwrap();
|
||||
}
|
||||
}
|
||||
let start = Vec3::new(20.5, 11.0, 20.5);
|
||||
let mut bare = ArmorStand::new(1, start, 0.0);
|
||||
bare.on_ground = true;
|
||||
for _ in 0..8 {
|
||||
tick_stand(&mut bare, &world, &facts, &collision);
|
||||
}
|
||||
assert!(
|
||||
bare.position.y < start.y - 0.2,
|
||||
"bare feet must sink into powder snow, y={}",
|
||||
bare.position.y
|
||||
);
|
||||
|
||||
let mut booted = ArmorStand::new(2, start, 0.0);
|
||||
booted.on_ground = true;
|
||||
booted.equipment[2] = Some(ItemStack::new("minecraft:leather_boots", 1));
|
||||
for _ in 0..8 {
|
||||
tick_stand(&mut booted, &world, &facts, &collision);
|
||||
}
|
||||
assert!(
|
||||
(booted.position.y - start.y).abs() < 1.0e-6,
|
||||
"leather boots must walk on powder snow, y={}",
|
||||
booted.position.y
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
@@ -1407,6 +1408,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.)
|
||||
@@ -2019,6 +2023,28 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// `PrimedTnt.addAdditionalSaveData`: its `fuse`.
|
||||
pub fn tnt_to_nbt(
|
||||
position: Vec3,
|
||||
motion: Vec3,
|
||||
on_ground: bool,
|
||||
fuse: i32,
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&Tag>,
|
||||
) -> Tag {
|
||||
let mut tag = Base {
|
||||
id: "minecraft:tnt",
|
||||
uuid,
|
||||
position,
|
||||
motion,
|
||||
rotation: [0.0, 0.0],
|
||||
on_ground,
|
||||
fall_distance: 0.0,
|
||||
}
|
||||
.tag(raw);
|
||||
put(&mut tag, "fuse", Tag::Short(fuse as i16));
|
||||
tag
|
||||
}
|
||||
pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemEntity> {
|
||||
let stack = item_from_nbt(tag.get("Item")?)?;
|
||||
let position = position(tag)?;
|
||||
|
||||
@@ -10,7 +10,16 @@ use crate::{
|
||||
ui,
|
||||
};
|
||||
|
||||
/// The English title: the fallback of [`title`], and what the screen's widgets are named by.
|
||||
pub const TITLE: &str = "Play with Friends";
|
||||
/// A string of this screen: the language table's, or the English text.
|
||||
fn tr(key: &str, english: &str) -> String {
|
||||
crate::lang::get_or(key, english)
|
||||
}
|
||||
/// The title in the current language.
|
||||
pub fn title() -> String {
|
||||
tr("horizon.friends.title", TITLE)
|
||||
}
|
||||
/// What the screen shows (game/friends.rs fills it).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FriendsView {
|
||||
@@ -47,13 +56,18 @@ fn grey() -> u32 {
|
||||
rgba(160, 160, 160, 255)
|
||||
}
|
||||
const WHITE: u32 = u32::MAX;
|
||||
const HINT: &str = "Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.";
|
||||
fn hint() -> String {
|
||||
tr(
|
||||
"horizon.friends.hint",
|
||||
"Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
|
||||
)
|
||||
}
|
||||
|
||||
struct Button {
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
label: &'static str,
|
||||
label: String,
|
||||
action: Action,
|
||||
active: bool,
|
||||
}
|
||||
@@ -76,7 +90,11 @@ fn layout(
|
||||
};
|
||||
let mut buttons = Vec::new();
|
||||
if view.sharing {
|
||||
lines.push(("Invite Code".to_owned(), grey(), y));
|
||||
lines.push((
|
||||
tr("horizon.multiplayer.invite_code", "Invite Code"),
|
||||
grey(),
|
||||
y,
|
||||
));
|
||||
y += 12.0;
|
||||
match &view.code {
|
||||
Some(code) => {
|
||||
@@ -92,7 +110,11 @@ fn layout(
|
||||
x: cx - 154.0,
|
||||
y,
|
||||
w: 150.0,
|
||||
label: if view.copied { "Copied" } else { "Copy Code" },
|
||||
label: if view.copied {
|
||||
tr("horizon.friends.copied", "Copied")
|
||||
} else {
|
||||
tr("horizon.friends.copy_code", "Copy Code")
|
||||
},
|
||||
action: Action::Copy,
|
||||
active: view.code.is_some(),
|
||||
});
|
||||
@@ -100,7 +122,7 @@ fn layout(
|
||||
x: cx + 4.0,
|
||||
y,
|
||||
w: 150.0,
|
||||
label: "Stop Sharing",
|
||||
label: tr("horizon.friends.stop_sharing", "Stop Sharing"),
|
||||
action: Action::Stop,
|
||||
active: true,
|
||||
});
|
||||
@@ -113,32 +135,40 @@ fn layout(
|
||||
y += 4.0;
|
||||
}
|
||||
let here = if view.guests.is_empty() {
|
||||
"No friends here yet.".to_owned()
|
||||
tr("horizon.friends.none_yet", "No friends here yet.")
|
||||
} else {
|
||||
format!("Here now: {}", view.guests.join(", "))
|
||||
tr("horizon.friends.here_now", "Here now: %s").replacen(
|
||||
"%s",
|
||||
&view.guests.join(", "),
|
||||
1,
|
||||
)
|
||||
};
|
||||
add(&mut lines, &here, grey(), &mut y);
|
||||
if let Some(notice) = &view.notice {
|
||||
add(&mut lines, notice, WHITE, &mut y);
|
||||
}
|
||||
// Where friends paste the code, while it fits above the footer.
|
||||
if y + 10.0 * wrap(HINT, width, atlas).len() as f32 <= h - 33.0 {
|
||||
add(&mut lines, HINT, grey(), &mut y);
|
||||
let hint = hint();
|
||||
if y + 10.0 * wrap(&hint, width, atlas).len() as f32 <= h - 33.0 {
|
||||
add(&mut lines, &hint, grey(), &mut y);
|
||||
}
|
||||
} else {
|
||||
add(
|
||||
&mut lines,
|
||||
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
|
||||
&tr(
|
||||
"horizon.friends.share_intro",
|
||||
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
|
||||
),
|
||||
WHITE,
|
||||
&mut y,
|
||||
);
|
||||
y += 4.0;
|
||||
add(&mut lines, HINT, grey(), &mut y);
|
||||
add(&mut lines, &hint(), grey(), &mut y);
|
||||
buttons.push(Button {
|
||||
x: cx - 100.0,
|
||||
y: y + 8.0,
|
||||
w: 200.0,
|
||||
label: "Start Sharing",
|
||||
label: tr("horizon.friends.start_sharing", "Start Sharing"),
|
||||
action: Action::Share,
|
||||
active: view.shown,
|
||||
});
|
||||
@@ -150,7 +180,7 @@ fn layout(
|
||||
x: cx - 154.0,
|
||||
y: h - 27.0,
|
||||
w: 150.0,
|
||||
label: "Troubleshooting...",
|
||||
label: tr("horizon.friends.troubleshooting", "Troubleshooting..."),
|
||||
action: Action::Help,
|
||||
active: true,
|
||||
});
|
||||
@@ -158,7 +188,7 @@ fn layout(
|
||||
x: cx + 4.0,
|
||||
y: h - 27.0,
|
||||
w: 150.0,
|
||||
label: "gui.done",
|
||||
label: "gui.done".to_owned(),
|
||||
action: Action::Done,
|
||||
active: true,
|
||||
});
|
||||
@@ -167,7 +197,7 @@ fn layout(
|
||||
x: cx - 100.0,
|
||||
y: h - 27.0,
|
||||
w: 200.0,
|
||||
label: "gui.done",
|
||||
label: "gui.done".to_owned(),
|
||||
action: Action::Done,
|
||||
active: true,
|
||||
});
|
||||
@@ -204,7 +234,7 @@ pub fn draw(
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
let title = Text::literal(TITLE);
|
||||
let title = Text::literal(title());
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
|
||||
let (lines, buttons) = layout(atlas, (w, h), view);
|
||||
@@ -221,7 +251,7 @@ pub fn draw(
|
||||
b.y,
|
||||
b.w,
|
||||
20.0,
|
||||
&label(b.label),
|
||||
&label(&b.label),
|
||||
b.active,
|
||||
hover,
|
||||
);
|
||||
|
||||
@@ -61,10 +61,12 @@ mod beacon;
|
||||
mod beds;
|
||||
mod beehives;
|
||||
mod block_editor;
|
||||
mod block_edits;
|
||||
mod block_entity_anims;
|
||||
mod block_entity_ticks;
|
||||
mod block_items;
|
||||
mod blockctl;
|
||||
mod boat_authority_fixture;
|
||||
mod boat_leads;
|
||||
mod boat_passengers;
|
||||
mod boats;
|
||||
@@ -124,6 +126,7 @@ mod fangs;
|
||||
mod fireworks;
|
||||
mod fishing;
|
||||
mod fov;
|
||||
mod foxes;
|
||||
mod frames;
|
||||
mod friends;
|
||||
mod functions;
|
||||
@@ -209,6 +212,8 @@ mod native_recipes;
|
||||
mod native_server;
|
||||
mod native_shaders;
|
||||
mod native_visuals;
|
||||
mod native_world_columns;
|
||||
mod native_world_edit;
|
||||
mod native_world_read;
|
||||
mod natural_spawning;
|
||||
mod nautili;
|
||||
@@ -2253,6 +2258,8 @@ struct Game<'a> {
|
||||
screen: Screen,
|
||||
window: Option<Arc<Window>>,
|
||||
renderer: Option<Renderer>,
|
||||
/// Avoid an endless rebuild loop if the new device is lost as well.
|
||||
last_device_recovery: Option<Instant>,
|
||||
/// CPU geometry storage retained across frames, including empty/title frames.
|
||||
entity_vertices: Vec<Vertex>,
|
||||
/// Horizon Extras' dynamic lights this frame (`dynamic_lights.rs`), and how brightly each
|
||||
@@ -2832,9 +2839,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`).
|
||||
@@ -2995,6 +3003,25 @@ fn view_vector(yaw: f32, pitch: f32) -> [f64; 3] {
|
||||
let (y, p) = (f64::from(yaw).to_radians(), f64::from(pitch).to_radians());
|
||||
[-y.sin() * p.cos(), -p.sin(), y.cos() * p.cos()]
|
||||
}
|
||||
/// `LivingEntity.canGlideUsing`: for each worn piece (head, chest, legs,
|
||||
/// feet), whether it is a glider (its own component, or one the server
|
||||
/// patched onto the stack) in the slot its `equippable` names, and will not
|
||||
/// 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"];
|
||||
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])
|
||||
})
|
||||
}
|
||||
/// `Camera.setRotation` (`rotationYXZ(π - yaw, -pitch, 0)`) for the player's
|
||||
/// view, turned round in third-person front view (`Camera.setup`).
|
||||
fn camera_rotation(camera_type: perspective::CameraType, yaw: f32, pitch: f32) -> glam::Quat {
|
||||
@@ -3233,15 +3260,31 @@ impl Game<'_> {
|
||||
self.stack_in_mut(self.held_slot())
|
||||
}
|
||||
/// The slot of the hand that acts: the selected one, or `OFFHAND_SLOT`.
|
||||
/// `LivingEntity.canGlideUsing`: a glider in the chest slot that will
|
||||
/// not break with its next point of wear.
|
||||
/// `LivingEntity.canGlide`: some worn piece `gliding_pieces` lets glide.
|
||||
fn glider_worn(&self) -> bool {
|
||||
self.armor[1].as_ref().is_some_and(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.get(&s.item)
|
||||
.is_some_and(|i| i.glider && (i.max_damage == 0 || s.damage + 1 < i.max_damage))
|
||||
})
|
||||
gliding_pieces(self.res.items(), &self.armor).contains(&true)
|
||||
}
|
||||
/// `LivingEntity.updateFallFlying`: the glide's wear. `Util.getRandom`
|
||||
/// picks one of the gliders that will not break (from the entity's own
|
||||
/// random), and
|
||||
/// `ItemStack.hurtAndBreak` takes `points` of its durability (Unbreaking
|
||||
/// may save it). Callers skip creative.
|
||||
pub(super) fn wear_glider(&mut self, points: u32) {
|
||||
let gliding = gliding_pieces(self.res.items(), &self.armor);
|
||||
let pieces: Vec<usize> = (0..4).filter(|&i| gliding[i]).collect();
|
||||
if pieces.is_empty() {
|
||||
return;
|
||||
}
|
||||
// `Util.getRandom(slotsWithGliders, this.random)`: the entity's own
|
||||
// random, which never touches the level's stream.
|
||||
let pick = dispenser::entity_random().next_int_bound(pieces.len() as i32);
|
||||
let pick = pieces[pick.unwrap_or(0) as usize];
|
||||
if let Some(stack) = self.armor[pick].clone() {
|
||||
let loss = self.wear(&stack, points);
|
||||
if let Some(piece) = self.armor[pick].as_mut() {
|
||||
piece.damage += loss;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.canFreeze`'s armor half: any worn piece in
|
||||
/// `freeze_immune_wearables`.
|
||||
@@ -6984,15 +7027,8 @@ impl Game<'_> {
|
||||
lan_particles::send_soul_particle(host, at, velocity);
|
||||
}
|
||||
}
|
||||
// `ItemStack.hurtAndBreak` for the glide: Unbreaking can save the wear.
|
||||
if glider_wear > 0
|
||||
&& !self.creative
|
||||
&& let Some(stack) = self.armor[1].clone()
|
||||
{
|
||||
let loss = self.wear(&stack, glider_wear);
|
||||
if let Some(elytra) = self.armor[1].as_mut() {
|
||||
elytra.damage += loss;
|
||||
}
|
||||
if glider_wear > 0 && !self.creative {
|
||||
self.wear_glider(glider_wear);
|
||||
}
|
||||
self.tick_avatar(before, riding);
|
||||
self.end_spin_attack(spin_hit);
|
||||
@@ -7288,6 +7324,7 @@ impl Game<'_> {
|
||||
if running {
|
||||
horizon_client::spans::time("tick_mobs", || self.tick_mobs())?;
|
||||
horizon_client::spans::time("recheck_golem_openers", || self.recheck_golem_openers());
|
||||
self.tick_mod_container_viewers();
|
||||
let before = self.orb_ids();
|
||||
horizon_client::spans::time("tick_experience", || self.tick_experience());
|
||||
// Merged, despawned or picked up.
|
||||
@@ -10932,7 +10969,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);
|
||||
}
|
||||
@@ -11098,7 +11135,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);
|
||||
self.mobs[index].knockback(f64::from(knockback), xd, zd, &source);
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
let mob = &self.mobs[index];
|
||||
@@ -12242,6 +12279,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};
|
||||
@@ -12276,22 +12353,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);
|
||||
@@ -12796,18 +12868,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.
|
||||
@@ -12968,10 +13105,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 {
|
||||
@@ -13387,6 +13526,67 @@ impl Game<'_> {
|
||||
fn new_renderer(&self, window: Arc<Window>) -> Result<Renderer> {
|
||||
self.renderer_for(window, &self.pack, &self.gui)
|
||||
}
|
||||
/// Rebuild GPU-owned state after the backend reports device loss.
|
||||
fn recover_device(&mut self) -> Result<()> {
|
||||
if !self.renderer.as_ref().is_some_and(Renderer::device_lost) {
|
||||
return Ok(());
|
||||
}
|
||||
// Split-screen compositors and guests share the device; rebuilding one
|
||||
// renderer alone would leave the other windows using invalid resources.
|
||||
if self.options.paced_by_split {
|
||||
return Err("the graphics device was lost during split-screen play".into());
|
||||
}
|
||||
let now = Instant::now();
|
||||
if self
|
||||
.last_device_recovery
|
||||
.is_some_and(|last| now.duration_since(last) < Duration::from_secs(60))
|
||||
{
|
||||
return Err("the graphics device was lost again after recovery".into());
|
||||
}
|
||||
self.last_device_recovery = Some(now);
|
||||
let window = self
|
||||
.window
|
||||
.clone()
|
||||
.ok_or("no window for graphics recovery")?;
|
||||
log::warn!("Horizon: rebuilding the renderer after graphics device loss");
|
||||
// Drop the old surface and device before opening another on this window.
|
||||
let mut old = self
|
||||
.renderer
|
||||
.take()
|
||||
.ok_or("no renderer for graphics recovery")?;
|
||||
let screenshot = old.screenshot.take();
|
||||
let screenshot_result = old.screenshot_result.take();
|
||||
drop(old);
|
||||
let mut renderer = build_renderer(
|
||||
window,
|
||||
self.options.offscreen,
|
||||
&self.pack,
|
||||
&self.gui,
|
||||
None,
|
||||
None,
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
)?;
|
||||
renderer.screenshot = screenshot;
|
||||
renderer.screenshot_result = screenshot_result;
|
||||
self.renderer = Some(renderer);
|
||||
self.skin_slots.forget();
|
||||
self.maps.slots.forget();
|
||||
self.map_patches.clear();
|
||||
self.kept_block_entities.get_mut().clear();
|
||||
horizon_client::ui_theme::reset_atlas();
|
||||
self.clear_lod();
|
||||
self.pending_uploads.clear();
|
||||
self.streaming
|
||||
.reload_resources(&mut self.world, self.pack.context.clone());
|
||||
self.native_shaders = native_shaders::NativeShaders::picking(&std::mem::take(
|
||||
&mut self.native_shaders.picked,
|
||||
));
|
||||
self.apply_ui_settings();
|
||||
log::info!("Horizon: graphics device recovered; rebuilding world meshes");
|
||||
Ok(())
|
||||
}
|
||||
fn renderer_for(
|
||||
&self,
|
||||
window: Arc<Window>,
|
||||
@@ -13442,6 +13642,14 @@ impl Game<'_> {
|
||||
);
|
||||
self.apply_ui_settings();
|
||||
}
|
||||
/// The sprite layers a shader pack draws in the translucent pass: every section is meshed
|
||||
/// again when they change.
|
||||
pub(super) fn set_translucent_layers(&mut self, layers: Vec<bool>) {
|
||||
if *layers != *self.streaming.translucent_layers {
|
||||
self.streaming.translucent_layers = layers.into();
|
||||
self.all_sections_changed();
|
||||
}
|
||||
}
|
||||
/// `LevelExtractor.allChanged`: every loaded section is rebuilt.
|
||||
fn all_sections_changed(&mut self) {
|
||||
for &(x, z) in self.world.chunks.keys() {
|
||||
@@ -17106,6 +17314,7 @@ impl Game<'_> {
|
||||
self.mapped_key_event(key, event.state == ElementState::Pressed, event.repeat)?;
|
||||
}
|
||||
WindowEvent::RedrawRequested => {
|
||||
self.recover_device()?;
|
||||
self.observe_fullscreen();
|
||||
self.sync_ime();
|
||||
let now = Instant::now();
|
||||
@@ -17188,7 +17397,14 @@ impl Game<'_> {
|
||||
self.advance_flight();
|
||||
self.tick_world_icon();
|
||||
let ticked = Instant::now();
|
||||
let rendered = self.render()?;
|
||||
let rendered = match self.render() {
|
||||
// The callback can fire during this frame; rebuild on
|
||||
// the next redraw rather than exiting on its error.
|
||||
Err(_) if self.renderer.as_ref().is_some_and(Renderer::device_lost) => {
|
||||
false
|
||||
}
|
||||
result => result?,
|
||||
};
|
||||
self.apply_cursor_icon();
|
||||
if let Some(threshold) = self.options.spikes
|
||||
&& let Some(r) = self.renderer.as_ref()
|
||||
@@ -17571,6 +17787,7 @@ impl<'a> Game<'a> {
|
||||
screen: Screen::Playing,
|
||||
window: None,
|
||||
renderer: None,
|
||||
last_device_recovery: None,
|
||||
entity_vertices: Vec::new(),
|
||||
dynamic_lights: Vec::new(),
|
||||
luminance: None,
|
||||
@@ -18506,10 +18723,12 @@ fn load_core(
|
||||
assets.reload(root)
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
let item_renderer = horizon_client::item_render::ItemRenderer::load_with_library(
|
||||
root,
|
||||
render.model_library.clone(),
|
||||
)
|
||||
let library = render.model_library.clone();
|
||||
let item_renderer = if headless {
|
||||
horizon_client::item_render::ItemRenderer::load_for_server(root, library)
|
||||
} else {
|
||||
horizon_client::item_render::ItemRenderer::load_with_library(root, library)
|
||||
}
|
||||
.map_err(|e| e.to_string());
|
||||
Ok((render, item_renderer))
|
||||
});
|
||||
@@ -19261,6 +19480,10 @@ fn start_game<'a>(
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some(directory) = std::env::var_os("HORIZON_BOAT_AUTHORITY_FIXTURE") {
|
||||
game.run_boat_authority_fixture(std::path::Path::new(&directory))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if let Ok(spec) = std::env::var("HORIZON_DISPENSE_FIXTURE") {
|
||||
game.run_component_harness(integrated::ComponentHarness::Dispenser(spec))?;
|
||||
return Ok(None);
|
||||
@@ -19849,6 +20072,7 @@ pub fn run() -> Result<()> {
|
||||
}
|
||||
let _ = horizon_client::health::HURT_SERVER.set(native_hooks::hurt_server);
|
||||
let _ = horizon_client::block_contact::HOOK.set(native_hooks::block_contact);
|
||||
let _ = horizon_client::health::KNOCKBACK.set(native_hooks::knockback);
|
||||
// Before any worker starts: on Linux they go behind the server thread
|
||||
// and the render thread.
|
||||
horizon_threads::put_helpers_behind();
|
||||
|
||||
@@ -699,7 +699,7 @@ impl Game<'_> {
|
||||
push(
|
||||
[orb.position.x, orb.position.y, orb.position.z],
|
||||
"minecraft:experience_orb",
|
||||
json!({ "kind": "orb", "value": orb.value }),
|
||||
json!({ "kind": "orb", "value": orb.value, "velocity": [orb.velocity.x, orb.velocity.y, orb.velocity.z] }),
|
||||
);
|
||||
}
|
||||
list.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -148,6 +148,11 @@ impl BlockEditScreen {
|
||||
dragging: None,
|
||||
}
|
||||
}
|
||||
/// Whether one of the block's text boxes has the keyboard's focus: the
|
||||
/// window asks the OS for an input method only then.
|
||||
pub(super) fn text_focused(&self) -> bool {
|
||||
self.focus.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! The native parity harness runs the actual client boat ticker without a
|
||||
//! renderer, on an empty world; movement ownership changes between ticks.
|
||||
use super::{Game, boats::Boat};
|
||||
use horizon_client::Result;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Case {
|
||||
name: String,
|
||||
velocity: [f64; 3],
|
||||
spin: f32,
|
||||
owned: Vec<bool>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn run_boat_authority_fixture(&mut self, directory: &Path) -> Result<()> {
|
||||
let cases: Vec<Case> =
|
||||
serde_json::from_str(&std::fs::read_to_string(directory.join("inputs.json"))?)?;
|
||||
let mut output = Vec::new();
|
||||
for case in cases {
|
||||
self.boats.clear();
|
||||
let mut boat = Boat::new(1, "oak".into(), Vec3::new(0.0, 100.0, 0.0), 0.0);
|
||||
let [x, y, z] = case.velocity;
|
||||
boat.velocity = Vec3::new(x, y, z);
|
||||
boat.delta_rotation = case.spin;
|
||||
self.boats.push(boat);
|
||||
let mut steps = Vec::new();
|
||||
for owned in case.owned {
|
||||
self.riding = owned.then_some(1);
|
||||
if owned {
|
||||
let boat = self.boats.remove(0);
|
||||
let boat = self.tick_boat(boat, true);
|
||||
self.boats.push(boat);
|
||||
}
|
||||
self.tick_remote_boat_bubbles();
|
||||
let boat = &self.boats[0];
|
||||
steps.push(json!({
|
||||
"owned": owned,
|
||||
"spin": format!("{:08x}", boat.delta_rotation.to_bits()),
|
||||
"velocity": boat.velocity.components().map(|v| format!("{:016x}", v.to_bits())),
|
||||
}));
|
||||
}
|
||||
output.push(json!({"name": case.name, "steps": steps}));
|
||||
}
|
||||
std::fs::write(directory.join("rust.json"), serde_json::to_vec(&output)?)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1300,6 +1300,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
|
||||
}
|
||||
|
||||
@@ -252,13 +252,14 @@ fn block_properties(registry: &'static BlockRegistry) -> Arc<BlockProperties> {
|
||||
properties
|
||||
}
|
||||
|
||||
static COMMAND_EXPORTS: Mutex<Vec<(PathBuf, Option<Arc<serde_json::Value>>)>> =
|
||||
Mutex::new(Vec::new());
|
||||
static COMMAND_EXPORTS: Mutex<Vec<(PathBuf, Option<Arc<Registries>>)>> = Mutex::new(Vec::new());
|
||||
|
||||
/// A folder's `command-registries.json` (vanilla's built-in registries),
|
||||
/// read once like the resource ids: parsing its 1.6 MB again took about
|
||||
/// 15 ms of every world opening.
|
||||
fn command_export(dir: &Path) -> Option<Arc<serde_json::Value>> {
|
||||
/// What the registries take from a folder's `command-registries.json`
|
||||
/// (vanilla's built-in registries), read once like the resource ids: parsing
|
||||
/// its 1.6 MB again took about 15 ms of every world opening. Only the part
|
||||
/// the registries fill from stays, not the parsed export (4.2 MB, against
|
||||
/// 2.2 MB). A dedicated server opens its world once and keeps nothing.
|
||||
fn command_export(dir: &Path, keep: bool) -> Option<Arc<Registries>> {
|
||||
let mut cache = COMMAND_EXPORTS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((_, export)) = cache.iter().find(|(cached, _)| cached == dir) {
|
||||
return export.clone();
|
||||
@@ -266,8 +267,10 @@ fn command_export(dir: &Path) -> Option<Arc<serde_json::Value>> {
|
||||
let export = std::fs::read(dir.join("command-registries.json"))
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||
.map(Arc::new);
|
||||
cache.push((dir.to_owned(), export.clone()));
|
||||
.map(|export| Arc::new(Registries::from_export(&export)));
|
||||
if keep {
|
||||
cache.push((dir.to_owned(), export.clone()));
|
||||
}
|
||||
export
|
||||
}
|
||||
|
||||
@@ -531,8 +534,8 @@ impl Game<'_> {
|
||||
}
|
||||
// Vanilla's built-in registries (mob effects, components, dialogs...).
|
||||
for dir in [&self.options.resources, &self.ui.vanilla_root] {
|
||||
if let Some(export) = command_export(dir) {
|
||||
self.ui.registries.fill_from_export(&export);
|
||||
if let Some(export) = command_export(dir, self.options.dedicated.is_none()) {
|
||||
self.ui.registries.fill_from_exported(&export);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1552,11 +1555,22 @@ mod tests {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-export-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("command-registries.json"), r#"{"effects":["a"]}"#).unwrap();
|
||||
let first = command_export(&dir).unwrap();
|
||||
assert_eq!(first["effects"][0], "a");
|
||||
let first = command_export(&dir, true).unwrap();
|
||||
assert_eq!(first.effects, ["a"]);
|
||||
std::fs::write(dir.join("command-registries.json"), "{}").unwrap();
|
||||
assert!(Arc::ptr_eq(&first, &command_export(&dir).unwrap()));
|
||||
assert!(command_export(&dir.join("missing")).is_none());
|
||||
assert!(Arc::ptr_eq(&first, &command_export(&dir, true).unwrap()));
|
||||
assert!(command_export(&dir.join("missing"), true).is_none());
|
||||
// A dedicated server's read is not kept.
|
||||
let other = dir.join("other");
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
std::fs::write(
|
||||
other.join("command-registries.json"),
|
||||
r#"{"effects":["b"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let once = command_export(&other, false).unwrap();
|
||||
assert_eq!(once.effects, ["b"]);
|
||||
assert!(!Arc::ptr_eq(&once, &command_export(&other, false).unwrap()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 @@ 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),
|
||||
}
|
||||
/// 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 +137,8 @@ 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)) = t
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
@@ -242,11 +245,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 +262,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 +274,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 +343,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 +512,11 @@ 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]),
|
||||
}
|
||||
}
|
||||
pub(super) fn target_type(&self, t: Target) -> String {
|
||||
@@ -525,6 +563,7 @@ 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(),
|
||||
}
|
||||
}
|
||||
/// The name of the player the game acts for: a guest's own while their
|
||||
@@ -801,6 +840,7 @@ 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
|
||||
}
|
||||
/// The loaded levels, in the order the server holds them: the
|
||||
@@ -832,6 +872,7 @@ impl Game<'_> {
|
||||
Target::Other(id),
|
||||
Target::Crystal(id),
|
||||
Target::Dragon(id),
|
||||
Target::Tnt(id),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|t| self.level_holds(*t))
|
||||
@@ -853,6 +894,7 @@ 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),
|
||||
}
|
||||
}
|
||||
/// Run `f` with the level that holds the entity a target names in place
|
||||
@@ -1419,6 +1461,7 @@ impl Game<'_> {
|
||||
Target::Stand(id) => {
|
||||
if let Some(a) = self.armor_stands.iter_mut().find(|a| a.id == id) {
|
||||
a.position = v;
|
||||
a.previous = v;
|
||||
if let Some([yaw, _]) = rotation {
|
||||
a.yaw = yaw;
|
||||
}
|
||||
@@ -1462,6 +1505,12 @@ 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;
|
||||
}
|
||||
}
|
||||
// Wall-mounted entities do not move.
|
||||
Target::Frame(_) | Target::Painting(_) => {}
|
||||
}
|
||||
@@ -1959,6 +2008,7 @@ 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),
|
||||
}
|
||||
// The orbs marked for removal are gone (Entity.kill removes them).
|
||||
let before = self.orb_ids();
|
||||
@@ -2161,26 +2211,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(
|
||||
@@ -3586,6 +3622,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]
|
||||
@@ -3615,6 +3680,7 @@ mod look_at_tests {
|
||||
Target::Cart(7),
|
||||
Target::Crystal(7),
|
||||
Target::Dragon(7),
|
||||
Target::Tnt(7),
|
||||
] {
|
||||
assert_eq!(non_player_entity_id(t), Some(7), "{t:?}");
|
||||
}
|
||||
|
||||
@@ -176,6 +176,18 @@ 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),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
/// `Entity.load` of new data (`EntityDataAccessor.setData`): the entity is
|
||||
|
||||
@@ -9,6 +9,7 @@ use horizon_client::{
|
||||
arrows::EntityHit,
|
||||
blocks::Fluid,
|
||||
collisions::SceneCollisions,
|
||||
health::{HurtSource, Hurting, Knock, knockback_canceled},
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{
|
||||
@@ -1109,6 +1110,8 @@ impl Game<'_> {
|
||||
self.tick_fangs();
|
||||
self.tick_allays();
|
||||
self.tick_pandas();
|
||||
self.tick_foxes();
|
||||
self.tick_dolphins();
|
||||
self.tick_mob_looting();
|
||||
self.tick_breeze_effects();
|
||||
self.tick_sniffer_effects();
|
||||
@@ -1829,11 +1832,20 @@ impl Game<'_> {
|
||||
power,
|
||||
x,
|
||||
z,
|
||||
source,
|
||||
} => match target {
|
||||
Who::Player => self.knock_back_player(power, x, z),
|
||||
// A player's source names no melee attacker, as its damage's doesn't.
|
||||
Who::Player => {
|
||||
let source = HurtSource {
|
||||
damage_type: source_damage_type(source),
|
||||
causing: 0,
|
||||
direct: 0,
|
||||
};
|
||||
self.knock_back_player(power, x, z, source);
|
||||
}
|
||||
Who::Mob(id) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.knockback(power, x, z);
|
||||
m.knockback(power, x, z, &source);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2145,6 +2157,20 @@ impl Game<'_> {
|
||||
self.finalize_converted(id);
|
||||
}
|
||||
}
|
||||
MobEvent::ThrowItem {
|
||||
mob,
|
||||
stack,
|
||||
at,
|
||||
motion,
|
||||
pickup_delay,
|
||||
} => {
|
||||
// `thrownItem.setThrower(the mob)`.
|
||||
self.spawn_item(stack, at, motion, pickup_delay);
|
||||
let thrower = self.mob_uuid(mob);
|
||||
if let Some(thrown) = self.just_spawned_item() {
|
||||
thrown.thrower = Some(thrower);
|
||||
}
|
||||
}
|
||||
MobEvent::DropItem { item, count, at } => self.spawn_item(
|
||||
ItemStack {
|
||||
item,
|
||||
@@ -3198,7 +3224,12 @@ impl Game<'_> {
|
||||
if o.blocked <= 0.0 {
|
||||
player.health.indicate_damage(xd, zd, player.yaw);
|
||||
}
|
||||
self.knock_back_player(f64::from(0.4_f32), xd, zd);
|
||||
let source = HurtSource {
|
||||
damage_type,
|
||||
causing,
|
||||
direct: 0,
|
||||
};
|
||||
self.knock_back_player(f64::from(0.4_f32), xd, zd, source);
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& let Some(r) = p.hurt_log.last_mut()
|
||||
{
|
||||
@@ -3245,38 +3276,30 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
if raised {
|
||||
self.after_block(outcome, attacker, projectile);
|
||||
let source = HurtSource {
|
||||
damage_type,
|
||||
causing,
|
||||
direct: 0,
|
||||
};
|
||||
self.after_block(outcome, (attacker, projectile), source);
|
||||
}
|
||||
outcome.hurt
|
||||
}
|
||||
/// `LivingEntity.knockback` on the player: pushed `power` away from the
|
||||
/// direction (`xd`, `zd`), less its knockback resistance, and up only from
|
||||
/// the ground.
|
||||
pub(super) fn knock_back_player(&mut self, power: f64, mut xd: f64, mut zd: f64) {
|
||||
let Some(p) = self.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let resistance = p.attribute("minecraft:knockback_resistance").unwrap_or(0.0);
|
||||
let power = power * (1.0 - resistance);
|
||||
if power <= 0.0 {
|
||||
return;
|
||||
/// The native knockback hook runs once the resistance is applied, with
|
||||
/// `source`, the hit's.
|
||||
pub(super) fn knock_back_player(
|
||||
&mut self,
|
||||
power: f64,
|
||||
xd: f64,
|
||||
zd: f64,
|
||||
source: HurtSource<'_>,
|
||||
) {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
knock_back(p, &mut self.world.random, power, (xd, zd), source);
|
||||
}
|
||||
while xd * xd + zd * zd < f64::from(1.0e-5_f32) {
|
||||
let r = &mut self.world.random;
|
||||
xd = (r.next_double() - r.next_double()) * 0.01;
|
||||
zd = (r.next_double() - r.next_double()) * 0.01;
|
||||
}
|
||||
let l = (xd * xd + zd * zd).sqrt();
|
||||
let v = p.velocity;
|
||||
p.velocity = Vec3::new(
|
||||
v.x / 2.0 - xd / l * power,
|
||||
if p.ground.on_ground {
|
||||
(v.y / 2.0 + power).min(0.4)
|
||||
} else {
|
||||
v.y
|
||||
},
|
||||
v.z / 2.0 - zd / l * power,
|
||||
);
|
||||
}
|
||||
/// A status effect from a mob, arrow or splash potion (`addEffect`).
|
||||
pub(super) fn give_player_effect(&mut self, effect: &'static str, ticks: i32, amplifier: i32) {
|
||||
@@ -6684,6 +6707,48 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.knockback` on `p`: [`Game::knock_back_player`].
|
||||
fn knock_back(
|
||||
p: &mut horizon_client::motion::PlayerMotion,
|
||||
random: &mut horizon_core::random::LegacyRandom,
|
||||
power: f64,
|
||||
(xd, zd): (f64, f64),
|
||||
source: HurtSource<'_>,
|
||||
) {
|
||||
let resistance = p.attribute("minecraft:knockback_resistance").unwrap_or(0.0);
|
||||
let mut knock = Knock {
|
||||
strength: power * (1.0 - resistance),
|
||||
x: xd,
|
||||
z: zd,
|
||||
};
|
||||
if knockback_canceled(Hurting::Player(p.entity_id, source), &mut knock) {
|
||||
return;
|
||||
}
|
||||
let Knock {
|
||||
strength: power,
|
||||
x: mut xd,
|
||||
z: mut zd,
|
||||
} = knock;
|
||||
if power <= 0.0 {
|
||||
return;
|
||||
}
|
||||
while xd * xd + zd * zd < f64::from(1.0e-5_f32) {
|
||||
xd = (random.next_double() - random.next_double()) * 0.01;
|
||||
zd = (random.next_double() - random.next_double()) * 0.01;
|
||||
}
|
||||
let l = (xd * xd + zd * zd).sqrt();
|
||||
let v = p.velocity;
|
||||
p.velocity = Vec3::new(
|
||||
v.x / 2.0 - xd / l * power,
|
||||
if p.ground.on_ground {
|
||||
(v.y / 2.0 + power).min(0.4)
|
||||
} else {
|
||||
v.y
|
||||
},
|
||||
v.z / 2.0 - zd / l * power,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::perspective::CameraType;
|
||||
@@ -6959,3 +7024,94 @@ mod pearl_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod knockback_tests {
|
||||
use super::*;
|
||||
use horizon_client::health::KNOCKBACK;
|
||||
|
||||
/// What the hook of these tests saw: the player, its source (as damage
|
||||
/// type, causing, direct) and the push.
|
||||
type Pushes = std::sync::Mutex<Vec<(i32, (String, i32, i32), Knock)>>;
|
||||
static PUSHES: Pushes = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
/// Sets the native knockback hook of these tests (global: it stays set for
|
||||
/// the process) for players 9201 to 9203: it records the push, cancels
|
||||
/// 9201's and doubles 9202's strength and turns its direction around.
|
||||
fn hook(pushed: Hurting<'_>, knock: &mut Knock) -> bool {
|
||||
let Hurting::Player(entity @ 9201..=9203, s) = pushed else {
|
||||
return false;
|
||||
};
|
||||
let source = (s.damage_type.to_owned(), s.causing, s.direct);
|
||||
PUSHES.lock().unwrap().push((entity, source, *knock));
|
||||
match entity {
|
||||
9201 => return true,
|
||||
9202 => {
|
||||
knock.strength *= 2.0;
|
||||
knock.z = -knock.z;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// `LivingEntity.knockback` on a player: the hook runs after the
|
||||
/// knockback resistance and before the motion changes; a mod's strength or
|
||||
/// direction pushes, a cancel pushes nothing, and without a mod in the way
|
||||
/// the push is vanilla's. Needs the prepared data in
|
||||
/// `.local/horizon-client/26.3`; skipped without it.
|
||||
#[test]
|
||||
fn the_native_knockback_hook_changes_or_cancels_a_players_push() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let installed = *KNOCKBACK.get_or_init(|| hook);
|
||||
assert!(std::ptr::fn_addr_eq(
|
||||
installed,
|
||||
hook as fn(Hurting<'_>, &mut Knock) -> bool
|
||||
));
|
||||
let assets = horizon_client::assets::Assets::load(&root).unwrap();
|
||||
let resources = horizon_client::motion::MotionResources::load(&root, &assets).unwrap();
|
||||
let mut random = horizon_core::random::LegacyRandom::new(1);
|
||||
let hit = HurtSource {
|
||||
damage_type: "minecraft:player_attack",
|
||||
causing: 5,
|
||||
direct: 5,
|
||||
};
|
||||
let mut push = |entity, resistance: f64| {
|
||||
let mut p = horizon_client::motion::PlayerMotion::new(&resources, Vec3::ZERO).unwrap();
|
||||
p.entity_id = entity;
|
||||
p.ground.on_ground = true;
|
||||
if resistance != 0.0 {
|
||||
let attribute = "minecraft:knockback_resistance";
|
||||
let set = p.set_modifier(attribute, "horizon:test", resistance, "add_value", true);
|
||||
set.unwrap();
|
||||
}
|
||||
knock_back(&mut p, &mut random, 0.4, (0.0, 3.0), hit);
|
||||
(p.velocity.x, p.velocity.y, p.velocity.z)
|
||||
};
|
||||
// No mod in the way: vanilla's push, away from z = 3.
|
||||
assert_eq!(push(9200, 0.0), (0.0, 0.4, -0.4));
|
||||
// Canceled: no motion. The hook saw the push and the hit.
|
||||
assert_eq!(push(9201, 0.0), (0.0, 0.0, 0.0));
|
||||
let made = Knock {
|
||||
strength: 0.4,
|
||||
x: 0.0,
|
||||
z: 3.0,
|
||||
};
|
||||
let named = ("minecraft:player_attack".to_owned(), 5, 5);
|
||||
let seen = |entity| -> Vec<_> {
|
||||
let seen = PUSHES.lock().unwrap();
|
||||
seen.iter().filter(|p| p.0 == entity).cloned().collect()
|
||||
};
|
||||
assert_eq!(seen(9201), [(9201, named, made)]);
|
||||
// Twice the strength, direction turned around.
|
||||
assert_eq!(push(9202, 0.0), (0.0, 0.4, 0.8));
|
||||
// After the resistance: a 0.5 resistance hands the hook half the strength.
|
||||
assert_eq!(push(9203, 0.5), (0.0, 0.2, -0.2));
|
||||
assert_eq!(seen(9203)[0].2.strength, 0.2);
|
||||
}
|
||||
}
|
||||
@@ -359,8 +359,10 @@ impl Game<'_> {
|
||||
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
|
||||
return false;
|
||||
}
|
||||
// `skipAttackInteraction` when `mayInteract` is false.
|
||||
// `Cushion.hurtServer` refuses a player who may not build, so
|
||||
// skipAttackInteraction is false and Player.attack goes on to onAttack.
|
||||
if !self.may_build() {
|
||||
self.attack_ticks = 0;
|
||||
return true;
|
||||
}
|
||||
self.break_cushion(i, true);
|
||||
|
||||
@@ -363,8 +363,19 @@ impl Game<'_> {
|
||||
.map_or("0".into(), |mhz| {
|
||||
debug_overlay::refresh_rate_label(f64::from(mhz) / 1000.0)
|
||||
});
|
||||
// Horizon: with frame generation running, the frames reaching the screen too.
|
||||
let generated = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.and_then(|r| r.frame_generation_share())
|
||||
.map_or(String::new(), |share| {
|
||||
format!(
|
||||
" ({} fps FSR 3.1)",
|
||||
(self.fps * (1.0 + share)).round() as i32
|
||||
)
|
||||
});
|
||||
d.priority(format!(
|
||||
"{} fps T: {}{present} @{refresh}Hz",
|
||||
"{} fps{generated} T: {}{present} @{refresh}Hz",
|
||||
self.fps.round() as i32,
|
||||
if limit == 260 {
|
||||
"inf".into()
|
||||
|
||||
@@ -867,7 +867,13 @@ impl Game<'_> {
|
||||
/// `EnderDragonFight.tick` and the fight's entities, while the End is
|
||||
/// ticked (in place for its player, or on a server with players in it).
|
||||
pub(super) fn tick_fight(&mut self) {
|
||||
if self.dim != Dim::End || self.travelling.is_some() {
|
||||
if self.travelling.is_some() {
|
||||
return;
|
||||
}
|
||||
// A dragon outside the End (`/summon`) has no fight: `ServerLevel.getDragonFight` is
|
||||
// null there, but `EnderDragon.aiStep` still flies it.
|
||||
if self.dim != Dim::End {
|
||||
self.tick_dragon();
|
||||
return;
|
||||
}
|
||||
self.load_fight();
|
||||
|
||||
@@ -489,6 +489,9 @@ impl Game<'_> {
|
||||
if self.dim == Dim::End {
|
||||
self.tick_fight();
|
||||
self.tick_dragon_fireballs();
|
||||
} else {
|
||||
// `/summon ender_dragon` outside the End: no fight, but the dragon flies.
|
||||
self.tick_fight();
|
||||
}
|
||||
self.tick_crystals();
|
||||
self.tick_eyes();
|
||||
|
||||
@@ -441,7 +441,12 @@ impl Game<'_> {
|
||||
}
|
||||
self.combat.forget_noted(outcome.canceled);
|
||||
if raised {
|
||||
self.after_block(outcome, None, false);
|
||||
let source = horizon_client::health::HurtSource {
|
||||
damage_type,
|
||||
causing: attacker.map_or(0, |(id, _)| *id as i32),
|
||||
direct: 0,
|
||||
};
|
||||
self.after_block(outcome, (None, false), source);
|
||||
}
|
||||
pushed
|
||||
}
|
||||
@@ -670,9 +675,18 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
self.tnts = tnts;
|
||||
// `VehicleEntity.ignoreExplosion`: a mob's blast (not a primed TNT's)
|
||||
// spares boats and minecarts while mob griefing is off.
|
||||
let vehicles_ignore = self.combat.blast_owner.is_none()
|
||||
&& self.blast_attacker().is_some()
|
||||
&& !self.ui.rules.bool("mob_griefing");
|
||||
// Minecarts: VehicleEntity.hurtServer (explosions ignite TNT carts),
|
||||
// then the push from their eyes (0.85 of their height).
|
||||
let ids: Vec<u32> = self.carts.iter().map(|c| c.id).collect();
|
||||
let ids: Vec<u32> = if vehicles_ignore {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.carts.iter().map(|c| c.id).collect()
|
||||
};
|
||||
for id in ids {
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
continue;
|
||||
@@ -698,11 +712,67 @@ impl Game<'_> {
|
||||
c.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
// Boats: `VehicleEntity.hurtServer` (damage * 10, broken past 40), then the
|
||||
// push from their eyes (`EntityType.OAK_BOAT & co`: the full 0.5625).
|
||||
let ids: Vec<u32> = if vehicles_ignore {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.boats.iter().map(|b| b.id).collect()
|
||||
};
|
||||
for id in ids {
|
||||
let Some(i) = self.boats.iter().position(|b| b.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let bounds = self.boats[i].bounds();
|
||||
let feet = self.boats[i].position;
|
||||
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
self.hurt_boat(i, damage(dist, exposure), false);
|
||||
let Some(b) = self.boats.iter_mut().find(|b| b.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let dir = direction([feet.x, feet.y + 0.5625, feet.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
b.velocity = Vec3::new(
|
||||
b.velocity.x + dir[0] * power,
|
||||
b.velocity.y + dir[1] * power,
|
||||
b.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
// Falling blocks: `Entity.hurtServer` does nothing, `pushFromExplosion`
|
||||
// moves them from their eyes (0.85 of the 0.98 box).
|
||||
let mut falling = std::mem::take(&mut self.falling);
|
||||
for f in &mut falling {
|
||||
let feet = f.position;
|
||||
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let b = horizon_client::falling_block::bounds(feet);
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
let eye = f64::from((hi.y - lo.y) as f32 * 0.85f32);
|
||||
let dir = direction([feet.x, feet.y + eye, feet.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
f.velocity = Vec3::new(
|
||||
f.velocity.x + dir[0] * power,
|
||||
f.velocity.y + dir[1] * power,
|
||||
f.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
self.falling = falling;
|
||||
// Arrows, fire charges, snowballs and the rest: `Entity.pushFromExplosion`
|
||||
// (`push`) from their eyes (0.85 of their height). `AbstractArrow.push`
|
||||
// does nothing while it is in the ground; `hurtServer` never hurts them.
|
||||
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
|
||||
for p in projectiles.iter_mut().filter(|p| !p.stuck) {
|
||||
for p in projectiles
|
||||
.iter_mut()
|
||||
.filter(|p| !p.stuck && !p.kind.ignores_blast())
|
||||
{
|
||||
let (lo, hi) = (p.bounds().min_position(), p.bounds().max_position());
|
||||
let dist = distance([p.position.x, p.position.y, p.position.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Foxes and dolphins on the game side: what they take from the ground (`Fox.pickUpItem`,
|
||||
//! `Fox.canHoldItem`, `Dolphin.pickUpItem`) and where `FoxSearchForItemsGoal` and
|
||||
//! `Dolphin.MoveToItemGoal` walk to.
|
||||
use super::{
|
||||
Game,
|
||||
pickups::{Collector, Taken},
|
||||
};
|
||||
use horizon_client::mobs::{
|
||||
Species, dolphin,
|
||||
equip::Slot,
|
||||
finalize::{self, set_drop_chance, set_item_stack},
|
||||
fox,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Fox.ALLOWED_ITEMS` and the looting block of `Mob.aiStep` for foxes, once a tick.
|
||||
pub(super) fn tick_foxes(&mut self) {
|
||||
let griefing = self.ui.rules.bool("mob_griefing");
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if m.species() != Species::Fox || !m.alive() || m.removed {
|
||||
continue;
|
||||
}
|
||||
// `Mob.aiStep`: the items in the box grown by (1, 0, 1), in order.
|
||||
if griefing && m.can_pick_up_loot() {
|
||||
let reach = m.bounds().inflate(Vec3::new(1.0, 0.0, 1.0));
|
||||
let mut j = 0;
|
||||
while j < self.dropped.len() {
|
||||
let e = &self.dropped[j];
|
||||
if e.pickup_delay > 0 || e.stack.count == 0 || !reach.intersects(e.bounds()) {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
let item_food = self.fox_food(&e.stack.item);
|
||||
let held_food = self.mobs[i]
|
||||
.extra
|
||||
.main_hand
|
||||
.as_deref()
|
||||
.is_some_and(|held| self.fox_food(held));
|
||||
if !fox::can_hold_item(&self.mobs[i], item_food, held_food) {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
// The entity is discarded: the next one is at `j` now.
|
||||
self.fox_pick_up(i, j);
|
||||
}
|
||||
}
|
||||
// `FoxSearchForItemsGoal`: the first allowed item within 8 blocks.
|
||||
let m = &self.mobs[i];
|
||||
let area = m.bounds().inflate(Vec3::new(8.0, 8.0, 8.0));
|
||||
let at = self
|
||||
.dropped
|
||||
.iter()
|
||||
.find(|e| e.pickup_delay == 0 && e.health > 0 && area.intersects(e.bounds()))
|
||||
.map(|e| [e.position.x, e.position.y, e.position.z]);
|
||||
fox::set_item_at(&mut self.mobs[i], at);
|
||||
}
|
||||
}
|
||||
/// `Dolphin.pickUpItem` in `Mob.aiStep`'s looting block, and the item
|
||||
/// `Dolphin.MoveToItemGoal` swims to (`Dolphin.ALLOWED_ITEMS`: no pickup delay, alive, in water).
|
||||
pub(super) fn tick_dolphins(&mut self) {
|
||||
let griefing = self.ui.rules.bool("mob_griefing");
|
||||
let in_water = |g: &Self, e: &horizon_client::entities::ItemEntity| {
|
||||
g.res
|
||||
.facts()
|
||||
.get(g.world.block(
|
||||
e.position.x.floor() as i32,
|
||||
e.position.y.floor() as i32,
|
||||
e.position.z.floor() as i32,
|
||||
))
|
||||
.fluid
|
||||
== horizon_client::blocks::Fluid::Water
|
||||
};
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if m.species() != Species::Dolphin || !m.alive() || m.removed {
|
||||
continue;
|
||||
}
|
||||
// `Mob.aiStep`: the items in the box grown by (1, 0, 1); `Dolphin.pickUpItem`
|
||||
// takes the whole stack when the mouth is empty (`Mob.canHoldItem` is true).
|
||||
if griefing && m.can_pick_up_loot() {
|
||||
let reach = m.bounds().inflate(Vec3::new(1.0, 0.0, 1.0));
|
||||
let mut j = 0;
|
||||
while j < self.dropped.len() {
|
||||
let e = &self.dropped[j];
|
||||
if e.pickup_delay > 0
|
||||
|| e.stack.count == 0
|
||||
|| !reach.intersects(e.bounds())
|
||||
|| self.mobs[i].extra.main_hand.is_some()
|
||||
{
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
let before = self.dropped[j].clone();
|
||||
self.mob_item_pickup_trigger(i, &before);
|
||||
let m = &mut self.mobs[i];
|
||||
set_item_stack(m, Slot::MainHand.index(), before.stack.clone());
|
||||
set_drop_chance(m, Slot::MainHand.index(), 2.0);
|
||||
let id = m.id;
|
||||
self.add_pickup(Taken::Item(Box::new(before)), Collector::Mob(id));
|
||||
self.remove_dropped(j);
|
||||
}
|
||||
}
|
||||
let area = self.mobs[i].bounds().inflate(Vec3::new(8.0, 8.0, 8.0));
|
||||
let at = self
|
||||
.dropped
|
||||
.iter()
|
||||
.find(|e| {
|
||||
e.pickup_delay == 0
|
||||
&& e.health > 0
|
||||
&& area.intersects(e.bounds())
|
||||
&& in_water(self, e)
|
||||
})
|
||||
.map(|e| [e.position.x, e.position.y, e.position.z]);
|
||||
dolphin::set_item_at(&mut self.mobs[i], at);
|
||||
}
|
||||
}
|
||||
/// `Fox.isConsumableFood`.
|
||||
fn fox_food(&self, item: &str) -> bool {
|
||||
self.items()
|
||||
.get(item)
|
||||
.is_some_and(|i| i.food.is_some() && i.consumable.is_some())
|
||||
}
|
||||
/// `Fox.pickUpItem`: the extra items drop where it stands, what it held is spat out
|
||||
/// ahead of it, and it holds one of the item.
|
||||
fn fox_pick_up(&mut self, fox_index: usize, item_index: usize) {
|
||||
let before = self.dropped[item_index].clone();
|
||||
let mut stack = before.stack.clone();
|
||||
let count = stack.count;
|
||||
let at = {
|
||||
let m = &self.mobs[fox_index];
|
||||
[m.position.x, m.position.y, m.position.z]
|
||||
};
|
||||
// `dropItemStack(itemStack.split(count - 1))`: a default ItemEntity at its feet.
|
||||
if count > 1 {
|
||||
let mut rest = stack.clone();
|
||||
rest.count = count - 1;
|
||||
stack.count = 1;
|
||||
self.spawn_item(rest, at, super::dispenser::new_item_motion(), 10);
|
||||
}
|
||||
// `spitOutItem(getItemBySlot(MAINHAND))`.
|
||||
let held = finalize::item_stack(&self.mobs[fox_index], Slot::MainHand.index());
|
||||
if let Some(held) = held {
|
||||
let look = self.mobs[fox_index].view_vector();
|
||||
let from = [at[0] + look[0], at[1] + 1.0, at[2] + look[2]];
|
||||
self.spawn_item(held, from, super::dispenser::new_item_motion(), 40);
|
||||
let id = self.mob_uuid(self.mobs[fox_index].id);
|
||||
if let Some(thrown) = self.just_spawned_item() {
|
||||
thrown.thrower = Some(id);
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:entity.fox.spit", Some(at), 1.0, 1.0);
|
||||
}
|
||||
// `onItemPickup`, then `setItemSlot(MAINHAND, split(1))`, `setGuaranteedDrop`.
|
||||
self.mob_item_pickup_trigger(fox_index, &before);
|
||||
let m = &mut self.mobs[fox_index];
|
||||
set_item_stack(m, Slot::MainHand.index(), stack);
|
||||
set_drop_chance(m, Slot::MainHand.index(), 2.0);
|
||||
fox::took_item(m);
|
||||
let id = m.id;
|
||||
// `take(entity, count)` and `entity.discard()`.
|
||||
self.add_pickup(Taken::Item(Box::new(before)), Collector::Mob(id));
|
||||
self.remove_dropped(item_index);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -15,7 +15,10 @@ impl Game<'_> {
|
||||
return self.dialog_has_text_field();
|
||||
}
|
||||
if let Some(title) = self.title.as_mut() {
|
||||
return title.focused_field().is_some();
|
||||
// A caret field, an append-only one (`focused_field`), or a game
|
||||
// rule's box (`has_caret_field` asks the rules screen itself, and
|
||||
// `title_edit` types into it).
|
||||
return title.has_caret_field() || title.focused_field().is_some();
|
||||
}
|
||||
if self.sign_edit.is_some() || matches!(self.book, Some(BookScreen::Edit { .. })) {
|
||||
return true;
|
||||
@@ -26,8 +29,12 @@ impl Game<'_> {
|
||||
.debug_options
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.search_focused()),
|
||||
// The language list's search, the settings search and the pack
|
||||
// screen's search (OptionsUi::typing).
|
||||
Screen::Options => self.ui.options.typing(),
|
||||
Screen::WorldOptions => self.world_options_typing(),
|
||||
Screen::Creative => self.creative_screen.tab().search,
|
||||
Screen::BlockEditor => self.block_editor.as_ref().is_some_and(|s| s.text_focused()),
|
||||
Screen::Inventory => {
|
||||
self.mod_browser_typing() || self.recipe_search_focused() || self.station_typing()
|
||||
}
|
||||
@@ -119,8 +126,10 @@ impl Game<'_> {
|
||||
Screen::CommandBlock => self.command_block_type(&text),
|
||||
Screen::Social => self.social_type(&text),
|
||||
Screen::DebugOptions => self.debug_options_type(&text),
|
||||
Screen::Options => self.ui.options.type_text(&self.gui.atlas, &text),
|
||||
Screen::WorldOptions => self.world_options_text(Some(&text)),
|
||||
Screen::Creative => self.creative_search_edit(Some(&text)),
|
||||
Screen::BlockEditor => self.block_editor_type(&text),
|
||||
Screen::Inventory if self.mod_browser_typing() => {
|
||||
self.mod_browser_text(Some(&text), false)
|
||||
}
|
||||
|
||||
@@ -2219,6 +2219,13 @@ impl Game<'_> {
|
||||
for d in &mut self.dropped {
|
||||
d.needs_sync = false;
|
||||
}
|
||||
// The step command reaches clients before the events its tick
|
||||
// generated, as ServerTickRateManager.updateStepTicks does.
|
||||
host.hub.sync_ticking(
|
||||
self.clock.rate(),
|
||||
self.clock.frozen(),
|
||||
self.clock.step_state(),
|
||||
);
|
||||
// `runBlockEvents`' broadcast precedes the tick's block changes
|
||||
// (`ChunkHolder.broadcastChanges` in `tick_level` below): the
|
||||
// guest re-runs a piston's `triggerEvent` while its level still
|
||||
|
||||
@@ -32,37 +32,8 @@ use horizon_protocol::{
|
||||
text::literal,
|
||||
};
|
||||
use horizon_world::entity_move::EntityMoveWorld;
|
||||
use std::collections::HashMap;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// Sets the viewer counts `known` holds for `dim` to `now` and returns the
|
||||
/// changes as `(pos, previous, viewers)`, in position order. A container that
|
||||
/// is no longer counted goes to 0 and leaves `known`.
|
||||
pub(super) fn viewer_changes(
|
||||
known: &mut HashMap<(&'static str, (i32, i32, i32)), u32>,
|
||||
dim: &'static str,
|
||||
mut now: HashMap<(i32, i32, i32), u32>,
|
||||
) -> Vec<((i32, i32, i32), u32, u32)> {
|
||||
let mut changes = Vec::new();
|
||||
known.retain(|&(d, pos), previous| {
|
||||
if d != dim {
|
||||
return true;
|
||||
}
|
||||
let viewers = now.remove(&pos).unwrap_or(0);
|
||||
if viewers != *previous {
|
||||
changes.push((pos, *previous, viewers));
|
||||
*previous = viewers;
|
||||
}
|
||||
viewers > 0
|
||||
});
|
||||
for (pos, viewers) in now {
|
||||
known.insert((dim, pos), viewers);
|
||||
changes.push((pos, 0, viewers));
|
||||
}
|
||||
changes.sort_unstable();
|
||||
changes
|
||||
}
|
||||
|
||||
/// Per-guest state of the gameplay code that lives outside `Guest`.
|
||||
#[derive(Default)]
|
||||
pub(super) struct GuestPlay {
|
||||
@@ -1395,27 +1366,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ContainerOpenersCounter` for the mod containers of the level in place:
|
||||
/// the players with each one open, recounted from their menus, so a
|
||||
/// change is heard whichever way a player left (`native_mod_container_viewers`).
|
||||
fn tick_mod_container_viewers(&mut self, host: &LanHost) {
|
||||
let mut now: HashMap<(i32, i32, i32), u32> = HashMap::new();
|
||||
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
|
||||
// `BarrelBlockEntity.startOpen`: a spectator does not open it.
|
||||
if !g.spectator()
|
||||
&& let Some(m) = &g.menu
|
||||
&& let Slots::Block(pos) = m.slots
|
||||
&& self.mod_menu_at(pos).is_some()
|
||||
{
|
||||
*now.entry(pos).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let dim = self.dim.id();
|
||||
for (pos, previous, viewers) in viewer_changes(&mut self.mod_container_viewers, dim, now) {
|
||||
self.native_mod_container_viewers(dim, [pos.0, pos.1, pos.2], viewers, previous);
|
||||
}
|
||||
}
|
||||
|
||||
/// `stillValid` of the guest's open container: its blocks are still there
|
||||
/// and in reach, or its vehicle exists and is in reach. True with none open.
|
||||
fn container_menu_valid(&self, g: &horizon_client::lan::guest::Guest) -> bool {
|
||||
@@ -3019,12 +2969,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
|
||||
@@ -3038,7 +2988,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());
|
||||
}
|
||||
}
|
||||
@@ -3305,7 +3256,6 @@ impl Game<'_> {
|
||||
self.lan_stations_tick(host);
|
||||
self.lan_tables_tick(host);
|
||||
self.lan_containers_tick(host);
|
||||
self.tick_mod_container_viewers(host);
|
||||
self.tick_lan_mount_menus(host);
|
||||
self.announce_guest_advancements(host);
|
||||
let conns: Vec<u32> = host
|
||||
@@ -3978,6 +3928,15 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
MotionEvent::Fall(event) => self.landing_block_updates(&event),
|
||||
// PowderSnowBlock.entityInside: every player around sees the
|
||||
// snowflake (`ServerLevel.sendParticles`).
|
||||
MotionEvent::Snowflake { position, velocity } => {
|
||||
super::lan_particles::send_snowflake_particle(
|
||||
host,
|
||||
[position.x, position.y, position.z],
|
||||
[velocity.x, velocity.y, velocity.z],
|
||||
);
|
||||
}
|
||||
// LivingEntity.baseTick's entity event 67, to every client
|
||||
// around, the guest's own included; this window draws it too.
|
||||
MotionEvent::Drown => {
|
||||
@@ -4096,15 +4055,8 @@ impl Game<'_> {
|
||||
if next % 10 != 0 {
|
||||
return;
|
||||
}
|
||||
if (next / 10) % 2 == 0
|
||||
&& !creative
|
||||
&& let Some(stack) = game.armor[1].clone()
|
||||
{
|
||||
// `ItemStack.hurtAndBreak`: Unbreaking can save the wear.
|
||||
let loss = game.wear(&stack, 1);
|
||||
if let Some(elytra) = game.armor[1].as_mut() {
|
||||
elytra.damage += loss;
|
||||
}
|
||||
if (next / 10) % 2 == 0 && !creative {
|
||||
game.wear_glider(1);
|
||||
}
|
||||
let ctx = game.player_context(None);
|
||||
game.post_game_event("minecraft:elytra_glide", at, ctx);
|
||||
|
||||
@@ -121,6 +121,39 @@ pub(super) fn send_soul_particle(host: &super::lan::LanHost, at: [f64; 3], veloc
|
||||
}
|
||||
}
|
||||
|
||||
/// `PowderSnowBlock.entityInside`'s `ServerLevel.sendParticles` of one
|
||||
/// snowflake (`count` 1, no spread, `RandomizationType.ALTERNATIVE`).
|
||||
pub(super) fn send_snowflake_particle(
|
||||
host: &super::lan::LanHost,
|
||||
at: [f64; 3],
|
||||
velocity: [f64; 3],
|
||||
) {
|
||||
let Some(id) = host
|
||||
.hub
|
||||
.registry_id("minecraft:particle_type", "minecraft:snowflake")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let packet = C::LevelParticles(play::LevelParticles {
|
||||
particle: Particle { id, data: None },
|
||||
override_limiter: false,
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: [0.0; 3],
|
||||
max_speed: velocity.map(|v| v as f32),
|
||||
count: 1,
|
||||
randomization: 1,
|
||||
});
|
||||
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
|
||||
let distance: f64 = (0..3)
|
||||
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
.sum();
|
||||
if distance < 32.0 * 32.0 {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `SonicBoom.tick` uses `ServerLevel.sendParticles` for each ray point.
|
||||
/// Keep the local event's rendering; connected players receive one at rest.
|
||||
|
||||
@@ -9,6 +9,7 @@ use super::{Game, lan::LanHost, lan_mobs::guest_bounds, mace::Spared, ray_box};
|
||||
|
||||
use horizon_client::{
|
||||
Result,
|
||||
health::HurtSource,
|
||||
mobs::Who,
|
||||
projectiles::{Hit, Projectile, ProjectileKind},
|
||||
text::Text,
|
||||
@@ -69,6 +70,15 @@ pub(super) struct Strike {
|
||||
item: Option<horizon_client::text::Text>,
|
||||
}
|
||||
|
||||
/// A player's blow on another player as the native hooks name it: the hit's
|
||||
/// own push and its damage name no attacker yet, so the extra and the sweep's
|
||||
/// pushes don't either.
|
||||
const PVP_SOURCE: HurtSource<'static> = HurtSource {
|
||||
damage_type: "minecraft:player_attack",
|
||||
causing: 0,
|
||||
direct: 0,
|
||||
};
|
||||
|
||||
/// A sweep attack's reach to the other players (`Player.doSweepAttack`),
|
||||
/// worked out as the attacker.
|
||||
#[derive(Clone)]
|
||||
@@ -355,7 +365,7 @@ impl Game<'_> {
|
||||
g.combat.player_attacker = None;
|
||||
let (strength, xd, zd) = s.knockback;
|
||||
if hurt && strength > 0.0 {
|
||||
g.knock_back_player(f64::from(strength), xd, zd);
|
||||
g.knock_back_player(f64::from(strength), xd, zd, PVP_SOURCE);
|
||||
}
|
||||
// The victim's thorns, dealt to the attacker below.
|
||||
let thorns = if hurt { g.thorns_damage() } else { Vec::new() };
|
||||
@@ -552,7 +562,7 @@ impl Game<'_> {
|
||||
g.note_named_attacker("minecraft:player_attack", *id, name.clone());
|
||||
}
|
||||
if g.mob_hits_player(sweep.damage, sweep.from, "minecraft:player_attack") {
|
||||
g.knock_back_player(0.4, xd, zd);
|
||||
g.knock_back_player(0.4, xd, zd, PVP_SOURCE);
|
||||
}
|
||||
};
|
||||
for (victim, id, team) in victims {
|
||||
|
||||
@@ -570,7 +570,7 @@ impl Game<'_> {
|
||||
if old_health > 0.0 && !self.mobs[i].alive() {
|
||||
self.native_kill_event(i);
|
||||
}
|
||||
self.mobs[i].knockback(0.4, xd, zd);
|
||||
self.mobs[i].knockback(0.4, xd, zd, &source);
|
||||
self.last_hurt_by_player
|
||||
.insert(self.mobs[i].id, self.lan_acting);
|
||||
self.post_attack(i);
|
||||
|
||||
@@ -532,10 +532,21 @@ impl Game<'_> {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `Entity.isVehicle` for a cart: the local player, a LAN guest or a mob
|
||||
/// sits in it. `getSlowdownFactor` (0.997) and the 0.75 speed scale key on
|
||||
/// this, not on who rides.
|
||||
fn cart_is_vehicle(&self, id: u32) -> bool {
|
||||
self.riding == Some(id)
|
||||
|| self.ridden_by_guest(id)
|
||||
|| self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| !m.removed && m.extra.mount == Some(id))
|
||||
}
|
||||
/// `OldMinecartBehavior.moveAlongTrack`.
|
||||
fn move_along_track(&mut self, c: &mut Minecart, pos: [i32; 3], rail: &Rail, intent: [f64; 2]) {
|
||||
c.fall_distance = 0.0;
|
||||
let ridden = Some(c.id) == self.riding;
|
||||
let ridden = self.cart_is_vehicle(c.id);
|
||||
let (x, z) = (c.position.x, c.position.z);
|
||||
let old = self.rail_pos(x, c.position.y, z);
|
||||
let mut y = f64::from(pos[1]);
|
||||
@@ -700,7 +711,7 @@ impl Game<'_> {
|
||||
/// recomputed per rail (slope, halt, boost).
|
||||
fn move_along_track_new(&mut self, c: &mut Minecart, _rail: &Rail, intent: [f64; 2]) {
|
||||
c.fall_distance = 0.0;
|
||||
let ridden = Some(c.id) == self.riding;
|
||||
let ridden = self.cart_is_vehicle(c.id);
|
||||
// `TrackIteration`: the slope, halt and boost speeds each apply once
|
||||
// per tick, and the iteration keeps moving while distance is left.
|
||||
let mut first = true;
|
||||
@@ -1730,6 +1741,8 @@ impl Game<'_> {
|
||||
let (damage, _, _, magic) = self.melee_damage(self.carts[i].kind.id(), false);
|
||||
self.hurt_cart(i, damage, self.creative, burning);
|
||||
self.vehicle_attack_sounds(area, magic.then_some(id));
|
||||
// Player.onAttack: the hit starts the attack strength again.
|
||||
self.attack_ticks = 0;
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -113,7 +113,8 @@ fn target_id(t: Target) -> Option<u32> {
|
||||
| Target::Cart(id)
|
||||
| Target::Other(id)
|
||||
| Target::Crystal(id)
|
||||
| Target::Dragon(id) => Some(id),
|
||||
| Target::Dragon(id)
|
||||
| Target::Tnt(id) => Some(id),
|
||||
Target::Player | Target::Guest(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,10 +292,11 @@ use horizon_client::{
|
||||
extras_hud::Corners,
|
||||
fast_hash::FxHashMap,
|
||||
first_person::Hand,
|
||||
health::{HurtSource, Hurting},
|
||||
health::{HurtSource, Hurting, Knock},
|
||||
hud::{HudBatch, rgba},
|
||||
items::ItemStack,
|
||||
keybinds,
|
||||
lan::menu::Slots,
|
||||
mobs::{Damage, Who},
|
||||
mod_list::{self, Installed, NativePage},
|
||||
music::{Music, MusicLayer},
|
||||
@@ -315,6 +316,7 @@ use horizon_world::{
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
@@ -2615,6 +2617,41 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (dimension, entity, kind, pos, loaded_from_disk, canceled);
|
||||
}
|
||||
/// Whether the experience orb `into` takes the orb `from` in (vanilla's
|
||||
/// `ExperienceOrb.canMerge`): the same value, and ids a multiple of 40 apart. Asked
|
||||
/// for each pair whose boxes meet, when an orb spawns (`tryMergeToExisting`: `from` is
|
||||
/// 0, the new orb isn't an entity yet, and only `from_value` is known) and every 20
|
||||
/// ticks as an orb scans for neighbours (`scanForMerges`). `can_merge` holds vanilla's
|
||||
/// answer; change it either way. A merged orb counts as `count` orbs of `into`'s
|
||||
/// value, so forcing orbs of different values together drops points unless the mod
|
||||
/// raises `into`'s value with `native_set_experience_orb` in `native_experience_orb_merge`.
|
||||
/// Server-side. API @API:experience_orb_merge@.
|
||||
pub(super) fn native_experience_orb_can_merge(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
into: u32,
|
||||
from: u32,
|
||||
into_value: u32,
|
||||
from_value: u32,
|
||||
can_merge: &mut bool,
|
||||
) {
|
||||
let _ = (dimension, into, from, into_value, from_value, can_merge);
|
||||
}
|
||||
/// The experience orb `into` is about to take `from` in, after
|
||||
/// `native_experience_orb_can_merge` said yes (`from` is 0 and `from_value` the
|
||||
/// points of a new orb that is spawning). Set `canceled` to stop it: both orbs stay, and
|
||||
/// a spawning orb tries the next orb in reach, then spawns on its own. Server-side.
|
||||
/// API @API:experience_orb_merge@.
|
||||
pub(super) fn native_experience_orb_merge(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
into: u32,
|
||||
from: u32,
|
||||
from_value: u32,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (dimension, into, from, from_value, canceled);
|
||||
}
|
||||
/// `player`'s melee blow killed the mob `entity` of `kind` (other deaths
|
||||
/// aren't told yet). It only hears, after the blow landed. Server-side.
|
||||
/// API 40.
|
||||
@@ -2774,6 +2811,51 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (entity, kind, damage_type, causing, direct, amount, canceled);
|
||||
}
|
||||
/// `LivingEntity.knockback` is about to push entity `entity` of type `kind`
|
||||
/// (`minecraft:zombie`, `minecraft:player`) `strength` away from the direction (`x`, `z`),
|
||||
/// after the entity's knockback resistance took its share and before its motion changes
|
||||
/// (docs/native-mods.md, API @API:living_knockback@). Vanilla's direction points at the
|
||||
/// source of a hit (the attacker's side), and the entity is pushed the other way. Change
|
||||
/// `strength`, `x` or `z`, or set `canceled`, and the push follows them: canceled, the
|
||||
/// entity's motion is left alone. A `strength` of 0 or less after the hook is no push, and a
|
||||
/// direction too short to tell draws a random one after it, as in vanilla. A value a mod
|
||||
/// leaves that is not a finite number is put back as it was. `damage_type`
|
||||
/// (`minecraft:player_attack`), `causing` (`DamageSource.getEntity`) and `direct`
|
||||
/// (`getDirectEntity`) are the push's `DamageSource`, named as `native_living_incoming_damage`
|
||||
/// names a hit's, with the same entity ids left at 0. It fires for the players and mobs the
|
||||
/// game knocks back: a hit's own push, a sprinting or Knockback hit, a sweep, a spear, a
|
||||
/// shield's push on its holder and a goat's ram. It does not fire for a sulfur cube with a
|
||||
/// block that a hit with a causing entity knocks back (`SulfurCube.knockback` does not call
|
||||
/// `super` there), for the pushes vanilla doesn't make with `LivingEntity.knockback` (the
|
||||
/// ender dragon's wings, Punch arrows, explosions, wind charges, a mace smash's area, a
|
||||
/// hoglin's throw), nor yet for the ones Horizon makes another way: a mob's melee push from
|
||||
/// its attack knockback or a Knockback weapon, an armor stand's, the ender dragon's. It runs
|
||||
/// on the thread that ticks the world (the server thread; the window's in-frame): state
|
||||
/// shared with render thread hooks needs synchronising. No `self`: the game is in the middle
|
||||
/// of the hit. API @API:living_knockback@.
|
||||
pub(super) fn native_living_knockback(
|
||||
entity: i32,
|
||||
kind: &str,
|
||||
damage_type: &str,
|
||||
causing: i32,
|
||||
direct: i32,
|
||||
strength: &mut f64,
|
||||
x: &mut f64,
|
||||
z: &mut f64,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (
|
||||
entity,
|
||||
kind,
|
||||
damage_type,
|
||||
causing,
|
||||
direct,
|
||||
strength,
|
||||
x,
|
||||
z,
|
||||
canceled,
|
||||
);
|
||||
}
|
||||
|
||||
/// `Block.stepOn` for a mod block, on its authoritative server level.
|
||||
/// No self: the simulation owns the entity during this callback.
|
||||
@@ -2813,6 +2895,31 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `native_living_knockback` for the knockback code (`health::KNOCKBACK`): whether it
|
||||
/// canceled the push. Its one caller. The pushed entity and the source are named as the
|
||||
/// damage hook names them.
|
||||
pub(super) fn knockback(pushed: Hurting<'_>, knock: &mut Knock) -> bool {
|
||||
let (entity, kind, source) = hurt_server_args(pushed);
|
||||
let HurtSource {
|
||||
damage_type,
|
||||
causing,
|
||||
direct,
|
||||
} = source;
|
||||
let mut canceled = false;
|
||||
Game::native_living_knockback(
|
||||
entity,
|
||||
kind,
|
||||
damage_type,
|
||||
causing,
|
||||
direct,
|
||||
&mut knock.strength,
|
||||
&mut knock.x,
|
||||
&mut knock.z,
|
||||
&mut canceled,
|
||||
);
|
||||
canceled
|
||||
}
|
||||
|
||||
pub(super) fn block_contact(
|
||||
dimension: &str,
|
||||
event: horizon_client::block_contact::Event<'_>,
|
||||
@@ -2905,6 +3012,34 @@ fn sound_id(id: &str) -> bool {
|
||||
&& path.chars().all(|c| allowed(c, true))
|
||||
}
|
||||
|
||||
/// Sets the viewer counts `known` holds for `dim` to `now` and returns the
|
||||
/// changes as `(pos, previous, viewers)`, in position order. A container that
|
||||
/// is no longer counted goes to 0 and leaves `known`.
|
||||
pub(super) fn viewer_changes(
|
||||
known: &mut HashMap<(&'static str, (i32, i32, i32)), u32>,
|
||||
dim: &'static str,
|
||||
mut now: HashMap<(i32, i32, i32), u32>,
|
||||
) -> Vec<((i32, i32, i32), u32, u32)> {
|
||||
let mut changes = Vec::new();
|
||||
known.retain(|&(d, pos), previous| {
|
||||
if d != dim {
|
||||
return true;
|
||||
}
|
||||
let viewers = now.remove(&pos).unwrap_or(0);
|
||||
if viewers != *previous {
|
||||
changes.push((pos, *previous, viewers));
|
||||
*previous = viewers;
|
||||
}
|
||||
viewers > 0
|
||||
});
|
||||
for (pos, viewers) in now {
|
||||
known.insert((dim, pos), viewers);
|
||||
changes.push((pos, 0, viewers));
|
||||
}
|
||||
changes.sort_unstable();
|
||||
changes
|
||||
}
|
||||
|
||||
fn dimension_id(dimension: LevelDimension) -> &'static str {
|
||||
match dimension {
|
||||
LevelDimension::Overworld => "minecraft:overworld",
|
||||
@@ -4311,6 +4446,44 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `ContainerOpenersCounter` for the mod containers of the level in place:
|
||||
/// everyone with one open, recounted every tick, so a change is heard
|
||||
/// whichever way an opener left (`native_mod_container_viewers`). The
|
||||
/// openers are the guests (but not a spectator, `BarrelBlockEntity.startOpen`),
|
||||
/// the host's own player, and the copper golems that opened it
|
||||
/// (`chest_openers`).
|
||||
pub(super) fn tick_mod_container_viewers(&mut self) {
|
||||
let mut now: HashMap<(i32, i32, i32), u32> = HashMap::new();
|
||||
let mut open = |game: &Self, pos: (i32, i32, i32), n: u32| {
|
||||
if n > 0 && game.mod_menu_at(pos).is_some() {
|
||||
*now.entry(pos).or_default() += n;
|
||||
}
|
||||
};
|
||||
if let Some(host) = &self.lan {
|
||||
for g in host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && !g.spectator())
|
||||
{
|
||||
if let Some(m) = &g.menu
|
||||
&& let Slots::Block(pos) = m.slots
|
||||
{
|
||||
open(self, pos, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(pos) = self.container.filter(|_| !self.spectator()) {
|
||||
open(self, pos, 1);
|
||||
}
|
||||
for (&pos, &(golems, _)) in &self.world.chest_openers {
|
||||
open(self, pos, golems.max(0) as u32);
|
||||
}
|
||||
let dim = self.dim.id();
|
||||
for (pos, previous, viewers) in viewer_changes(&mut self.mod_container_viewers, dim, now) {
|
||||
self.native_mod_container_viewers(dim, [pos.0, pos.1, pos.2], viewers, previous);
|
||||
}
|
||||
}
|
||||
/// The menu type of the mod container at `key`, if it has one.
|
||||
pub(super) fn mod_menu_at(&self, key: Pos) -> Option<&'static str> {
|
||||
let state = self.world.block(key.0, key.1, key.2).0;
|
||||
|
||||
@@ -817,6 +817,34 @@ impl Game<'_> {
|
||||
.filter(|o| orb_meets(o.position, min, max))
|
||||
.collect()
|
||||
}
|
||||
/// Writes the experience orb `id`'s `value` (the points a pickup gives), `count`
|
||||
/// (orbs merged into it, each worth `value`) and `age` (ticks lived; an orb
|
||||
/// despawns at 6000); a `None` is left as it is. API @API:experience_orb_merge@.
|
||||
/// False, and nothing written, for an orb not in the server level `dimension`, a
|
||||
/// `value` or `count` of 0, and on a client of a server.
|
||||
pub(super) fn native_set_experience_orb(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
id: u32,
|
||||
value: Option<u32>,
|
||||
count: Option<u32>,
|
||||
age: Option<u32>,
|
||||
) -> bool {
|
||||
if (self.serving.is_none() && self.options.dedicated.is_none())
|
||||
|| dimension != self.dim.id()
|
||||
|| value == Some(0)
|
||||
|| count == Some(0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(orb) = self.orbs.iter_mut().find(|o| o.id == id) else {
|
||||
return false;
|
||||
};
|
||||
orb.value = value.unwrap_or(orb.value);
|
||||
orb.count = count.unwrap_or(orb.count);
|
||||
orb.age = age.unwrap_or(orb.age);
|
||||
true
|
||||
}
|
||||
/// Gives `points` of experience to the player `uuid` as an orb they touch
|
||||
/// does (`ExperienceOrb.playerTouch`): Mending repairs their damaged items
|
||||
/// with it first (`repairPlayerItems`), then the rest goes to their
|
||||
|
||||
@@ -67,7 +67,24 @@ impl Game<'_> {
|
||||
layers.map(|(name, layer)| (name.as_str(), *layer))
|
||||
};
|
||||
let tags = pack.manifest.sprite_tags(sprites());
|
||||
let materials = pack.manifest.sprite_materials(sprites());
|
||||
let mut materials = pack.manifest.sprite_materials(sprites());
|
||||
// `blocks` lists: the sprites their models draw with, where no pattern named them.
|
||||
let listed = pack.manifest.material_blocks();
|
||||
if !listed.is_empty() {
|
||||
let macros = horizon_shaders::Macros::standard();
|
||||
let properties = horizon_shaders::Properties::parse(&listed, ¯os);
|
||||
let ids = horizon_shaders::BlockIds::parse(&properties);
|
||||
let table = super::shader_packs::block_id_table(&self.pack, &ids);
|
||||
for (layer, id) in table.iter().step_by(2).enumerate() {
|
||||
let layer = layer as u32;
|
||||
if *id > 0 && !materials.iter().any(|m| m.0 == layer) {
|
||||
materials.push((layer, *id as u32));
|
||||
}
|
||||
}
|
||||
materials.sort_unstable();
|
||||
}
|
||||
let layers = sprites().map(|(_, l)| l as usize + 1).max().unwrap_or(0);
|
||||
let translucent = pack.manifest.translucent_layers(&materials, layers);
|
||||
// Each light-giving block state's `[[lights]]` colour, with its level.
|
||||
let mut lights = horizon_client::gfx::block_light::LightTable::default();
|
||||
if !pack.manifest.lights.is_empty() {
|
||||
@@ -83,11 +100,11 @@ impl Game<'_> {
|
||||
let labels = renderer
|
||||
.set_native_shader_pack(Some((report, constants, tags, materials, lights)))
|
||||
.map_err(|e| vec![e])?;
|
||||
Ok((pack.manifest, labels))
|
||||
Ok((pack.manifest, labels, translucent))
|
||||
});
|
||||
// The resources were loaded without a native pack, so without the resource packs'
|
||||
// LabPBR maps its material stage reads: load them once, now that one runs.
|
||||
if result.as_ref().is_ok_and(|(m, _)| m.material.is_some())
|
||||
if result.as_ref().is_ok_and(|(m, _, _)| m.material.is_some())
|
||||
&& !horizon_client::sprites::labpbr_maps()
|
||||
{
|
||||
horizon_client::sprites::set_labpbr_maps(true);
|
||||
@@ -96,7 +113,8 @@ impl Game<'_> {
|
||||
}
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
match result {
|
||||
Ok((m, labels)) => {
|
||||
Ok((m, labels, translucent)) => {
|
||||
self.set_translucent_layers(translucent);
|
||||
log::info!(
|
||||
"shader pack {} {}: {} in {} ms",
|
||||
m.id,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Column reads for server mods, as helpers on the level being ticked: many columns' heights, and the blocks round
|
||||
//! them, in one call.
|
||||
use super::Game;
|
||||
use super::block_edits::MAX_COLUMNS;
|
||||
use super::native_world_edit::NativeHeightmap;
|
||||
|
||||
/// What `native_read_columns` read: the blocks round each column's height (`bases`, none where it isn't loaded),
|
||||
/// as indexes into `palette`, a column after another, and three biome tints a column (grass, foliage, water).
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub(crate) struct NativeColumnBlocks {
|
||||
pub(crate) palette: Vec<String>,
|
||||
pub(crate) bases: Vec<Option<i32>>,
|
||||
pub(crate) blocks: Vec<u16>,
|
||||
pub(crate) tints: Vec<u32>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// For a server mod: each column's height by `heightmap` in the authoritative level `dimension` (the one being
|
||||
/// ticked), none for a column not loaded. `Err` says why nothing was read: another dimension or a client of a
|
||||
/// server, or more than 65,536 columns (`MAX_COLUMNS`). API @API:native_read_columns@.
|
||||
pub(super) fn native_surface_heights(
|
||||
&self,
|
||||
dimension: &str,
|
||||
columns: &[(i32, i32)],
|
||||
heightmap: NativeHeightmap,
|
||||
) -> Result<Vec<Option<i32>>, String> {
|
||||
if !self.native_level_open(dimension) {
|
||||
return Err(format!("{dimension} is not the level being ticked"));
|
||||
}
|
||||
if columns.len() > MAX_COLUMNS {
|
||||
return Err(format!(
|
||||
"{} columns, more than {MAX_COLUMNS} a call",
|
||||
columns.len()
|
||||
));
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let out = self.surface_heights(columns, heightmap);
|
||||
self.native_read_logged(started, "height query", columns.len());
|
||||
Ok(out)
|
||||
}
|
||||
/// For a server mod: `below` blocks under and `above` blocks from each column's height by `heightmap`, as a
|
||||
/// palette and indexes, with the biome's grass, foliage and water tints (air outside the column and in columns
|
||||
/// not loaded). The tints are the biome's at the column's top, not blended over the biome blend radius as the
|
||||
/// game draws them: near a biome border they can differ from what players see. `Err` says why nothing was
|
||||
/// read: another dimension or a client of a server, `below + above` of 0 or more than the level's height, or
|
||||
/// a read of more than 1,048,576 blocks (`MAX_READ`; it runs on the server tick, so read big areas in pieces).
|
||||
/// API @API:native_read_columns@.
|
||||
pub(super) fn native_read_columns(
|
||||
&self,
|
||||
dimension: &str,
|
||||
columns: &[(i32, i32)],
|
||||
heightmap: NativeHeightmap,
|
||||
(below, above): (u32, u32),
|
||||
) -> Result<NativeColumnBlocks, String> {
|
||||
if !self.native_level_open(dimension) {
|
||||
return Err(format!("{dimension} is not the level being ticked"));
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let out = self.read_columns(columns, heightmap, below, above)?;
|
||||
self.native_read_logged(started, "column read", columns.len());
|
||||
Ok(out)
|
||||
}
|
||||
/// A read's time logged with `HORIZON_SPIKES`, as an edit's is.
|
||||
fn native_read_logged(&self, started: std::time::Instant, what: &str, columns: usize) {
|
||||
let ms = started.elapsed().as_secs_f32() * 1000.0;
|
||||
if self.options.spikes.is_some_and(|threshold| ms > threshold) {
|
||||
log::info!("Horizon: slow mod {what} {ms:.1} ms ({columns} columns)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Bulk block edits for server mods: a helper on the level being ticked, as `native_set_block` is, that makes many
|
||||
//! runs of blocks up columns at once, straight into the chunks.
|
||||
use super::Game;
|
||||
|
||||
/// Which of a column's heights (`Heightmap.Types`) a run's `y` counts from.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum NativeHeightmap {
|
||||
WorldSurface,
|
||||
MotionBlocking,
|
||||
MotionBlockingNoLeaves,
|
||||
OceanFloor,
|
||||
}
|
||||
/// A run: `height` blocks up a column from `y` (from the column's `surface` height when one is
|
||||
/// given), set to palette entry `block`, only where filter `only` matches when one is given.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct NativeBlockRun {
|
||||
pub(crate) x: i32,
|
||||
pub(crate) z: i32,
|
||||
pub(crate) y: i32,
|
||||
pub(crate) height: u32,
|
||||
pub(crate) surface: Option<NativeHeightmap>,
|
||||
pub(crate) block: u32,
|
||||
pub(crate) only: Option<u32>,
|
||||
}
|
||||
/// A bulk edit: block states and block predicates as commands write them (without NBT), and the runs naming them
|
||||
/// by index.
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub(crate) struct NativeBlockEdit {
|
||||
pub(crate) palette: Vec<String>,
|
||||
pub(crate) filters: Vec<String>,
|
||||
pub(crate) runs: Vec<NativeBlockRun>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// For a server mod: the runs made at once, straight into the chunks of the authoritative level `dimension`
|
||||
/// (the one being ticked, as for `native_set_block`), with what `fill ... strict` does for block entities (a
|
||||
/// block replaced by another loses its block entity, and a new block gets the one `native_set_block` would
|
||||
/// give it, a mod block's included); no neighbour is told, and the chunks changed go to the players whole.
|
||||
/// Runs in chunks not loaded, and the parts of runs outside the column, are left out. The blocks changed, or
|
||||
/// why the edit was left out, with nothing changed: another dimension, a client of a server, a palette entry
|
||||
/// or filter that doesn't read or carries NBT (refused), a run naming a palette entry or filter past the end,
|
||||
/// or more than 65536 filters. API @API:native_edit_blocks@.
|
||||
pub(super) fn native_edit_blocks(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
edit: &NativeBlockEdit,
|
||||
) -> Result<u64, String> {
|
||||
if !self.native_level_open(dimension) {
|
||||
return Err(format!("{dimension} is not the level being ticked"));
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let changed = self.edit_blocks(edit)?;
|
||||
let ms = started.elapsed().as_secs_f32() * 1000.0;
|
||||
if self.options.spikes.is_some_and(|threshold| ms > threshold) {
|
||||
log::info!(
|
||||
"Horizon: slow mod block edit {ms:.1} ms ({} runs, {changed} blocks)",
|
||||
edit.runs.len()
|
||||
);
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
/// Whether `dimension` is the authoritative level being ticked: where a server mod's world helpers act.
|
||||
pub(super) fn native_level_open(&self, dimension: &str) -> bool {
|
||||
(self.serving.is_some() || self.options.dedicated.is_some()) && dimension == self.dim.id()
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,14 @@ fn rotate_y(v: Vec3, degrees: f32) -> Vec3 {
|
||||
);
|
||||
Vec3::new(v.x * cos + v.z * sin, v.y, v.z * cos - v.x * sin)
|
||||
}
|
||||
/// Whether a projectile is an arrow, which goes through a portal like the
|
||||
/// other entities.
|
||||
fn is_arrow(p: &horizon_client::projectiles::Projectile) -> bool {
|
||||
matches!(
|
||||
p.kind,
|
||||
horizon_client::projectiles::ProjectileKind::Arrow { .. }
|
||||
)
|
||||
}
|
||||
fn doubles(values: [f64; 3]) -> Tag {
|
||||
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
|
||||
}
|
||||
@@ -657,6 +665,9 @@ impl Game<'_> {
|
||||
for c in &self.carts {
|
||||
self.portals.before.insert(c.id, c.position);
|
||||
}
|
||||
for t in &self.tnts {
|
||||
self.portals.before.insert(t.id, t.position);
|
||||
}
|
||||
// Only an entity in a portal, or cooling down from one, has a state
|
||||
// here: whether the others could use a portal is not worth asking
|
||||
// for each of them on every tick.
|
||||
@@ -673,12 +684,22 @@ impl Game<'_> {
|
||||
ids.push((m.id, self.mob_can_use_portal(m)));
|
||||
}
|
||||
}
|
||||
for t in &self.tnts {
|
||||
if tracked.contains_key(&t.id) {
|
||||
ids.push((t.id, self.tnt_can_use_portal(t.id)));
|
||||
}
|
||||
}
|
||||
let vehicles = self.boats.iter().map(|b| b.id);
|
||||
for id in vehicles.chain(self.carts.iter().map(|c| c.id)) {
|
||||
if tracked.contains_key(&id) {
|
||||
ids.push((id, self.vehicle_can_use_portal(id)));
|
||||
}
|
||||
}
|
||||
for p in &self.creatures.projectiles {
|
||||
if tracked.contains_key(&p.id) && is_arrow(p) {
|
||||
ids.push((p.id, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
|
||||
self.portals.entities.retain(|id, _| present.contains(id));
|
||||
@@ -752,6 +773,11 @@ impl Game<'_> {
|
||||
}
|
||||
/// `canUsePortal(false)` of a boat or minecart: empty, or ridden by
|
||||
/// server players only, whom it carries along.
|
||||
/// `Entity.canUsePortal(false)` for a primed TNT: it carries no mob.
|
||||
fn tnt_can_use_portal(&self, id: u32) -> bool {
|
||||
// Id 0: not tracked yet (the in-frame path), nothing to key its portal state by.
|
||||
id != 0 && !self.mobs.iter().any(|m| m.extra.mount == Some(id))
|
||||
}
|
||||
fn vehicle_can_use_portal(&self, id: u32) -> bool {
|
||||
if !self.vehicle_occupied(id) {
|
||||
return true;
|
||||
@@ -799,8 +825,8 @@ impl Game<'_> {
|
||||
|| self.mobs.iter().any(|m| m.extra.mount == Some(id))
|
||||
}
|
||||
/// `NetherPortalBlock.entityInside` and `EndPortalBlock.entityInside`
|
||||
/// for the items, mobs and empty boats and minecarts that moved this
|
||||
/// tick: the portal blocks their box swept through (an End portal's
|
||||
/// for the items, mobs, empty boats and minecarts, and arrows that moved
|
||||
/// this tick: the portal blocks their box swept through (an End portal's
|
||||
/// transition time is 0 too).
|
||||
pub(super) fn find_entity_portals(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
@@ -812,6 +838,13 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.map(|e| (e.id, e.position, 0.25, 0.25))
|
||||
.collect();
|
||||
// PrimedTnt is 0.98 across; one that carries a mob stays (the rider would be left behind).
|
||||
let tnts: Vec<(u32, Vec3, f32, f32)> = self
|
||||
.tnts
|
||||
.iter()
|
||||
.filter(|t| self.tnt_can_use_portal(t.id))
|
||||
.map(|t| (t.id, t.position, 0.98, 0.98))
|
||||
.collect();
|
||||
let mobs: Vec<(u32, Vec3, f32, f32)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
@@ -834,12 +867,31 @@ impl Game<'_> {
|
||||
.map(|(id, at, w, h)| (id, at, w as f32, h as f32))
|
||||
.collect();
|
||||
let mut portal_sections = std::mem::take(&mut self.portals.sections);
|
||||
for (id, to, width, height) in items.into_iter().chain(mobs).chain(vehicles) {
|
||||
for (id, to, width, height) in items.into_iter().chain(tnts).chain(mobs).chain(vehicles) {
|
||||
let from = self.portals.before.get(&id).copied().unwrap_or(to);
|
||||
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
|
||||
inside.push((id, pos));
|
||||
}
|
||||
}
|
||||
// The arrow's `previous` is where it stood at the start of its tick,
|
||||
// which is also where an arrow shot this tick began. A stuck arrow
|
||||
// that did not move this tick enters no block.
|
||||
for p in self
|
||||
.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.filter(|p| is_arrow(p) && (!p.stuck || p.previous != p.position))
|
||||
{
|
||||
let (width, height) = (
|
||||
horizon_client::arrows::WIDTH,
|
||||
horizon_client::arrows::HEIGHT,
|
||||
);
|
||||
if let Some(pos) =
|
||||
self.swept_portal(p.previous, p.position, width, height, &mut portal_sections)
|
||||
{
|
||||
inside.push((p.id, pos));
|
||||
}
|
||||
}
|
||||
self.portals.sections = portal_sections;
|
||||
for (id, pos) in inside {
|
||||
self.portals
|
||||
@@ -957,6 +1009,26 @@ impl Game<'_> {
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.tnts.iter().position(|t| t.id == id) {
|
||||
let t = &self.tnts[i];
|
||||
let plan = self.entity_plan(entry, t.position, 0.98, 0.98, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
let uuid = self.uuid_for(id);
|
||||
let t = &self.tnts[i];
|
||||
let tag = horizon_client::entity_nbt::tnt_to_nbt(
|
||||
t.position,
|
||||
t.velocity,
|
||||
t.on_ground,
|
||||
t.fuse,
|
||||
uuid,
|
||||
self.persist_raw(id),
|
||||
);
|
||||
self.tnts.remove(i);
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
|
||||
let m = &self.mobs[i];
|
||||
let plan = self.entity_plan(entry, m.position, m.width(), m.height(), true);
|
||||
@@ -1026,6 +1098,41 @@ impl Game<'_> {
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self
|
||||
.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.position(|p| p.id == id && is_arrow(p))
|
||||
{
|
||||
let p = &self.creatures.projectiles[i];
|
||||
let (width, height) = (
|
||||
horizon_client::arrows::WIDTH,
|
||||
horizon_client::arrows::HEIGHT,
|
||||
);
|
||||
let plan = self.entity_plan(entry, p.position, width, height, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
let uuid = self.uuid_for(id);
|
||||
let p = &self.creatures.projectiles[i];
|
||||
let in_block = p
|
||||
.arrow
|
||||
.as_ref()
|
||||
.and_then(|a| a.last_state)
|
||||
.map(|s| self.state_to_nbt(s));
|
||||
let Some(tag) = horizon_client::entity_nbt::arrow_to_nbt(
|
||||
p,
|
||||
uuid,
|
||||
p.owner_uuid,
|
||||
in_block,
|
||||
self.persist_raw(id),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
self.creatures.projectiles.remove(i);
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.dirty = true;
|
||||
}
|
||||
}
|
||||
/// The chunks of the arrivals still waiting to land in `dim`, for the
|
||||
|
||||
@@ -471,6 +471,9 @@ impl Game<'_> {
|
||||
s.horizon.taa,
|
||||
s.horizon.upscaling.ratio(),
|
||||
);
|
||||
r.set_frame_generation(
|
||||
s.horizon.frame_generation != horizon_client::graphics::FrameGeneration::Off,
|
||||
);
|
||||
r.set_hdr(
|
||||
s.horizon.hdr,
|
||||
s.horizon.paper_white_nits() as f32,
|
||||
|
||||
@@ -373,6 +373,11 @@ impl Game<'_> {
|
||||
a.last_state = in_block.and_then(|t| self.state_from_nbt(&t));
|
||||
}
|
||||
self.claim_owner(&mut p);
|
||||
// An arrow that came through a portal keeps its
|
||||
// cooldown: it does not go straight back through.
|
||||
self.next_entity += 1;
|
||||
p.id = self.next_entity;
|
||||
self.restore_portal_cooldown(p.id, &tag);
|
||||
self.add_projectile(p);
|
||||
}
|
||||
}
|
||||
@@ -453,6 +458,8 @@ impl Game<'_> {
|
||||
owner: None,
|
||||
guest: None,
|
||||
});
|
||||
// `Entity.load`: the `PortalCooldown` of one that just came through.
|
||||
self.remember(id, uuid, tag);
|
||||
for rider in riders {
|
||||
self.load_mob(rider, Some(id));
|
||||
}
|
||||
|
||||
@@ -50,12 +50,7 @@ impl Game<'_> {
|
||||
// hears it, and the replay follows, but nothing counts.
|
||||
let waiting = g.awaiting_teleport.is_some();
|
||||
// The server's copy of a guest doesn't follow its armor (Game.glider_worn).
|
||||
let glider = g.armor[1].as_ref().is_some_and(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.get(&s.item)
|
||||
.is_some_and(|i| i.glider && (i.max_damage == 0 || s.damage + 1 < i.max_damage))
|
||||
});
|
||||
let glider = super::gliding_pieces(self.res.items(), &g.armor).contains(&true);
|
||||
if let Some(motion) = g.motion.as_deref_mut() {
|
||||
motion.glider = glider;
|
||||
motion.item_use_speed = item_use.map(|(speed, _)| speed);
|
||||
|
||||
@@ -5484,19 +5484,21 @@ impl Game<'_> {
|
||||
self.tick_breeze_effects();
|
||||
}
|
||||
self.tick_remote_guardian_eyes(running);
|
||||
// ClientLevel.tickBlockEntities: chest lids, shulker boxes, bells,
|
||||
// and the moving pistons a block event's client-side moveBlocks made
|
||||
// (their progress advances between the server's block updates).
|
||||
self.tick_block_entity_anims();
|
||||
self.tick_remote_spawners();
|
||||
self.client_tick_beacons();
|
||||
self.tick_remote_potent_sulfur();
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.client_tick_block_entities(level, ticks);
|
||||
});
|
||||
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
|
||||
self.push_piston_entities(pushes);
|
||||
self.sync_moving_pistons();
|
||||
// Level.tickBlockEntities: a frozen level ticks no block entities.
|
||||
// A step advances the moving pistons created by the block event once,
|
||||
// rather than finishing them during the frozen ticks between steps.
|
||||
if running {
|
||||
self.tick_block_entity_anims();
|
||||
self.tick_remote_spawners();
|
||||
self.client_tick_beacons();
|
||||
self.tick_remote_potent_sulfur();
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.client_tick_block_entities(level, ticks);
|
||||
});
|
||||
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
|
||||
self.push_piston_entities(pushes);
|
||||
self.sync_moving_pistons();
|
||||
}
|
||||
self.tick_hand();
|
||||
if running {
|
||||
self.tick_particles();
|
||||
@@ -7029,6 +7031,18 @@ impl Game<'_> {
|
||||
} else {
|
||||
(result, changes)
|
||||
};
|
||||
// AbstractCauldronBlock.useItemOn, either hand: CauldronInteractions
|
||||
// answers on the client as on the server and only the server acts,
|
||||
// so a bucket the cauldron takes is not used again (UseItem).
|
||||
let result = if result.consumes_action() || spectator {
|
||||
result
|
||||
} else {
|
||||
match self.cauldron_outcome(hit.pos, held.as_ref()) {
|
||||
horizon_client::cauldron::Outcome::Success => InteractionResult::Success,
|
||||
horizon_client::cauldron::Outcome::Consume => InteractionResult::Consume,
|
||||
horizon_client::cauldron::Outcome::Pass => result,
|
||||
}
|
||||
};
|
||||
let reacts = result.consumes_action();
|
||||
// useItemOn on this client, unless a sneak with something in hand
|
||||
// skips it: RedStoneOreBlock's dust, and the wick smoke of a lit
|
||||
|
||||
@@ -282,7 +282,14 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let (x, z) = (p.x, p.z);
|
||||
self.streaming.insert_remote(&mut self.world, chunk);
|
||||
// A column sent again while it is resident (a bulk edit): only what changed is lit and meshed again.
|
||||
if self.world.chunks.contains_key(&(x, z)) {
|
||||
horizon_client::spans::time("replace_remote", || {
|
||||
self.streaming.replace_remote(&mut self.world, chunk)
|
||||
});
|
||||
} else {
|
||||
self.streaming.insert_remote(&mut self.world, chunk);
|
||||
}
|
||||
self.world
|
||||
.block_entities
|
||||
.retain(|k, _| (k.0 >> 4, k.2 >> 4) != (x, z));
|
||||
|
||||
@@ -161,6 +161,17 @@ pub(super) fn remote_projectile_tick(p: &mut Projectile, w: &impl ProjectileWorl
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.getRandomX(0.5)` / `getRandomY` / `getRandomZ(0.5)` for a shulker
|
||||
/// bullet (`EntityType.SHULKER_BULLET` is 0.3125 x 0.3125).
|
||||
fn shulker_bullet_trail_at(at: Vec3, random: &mut impl RandomSource) -> [f64; 3] {
|
||||
const SIZE: f64 = 0.3125;
|
||||
[
|
||||
at.x + SIZE * (2.0 * random.next_double() - 1.0) * 0.5,
|
||||
at.y + SIZE * random.next_double(),
|
||||
at.z + SIZE * (2.0 * random.next_double() - 1.0) * 0.5,
|
||||
]
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleProjectilePowerPacket`: the power a hurting
|
||||
/// projectile (`AbstractHurtingProjectile`) accelerates by, found by id;
|
||||
/// anything else is ignored.
|
||||
@@ -268,6 +279,25 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ShulkerBullet.tick` on a client: two `END_ROD` particles in its
|
||||
/// 0.3125 box (`getRandomX(0.5)`, `getRandomY`, `getRandomZ(0.5)`).
|
||||
pub(super) fn shulker_bullet_trails(&mut self) {
|
||||
let bullets: Vec<Vec3> = self
|
||||
.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.filter(|p| matches!(p.kind, ProjectileKind::ShulkerBullet { .. }))
|
||||
.map(|p| p.position)
|
||||
.collect();
|
||||
let options = ParticleOptions::simple(ParticleType::EndRod);
|
||||
for at in bullets {
|
||||
for _ in 0..2 {
|
||||
let p = shulker_bullet_trail_at(at, self.particles.random());
|
||||
self.particles.add(&options, p, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `handleAddEntity` for these types; false when `kind` isn't one.
|
||||
pub(super) fn spawn_remote_projectile(
|
||||
&mut self,
|
||||
@@ -615,6 +645,7 @@ impl Game<'_> {
|
||||
.map(|p| p.position)
|
||||
.collect();
|
||||
self.smoke_trails(trails);
|
||||
self.shulker_bullet_trails();
|
||||
// FireworkRocketEntity.tick: a rocket attached to an entity follows it
|
||||
// and boosts it client-side too (attachedToEntity.isFallFlying), and
|
||||
// every rocket leaves its spark trail on a client.
|
||||
@@ -751,7 +782,13 @@ impl Game<'_> {
|
||||
out.push(json!({"id": t.id, "kind": "minecraft:tnt", "position": at(t.position), "fuse": t.fuse}));
|
||||
}
|
||||
for f in &self.falling {
|
||||
out.push(json!({"id": f.id, "kind": "minecraft:falling_block", "position": at(f.position), "state": f.state.0}));
|
||||
out.push(json!({"id": f.id, "kind": "minecraft:falling_block", "position": at(f.position), "velocity": at(f.velocity), "state": f.state.0}));
|
||||
}
|
||||
for b in &self.boats {
|
||||
out.push(json!({"id": b.id, "kind": "minecraft:boat", "position": at(b.position), "velocity": at(b.velocity)}));
|
||||
}
|
||||
for c in &self.carts {
|
||||
out.push(json!({"id": c.id, "kind": "minecraft:minecart", "position": at(c.position), "velocity": at(c.velocity)}));
|
||||
}
|
||||
for c in &self.crystals {
|
||||
out.push(json!({"id": c.id, "kind": "minecraft:end_crystal", "position": at(c.position), "show_bottom": c.show_bottom}));
|
||||
@@ -808,6 +845,18 @@ mod tests {
|
||||
Projectile { id, ..thrown(kind) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shulker_bullet_trail_stays_inside_its_box() {
|
||||
let at = Vec3::new(10.0, 20.0, 30.0);
|
||||
let mut random = horizon_core::random::LegacyRandom::new(1);
|
||||
for _ in 0..32 {
|
||||
let [x, y, z] = shulker_bullet_trail_at(at, &mut random);
|
||||
assert!((x - at.x).abs() <= 0.3125 * 0.5 + 1e-12, "{x}");
|
||||
assert!((0.0..0.3125 + 1e-12).contains(&(y - at.y)), "{y}");
|
||||
assert!((z - at.z).abs() <= 0.3125 * 0.5 + 1e-12, "{z}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thrown_item_flies_on_between_the_server_updates() {
|
||||
// A thrown egg, an ender pearl and a wind charge (#1762): the server
|
||||
|
||||
@@ -322,6 +322,10 @@ impl Game<'_> {
|
||||
for i in 0..self.boats.len() {
|
||||
let driven = self.riding == Some(self.boats[i].id);
|
||||
if !driven {
|
||||
// `AbstractBoat.tick`: the non-authoritative copy carries no
|
||||
// cached motion into the next time this player drives it.
|
||||
self.boats[i].velocity = Vec3::ZERO;
|
||||
self.boats[i].delta_rotation = 0.0;
|
||||
self.refresh_boat_status(i);
|
||||
}
|
||||
let touches = self.boat_bubble_columns(i);
|
||||
|
||||
@@ -179,6 +179,7 @@ impl Game<'_> {
|
||||
| Target::Cart(id)
|
||||
| Target::Crystal(id)
|
||||
| Target::Dragon(id)
|
||||
| Target::Tnt(id)
|
||||
| Target::Other(id) => Some(uuid_text(self.uuid_for(id))),
|
||||
// Orbs and projectiles have no UUID here.
|
||||
Target::Orb(_) | Target::Projectile(_) => None,
|
||||
@@ -211,6 +212,7 @@ impl Game<'_> {
|
||||
| Target::Painting(id)
|
||||
| Target::Boat(id)
|
||||
| Target::Cart(id)
|
||||
| Target::Tnt(id)
|
||||
| Target::Other(id) => self.persist.raw.get(&id),
|
||||
Target::Player => self.level.player_extra.as_ref(),
|
||||
Target::Guest(_)
|
||||
|
||||
@@ -325,7 +325,7 @@ impl Game<'_> {
|
||||
batch.sprite_scaled(atlas, sprite, x + 3.0, y + 3.0, 15.0, 15.0, u32::MAX);
|
||||
if hover {
|
||||
tooltip = Some(if i == FRIENDS_ICON {
|
||||
Text::literal(horizon_client::friends_screen::TITLE)
|
||||
Text::literal(horizon_client::friends_screen::title())
|
||||
} else {
|
||||
Text::translate(ICON_LABELS[i], &[])
|
||||
});
|
||||
|
||||
@@ -158,10 +158,11 @@ impl Game<'_> {
|
||||
self.streaming.shader_pack_lighting = pack_lighting;
|
||||
self.all_sections_changed();
|
||||
}
|
||||
let picked = picked_pack(&self.settings);
|
||||
let picked = picked_pack(&self.settings).to_owned();
|
||||
if self.native_shaders.picked != picked {
|
||||
// Another pack, or none: drop what ran.
|
||||
self.native_shaders = super::native_shaders::NativeShaders::picking(picked);
|
||||
self.native_shaders = super::native_shaders::NativeShaders::picking(&picked);
|
||||
self.set_translucent_layers(Vec::new());
|
||||
self.pack_ids = None;
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
renderer.drop_shader_packs();
|
||||
@@ -170,7 +171,7 @@ impl Game<'_> {
|
||||
if picked.is_empty() {
|
||||
return PackInputs::default();
|
||||
}
|
||||
let name = picked.to_owned();
|
||||
let name = picked;
|
||||
let dimension = match self.dim {
|
||||
Dim::Overworld => 0,
|
||||
Dim::Nether => -1,
|
||||
@@ -533,7 +534,7 @@ fn name_id(name: &str) -> i32 {
|
||||
/// their sprite layer, not their block. A layer takes the id of the block named like its sprite
|
||||
/// (`block/oak_leaves` for `oak_leaves`), else the id most blocks drawing it share (ties: the
|
||||
/// lowest); -1 when no block drawing it has one. y is 1 for the fluid sprites, -1 otherwise.
|
||||
fn block_id_table(pack: &super::PackResources, ids: &BlockIds) -> Vec<i32> {
|
||||
pub(super) fn block_id_table(pack: &super::PackResources, ids: &BlockIds) -> Vec<i32> {
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
let layers = pack
|
||||
.render
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! the shield (`Player.blockUsingItem`, `BlocksAttacks.disable`).
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
health::{BlockOutcome, Blocking},
|
||||
health::{BlockOutcome, Blocking, HurtSource},
|
||||
items::ItemStack,
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
@@ -114,12 +114,12 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
/// After a hurt with the shield raised: `hurtBlockingItem`, `onBlocked`
|
||||
/// and, for melee from a mob, `blockUsingItem`.
|
||||
/// and, for melee from a mob, `blockUsingItem`. `source` is the hit's.
|
||||
pub(super) fn after_block(
|
||||
&mut self,
|
||||
outcome: BlockOutcome,
|
||||
attacker: Option<u32>,
|
||||
projectile: bool,
|
||||
(attacker, projectile): (Option<u32>, bool),
|
||||
source: HurtSource<'_>,
|
||||
) {
|
||||
let Some((slot, _)) = self.using else { return };
|
||||
let Some(stack) = self.stack_in(slot).cloned() else {
|
||||
@@ -185,7 +185,8 @@ impl Game<'_> {
|
||||
// Another player's melee hit has no mob behind it.
|
||||
if let Some((from, disable)) = self.combat.player_attacker {
|
||||
let fully = outcome.fully_blocked;
|
||||
self.blocked_by_attacker(&component, [from[0], from[2]], fully, disable, at);
|
||||
let attacker = ([from[0], from[2]], disable);
|
||||
self.blocked_by_attacker(&component, attacker, fully, at, source);
|
||||
}
|
||||
return;
|
||||
};
|
||||
@@ -231,36 +232,26 @@ impl Game<'_> {
|
||||
let _ = self.handle_mob_events(events);
|
||||
return;
|
||||
}
|
||||
self.blocked_by_attacker(&component, [mx, mz], fully, disable, at);
|
||||
self.blocked_by_attacker(&component, ([mx, mz], disable), fully, at, source);
|
||||
}
|
||||
|
||||
/// What a blocked melee hit does to the defender once the shield took
|
||||
/// it: `LivingEntity.blockedByItem` knocks them back unless fully
|
||||
/// blocked, then `Player.blockUsingItem` disables the shield.
|
||||
/// blocked, then `Player.blockUsingItem` disables the shield. `attacker`
|
||||
/// is where the attacker stands (x, z) and its seconds of disabling.
|
||||
fn blocked_by_attacker(
|
||||
&mut self,
|
||||
component: &horizon_client::items::BlocksAttacks,
|
||||
attacker: [f64; 2],
|
||||
([mx, mz], disable): ([f64; 2], f32),
|
||||
fully: bool,
|
||||
disable: f32,
|
||||
at: Option<[f64; 3]>,
|
||||
source: HurtSource<'_>,
|
||||
) {
|
||||
let [mx, mz] = attacker;
|
||||
// LivingEntity.blockedByItem: defender.knockback(0.5, defender - attacker),
|
||||
// which moves the defender along -(xd, zd).
|
||||
if !fully && let Some(p) = self.player.as_mut() {
|
||||
// LivingEntity.blockedByItem: defender.knockback(0.5, defender - attacker,
|
||||
// source), which moves the defender along -(xd, zd).
|
||||
if !fully && let Some(p) = self.player.as_ref() {
|
||||
let (xd, zd) = (p.position.x - mx, p.position.z - mz);
|
||||
let len = (xd * xd + zd * zd).sqrt();
|
||||
if len >= 1.0e-5 {
|
||||
let (dx, dz) = (xd / len * 0.5, zd / len * 0.5);
|
||||
let v = p.velocity;
|
||||
let y = if p.ground.on_ground {
|
||||
(v.y / 2.0 + 0.5).min(0.4)
|
||||
} else {
|
||||
v.y
|
||||
};
|
||||
p.velocity = horizon_core::geometry::Vec3::new(v.x / 2.0 - dx, y, v.z / 2.0 - dz);
|
||||
}
|
||||
self.knock_back_player(0.5, xd, zd, source);
|
||||
}
|
||||
// BlocksAttacks.disable: item cooldown and stopUsingItem.
|
||||
let ticks = disable * component.disable_scale;
|
||||
|
||||
@@ -227,9 +227,10 @@ impl Game<'_> {
|
||||
// causeExtraKnockback: a push of 0.4, then the weapon's Knockback.
|
||||
if deals_knockback {
|
||||
let (xd, zd) = self.extra_knockback_direction();
|
||||
self.mobs[index].knockback(0.4, xd, zd);
|
||||
let source = Damage::spear(from);
|
||||
self.mobs[index].knockback(0.4, xd, zd, &source);
|
||||
let (strength, _) = self.extra_knockback(&kind, 0.0);
|
||||
self.mobs[index].knockback(f64::from(strength), xd, zd);
|
||||
self.mobs[index].knockback(f64::from(strength), xd, zd, &source);
|
||||
// ...and the attacker stops sprinting.
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
|
||||
@@ -70,6 +70,15 @@ impl PrimedTnt {
|
||||
if moved.y != wanted.y {
|
||||
self.velocity.y = 0.0;
|
||||
}
|
||||
// Entity.applyEffectsFromBlocks: a bubble column lifts or drags it.
|
||||
horizon_client::motion::bubble_column_push(
|
||||
world,
|
||||
assets.registry,
|
||||
facts,
|
||||
collision,
|
||||
self.bounds(),
|
||||
&mut self.velocity,
|
||||
);
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x * 0.98,
|
||||
self.velocity.y * 0.98,
|
||||
@@ -158,8 +167,10 @@ impl Game<'_> {
|
||||
f64::from(pos.y),
|
||||
f64::from(pos.z) + 0.5,
|
||||
);
|
||||
// An id from the entity counter at once, so a command can name it.
|
||||
self.next_entity += 1;
|
||||
self.tnts.push(PrimedTnt {
|
||||
id: 0,
|
||||
id: self.next_entity,
|
||||
position,
|
||||
previous: position,
|
||||
velocity: Vec3::new(-rot.sin() * 0.02, f64::from(0.2f32), -rot.cos() * 0.02),
|
||||
@@ -613,7 +624,7 @@ mod tests {
|
||||
}
|
||||
world.set(20, 4, 20, water).unwrap();
|
||||
world.set(21, 4, 20, flowing).unwrap();
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
let at = Vec3::new(20.5, 6.0, 20.5);
|
||||
let mut tnt = PrimedTnt {
|
||||
id: 0,
|
||||
position: at,
|
||||
@@ -672,4 +683,74 @@ mod tests {
|
||||
"TNT sinks; water does not add buoyancy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_soul_sand_bubble_column_lifts_primed_tnt() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = horizon_client::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = horizon_client::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let collision = horizon_client::collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap();
|
||||
let water = assets.registry.default_state("minecraft:water").unwrap();
|
||||
let soul_sand = assets
|
||||
.registry
|
||||
.default_state("minecraft:soul_sand")
|
||||
.unwrap();
|
||||
let column = assets
|
||||
.registry
|
||||
.default_state("minecraft:bubble_column")
|
||||
.unwrap();
|
||||
for y in 3..=8 {
|
||||
world.set(20, y, 20, column).unwrap();
|
||||
}
|
||||
world.set(20, 2, 20, soul_sand).unwrap();
|
||||
world.set(20, 9, 20, water).unwrap();
|
||||
// BubbleColumnBlock.entityInside: the columns its box touches lift it
|
||||
// (Entity.handleOnInsideBubbleColumn / handleOnAboveBubbleColumn).
|
||||
let at = Vec3::new(20.5, 6.0, 20.5);
|
||||
let mut tnt = PrimedTnt {
|
||||
id: 0,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
fuse: 80,
|
||||
on_ground: false,
|
||||
owner: None,
|
||||
guest: None,
|
||||
};
|
||||
for _ in 0..10 {
|
||||
tnt.tick(&world, &collision, &assets, &facts).unwrap();
|
||||
}
|
||||
assert!(
|
||||
tnt.velocity.y > 0.0,
|
||||
"soul sand lifts primed TNT, got {:?}",
|
||||
tnt.velocity
|
||||
);
|
||||
assert!(
|
||||
tnt.position.y > at.y,
|
||||
"the column carries it up, got {}",
|
||||
tnt.position.y
|
||||
);
|
||||
// Without the column the same TNT only falls.
|
||||
for y in 3..=8 {
|
||||
world.set(20, y, 20, water).unwrap();
|
||||
}
|
||||
tnt.position = at;
|
||||
tnt.velocity = Vec3::ZERO;
|
||||
tnt.on_ground = false;
|
||||
for _ in 0..4 {
|
||||
tnt.tick(&world, &collision, &assets, &facts).unwrap();
|
||||
}
|
||||
assert!(
|
||||
tnt.position.y < at.y,
|
||||
"water alone does not lift it, got {}",
|
||||
tnt.position.y
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -361,6 +361,105 @@ impl Game<'_> {
|
||||
push(t.position, &mut t.velocity, self);
|
||||
}
|
||||
self.tnts = tnts;
|
||||
// The rest of the entities in reach take `Entity.pushFromExplosion`
|
||||
// alike: their distance is from their feet, their direction from their
|
||||
// eyes (0.85 of the box height unless a type says otherwise).
|
||||
let push_box =
|
||||
|game: &Game<'_>, feet: Vec3, lo: [f64; 3], hi: [f64; 3], eye: f64, v: &mut Vec3| {
|
||||
let feet = [feet.x, feet.y, feet.z];
|
||||
let dist = distance(feet) / diameter;
|
||||
if dist > 1.0 {
|
||||
return;
|
||||
}
|
||||
let exposure = game.seen_percent(center, lo, hi);
|
||||
let power = (1.0 - dist) * f64::from(exposure) * multiplier;
|
||||
let dir = direction([feet[0], feet[1] + eye, feet[2]]);
|
||||
*v = Vec3::new(
|
||||
v.x + dir[0] * power,
|
||||
v.y + dir[1] * power,
|
||||
v.z + dir[2] * power,
|
||||
);
|
||||
};
|
||||
// ItemEntity.ignoreExplosion: items feel only a burst that affects
|
||||
// block-like entities (a mace's, never a wind charge's).
|
||||
if blocklike {
|
||||
let mut items = std::mem::take(&mut self.dropped);
|
||||
for item in &mut items {
|
||||
let p = item.position;
|
||||
let half = 0.125;
|
||||
push_box(
|
||||
self,
|
||||
p,
|
||||
[p.x - half, p.y, p.z - half],
|
||||
[p.x + half, p.y + 0.25, p.z + half],
|
||||
0.2125,
|
||||
&mut item.velocity,
|
||||
);
|
||||
}
|
||||
self.dropped = items;
|
||||
}
|
||||
let mut orbs = std::mem::take(&mut self.orbs);
|
||||
for o in &mut orbs {
|
||||
let p = o.position;
|
||||
push_box(
|
||||
self,
|
||||
p,
|
||||
[p.x - 0.25, p.y, p.z - 0.25],
|
||||
[p.x + 0.25, p.y + 0.5, p.z + 0.25],
|
||||
f64::from(0.5f32 * 0.85f32),
|
||||
&mut o.velocity,
|
||||
);
|
||||
}
|
||||
self.orbs = orbs;
|
||||
let mut falling = std::mem::take(&mut self.falling);
|
||||
for f in &mut falling {
|
||||
let b = horizon_client::falling_block::bounds(f.position);
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
let eye = f64::from((hi.y - lo.y) as f32 * 0.85f32);
|
||||
push_box(
|
||||
self,
|
||||
f.position,
|
||||
[lo.x, lo.y, lo.z],
|
||||
[hi.x, hi.y, hi.z],
|
||||
eye,
|
||||
&mut f.velocity,
|
||||
);
|
||||
}
|
||||
self.falling = falling;
|
||||
// VehicleEntity.ignoreExplosion: a mob's burst (a breeze's) leaves
|
||||
// boats and minecarts alone with mob griefing off.
|
||||
if !(breeze && !self.ui.rules.bool("mob_griefing")) {
|
||||
let mut boats = std::mem::take(&mut self.boats);
|
||||
for b in &mut boats {
|
||||
let bounds = b.bounds();
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
// EntityType.OAK_BOAT & co: eyeHeight is the full 0.5625.
|
||||
push_box(
|
||||
self,
|
||||
b.position,
|
||||
[lo.x, lo.y, lo.z],
|
||||
[hi.x, hi.y, hi.z],
|
||||
0.5625,
|
||||
&mut b.velocity,
|
||||
);
|
||||
}
|
||||
self.boats = boats;
|
||||
let mut carts = std::mem::take(&mut self.carts);
|
||||
for c in &mut carts {
|
||||
let bounds = c.bounds();
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
let eye = f64::from((hi.y - lo.y) as f32 * 0.85f32);
|
||||
push_box(
|
||||
self,
|
||||
c.position,
|
||||
[lo.x, lo.y, lo.z],
|
||||
[hi.x, hi.y, hi.z],
|
||||
eye,
|
||||
&mut c.velocity,
|
||||
);
|
||||
}
|
||||
self.carts = carts;
|
||||
}
|
||||
pushed
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` for the player (not a spectator, nor a
|
||||
|
||||
@@ -35,7 +35,9 @@ pub struct HurtSource<'a> {
|
||||
pub causing: i32,
|
||||
pub direct: i32,
|
||||
}
|
||||
/// What `LivingEntity.hurtServer` is about to hurt, for [`HURT_SERVER`].
|
||||
/// What `LivingEntity.hurtServer` is about to hurt, for [`HURT_SERVER`], or
|
||||
/// `LivingEntity.knockback` to push, for [`KNOCKBACK`]: the entity and the
|
||||
/// hit's source.
|
||||
pub enum Hurting<'a> {
|
||||
/// A player, by its entity id.
|
||||
Player(i32, HurtSource<'a>),
|
||||
@@ -52,6 +54,38 @@ pub static HURT_SERVER: OnceLock<fn(Hurting<'_>, &mut f32) -> bool> = OnceLock::
|
||||
pub fn hurt_server_canceled(hurting: Hurting<'_>, amount: &mut f32) -> bool {
|
||||
HURT_SERVER.get().is_some_and(|hook| hook(hurting, amount))
|
||||
}
|
||||
/// A knockback's strength and direction, as `LivingEntity.knockback` has them
|
||||
/// once the knockback resistance is applied. The entity is pushed away from
|
||||
/// the direction (`x`, `z`), which vanilla points at the source of the hit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Knock {
|
||||
pub strength: f64,
|
||||
pub x: f64,
|
||||
pub z: f64,
|
||||
}
|
||||
/// The game's native knockback hook (`native_living_knockback`), set once by
|
||||
/// the game: `LivingEntity.knockback`, after the knockback resistance and
|
||||
/// before the motion changes. It may change the [`Knock`]; true cancels it.
|
||||
pub static KNOCKBACK: OnceLock<fn(Hurting<'_>, &mut Knock) -> bool> = OnceLock::new();
|
||||
/// Whether [`KNOCKBACK`] canceled the knockback (never while it is unset). A
|
||||
/// value it leaves that is not a finite number is put back as it was.
|
||||
pub fn knockback_canceled(pushed: Hurting<'_>, knock: &mut Knock) -> bool {
|
||||
let Some(hook) = KNOCKBACK.get() else {
|
||||
return false;
|
||||
};
|
||||
let before = *knock;
|
||||
let canceled = hook(pushed, knock);
|
||||
for (now, was) in [
|
||||
(&mut knock.strength, before.strength),
|
||||
(&mut knock.x, before.x),
|
||||
(&mut knock.z, before.z),
|
||||
] {
|
||||
if !now.is_finite() {
|
||||
*now = was;
|
||||
}
|
||||
}
|
||||
canceled
|
||||
}
|
||||
pub struct HealthResources {
|
||||
pub damage: Catalog,
|
||||
pub ids: PlayerFloatIds,
|
||||
@@ -1127,6 +1161,122 @@ mod tests {
|
||||
assert_eq!(seen(9013), [("minecraft:arrow 44".to_owned(), 5.0)]);
|
||||
}
|
||||
|
||||
/// What the knockback hook of these tests saw: the entity, the hit that
|
||||
/// causes it (a mob's damage kind) and the push it was handed.
|
||||
static PUSHES: std::sync::Mutex<Vec<(i32, String, super::Knock)>> =
|
||||
std::sync::Mutex::new(Vec::new());
|
||||
/// Sets the native knockback hook of these tests (global: it stays set for
|
||||
/// the process), for mobs 9101 to 9106 only: it records the push, cancels
|
||||
/// 9101's, doubles 9102's strength and turns its direction around, leaves
|
||||
/// 9103 non-finite numbers, and gives 9105 (whose resistance leaves
|
||||
/// nothing) a strength of 0.4.
|
||||
fn set_knockback_hook() -> fn(i32) -> Vec<(String, super::Knock)> {
|
||||
use super::{Hurting, Knock};
|
||||
fn hook(pushed: Hurting<'_>, knock: &mut Knock) -> bool {
|
||||
let Hurting::Mob(mob, source) = pushed else {
|
||||
return false;
|
||||
};
|
||||
let entity = mob.id as i32;
|
||||
if !(9101..=9106).contains(&entity) {
|
||||
return false;
|
||||
}
|
||||
let hit = format!("{:?}", source.kind);
|
||||
PUSHES.lock().unwrap().push((entity, hit, *knock));
|
||||
match entity {
|
||||
9101 => return true,
|
||||
9102 => {
|
||||
knock.strength *= 2.0;
|
||||
knock.x = -knock.x;
|
||||
}
|
||||
9103 => {
|
||||
knock.strength = f64::NAN;
|
||||
knock.x = f64::INFINITY;
|
||||
knock.z = f64::NEG_INFINITY;
|
||||
}
|
||||
9105 => knock.strength = 0.4,
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
let installed = *super::KNOCKBACK.get_or_init(|| hook);
|
||||
assert!(std::ptr::fn_addr_eq(
|
||||
installed,
|
||||
hook as fn(Hurting<'_>, &mut Knock) -> bool
|
||||
));
|
||||
|entity| {
|
||||
let seen = PUSHES.lock().unwrap();
|
||||
let pushes = seen.iter().filter(|p| p.0 == entity);
|
||||
pushes.map(|p| (p.1.clone(), p.2)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.knockback`: the hook runs once the knockback resistance
|
||||
/// took its share and before the motion changes; a mod's strength or
|
||||
/// direction is what pushes, a cancel pushes nothing, and with no mod in
|
||||
/// the way the push is vanilla's. Expected values are worked out by hand
|
||||
/// from vanilla's formula: the horizontal speed halves and the push is
|
||||
/// subtracted along the normalized direction; on the ground the vertical
|
||||
/// speed becomes `min(0.4, vy / 2 + strength)`.
|
||||
#[test]
|
||||
fn the_native_knockback_hook_changes_or_cancels_a_mobs_push() {
|
||||
use super::Knock;
|
||||
use crate::mobs::{Damage, Mob};
|
||||
let seen = set_knockback_hook();
|
||||
let mob = |id, resistance| {
|
||||
let mut m = Mob::new(
|
||||
id,
|
||||
crate::mobs::sensor_scan_tests::kind(),
|
||||
Default::default(),
|
||||
0.0,
|
||||
);
|
||||
m.on_ground = true;
|
||||
m.attrs.knockback_resistance = resistance;
|
||||
m
|
||||
};
|
||||
let velocity = |m: &Mob| (m.velocity.x, m.velocity.y, m.velocity.z);
|
||||
let hit = Damage::player([3.0, 0.0, 0.0]);
|
||||
let push = |id, resistance| {
|
||||
let mut m = mob(id, resistance);
|
||||
m.knockback(0.4, 3.0, 0.0, &hit);
|
||||
m
|
||||
};
|
||||
// No mod in the way (the hook leaves 9100 alone): vanilla's push, away from x = 3.
|
||||
let plain = push(9100, 0.0);
|
||||
assert_eq!(velocity(&plain), (-0.4, 0.4, 0.0));
|
||||
assert!(plain.needs_sync);
|
||||
// Canceled: no motion, no resync. The hook saw the push the game made.
|
||||
let kept = push(9101, 0.0);
|
||||
assert_eq!(velocity(&kept), (0.0, 0.0, 0.0));
|
||||
assert!(!kept.needs_sync);
|
||||
let made = Knock {
|
||||
strength: 0.4,
|
||||
x: 3.0,
|
||||
z: 0.0,
|
||||
};
|
||||
assert_eq!(seen(9101), [("PlayerAttack".to_owned(), made)]);
|
||||
// A stronger push the other way: twice the strength, x turned around.
|
||||
let turned = push(9102, 0.0);
|
||||
assert_eq!(velocity(&turned), (0.8, 0.4, 0.0));
|
||||
// Numbers that are not finite are put back as the game made them.
|
||||
assert_eq!(velocity(&push(9103, 0.0)), (-0.4, 0.4, 0.0));
|
||||
// After the resistance: half of 0.4 is what the hook is handed.
|
||||
let resisted = push(9104, 0.5);
|
||||
assert_eq!(velocity(&resisted), (-0.2, 0.2, 0.0));
|
||||
assert_eq!(seen(9104)[0].1.strength, 0.2);
|
||||
// A full resistance leaves a strength of 0, which a mod may raise.
|
||||
let raised = push(9105, 1.0);
|
||||
assert_eq!(seen(9105)[0].1.strength, 0.0);
|
||||
assert_eq!(velocity(&raised), (-0.4, 0.4, 0.0));
|
||||
// The game's own hit on a mob reaches the hook with its damage.
|
||||
let mut hurt = mob(9106, 0.0);
|
||||
assert!(hurt.damage(2.0, hit));
|
||||
// dealDefaultKnockback pushes with the float 0.4F.
|
||||
let strength = f64::from(0.4_f32);
|
||||
assert_eq!(velocity(&hurt), (-strength, 0.4, 0.0));
|
||||
let made = Knock { strength, ..made };
|
||||
assert_eq!(seen(9106), [("PlayerAttack".to_owned(), made)]);
|
||||
}
|
||||
|
||||
/// #4301: `ServerPlayer.isInvulnerableTo`'s `!connection.hasClientLoaded`
|
||||
/// term spares a respawned player the void until the client has loaded the
|
||||
/// world; once it has, the void hurts again.
|
||||
|
||||
@@ -547,13 +547,34 @@ impl ItemRenderer {
|
||||
pub fn load_with_library(
|
||||
root: &Path,
|
||||
library: std::result::Result<Arc<ModelLibrary>, String>,
|
||||
) -> crate::Result<Self> {
|
||||
Self::load_tables(root, library, true)
|
||||
}
|
||||
/// What a dedicated server reads: the item prototypes (the stacks'
|
||||
/// default components) and ids, without the client item definitions,
|
||||
/// the special renderers' entity models and the grass colormap, which
|
||||
/// only drawing reads (1.4 MB and ~1,500 files).
|
||||
pub fn load_for_server(
|
||||
root: &Path,
|
||||
library: std::result::Result<Arc<ModelLibrary>, String>,
|
||||
) -> crate::Result<Self> {
|
||||
Self::load_tables(root, library, false)
|
||||
}
|
||||
fn load_tables(
|
||||
root: &Path,
|
||||
library: std::result::Result<Arc<ModelLibrary>, String>,
|
||||
drawn: bool,
|
||||
) -> crate::Result<Self> {
|
||||
let prototypes: BTreeMap<String, Components> = horizon_world::item_defaults::read(root)?
|
||||
.into_iter()
|
||||
.map(|(item, components)| Ok((item, serde_json::from_value(components)?)))
|
||||
.collect::<serde_json::Result<_>>()?;
|
||||
let models = ItemModels {
|
||||
definitions: load_definitions(root),
|
||||
definitions: if drawn {
|
||||
load_definitions(root)
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
},
|
||||
prototypes,
|
||||
};
|
||||
let library = library?;
|
||||
@@ -561,10 +582,18 @@ impl ItemRenderer {
|
||||
&fs::read_to_string(root.join("assets/minecraft/atlases/items.json"))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
let parts = special::load_parts(root);
|
||||
let grass = crate::hud::decode(&root.join("assets/minecraft/textures/colormap/grass.png"))
|
||||
.map(|(_, _, rgba)| argb_pixels(&rgba).into_iter().map(|p| p as i32).collect())
|
||||
.unwrap_or_default();
|
||||
let parts = if drawn {
|
||||
special::load_parts(root)
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
let grass = if drawn {
|
||||
crate::hud::decode(&root.join("assets/minecraft/textures/colormap/grass.png"))
|
||||
.map(|(_, _, rgba)| argb_pixels(&rgba).into_iter().map(|p| p as i32).collect())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let item_ids = horizon_protocol::registry::Builtin::find(root)
|
||||
.and_then(|p| horizon_protocol::registry::Builtin::load(&p).ok())
|
||||
.and_then(|b| {
|
||||
@@ -993,6 +1022,31 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A dedicated server keeps the stacks' default components but reads
|
||||
/// none of the client item definitions.
|
||||
#[test]
|
||||
fn a_server_keeps_prototypes_without_definitions() -> crate::Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("horizon-item-server-{}", std::process::id()));
|
||||
let definitions = root.join("assets/horizon/items");
|
||||
fs::create_dir_all(&definitions)?;
|
||||
fs::write(
|
||||
definitions.join("top.json"),
|
||||
r#"{"model":{"type":"minecraft:empty"}}"#,
|
||||
)?;
|
||||
fs::write(
|
||||
root.join("items.json"),
|
||||
r#"{"horizon:top":{"minecraft:max_stack_size":16}}"#,
|
||||
)?;
|
||||
let library = crate::model_library::ParsedModels::load(&root).into_item_library();
|
||||
let client = ItemRenderer::load_with_library(&root, library.clone())?;
|
||||
let server = ItemRenderer::load_for_server(&root, library)?;
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
assert!(client.models.definitions.contains_key("horizon:top"));
|
||||
assert!(server.models.definitions.is_empty());
|
||||
assert_eq!(server.models.prototypes, client.models.prototypes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendered_amounts_follow_the_count_bands() {
|
||||
let bands: Vec<u32> = [0, 1, 2, 16, 17, 32, 33, 48, 49, 64]
|
||||
|
||||
@@ -19,8 +19,10 @@ pub struct ServerAdvancements {
|
||||
tree: std::sync::Arc<Tree>,
|
||||
kids: Vec<Vec<usize>>,
|
||||
layout: Vec<Option<(i32, f32)>>,
|
||||
/// Recipe files by id.
|
||||
recipes: BTreeMap<String, Value>,
|
||||
/// Recipe files by id, as compact JSON text: a book entry parses its
|
||||
/// recipe when a player gets it (a few µs), and the ~1,400 parsed files
|
||||
/// would keep about 5 MB alive for the server's whole run.
|
||||
recipes: BTreeMap<String, Box<str>>,
|
||||
displays: RecipeIndex,
|
||||
/// RecipeManager.unpackRecipeInfo's group numbers.
|
||||
groups: HashMap<String, i32>,
|
||||
@@ -48,6 +50,10 @@ impl ServerAdvancements {
|
||||
groups.entry(g.to_owned()).or_insert(next);
|
||||
}
|
||||
}
|
||||
let recipes = recipes
|
||||
.into_iter()
|
||||
.map(|(id, r)| (id, r.to_string().into_boxed_str()))
|
||||
.collect();
|
||||
Self {
|
||||
tree,
|
||||
kids,
|
||||
@@ -351,14 +357,19 @@ impl ServerAdvancements {
|
||||
/// ClientboundRecipeBookAddPacket.Entry for each display of a recipe:
|
||||
/// highlighted, and with a notification unless the recipe turns it off.
|
||||
fn book_entries(&self, id: &str, sync: &SyncData, flags: i8) -> Vec<Vec<u8>> {
|
||||
let Some(r) = self.recipes.get(id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let ids = self.displays.display_ids(id);
|
||||
if ids.len() != 1 {
|
||||
// Special recipes have no display; transmutations aren't unlocked on joining.
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(r) = self
|
||||
.recipes
|
||||
.get(id)
|
||||
.and_then(|text| serde_json::from_str::<Value>(text).ok())
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let r = &r;
|
||||
let kind = r["type"].as_str().unwrap_or("");
|
||||
let item = |n: &str| sync.id("minecraft:item", n).unwrap_or(0);
|
||||
let slot = |n: &str| sync.id("minecraft:slot_display", n).unwrap_or(0);
|
||||
|
||||
@@ -144,9 +144,8 @@ pub struct TickCtx<'a> {
|
||||
/// TickRateManager: the tick rate and whether the game is frozen.
|
||||
pub tick_rate: f32,
|
||||
pub tick_frozen: bool,
|
||||
/// `/tick step`: a count that changes with each step started or
|
||||
/// stopped, and the ticks the frozen game still runs (`FixedStep::step_state`).
|
||||
pub tick_steps: (u32, i32),
|
||||
/// The last step command's broadcast and the remaining ticks for joins.
|
||||
pub tick_steps: crate::clock::StepState,
|
||||
/// Block collisions, for ServerGamePacketListenerImpl's moved-wrongly
|
||||
/// check (none: moves are not checked against blocks).
|
||||
pub collision: Option<&'a BlockCollisionRegistry<'a>>,
|
||||
@@ -593,6 +592,7 @@ impl Hub {
|
||||
stack_to_network(item_id, |registry, name| sync.id(registry, name), s)
|
||||
}
|
||||
|
||||
/// `PlayerList.broadcastAll`: send a packet to every placed player.
|
||||
pub fn broadcast(&self, packet: &C) {
|
||||
let body = packet.encode();
|
||||
for g in self.guests.iter().filter(|g| g.placed) {
|
||||
@@ -1288,7 +1288,7 @@ impl Hub {
|
||||
// TickRateManager.updateJoiningPlayer.
|
||||
g.send(&ticking_state(ctx.tick_rate, ctx.tick_frozen));
|
||||
g.send(&C::TickingStep(play::TickingStep {
|
||||
steps: ctx.tick_steps.1,
|
||||
steps: ctx.tick_steps.remaining,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1480,7 +1480,7 @@ impl Hub {
|
||||
// (`updateJoiningPlayer`).
|
||||
if !self.guests.iter().any(|g| g.placed) {
|
||||
self.ticking = (ctx.tick_rate, ctx.tick_frozen);
|
||||
self.stepping = ctx.tick_steps.0;
|
||||
self.stepping = ctx.tick_steps.revision;
|
||||
}
|
||||
if !self
|
||||
.guests
|
||||
@@ -1492,6 +1492,7 @@ impl Hub {
|
||||
if let Some(far) = self.far.as_mut() {
|
||||
far.forget_level();
|
||||
}
|
||||
ctx.world.bulk_log.clear();
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -1585,6 +1586,22 @@ impl Hub {
|
||||
g
|
||||
}
|
||||
|
||||
/// `ServerTickRateManager.updateStepTicks` / `updateStateToClients`:
|
||||
/// send clock changes before the block events caused by their ticks.
|
||||
pub fn sync_ticking(&mut self, rate: f32, frozen: bool, steps: crate::clock::StepState) {
|
||||
if (rate, frozen) != self.ticking {
|
||||
self.ticking = (rate, frozen);
|
||||
self.broadcast(&ticking_state(rate, frozen));
|
||||
}
|
||||
// ServerTickRateManager.updateStepTicks: a step started or stopped.
|
||||
if steps.revision != self.stepping {
|
||||
self.stepping = steps.revision;
|
||||
self.broadcast(&C::TickingStep(play::TickingStep {
|
||||
steps: steps.requested,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Time and weather (ServerLevel.tickTime / advanceWeatherCycle broadcasts).
|
||||
fn sync_world(&mut self, ctx: &TickCtx) {
|
||||
let world = &*ctx.world;
|
||||
@@ -1619,17 +1636,7 @@ impl Hub {
|
||||
}
|
||||
self.last_clock = day;
|
||||
self.last_rate = rate;
|
||||
if (ctx.tick_rate, ctx.tick_frozen) != self.ticking {
|
||||
self.ticking = (ctx.tick_rate, ctx.tick_frozen);
|
||||
self.broadcast(&ticking_state(ctx.tick_rate, ctx.tick_frozen));
|
||||
}
|
||||
// ServerTickRateManager.updateStepTicks: a step started or stopped.
|
||||
if ctx.tick_steps.0 != self.stepping {
|
||||
self.stepping = ctx.tick_steps.0;
|
||||
self.broadcast(&C::TickingStep(play::TickingStep {
|
||||
steps: ctx.tick_steps.1,
|
||||
}));
|
||||
}
|
||||
self.sync_ticking(ctx.tick_rate, ctx.tick_frozen, ctx.tick_steps);
|
||||
let w = &world.weather;
|
||||
let (raining, rain, thunder) = self.weather;
|
||||
if w.raining != raining {
|
||||
@@ -1922,9 +1929,71 @@ impl Hub {
|
||||
}
|
||||
|
||||
fn broadcast_block_changes(&mut self, world: &mut World) {
|
||||
// Chunks changed in bulk (`World::edit_runs`) go whole to those who see them: one packet each, where a
|
||||
// change of each block would be thousands (the client lights and meshes them once, on its workers).
|
||||
let bulk: Vec<(i32, i32)> = if world.bulk_log.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let mut keys: Vec<_> = world.bulk_log.drain().collect();
|
||||
keys.sort_unstable();
|
||||
keys
|
||||
};
|
||||
if !bulk.is_empty() {
|
||||
let recording = self.recording();
|
||||
let columns: Vec<&Chunk> = bulk
|
||||
.iter()
|
||||
.filter_map(|k| world.chunks.get(k).map(|c| &**c))
|
||||
.collect();
|
||||
self.encoder.warm(&columns);
|
||||
for chunk in columns {
|
||||
let key = (chunk.x, chunk.z);
|
||||
if let Some(far) = self.far.as_mut() {
|
||||
far.changed(key.0, key.1);
|
||||
}
|
||||
let viewers: Vec<usize> = (0..self.guests.len())
|
||||
.filter(|&i| {
|
||||
self.here(&self.guests[i]) && self.guests[i].sent_chunks.contains(&key)
|
||||
})
|
||||
.collect();
|
||||
if viewers.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let body = self.encoder.body_in(chunk, world);
|
||||
for &i in &viewers {
|
||||
self.guests[i].sender.send(body.clone());
|
||||
}
|
||||
if recording {
|
||||
// The prediction's replay excuses a client near the column: any of its blocks may have
|
||||
// changed, so the one nearest the player stands for them.
|
||||
for &i in &viewers {
|
||||
let g = &mut self.guests[i];
|
||||
let Some(record) = g.anticheat.record.as_deref_mut() else {
|
||||
continue;
|
||||
};
|
||||
let slack = super::prediction::slack(std::array::from_fn(|i| {
|
||||
g.position[i] - g.previous_position[i]
|
||||
}));
|
||||
let column =
|
||||
|c: f64, at: i32| (c.floor() as i32).clamp(at * 16, at * 16 + 15);
|
||||
let at = [
|
||||
column(g.position[0], key.0),
|
||||
g.position[1].floor() as i32,
|
||||
column(g.position[2], key.1),
|
||||
];
|
||||
if super::prediction::near(g.position, at, slack) {
|
||||
record.changed_near(self.ticks, at);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(log) = world.block_log.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if !bulk.is_empty() {
|
||||
// (what changed in them one at a time went with them)
|
||||
log.retain(|p| bulk.binary_search(&(p.0 >> 4, p.2 >> 4)).is_err());
|
||||
}
|
||||
if log.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,9 @@ pub fn load_owner(guest: &mut Guest, world: &Path, saved: Option<&Level>) -> boo
|
||||
/// then to the file level.dat names, which it loads ([`load_owner`]), and to
|
||||
/// its own file, so that the one it loads is never the older of the two.
|
||||
pub fn save_owner(guest: &Guest, level: &mut Level, world: &Path) {
|
||||
// `MinecraftServer.saveAllChunks`: `singleplayer_uuid` is the integrated server's own profile,
|
||||
// never another player's file the world happened to hold.
|
||||
level.player_uuid.get_or_insert(uuid_ints(guest.uuid));
|
||||
store(guest, level, None);
|
||||
let _ = level.write_player(world);
|
||||
save(guest, world);
|
||||
@@ -586,4 +589,35 @@ mod tests {
|
||||
assert_eq!(other.flying_speed, 0.05);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_world_without_singleplayer_uuid_gives_the_owner_a_new_player_not_anothers() {
|
||||
// A world from a vanilla server: no `singleplayer_uuid`, and the players' files are other
|
||||
// people's. The owner has none: vanilla starts them new (PlayerList.loadPlayerData).
|
||||
let owner = horizon_protocol::crypto::offline_uuid("Owner");
|
||||
let friend = horizon_protocol::crypto::offline_uuid("Friend");
|
||||
let dir = world("server-world");
|
||||
save(&holding(friend, "minecraft:netherite_ingot", 9), &dir);
|
||||
let mut level = Level::default();
|
||||
level.write(&dir).unwrap();
|
||||
let _ = std::fs::remove_file(dir.join("level.dat_old"));
|
||||
let mut tag = read_dat(&dir.join("level.dat")).unwrap();
|
||||
if let Some(Tag::Compound(data)) = tag.get_mut("Data") {
|
||||
data.remove(&"singleplayer_uuid".into());
|
||||
}
|
||||
write_dat(&dir.join("level.dat"), tag).unwrap();
|
||||
let read = Level::read(&dir).unwrap();
|
||||
let mut g = holding(owner, "minecraft:dirt", 1);
|
||||
g.inventory[0] = None;
|
||||
let known = load_owner(&mut g, &dir, Some(&read));
|
||||
assert!(!known, "the owner has not been here before");
|
||||
assert_eq!(first_item(&g), None, "no inventory of the friend's");
|
||||
// The owner's save names the owner, and leaves the friend's file alone.
|
||||
let before = std::fs::read(path(&dir, friend)).unwrap();
|
||||
let mut level = read;
|
||||
save_owner(&g, &mut level, &dir);
|
||||
assert_eq!(level.player_uuid, Some(uuid_ints(owner)));
|
||||
assert_eq!(std::fs::read(path(&dir, friend)).unwrap(), before);
|
||||
assert!(exists(&dir, owner));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user