mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 02:21:33 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cea8c2ee6 | ||
|
|
0694db7dcb | ||
|
|
92742bef1b | ||
|
|
bb29b70b32 | ||
|
|
3ad8a18a7b |
No files matched your search
@@ -13,3 +13,4 @@
|
||||
87152661 rdbtCVS # added by Ludovic, 2026-10-27
|
||||
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
|
||||
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
|
||||
169198624 itarqos5 # added by Ludovic, 2026-11-08 (Discord Literal; Horizon Team)
|
||||
Generated
+2
-11
@@ -1461,6 +1461,7 @@ dependencies = [
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-system",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
@@ -1796,27 +1797,17 @@ dependencies = [
|
||||
"horizon-friends",
|
||||
"horizon-game",
|
||||
"horizon-heartbeat",
|
||||
"horizon-official-servers",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-official-servers"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-addresses",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"horizon-core",
|
||||
"horizon-system",
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
@@ -34,7 +34,6 @@ members = [
|
||||
"crates/horizon-game",
|
||||
"crates/horizon-official",
|
||||
"crates/horizon-heartbeat",
|
||||
"crates/horizon-official-servers",
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
|
||||
@@ -15,10 +15,6 @@ pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
|
||||
/// `official_server_previous` in `launcher/links.json`).
|
||||
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
|
||||
|
||||
/// Afri-Network, a partner server the game added once below the official server (#9299) until
|
||||
/// the partnership ended: kept so the game can take back out the entry it added.
|
||||
pub const WITHDRAWN_PARTNER_SERVER: &str = "afri-network.co.za";
|
||||
|
||||
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
|
||||
/// (the telemetry server's status checks).
|
||||
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
|
||||
//! come from the program that rendered (the game knows its adapter, the launcher its window).
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
use horizon_telemetry::system::command_output as output;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -68,12 +69,12 @@ impl Hardware {
|
||||
os: std::env::consts::OS.to_owned(),
|
||||
os_version: os_version(),
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
cpu: cpu(),
|
||||
cpu: horizon_telemetry::system::cpu(),
|
||||
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
cpu_cores: probes.cores,
|
||||
cpu_base_mhz: probes.base,
|
||||
cpu_boost_mhz: probes.boost,
|
||||
ram_mb: ram_bytes().map(|b| b >> 20),
|
||||
ram_mb: horizon_telemetry::system::ram_bytes().map(|b| b >> 20),
|
||||
storage: None,
|
||||
form: probes.form,
|
||||
gpu: None,
|
||||
@@ -109,84 +110,16 @@ fn probes() -> Probes {
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-minute load average: above about the number of threads the machine was busy.
|
||||
pub fn load_average() -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "{ 1.23 1.10 1.00 }"
|
||||
output("sysctl", &["-n", "vm.loadavg"])?
|
||||
.trim_start_matches('{')
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
} else {
|
||||
std::fs::read_to_string("/proc/loadavg")
|
||||
.ok()?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
|
||||
pub fn power_source() -> Option<&'static str> {
|
||||
horizon_telemetry::system::power()
|
||||
}
|
||||
|
||||
/// Resident memory of a process, in bytes (macOS, Linux).
|
||||
pub fn rss_bytes(pid: u32) -> Option<u64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
return output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024);
|
||||
}
|
||||
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
|
||||
line.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
|
||||
pub fn cpu_seconds(pid: u32) -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "[[dd-]hh:]mm:ss.cc"
|
||||
let time = output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
|
||||
let (rest, days) = match time.split_once('-') {
|
||||
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
|
||||
None => (time, 0.0),
|
||||
};
|
||||
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
|
||||
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
|
||||
})?;
|
||||
return Some(days * 86_400.0 + seconds);
|
||||
}
|
||||
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
|
||||
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
|
||||
Some(ticks / 100.0)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_version() -> Option<String> {
|
||||
output("sw_vers", &["-productVersion"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn cpu() -> Option<String> {
|
||||
output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
output("sysctl", &["-n", "hw.memsize"])?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn os_version() -> Option<String> {
|
||||
let release = std::fs::read_to_string("/etc/os-release").ok()?;
|
||||
@@ -199,22 +132,6 @@ fn os_version() -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cpu() -> Option<String> {
|
||||
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
|
||||
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
|
||||
horizon_telemetry::system::linux_cpu_name(&info)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
let kb = key_value(&info, "MemTotal", ':')?
|
||||
.trim_end_matches("kB")
|
||||
.trim();
|
||||
kb.parse::<u64>().ok().map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
|
||||
text.lines().find_map(|line| {
|
||||
@@ -228,39 +145,11 @@ fn os_version() -> Option<String> {
|
||||
horizon_telemetry::system::windows_version()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn cpu() -> Option<String> {
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let out = output(
|
||||
"powershell",
|
||||
&[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
|
||||
],
|
||||
)?;
|
||||
out.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn os_version() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn cpu() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -274,9 +163,6 @@ mod tests {
|
||||
assert!(hw.cpu.is_some(), "{hw:?}");
|
||||
assert!(hw.ram_mb.unwrap() > 256, "{hw:?}");
|
||||
assert!(hw.os_version.is_some(), "{hw:?}");
|
||||
assert!(load_average().is_some());
|
||||
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
|
||||
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
mod hardware;
|
||||
|
||||
pub use hardware::{cpu_seconds, load_average, power_source, rss_bytes, Display, Gpu, Hardware};
|
||||
pub use hardware::{power_source, Display, Gpu, Hardware};
|
||||
/// The process probes, from the platform (`horizon-system`).
|
||||
pub use horizon_telemetry::system::{cpu_seconds, load_average, rss_bytes};
|
||||
|
||||
pub const FORMAT: &str = "horizon-benchmark-report";
|
||||
pub const VERSION: u32 = 1;
|
||||
|
||||
@@ -74,70 +74,11 @@ pub trait ResourceSource {
|
||||
vec!["minecraft".into()]
|
||||
}
|
||||
}
|
||||
/// The bundled file a language reads, and whether it drops `ß`: a regional variant reads its
|
||||
/// language's file (Swiss German writes "ss" for "ß", as vanilla's de_ch does).
|
||||
fn bundled_code(code: &str) -> (&str, bool) {
|
||||
match code {
|
||||
"ko" => ("ko_kr", false),
|
||||
"hr" => ("hr_hr", false),
|
||||
"de_at" => ("de_de", false),
|
||||
"de_ch" => ("de_de", true),
|
||||
"fr_ca" | "fr_ch" => ("fr_fr", false),
|
||||
"es_ar" | "es_cl" | "es_ec" | "es_mx" | "es_uy" | "es_ve" => ("es_es", false),
|
||||
code => (code, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lang files crates above the engine ship (`horizon_hooks::lang`), each a list of
|
||||
/// (code, JSON) pairs. The game's entry installs them once, before any language loads.
|
||||
static CRATE_FILES: std::sync::OnceLock<&'static [&'static [(&'static str, &'static str)]]> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
/// Installs the crates' lang files (`horizon_hooks::lang::engine::all()`). Only the first call
|
||||
/// counts.
|
||||
pub fn install_crate_files(files: &'static [&'static [(&'static str, &'static str)]]) {
|
||||
let _ = CRATE_FILES.set(files);
|
||||
}
|
||||
|
||||
/// The crates' entries for language `code` (through [`bundled_code`]'s variants) that the
|
||||
/// engine's own `horizon` file for it doesn't have: on a key both have, the engine wins.
|
||||
fn read_crate_files(files: &[&[(&str, &str)]], code: &str, map: &mut HashMap<String, String>) {
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (file, swiss) = bundled_code(code);
|
||||
let mut own = HashMap::new();
|
||||
if let Some(bytes) = Bundled.read(&format!("assets/horizon/lang/{code}.json")) {
|
||||
read_lang_file(&bytes, &mut own);
|
||||
}
|
||||
for (c, json) in files.iter().flat_map(|f| f.iter()) {
|
||||
if *c != file {
|
||||
continue;
|
||||
}
|
||||
let mut theirs = HashMap::new();
|
||||
if swiss {
|
||||
read_lang_file(json.replace('ß', "ss").as_bytes(), &mut theirs);
|
||||
} else {
|
||||
read_lang_file(json.as_bytes(), &mut theirs);
|
||||
}
|
||||
for (key, value) in theirs {
|
||||
if !own.contains_key(&key) {
|
||||
map.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's own strings, compiled in so they need no prepared resources.
|
||||
struct Bundled;
|
||||
impl ResourceSource for Bundled {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
let code = path
|
||||
.strip_prefix("assets/horizon/lang/")?
|
||||
.strip_suffix(".json")?;
|
||||
let (file, swiss) = bundled_code(code);
|
||||
let path = format!("assets/horizon/lang/{file}.json");
|
||||
let body = match path.as_str() {
|
||||
let body = match path {
|
||||
"assets/horizon/lang/en_us.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
|
||||
}
|
||||
@@ -162,10 +103,10 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/tr_tr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
|
||||
}
|
||||
"assets/horizon/lang/ko_kr.json" => {
|
||||
"assets/horizon/lang/ko_kr.json" | "assets/horizon/lang/ko.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/ko_kr.json")
|
||||
}
|
||||
"assets/horizon/lang/hr_hr.json" => {
|
||||
"assets/horizon/lang/hr_hr.json" | "assets/horizon/lang/hr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/hr_hr.json")
|
||||
}
|
||||
"assets/horizon/lang/pt_pt.json" => {
|
||||
@@ -390,20 +331,30 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/vec_it.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/vec_it.json")
|
||||
}
|
||||
"assets/horizon/lang/de_de.json" => {
|
||||
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
|
||||
}
|
||||
"assets/horizon/lang/fr_fr.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_es.json"
|
||||
| "assets/horizon/lang/es_ar.json"
|
||||
| "assets/horizon/lang/es_cl.json"
|
||||
| "assets/horizon/lang/es_ec.json"
|
||||
| "assets/horizon/lang/es_mx.json"
|
||||
| "assets/horizon/lang/es_uy.json"
|
||||
| "assets/horizon/lang/es_ve.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/es_es.json")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if swiss {
|
||||
return Some(body.replace('ß', "ss").into_bytes());
|
||||
}
|
||||
Some(body.as_bytes().to_vec())
|
||||
}
|
||||
fn namespaces(&self) -> Vec<String> {
|
||||
@@ -487,16 +438,6 @@ impl Language {
|
||||
/// from every namespace and every source in priority order (later sources
|
||||
/// override).
|
||||
pub fn load(sources: &[&dyn ResourceSource], code: &str) -> Self {
|
||||
let crate_files = CRATE_FILES.get().copied().unwrap_or_default();
|
||||
Self::load_with(sources, code, crate_files)
|
||||
}
|
||||
|
||||
/// [`Language::load`] with these crates' lang files.
|
||||
fn load_with(
|
||||
sources: &[&dyn ResourceSource],
|
||||
code: &str,
|
||||
crate_files: &[&[(&str, &str)]],
|
||||
) -> Self {
|
||||
let mut map = HashMap::new();
|
||||
let mut codes = vec![DEFAULT];
|
||||
if code != DEFAULT {
|
||||
@@ -512,14 +453,12 @@ impl Language {
|
||||
sources.iter().flat_map(|s| s.namespaces()).collect();
|
||||
for c in codes {
|
||||
for namespace in &namespaces {
|
||||
for (i, source) in sources.iter().enumerate() {
|
||||
if let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json")) {
|
||||
read_lang_file(&bytes, &mut map);
|
||||
}
|
||||
// The crates' text right after the engine's own (`Bundled`, the first source).
|
||||
if i == 0 && namespace == "horizon" {
|
||||
read_crate_files(crate_files, c, &mut map);
|
||||
}
|
||||
for source in sources {
|
||||
let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
read_lang_file(&bytes, &mut map);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -749,43 +688,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crates_text_loads_after_the_engines_and_before_packs() {
|
||||
static CRATE: &[(&str, &str)] = &[
|
||||
(
|
||||
"en_us",
|
||||
r#"{"test.crate.hello": "Hello", "horizon.osk.close": "Shut", "test.crate.packed": "Crate"}"#,
|
||||
),
|
||||
(
|
||||
"de_de",
|
||||
r#"{"test.crate.hello": "Grüß dich", "horizon.osk.close": "Zu"}"#,
|
||||
),
|
||||
("fr_fr", r#"{"test.crate.hello": "Bonjour"}"#),
|
||||
];
|
||||
let pack = Mem(HashMap::from([(
|
||||
"assets/horizon/lang/en_us.json".to_owned(),
|
||||
br#"{"test.crate.packed": "Pack"}"#.to_vec(),
|
||||
)]));
|
||||
let load = |code| Language::load_with(&[&pack], code, &[CRATE]);
|
||||
let en = load("en_us");
|
||||
assert_eq!(en.get("test.crate.hello"), Some("Hello"));
|
||||
// The engine's own file wins on a key both have; a pack wins over both.
|
||||
assert_eq!(en.get("horizon.osk.close"), Some("Close"));
|
||||
assert_eq!(en.get("test.crate.packed"), Some("Pack"));
|
||||
let de = load("de_de");
|
||||
assert_eq!(de.get("test.crate.hello"), Some("Grüß dich"));
|
||||
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
|
||||
// Regional variants read their language's file, as the engine's do.
|
||||
assert_eq!(load("de_ch").get("test.crate.hello"), Some("Grüss dich"));
|
||||
assert_eq!(load("fr_ca").get("test.crate.hello"), Some("Bonjour"));
|
||||
// A language the crate lacks falls back to its English.
|
||||
assert_eq!(load("pl_pl").get("test.crate.hello"), Some("Hello"));
|
||||
// Without crate files, nothing changes.
|
||||
let plain = Language::load_with(&[&pack], "de_de", &[]);
|
||||
assert_eq!(plain.get("test.crate.hello"), None);
|
||||
assert_eq!(plain.len() + 1, de.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
|
||||
let empty = Mem(HashMap::new());
|
||||
@@ -837,20 +739,6 @@ mod tests {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-client/assets/horizon/lang")
|
||||
}
|
||||
/// The crates whose own lang files the game loads (`crates/<name>/assets/lang`,
|
||||
/// registered through `horizon_hooks::lang`), with that folder.
|
||||
fn crate_lang_dirs() -> Vec<(String, std::path::PathBuf)> {
|
||||
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let mut dirs: Vec<_> = std::fs::read_dir(&crates)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| (e.file_name().into_string().unwrap(), e.path()))
|
||||
.map(|(name, path)| (name, path.join("assets/lang")))
|
||||
.filter(|(_, dir)| dir.join("en_us.json").is_file())
|
||||
.collect();
|
||||
dirs.sort();
|
||||
dirs
|
||||
}
|
||||
/// 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> {
|
||||
@@ -888,33 +776,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
|
||||
let engine = check_lang_dir(&bundled_dir(), true);
|
||||
assert!(
|
||||
engine.1 >= 7,
|
||||
"found {} bundled lang files in {}",
|
||||
engine.1,
|
||||
bundled_dir().display()
|
||||
);
|
||||
// A crate's files pass the same checks. Its keys are its own: on a key the engine's
|
||||
// files also have, the engine's text would win and hide the crate's.
|
||||
for (name, dir) in crate_lang_dirs() {
|
||||
let (en, _) = check_lang_dir(&dir, false);
|
||||
for key in en.keys() {
|
||||
assert!(
|
||||
!engine.0.contains_key(key),
|
||||
"{name}: {key} is also the engine's"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The checks every lang folder passes: flat maps, no repeated key, no key en_us lacks, the
|
||||
/// same placeholders as English, no empty text. Its en_us and how many files it has.
|
||||
fn check_lang_dir(dir: &std::path::Path, bundled: bool) -> (HashMap<String, String>, usize) {
|
||||
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() {
|
||||
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;
|
||||
@@ -923,7 +789,7 @@ mod tests {
|
||||
// A file that Bundled::read does not name is never loaded.
|
||||
let path = format!("assets/horizon/lang/{name}");
|
||||
assert!(
|
||||
!bundled || Bundled.read(&path).is_some(),
|
||||
Bundled.read(&path).is_some(),
|
||||
"{name} is not served by Bundled"
|
||||
);
|
||||
let body = std::fs::read_to_string(entry.path()).unwrap();
|
||||
@@ -949,7 +815,11 @@ mod tests {
|
||||
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
|
||||
}
|
||||
}
|
||||
(en, seen)
|
||||
assert!(
|
||||
seen >= 7,
|
||||
"found {seen} bundled lang files in {}",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -964,10 +834,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
let english = |dir: &std::path::Path| -> HashMap<String, String> {
|
||||
serde_json::from_str(&std::fs::read_to_string(dir.join("en_us.json")).unwrap()).unwrap()
|
||||
};
|
||||
let mut en = english(&bundled_dir());
|
||||
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 [
|
||||
@@ -977,11 +847,6 @@ mod tests {
|
||||
] {
|
||||
rust_files(&crates.join(krate).join("src"), &mut files);
|
||||
}
|
||||
// A crate that ships lang files names its keys in its own sources.
|
||||
for (name, dir) in crate_lang_dirs() {
|
||||
en.extend(english(&dir));
|
||||
rust_files(&crates.join(name).join("src"), &mut files);
|
||||
}
|
||||
assert!(files.len() > 100, "found only {} sources", files.len());
|
||||
let mut found = 0;
|
||||
for file in files {
|
||||
|
||||
@@ -115,7 +115,8 @@ default = ["audio", "controllers", "horizon-extras"]
|
||||
audio = ["horizon-client-render/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
|
||||
# Horizon's own parts still in this crate (docs/forking.md, docs/layers.md): the features below.
|
||||
# Horizon's own parts (docs/forking.md, docs/layers.md): the features below, and the official
|
||||
# server in the list (`official_servers`).
|
||||
horizon-extras = ["discord"]
|
||||
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
|
||||
discord = []
|
||||
|
||||
@@ -386,7 +386,6 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
for _ in 0..32 {
|
||||
draw(&mut renderer)?;
|
||||
|
||||
@@ -207,7 +207,6 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: out.join("cpu.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.render(&frame)?;
|
||||
let expected = read(&out.join("cpu.png"));
|
||||
@@ -252,7 +251,6 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: out.join("gpu.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.render(&frame)?;
|
||||
assert_eq!(
|
||||
|
||||
@@ -167,7 +167,6 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
// twice: the first frame of a new warp builds it
|
||||
renderer.render(&frame).unwrap();
|
||||
@@ -337,7 +336,6 @@ fn bench(renderer: &mut Renderer, out: &Path) -> Result<(), Box<dyn std::error::
|
||||
path: out.join("bench.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.render(&frame).unwrap();
|
||||
renderer.render(&frame).unwrap();
|
||||
|
||||
@@ -112,11 +112,7 @@ fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
|
||||
};
|
||||
Row::new(id, lang::get(label), value)
|
||||
}
|
||||
added::Row::Button {
|
||||
label,
|
||||
active: true,
|
||||
..
|
||||
} => Row::new(id, lang::get(label), Value::Press),
|
||||
added::Row::Button { label } => Row::new(id, lang::get(label), Value::Press),
|
||||
added::Row::Key { label, key } => {
|
||||
let key = crate::keybinds::display_name(&key).plain();
|
||||
Row::new(id, lang::get(label), Value::Read(key))
|
||||
@@ -494,7 +490,9 @@ mod tests {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::button("test.elsewhere.button"));
|
||||
rows.push(added::Row::Button {
|
||||
label: "test.elsewhere.button",
|
||||
});
|
||||
}
|
||||
fn act(&self, _: usize, _: Act) {}
|
||||
}
|
||||
|
||||
@@ -233,8 +233,6 @@ pub struct FogInputs<'a> {
|
||||
pub night_vision: f32,
|
||||
/// The packs' `environment` fog settings (Polytone's).
|
||||
pub environment: crate::pack_palette::Environment,
|
||||
/// The packs' fluid modifiers' fog.
|
||||
pub fluid_fog: crate::pack_palette::FluidFogs,
|
||||
}
|
||||
|
||||
/// `FogRenderer.setupFog` + `computeFogColor`.
|
||||
@@ -399,24 +397,6 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
}
|
||||
}
|
||||
}
|
||||
// A pack's fluid modifier over its fluid's fog, after everything else (wiki "Custom Colors").
|
||||
let fluid = match input.fog_type {
|
||||
FogType::Lava => input.fluid_fog.lava,
|
||||
FogType::Water if input.blindness.is_none() && input.darkness.is_none() => {
|
||||
input.fluid_fog.water
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(f) = fluid {
|
||||
if let Some(rgb) = f.color {
|
||||
let [r, g, b] = self::rgb(rgb);
|
||||
fog.color = [r, g, b, 1.0];
|
||||
}
|
||||
let end = fog.environmental_end * f.radius;
|
||||
let start = end - (end - fog.environmental_start * f.radius) * f.fade;
|
||||
(fog.environmental_start, fog.environmental_end) = (start, end);
|
||||
(fog.sky_end, fog.cloud_end) = (end, end);
|
||||
}
|
||||
let span = (rd / 10.0).clamp(4.0, 64.0);
|
||||
fog.render_distance_start = rd - span;
|
||||
fog.render_distance_end = rd;
|
||||
@@ -515,7 +495,6 @@ mod tests {
|
||||
full_water_vision: false,
|
||||
night_vision: 0.0,
|
||||
environment: Default::default(),
|
||||
fluid_fog: Default::default(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
@@ -632,38 +611,6 @@ mod tests {
|
||||
let kept = setup_fog(&tables, &state, &input).color;
|
||||
assert_eq!(&kept[..3], &[0.1, 0.2, 0.4]);
|
||||
}
|
||||
#[test]
|
||||
fn a_fluid_modifier_scales_and_colours_its_fog_last() {
|
||||
let sky = Sky {
|
||||
fog_start: 10.0,
|
||||
fog_end: 96.0,
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let mut input = inputs(&sky);
|
||||
input.fog_type = FogType::Lava;
|
||||
input.fire_resistance = true;
|
||||
let vanilla = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(
|
||||
(vanilla.environmental_start, vanilla.environmental_end),
|
||||
(0.0, 5.0)
|
||||
);
|
||||
input.fluid_fog.lava = Some(crate::pack_palette::FluidFog {
|
||||
color: Some(0x00FF00),
|
||||
radius: 2.0,
|
||||
fade: 0.5,
|
||||
});
|
||||
let fog = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(fog.color, [0.0, 1.0, 0.0, 1.0]);
|
||||
// The end doubles; half the fade from 0 to 10 starts it at 5.
|
||||
assert_eq!(
|
||||
(fog.environmental_start, fog.environmental_end),
|
||||
(5.0, 10.0)
|
||||
);
|
||||
assert_eq!((fog.sky_end, fog.cloud_end), (10.0, 10.0));
|
||||
}
|
||||
fn inputs_with<'a>(
|
||||
input: &FogInputs<'a>,
|
||||
environment: crate::pack_palette::Environment,
|
||||
|
||||
@@ -201,7 +201,6 @@ pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
|
||||
mod hold;
|
||||
pub use hold::WorldHold;
|
||||
pub use output::{DisplayReading, EffectiveOutput, FrameConfiguration, FrameRequest};
|
||||
pub use screenshot_save::Saved as SavedScreenshot;
|
||||
pub mod block_light;
|
||||
mod custom_sky;
|
||||
mod displace;
|
||||
@@ -237,7 +236,6 @@ pub mod panorama;
|
||||
mod readback;
|
||||
mod resident;
|
||||
mod reuse;
|
||||
mod screenshot_save;
|
||||
mod stars;
|
||||
mod surface_setup;
|
||||
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
|
||||
@@ -1086,9 +1084,6 @@ pub struct ScreenshotRequest {
|
||||
/// F2's: drawn at the game's resolution (Resolution Scale above 100%) times
|
||||
/// `Renderer::screenshot_quality`, not the window's.
|
||||
pub scaled: bool,
|
||||
/// Written on a thread of its own (F2's, the world icon's): its outcome comes back a
|
||||
/// frame or a few later in `Renderer::saved_screenshots`, not `screenshot_result`.
|
||||
pub background: bool,
|
||||
}
|
||||
/// A solid terrain pipeline without HDR output's lighting (terrain.wgsl's `HDR_LIGHTING`),
|
||||
/// for the frames HDR is off, and with it where the display offers HDR output: the
|
||||
@@ -1583,9 +1578,6 @@ pub struct Renderer {
|
||||
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
|
||||
/// The HDR copy the last screenshot wrote beside it, if it wrote one.
|
||||
pub screenshot_hdr: Option<std::path::PathBuf>,
|
||||
/// `background` screenshots written since the caller last took them.
|
||||
pub saved_screenshots: Vec<SavedScreenshot>,
|
||||
screenshot_saves: screenshot_save::Saves,
|
||||
/// Screenshot Quality (Horizon setting): a `scaled` screenshot's frame is this many
|
||||
/// times the game's resolution (1, 2, 4 or 8).
|
||||
pub screenshot_quality: u32,
|
||||
@@ -5085,7 +5077,6 @@ impl Renderer {
|
||||
));
|
||||
}
|
||||
let world_primitives = world_primitives::WorldPrimitives::new(&device, frame_format);
|
||||
let screenshot_saves = screenshot_save::Saves::new(&device);
|
||||
Ok(Self {
|
||||
display: None,
|
||||
reading: display,
|
||||
@@ -5357,8 +5348,6 @@ impl Renderer {
|
||||
screenshot: None,
|
||||
screenshot_result: None,
|
||||
screenshot_hdr: None,
|
||||
saved_screenshots: Vec::new(),
|
||||
screenshot_saves,
|
||||
screenshot_quality: 1,
|
||||
screenshot_halvings: 0,
|
||||
gamma: 0.5,
|
||||
@@ -7052,10 +7041,8 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
/// This renderer's output as applied, with what its destination offers and reports.
|
||||
pub fn output(&self) -> output::EffectiveOutput {
|
||||
let native = self.native.as_ref().map(|n| n.frame.api);
|
||||
self.output
|
||||
.with_pack(self.shader_pack_kind() == Some("iris"), native)
|
||||
pub fn output(&self) -> &output::EffectiveOutput {
|
||||
&self.output
|
||||
}
|
||||
/// What this renderer's display last reported: a renderer made again for the window
|
||||
/// starts from it (`Renderer::new`'s `display`).
|
||||
@@ -7988,8 +7975,6 @@ impl Renderer {
|
||||
if self.device_lost() {
|
||||
return Err("the graphics device was lost".into());
|
||||
}
|
||||
let saved = self.screenshot_saves.finished();
|
||||
self.saved_screenshots.extend(saved);
|
||||
if let Some(request) = self.capture.take() {
|
||||
let result = self.write_capture(&request, frame);
|
||||
self.capture_result = Some(result.map_err(|e| e.to_string()));
|
||||
@@ -8334,7 +8319,9 @@ impl Renderer {
|
||||
let taa = !frame.title
|
||||
&& 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());
|
||||
let hdr_lighting = self.shader_pack_kind().is_none();
|
||||
let hdr_lighting = self.native.is_none()
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0");
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.lighting = hdr_lighting && hdr.look;
|
||||
}
|
||||
@@ -12057,18 +12044,24 @@ impl Renderer {
|
||||
self.screenshot = Some(request);
|
||||
} else {
|
||||
self.screenshot_halvings = 0;
|
||||
let size = [self.config.width, self.config.height];
|
||||
let hdr = hdr_readback
|
||||
.filter(|_| self.hdr.is_some())
|
||||
.map(|b| (hdr_screenshot_path(&request.path), b));
|
||||
let shot = screenshot_save::Shot::new(&request, size, self.config.format, hdr);
|
||||
if request.background {
|
||||
self.screenshot_saves.start(shot, buffer);
|
||||
} else {
|
||||
let saved = screenshot_save::save_now(&self.device, shot, &buffer);
|
||||
self.screenshot_hdr = saved.hdr;
|
||||
self.screenshot_result = Some(saved.result);
|
||||
self.screenshot_hdr = None;
|
||||
if let Some(hdr_buffer) = hdr_readback {
|
||||
let hdr_path = hdr_screenshot_path(&request.path);
|
||||
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
|
||||
Ok(()) => {
|
||||
log::info!("Saved HDR screenshot to {}", hdr_path.display());
|
||||
self.screenshot_hdr = Some(hdr_path);
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"Horizon renderer: HDR screenshot {}: {e}",
|
||||
hdr_path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&request, &buffer)
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
// The frame's own waits for the submit thread (its queue writes and passes).
|
||||
@@ -12671,22 +12664,96 @@ impl Renderer {
|
||||
let texture = copy.as_ref().unwrap_or(texture);
|
||||
Some((request, copy_frame(&self.device, encoder, texture, size)))
|
||||
} else {
|
||||
let error = format!(
|
||||
self.screenshot_result = Some(Err(format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
);
|
||||
if request.background {
|
||||
self.saved_screenshots.push(screenshot_save::Saved {
|
||||
path: request.path,
|
||||
result: Err(error),
|
||||
hdr: None,
|
||||
});
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(error));
|
||||
}
|
||||
)));
|
||||
None
|
||||
}
|
||||
}
|
||||
/// The pixels `copy_frame` read back into `buffer`, 4 bytes each, row after row.
|
||||
fn read_frame(&self, buffer: &wgpu::Buffer) -> Result<Vec<[u8; 4]>> {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = sender.send(r);
|
||||
});
|
||||
self.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.map_err(|e| e.to_string())?;
|
||||
receiver
|
||||
.recv()
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (width, height, row) = (
|
||||
self.config.width as usize,
|
||||
self.config.height as usize,
|
||||
padded_row(self.config.width) as usize,
|
||||
);
|
||||
let mut pixels = Vec::with_capacity(width * height);
|
||||
{
|
||||
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
|
||||
for y in 0..height {
|
||||
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
Ok(pixels)
|
||||
}
|
||||
fn write_screenshot(
|
||||
&self,
|
||||
request: &ScreenshotRequest,
|
||||
buffer: &wgpu::Buffer,
|
||||
) -> Result<[u32; 2]> {
|
||||
let bgra = matches!(
|
||||
self.config.format.remove_srgb_suffix(),
|
||||
wgpu::TextureFormat::Bgra8Unorm
|
||||
);
|
||||
let mut pixels = Vec::with_capacity((self.config.width * self.config.height * 4) as usize);
|
||||
for px in self.read_frame(buffer)? {
|
||||
let (r, b) = if bgra { (px[2], px[0]) } else { (px[0], px[2]) };
|
||||
pixels.extend_from_slice(&[r, px[1], b, 255]);
|
||||
}
|
||||
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
|
||||
// is the frame's centre square averaged down to its size.
|
||||
let (width, height, pixels) = match request.size {
|
||||
Some(size) => (
|
||||
size[0],
|
||||
size[1],
|
||||
square_downscale(&pixels, self.config.width, self.config.height, size),
|
||||
),
|
||||
None => (self.config.width, self.config.height, pixels),
|
||||
};
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(&request.path)?),
|
||||
width,
|
||||
height,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder.write_header()?.write_image_data(&pixels)?;
|
||||
Ok([width, height])
|
||||
}
|
||||
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
|
||||
/// third edition), with the content light levels its pixels hold (cLLI), and
|
||||
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
|
||||
fn write_hdr_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
|
||||
if self.hdr.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let pixels = hdr10_to_rgb16(&self.read_frame(buffer)?);
|
||||
let mut info = png::Info::with_size(self.config.width, self.config.height);
|
||||
info.color_type = png::ColorType::Rgb;
|
||||
info.bit_depth = png::BitDepth::Sixteen;
|
||||
info.icc_profile = Some(hdr_icc::profile().into());
|
||||
let encoder =
|
||||
png::Encoder::with_info(std::io::BufWriter::new(std::fs::File::create(path)?), info)?;
|
||||
let mut writer = encoder.write_header()?;
|
||||
// BT.2020 primaries, PQ, RGB, full range.
|
||||
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
|
||||
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
|
||||
writer.write_image_data(&pixels)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
|
||||
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
|
||||
|
||||
@@ -191,7 +191,7 @@ pub struct Hdr {
|
||||
pub masks: Masks,
|
||||
/// HDR lighting, whether Horizon lifts this frame's world: off under a Horizon shader pack
|
||||
/// that draws, while a `shaders/` pack's post or material stage runs (the pack's own),
|
||||
/// and with HDR Lighting (`look`) off.
|
||||
/// with HDR Lighting (`look`) off, and with `HORIZON_HDR_LIGHTING=0`, to compare.
|
||||
pub lighting: bool,
|
||||
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
|
||||
/// sun included, so the picture is SDR's on the HDR canvas.
|
||||
|
||||
@@ -144,9 +144,6 @@ pub struct EffectiveOutput {
|
||||
pub(crate) nits: [f32; 3],
|
||||
/// The HUD and menus' brightness, as a share of paper white: their nits over its.
|
||||
pub(crate) ui_brightness: f32,
|
||||
/// The running shader pack lights the world itself: Horizon lifts none of it, so HDR
|
||||
/// Lighting has nothing to switch (`with_pack`).
|
||||
pub(crate) pack_lights: bool,
|
||||
}
|
||||
impl Default for EffectiveOutput {
|
||||
fn default() -> Self {
|
||||
@@ -169,22 +166,7 @@ impl EffectiveOutput {
|
||||
},
|
||||
nits: [203.0, 1000.0, 0.0],
|
||||
ui_brightness: 1.0,
|
||||
pack_lights: false,
|
||||
};
|
||||
/// This output under the running shader pack: a `shaders/` one (`shaders`), or one of
|
||||
/// Horizon's own of API `native`. Under a 0.1 pack on the 8- or 16-bit frame the last pass
|
||||
/// still lifts the light sources and the sun its marks hold (`lifts_world` in
|
||||
/// `Renderer::render`); under the rest Horizon lifts nothing.
|
||||
pub(crate) fn with_pack(self, shaders: bool, native: Option<horizon_shader_api::Api>) -> Self {
|
||||
let pack_lights = shaders
|
||||
|| native.is_some_and(|api| {
|
||||
api != horizon_shader_api::Api::V0_1 || self.frame.format == super::LINEAR_FRAME
|
||||
});
|
||||
Self {
|
||||
pack_lights,
|
||||
..self
|
||||
}
|
||||
}
|
||||
/// Whether the destination offers HDR output.
|
||||
pub fn hdr_offered(&self) -> bool {
|
||||
self.offered.is_some()
|
||||
@@ -291,7 +273,6 @@ pub(crate) fn resolve_output(
|
||||
],
|
||||
ui_brightness: settings.ui_nits(reading.luminance) as f32
|
||||
/ settings.paper_white_nits(reading.luminance) as f32,
|
||||
pack_lights: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
//! Writing a screenshot's PNG once its frame is read back: on the render thread for the
|
||||
//! requests that want the result with the frame (an agent's, a `screenshot-sequence`'s),
|
||||
//! on a thread of its own for F2's and the world icon's (`ScreenshotRequest::background`).
|
||||
//! The render thread then neither waits for the GPU to finish the frame nor encodes the
|
||||
//! PNG, which at 4K or a Screenshot Quality above 1x froze the game for a second or more.
|
||||
use super::{ScreenshotRequest, hdr_icc, hdr10_to_rgb16, padded_row, square_downscale};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
/// What a frame's readback holds and where its PNG goes.
|
||||
pub(super) struct Shot {
|
||||
pub path: PathBuf,
|
||||
/// `ScreenshotRequest::size`: the world icon's square.
|
||||
pub size: Option<[u32; 2]>,
|
||||
/// The frame's size, which the readback's rows hold (padded to `padded_row`).
|
||||
pub frame: [u32; 2],
|
||||
/// Blue first (`Bgra8Unorm`), swapped to PNG's red first.
|
||||
pub bgra: bool,
|
||||
/// The HDR10 copy's readback and its PNG's path, beside the SDR one.
|
||||
pub hdr: Option<(PathBuf, wgpu::Buffer)>,
|
||||
}
|
||||
impl Shot {
|
||||
pub fn new(
|
||||
request: &ScreenshotRequest,
|
||||
frame: [u32; 2],
|
||||
format: wgpu::TextureFormat,
|
||||
hdr: Option<(PathBuf, wgpu::Buffer)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
path: request.path.clone(),
|
||||
size: request.size,
|
||||
frame,
|
||||
bgra: format.remove_srgb_suffix() == wgpu::TextureFormat::Bgra8Unorm,
|
||||
hdr,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A written screenshot: its path, its size or the error, and the HDR copy written beside it.
|
||||
pub struct Saved {
|
||||
pub path: PathBuf,
|
||||
pub result: Result<[u32; 2], String>,
|
||||
pub hdr: Option<PathBuf>,
|
||||
}
|
||||
/// Waits for `buffer`'s mapping on the render thread and writes the PNG there.
|
||||
pub(super) fn save_now(device: &wgpu::Device, shot: Shot, buffer: &wgpu::Buffer) -> Saved {
|
||||
let (sender, receiver) = channel();
|
||||
map(buffer, &sender);
|
||||
if let Some((_, hdr)) = &shot.hdr {
|
||||
map(hdr, &sender);
|
||||
}
|
||||
let waited = device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.map_err(|e| e.to_string());
|
||||
let count = 1 + usize::from(shot.hdr.is_some());
|
||||
let mapped = waited.and_then(|_| wait_mapped(&receiver, count));
|
||||
write(shot, buffer, mapped)
|
||||
}
|
||||
/// Screenshots being written on their thread.
|
||||
pub(super) struct Saves {
|
||||
device: wgpu::Device,
|
||||
sender: Sender<Saved>,
|
||||
receiver: Receiver<Saved>,
|
||||
in_flight: usize,
|
||||
}
|
||||
impl Saves {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
let (sender, receiver) = channel();
|
||||
Self {
|
||||
device: device.clone(),
|
||||
sender,
|
||||
receiver,
|
||||
in_flight: 0,
|
||||
}
|
||||
}
|
||||
/// Maps `buffer` (and the HDR copy's), whose copy was just submitted, and writes the
|
||||
/// PNG on a thread of its own once the GPU has finished the frame.
|
||||
pub fn start(&mut self, shot: Shot, buffer: wgpu::Buffer) {
|
||||
let (mapped, on_mapped) = channel();
|
||||
map(&buffer, &mapped);
|
||||
if let Some((_, hdr)) = &shot.hdr {
|
||||
map(hdr, &mapped);
|
||||
}
|
||||
let done = self.sender.clone();
|
||||
let path = shot.path.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Screenshot writer".into())
|
||||
.spawn(move || {
|
||||
let count = 1 + usize::from(shot.hdr.is_some());
|
||||
let ready = wait_mapped(&on_mapped, count);
|
||||
let _ = done.send(write(shot, &buffer, ready));
|
||||
});
|
||||
match spawned {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
let _ = self.sender.send(Saved {
|
||||
path,
|
||||
result: Err(format!("no thread to write it: {e}")),
|
||||
hdr: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.in_flight += 1;
|
||||
}
|
||||
/// The screenshots written since the last call. Polls the device so a finished frame's
|
||||
/// mapping reaches its writer: the render thread never waits for it.
|
||||
pub fn finished(&mut self) -> Vec<Saved> {
|
||||
if self.in_flight == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let _ = self.device.poll(wgpu::PollType::Poll);
|
||||
let saved: Vec<Saved> = self.receiver.try_iter().collect();
|
||||
self.in_flight -= saved.len();
|
||||
saved
|
||||
}
|
||||
}
|
||||
impl Drop for Saves {
|
||||
/// A screenshot taken right before quitting is still written whole.
|
||||
fn drop(&mut self) {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while self.in_flight > 0 && std::time::Instant::now() < deadline {
|
||||
let _ = self.device.poll(wgpu::PollType::Poll);
|
||||
if let Ok(saved) = self.receiver.recv_timeout(Duration::from_millis(20)) {
|
||||
log_saved(&saved);
|
||||
self.in_flight -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn log_saved(saved: &Saved) {
|
||||
match &saved.result {
|
||||
Ok(_) => log::info!("Saved screenshot to {}", saved.path.display()),
|
||||
Err(e) => log::warn!("Horizon renderer: screenshot {}: {e}", saved.path.display()),
|
||||
}
|
||||
}
|
||||
fn map(buffer: &wgpu::Buffer, mapped: &Sender<Result<(), String>>) {
|
||||
let mapped = mapped.clone();
|
||||
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = mapped.send(r.map_err(|e| e.to_string()));
|
||||
});
|
||||
}
|
||||
/// `count` mappings done, or the first one that failed. A frame the GPU never finishes
|
||||
/// (a lost device) gives up after a while instead of keeping its thread.
|
||||
fn wait_mapped(mapped: &Receiver<Result<(), String>>, count: usize) -> Result<(), String> {
|
||||
for _ in 0..count {
|
||||
mapped
|
||||
.recv_timeout(Duration::from_secs(30))
|
||||
.map_err(|_| "the frame never came back from the GPU".to_string())??;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The PNG (and the HDR copy's beside it) from the mapped readbacks.
|
||||
fn write(shot: Shot, buffer: &wgpu::Buffer, mapped: Result<(), String>) -> Saved {
|
||||
let mut hdr_written = None;
|
||||
let result = mapped.and_then(|()| {
|
||||
if let Some((hdr_path, hdr_buffer)) = &shot.hdr {
|
||||
let pixels = read_rows(hdr_buffer, shot.frame);
|
||||
match pixels.and_then(|p| write_hdr_png(hdr_path, shot.frame, &p)) {
|
||||
Ok(()) => {
|
||||
log::info!("Saved HDR screenshot to {}", hdr_path.display());
|
||||
hdr_written = Some(hdr_path.clone());
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"Horizon renderer: HDR screenshot {}: {e}",
|
||||
hdr_path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
let pixels = read_rows(buffer, shot.frame)?;
|
||||
write_png(&shot, &pixels)
|
||||
});
|
||||
Saved {
|
||||
path: shot.path,
|
||||
result,
|
||||
hdr: hdr_written,
|
||||
}
|
||||
}
|
||||
/// The pixels `copy_frame` read back into the mapped `buffer`, 4 bytes each, row after
|
||||
/// row; unmaps it.
|
||||
fn read_rows(buffer: &wgpu::Buffer, [width, height]: [u32; 2]) -> Result<Vec<[u8; 4]>, String> {
|
||||
let (width, height, row) = (width as usize, height as usize, padded_row(width) as usize);
|
||||
let mut pixels = Vec::with_capacity(width * height);
|
||||
{
|
||||
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
|
||||
for y in 0..height {
|
||||
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
Ok(pixels)
|
||||
}
|
||||
fn write_png(shot: &Shot, frame: &[[u8; 4]]) -> Result<[u32; 2], String> {
|
||||
let [frame_w, frame_h] = shot.frame;
|
||||
let mut pixels = Vec::with_capacity(frame.len() * 4);
|
||||
for px in frame {
|
||||
let (r, b) = if shot.bgra {
|
||||
(px[2], px[0])
|
||||
} else {
|
||||
(px[0], px[2])
|
||||
};
|
||||
pixels.extend_from_slice(&[r, px[1], b, 255]);
|
||||
}
|
||||
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
|
||||
// is the frame's centre square averaged down to its size.
|
||||
let (width, height, pixels) = match shot.size {
|
||||
Some(size) => (
|
||||
size[0],
|
||||
size[1],
|
||||
square_downscale(&pixels, frame_w, frame_h, size),
|
||||
),
|
||||
None => (frame_w, frame_h, pixels),
|
||||
};
|
||||
let file = std::fs::File::create(&shot.path).map_err(|e| e.to_string())?;
|
||||
let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), width, height);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.and_then(|mut w| w.write_image_data(&pixels))
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok([width, height])
|
||||
}
|
||||
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
|
||||
/// third edition), with the content light levels its pixels hold (cLLI), and
|
||||
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
|
||||
fn write_hdr_png(
|
||||
path: &std::path::Path,
|
||||
[width, height]: [u32; 2],
|
||||
frame: &[[u8; 4]],
|
||||
) -> Result<(), String> {
|
||||
let pixels = hdr10_to_rgb16(frame);
|
||||
let mut info = png::Info::with_size(width, height);
|
||||
info.color_type = png::ColorType::Rgb;
|
||||
info.bit_depth = png::BitDepth::Sixteen;
|
||||
info.icc_profile = Some(hdr_icc::profile().into());
|
||||
let file = std::fs::File::create(path).map_err(|e| e.to_string())?;
|
||||
let written = png::Encoder::with_info(std::io::BufWriter::new(file), info)
|
||||
.and_then(|encoder| encoder.write_header())
|
||||
.and_then(|mut writer| {
|
||||
// BT.2020 primaries, PQ, RGB, full range.
|
||||
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
|
||||
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
|
||||
writer.write_image_data(&pixels)
|
||||
});
|
||||
written.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_background_save_writes_the_png_off_the_render_thread() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let dir = std::env::temp_dir().join(format!("horizon-shot-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("shot.png");
|
||||
let [w, h] = [3u32, 2];
|
||||
// A frame of blue-first pixels, each row padded as `copy_frame` reads them back.
|
||||
let row = padded_row(w) as usize;
|
||||
let mut bytes = vec![0u8; row * h as usize];
|
||||
for y in 0..h as usize {
|
||||
for x in 0..w as usize {
|
||||
bytes[y * row + x * 4..][..4].copy_from_slice(&[10 * x as u8, 20, 200, 0]);
|
||||
}
|
||||
}
|
||||
let source = wgpu::util::DeviceExt::create_buffer_init(
|
||||
&device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &bytes,
|
||||
usage: wgpu::BufferUsages::COPY_SRC,
|
||||
},
|
||||
);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: bytes.len() as u64,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(&source, 0, &buffer, 0, bytes.len() as u64);
|
||||
queue.submit([encoder.finish()]);
|
||||
let request = ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: true,
|
||||
};
|
||||
let mut saves = Saves::new(&device);
|
||||
saves.start(
|
||||
Shot::new(&request, [w, h], wgpu::TextureFormat::Bgra8Unorm, None),
|
||||
buffer,
|
||||
);
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
let saved = loop {
|
||||
if let Some(saved) = saves.finished().pop() {
|
||||
break saved;
|
||||
}
|
||||
assert!(std::time::Instant::now() < deadline, "never written");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
};
|
||||
assert_eq!(saved.path, path);
|
||||
assert_eq!(saved.result, Ok([w, h]));
|
||||
let decoder =
|
||||
png::Decoder::new(std::io::BufReader::new(std::fs::File::open(&path).unwrap()));
|
||||
let mut reader = decoder.read_info().unwrap();
|
||||
let mut out = vec![0; reader.output_buffer_size().unwrap()];
|
||||
reader.next_frame(&mut out).unwrap();
|
||||
// Red first, opaque: the last pixel of the first row was [20, 20, 200] blue-first.
|
||||
assert_eq!(&out[8..12], &[200, 20, 20, 255]);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1110,8 +1110,7 @@ impl Item {
|
||||
/// active; saved values wait for it. Translucent Batching switches where the device can batch
|
||||
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
|
||||
/// Smooth Gradients only where it can draw the 16-bit frame, the same way, and GPU Culling
|
||||
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't. HDR
|
||||
/// Lighting has nothing to switch where the running shader pack lights the world itself.
|
||||
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't.
|
||||
fn blocked(self, s: &Settings, out: &EffectiveOutput) -> Option<&'static str> {
|
||||
if !self.available() {
|
||||
return Some("This feature is not yet available in Horizon.");
|
||||
@@ -1142,9 +1141,6 @@ impl Item {
|
||||
} else {
|
||||
"Turn HDR on to use it."
|
||||
}),
|
||||
Item::Horizon(H::HdrLighting) if out.pack_lights => {
|
||||
Some("The shader pack lights the world itself.")
|
||||
}
|
||||
Item::Horizon(H::TranslucentBatching)
|
||||
if !crate::graphics::translucent_batching_offered() =>
|
||||
{
|
||||
|
||||
@@ -31,49 +31,6 @@ fn smooth_gradients_wait_for_a_device_that_draws_the_16_bit_frame() {
|
||||
assert_eq!(item.blocked(&s, &out), Some("Turn HDR on to use it."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_lighting_is_greyed_only_where_the_running_pack_lights_the_world() {
|
||||
use horizon_shader_api::Api;
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
let item = Item::Horizon(H::HdrLighting);
|
||||
let lights = Some("The shader pack lights the world itself.");
|
||||
let out = offering(true, None);
|
||||
// No pack running, as with one picked but refused, missing or its mod off: live.
|
||||
assert_eq!(item.blocked(&s, &out.with_pack(false, None)), None);
|
||||
// A 0.1 pack on the 16-bit frame: the light sources and the sun still shine.
|
||||
assert_eq!(
|
||||
item.blocked(&s, &out.with_pack(false, Some(Api::V0_1))),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
item.blocked(&s, &out.with_pack(false, Some(Api::V0_2))),
|
||||
lights
|
||||
);
|
||||
assert_eq!(item.blocked(&s, &out.with_pack(true, None)), lights);
|
||||
let float = EffectiveOutput {
|
||||
frame: crate::gfx::FrameConfiguration {
|
||||
format: wgpu::TextureFormat::Rgba16Float,
|
||||
..out.frame
|
||||
},
|
||||
..out
|
||||
};
|
||||
assert_eq!(
|
||||
item.blocked(&s, &float.with_pack(false, Some(Api::V0_1))),
|
||||
lights
|
||||
);
|
||||
// HDR's own reasons come first.
|
||||
assert_eq!(
|
||||
item.blocked(&s, &NONE.with_pack(true, None)),
|
||||
Some("Your display or system doesn't offer HDR output.")
|
||||
);
|
||||
s.horizon.hdr = HdrMode::Off;
|
||||
assert_eq!(
|
||||
item.blocked(&s, &out.with_pack(true, None)),
|
||||
Some("Turn HDR on to use it.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_auto_can_be_selected_before_querying_and_brightness_waits_for_output() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -162,38 +162,10 @@ impl OptionsUi {
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
registered.rows(&mut rows);
|
||||
let mut out = Vec::new();
|
||||
// The row a half-width button started, waiting for the next to sit beside it.
|
||||
let mut half_open = false;
|
||||
for (r, row) in rows.into_iter().enumerate() {
|
||||
match row {
|
||||
// Wrapped as the engine's own notes; space is for pages drawn as a screen.
|
||||
added::Row::Text { text, .. } => {
|
||||
out.extend(wrap(&text, 64).into_iter().map(Row::Note))
|
||||
}
|
||||
added::Row::Space(_) => continue,
|
||||
added::Row::Button {
|
||||
label,
|
||||
active,
|
||||
half: true,
|
||||
..
|
||||
} => {
|
||||
let mut half = button(0.0, 0.0, 150.0, tr(label), Act::PageRow(page, r));
|
||||
if !active {
|
||||
half = inactive(half);
|
||||
}
|
||||
match out.last_mut() {
|
||||
Some(Row::Buttons(_, second @ None)) if half_open => *second = Some(half),
|
||||
_ => out.push(Row::Buttons(half, None)),
|
||||
}
|
||||
half_open = !half_open;
|
||||
continue;
|
||||
}
|
||||
row => out.push(self.added_row(page, r, row)),
|
||||
}
|
||||
half_open = false;
|
||||
}
|
||||
out
|
||||
rows.into_iter()
|
||||
.enumerate()
|
||||
.map(|(r, row)| self.added_row(page, r, row))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn added_row(&self, page: usize, r: usize, row: added::Row) -> Row {
|
||||
@@ -205,10 +177,7 @@ impl OptionsUi {
|
||||
added::Row::Toggle { label, on } => wide(generic(label, on_off(on))),
|
||||
added::Row::Cycle { label, value } => wide(generic(label, label_text(value))),
|
||||
added::Row::Slider { .. } => Row::Big(Opt::Page(page, r)),
|
||||
added::Row::Button { label, active, .. } => {
|
||||
let wide = button(0.0, 0.0, 310.0, tr(label), act);
|
||||
Row::Buttons(if active { wide } else { inactive(wide) }, None)
|
||||
}
|
||||
added::Row::Button { label } => wide(tr(label)),
|
||||
added::Row::Key { label, key } => {
|
||||
let mut message = keybinds::display_name(&key);
|
||||
if self.selected_key == Some(PAGE_KEY)
|
||||
@@ -492,84 +461,4 @@ mod tests {
|
||||
Some(tr("test.sample.switch.tooltip").plain())
|
||||
);
|
||||
}
|
||||
|
||||
/// Text, a grey note, space, two half buttons (the second inactive) and a footer button.
|
||||
struct Shaped(Mutex<Vec<usize>>);
|
||||
|
||||
impl added::Page for Shaped {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.shaped"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"test.shaped.title"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::text("ABCDE-FGHIJ").groups());
|
||||
rows.push(added::Row::text("grey").quiet());
|
||||
rows.push(added::Row::Space(6));
|
||||
rows.push(added::Row::button("test.shaped.copy").half());
|
||||
rows.push(added::Row::button("test.shaped.stop").half().active(false));
|
||||
rows.push(added::Row::button("test.shaped.help").footer());
|
||||
}
|
||||
fn act(&self, row: usize, _: added::Act) {
|
||||
self.0.lock().unwrap().push(row);
|
||||
}
|
||||
}
|
||||
|
||||
static SHAPED: Shaped = Shaped(Mutex::new(Vec::new()));
|
||||
static SHAPED_PAGES: [&dyn added::Page; 1] = [&SHAPED];
|
||||
|
||||
#[test]
|
||||
fn half_buttons_share_a_row_and_an_inactive_one_is_never_pressed() {
|
||||
let mut s = Settings::default();
|
||||
TEST_PAGES.set(Some(&SHAPED_PAGES));
|
||||
let ui = OptionsUi::default();
|
||||
let rows = ui.added_rows(0);
|
||||
// The two texts as notes (space shows nothing here), the halves on one row, the
|
||||
// footer button below them (the settings shell has no footer of its own).
|
||||
assert!(matches!(&rows[0], Row::Note(t) if t == "ABCDE-FGHIJ"));
|
||||
assert!(matches!(&rows[1], Row::Note(t) if t == "grey"));
|
||||
let Row::Buttons(copy, Some(stop)) = &rows[2] else {
|
||||
panic!("the halves sit side by side: {:?}", rows.len());
|
||||
};
|
||||
assert_eq!((copy.w, stop.w), (150.0, 150.0));
|
||||
assert!(matches!(&rows[3], Row::Buttons(help, None) if help.w == 310.0));
|
||||
assert_eq!(rows.len(), 4);
|
||||
let mut ui = ui;
|
||||
ui.show(Page::Added(0));
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 3), false);
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 5), false);
|
||||
let (widgets, ..) = ui.layout(&s, 480.0, 240.0);
|
||||
let stop = widgets
|
||||
.iter()
|
||||
.find(|w| {
|
||||
matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::PageRow(0, 4),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.expect("Stop is drawn");
|
||||
let atlas = super::super::tests::atlas();
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
stop.x + 1.0,
|
||||
stop.y + 1.0,
|
||||
(480.0, 240.0),
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
*SHAPED.0.lock().unwrap(),
|
||||
[3, 5],
|
||||
"the inactive half heard nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,6 @@ use horizon_polytone::colors::PackColors;
|
||||
use crate::block_entity_render::DYES;
|
||||
use crate::pack_palette::{self, Environment};
|
||||
|
||||
fn fluid(fog: horizon_polytone::colors::FluidFog) -> pack_palette::FluidFog {
|
||||
pack_palette::FluidFog {
|
||||
color: fog.color,
|
||||
radius: fog.radius,
|
||||
fade: fog.fade,
|
||||
}
|
||||
}
|
||||
|
||||
/// The colours of every namespace's file under the pack overlay at `root`.
|
||||
pub fn read(root: &Path) -> pack_palette::PackColors {
|
||||
let colors = PackColors::read(root);
|
||||
@@ -37,10 +29,6 @@ pub fn read(root: &Path) -> pack_palette::PackColors {
|
||||
water_fog_brightening_time: env.water_fog_brightening_time,
|
||||
horizon_height: env.horizon_height,
|
||||
},
|
||||
fluid_fog: pack_palette::FluidFogs {
|
||||
water: colors.fluid_fog.water.map(fluid),
|
||||
lava: colors.fluid_fog.lava.map(fluid),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,24 +59,6 @@ pub struct PackColors {
|
||||
pub xp_bar: [Option<u32>; 2],
|
||||
/// The effect fogs' settings: the fog reads them every frame.
|
||||
pub env: Environment,
|
||||
/// The fog the packs' fluid modifiers give water and lava.
|
||||
pub fluid_fog: FluidFogs,
|
||||
}
|
||||
|
||||
/// A fluid modifier's fog: a flat colour (RGB), and the fog's radius and fade multipliers (1 is
|
||||
/// vanilla's).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FluidFog {
|
||||
pub color: Option<u32>,
|
||||
pub radius: f32,
|
||||
pub fade: f32,
|
||||
}
|
||||
|
||||
/// The fog the packs' fluid modifiers give water and lava.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct FluidFogs {
|
||||
pub water: Option<FluidFog>,
|
||||
pub lava: Option<FluidFog>,
|
||||
}
|
||||
|
||||
impl Default for PackColors {
|
||||
@@ -90,7 +72,6 @@ impl Default for PackColors {
|
||||
entity_shadows: true,
|
||||
xp_bar: [None; 2],
|
||||
env: Environment::default(),
|
||||
fluid_fog: FluidFogs::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,13 +337,6 @@ impl Settings {
|
||||
.and_then(|mode| self.video_modes.iter().position(|m| *m == mode))
|
||||
.map_or(-1, |i| i as i32)
|
||||
}
|
||||
/// Loads only the crates' settings stores (`horizon_hooks::settings`) from the files next to
|
||||
/// `path`, into the seat whose turn it is (`horizon_hooks::seat`): a splitscreen player whose
|
||||
/// settings were made from P1's rather than loaded.
|
||||
pub fn load_stores(path: &Path) {
|
||||
let dir = path.parent();
|
||||
horizon_hooks::settings::engine::load(&mut |file| fs::read_to_string(dir?.join(file)).ok());
|
||||
}
|
||||
pub fn load(path: &Path) -> Self {
|
||||
let mut horizon = path
|
||||
.parent()
|
||||
|
||||
@@ -72,6 +72,8 @@ horizon-theme = { path = "../horizon-theme" }
|
||||
# anonymous "I'm playing" ping is the Horizon layer's (`horizon-heartbeat`, in the official executable).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
# The machine and process probes (the dedicated server's performance report).
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
crc32fast = "1"
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
|
||||
@@ -287,7 +287,6 @@
|
||||
"This feature is not yet available in Horizon.": "This feature is not yet available in Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Your display or system doesn't offer HDR output.",
|
||||
"Turn HDR on to use it.": "Turn HDR on to use it.",
|
||||
"The shader pack lights the world itself.": "The shader pack lights the world itself.",
|
||||
"Your GPU or graphics API doesn't support it.": "Your GPU or graphics API doesn't support it.",
|
||||
"Not with software rendering, which runs it on the CPU.": "Not with software rendering, which runs it on the CPU.",
|
||||
"Out of video memory: off until the next start.": "Out of video memory: off until the next start.",
|
||||
|
||||
@@ -102,6 +102,8 @@ pub mod native_data;
|
||||
pub mod net;
|
||||
pub mod nether_portal;
|
||||
pub mod no_resources;
|
||||
#[cfg(feature = "horizon-extras")]
|
||||
pub mod official_servers;
|
||||
pub mod offline_name;
|
||||
pub mod old_world;
|
||||
pub mod osk;
|
||||
|
||||
@@ -23,12 +23,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
horizon_client::telemetry::mark_start();
|
||||
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
|
||||
crash::on_report(horizon_client::telemetry::keep_crash);
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`),
|
||||
// then the Horizon parts still in this crate; none registers after this point.
|
||||
game::player_hooks::register();
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`);
|
||||
// none registers after this point.
|
||||
horizon_hooks::engine::seal();
|
||||
// The text crates ship (`horizon_hooks::lang`), loaded with the game's own languages.
|
||||
horizon_client::lang::install_crate_files(horizon_hooks::lang::engine::all());
|
||||
horizon_client::spans::startup::init(std::time::Instant::now());
|
||||
horizon_client::spans::trace::start_from_env();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
|
||||
@@ -373,7 +373,6 @@ mod place_feature_tests;
|
||||
mod play_input;
|
||||
mod play_keys;
|
||||
mod player_equip;
|
||||
pub(crate) mod player_hooks;
|
||||
mod player_spawn;
|
||||
mod pointer_capture;
|
||||
#[cfg(test)]
|
||||
@@ -403,7 +402,6 @@ mod remote_vehicles;
|
||||
mod render_control;
|
||||
mod render_settings;
|
||||
mod render_tick;
|
||||
mod replay;
|
||||
mod repro;
|
||||
mod resource_load;
|
||||
mod resource_loader;
|
||||
@@ -2884,9 +2882,19 @@ struct Game<'a> {
|
||||
/// The open connection joined from Realms: `ServerData.isRealm` for the friends
|
||||
/// service's presence status.
|
||||
realm_session: bool,
|
||||
/// Replay playback (game/replay.rs), and Horizon Studio's editor on it (game/studio.rs).
|
||||
pub(super) replay: replay::Replay,
|
||||
pub(super) studio: studio::Studio,
|
||||
/// Active packet replay session (game/studio.rs).
|
||||
pub(super) studio_replay: Option<horizon_client::studio::ReplayDriver>,
|
||||
pub(super) studio_freecam: horizon_client::studio::StudioFreecam,
|
||||
pub(super) studio_timeline: horizon_client::studio::TimelineUi,
|
||||
pub(super) studio_topbar: horizon_client::studio::TopBarUi,
|
||||
pub(super) studio_rmb_down: bool,
|
||||
studio_camera_track_dirty: bool,
|
||||
/// Camera perspective to restore after leaving replay playback.
|
||||
studio_replay_previous_camera_type: Option<perspective::CameraType>,
|
||||
/// Replay opened from the title screen owns the synthetic remote session.
|
||||
studio_replay_standalone: bool,
|
||||
last_replay_tick: Option<Instant>,
|
||||
pub(super) studio_virtual_tick_ms: f32,
|
||||
/// This world is open to the network (game/lan.rs).
|
||||
lan: Option<lan::LanHost>,
|
||||
/// Single player's server thread, which `remote` is connected to.
|
||||
@@ -5934,7 +5942,8 @@ impl Game<'_> {
|
||||
// the sleep overlay, debug screen and open screens still draw.
|
||||
let spectator = self.spectator();
|
||||
let (wi, hi) = (w as i32, h as i32);
|
||||
let show_hud = !self.ui.hide_gui && !self.replay.playing();
|
||||
let in_studio = self.studio_replay.is_some();
|
||||
let show_hud = !self.ui.hide_gui && !in_studio;
|
||||
// Hud.extractRenderState asks `Screen.isInGameUi` twice: such a screen
|
||||
// defers the subtitles over itself, and keeps them while F1 hides the
|
||||
// rest of the HUD.
|
||||
@@ -6273,7 +6282,23 @@ impl Game<'_> {
|
||||
self.draw_sign_editor(&mut batch);
|
||||
self.draw_book(&mut batch);
|
||||
}
|
||||
self.draw_studio_bars(&mut batch);
|
||||
if !self.ui.hide_gui
|
||||
&& self.screen == Screen::Playing
|
||||
&& (self.studio_replay.is_some() || self.studio_freecam.active)
|
||||
{
|
||||
self.studio_topbar.render(
|
||||
&mut batch,
|
||||
&self.gui.atlas,
|
||||
self.cursor,
|
||||
self.studio_freecam.locked_to_path,
|
||||
);
|
||||
self.studio_timeline.render(
|
||||
&mut batch,
|
||||
&self.gui.atlas,
|
||||
self.cursor,
|
||||
&self.studio_freecam,
|
||||
);
|
||||
}
|
||||
if show_hud {
|
||||
self.draw_pad_guide(&mut batch, guide_from);
|
||||
}
|
||||
@@ -6496,12 +6521,12 @@ impl Game<'_> {
|
||||
/// `Camera.setRotation`: the camera's orientation, turned round in
|
||||
/// third-person front view.
|
||||
pub(super) fn camera_orientation(&self) -> glam::Quat {
|
||||
if self.studio.flying() {
|
||||
if self.studio_freecam.active {
|
||||
return camera_rotation(
|
||||
perspective::CameraType::FirstPerson,
|
||||
self.studio.freecam.yaw,
|
||||
self.studio.freecam.pitch,
|
||||
) * glam::Quat::from_rotation_z(self.studio.freecam.roll.to_radians());
|
||||
self.studio_freecam.yaw,
|
||||
self.studio_freecam.pitch,
|
||||
) * glam::Quat::from_rotation_z(self.studio_freecam.roll.to_radians());
|
||||
}
|
||||
match self.posed_rotation {
|
||||
// The frame's camera, already turned round in front view.
|
||||
@@ -6708,7 +6733,11 @@ impl Game<'_> {
|
||||
self.mark_player(out.len(), !self.own_body_in_view());
|
||||
let player_start = out.len();
|
||||
self.mark_motion(player_start, Self::MOTION_OWN_PLAYER);
|
||||
let player_alpha = self.own_player_alpha();
|
||||
let player_alpha = if self.studio_replay.is_some() {
|
||||
(self.studio_virtual_tick_ms as f64 / 50.0).clamp(0.0, 1.0)
|
||||
} else {
|
||||
self.clock.camera_fraction()
|
||||
};
|
||||
self.player_model_geometry(camera, player_alpha, &mut out);
|
||||
// A native pack's shadow pass alone gets the first-person model (no per-entity draws).
|
||||
if self.pack_ids.is_none() && !self.own_body_in_view() {
|
||||
@@ -7437,6 +7466,7 @@ impl Game<'_> {
|
||||
self.spawn_phantoms();
|
||||
self.tick_advancements();
|
||||
self.tick_player_model();
|
||||
self.tick_emotes();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -9206,7 +9236,7 @@ impl Game<'_> {
|
||||
let camera_alpha = self.clock.camera_fraction();
|
||||
let vanilla = self.camera_view(camera_alpha as f32);
|
||||
let view = vanilla.map(|view| {
|
||||
if self.shows_panorama() || self.studio.flying() {
|
||||
if self.shows_panorama() || self.studio_freecam.active {
|
||||
view
|
||||
} else {
|
||||
self.native_camera_pose_now(camera_alpha as f32, view)
|
||||
@@ -9384,7 +9414,14 @@ impl Game<'_> {
|
||||
// hitboxes (F3+G, F3+B).
|
||||
let mut lines = self.fishing_line(alpha, size);
|
||||
let mut g = self.debug_gizmos(alpha as f32, camera.to_array());
|
||||
self.studio_camera_path(&mut g);
|
||||
if self.studio_freecam.active || self.studio_replay.is_some() {
|
||||
horizon_client::studio::render_camera_path(
|
||||
&self.studio_freecam.track,
|
||||
&mut g,
|
||||
None,
|
||||
100,
|
||||
);
|
||||
}
|
||||
lines.extend(g.lines);
|
||||
let on_top = g.on_top;
|
||||
pr.lap("pr_debug");
|
||||
@@ -9406,7 +9443,7 @@ impl Game<'_> {
|
||||
Vec::new()
|
||||
};
|
||||
pr.lap("pr_native_visuals");
|
||||
let view = if self.studio.flying() {
|
||||
let view = if self.studio_freecam.active {
|
||||
glam::Mat4::from_quat(self.camera_orientation().conjugate())
|
||||
} else {
|
||||
horizon_client::camera::view_matrix(view_yaw, view_pitch)
|
||||
@@ -9532,7 +9569,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.fov_degrees(camera_alpha as f32, camera.to_array())
|
||||
};
|
||||
let camera_effects = fov::camera_effects_enabled(title, self.studio.flying());
|
||||
let camera_effects = fov::camera_effects_enabled(title, self.studio_freecam.active);
|
||||
let bob = if !camera_effects {
|
||||
glam::Mat4::IDENTITY
|
||||
} else {
|
||||
@@ -10425,7 +10462,21 @@ impl Game<'_> {
|
||||
let b = HudBatch::new([r.config.width, r.config.height]);
|
||||
let previous = self.cursor;
|
||||
self.cursor = (position.x as f32 / b.scale, position.y as f32 / b.scale);
|
||||
if self.studio_mouse_dragged(b.width) {
|
||||
if self.screen == Screen::Playing
|
||||
&& (self.studio_timeline.dragging_playhead
|
||||
|| self.studio_timeline.dragging_keyframe.is_some())
|
||||
{
|
||||
let action = self.studio_timeline.handle_mouse_drag(
|
||||
self.cursor,
|
||||
b.width,
|
||||
&mut self.studio_freecam,
|
||||
);
|
||||
if matches!(
|
||||
action,
|
||||
horizon_client::studio::TimelineAction::KeyframeMoved
|
||||
) {
|
||||
self.studio_camera_track_dirty = true;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.screen_mouse_dragged(previous) {
|
||||
@@ -10476,7 +10527,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseWheel { .. }
|
||||
if self.screen == Screen::Playing && self.replay.playing() => {}
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() => {}
|
||||
WindowEvent::MouseWheel { delta, .. } => {
|
||||
let (raw_x, raw_y) = match delta {
|
||||
MouseScrollDelta::LineDelta(x, y) => (f64::from(x), f64::from(y)),
|
||||
@@ -10753,7 +10804,90 @@ impl Game<'_> {
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
let pressed = state == ElementState::Pressed;
|
||||
if self.studio_mouse_input(pressed, button) {
|
||||
if !pressed && button == MouseButton::Left {
|
||||
let action = self.studio_timeline.handle_mouse_up();
|
||||
if !matches!(action, horizon_client::studio::TimelineAction::None) {
|
||||
self.handle_studio_timeline_action(action);
|
||||
}
|
||||
}
|
||||
if (self.studio_freecam.active || self.studio_replay.is_some())
|
||||
&& button == MouseButton::Right
|
||||
{
|
||||
if let Some(renderer) = &self.renderer {
|
||||
let batch =
|
||||
HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
if self.studio_timeline.visible
|
||||
&& self.cursor.1 >= batch.height - self.studio_timeline.height()
|
||||
{
|
||||
if pressed
|
||||
&& let Some(index) = self.studio_timeline.keyframe_at_cursor(
|
||||
self.cursor,
|
||||
batch.width,
|
||||
batch.height,
|
||||
&self.studio_freecam,
|
||||
)
|
||||
{
|
||||
if index < self.studio_freecam.track.keyframes.len() {
|
||||
self.studio_freecam.track.keyframes.remove(index);
|
||||
self.studio_camera_track_dirty = true;
|
||||
}
|
||||
self.studio_timeline.selected_keyframe = None;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if pressed {
|
||||
self.studio_topbar.close_dropdown();
|
||||
self.studio_rmb_down = true;
|
||||
self.capture(true);
|
||||
return Ok(());
|
||||
} else if self.studio_rmb_down {
|
||||
self.studio_rmb_down = false;
|
||||
self.capture(false);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if pressed
|
||||
&& button == MouseButton::Left
|
||||
&& self.screen == Screen::Playing
|
||||
&& (self.studio_replay.is_some() || self.studio_freecam.active)
|
||||
{
|
||||
let size = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|renderer| [renderer.config.width, renderer.config.height])
|
||||
.unwrap_or([1, 1]);
|
||||
let batch = HudBatch::new(size);
|
||||
let had_open_menu = self.studio_topbar.open_menu.is_some();
|
||||
let topbar_action = self.studio_topbar.handle_mouse_click(
|
||||
self.cursor,
|
||||
batch.width,
|
||||
batch.height,
|
||||
);
|
||||
if !matches!(topbar_action, horizon_client::studio::TopBarAction::None) {
|
||||
self.handle_studio_topbar_action(topbar_action);
|
||||
return Ok(());
|
||||
}
|
||||
if had_open_menu || self.cursor.1 <= self.studio_topbar.height {
|
||||
return Ok(());
|
||||
}
|
||||
if self.studio_timeline.visible {
|
||||
let action = self.studio_timeline.handle_mouse_down(
|
||||
self.cursor,
|
||||
batch.width,
|
||||
batch.height,
|
||||
&self.studio_freecam,
|
||||
);
|
||||
if !matches!(action, horizon_client::studio::TimelineAction::None) {
|
||||
self.handle_studio_timeline_action(action);
|
||||
return Ok(());
|
||||
}
|
||||
if self.cursor.1 >= batch.height - self.studio_timeline.height() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(self.screen, Screen::Inventory | Screen::Creative)
|
||||
@@ -11223,7 +11357,24 @@ impl Game<'_> {
|
||||
let between = now.saturating_duration_since(self.frame_end);
|
||||
self.frame_ticks = elapsed.as_secs_f32() * 20.0;
|
||||
self.previous = now;
|
||||
self.studio_fly(elapsed);
|
||||
if self.studio_freecam.active
|
||||
&& self.screen == Screen::Playing
|
||||
&& !self.dialog_open()
|
||||
{
|
||||
let held = |key| f64::from(u8::from(self.keys.raw(&key)));
|
||||
let forward = held(KeyCode::KeyW) - held(KeyCode::KeyS);
|
||||
let strafe = held(KeyCode::KeyD) - held(KeyCode::KeyA);
|
||||
let vertical =
|
||||
held(KeyCode::KeyE) - held(KeyCode::KeyQ) + held(KeyCode::Space);
|
||||
let fast = self.keys.raw(&KeyCode::ControlRight);
|
||||
self.studio_freecam.update_flight_motion(
|
||||
forward,
|
||||
strafe,
|
||||
vertical,
|
||||
fast,
|
||||
elapsed.as_secs_f64().min(0.1),
|
||||
);
|
||||
}
|
||||
// Arrow-key camera controls belong to gameplay; chat and
|
||||
// other screens use the same keys for their own navigation.
|
||||
if self.screen == Screen::Playing
|
||||
@@ -11408,18 +11559,17 @@ impl Game<'_> {
|
||||
server.set_legacy_rules(self.settings.extras.pvp.combat.rules);
|
||||
}
|
||||
self.clock.end_frame(self.pause);
|
||||
let saved = self
|
||||
.renderer
|
||||
.as_mut()
|
||||
.map(|r| std::mem::take(&mut r.saved_screenshots))
|
||||
.unwrap_or_default();
|
||||
for saved in saved {
|
||||
self.finish_screenshot(saved);
|
||||
}
|
||||
let mut consumed = false;
|
||||
let finished = self
|
||||
.renderer
|
||||
.as_mut()
|
||||
.and_then(|r| r.screenshot_result.take());
|
||||
if let Some(result) = &finished
|
||||
&& self.ui.screenshot_message.is_some()
|
||||
{
|
||||
self.finish_screenshot(result);
|
||||
consumed = true;
|
||||
}
|
||||
if let Some(result) = &finished
|
||||
&& let Some(reply) = self.pending_screenshot.take()
|
||||
{
|
||||
@@ -11432,6 +11582,7 @@ impl Game<'_> {
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
consumed = true;
|
||||
}
|
||||
if let Some(result) =
|
||||
self.renderer.as_mut().and_then(|r| r.capture_result.take())
|
||||
@@ -11440,6 +11591,12 @@ impl Game<'_> {
|
||||
let value = result.unwrap_or_else(|e| serde_json::json!({ "error": e }));
|
||||
let _ = reply.send(value.to_string());
|
||||
}
|
||||
// A result nobody asked for was the world icon's
|
||||
// (`tick_world_icon`): a failure tries again a second
|
||||
// later, as `tryTakeScreenshotIfNeeded` does.
|
||||
if !consumed && let Some(Err(error)) = finished {
|
||||
log::warn!("Horizon: couldn't save the world icon: {error}");
|
||||
}
|
||||
if rendered {
|
||||
self.frame_stats.record(elapsed.as_secs_f32() * 1000.0);
|
||||
self.debug_charts.frame_times.log(elapsed.as_nanos() as i64);
|
||||
@@ -12000,8 +12157,16 @@ impl<'a> Game<'a> {
|
||||
entity_attachments: Default::default(),
|
||||
remote: None,
|
||||
realm_session: false,
|
||||
replay: Default::default(),
|
||||
studio: Default::default(),
|
||||
studio_replay: None,
|
||||
studio_freecam: Default::default(),
|
||||
studio_timeline: Default::default(),
|
||||
studio_topbar: Default::default(),
|
||||
studio_rmb_down: false,
|
||||
studio_camera_track_dirty: false,
|
||||
studio_replay_previous_camera_type: None,
|
||||
studio_replay_standalone: false,
|
||||
last_replay_tick: None,
|
||||
studio_virtual_tick_ms: 0.0,
|
||||
lan: None,
|
||||
integrated: None,
|
||||
level_shapes: Default::default(),
|
||||
@@ -12500,8 +12665,10 @@ pub fn run_with() -> Result<()> {
|
||||
// horizon-ctl drives: those start in a source checkout and wrote the list there.
|
||||
if !options.offscreen && !synchronous_startup(&options) {
|
||||
let version = horizon_client::telemetry::version();
|
||||
let game_dir = horizon_client::logging::player_dir();
|
||||
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version, &game_dir));
|
||||
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
|
||||
// horizon-extras: the official server is Horizon's (docs/forking.md).
|
||||
#[cfg(feature = "horizon-extras")]
|
||||
horizon_client::official_servers::update(&horizon_client::logging::player_dir());
|
||||
}
|
||||
mods::register_components(&vanilla_root)?;
|
||||
mods::register_standalone_models();
|
||||
|
||||
@@ -848,20 +848,34 @@ impl Game<'_> {
|
||||
self.armor_stands[i].last_hit = time;
|
||||
return true;
|
||||
}
|
||||
// brokenByPlayer: the stand item, with the stand's name, pops at its
|
||||
// block, then brokenByAnything: the loot table and the equipment.
|
||||
let (width, height) = stand_box(&self.armor_stands[i]);
|
||||
let p = self.armor_stands[i].position;
|
||||
let pos = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
|
||||
let mut stand = ItemStack::new("minecraft:armor_stand", 1);
|
||||
if let Some(name) = &self.armor_stands[i].custom_name {
|
||||
stand.data_mut().custom_name =
|
||||
Some(horizon_client::scoreboard::component_tag(&name.component()));
|
||||
}
|
||||
self.pop_resource(pos, stand);
|
||||
// ArmorStand.kill(level, the causing entity), after the drops.
|
||||
self.break_armor_stand_with_source(i, by);
|
||||
// brokenByPlayer: the stand item, then brokenByAnything: the equipment.
|
||||
let a = self.armor_stands.remove(i);
|
||||
let (width, height) = stand_box(&a);
|
||||
self.audio
|
||||
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
|
||||
self.show_breaking_particles(at, f64::from(width), f64::from(height));
|
||||
let center = [at[0], at[1] + if a.small { 0.4875 } else { 0.9875 }, at[2]];
|
||||
let stand = ItemStack {
|
||||
item: Arc::from("minecraft:armor_stand"),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
self.spawn_item(stand, center, [0.0; 3], 10);
|
||||
for stack in a.equipment.into_iter().flatten() {
|
||||
let r = &mut self.world.random;
|
||||
let v = [
|
||||
f64::from(r.next_float()) * 0.2 - 0.1,
|
||||
0.2,
|
||||
f64::from(r.next_float()) * 0.2 - 0.1,
|
||||
];
|
||||
self.spawn_item(stack, center, v, 10);
|
||||
}
|
||||
// ArmorStand.kill(level, the causing entity), after the drops.
|
||||
self.stand_game_event("minecraft:entity_die", at, by);
|
||||
self.world
|
||||
.modified
|
||||
.insert(((at[0].floor() as i32) >> 4, (at[2].floor() as i32) >> 4));
|
||||
true
|
||||
}
|
||||
/// `Entity.gameEvent(event, attributedTo)` for a stand at `at`: the
|
||||
@@ -1188,8 +1202,6 @@ impl Game<'_> {
|
||||
let a = self.armor_stands.remove(i);
|
||||
let at = [a.position.x, a.position.y, a.position.z];
|
||||
let a_id = a.id;
|
||||
self.audio
|
||||
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
|
||||
// brokenByAnything's dropAllDeathLoot, before the equipment.
|
||||
if !a.small && self.ui.rules.bool("mob_drops") {
|
||||
self.armor_stand_death_loot(at, a.fire_ticks > 0);
|
||||
@@ -1198,6 +1210,8 @@ impl Game<'_> {
|
||||
for stack in drops {
|
||||
self.pop_resource(pos, stack);
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
|
||||
self.entity_killed_event_by(at, source.or(Some(super::commands::Target::Stand(a_id))));
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
}
|
||||
|
||||
@@ -42,25 +42,12 @@ impl Game<'_> {
|
||||
self.trial_spawner_tick_positions.clear();
|
||||
self.item_columns = Some(Default::default());
|
||||
self.pass_mobs = Some(Default::default());
|
||||
let tickers = self.world.block_entity_tickers();
|
||||
let mut last_chunk = (i32::MIN, i32::MIN);
|
||||
let mut last_chunk_ticking = false;
|
||||
for slot in tickers.iter().copied() {
|
||||
for slot in self.world.block_entity_tickers().iter().copied() {
|
||||
let pos = slot.0;
|
||||
let chunk = (pos.0 >> 4, pos.2 >> 4);
|
||||
let chunk_ticking = if chunk == last_chunk {
|
||||
last_chunk_ticking
|
||||
} else {
|
||||
last_chunk = chunk;
|
||||
last_chunk_ticking = ticking(pos.0, pos.2);
|
||||
last_chunk_ticking
|
||||
};
|
||||
if !chunk_ticking {
|
||||
continue;
|
||||
}
|
||||
// Ticks before this one can remove it (`TickingBlockEntity.isRemoved`).
|
||||
if self.world.block_entities.is_asleep(&pos)
|
||||
|| !self.world.block_entities.is_live(slot)
|
||||
|| !ticking(pos.0, pos.2)
|
||||
|| !self.border.contains(f64::from(pos.0), f64::from(pos.2))
|
||||
{
|
||||
continue;
|
||||
@@ -223,14 +210,8 @@ impl Game<'_> {
|
||||
/// (`ServerLevel.areEntitiesLoaded`).
|
||||
pub(super) fn block_ticking_area(&self) -> impl Fn(i32, i32) -> bool + use<> {
|
||||
let levels = self.tickets.levels();
|
||||
let has_pending = !self.world.entities_pending.is_empty();
|
||||
let pending = has_pending.then(|| self.world.entities_pending.clone());
|
||||
move |x, z| {
|
||||
levels.block_ticking_at(x, z)
|
||||
&& pending
|
||||
.as_ref()
|
||||
.is_none_or(|p| !p.contains(&(x >> 4, z >> 4)))
|
||||
}
|
||||
let pending = self.world.entities_pending.clone();
|
||||
move |x, z| levels.block_ticking_at(x, z) && !pending.contains(&(x >> 4, z >> 4))
|
||||
}
|
||||
/// `DaylightDetectorBlock.tickEntity` of the detector at `pos`.
|
||||
fn tick_daylight_detector(&mut self, pos: Pos) {
|
||||
|
||||
@@ -255,8 +255,8 @@ pub struct UiState {
|
||||
/// What this frame's HUD shows that Horizon's overlays keep clear of (`Game::hud_room`),
|
||||
/// set by `hud_layers` before anything draws.
|
||||
pub room: horizon_client::hud_kit::room::Room,
|
||||
/// F2 screenshots not written yet: each reports in chat once its PNG is.
|
||||
pub screenshot_messages: Vec<std::path::PathBuf>,
|
||||
/// Path of an F2 screenshot waiting for its frame.
|
||||
pub screenshot_message: Option<std::path::PathBuf>,
|
||||
/// The options screens.
|
||||
pub options: horizon_client::options_ui::OptionsUi,
|
||||
/// The prepared resources before resource packs are layered on.
|
||||
@@ -1982,17 +1982,15 @@ impl Game<'_> {
|
||||
if std::fs::create_dir_all(&dir).is_err() {
|
||||
return None;
|
||||
}
|
||||
// A screenshot still being written has no file yet: its name is taken all the same.
|
||||
let name = screenshot_name(&dir, &self.ui.screenshot_messages);
|
||||
let name = screenshot_name(&dir);
|
||||
let renderer = self.renderer.as_mut()?;
|
||||
let path = dir.join(name);
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: true,
|
||||
background: true,
|
||||
});
|
||||
self.ui.screenshot_messages.push(path.clone());
|
||||
self.ui.screenshot_message = Some(path.clone());
|
||||
Some(path)
|
||||
}
|
||||
/// `Minecraft.handleGlobalKeyPress`'s screenshot branch: the
|
||||
@@ -2016,23 +2014,12 @@ impl Game<'_> {
|
||||
self.take_screenshot();
|
||||
true
|
||||
}
|
||||
/// A screenshot written on its thread: F2's reports in chat. Any other was the world
|
||||
/// icon's (`tick_world_icon`): a failure tries again a second later, as
|
||||
/// `tryTakeScreenshotIfNeeded` does.
|
||||
pub(super) fn finish_screenshot(&mut self, saved: horizon_client::gfx::SavedScreenshot) {
|
||||
let Some(at) = self
|
||||
.ui
|
||||
.screenshot_messages
|
||||
.iter()
|
||||
.position(|p| *p == saved.path)
|
||||
else {
|
||||
if let Err(error) = saved.result {
|
||||
log::warn!("Horizon: couldn't save the world icon: {error}");
|
||||
}
|
||||
/// After a frame: report a finished F2 screenshot in chat.
|
||||
pub(super) fn finish_screenshot(&mut self, result: &std::result::Result<[u32; 2], String>) {
|
||||
let Some(path) = self.ui.screenshot_message.take() else {
|
||||
return;
|
||||
};
|
||||
let path = self.ui.screenshot_messages.remove(at);
|
||||
let message = match &saved.result {
|
||||
let message = match result {
|
||||
Ok(_) => {
|
||||
log::info!("Saved screenshot to {}", path.display());
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
@@ -2068,7 +2055,7 @@ fn chat_box_text(text: &str) -> String {
|
||||
.filter(|&ch| ch >= ' ' && ch != '\u{7f}' && ch != '§')
|
||||
.collect()
|
||||
}
|
||||
pub fn screenshot_name(dir: &std::path::Path, pending: &[std::path::PathBuf]) -> String {
|
||||
pub fn screenshot_name(dir: &std::path::Path) -> String {
|
||||
// Util.getFilenameFormattedDateTime: the local date and time.
|
||||
let base = chrono::Local::now().format("%Y-%m-%d_%H.%M.%S").to_string();
|
||||
let mut n = 1;
|
||||
@@ -2078,8 +2065,7 @@ pub fn screenshot_name(dir: &std::path::Path, pending: &[std::path::PathBuf]) ->
|
||||
} else {
|
||||
format!("{base}_{n}.png")
|
||||
};
|
||||
let path = dir.join(&name);
|
||||
if !path.exists() && !pending.contains(&path) {
|
||||
if !dir.join(&name).exists() {
|
||||
return name;
|
||||
}
|
||||
n += 1;
|
||||
@@ -2121,25 +2107,6 @@ mod tests {
|
||||
assert!(should_discard_draft(ChatExit::Done, true));
|
||||
}
|
||||
|
||||
/// `Screenshot.getFile` picks the first free name of the second; an F2
|
||||
/// screenshot still being written on its thread has no file yet, so a
|
||||
/// second press in the same second takes the next name, not the same one.
|
||||
#[test]
|
||||
fn a_screenshot_being_written_keeps_its_name() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-shots-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let first = screenshot_name(&dir, &[]);
|
||||
let second = screenshot_name(&dir, &[dir.join(&first)]);
|
||||
assert_ne!(first, second);
|
||||
// Unless the clock turned to the next second in between.
|
||||
let same_second = second.starts_with(first.trim_end_matches(".png"));
|
||||
assert!(
|
||||
!same_second || second == first.replace(".png", "_2.png"),
|
||||
"{second}"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_ids_are_listed_once_per_root() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-ids-{}", std::process::id()));
|
||||
|
||||
@@ -3831,9 +3831,12 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
g.spawn = None;
|
||||
g.respawn_config = Some(super::respawn_anchor::RespawnConfig::forced(
|
||||
level, pos, yaw, pitch,
|
||||
));
|
||||
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
|
||||
dim: level,
|
||||
pos,
|
||||
yaw,
|
||||
forced: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
let dim = Text::literal(self.dim.id());
|
||||
|
||||
@@ -1039,7 +1039,6 @@ impl Game<'_> {
|
||||
path: path.trim().into(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
self.pending_screenshot = Some(reply.clone());
|
||||
Ok(None)
|
||||
|
||||
@@ -20,17 +20,13 @@ use horizon_cosmetics::follow::Follow;
|
||||
use horizon_cosmetics::prefs::{self, Others};
|
||||
use horizon_cosmetics::trail::{Budget, Emitter};
|
||||
use horizon_cosmetics::{Item, Slot};
|
||||
use horizon_hooks::players::{
|
||||
Badge, CameraOverride, CameraView, Frame, Gear, Kind, Mark, NameDecoration, PlayerModel,
|
||||
PlayerModels, SpriteStore, Sprites,
|
||||
};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Horizon's mark before a Horizon player's name, over their name tag and in the player list.
|
||||
const MARK: Mark = &horizon_cosmetics::mark::MARK;
|
||||
pub(super) const MARK: horizon_client::tab_list::NameMark = &horizon_cosmetics::mark::MARK;
|
||||
|
||||
/// An item as the renderer takes it.
|
||||
struct Baked {
|
||||
@@ -59,8 +55,7 @@ impl Baked {
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Cosmetics {
|
||||
/// The hooks' client (`Seat`), taken once it started.
|
||||
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
|
||||
client: Option<horizon_cosmetics::Cosmetics>,
|
||||
/// By the item's address; each entry holds its item, so the address isn't reused.
|
||||
baked: RefCell<FxHashMap<usize, Baked>>,
|
||||
/// Items drawn whose texture isn't in the atlas yet.
|
||||
@@ -70,6 +65,16 @@ pub(super) struct Cosmetics {
|
||||
poses: RefCell<(Poses, Poses)>,
|
||||
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins wear them too.
|
||||
local: bool,
|
||||
/// Show my cosmetics on its way to the service.
|
||||
mine: AccountSync,
|
||||
/// Show me as a Horizon player: sent at start, on each change and every hour while on.
|
||||
listed: AccountSync,
|
||||
/// Whether the signed-in account owns a cosmetic.
|
||||
owner: OwnerCheck,
|
||||
/// Which other players play an emote (`horizon_cosmetics::Feed`), started with the client.
|
||||
feed: Option<horizon_cosmetics::Feed>,
|
||||
/// My emote on its way to the service.
|
||||
emote_out: EmoteOut,
|
||||
/// Companions, trails and auras.
|
||||
fx: RefCell<Fx>,
|
||||
/// Emotes: those the wheel offers and the one playing (`cosmetics/emotes.rs`).
|
||||
@@ -400,13 +405,17 @@ fn slot_image(item: &Item) -> Vec<u8> {
|
||||
|
||||
/// Which slots an avatar's gear covers: a helmet or head item hides a hat, an elytra or a cape
|
||||
/// what is worn on the back. A name tag style is never drawn on the body.
|
||||
fn covered(gear: Gear, slot: Slot) -> bool {
|
||||
fn covered(m: &Mob, slot: Slot) -> bool {
|
||||
match slot {
|
||||
Slot::Head => gear.head,
|
||||
Slot::Back => gear.back,
|
||||
Slot::Head => m.extra.head.is_some(),
|
||||
Slot::Back => m.cape.is_some() || m.extra.chest.as_deref() == Some("minecraft:elytra"),
|
||||
Slot::Shoulder => false,
|
||||
// A chestplate, not an elytra.
|
||||
Slot::Torso => gear.chest,
|
||||
Slot::Torso => m
|
||||
.extra
|
||||
.chest
|
||||
.as_deref()
|
||||
.is_some_and(|c| c != "minecraft:elytra"),
|
||||
Slot::NameTag | Slot::Emote => true,
|
||||
// On no bone: drawn by a pass of their own, never on the body.
|
||||
Slot::Companion | Slot::Trail | Slot::Aura => true,
|
||||
@@ -418,12 +427,12 @@ fn covered(gear: Gear, slot: Slot) -> bool {
|
||||
/// (`worn` is None), another player's at "name tags only" or hidden, or every item `covered` (a
|
||||
/// helmet over a hat). One max over at most five items, read when the item was parsed.
|
||||
fn body_top(
|
||||
others: Others,
|
||||
extras: &horizon_client::extras::Extras,
|
||||
own: bool,
|
||||
worn: Option<&horizon_cosmetics::Worn>,
|
||||
covered: impl Fn(Slot) -> bool,
|
||||
) -> Option<f32> {
|
||||
if !own && others != Others::Shown {
|
||||
if !own && extras.effective_others() != Others::Shown {
|
||||
return None;
|
||||
}
|
||||
worn?
|
||||
@@ -440,424 +449,115 @@ pub(super) fn enabled() -> bool {
|
||||
cfg!(feature = "horizon-extras") && horizon_cosmetics::enabled()
|
||||
}
|
||||
|
||||
/// What the cosmetics' hooks keep for one splitscreen player (`horizon_hooks::seat`): the
|
||||
/// service's client, the emote feed and what the account tells the service.
|
||||
#[derive(Default)]
|
||||
struct Seat {
|
||||
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
|
||||
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`).
|
||||
local: bool,
|
||||
/// Show my cosmetics on its way to the service.
|
||||
mine: AccountSync,
|
||||
/// Show me as a Horizon player: sent at start, on each change and every hour while on.
|
||||
listed: AccountSync,
|
||||
/// Whether the signed-in account owns a cosmetic.
|
||||
owner: OwnerCheck,
|
||||
/// Which other players play an emote (`horizon_cosmetics::Feed`), started with the client.
|
||||
feed: Option<horizon_cosmetics::Feed>,
|
||||
/// My emote on its way to the service.
|
||||
emote_out: EmoteOut,
|
||||
/// What the game's settings and the player's emote say (`Game::cosmetics_say`).
|
||||
said: Said,
|
||||
/// Kept between ticks so a tick allocates nothing: the player models in view.
|
||||
models: Vec<PlayerModel>,
|
||||
/// The world is the window's own single-player one (`Frame::solo`).
|
||||
solo: bool,
|
||||
/// Name tag badges' sprites, by the item's address: their id (never reused) and the item,
|
||||
/// held so the address isn't reused while it is there.
|
||||
sprites: FxHashMap<usize, (u64, Arc<Item>)>,
|
||||
last_sprite: u64,
|
||||
/// Sprites drawing missed, put in the atlas next frame.
|
||||
wanted: Vec<u64>,
|
||||
}
|
||||
|
||||
/// What the game tells the hooks before each call, until the settings move here (spec #14091
|
||||
/// step 5a) and the player's emote with them (step 4).
|
||||
#[derive(Default)]
|
||||
struct Said {
|
||||
mine: bool,
|
||||
listed: bool,
|
||||
/// Other players' cosmetics are shown (not "name tags only", not hidden).
|
||||
others_shown: bool,
|
||||
/// Other players' cosmetics are hidden (not even name tag styles).
|
||||
others_hidden: bool,
|
||||
/// Horizon's mark by Horizon players' names.
|
||||
marks: bool,
|
||||
/// Cosmetics in single player: the signed-in account's on the player's own model.
|
||||
solo: bool,
|
||||
/// My emote pulled the camera back: when it started (the own model's age).
|
||||
pull: Option<f32>,
|
||||
/// My emote while it plays: its id and whether it loops.
|
||||
playing: Option<(String, bool)>,
|
||||
/// A folder's first emote, which mannequins play.
|
||||
first: Option<Arc<Item>>,
|
||||
}
|
||||
|
||||
/// One [`Seat`] per splitscreen player.
|
||||
static SERVICES: Mutex<Vec<Seat>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Runs `f` on this seat's [`Seat`]. Never called inside itself.
|
||||
fn service<R>(f: impl FnOnce(&mut Seat) -> R) -> R {
|
||||
let seat = horizon_hooks::seat();
|
||||
let mut all = SERVICES.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if all.len() <= seat {
|
||||
all.resize_with(seat + 1, Seat::default);
|
||||
}
|
||||
f(&mut all[seat])
|
||||
}
|
||||
|
||||
/// The cosmetics' hooks, registered when [`enabled`].
|
||||
pub(super) struct Hooks;
|
||||
pub(super) static HOOKS: Hooks = Hooks;
|
||||
|
||||
impl horizon_hooks::Hooks for Hooks {
|
||||
fn frame(&self, frame: &Frame, sprites: &mut dyn SpriteStore) {
|
||||
service(|s| {
|
||||
s.solo = frame.solo;
|
||||
s.frame();
|
||||
s.put_sprites(sprites);
|
||||
});
|
||||
}
|
||||
|
||||
fn tick(&self, models: &mut dyn PlayerModels) {
|
||||
service(|s| s.tick(models));
|
||||
}
|
||||
|
||||
fn name_decoration(
|
||||
&self,
|
||||
model: &PlayerModel,
|
||||
sprites: &dyn Sprites,
|
||||
) -> Option<NameDecoration> {
|
||||
service(|s| s.name_decoration(model, sprites))
|
||||
}
|
||||
|
||||
fn player_mark(&self, uuid: u128) -> Option<Mark> {
|
||||
service(|s| {
|
||||
let client = s.client.as_ref().filter(|_| s.said.marks)?;
|
||||
client.frame().horizon(uuid).then_some(MARK)
|
||||
})
|
||||
}
|
||||
|
||||
/// An emote pulls the camera back: it eases out over the emote's first quarter second.
|
||||
fn camera(&self, own: &CameraView) -> Option<CameraOverride> {
|
||||
let start = service(|s| s.said.pull)?;
|
||||
let t = ((own.age - start) / 5.0).clamp(0.0, 1.0);
|
||||
Some(CameraOverride::new(0.35 + 0.65 * t * t * (3.0 - 2.0 * t)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Seat {
|
||||
/// Once a frame: starts the service's client on first use, asks whether the account owns a
|
||||
/// cosmetic, and tells the service what the account shows.
|
||||
fn frame(&mut self) {
|
||||
if self.client.is_none() {
|
||||
let (client, local) = Cosmetics::client();
|
||||
self.client = Some(Arc::new(client));
|
||||
self.local = local;
|
||||
// Others' emotes come from the service only (a local folder has no one playing).
|
||||
if !local {
|
||||
self.feed = Some(horizon_cosmetics::Feed::new(http()));
|
||||
}
|
||||
}
|
||||
// Item files from a folder (the art, the bench): shown as to an owner, nothing asked.
|
||||
if self.local {
|
||||
self.owner.owns = true;
|
||||
return;
|
||||
}
|
||||
// Whether the account owns a cosmetic: what the last run learnt, then the service.
|
||||
let o = &mut self.owner;
|
||||
if !o.started {
|
||||
o.started = true;
|
||||
o.owns = account()
|
||||
.is_some_and(|(_, uuid, home)| prefs::owned(&home, &uuid).unwrap_or(false));
|
||||
}
|
||||
if let Some(done) = o.asking.as_ref().and_then(|rx| rx.try_recv().ok()) {
|
||||
o.asking = None;
|
||||
match done {
|
||||
Ok(owns) => (o.owns, o.known, o.retry) = (owns, true, None),
|
||||
Err(_) => o.retry = backoff(o.retry),
|
||||
}
|
||||
}
|
||||
// One sign-in at a time: Mojang keeps one joined server id per account.
|
||||
let due = !o.known && o.retry.is_none_or(|(at, _)| Instant::now() >= at);
|
||||
if due && o.asking.is_none() && self.mine.sending.is_none() && self.listed.sending.is_none()
|
||||
{
|
||||
let (tx, rx) = channel();
|
||||
o.asking = Some(rx);
|
||||
std::thread::Builder::new()
|
||||
.name("Cosmetics account".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send(check_owner());
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
// Show my cosmetics: sent once per change of the wish, then retried until confirmed.
|
||||
// Show me as a Horizon player: also said again every hour while on.
|
||||
let every = horizon_cosmetics::account::PRESENCE_EVERY;
|
||||
self.mine.settle(None);
|
||||
self.listed.settle(Some(every));
|
||||
// One sign-in at a time, as for the ownership check above.
|
||||
let busy = self.owner.asking.is_some()
|
||||
|| self.mine.sending.is_some()
|
||||
|| self.listed.sending.is_some();
|
||||
let said = &self.said;
|
||||
if let Some(done) = self
|
||||
.emote_out
|
||||
.sending
|
||||
.as_ref()
|
||||
.and_then(|rx| rx.try_recv().ok())
|
||||
{
|
||||
self.emote_out.sending = None;
|
||||
match done {
|
||||
Ok(()) => self.emote_out.retry = None,
|
||||
Err(_) => {
|
||||
self.emote_out.retry = backoff(self.emote_out.retry);
|
||||
self.emote_out.sent = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
// My emote: only while I show myself as a Horizon player (playing one says so).
|
||||
let playing = said.playing.as_ref();
|
||||
let looping = playing.is_some_and(|(_, looping)| *looping);
|
||||
let wish = playing.map(|(id, _)| id.as_str()).filter(|_| said.listed);
|
||||
let o = &mut self.emote_out;
|
||||
let changed = o.sent.as_ref().map(Option::as_deref) != Some(wish);
|
||||
let again = looping && o.again.is_some_and(|at| Instant::now() >= at);
|
||||
let waited = o.retry.is_none_or(|(at, _)| Instant::now() >= at);
|
||||
// Nothing sent yet and nothing playing: nothing to say (a failed send forgets what
|
||||
// was sent: the service ends a one-shot by itself, a loop within a minute).
|
||||
let quiet = wish.is_none() && o.sent.is_none();
|
||||
let emote_due = !quiet && waited && (changed || again);
|
||||
// One sign-in at a time, the emote's included.
|
||||
if busy || o.sending.is_some() {
|
||||
} else if emote_due {
|
||||
let wish = wish.map(str::to_owned);
|
||||
o.sent = Some(wish.clone());
|
||||
o.again = looping.then(|| Instant::now() + LOOP_AGAIN);
|
||||
let (tx, rx) = channel();
|
||||
o.sending = Some(rx);
|
||||
let kept = o.kept.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("Cosmetics emote".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send(send_emote(&kept, wish.as_deref()));
|
||||
})
|
||||
.ok();
|
||||
} else if self.mine.due(said.mine) {
|
||||
self.mine.start(said.mine, send_mine);
|
||||
} else if self.listed.due(said.listed) {
|
||||
self.listed.start(said.listed, send_listed);
|
||||
}
|
||||
}
|
||||
|
||||
/// What goes by `m`'s name: Horizon's mark if they play Horizon (and the mark is on), the
|
||||
/// name tag style they wear if cosmetics are shown for them (name tags only counts), and how
|
||||
/// high what is drawn on their body gets (`body_top`). One look in the cache for all three.
|
||||
fn name_decoration(
|
||||
&mut self,
|
||||
m: &PlayerModel,
|
||||
sprites: &dyn Sprites,
|
||||
) -> Option<NameDecoration> {
|
||||
let said = &self.said;
|
||||
let own = m.kind == Kind::Own;
|
||||
let styles = if own { said.mine } else { !said.others_hidden };
|
||||
if !(styles || said.marks) {
|
||||
return None;
|
||||
}
|
||||
let uuid = match m.kind {
|
||||
Kind::Own => own_uuid(m.uuid, self.solo, said.solo, || {
|
||||
horizon_client::auth::current().map(|s| s.uuid)
|
||||
}),
|
||||
Kind::Mannequin if self.local => Some(u128::from(m.id)),
|
||||
Kind::Player => m.uuid,
|
||||
_ => None,
|
||||
}?;
|
||||
let mut frame = self.client.as_ref()?.frame();
|
||||
let mark = (said.marks && frame.horizon(uuid)).then_some(MARK);
|
||||
let worn = if styles { frame.worn(uuid) } else { None };
|
||||
drop(frame);
|
||||
let others = if said.others_shown {
|
||||
Others::Shown
|
||||
} else {
|
||||
Others::NameTags
|
||||
};
|
||||
let top = body_top(others, own, worn.as_ref(), |slot| covered(m.gear, slot))
|
||||
.filter(|_| !m.invisible);
|
||||
let badge = worn.and_then(|w| self.badge(&w, sprites));
|
||||
Some(NameDecoration::new(mark, badge, top))
|
||||
}
|
||||
|
||||
/// The name tag style in `worn`: its badge's place in the atlas and its line's colour. None
|
||||
/// until the badge is in the atlas (it is put there next frame).
|
||||
fn badge(&mut self, worn: &horizon_cosmetics::Worn, sprites: &dyn Sprites) -> Option<Badge> {
|
||||
let item = worn.iter().find(|i| i.slot == Slot::NameTag)?;
|
||||
let [r, g, b] = item.tag?.line;
|
||||
let next = self.last_sprite + 1;
|
||||
let (id, _) = *self
|
||||
.sprites
|
||||
.entry(Arc::as_ptr(item) as usize)
|
||||
.or_insert_with(|| (next, item.clone()));
|
||||
self.last_sprite = self.last_sprite.max(id);
|
||||
let Some([u0, v0, u1, v1]) = sprites.get(id) else {
|
||||
if !self.wanted.contains(&id) {
|
||||
self.wanted.push(id);
|
||||
}
|
||||
return None;
|
||||
};
|
||||
// The mark sits in the slot's top left corner.
|
||||
let u1 = u0 + (u1 - u0) * item.texture.width as f32 / 64.0;
|
||||
let v1 = v0 + (v1 - v0) * item.texture.height as f32 / 64.0;
|
||||
Some(Badge::new(
|
||||
[u0, v0, u1, v1],
|
||||
u32::from_be_bytes([0, r, g, b]),
|
||||
))
|
||||
}
|
||||
|
||||
/// Puts the sprites drawing missed in the atlas; forgets those of items no one wears.
|
||||
fn put_sprites(&mut self, store: &mut dyn SpriteStore) {
|
||||
for id in self.wanted.drain(..) {
|
||||
let item = self.sprites.values().find(|(i, _)| *i == id);
|
||||
if let Some((_, item)) = item.filter(|_| !store.has(id)) {
|
||||
store.put(id, slot_image(item));
|
||||
}
|
||||
}
|
||||
self.sprites
|
||||
.retain(|_, (_, item)| Arc::strong_count(item) > 1);
|
||||
}
|
||||
|
||||
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
|
||||
/// they play on their models; one that moved ends theirs here at once. Others' emotes show
|
||||
/// with "Other players' cosmetics" on Shown only; otherwise nothing is asked.
|
||||
fn tick(&mut self, models: &mut dyn PlayerModels) {
|
||||
let mut seen = std::mem::take(&mut self.models);
|
||||
seen.clear();
|
||||
models.for_each(&mut |m| {
|
||||
if m.kind != Kind::Own {
|
||||
seen.push(m);
|
||||
}
|
||||
});
|
||||
self.play_others(models, &mut seen);
|
||||
self.models = seen;
|
||||
}
|
||||
|
||||
fn play_others(&self, models: &mut dyn PlayerModels, view: &mut Vec<PlayerModel>) {
|
||||
let shown = self.said.others_shown;
|
||||
// Items from a folder (the art, the bench): every mannequin plays its first emote, again
|
||||
// and again.
|
||||
if self.local && shown {
|
||||
let Some(first) = &self.said.first else {
|
||||
return;
|
||||
};
|
||||
for m in view.iter().filter(|m| m.kind == Kind::Mannequin) {
|
||||
let age = m.age as f32;
|
||||
let now = emotes::pose_playing(models.body_pose(m.id));
|
||||
if now.is_none_or(|p| p.at(age).is_none()) {
|
||||
let playing = horizon_cosmetics::Playing {
|
||||
item: first.clone(),
|
||||
// Out of step, so a crowd doesn't move as one.
|
||||
start: age - (m.id % 40) as f32,
|
||||
stop: None,
|
||||
};
|
||||
emotes::set_model_playing(models, m.id, Some(playing));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (Some(feed), Some(client)) = (&self.feed, &self.client) else {
|
||||
return;
|
||||
};
|
||||
if !shown {
|
||||
feed.ask([]);
|
||||
for m in view.iter() {
|
||||
emotes::set_model_playing(models, m.id, None);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut frame = client.frame();
|
||||
view.retain(|m| m.kind == Kind::Player && m.uuid.is_some_and(|u| frame.horizon(u)));
|
||||
drop(frame);
|
||||
feed.ask(view.iter().filter_map(|m| m.uuid));
|
||||
let now = Instant::now();
|
||||
for m in view.iter() {
|
||||
let Some(uuid) = m.uuid else { continue };
|
||||
let seen = feed.playing(uuid);
|
||||
let age = m.age as f32;
|
||||
let mut next = emotes::pose_playing(models.body_pose(m.id)).cloned();
|
||||
match (seen, &mut next) {
|
||||
(Some(seen), Some(p)) if Arc::ptr_eq(&p.item, &seen.item) => {
|
||||
if m.moved && p.stop.is_none() {
|
||||
p.stop = Some(age);
|
||||
}
|
||||
}
|
||||
(Some(seen), slot) if !m.moved => {
|
||||
let ticks = now.duration_since(seen.started).as_secs_f32() * 20.0;
|
||||
*slot = Some(horizon_cosmetics::Playing {
|
||||
item: seen.item,
|
||||
start: age - ticks,
|
||||
stop: None,
|
||||
});
|
||||
}
|
||||
(_, Some(p)) if p.stop.is_none() => p.stop = Some(age),
|
||||
_ => {}
|
||||
}
|
||||
emotes::set_model_playing(models, m.id, next.filter(|p| p.at(age).is_some()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Hands the hooks what the game's settings and the player's emote say (allocating only when
|
||||
/// the emote changes).
|
||||
pub(super) fn cosmetics_say(&mut self) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
let first = if self.cosmetics.local {
|
||||
service(|s| s.said.first.is_none())
|
||||
.then(|| self.owned_emotes().first().cloned())
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let extras = &self.settings.extras;
|
||||
let playing = self.cosmetics.emotes.sending();
|
||||
let pull = self.cosmetics.emotes.pull();
|
||||
service(|s| {
|
||||
let said = &mut s.said;
|
||||
said.mine = extras.cosmetics_mine;
|
||||
said.listed = extras.horizon_listed;
|
||||
said.others_shown = extras.effective_others() == Others::Shown;
|
||||
said.others_hidden = extras.effective_others() == Others::Hidden;
|
||||
said.marks = extras.horizon_marks;
|
||||
said.solo = extras.cosmetics_solo;
|
||||
said.pull = pull;
|
||||
if said.playing.as_ref().map(|(id, l)| (id.as_str(), *l)) != playing {
|
||||
said.playing = playing.map(|(id, l)| (id.to_owned(), l));
|
||||
}
|
||||
if first.is_some() {
|
||||
said.first = first;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Once a frame, after the hooks: takes the service's client for drawing and puts the
|
||||
/// Once a frame, before drawing: starts the service's client on first use and puts the
|
||||
/// textures the last frame wanted into the atlas.
|
||||
pub(super) fn cosmetics_frame(&mut self) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
let c = &mut self.cosmetics;
|
||||
let owns = service(|s| {
|
||||
if c.client.is_none() {
|
||||
c.client = s.client.clone();
|
||||
c.local = s.local;
|
||||
if c.client.is_none() {
|
||||
let (client, local) = Cosmetics::client();
|
||||
c.client = Some(client);
|
||||
c.local = local;
|
||||
// Others' emotes come from the service only (a local folder has no one playing).
|
||||
if !local {
|
||||
c.feed = Some(horizon_cosmetics::Feed::new(http()));
|
||||
}
|
||||
s.owner.owns
|
||||
});
|
||||
// Read every frame: the settings button shows it, and it is never saved.
|
||||
self.settings.extras.cosmetics_owner = owns;
|
||||
}
|
||||
c.frame.set(c.frame.get().wrapping_add(1));
|
||||
c.fx.get_mut().frame(c.frame.get());
|
||||
// Item files from a folder (the art, the bench): shown as to an owner, nothing asked.
|
||||
if c.local {
|
||||
c.owner.owns = true;
|
||||
} else {
|
||||
// Whether the account owns a cosmetic: what the last run learnt, then the service.
|
||||
let o = &mut c.owner;
|
||||
if !o.started {
|
||||
o.started = true;
|
||||
o.owns = account()
|
||||
.is_some_and(|(_, uuid, home)| prefs::owned(&home, &uuid).unwrap_or(false));
|
||||
}
|
||||
if let Some(done) = o.asking.as_ref().and_then(|rx| rx.try_recv().ok()) {
|
||||
o.asking = None;
|
||||
match done {
|
||||
Ok(owns) => (o.owns, o.known, o.retry) = (owns, true, None),
|
||||
Err(_) => o.retry = backoff(o.retry),
|
||||
}
|
||||
}
|
||||
// One sign-in at a time: Mojang keeps one joined server id per account.
|
||||
let due = !o.known && o.retry.is_none_or(|(at, _)| Instant::now() >= at);
|
||||
if due && o.asking.is_none() && c.mine.sending.is_none() && c.listed.sending.is_none() {
|
||||
let (tx, rx) = channel();
|
||||
o.asking = Some(rx);
|
||||
std::thread::Builder::new()
|
||||
.name("Cosmetics account".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send(check_owner());
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
// Read every frame: the settings button shows it, and it is never saved.
|
||||
self.settings.extras.cosmetics_owner = c.owner.owns;
|
||||
// Show my cosmetics: sent once per change of the wish, then retried until confirmed.
|
||||
// Show me as a Horizon player: also said again every hour while on.
|
||||
if !c.local {
|
||||
let every = horizon_cosmetics::account::PRESENCE_EVERY;
|
||||
c.mine.settle(None);
|
||||
c.listed.settle(Some(every));
|
||||
// One sign-in at a time, as for the ownership check above.
|
||||
let busy =
|
||||
c.owner.asking.is_some() || c.mine.sending.is_some() || c.listed.sending.is_some();
|
||||
let extras = &self.settings.extras;
|
||||
if let Some(done) = c
|
||||
.emote_out
|
||||
.sending
|
||||
.as_ref()
|
||||
.and_then(|rx| rx.try_recv().ok())
|
||||
{
|
||||
c.emote_out.sending = None;
|
||||
match done {
|
||||
Ok(()) => c.emote_out.retry = None,
|
||||
Err(_) => {
|
||||
c.emote_out.retry = backoff(c.emote_out.retry);
|
||||
c.emote_out.sent = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
// My emote: only while I show myself as a Horizon player (playing one says so).
|
||||
let playing = c.emotes.sending();
|
||||
let looping = playing.as_ref().is_some_and(|(_, looping)| *looping);
|
||||
let wish = playing.map(|(id, _)| id).filter(|_| extras.horizon_listed);
|
||||
let o = &mut c.emote_out;
|
||||
let changed = o.sent.as_ref() != Some(&wish);
|
||||
let again = looping && o.again.is_some_and(|at| Instant::now() >= at);
|
||||
let waited = o.retry.is_none_or(|(at, _)| Instant::now() >= at);
|
||||
// Nothing sent yet and nothing playing: nothing to say (a failed send forgets what
|
||||
// was sent: the service ends a one-shot by itself, a loop within a minute).
|
||||
let quiet = wish.is_none() && o.sent.is_none();
|
||||
let emote_due = !quiet && waited && (changed || again);
|
||||
// One sign-in at a time, the emote's included.
|
||||
if busy || o.sending.is_some() {
|
||||
} else if emote_due {
|
||||
o.sent = Some(wish.clone());
|
||||
o.again = looping.then(|| Instant::now() + LOOP_AGAIN);
|
||||
let (tx, rx) = channel();
|
||||
o.sending = Some(rx);
|
||||
let kept = o.kept.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("Cosmetics emote".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send(send_emote(&kept, wish.as_deref()));
|
||||
})
|
||||
.ok();
|
||||
} else if c.mine.due(extras.cosmetics_mine) {
|
||||
c.mine.start(extras.cosmetics_mine, send_mine);
|
||||
} else if c.listed.due(extras.horizon_listed) {
|
||||
c.listed.start(extras.horizon_listed, send_listed);
|
||||
}
|
||||
}
|
||||
let uploads = std::mem::take(&mut *c.uploads.borrow_mut());
|
||||
for item in uploads {
|
||||
let location = format!("cosmetics/{:x}", Arc::as_ptr(&item) as usize);
|
||||
@@ -872,6 +572,88 @@ impl Game<'_> {
|
||||
.retain(|_, b| Arc::strong_count(&b.item) > 1);
|
||||
}
|
||||
|
||||
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
|
||||
/// they play on their models; one that moved ends theirs here at once. Others' emotes show
|
||||
/// with "Other players' cosmetics" on Shown only; otherwise nothing is asked.
|
||||
pub(super) fn tick_others_emotes(&mut self) {
|
||||
let shown = self.settings.extras.effective_others() == Others::Shown;
|
||||
// Items from a folder (the art, the bench): every mannequin plays its first emote, again
|
||||
// and again.
|
||||
if self.cosmetics.local && shown {
|
||||
let Some(first) = self.owned_emotes().first().cloned() else {
|
||||
return;
|
||||
};
|
||||
for m in self.mobs.iter_mut() {
|
||||
let age = m.age as f32;
|
||||
if &*m.kind.id == "minecraft:mannequin"
|
||||
&& emotes::playing(m).is_none_or(|p| p.at(age).is_none())
|
||||
{
|
||||
let playing = horizon_cosmetics::Playing {
|
||||
item: first.clone(),
|
||||
// Out of step, so a crowd doesn't move as one.
|
||||
start: age - (m.id % 40) as f32,
|
||||
stop: None,
|
||||
};
|
||||
emotes::set_playing(m, Some(playing));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (Some(feed), Some(client)) = (&self.cosmetics.feed, &self.cosmetics.client) else {
|
||||
return;
|
||||
};
|
||||
if !shown {
|
||||
feed.ask([]);
|
||||
for m in self.mobs.iter_mut() {
|
||||
emotes::set_playing(m, None);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut frame = client.frame();
|
||||
let mut horizon = Vec::new();
|
||||
for m in self.mobs.iter() {
|
||||
if &*m.kind.id == "minecraft:player"
|
||||
&& let Some(uuid) = self.remote_player_uuid(m.id)
|
||||
&& frame.horizon(uuid)
|
||||
{
|
||||
horizon.push((m.id, uuid));
|
||||
}
|
||||
}
|
||||
drop(frame);
|
||||
feed.ask(horizon.iter().map(|(_, u)| *u));
|
||||
let now = Instant::now();
|
||||
for (id, uuid) in horizon {
|
||||
let seen = feed.playing(uuid);
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let age = m.age as f32;
|
||||
let moved = {
|
||||
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
|
||||
dx * dx + dz * dz > 1e-4
|
||||
};
|
||||
let mut next = emotes::playing(m).cloned();
|
||||
match (seen, &mut next) {
|
||||
(Some(seen), Some(p)) if Arc::ptr_eq(&p.item, &seen.item) => {
|
||||
if moved && p.stop.is_none() {
|
||||
p.stop = Some(age);
|
||||
}
|
||||
}
|
||||
(Some(seen), slot) if !moved => {
|
||||
let ticks = now.duration_since(seen.started).as_secs_f32() * 20.0;
|
||||
*slot = Some(horizon_cosmetics::Playing {
|
||||
item: seen.item,
|
||||
start: age - ticks,
|
||||
stop: None,
|
||||
});
|
||||
}
|
||||
(_, Some(p)) if p.stop.is_none() => p.stop = Some(age),
|
||||
_ => {}
|
||||
}
|
||||
emotes::set_playing(m, next.filter(|p| p.at(age).is_some()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the service which emotes the account owns, for the wheel: when it opens, at most
|
||||
/// every 10 minutes (a folder's emotes need no asking; an offline account has none).
|
||||
pub(super) fn refresh_owned_emotes(&mut self) {
|
||||
@@ -887,7 +669,7 @@ impl Game<'_> {
|
||||
}
|
||||
let (tx, rx) = channel();
|
||||
e.loading = Some(rx);
|
||||
let kept = service(|s| s.emote_out.kept.clone());
|
||||
let kept = self.cosmetics.emote_out.kept.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("Cosmetics emotes owned".into())
|
||||
.spawn(move || {
|
||||
@@ -896,6 +678,81 @@ impl Game<'_> {
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Items from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins are dressed and marked too.
|
||||
pub(super) fn cosmetics_local(&self) -> bool {
|
||||
self.cosmetics.local
|
||||
}
|
||||
|
||||
/// What goes by `m`'s name: Horizon's mark if they play Horizon (and the mark is on), the
|
||||
/// name tag style they wear if cosmetics are shown for them (name tags only counts), and how
|
||||
/// high what is drawn on their body gets (`body_top`). One look in the cache for all three.
|
||||
pub(super) fn cosmetics_marks(&self, m: &Mob, own: bool) -> super::names::Marks {
|
||||
let mut marks = super::names::Marks::default();
|
||||
let extras = &self.settings.extras;
|
||||
let styles = if own {
|
||||
extras.cosmetics_mine
|
||||
} else {
|
||||
extras.effective_others() != Others::Hidden
|
||||
};
|
||||
if !(styles || extras.horizon_marks) {
|
||||
return marks;
|
||||
}
|
||||
let (Some(client), Some(uuid)) = (&self.cosmetics.client, self.cosmetics_uuid(m, own))
|
||||
else {
|
||||
return marks;
|
||||
};
|
||||
let mut frame = client.frame();
|
||||
marks.horizon = extras.horizon_marks && frame.horizon(uuid);
|
||||
let worn = if styles { frame.worn(uuid) } else { None };
|
||||
drop(frame);
|
||||
if !horizon_client::mobs::trader::is_invisible(m) {
|
||||
marks.top = body_top(extras, own, worn.as_ref(), |slot| covered(m, slot));
|
||||
}
|
||||
marks.style = worn.and_then(|w| self.cosmetics_tag(&w));
|
||||
marks
|
||||
}
|
||||
|
||||
/// Those of `uuids` who play Horizon, with the mark on (the player list): one look each.
|
||||
pub(super) fn horizon_players(
|
||||
&self,
|
||||
uuids: impl Iterator<Item = u128>,
|
||||
) -> std::collections::HashSet<u128> {
|
||||
match &self.cosmetics.client {
|
||||
Some(c) if self.settings.extras.horizon_marks => {
|
||||
let mut frame = c.frame();
|
||||
uuids.filter(|u| frame.horizon(*u)).collect()
|
||||
}
|
||||
_ => Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The name tag style in `worn`: its badge's place in the atlas and its line's colour. None
|
||||
/// until the badge is in the atlas.
|
||||
fn cosmetics_tag(&self, worn: &horizon_cosmetics::Worn) -> Option<super::names::TagStyle> {
|
||||
let item = worn.iter().find(|i| i.slot == Slot::NameTag)?;
|
||||
let [r, g, b] = item.tag?.line;
|
||||
let frame = self.cosmetics.frame.get();
|
||||
let mut baked = self.cosmetics.baked.borrow_mut();
|
||||
let entry = baked
|
||||
.entry(Arc::as_ptr(item) as usize)
|
||||
.or_insert_with(|| Baked::new(item, frame));
|
||||
let Some(slot) = self.gui.atlas.sprites.get(&entry.location) else {
|
||||
self.cosmetics.uploads.borrow_mut().push(item.clone());
|
||||
return None;
|
||||
};
|
||||
if entry.marked.replace(frame) != frame {
|
||||
self.skin_downloads.borrow_mut().drawn(&entry.location);
|
||||
}
|
||||
// The mark sits in the slot's top left corner.
|
||||
let mut mark = *slot;
|
||||
mark.u1 = mark.u0 + (slot.u1 - slot.u0) * item.texture.width as f32 / 64.0;
|
||||
mark.v1 = mark.v0 + (slot.v1 - slot.v0) * item.texture.height as f32 / 64.0;
|
||||
Some(super::names::TagStyle {
|
||||
mark,
|
||||
line: u32::from_be_bytes([0, r, g, b]),
|
||||
})
|
||||
}
|
||||
|
||||
/// What `m` wears, if cosmetics are shown for it: `own` for the player's own model.
|
||||
fn worn(&self, m: &Mob, own: bool) -> Option<horizon_cosmetics::Worn> {
|
||||
let client = self.cosmetics.client.as_ref()?;
|
||||
@@ -987,7 +844,7 @@ impl Game<'_> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if emoting.is_none() && covered(super::player_hooks::gear(m), item.slot) {
|
||||
if emoting.is_none() && covered(m, item.slot) {
|
||||
continue;
|
||||
}
|
||||
let Some((models, uv)) = self.cosmetics_baked(item) else {
|
||||
@@ -1105,7 +962,7 @@ impl Game<'_> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::extras::Extras;
|
||||
use horizon_client::mobs::anim::BodyPose;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A name tag style with a 1×1 mark.
|
||||
const TAG: &str = r##"{"format": 1, "id": "tag", "slot": "name_tag", "parts": [],
|
||||
@@ -1293,18 +1150,13 @@ mod tests {
|
||||
let (short, long) = (Duration::from_millis(300), Duration::from_secs(10));
|
||||
let top_of = |extras: &Extras, patience| {
|
||||
let (worn, _) = other_player_from(Service(Arc::default(), both), extras, patience);
|
||||
body_top(extras.effective_others(), false, worn.as_ref(), |_| false)
|
||||
body_top(extras, false, worn.as_ref(), |_| false)
|
||||
};
|
||||
// No cosmetics drawn on the body (a name tag style only): vanilla's height exactly.
|
||||
let (worn, _) = other_player(&shown, long);
|
||||
assert!(worn.is_some());
|
||||
assert_eq!(
|
||||
tag(body_top(
|
||||
shown.effective_others(),
|
||||
false,
|
||||
worn.as_ref(),
|
||||
|_| false
|
||||
)),
|
||||
tag(body_top(&shown, false, worn.as_ref(), |_| false)),
|
||||
VANILLA
|
||||
);
|
||||
// A small hat under the tag: vanilla's height too.
|
||||
@@ -1318,9 +1170,7 @@ mod tests {
|
||||
);
|
||||
// A helmet over it: vanilla's height.
|
||||
let (worn, _) = other_player_from(Service(Arc::default(), both), &shown, long);
|
||||
let helmet = body_top(shown.effective_others(), false, worn.as_ref(), |slot| {
|
||||
slot == Slot::Head
|
||||
});
|
||||
let helmet = body_top(&shown, false, worn.as_ref(), |slot| slot == Slot::Head);
|
||||
assert_eq!(tag(helmet), VANILLA);
|
||||
// Others' cosmetics on name tags only, or hidden: nothing on the body, vanilla's height.
|
||||
for others in [Others::NameTags, Others::Hidden] {
|
||||
@@ -1336,66 +1186,4 @@ mod tests {
|
||||
assert_eq!(tag(top_of(&extras, patience)), VANILLA, "{others:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Player models as the hooks see them, with the pose laid over each.
|
||||
struct View(Vec<(PlayerModel, Option<Arc<dyn BodyPose>>)>);
|
||||
|
||||
impl PlayerModels for View {
|
||||
fn connection(&self) -> u64 {
|
||||
1
|
||||
}
|
||||
fn for_each(&self, f: &mut dyn FnMut(PlayerModel)) {
|
||||
self.0.iter().for_each(|(m, _)| f(*m));
|
||||
}
|
||||
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>> {
|
||||
self.0.iter().find(|(m, _)| m.id == id)?.1.as_ref()
|
||||
}
|
||||
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>) {
|
||||
if let Some((_, p)) = self.0.iter_mut().find(|(m, _)| m.id == id) {
|
||||
*p = pose;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_a_folder_mannequins_play_its_first_emote_out_of_step_and_players_do_not() {
|
||||
let item = Arc::new(
|
||||
Item::parse(
|
||||
br#"{"format": 1, "id": "up", "slot": "emote", "parts": [],
|
||||
"emote": {"length": 20, "blend": 0, "tracks": [
|
||||
{"part": "right_arm", "keys": [{"t": 10, "rotation": [0, 0, 90]}]}]}}"#,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let mut service = Seat {
|
||||
local: true,
|
||||
said: Said {
|
||||
others_shown: true,
|
||||
first: Some(item.clone()),
|
||||
..Said::default()
|
||||
},
|
||||
..Seat::default()
|
||||
};
|
||||
let model = |id, kind, uuid| (PlayerModel::new(id, kind, uuid, 50, false), None);
|
||||
let mut view = View(vec![
|
||||
model(1, Kind::Own, None),
|
||||
model(41, Kind::Mannequin, None),
|
||||
model(2, Kind::Player, Some(7)),
|
||||
]);
|
||||
service.tick(&mut view);
|
||||
let playing = emotes::pose_playing(view.0[1].1.as_ref()).expect("the mannequin plays");
|
||||
assert!(Arc::ptr_eq(&playing.item, &item));
|
||||
// Out of step by its id: 50 - 41 % 40.
|
||||
assert_eq!(playing.start, 49.0);
|
||||
assert!(view.0[0].1.is_none() && view.0[2].1.is_none());
|
||||
// Still playing: the same pose stays.
|
||||
let first = view.0[1].1.clone().unwrap();
|
||||
service.tick(&mut view);
|
||||
assert!(Arc::ptr_eq(&first, view.0[1].1.as_ref().unwrap()));
|
||||
// Others' cosmetics not shown: a new mannequin plays nothing.
|
||||
service.said.others_shown = false;
|
||||
view.0.push(model(3, Kind::Mannequin, None));
|
||||
service.tick(&mut view);
|
||||
assert!(view.0[3].1.is_none());
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ use horizon_client::mobs::Mob;
|
||||
use horizon_client::mobs::anim::BodyPose;
|
||||
use horizon_cosmetics::account::OwnedEmote;
|
||||
use horizon_cosmetics::{Item, Playing, Slot};
|
||||
use horizon_hooks::players::PlayerModels;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// How far the player may drift (blocks) before the emote stops: a nudge, not a step.
|
||||
@@ -38,15 +37,10 @@ pub(in crate::game) struct Emotes {
|
||||
|
||||
impl Emotes {
|
||||
/// The emote to tell the service about, while it plays: its id and whether it loops.
|
||||
pub(in crate::game) fn sending(&self) -> Option<(&str, bool)> {
|
||||
pub(in crate::game) fn sending(&self) -> Option<(String, bool)> {
|
||||
let p = self.playing.as_ref().filter(|p| p.stop.is_none())?;
|
||||
let looping = p.item.emote.as_ref().is_some_and(|e| e.looping);
|
||||
Some((&p.item.id, looping))
|
||||
}
|
||||
|
||||
/// When the emote that pulled the camera back started (the own model's age), while it plays.
|
||||
pub(in crate::game) fn pull(&self) -> Option<f32> {
|
||||
self.camera_from.and(self.playing.as_ref()).map(|p| p.start)
|
||||
Some((p.item.id.clone(), looping))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,48 +59,23 @@ impl BodyPose for EmotePose {
|
||||
}
|
||||
}
|
||||
|
||||
/// The emote a model's pose plays, if it is one.
|
||||
pub(in crate::game) fn pose_playing(pose: Option<&Arc<dyn BodyPose>>) -> Option<&Playing> {
|
||||
let any: &dyn std::any::Any = &**pose?;
|
||||
/// The emote `m` plays, if any.
|
||||
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
|
||||
let any: &dyn std::any::Any = &**m.emote.as_ref()?;
|
||||
any.downcast_ref::<EmotePose>().map(|e| &e.0)
|
||||
}
|
||||
|
||||
/// The emote `m` plays, if any.
|
||||
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
|
||||
pose_playing(m.emote.as_ref())
|
||||
}
|
||||
|
||||
/// The pose that plays `next` where `now` is laid, unless it plays that already (None: keep
|
||||
/// it; a new one allocates).
|
||||
fn replaced(
|
||||
now: Option<&Arc<dyn BodyPose>>,
|
||||
next: Option<Playing>,
|
||||
) -> Option<Option<Arc<dyn BodyPose>>> {
|
||||
let same = match (pose_playing(now), &next) {
|
||||
/// Makes `m` play `next`, unless it plays that already (a new one allocates).
|
||||
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
|
||||
let same = match (playing(m), &next) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => {
|
||||
Arc::ptr_eq(&a.item, &b.item) && a.start == b.start && a.stop == b.stop
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
(!same).then(|| next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>))
|
||||
}
|
||||
|
||||
/// Makes `m` play `next`, unless it plays that already.
|
||||
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
|
||||
if let Some(pose) = replaced(m.emote.as_ref(), next) {
|
||||
m.emote = pose;
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes model `id` play `next`, unless it plays that already.
|
||||
pub(in crate::game) fn set_model_playing(
|
||||
models: &mut dyn PlayerModels,
|
||||
id: u32,
|
||||
next: Option<Playing>,
|
||||
) {
|
||||
if let Some(pose) = replaced(models.body_pose(id), next) {
|
||||
models.set_body_pose(id, pose);
|
||||
if !same {
|
||||
m.emote = next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +229,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a tick: my emote (the others' are `cosmetics::Seat::tick`'s, a hook's).
|
||||
/// Once a tick: my emote, then the other players'.
|
||||
pub(in crate::game) fn tick_emotes(&mut self) {
|
||||
if let Some(done) = self
|
||||
.cosmetics
|
||||
@@ -281,6 +250,20 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.tick_emote();
|
||||
self.tick_others_emotes();
|
||||
}
|
||||
|
||||
/// How far the camera stands back while an emote pulls it (1: the usual distance): it eases
|
||||
/// out over the emote's first quarter second.
|
||||
pub(in crate::game) fn emote_camera_pull(&self, partial: f32) -> f32 {
|
||||
let (Some(p), Some(m)) = (&self.cosmetics.emotes.playing, &self.player_model) else {
|
||||
return 1.0;
|
||||
};
|
||||
if self.cosmetics.emotes.camera_from.is_none() {
|
||||
return 1.0;
|
||||
}
|
||||
let t = ((m.age as f32 + partial - p.start) / 5.0).clamp(0.0, 1.0);
|
||||
0.35 + 0.65 * t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -280,18 +280,25 @@ impl Game<'_> {
|
||||
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
|
||||
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
|
||||
}
|
||||
// What crates draw on players and mannequins (`game/player_hooks.rs`: cosmetics),
|
||||
// posed on the GPU with the mob's own jobs: culled with it.
|
||||
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
|
||||
// with it.
|
||||
if !layers_only
|
||||
&& horizon_hooks::engine::any()
|
||||
&& super::super::cosmetics::enabled()
|
||||
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
|
||||
{
|
||||
let mark = self.skin_frame.borrow().mark();
|
||||
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
|
||||
let gpu = skinning;
|
||||
if let Some((lo, hi)) =
|
||||
self.player_layers_geometry(mob, base, camera, alpha as f32, packed, gpu, out)
|
||||
{
|
||||
if let Some((lo, hi)) = self.cosmetics_geometry(
|
||||
mob,
|
||||
false,
|
||||
base,
|
||||
Some(camera),
|
||||
alpha as f32,
|
||||
packed,
|
||||
gpu,
|
||||
out,
|
||||
) {
|
||||
skinned = Some(match skinned {
|
||||
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
|
||||
Some((first, None)) => (first, Some((lo, hi))),
|
||||
|
||||
@@ -58,7 +58,7 @@ impl Recording {
|
||||
Self {
|
||||
seconds,
|
||||
started: Instant::now(),
|
||||
cpu_s: horizon_bench::cpu_seconds(std::process::id()),
|
||||
cpu_s: horizon_system::cpu_seconds(std::process::id()),
|
||||
spans_were_on,
|
||||
ticks: Vec::with_capacity(seconds as usize * 20),
|
||||
sampled_ticks: 0,
|
||||
@@ -363,7 +363,7 @@ impl Game<'_> {
|
||||
let costs = tick_profile::take();
|
||||
let mob_samples = costs.iter().map(|c| c.mob_ticks).sum();
|
||||
let jobs = job_stats::take();
|
||||
let cpu = horizon_bench::cpu_seconds(std::process::id())
|
||||
let cpu = horizon_system::cpu_seconds(std::process::id())
|
||||
.zip(r.cpu_s)
|
||||
.map(|(end, start)| round((end - start) / window_s.max(1e-9)));
|
||||
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
|
||||
@@ -382,7 +382,7 @@ impl Game<'_> {
|
||||
"overhead": overhead(&r, mob_samples, span_ns, mob_ns),
|
||||
"players": players,
|
||||
"levels": self.report_levels(),
|
||||
"memory": { "rss_mb": horizon_bench::rss_bytes(std::process::id()).map(|b| b >> 20) },
|
||||
"memory": { "rss_mb": horizon_system::rss_bytes(std::process::id()).map(|b| b >> 20) },
|
||||
"process": process_limits(),
|
||||
"cpu_cores_used": cpu,
|
||||
"config": {
|
||||
@@ -395,9 +395,12 @@ impl Game<'_> {
|
||||
// The machine's description runs commands: off the tick.
|
||||
std::thread::spawn(move || {
|
||||
let mut report = report;
|
||||
let hw = horizon_bench::Hardware::detect();
|
||||
report["machine"] = json!({
|
||||
"os": hw.os, "arch": hw.arch, "cpu": hw.cpu, "cpu_threads": hw.cpu_threads, "ram_mb": hw.ram_mb,
|
||||
"os": std::env::consts::OS,
|
||||
"arch": std::env::consts::ARCH,
|
||||
"cpu": horizon_system::cpu(),
|
||||
"cpu_threads": std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
"ram_mb": horizon_system::ram_bytes().map(|b| b >> 20),
|
||||
});
|
||||
match write_report(Path::new("reports"), &report, &text) {
|
||||
Ok(path) => log::info!(
|
||||
|
||||
@@ -353,7 +353,7 @@ impl Game<'_> {
|
||||
let found =
|
||||
self.find_respawn_and_use(config, op["consume"].as_bool().unwrap_or(true))?;
|
||||
used.push(match found {
|
||||
Some((p, yaw, _pitch)) => format!(
|
||||
Some((p, yaw)) => format!(
|
||||
"respawn {} {} {} {}",
|
||||
num(p.x),
|
||||
num(p.y),
|
||||
@@ -579,7 +579,6 @@ impl Game<'_> {
|
||||
dim: self.dim,
|
||||
pos: [p(0), p(1), p(2)],
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
forced: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -739,8 +739,7 @@ impl Game<'_> {
|
||||
// anchor puts the player, facing it; else onto the spawn column.
|
||||
let mut target = target;
|
||||
if let Some(c) = self.respawn_config.filter(|c| c.dim == self.dim) {
|
||||
// This existing owner transition adapter carries yaw only; its pitch path remains open.
|
||||
if let Some((at, yaw, _pitch)) = self.find_respawn_and_use(c, false)? {
|
||||
if let Some((at, yaw)) = self.find_respawn_and_use(c, false)? {
|
||||
return Ok(Some((at, Some(yaw))));
|
||||
}
|
||||
target = fallback_spawn(self.spawn, self.level.world_spawn);
|
||||
|
||||
@@ -70,10 +70,9 @@ fn overworld_spawnpoint_is_validated_at_both_player_blocks() {
|
||||
assert_eq!(actual.is_some(), allowed, "{block} at height {height}");
|
||||
assert_eq!(game.respawn_config.is_some(), allowed);
|
||||
assert_eq!(game.level.world_spawn, Some([0, 80, 0]));
|
||||
if let Some((at, yaw, pitch)) = actual {
|
||||
if let Some((at, yaw)) = actual {
|
||||
assert_eq!(at, Vec3::new(5.5, 64.1, 5.5));
|
||||
assert_eq!(yaw, 0.0);
|
||||
assert_eq!(pitch.to_bits(), 0.0f32.to_bits());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,8 +135,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// `Camera.calculateFov` with `modifyFovBasedOnDeathOrFluid`.
|
||||
pub(super) fn fov_degrees(&self, partial: f32, camera: [f64; 3]) -> f32 {
|
||||
if self.studio.flying() {
|
||||
return self.studio.freecam.fov.clamp(1.0, 179.0);
|
||||
if self.studio_freecam.active {
|
||||
return self.studio_freecam.fov.clamp(1.0, 179.0);
|
||||
}
|
||||
let fov = self.view.calculate_fov(
|
||||
partial,
|
||||
|
||||
@@ -193,7 +193,7 @@ impl Game<'_> {
|
||||
// replay player's hands remain visible in first person; freecam has
|
||||
// no player-view hand.
|
||||
if !fov::studio_draws_hand(
|
||||
self.studio.flying(),
|
||||
self.studio_freecam.active,
|
||||
self.ui.hands_drawn(
|
||||
self.spectator(),
|
||||
self.sleeping.is_some(),
|
||||
|
||||
@@ -4190,8 +4190,8 @@ impl Game<'_> {
|
||||
.map_or([0.5, 80.0, 0.5], |[x, y, z]| {
|
||||
[f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5]
|
||||
});
|
||||
let at = found.map_or(world, |(at, yaw, pitch)| {
|
||||
(host.hub.guests[i].yaw, host.hub.guests[i].pitch) = (yaw, pitch);
|
||||
let at = found.map_or(world, |(at, yaw)| {
|
||||
host.hub.guests[i].yaw = yaw;
|
||||
[at.x, at.y, at.z]
|
||||
});
|
||||
if found.is_none() {
|
||||
|
||||
@@ -118,78 +118,34 @@ impl View {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `WorldlyContainer.getSlotsForFace` or every slot, zero-allocation iterator.
|
||||
pub(super) fn slots_for_face_iter(&self, face: Option<Direction>) -> Slots<'_> {
|
||||
/// `WorldlyContainer.getSlotsForFace` or every slot.
|
||||
fn slots_for_face(&self, face: Option<Direction>) -> Vec<usize> {
|
||||
match (self.kind, face) {
|
||||
(Kind::Furnace, Some(Direction::Down)) => Slots::Slice(&[2, 1]),
|
||||
(Kind::Furnace, Some(Direction::Up)) => Slots::Slice(&[0]),
|
||||
(Kind::Furnace, Some(_)) => Slots::Slice(&[1]),
|
||||
(Kind::Brewing, Some(Direction::Up)) => Slots::Slice(&[3]),
|
||||
(Kind::Brewing, Some(Direction::Down)) => Slots::Slice(&[0, 1, 2, 3]),
|
||||
(Kind::Brewing, Some(_)) => Slots::Slice(&[0, 1, 2, 4]),
|
||||
(Kind::Composter(8), Some(Direction::Down)) => Slots::Slice(&[0]),
|
||||
(Kind::Composter(level), Some(Direction::Up)) if level < 7 => Slots::Slice(&[0]),
|
||||
(Kind::Composter(_), _) => Slots::Slice(&[]),
|
||||
(Kind::Furnace, Some(Direction::Down)) => vec![2, 1],
|
||||
(Kind::Furnace, Some(Direction::Up)) => vec![0],
|
||||
(Kind::Furnace, Some(_)) => vec![1],
|
||||
(Kind::Brewing, Some(Direction::Up)) => vec![3],
|
||||
(Kind::Brewing, Some(Direction::Down)) => vec![0, 1, 2, 3],
|
||||
(Kind::Brewing, Some(_)) => vec![0, 1, 2, 4],
|
||||
(Kind::Composter(8), Some(Direction::Down)) => vec![0],
|
||||
(Kind::Composter(level), Some(Direction::Up)) if level < 7 => vec![0],
|
||||
(Kind::Composter(_), _) => vec![],
|
||||
(Kind::Mod, Some(face)) => self
|
||||
.container
|
||||
.as_ref()
|
||||
.map_or(Slots::Slice(&[]), |c| Slots::Mod(&c.faces[face as usize])),
|
||||
_ => Slots::Range(0..self.len()),
|
||||
.map_or_else(Vec::new, |c| c.faces[face as usize].clone()),
|
||||
_ => (0..self.len()).collect(),
|
||||
}
|
||||
}
|
||||
/// The slots `HopperBlockEntity.addItem` tries through `face`, zero-allocation iterator.
|
||||
pub(super) fn slots_to_add_iter(&self, face: Option<Direction>) -> Slots<'_> {
|
||||
if matches!(self.kind, Kind::Furnace | Kind::Brewing | Kind::Mod) && face.is_some() {
|
||||
self.slots_for_face_iter(face)
|
||||
} else {
|
||||
Slots::Range(0..self.len())
|
||||
}
|
||||
}
|
||||
/// `WorldlyContainer.getSlotsForFace` or every slot.
|
||||
fn slots_for_face(&self, face: Option<Direction>) -> Vec<usize> {
|
||||
self.slots_for_face_iter(face).collect()
|
||||
}
|
||||
/// The slots `HopperBlockEntity.addItem` tries through `face`.
|
||||
fn slots_to_add(&self, face: Option<Direction>) -> Vec<usize> {
|
||||
self.slots_to_add_iter(face).collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum Slots<'a> {
|
||||
Range(std::ops::Range<usize>),
|
||||
Slice(&'static [usize]),
|
||||
Mod(&'a [usize]),
|
||||
}
|
||||
|
||||
impl Iterator for Slots<'_> {
|
||||
type Item = usize;
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<usize> {
|
||||
match self {
|
||||
Slots::Range(r) => r.next(),
|
||||
Slots::Slice(s) => {
|
||||
let (&first, rest) = s.split_first()?;
|
||||
*s = rest;
|
||||
Some(first)
|
||||
}
|
||||
Slots::Mod(s) => {
|
||||
let (&first, rest) = s.split_first()?;
|
||||
*s = rest;
|
||||
Some(first)
|
||||
}
|
||||
if matches!(self.kind, Kind::Furnace | Kind::Brewing | Kind::Mod) && face.is_some() {
|
||||
self.slots_for_face(face)
|
||||
} else {
|
||||
(0..self.len()).collect()
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
let len = match self {
|
||||
Slots::Range(r) => r.len(),
|
||||
Slots::Slice(s) => s.len(),
|
||||
Slots::Mod(s) => s.len(),
|
||||
};
|
||||
(len, Some(len))
|
||||
}
|
||||
}
|
||||
impl ExactSizeIterator for Slots<'_> {}
|
||||
|
||||
pub(super) fn dir_from_name(name: &str) -> Option<Direction> {
|
||||
Some(match name {
|
||||
@@ -224,6 +180,8 @@ struct Containers<'a> {
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
/// Brewing recipes, for what a brewing stand accepts (not needed for signals).
|
||||
brewing: Option<&'a horizon_client::brewing::Brewing>,
|
||||
/// The stack of a full composter's `OutputContainer`.
|
||||
bone_meal: ItemStack,
|
||||
}
|
||||
impl Containers<'_> {
|
||||
fn prop(&self, state: BlockStateId, key: &str) -> Option<&str> {
|
||||
@@ -388,7 +346,7 @@ impl Containers<'_> {
|
||||
fn get(&self, view: &View, slot: usize) -> Option<&ItemStack> {
|
||||
if let Kind::Composter(level) = view.kind {
|
||||
// OutputContainer holds one bone meal; InputContainer composts at once.
|
||||
return (level == 8 && slot == 0).then_some(bone_meal_stack());
|
||||
return (level == 8 && slot == 0).then_some(&self.bone_meal);
|
||||
}
|
||||
let (pos, i) = view.locate(slot)?;
|
||||
self.world
|
||||
@@ -494,7 +452,7 @@ impl Containers<'_> {
|
||||
}
|
||||
/// `HopperBlockEntity.isFullContainer`.
|
||||
fn is_full(&self, view: &View, face: Direction) -> bool {
|
||||
view.slots_for_face_iter(Some(face)).all(|i| {
|
||||
view.slots_for_face(Some(face)).into_iter().all(|i| {
|
||||
self.insertion_item(view, i)
|
||||
.is_some_and(|s| s.count >= self.max_stack(&s))
|
||||
})
|
||||
@@ -540,9 +498,6 @@ impl ItemColumns {
|
||||
x: i32,
|
||||
z: i32,
|
||||
) -> bool {
|
||||
if items.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let (len, last) = (items.len(), items.last().map(|i| i.id));
|
||||
let stale = self
|
||||
.seen
|
||||
@@ -601,14 +556,13 @@ impl StampedItemColumns {
|
||||
}
|
||||
}
|
||||
|
||||
fn bone_meal_stack() -> &'static ItemStack {
|
||||
static STACK: std::sync::LazyLock<ItemStack> = std::sync::LazyLock::new(|| ItemStack {
|
||||
fn bone_meal() -> ItemStack {
|
||||
ItemStack {
|
||||
item: "minecraft:bone_meal".into(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
});
|
||||
&STACK
|
||||
}
|
||||
}
|
||||
|
||||
/// Comparator input from a container block entity (0 when there is none).
|
||||
@@ -625,6 +579,7 @@ pub(super) fn container_signal(
|
||||
items,
|
||||
registry,
|
||||
brewing: None,
|
||||
bone_meal: bone_meal(),
|
||||
};
|
||||
c.view((pos.x, pos.y, pos.z), true)
|
||||
.filter(|v| !matches!(v.kind, Kind::Composter(_)))
|
||||
@@ -680,6 +635,7 @@ impl Game<'_> {
|
||||
items: self.items(),
|
||||
registry: self.pack.assets.registry,
|
||||
brewing: Some(&self.stations().brewing),
|
||||
bone_meal: bone_meal(),
|
||||
}
|
||||
}
|
||||
pub(super) fn block_can_place(&self, pos: [i32; 3], slot: usize, stack: &ItemStack) -> bool {
|
||||
@@ -891,7 +847,7 @@ impl Game<'_> {
|
||||
return self.composter_container_insert([x, y, z], stack, face == Some(Direction::Up));
|
||||
}
|
||||
let mut rest = Some(stack);
|
||||
for slot in view.slots_to_add_iter(face) {
|
||||
for slot in view.slots_to_add(face) {
|
||||
let Some(stack) = rest.take() else { break };
|
||||
rest = self.try_move_in_item(from_hopper, view, stack, slot, face);
|
||||
}
|
||||
@@ -924,31 +880,32 @@ impl Game<'_> {
|
||||
/// is still a hopper (`BlockEntityType.isValid`).
|
||||
pub(super) fn tick_hopper(&mut self, pos: Pos) {
|
||||
let time = self.world.game_time;
|
||||
// Every hopper keeps its cooldown and the tick it last ticked
|
||||
// (`HopperBlockEntity.tickedGameTime`), idle ones too: a hopper that
|
||||
// receives an item reads it for its cooldown (8, or 7 once it ticked).
|
||||
let state = self.hoppers.entry(pos).or_default();
|
||||
state.cooldown = state.cooldown.wrapping_sub(1);
|
||||
state.ticked = time;
|
||||
if state.cooldown > 0 {
|
||||
let waiting = {
|
||||
let state = self.hoppers.entry(pos).or_default();
|
||||
state.cooldown = state.cooldown.wrapping_sub(1);
|
||||
state.ticked = time;
|
||||
state.cooldown > 0
|
||||
};
|
||||
if waiting {
|
||||
return;
|
||||
}
|
||||
state.cooldown = 0;
|
||||
// Most ticks only advance the cooldown: the block is checked when the
|
||||
// hopper is about to move, not on every tick it waits.
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let Some(info) = self.pack.assets.registry.state(state) else {
|
||||
self.hoppers.remove(&pos);
|
||||
return;
|
||||
};
|
||||
if info.name != "minecraft:hopper" {
|
||||
let hopper = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(pos.0, pos.1, pos.2))
|
||||
.name
|
||||
.as_ref()
|
||||
== "minecraft:hopper";
|
||||
if !hopper {
|
||||
self.hoppers.remove(&pos);
|
||||
return;
|
||||
}
|
||||
if info.properties.get("enabled").is_some_and(|v| v == "false") {
|
||||
return;
|
||||
if let Some(state) = self.hoppers.get_mut(&pos) {
|
||||
state.cooldown = 0;
|
||||
}
|
||||
self.hopper_try_move_items_with_state(pos, state);
|
||||
self.hopper_try_move_items(pos);
|
||||
}
|
||||
/// Drops the cooldowns of hoppers whose block entity is gone.
|
||||
pub(super) fn forget_gone_hoppers(&mut self) {
|
||||
@@ -960,69 +917,23 @@ impl Game<'_> {
|
||||
/// `HopperBlockEntity.tryMoveItems` with `suckInItems`.
|
||||
pub(super) fn hopper_try_move_items(&mut self, pos: Pos) {
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
self.hopper_try_move_items_with_state(pos, state);
|
||||
}
|
||||
fn hopper_try_move_items_with_state(&mut self, pos: Pos, state: BlockStateId) {
|
||||
let Some(info) = self.pack.assets.registry.state(state) else {
|
||||
let c = self.containers();
|
||||
let enabled = c.prop(state, "enabled") != Some("false");
|
||||
let Some(view) = c.view(pos, false) else {
|
||||
return;
|
||||
};
|
||||
if info.properties.get("enabled").is_some_and(|v| v == "false") {
|
||||
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) || !enabled {
|
||||
return;
|
||||
}
|
||||
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) {
|
||||
return;
|
||||
}
|
||||
let Some(BlockEntity::Chest(items)) = self.world.block_entities.get(&pos) else {
|
||||
return;
|
||||
};
|
||||
if items.len() != HOPPER_SLOTS {
|
||||
return;
|
||||
}
|
||||
let is_empty = items
|
||||
.iter()
|
||||
.all(|s| s.as_ref().is_none_or(|s| s.count == 0));
|
||||
let has_empty_slot = items
|
||||
.iter()
|
||||
.any(|s| s.as_ref().is_none_or(|s| s.count == 0));
|
||||
|
||||
let above = (pos.0, pos.1 + 1, pos.2);
|
||||
// Fast path for empty hopper with no source container and no item entities:
|
||||
// if empty, cannot eject; if no container above, cannot suck container;
|
||||
// if no dropped items nearby, cannot suck entities -> nothing can happen!
|
||||
if is_empty
|
||||
&& self.world.block_entities.get(&above).is_none()
|
||||
&& self.carts.is_empty()
|
||||
&& self.boats.is_empty()
|
||||
&& (self.dropped.is_empty()
|
||||
|| self
|
||||
.item_columns
|
||||
.as_mut()
|
||||
.is_some_and(|c| !c.may_reach(&self.dropped, pos.0, pos.2)))
|
||||
&& self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(self.world.block(above.0, above.1, above.2))
|
||||
.is_none_or(|s| s.name != "minecraft:composter")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.native_prepare_hopper_transfer([pos.0, pos.1, pos.2]);
|
||||
let view = View {
|
||||
parts: vec![(pos, HOPPER_SLOTS)],
|
||||
kind: Kind::Hopper,
|
||||
container: None,
|
||||
};
|
||||
let mut changed = false;
|
||||
if !is_empty {
|
||||
changed = self.hopper_eject_with_state(pos, state, &view);
|
||||
if !c.is_empty(&view) {
|
||||
changed = self.hopper_eject(pos, &view);
|
||||
}
|
||||
let is_full = !changed && !has_empty_slot && {
|
||||
let c = self.containers();
|
||||
(0..HOPPER_SLOTS).all(|i| c.get(&view, i).is_some_and(|s| s.count >= c.max_stack(s)))
|
||||
};
|
||||
if !is_full {
|
||||
let c = self.containers();
|
||||
let full =
|
||||
(0..view.len()).all(|i| c.get(&view, i).is_some_and(|s| s.count == c.max_stack(s)));
|
||||
if !full {
|
||||
changed |= self.hopper_suck(pos, &view);
|
||||
}
|
||||
if changed {
|
||||
@@ -1084,20 +995,19 @@ impl Game<'_> {
|
||||
/// `tryMoveItems(…, () -> addItem(hopper, itemEntity))`; true when the
|
||||
/// item entity was taken whole.
|
||||
fn hopper_take_item(&mut self, pos: Pos, i: usize) -> bool {
|
||||
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) {
|
||||
return false;
|
||||
}
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let c = self.containers();
|
||||
if c.prop(state, "enabled") == Some("false") {
|
||||
return false;
|
||||
}
|
||||
let Some(view) = c.view(pos, false) else {
|
||||
return false;
|
||||
};
|
||||
if c.prop(state, "enabled") == Some("false")
|
||||
|| self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut changed = false;
|
||||
if !c.is_empty(&view) {
|
||||
changed = self.hopper_eject_with_state(pos, state, &view);
|
||||
changed = self.hopper_eject(pos, &view);
|
||||
}
|
||||
let c = self.containers();
|
||||
let full =
|
||||
@@ -1133,7 +1043,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// `HopperBlockEntity.ejectItems`: into the block container in front, else
|
||||
/// a container entity there (`getContainerAt`).
|
||||
fn hopper_eject_with_state(&mut self, pos: Pos, state: BlockStateId, hopper: &View) -> bool {
|
||||
fn hopper_eject(&mut self, pos: Pos, hopper: &View) -> bool {
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let c = self.containers();
|
||||
let Some(facing) = c.prop(state, "facing").and_then(dir_from_name) else {
|
||||
return false;
|
||||
@@ -1245,9 +1156,6 @@ impl Game<'_> {
|
||||
if blocked {
|
||||
return false;
|
||||
}
|
||||
if self.dropped.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Hopper.SUCK_AABB: the hopper bowl and the block above.
|
||||
let (min, max) = (
|
||||
[
|
||||
@@ -1306,7 +1214,7 @@ impl Game<'_> {
|
||||
// second setChanged after the move or, when the hopper has no
|
||||
// room, the setItem putting the bone meal back into the detached
|
||||
// container (whose bone meal is lost with it).
|
||||
let bone_meal = bone_meal_stack().clone();
|
||||
let bone_meal = c.bone_meal.clone();
|
||||
if !horizon_client::mod_api::mods().is_empty() {
|
||||
let mut cancel = false;
|
||||
self.native_inventory_move_item(
|
||||
@@ -1336,15 +1244,13 @@ impl Game<'_> {
|
||||
source: &View,
|
||||
hopper: &View,
|
||||
) -> bool {
|
||||
for slot in source.slots_for_face_iter(Some(Direction::Down)) {
|
||||
{
|
||||
let c = self.containers();
|
||||
let Some(stack) = c.get(source, slot) else {
|
||||
continue;
|
||||
};
|
||||
if !c.can_take(source, slot, stack, Direction::Down) {
|
||||
continue;
|
||||
}
|
||||
for slot in source.slots_for_face(Some(Direction::Down)) {
|
||||
let c = self.containers();
|
||||
let Some(stack) = c.get(source, slot) else {
|
||||
continue;
|
||||
};
|
||||
if !c.can_take(source, slot, stack, Direction::Down) {
|
||||
continue;
|
||||
}
|
||||
if self.hopper_move_canceled(source, slot, source, hopper) {
|
||||
continue;
|
||||
@@ -1363,9 +1269,7 @@ impl Game<'_> {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Some(rest) => {
|
||||
self.put_back(source, slot, rest);
|
||||
}
|
||||
Some(rest) => self.put_back(source, slot, rest),
|
||||
}
|
||||
}
|
||||
false
|
||||
@@ -1375,9 +1279,6 @@ impl Game<'_> {
|
||||
/// minecarts, chest boats) whose box meets the 1x1x1 box around `at`,
|
||||
/// picked at random.
|
||||
fn entity_container_at(&mut self, at: [f64; 3]) -> Option<u32> {
|
||||
if self.carts.is_empty() && self.boats.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let meets = |b: horizon_core::geometry::Aabb| {
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
lo.x < at[0] + 0.5
|
||||
|
||||
@@ -300,7 +300,11 @@ impl Game<'_> {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let marks = self.hooks_name_marks(m, Some(m.id) == own);
|
||||
let marks = if player || self.cosmetics_local() {
|
||||
self.cosmetics_marks(m, Some(m.id) == own)
|
||||
} else {
|
||||
Marks::default()
|
||||
};
|
||||
let at = match marks.top {
|
||||
Some(top) => p + glam::DVec3::Y * cosmetic_lift(top, m.model_scale()),
|
||||
None => p,
|
||||
@@ -766,10 +770,10 @@ impl Game<'_> {
|
||||
if background { BACKGROUND } else { 0 },
|
||||
),
|
||||
};
|
||||
if let Some(mark) = marks.mark.filter(|_| n == last) {
|
||||
if n == last && marks.horizon {
|
||||
let x = -self.gui.atlas.font.width_text(text) / 2.0 - 10.0;
|
||||
// 8 text pixels square, a pixel left of the background.
|
||||
for &(mx, my, w, h, argb) in mark {
|
||||
for &(mx, my, w, h, argb) in super::cosmetics::MARK {
|
||||
let a = text_alpha * (argb >> 24) / 255;
|
||||
batch.fill(
|
||||
&self.gui.atlas,
|
||||
@@ -933,11 +937,10 @@ struct NameTag {
|
||||
marks: Marks,
|
||||
}
|
||||
|
||||
/// What a hook draws by a player's name (`horizon_hooks::players::NameDecoration`): a mark
|
||||
/// before it (Horizon's: they play Horizon) and a name tag style.
|
||||
/// What Horizon draws by a player's name: its mark (they play Horizon) and their name tag style.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(super) struct Marks {
|
||||
pub mark: Option<horizon_hooks::players::Mark>,
|
||||
pub horizon: bool,
|
||||
pub style: Option<TagStyle>,
|
||||
/// How high the cosmetics drawn on their body get above the neck, in model pixels: the tag
|
||||
/// goes up over a tall one (`cosmetic_lift`).
|
||||
|
||||
@@ -471,7 +471,6 @@ pub(crate) mod tests {
|
||||
dim: game.dim,
|
||||
pos: [1, 2, 3],
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
forced: false,
|
||||
};
|
||||
let spawn = Vec3::new(1.0, 2.0, 3.0);
|
||||
|
||||
@@ -46,8 +46,6 @@ pub(super) struct SpawnScratch {
|
||||
/// dx/dz order, that no earlier player's square holds.
|
||||
segments: Vec<Vec<(i32, i32)>>,
|
||||
stale: Vec<bool>,
|
||||
/// Reusable buffer for spawn players across ticks to avoid heap allocation.
|
||||
pub(super) spawn_players: Vec<SpawnPlayer>,
|
||||
}
|
||||
impl SpawnScratch {
|
||||
fn chunk(p: &LevelPlayer) -> (i32, i32) {
|
||||
@@ -130,20 +128,6 @@ impl SpawnScratch {
|
||||
self.prepare(players);
|
||||
self.candidates.clear();
|
||||
let mut count = 0;
|
||||
if players.len() == 1 {
|
||||
let p = &players[0];
|
||||
count = self.segments.first().map_or(0, |s| s.len());
|
||||
if !p.spectator
|
||||
&& let Some(segment) = self.segments.first()
|
||||
{
|
||||
for &c in segment {
|
||||
if close_enough_for_spawning(p, c) && keep(c) {
|
||||
self.candidates.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
return count as i32;
|
||||
}
|
||||
for (i, segment) in self.segments.iter().enumerate() {
|
||||
count += segment.len();
|
||||
let near = &self.neighbours[i];
|
||||
@@ -666,10 +650,8 @@ fn spawn_cost_types(biomes: &horizon_client::biome::Biomes) -> Vec<&str> {
|
||||
types
|
||||
}
|
||||
impl<'a> Game<'a> {
|
||||
pub(super) fn natural_level_with_players(
|
||||
&self,
|
||||
players: Vec<LevelPlayer>,
|
||||
) -> GameLevel<'_, 'a> {
|
||||
pub(super) fn natural_level(&self) -> GameLevel<'_, 'a> {
|
||||
let players = self.level_players();
|
||||
let nearest = players
|
||||
.iter()
|
||||
.filter(|p| !p.spectator)
|
||||
@@ -688,9 +670,6 @@ impl<'a> Game<'a> {
|
||||
costed: spawn_cost_types(world_biomes!(self)),
|
||||
}
|
||||
}
|
||||
pub(super) fn natural_level(&self) -> GameLevel<'_, 'a> {
|
||||
self.natural_level_with_players(self.level_players())
|
||||
}
|
||||
/// Creates a mob the spawner accepted, finalized the game's way; the
|
||||
/// caller adds it with [`Game::add_spawned`].
|
||||
fn finalize_spawned(&self, r: &SpawnRequest, pending: &mut Vec<Mob>) -> Option<Spawned> {
|
||||
@@ -794,8 +773,6 @@ impl<'a> Game<'a> {
|
||||
scratch: &mut SpawnScratch,
|
||||
) -> i32 {
|
||||
let levels = self.tickets.levels();
|
||||
// Recomputed each pass: a chunk's ticket level and entity loading change without the
|
||||
// players moving or the chunk count changing.
|
||||
scratch.collect(players, |(cx, cz)| {
|
||||
levels.loaded_ticking(cx, cz) && super::ticking_chunk(&self.world, cx, cz)
|
||||
})
|
||||
@@ -842,28 +819,31 @@ impl<'a> Game<'a> {
|
||||
{
|
||||
let level = self.natural_level();
|
||||
laps.lap("sn_level");
|
||||
let counted = self.mobs.iter().map(|m| Counted {
|
||||
entity: &m.kind.id,
|
||||
category: Category::from_name(&m.kind.category),
|
||||
block: [
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
],
|
||||
is_mob: true,
|
||||
persistent: m.persistent
|
||||
|| horizon_client::mobs::ai::requires_custom_persistence(m),
|
||||
});
|
||||
scratch.spawn_players.clear();
|
||||
scratch
|
||||
.spawn_players
|
||||
.extend(players.iter().map(|p| SpawnPlayer {
|
||||
let counted: Vec<Counted> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.map(|m| Counted {
|
||||
entity: &m.kind.id,
|
||||
category: Category::from_name(&m.kind.category),
|
||||
block: [
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
],
|
||||
is_mob: true,
|
||||
persistent: m.persistent
|
||||
|| horizon_client::mobs::ai::requires_custom_persistence(m),
|
||||
})
|
||||
.collect();
|
||||
let players = players
|
||||
.iter()
|
||||
.map(|p| SpawnPlayer {
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
spectator: p.spectator,
|
||||
}));
|
||||
let spawn_players = std::mem::take(&mut scratch.spawn_players);
|
||||
})
|
||||
.collect();
|
||||
laps.lap("sn_materialize");
|
||||
let mut state = SpawnState::new(&level, spawnable, spawn_players, counted, |cx, cz| {
|
||||
let mut state = SpawnState::new(&level, spawnable, players, &counted, |cx, cz| {
|
||||
self.world.chunks.contains_key(&(cx, cz))
|
||||
});
|
||||
laps.lap("sn_state");
|
||||
@@ -874,8 +854,7 @@ impl<'a> Game<'a> {
|
||||
Vec::new()
|
||||
};
|
||||
laps.lap("sn_categories");
|
||||
// Util.shuffle, every pass: it draws from the level's random even when no
|
||||
// category may spawn.
|
||||
// Util.shuffle.
|
||||
for i in (1..scratch.candidates.len()).rev() {
|
||||
let j = random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
|
||||
scratch.candidates.swap(i, j);
|
||||
@@ -906,7 +885,6 @@ impl<'a> Game<'a> {
|
||||
);
|
||||
}
|
||||
}
|
||||
scratch.spawn_players = state.into_players();
|
||||
}
|
||||
spawn_selection::attempt_counters(true);
|
||||
laps.lap("sn_attempts");
|
||||
|
||||
@@ -185,7 +185,7 @@ impl Game<'_> {
|
||||
let output = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map_or(Default::default(), |r| r.output());
|
||||
.map_or(Default::default(), |r| *r.output());
|
||||
ui.draw(&self.settings, output, batch, &self.gui.atlas, self.cursor);
|
||||
self.ui.options = ui;
|
||||
self.show_calibration((batch.width, batch.height));
|
||||
|
||||
@@ -280,11 +280,11 @@ impl Game<'_> {
|
||||
/// front of) it, pulled in where visual shapes are in the way. Returns the
|
||||
/// position, yaw and pitch to render from.
|
||||
pub(super) fn camera_view(&mut self, partial: f32) -> Option<(glam::DVec3, f32, f32)> {
|
||||
if self.studio.flying() {
|
||||
if self.studio_freecam.active {
|
||||
return Some((
|
||||
self.studio.freecam.position,
|
||||
self.studio.freecam.yaw,
|
||||
self.studio.freecam.pitch,
|
||||
self.studio_freecam.position,
|
||||
self.studio_freecam.yaw,
|
||||
self.studio_freecam.pitch,
|
||||
));
|
||||
}
|
||||
// Minecraft.cameraEntity: a server's spectator camera on a mob or
|
||||
@@ -311,8 +311,8 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let mut view = self.view;
|
||||
// A hook may pull the camera back (an emote, from the player's face).
|
||||
let pull = self.hooks_camera_distance(partial);
|
||||
// An emote pulls the camera back from the player's face (`game/cosmetics/emotes.rs`).
|
||||
let pull = self.emote_camera_pull(partial);
|
||||
let kind = match self.camera_type {
|
||||
CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson,
|
||||
CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack,
|
||||
@@ -643,9 +643,8 @@ impl Game<'_> {
|
||||
/// The model's old pitch is the player's `xRotO`, which the turn moves
|
||||
/// with `xRot` (`turn_rotation`).
|
||||
pub(super) fn turn_player(&mut self, xo: f64, yo: f64) {
|
||||
if self.studio.flying() {
|
||||
self.studio
|
||||
.freecam
|
||||
if self.studio_freecam.active {
|
||||
self.studio_freecam
|
||||
.turn((xo * 0.15) as f32, (yo * 0.15) as f32);
|
||||
return;
|
||||
}
|
||||
@@ -698,7 +697,7 @@ impl Game<'_> {
|
||||
/// `LevelExtractor`'s skip of the camera entity: the player's own body
|
||||
/// is drawn unless the camera is in first person, or while it sleeps.
|
||||
pub(super) fn own_body_in_view(&self) -> bool {
|
||||
!self.camera_type.first_person() || self.sleeping.is_some() || self.studio.flying()
|
||||
!self.camera_type.first_person() || self.sleeping.is_some() || self.studio_freecam.active
|
||||
}
|
||||
/// Whether the player's own model shows in the world view (its body or,
|
||||
/// while invisible, only its armour and held items).
|
||||
|
||||
@@ -180,7 +180,7 @@ impl Game<'_> {
|
||||
raw: KeyCode,
|
||||
source: Source,
|
||||
) -> Result<()> {
|
||||
if self.screen == Screen::Playing && self.replay.playing() {
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() {
|
||||
if !pressed {
|
||||
self.keys.release(&key, source);
|
||||
return Ok(());
|
||||
@@ -191,7 +191,7 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if !studio_freecam_movement_key(key) || !self.studio.flying() {
|
||||
if !studio_freecam_movement_key(key) || !self.studio_freecam.active {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,41 @@ impl Game<'_> {
|
||||
/// the container's hotbar keys are now (`checkHotbarKeyPressed`).
|
||||
pub(super) fn key_pressed(&mut self, key: KeyCode, raw: KeyCode) -> Result<()> {
|
||||
self.direct_craft_stop();
|
||||
if self.screen == Screen::Playing && self.replay.playing() {
|
||||
self.studio_key(key);
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() {
|
||||
match key {
|
||||
KeyCode::Escape => {
|
||||
self.close_studio_replay();
|
||||
return Ok(());
|
||||
}
|
||||
KeyCode::KeyP => {
|
||||
self.pause_focus = None;
|
||||
self.screen = Screen::Paused;
|
||||
self.pause_menu = true;
|
||||
self.capture(false);
|
||||
}
|
||||
KeyCode::KeyF => self.handle_studio_timeline_action(
|
||||
horizon_client::studio::TimelineAction::ToggleFreecam,
|
||||
),
|
||||
KeyCode::KeyT => self.studio_timeline.visible = !self.studio_timeline.visible,
|
||||
KeyCode::KeyM => self.add_studio_marker(),
|
||||
KeyCode::Home => self.handle_studio_topbar_action(
|
||||
horizon_client::studio::TopBarAction::ResetCameraToPlayer,
|
||||
),
|
||||
KeyCode::KeyL => {
|
||||
self.studio_freecam.locked_to_path = !self.studio_freecam.locked_to_path;
|
||||
}
|
||||
KeyCode::KeyK => self.handle_studio_timeline_action(
|
||||
horizon_client::studio::TimelineAction::AddCameraKeyframe,
|
||||
),
|
||||
KeyCode::Delete => {
|
||||
if let Some(index) = self.studio_timeline.selected_keyframe {
|
||||
self.handle_studio_timeline_action(
|
||||
horizon_client::studio::TimelineAction::RemoveCameraKeyframe(index),
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// `CreativeModeInventoryScreen.keyPressed` clears `ignoreTextInput` on
|
||||
@@ -45,7 +78,7 @@ impl Game<'_> {
|
||||
KeyCode::Escape => match self.screen {
|
||||
Screen::Options => self.options_key(KeyCode::Escape, None)?,
|
||||
Screen::Playing => {
|
||||
if self.replay.playing() && self.captured {
|
||||
if self.studio_replay.is_some() && self.captured {
|
||||
self.capture(false);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -180,7 +213,7 @@ impl Game<'_> {
|
||||
// key.toggleGui is a KeyMapping: it clicks only with no screen open.
|
||||
KeyCode::F1 if self.screen == Screen::Playing => self.toggle_hide_gui(),
|
||||
KeyCode::F11 => self.toggle_fullscreen(),
|
||||
KeyCode::F5 if self.screen == Screen::Playing && !self.replay.playing() => {
|
||||
KeyCode::F5 if self.screen == Screen::Playing && self.studio_replay.is_none() => {
|
||||
self.cycle_camera()
|
||||
}
|
||||
KeyCode::KeyE
|
||||
|
||||
@@ -1,448 +0,0 @@
|
||||
//! Where the game hands the player models to crates above the engine (`horizon_hooks::Hooks`'s
|
||||
//! `frame`, `tick`, `name_decoration`, `player_mark`, `camera`; spec #14091): only a window's
|
||||
//! game calls them, never the integrated server's thread or a dedicated server. With no hook
|
||||
//! registered nothing is built.
|
||||
|
||||
use super::Game;
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_client::join_seq::JoinList;
|
||||
use horizon_client::mesher::Vertex;
|
||||
use horizon_client::mobs::anim::{self, BodyPose, PooledPoses, Poses};
|
||||
use horizon_client::mobs::{Mob, Part, Polygon, render};
|
||||
use horizon_hooks::engine;
|
||||
use horizon_hooks::players::{
|
||||
Bone, CameraView, Frame, Gear, Kind, Mesh, PartPoses, Pass, PlayerDraw, PlayerModel,
|
||||
PlayerModels, SpriteStore, Sprites,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Registers the hooks of the Horizon parts still in this crate (cosmetics: spec #14091 step 5
|
||||
/// moves them out). Before the hooks are sealed.
|
||||
pub(crate) fn register() {
|
||||
if super::cosmetics::enabled()
|
||||
&& let Err(e) = horizon_hooks::register(&super::cosmetics::HOOKS)
|
||||
{
|
||||
log::error!("Cosmetics: their hooks came too late: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The models of the world a window shows: its own first, then the other players and mannequins.
|
||||
struct Models<'a> {
|
||||
own: Option<&'a mut Mob>,
|
||||
mobs: &'a mut JoinList<Mob>,
|
||||
remote: &'a super::remote::Remote,
|
||||
}
|
||||
|
||||
impl Models<'_> {
|
||||
fn find(&mut self, id: u32) -> Option<&mut Mob> {
|
||||
match self.own.as_deref_mut() {
|
||||
Some(m) if m.id == id => Some(m),
|
||||
_ => self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id && kind(m).is_some()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What kind of player model `m` is, if it is one (the window's own model aside).
|
||||
fn kind(m: &Mob) -> Option<Kind> {
|
||||
match &*m.kind.id {
|
||||
"minecraft:player" => Some(Kind::Player),
|
||||
"minecraft:mannequin" => Some(Kind::Mannequin),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What `m` wears where a crate may draw: a helmet or head item, a chestplate, a cape or elytra.
|
||||
pub(super) fn gear(m: &Mob) -> Gear {
|
||||
let elytra = m.extra.chest.as_deref() == Some("minecraft:elytra");
|
||||
Gear::new(
|
||||
m.extra.head.is_some(),
|
||||
m.extra.chest.is_some() && !elytra,
|
||||
m.cape.is_some() || elytra,
|
||||
)
|
||||
}
|
||||
|
||||
fn model(m: &Mob, kind: Kind, uuid: Option<u128>) -> PlayerModel {
|
||||
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
|
||||
PlayerModel::new(m.id, kind, uuid, m.age, dx * dx + dz * dz > 1e-4)
|
||||
.looks(gear(m), horizon_client::mobs::trader::is_invisible(m))
|
||||
}
|
||||
|
||||
impl PlayerModels for Models<'_> {
|
||||
fn connection(&self) -> u64 {
|
||||
self.remote.session.serial()
|
||||
}
|
||||
|
||||
fn for_each(&self, f: &mut dyn FnMut(PlayerModel)) {
|
||||
if let Some(m) = self.own.as_deref() {
|
||||
f(model(m, Kind::Own, Some(self.remote.profile())));
|
||||
}
|
||||
for m in self.mobs.iter() {
|
||||
if let Some(kind) = kind(m) {
|
||||
let uuid = (kind == Kind::Player)
|
||||
.then(|| self.remote.player_uuid(m.id))
|
||||
.flatten();
|
||||
f(model(m, kind, uuid));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>> {
|
||||
match self.own.as_deref() {
|
||||
Some(m) if m.id == id => m.emote.as_ref(),
|
||||
_ => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && kind(m).is_some())?
|
||||
.emote
|
||||
.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>) {
|
||||
if let Some(m) = self.find(id) {
|
||||
m.emote = pose;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sprite `id`'s place in the atlas's names (`hooks/<hex>`), written into `buf`: no allocation.
|
||||
fn location(id: u64, buf: &mut [u8; 22]) -> &str {
|
||||
use std::io::Write;
|
||||
let mut out = std::io::Cursor::new(&mut buf[..]);
|
||||
let _ = write!(out, "hooks/{id:x}");
|
||||
let len = out.position() as usize;
|
||||
std::str::from_utf8(&buf[..len]).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The crates' sprites in the GUI atlas, in the skin slots: read while drawing.
|
||||
struct HookSprites<'a, 'g>(&'a Game<'g>);
|
||||
|
||||
impl Sprites for HookSprites<'_, '_> {
|
||||
fn get(&self, id: u64) -> Option<[f32; 4]> {
|
||||
let mut buf = [0; 22];
|
||||
let at = location(id, &mut buf);
|
||||
let r = self.0.gui.atlas.sprites.get(at)?;
|
||||
// Kept this frame (`skin_slots.next_frame`).
|
||||
self.0.skin_downloads.borrow_mut().drawn(at);
|
||||
Some([r.u0, r.v0, r.u1, r.v1])
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a crate's frame hook puts its sprites.
|
||||
struct HookStore<'a, 'g>(&'a mut Game<'g>);
|
||||
|
||||
impl SpriteStore for HookStore<'_, '_> {
|
||||
fn has(&self, id: u64) -> bool {
|
||||
self.0
|
||||
.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.contains_key(location(id, &mut [0; 22]))
|
||||
}
|
||||
|
||||
fn put(&mut self, id: u64, rgba: Vec<u8>) {
|
||||
let at = location(id, &mut [0; 22]).to_owned();
|
||||
self.0.store_skin(at, rgba);
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The crates' meshes as the renderer takes them, by the mesh's address (each entry holds its
|
||||
/// mesh, so the address isn't reused); render thread only.
|
||||
static MESHES: std::cell::RefCell<FxHashMap<usize, (Arc<Mesh>, Arc<Part>)>> =
|
||||
Default::default();
|
||||
}
|
||||
|
||||
/// The game's model of `mesh`, made once.
|
||||
fn part_of(mesh: &Arc<Mesh>) -> Arc<Part> {
|
||||
fn part(m: &Mesh) -> Part {
|
||||
Part {
|
||||
name: m.name.clone(),
|
||||
pose: m.pose,
|
||||
polygons: m
|
||||
.quads
|
||||
.iter()
|
||||
.map(|q| Polygon {
|
||||
normal: q.normal,
|
||||
vertices: q.vertices,
|
||||
cull_back: false,
|
||||
})
|
||||
.collect(),
|
||||
children: m.children.iter().map(part).collect(),
|
||||
}
|
||||
}
|
||||
MESHES.with_borrow_mut(|meshes| {
|
||||
let (_, part_of) = meshes
|
||||
.entry(Arc::as_ptr(mesh) as usize)
|
||||
.or_insert_with(|| (mesh.clone(), Arc::new(part(mesh))));
|
||||
part_of.clone()
|
||||
})
|
||||
}
|
||||
|
||||
/// Forgets the meshes no crate holds any more (once a frame).
|
||||
fn forget_meshes() {
|
||||
let _ = MESHES.try_with(|m| {
|
||||
m.borrow_mut()
|
||||
.retain(|_, (mesh, _)| Arc::strong_count(mesh) > 1)
|
||||
});
|
||||
}
|
||||
|
||||
/// `Poses` as a crate poses a mesh's parts.
|
||||
struct PosesOf<'a>(&'a mut Poses);
|
||||
|
||||
impl PartPoses for PosesOf<'_> {
|
||||
fn pose(&mut self, name: &str) -> &mut [f32; 9] {
|
||||
self.0.get(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// A player model drawn in the world pass, as a crate draws on it: posed on the GPU's skin path
|
||||
/// when `gpu` (the mob pass's skin frame, begun; the bounds of what went there grow, for
|
||||
/// culling), else its vertices go to `out`.
|
||||
struct DrawPlayer<'a, 'g> {
|
||||
game: &'a Game<'g>,
|
||||
m: &'a Mob,
|
||||
pass: Pass,
|
||||
/// The model's matrix (`render::base_transform`).
|
||||
base: glam::Mat4,
|
||||
alpha: f32,
|
||||
light: u32,
|
||||
gpu: bool,
|
||||
out: &'a mut Vec<Vertex>,
|
||||
bounds: Option<(glam::Vec3, glam::Vec3)>,
|
||||
/// The body as it is drawn, posed on the first `bone`.
|
||||
body: Option<PooledPoses>,
|
||||
poses: PooledPoses,
|
||||
}
|
||||
|
||||
impl DrawPlayer<'_, '_> {
|
||||
fn body(&self) -> &Part {
|
||||
let (key, _) = render::body(self.m);
|
||||
self.m.kind.layer(&key).unwrap_or(&self.m.kind.model)
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerDraw for DrawPlayer<'_, '_> {
|
||||
fn pass(&self) -> Pass {
|
||||
self.pass
|
||||
}
|
||||
|
||||
fn ticks(&self) -> f32 {
|
||||
self.m.age as f32 + self.alpha
|
||||
}
|
||||
|
||||
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]> {
|
||||
if self.body.is_none() {
|
||||
let mut poses = Poses::pooled();
|
||||
poses.reset(self.body());
|
||||
let data = self.game.res.mob_data();
|
||||
let state = anim::state(self.m, self.alpha);
|
||||
anim::setup(self.m, &state, &mut poses, &data.animations);
|
||||
self.body = Some(poses);
|
||||
}
|
||||
let poses = self.body.as_ref()?;
|
||||
let at = render::part_matrix(self.body(), self.base, poses, bone.part(), 0.0)?;
|
||||
Some(at.to_cols_array_2d())
|
||||
}
|
||||
|
||||
fn body_pose(&self) -> Option<&Arc<dyn BodyPose>> {
|
||||
self.m.emote.as_ref()
|
||||
}
|
||||
|
||||
fn sprite(&self, id: u64) -> Option<[f32; 4]> {
|
||||
HookSprites(self.game).get(id)
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&mut self,
|
||||
mesh: &Arc<Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
uv: [f32; 4],
|
||||
pose: &mut dyn FnMut(&mut dyn PartPoses),
|
||||
) {
|
||||
let model = part_of(mesh);
|
||||
let matrix = glam::Mat4::from_cols_array_2d(&matrix);
|
||||
self.poses.reset(&model);
|
||||
pose(&mut PosesOf(&mut self.poses));
|
||||
if self.gpu {
|
||||
let mut models = self.game.skin_models.borrow_mut();
|
||||
let mut frame = self.game.skin_frame.borrow_mut();
|
||||
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
|
||||
skin.bounds = self.bounds;
|
||||
let paint = horizon_client::mobs::skin::Paint {
|
||||
uv,
|
||||
scroll: 0.0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: self.light,
|
||||
overlay: 0,
|
||||
};
|
||||
if skin.draw(&model, matrix, &self.poses, paint) {
|
||||
self.bounds = skin.bounds;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let poses = &self.poses;
|
||||
horizon_client::mobs::render_model(
|
||||
&model,
|
||||
matrix,
|
||||
&|p: &Part| poses.pose(p),
|
||||
uv,
|
||||
self.light,
|
||||
0,
|
||||
self.out,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// What crates draw on player model `m` in the world pass (`creatures.rs`), then the
|
||||
/// cosmetics still drawn here: see [`DrawPlayer`]. The bounds of what went to the GPU.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn player_layers_geometry(
|
||||
&self,
|
||||
m: &Mob,
|
||||
base: glam::Mat4,
|
||||
camera: glam::DVec3,
|
||||
alpha: f32,
|
||||
light: u32,
|
||||
gpu: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) -> Option<(glam::Vec3, glam::Vec3)> {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
let kind = kind(m)?;
|
||||
let mut bounds = None;
|
||||
if engine::any() {
|
||||
let model = model(m, kind, self.remote_player_uuid(m.id));
|
||||
let mut draw = DrawPlayer {
|
||||
game: self,
|
||||
m,
|
||||
pass: Pass::World,
|
||||
base,
|
||||
alpha,
|
||||
light,
|
||||
gpu,
|
||||
out: &mut *out,
|
||||
bounds: None,
|
||||
body: None,
|
||||
poses: Poses::pooled(),
|
||||
};
|
||||
engine::player_layers(&model, &mut draw);
|
||||
bounds = draw.bounds;
|
||||
}
|
||||
let worn = super::cosmetics::enabled()
|
||||
.then(|| self.cosmetics_geometry(m, false, base, Some(camera), alpha, light, gpu, out))
|
||||
.flatten();
|
||||
match (bounds, worn) {
|
||||
(Some((l, h)), Some((lo, hi))) => Some((l.min(lo), h.max(hi))),
|
||||
(b, w) => b.or(w),
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a frame, before drawing.
|
||||
pub(super) fn client_hooks_frame(&mut self) {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
if !engine::any() {
|
||||
return;
|
||||
}
|
||||
self.cosmetics_say();
|
||||
let connection = self.remote.as_ref().map_or(0, |r| r.session.serial());
|
||||
let frame = Frame::new(connection, self.integrated.is_some());
|
||||
engine::frame(&frame, &mut HookStore(self));
|
||||
forget_meshes();
|
||||
self.cosmetics_frame();
|
||||
}
|
||||
|
||||
/// Once a client tick in a world: the player's own emote, then the hooks.
|
||||
pub(super) fn client_hooks_tick(&mut self) {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
self.tick_emotes();
|
||||
if !engine::any() {
|
||||
return;
|
||||
}
|
||||
self.cosmetics_say();
|
||||
let Some(remote) = self.remote.as_ref() else {
|
||||
return;
|
||||
};
|
||||
engine::tick(&mut Models {
|
||||
own: self.player_model.as_mut(),
|
||||
mobs: &mut self.mobs,
|
||||
remote,
|
||||
});
|
||||
}
|
||||
|
||||
/// What goes by `m`'s name (`own`: the window's own player), from the hooks.
|
||||
pub(super) fn hooks_name_marks(&self, m: &Mob, own: bool) -> super::names::Marks {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
let mut marks = super::names::Marks::default();
|
||||
if !engine::any() {
|
||||
return marks;
|
||||
}
|
||||
let model = match (own, kind(m)) {
|
||||
(true, _) => model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile())),
|
||||
(false, Some(kind)) => model(m, kind, self.remote_player_uuid(m.id)),
|
||||
(false, None) => return marks,
|
||||
};
|
||||
if let Some(d) = engine::name_decoration(&model, &HookSprites(self)) {
|
||||
marks.mark = d.mark;
|
||||
marks.top = d.top;
|
||||
marks.style = d.badge.map(|b| {
|
||||
let [u0, v0, u1, v1] = b.uv;
|
||||
super::names::TagStyle {
|
||||
mark: horizon_client::hud::Rect {
|
||||
u0,
|
||||
v0,
|
||||
u1,
|
||||
v1,
|
||||
w: 8,
|
||||
h: 8,
|
||||
},
|
||||
line: b.line,
|
||||
}
|
||||
});
|
||||
}
|
||||
marks
|
||||
}
|
||||
|
||||
/// How far the third-person camera stands back, as a share of the usual distance: a hook's
|
||||
/// (an emote pulls it back), else 1.
|
||||
pub(super) fn hooks_camera_distance(&self, partial: f32) -> f32 {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
match &self.player_model {
|
||||
Some(m) if engine::any() => {
|
||||
engine::camera(&CameraView::new(m.age as f32 + partial)).map_or(1.0, |c| c.distance)
|
||||
}
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sprite_locations_fit_the_buffer_and_differ_by_id() {
|
||||
let mut a = [0; 22];
|
||||
let mut b = [0; 22];
|
||||
assert_eq!(location(u64::MAX, &mut a), "hooks/ffffffffffffffff");
|
||||
assert_eq!(location(10, &mut b), "hooks/a");
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ impl Game<'_> {
|
||||
let pointer = self.captured || self.agent_pointer;
|
||||
let grabbed = pointer
|
||||
&& self.screen == Screen::Playing
|
||||
&& (self.player.is_some() || self.studio.flying());
|
||||
&& (self.player.is_some() || self.studio_freecam.active);
|
||||
let active = self.focused || self.agent_pointer;
|
||||
if let Some((xo, yo)) = self
|
||||
.mouse
|
||||
@@ -75,7 +75,10 @@ impl Game<'_> {
|
||||
.is_some_and(|t| t.elapsed() > Duration::from_millis(500))
|
||||
&& self.control.is_none()
|
||||
&& self.screen == Screen::Playing
|
||||
&& !studio_suppresses_focus_pause(self.replay.playing(), self.studio.flying())
|
||||
&& !studio_suppresses_focus_pause(
|
||||
self.studio_replay.is_some(),
|
||||
self.studio_freecam.active,
|
||||
)
|
||||
&& self.player.is_some()
|
||||
&& !self.options.no_pause
|
||||
&& self.settings.file.bool("pauseOnLostFocus")
|
||||
@@ -146,7 +149,7 @@ impl Game<'_> {
|
||||
self.reset_inventory_clicks();
|
||||
if !capture {
|
||||
self.stop_mining();
|
||||
self.studio.rmb_down = false;
|
||||
self.studio_rmb_down = false;
|
||||
}
|
||||
// The real pointer takes over from an agent's.
|
||||
self.agent_pointer = false;
|
||||
@@ -350,9 +353,9 @@ impl Game<'_> {
|
||||
/// click. A screen that is open keeps it free, as releasing the pointer
|
||||
/// for that screen did: it is the screen, not the focus, that shows it.
|
||||
pub(super) fn regrab_on_refocus(&mut self) {
|
||||
if self.studio_up() {
|
||||
if self.studio_replay.is_some() || self.studio_freecam.active {
|
||||
self.regrab_on_refocus = false;
|
||||
self.studio.rmb_down = false;
|
||||
self.studio_rmb_down = false;
|
||||
self.capture(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2433,7 +2433,7 @@ impl Game<'_> {
|
||||
/// view centre, `calculateStorageRange` (the server's radius, at least
|
||||
/// two, plus three) chunks each way.
|
||||
pub(super) fn remote_chunk_in_range(&self, x: i32, z: i32) -> bool {
|
||||
if self.replay.playing() {
|
||||
if self.studio_replay.is_some() {
|
||||
return true;
|
||||
}
|
||||
self.remote
|
||||
@@ -3110,6 +3110,33 @@ impl Game<'_> {
|
||||
index.get(mobs, id as u32).map(mob_tracking_body)
|
||||
});
|
||||
}
|
||||
fn remove_entity(&mut self, id: i32) {
|
||||
self.detach_remote_tracking_entity(id);
|
||||
self.audio.remove_sound_entity(id as u32);
|
||||
// Entity.setRemoved detaches every passenger, even when no empty
|
||||
// SetPassengers packet preceded the vehicle's removal. Otherwise
|
||||
// mobs and other players keep their passenger pose after it is gone.
|
||||
self.remote_set_passengers(id, &[]);
|
||||
self.mobs.retain(|m| m.id != id as u32);
|
||||
self.orbs.retain(|o| o.id != id as u32);
|
||||
self.dropped.retain(|d| d.id != id as u32);
|
||||
// Before the crystals go: the dragon's beam index follows them.
|
||||
self.remove_remote_dragon(id);
|
||||
self.remove_remote_object(id as u32);
|
||||
self.remove_remote_projectile(id as u32);
|
||||
self.persist.uuids.remove(&(id as u32));
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.glowing.remove(&id);
|
||||
r.entities.remove(&id);
|
||||
r.passenger_vehicles.remove(&(id as u32));
|
||||
r.passenger_vehicles.retain(|_, v| *v != id as u32);
|
||||
r.lerps.remove(&id);
|
||||
r.cart_lerps.remove(&id);
|
||||
r.stepped.remove(&id);
|
||||
r.head_lerps.remove(&id);
|
||||
r.pending_items.remove(&id);
|
||||
}
|
||||
}
|
||||
/// Map a menu slot to Horizon's inventory screen slot ids.
|
||||
pub(super) fn menu_slot(menu: &str, slot: i32) -> Option<usize> {
|
||||
let s = usize::try_from(slot).ok()?;
|
||||
@@ -3680,7 +3707,7 @@ impl Game<'_> {
|
||||
self.move_remote_interaction(p.id, p.position);
|
||||
}
|
||||
C::RemoveEntities(p) => {
|
||||
for &id in &p.ids {
|
||||
for id in p.ids {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
if let Some(uuid) = r.level_players.remove(&id) {
|
||||
r.attachments.remove(&Holder::Player(uuid));
|
||||
@@ -3689,8 +3716,8 @@ impl Game<'_> {
|
||||
r.level_player_textures.remove(&id);
|
||||
r.attachments.remove(&Holder::Entity(id as u32));
|
||||
}
|
||||
self.remove_entity(id);
|
||||
}
|
||||
self.remove_entities(&p.ids);
|
||||
}
|
||||
C::MoveEntityPos(play::MoveEntityPos(m))
|
||||
| C::MoveEntityPosRot(play::MoveEntityPosRot(m))
|
||||
@@ -6158,7 +6185,7 @@ impl Game<'_> {
|
||||
self.apply_remote_abilities();
|
||||
}
|
||||
}
|
||||
if !self.replay.playing() {
|
||||
if self.studio_replay.is_none() {
|
||||
self.previous_position = position;
|
||||
}
|
||||
self.yaw = yaw;
|
||||
@@ -6494,7 +6521,7 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
};
|
||||
player.fluids.boat = boat;
|
||||
if !riding && !self.replay.playing() {
|
||||
if !riding && self.studio_replay.is_none() {
|
||||
self.previous_position = player.position;
|
||||
}
|
||||
let before = (
|
||||
@@ -6701,10 +6728,10 @@ impl Game<'_> {
|
||||
self.snap_player_model_pitch();
|
||||
}
|
||||
// The player's own model (third person, the inventory's preview).
|
||||
if !self.replay.playing() {
|
||||
if self.studio_replay.is_none() {
|
||||
self.tick_player_model();
|
||||
}
|
||||
self.client_hooks_tick();
|
||||
self.tick_emotes();
|
||||
if !dead {
|
||||
self.remote_tick_using();
|
||||
}
|
||||
@@ -10501,7 +10528,7 @@ impl Game<'_> {
|
||||
/// A connection's screen is over the world: connecting, reconfiguring, or
|
||||
/// the level loading screen, a portal's included (`remote_overlay`).
|
||||
pub(super) fn remote_screen_up(&self) -> bool {
|
||||
!self.replay.playing()
|
||||
self.studio_replay.is_none()
|
||||
&& self
|
||||
.remote
|
||||
.as_ref()
|
||||
@@ -10585,7 +10612,7 @@ impl Game<'_> {
|
||||
/// Connected or connecting with no player yet: `ConnectScreen`, then
|
||||
/// `LevelLoadingScreen`, over the panorama with its blur.
|
||||
pub(super) fn remote_loading(&self) -> bool {
|
||||
if self.replay.playing() {
|
||||
if self.studio_replay.is_some() {
|
||||
return false;
|
||||
}
|
||||
use horizon_client::level_loading::Reason;
|
||||
@@ -10687,11 +10714,17 @@ impl Game<'_> {
|
||||
/// The profile name of another player the server sent, by entity id.
|
||||
/// The UUID of another player's entity in the level.
|
||||
pub(super) fn remote_player_uuid(&self, id: u32) -> Option<u128> {
|
||||
self.remote.as_ref()?.player_uuid(id)
|
||||
let r = self.remote.as_ref()?;
|
||||
let uuid = *r.level_players.get(&(id as i32))?;
|
||||
(uuid != r.profile).then_some(uuid)
|
||||
}
|
||||
pub(super) fn remote_player_name(&self, id: u32) -> Option<&str> {
|
||||
let r = self.remote.as_ref()?;
|
||||
r.level_player_name(id as i32, &r.player_uuid(id)?)
|
||||
let uuid = r.level_players.get(&(id as i32))?;
|
||||
if *uuid == r.profile {
|
||||
return None;
|
||||
}
|
||||
r.level_player_name(id as i32, uuid)
|
||||
}
|
||||
/// Status for `horizon-ctl status`.
|
||||
/// The other players' entities in the level and their names (their
|
||||
@@ -11472,11 +11505,6 @@ impl Remote {
|
||||
pub(super) fn profile(&self) -> u128 {
|
||||
self.profile
|
||||
}
|
||||
/// Another player's profile UUID, by their entity id (None for the local player).
|
||||
pub(super) fn player_uuid(&self, id: u32) -> Option<u128> {
|
||||
let uuid = *self.level_players.get(&(id as i32))?;
|
||||
(uuid != self.profile).then_some(uuid)
|
||||
}
|
||||
/// The entity id of a player in the level, by profile UUID.
|
||||
pub(super) fn player_entity(&self, uuid: u128) -> Option<u32> {
|
||||
self.level_players
|
||||
@@ -11614,9 +11642,6 @@ fn in_storage_range(view_distance: i32, center: (i32, i32), x: i32, z: i32) -> b
|
||||
x.abs_diff(center.0) <= range && z.abs_diff(center.1) <= range
|
||||
}
|
||||
|
||||
// A packet's removals (`RemoveEntities`), apart: this file may not grow.
|
||||
#[path = "remote_removal.rs"]
|
||||
mod removal;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
||||
@@ -793,17 +793,13 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_projectile(&mut self, id: u32) {
|
||||
self.fangs.list.retain(|f| f.id != id);
|
||||
self.tnts.retain(|t| t.id != id);
|
||||
self.falling.retain(|f| f.id != id);
|
||||
self.crystals.retain(|c| c.id != id);
|
||||
self.remove_remote_small_projectiles(id);
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_small_projectiles(&mut self, id: u32) {
|
||||
self.fangs.list.retain(|f| f.id != id);
|
||||
self.creatures.projectiles.retain(|p| p.id != id);
|
||||
self.tridents.retain(|t| t.id != id);
|
||||
self.rockets.retain(|r| r.id != id);
|
||||
self.crystals.retain(|c| c.id != id);
|
||||
self.potions.remove_remote_thrown(id);
|
||||
self.potions.clouds.retain(|c| c.id != id);
|
||||
self.end.remove_remote_eye(id);
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
//! Removing entities a server dropped (`RemoveEntities`), out of remote.rs,
|
||||
//! which may not grow.
|
||||
use super::super::Game;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn remove_entity(&mut self, id: i32) {
|
||||
self.remove_entities(&[id]);
|
||||
}
|
||||
|
||||
/// `ClientboundRemoveEntitiesPacket`: a packet's ids go together, the big
|
||||
/// entity lists filtered once (#13007), each id's own cleanup in order.
|
||||
pub(super) fn remove_entities(&mut self, ids: &[i32]) {
|
||||
for &id in ids {
|
||||
self.detach_remote_tracking_entity(id);
|
||||
self.audio.remove_sound_entity(id as u32);
|
||||
// Entity.setRemoved detaches every passenger, even when no empty
|
||||
// SetPassengers packet preceded the vehicle's removal. Otherwise
|
||||
// mobs and other players keep their passenger pose after it is gone.
|
||||
self.remote_set_passengers(id, &[]);
|
||||
// Before the crystals go: the dragon's beam index follows them.
|
||||
self.remove_remote_dragon(id);
|
||||
self.crystals.retain(|c| c.id != id as u32);
|
||||
self.remove_remote_object(id as u32);
|
||||
self.remove_remote_small_projectiles(id as u32);
|
||||
self.persist.uuids.remove(&(id as u32));
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.glowing.remove(&id);
|
||||
r.entities.remove(&id);
|
||||
r.passenger_vehicles.remove(&(id as u32));
|
||||
r.lerps.remove(&id);
|
||||
r.cart_lerps.remove(&id);
|
||||
r.stepped.remove(&id);
|
||||
r.head_lerps.remove(&id);
|
||||
r.pending_items.remove(&id);
|
||||
}
|
||||
}
|
||||
let removed: horizon_client::fast_hash::FxHashSet<u32> = if ids.len() > 1 {
|
||||
ids.iter().map(|&id| id as u32).collect()
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let keep = |id: u32| {
|
||||
if ids.len() > 1 {
|
||||
!removed.contains(&id)
|
||||
} else {
|
||||
ids.first().is_none_or(|&removed| id != removed as u32)
|
||||
}
|
||||
};
|
||||
self.mobs.retain(|m| keep(m.id));
|
||||
self.orbs.retain(|o| keep(o.id));
|
||||
self.dropped.retain(|d| keep(d.id));
|
||||
self.falling.retain(|f| keep(f.id));
|
||||
self.tnts.retain(|t| keep(t.id));
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.passenger_vehicles.retain(|_, v| keep(*v));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn remove_entities_filters_mixed_entity_lists() {
|
||||
use crate::game::{
|
||||
Audio, Game, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = ExecutionRole::Client;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let (client, _server) = horizon_protocol::connection::Pipe::pair();
|
||||
game.connect_local(client, "RemovalTest").unwrap();
|
||||
for (id, kind) in [
|
||||
(40, "minecraft:falling_block"),
|
||||
(41, "minecraft:falling_block"),
|
||||
(42, "minecraft:falling_block"),
|
||||
(43, "minecraft:tnt"),
|
||||
(44, "minecraft:tnt"),
|
||||
(45, "minecraft:experience_orb"),
|
||||
(46, "minecraft:experience_orb"),
|
||||
(47, "minecraft:zombie"),
|
||||
(48, "minecraft:zombie"),
|
||||
] {
|
||||
if matches!(kind, "minecraft:falling_block" | "minecraft:tnt") {
|
||||
assert!(game.spawn_remote_projectile(
|
||||
id,
|
||||
kind,
|
||||
[0.0, 64.0, 0.0],
|
||||
(0.0, 0.0),
|
||||
[0.0; 3],
|
||||
0
|
||||
));
|
||||
} else {
|
||||
game.spawn_entity(id, kind, [0.0, 64.0, 0.0], [0.0; 3]);
|
||||
}
|
||||
}
|
||||
for id in [49, 50] {
|
||||
game.dropped.push(horizon_client::entities::ItemEntity::new(
|
||||
id,
|
||||
horizon_client::items::ItemStack::new("minecraft:sand", 1),
|
||||
horizon_core::geometry::Vec3::new(0.0, 64.0, 0.0),
|
||||
horizon_core::geometry::Vec3::ZERO,
|
||||
0.0,
|
||||
));
|
||||
}
|
||||
game.handle_packet(C::RemoveEntities(play::RemoveEntities {
|
||||
ids: vec![40, 42, 43, 45, 47, 49],
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(game.falling.iter().map(|e| e.id).collect::<Vec<_>>(), [41]);
|
||||
assert_eq!(game.tnts.iter().map(|e| e.id).collect::<Vec<_>>(), [44]);
|
||||
assert_eq!(game.orbs.iter().map(|e| e.id).collect::<Vec<_>>(), [46]);
|
||||
assert_eq!(game.mobs.iter().map(|e| e.id).collect::<Vec<_>>(), [48]);
|
||||
assert_eq!(game.dropped.iter().map(|e| e.id).collect::<Vec<_>>(), [50]);
|
||||
}
|
||||
}
|
||||
@@ -168,7 +168,6 @@ impl Game<'_> {
|
||||
full_water_vision: self.settings.extras.pvp.tweaks.clear_water,
|
||||
night_vision: self.fog_night_vision(),
|
||||
environment: self.pack.polytone_colors.env,
|
||||
fluid_fog: self.pack.polytone_colors.fluid_fog,
|
||||
};
|
||||
let mut fog = horizon_client::fog::setup_fog(tables, &self.fog, &input);
|
||||
if !self.settings.horizon.fog {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
//! Replay playback's state in a `Game`. Recording a session's packets as `.mcpr` and playing them
|
||||
//! back is platform (docs/layers.md, "Replays"); Studio's editor (`studio.rs`) builds on it.
|
||||
|
||||
use super::Game;
|
||||
use horizon_client::studio::ReplayDriver;
|
||||
|
||||
/// The packet replay a `Game` plays, if any, and its playback clock.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Replay {
|
||||
/// The packet replay playing, if any.
|
||||
pub(super) driver: Option<ReplayDriver>,
|
||||
/// Camera perspective to restore after leaving replay playback.
|
||||
pub(super) previous_camera_type: Option<super::perspective::CameraType>,
|
||||
/// Replay opened from the title screen owns the synthetic remote session.
|
||||
pub(super) standalone: bool,
|
||||
pub(super) last_tick: Option<std::time::Instant>,
|
||||
pub(super) virtual_tick_ms: f32,
|
||||
}
|
||||
|
||||
impl Replay {
|
||||
/// A replay plays: the world shown is the recording's.
|
||||
pub(super) fn playing(&self) -> bool {
|
||||
self.driver.is_some()
|
||||
}
|
||||
/// How far the replay's player is between its last two ticks, while a replay plays: it
|
||||
/// moves on the replay's clock, not the game's.
|
||||
pub(super) fn player_alpha(&self) -> Option<f64> {
|
||||
self.playing()
|
||||
.then(|| (f64::from(self.virtual_tick_ms) / 50.0).clamp(0.0, 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// How far the window's own player model is between two ticks: on the replay's clock while a
|
||||
/// replay plays, else the game's.
|
||||
pub(super) fn own_player_alpha(&self) -> f64 {
|
||||
self.replay
|
||||
.player_alpha()
|
||||
.unwrap_or_else(|| self.clock.camera_fraction())
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ use super::input_trace::{InputOrigin, TraceEvent, TraceKind};
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{Result, gamepad::Button, keybinds, text::Text, title::world_management};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_telemetry::scrub::Scrubber;
|
||||
use horizon_system::scrub::Scrubber;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
|
||||
@@ -29,13 +29,12 @@ pub(super) struct RespawnConfig {
|
||||
pub dim: Dim,
|
||||
pub pos: [i32; 3],
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
pub forced: bool,
|
||||
}
|
||||
|
||||
/// A respawn point as a player's save keeps it (`respawn`): dimension,
|
||||
/// block, yaw, pitch and whether it is forced.
|
||||
pub(super) type SavedRespawn = (String, [i32; 3], f32, f32, bool);
|
||||
/// block, yaw and whether it is forced.
|
||||
pub(super) type SavedRespawn = (String, [i32; 3], f32, bool);
|
||||
|
||||
impl RespawnConfig {
|
||||
/// `RespawnData.of`: the yaw wrapped to [-180, 180).
|
||||
@@ -44,37 +43,19 @@ impl RespawnConfig {
|
||||
dim,
|
||||
pos,
|
||||
yaw: wrap_degrees(f64::from(yaw)) as f32,
|
||||
pitch: 0.0,
|
||||
forced,
|
||||
}
|
||||
}
|
||||
/// `SetSpawnCommand`: the forced literal, with its already parsed angles.
|
||||
pub(super) fn forced(dim: Dim, pos: [i32; 3], yaw: f32, pitch: f32) -> Self {
|
||||
Self {
|
||||
dim,
|
||||
pos,
|
||||
yaw,
|
||||
pitch,
|
||||
forced: true,
|
||||
}
|
||||
}
|
||||
pub(super) fn from_saved((dim, pos, yaw, pitch, forced): &SavedRespawn) -> Self {
|
||||
pub(super) fn from_saved((dim, pos, yaw, forced): &SavedRespawn) -> Self {
|
||||
Self {
|
||||
dim: Dim::from_id(dim),
|
||||
pos: *pos,
|
||||
yaw: *yaw,
|
||||
pitch: *pitch,
|
||||
forced: *forced,
|
||||
}
|
||||
}
|
||||
pub(super) fn saved(&self) -> SavedRespawn {
|
||||
(
|
||||
self.dim.id().to_owned(),
|
||||
self.pos,
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
self.forced,
|
||||
)
|
||||
(self.dim.id().to_owned(), self.pos, self.yaw, self.forced)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +180,6 @@ impl Game<'_> {
|
||||
dim: self.dim,
|
||||
pos,
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
forced: false,
|
||||
};
|
||||
// RespawnConfig.isSamePosition: dimension and block.
|
||||
@@ -498,14 +478,14 @@ impl Game<'_> {
|
||||
rule.can_set_spawn.test(self.dark_outside())
|
||||
}
|
||||
/// `ServerPlayer.findRespawnAndUseSpawnBlock` for a config in the loaded
|
||||
/// dimension: position and angles, using an anchor's charge; a bed's
|
||||
/// dimension: the position and yaw, using an anchor's charge; a bed's
|
||||
/// stand-up spot. A forced point (`spawnpoint`) that is no anchor or bed
|
||||
/// holds if a player fits in it; anything else is gone.
|
||||
pub(super) fn find_respawn_and_use(
|
||||
&mut self,
|
||||
c: RespawnConfig,
|
||||
consume: bool,
|
||||
) -> Result<Option<(Vec3, f32, f32)>> {
|
||||
) -> Result<Option<(Vec3, f32)>> {
|
||||
let state = self.world.block(c.pos[0], c.pos[1], c.pos[2]);
|
||||
if !(self.is_anchor(state)
|
||||
&& (c.forced || self.anchor_charges(state) > 0)
|
||||
@@ -515,7 +495,7 @@ impl Game<'_> {
|
||||
if self.is_bed(state) && self.bed_sets_spawn(state) {
|
||||
let facing = self.bed_forward(state);
|
||||
let stand = self.bed_stand_up(c.pos, facing, c.yaw);
|
||||
return Ok(stand.map(|p| (p, look_at_yaw(p, c.pos), 0.0)));
|
||||
return Ok(stand.map(|p| (p, look_at_yaw(p, c.pos))));
|
||||
}
|
||||
let [x, y, z] = c.pos;
|
||||
let free = c.forced
|
||||
@@ -523,7 +503,7 @@ impl Game<'_> {
|
||||
&& self.possible_to_respawn_in(state)
|
||||
&& self.possible_to_respawn_in(self.world.block(x, y + 1, z));
|
||||
let at = Vec3::new(f64::from(x) + 0.5, f64::from(y) + 0.1, f64::from(z) + 0.5);
|
||||
return Ok(free.then_some((at, c.yaw, c.pitch)));
|
||||
return Ok(free.then_some((at, c.yaw)));
|
||||
}
|
||||
let stand = self.anchor_stand_up(c.pos);
|
||||
if !c.forced
|
||||
@@ -533,7 +513,7 @@ impl Game<'_> {
|
||||
{
|
||||
self.level_set(c.pos, next)?;
|
||||
}
|
||||
Ok(stand.map(|p| (p, look_at_yaw(p, c.pos), 0.0)))
|
||||
Ok(stand.map(|p| (p, look_at_yaw(p, c.pos))))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,354 +560,3 @@ mod tests {
|
||||
assert!((look_at_yaw(east, head) - 90.0).abs() < 1.0e-4);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod connected_spawnpoint_rotation_tests {
|
||||
use crate::game::{Game, lan::LanSettings};
|
||||
use horizon_client::{
|
||||
chunk::Chunk,
|
||||
lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig, playerdata},
|
||||
};
|
||||
use horizon_protocol::packets::{
|
||||
configuration::ClientInformation,
|
||||
play::{self, Clientbound as C, Serverbound as S},
|
||||
};
|
||||
use std::sync::{Arc, atomic::AtomicI32, mpsc};
|
||||
|
||||
fn command(game: &mut Game<'_>, text: &str) {
|
||||
game.chat_capture = Some(Vec::new());
|
||||
game.ui.registries.permission = 4;
|
||||
let dispatcher = horizon_client::command::vanilla::build_for(false, true);
|
||||
let parsed = dispatcher.parse(text, &game.ui.registries);
|
||||
let (ctx, _) = parsed
|
||||
.executable()
|
||||
.expect("actual registered dedicated command");
|
||||
let (root, ctx) = (parsed.context.clone(), ctx.clone());
|
||||
assert_eq!(game.run_chain(&root, &ctx).unwrap(), 1, "{text}");
|
||||
assert!(
|
||||
!game.chat_capture.take().unwrap().is_empty(),
|
||||
"actual command feedback {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "prepared resources and isolated scratch homes/cwd"]
|
||||
fn command_forced_free_point_survives_real_guest_death_and_respawn_packet() {
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
game.set_allow_commands(true);
|
||||
let scratch = std::env::temp_dir().join(format!(
|
||||
"horizon-forced-spawn-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&scratch).unwrap();
|
||||
game.world_root = scratch.clone();
|
||||
// Fixture setup through the existing column insertion API; admit every lit column below.
|
||||
for x in -2..=2 {
|
||||
for z in -2..=2 {
|
||||
game.streaming
|
||||
.insert_remote(&mut game.world, Chunk::empty(x, z));
|
||||
assert!(game.streaming.is_lit(x, z));
|
||||
}
|
||||
}
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
let air = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:air")
|
||||
.unwrap();
|
||||
game.world.set(12, 99, 12, stone).unwrap();
|
||||
for y in [100, 101] {
|
||||
game.world.set(12, y, 12, air).unwrap();
|
||||
}
|
||||
assert_eq!(game.world.block(12, 99, 12), stone);
|
||||
assert!(game.world.loaded(12, 12));
|
||||
game.level.world_spawn = Some([4, 100, 4]);
|
||||
game.level.world_spawn_angle = [-30.0, -5.0];
|
||||
game.lan_host(
|
||||
LanSettings {
|
||||
port: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
},
|
||||
NetConfig {
|
||||
listen: false,
|
||||
announce: false,
|
||||
..NetConfig::loopback(0, String::new(), 2)
|
||||
},
|
||||
HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 2,
|
||||
simulation_distance: 2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 2,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut guest = Guest::new(
|
||||
Joined {
|
||||
conn: 7,
|
||||
uuid: 77,
|
||||
name: "Alice".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(2),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:7".parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
7,
|
||||
[4.5, 100.0, 4.5],
|
||||
2,
|
||||
);
|
||||
guest.dimension = "minecraft:overworld".into();
|
||||
guest.placed = true;
|
||||
guest.loaded = true;
|
||||
guest.health = 20.0;
|
||||
guest.game_mode = 0;
|
||||
guest.yaw = 0.0;
|
||||
guest.pitch = 0.0;
|
||||
game.lan.as_mut().unwrap().hub.guests.push(guest);
|
||||
let clock = game.world.game_time;
|
||||
command(&mut game, "spawnpoint Alice 12 100 12 45 10");
|
||||
let host = game.lan.as_ref().unwrap();
|
||||
assert!(host.owner.is_none());
|
||||
let guest = &host.hub.guests[0];
|
||||
assert_eq!(
|
||||
(guest.uuid, guest.health, guest.game_mode, guest.position),
|
||||
(77, 20.0, 0, [4.5, 100.0, 4.5])
|
||||
);
|
||||
let tag = playerdata::player_tag(guest);
|
||||
let respawn = tag
|
||||
.get("respawn")
|
||||
.expect("actual serialized personal configuration")
|
||||
.clone();
|
||||
assert_eq!(
|
||||
respawn.get("yaw"),
|
||||
Some(&horizon_world::nbt::Tag::Float(45f32.to_bits()))
|
||||
);
|
||||
assert_eq!(
|
||||
respawn.get("pos"),
|
||||
Some(&horizon_world::nbt::Tag::IntArray(vec![12, 100, 12]))
|
||||
);
|
||||
command(&mut game, "kill Alice");
|
||||
game.tick_lan(false);
|
||||
let mut host = game.lan.take().unwrap();
|
||||
assert_eq!(host.hub.guests[0].uuid, 77);
|
||||
assert_eq!(host.hub.guests[0].health, 0.0);
|
||||
assert!(
|
||||
host.hub.guests[0].dead,
|
||||
"actual command death before request"
|
||||
);
|
||||
for _ in rx.try_iter() {}
|
||||
game.lan_guest_packet(
|
||||
&mut host,
|
||||
7,
|
||||
S::ClientCommand(play::ClientCommand { action: 0 }),
|
||||
);
|
||||
game.tick_server_travel(&mut host);
|
||||
assert!(
|
||||
!game.on_server_trip(7),
|
||||
"admitted lit columns permit actual arrival"
|
||||
);
|
||||
let guest = &host.hub.guests[0];
|
||||
assert_eq!(guest.uuid, 77);
|
||||
assert_eq!(guest.health, 20.0);
|
||||
assert!(!guest.dead);
|
||||
assert_eq!(guest.position, [12.5, 100.1, 12.5]);
|
||||
assert_eq!(game.level.world_spawn, Some([4, 100, 4]));
|
||||
assert_eq!(game.level.world_spawn_angle, [-30.0, -5.0]);
|
||||
assert_eq!(game.world.game_time, clock);
|
||||
let physical = [guest.yaw.to_bits(), guest.pitch.to_bits()];
|
||||
let awaiting_id = guest
|
||||
.awaiting_teleport
|
||||
.expect("actual final arrival awaits its acknowledgement")
|
||||
.0;
|
||||
let mut packets = Vec::new();
|
||||
for out in rx.try_iter() {
|
||||
match out {
|
||||
Out::Packet(p) => packets.push(p),
|
||||
Out::Packets(p) => packets.extend(p),
|
||||
Out::Disconnect(_) => panic!("unexpected disconnect"),
|
||||
}
|
||||
}
|
||||
let mut positions = Vec::new();
|
||||
let mut respawns = 0;
|
||||
for p in packets {
|
||||
println!("complete actual Hub frame {p:?}");
|
||||
let mut r = horizon_protocol::buf::Reader::new(&p);
|
||||
let id = r.varint().unwrap();
|
||||
match C::decode(id, r.rest()).unwrap() {
|
||||
C::PlayerPosition(p) => positions.push(p),
|
||||
C::Respawn(_) => respawns += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(respawns, 1);
|
||||
assert!(!positions.is_empty());
|
||||
println!("all actual arrival positions {positions:?}");
|
||||
let selected: Vec<_> = positions
|
||||
.iter()
|
||||
.filter(|p| p.teleport == awaiting_id)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
selected.len(),
|
||||
1,
|
||||
"select by actual authoritative pending id, never expected position/pitch"
|
||||
);
|
||||
let p = selected[0];
|
||||
assert_eq!(p.position, [12.5, 100.1, 12.5]);
|
||||
// All successful command/death/arrival/identity/world controls precede the intended regression.
|
||||
assert_eq!(
|
||||
(
|
||||
respawn.get("pitch"),
|
||||
physical,
|
||||
[p.yaw.to_bits(), p.pitch.to_bits()]
|
||||
),
|
||||
(
|
||||
Some(&horizon_world::nbt::Tag::Float(10f32.to_bits())),
|
||||
[45f32.to_bits(), 10f32.to_bits()],
|
||||
[45f32.to_bits(), 10f32.to_bits()]
|
||||
)
|
||||
);
|
||||
drop(host);
|
||||
drop(game);
|
||||
std::fs::remove_dir_all(scratch).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pitch_preservation_controls {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wrapped_existing_callers_default_to_positive_zero_pitch() {
|
||||
let c = RespawnConfig::wrapped(Dim::Overworld, [5, 100, 5], 45.0, false);
|
||||
assert_eq!(c.yaw.to_bits(), 45f32.to_bits());
|
||||
assert_eq!(c.pitch.to_bits(), 0.0f32.to_bits());
|
||||
assert!(!c.forced);
|
||||
}
|
||||
|
||||
/// Added source/model controls; these do not add a connected bed/anchor runtime claim.
|
||||
#[test]
|
||||
#[ignore = "prepared resources and isolated scratch homes/cwd"]
|
||||
fn bed_anchor_facing_and_invalid_personal_fallback_ignore_stored_pitch() {
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
game.world.set(x, 99, z, stone).unwrap();
|
||||
}
|
||||
}
|
||||
let state = |game: &Game<'_>, name: &str, properties: &[(&str, &str)]| {
|
||||
let registry = game.pack.assets.registry;
|
||||
let id = registry.default_state(name).unwrap();
|
||||
let mut value = registry.state(id).unwrap().clone();
|
||||
for (key, value_) in properties {
|
||||
value.properties.insert((*key).into(), (*value_).into());
|
||||
}
|
||||
registry.resolve(&value).unwrap()
|
||||
};
|
||||
let head = state(
|
||||
&game,
|
||||
"minecraft:red_bed",
|
||||
&[("facing", "north"), ("part", "head")],
|
||||
);
|
||||
let foot = state(
|
||||
&game,
|
||||
"minecraft:red_bed",
|
||||
&[("facing", "north"), ("part", "foot")],
|
||||
);
|
||||
game.world.set(5, 100, 5, head).unwrap();
|
||||
game.world.set(5, 100, 6, foot).unwrap();
|
||||
assert!(game.is_bed(head) && game.bed_sets_spawn(head));
|
||||
let bed = RespawnConfig {
|
||||
dim: Dim::Overworld,
|
||||
pos: [5, 100, 5],
|
||||
yaw: 45.0,
|
||||
pitch: 0.0,
|
||||
forced: true,
|
||||
};
|
||||
let zero = game
|
||||
.find_respawn_and_use(bed, false)
|
||||
.unwrap()
|
||||
.expect("valid bed geometry");
|
||||
let stored = game
|
||||
.find_respawn_and_use(RespawnConfig { pitch: 75.0, ..bed }, false)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(stored, zero);
|
||||
assert_eq!(stored.2.to_bits(), 0.0f32.to_bits());
|
||||
assert_eq!(stored.1.to_bits(), look_at_yaw(stored.0, bed.pos).to_bits());
|
||||
|
||||
// Existing built-in dimension facts select the anchor rule; no new world or actor API.
|
||||
game.dim = Dim::Nether;
|
||||
let anchor = state(&game, "minecraft:respawn_anchor", &[("charges", "2")]);
|
||||
game.world.set(10, 100, 10, anchor).unwrap();
|
||||
assert!(game.anchor_works() && game.is_anchor(anchor));
|
||||
let c = RespawnConfig {
|
||||
dim: Dim::Nether,
|
||||
pos: [10, 100, 10],
|
||||
yaw: 45.0,
|
||||
pitch: 0.0,
|
||||
forced: true,
|
||||
};
|
||||
let zero = game
|
||||
.find_respawn_and_use(c, false)
|
||||
.unwrap()
|
||||
.expect("valid anchor geometry");
|
||||
let stored = game
|
||||
.find_respawn_and_use(RespawnConfig { pitch: 75.0, ..c }, true)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(stored, zero);
|
||||
assert_eq!(stored.2.to_bits(), 0.0f32.to_bits());
|
||||
assert_eq!(stored.1.to_bits(), look_at_yaw(stored.0, c.pos).to_bits());
|
||||
assert_eq!(
|
||||
game.world.block(10, 100, 10),
|
||||
anchor,
|
||||
"existing forced-charge behavior"
|
||||
);
|
||||
|
||||
game.dim = Dim::Overworld;
|
||||
game.level.world_spawn = Some([4, 100, 4]);
|
||||
game.level.world_spawn_angle = [-30.0, -5.0];
|
||||
game.world.set(12, 100, 12, stone).unwrap();
|
||||
let invalid = RespawnConfig {
|
||||
dim: Dim::Overworld,
|
||||
pos: [12, 100, 12],
|
||||
yaw: 45.0,
|
||||
pitch: 10.0,
|
||||
forced: true,
|
||||
};
|
||||
game.respawn_config = Some(invalid);
|
||||
assert!(game.resolve_server_respawn(invalid).is_none());
|
||||
assert!(game.respawn_config.is_none());
|
||||
assert_eq!(game.level.world_spawn, Some([4, 100, 4]));
|
||||
assert_eq!(game.level.world_spawn_angle, [-30.0, -5.0]);
|
||||
}
|
||||
}
|
||||
@@ -362,7 +362,6 @@ impl Game<'_> {
|
||||
path,
|
||||
size: Some([64, 64]),
|
||||
scaled: false,
|
||||
background: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -823,7 +823,7 @@ impl Game<'_> {
|
||||
for (location, rgba) in ready {
|
||||
self.store_skin(location, rgba);
|
||||
}
|
||||
self.client_hooks_frame();
|
||||
self.cosmetics_frame();
|
||||
}
|
||||
/// `Hud.extractTabList`: the player list while the player-list key is
|
||||
/// held. A local world shows it with a joined LAN guest or a `list`
|
||||
@@ -900,12 +900,10 @@ impl Game<'_> {
|
||||
let board = &self.score.board;
|
||||
let objective = board.display_objective(sb::SLOT_LIST);
|
||||
let up = |id: u128| upside_down.contains(&id);
|
||||
// A hook's mark before a name (Horizon's: they play Horizon).
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
let marked = horizon_hooks::engine::player_mark;
|
||||
// Horizon's mark before the names of Horizon players (the cosmetics service's cache).
|
||||
let horizon = self.horizon_players(players.iter().map(|p| p.uuid));
|
||||
let horizon_mark = super::cosmetics::MARK;
|
||||
let marked = |id: u128| horizon.contains(&id).then_some(horizon_mark);
|
||||
let inputs = TabInputs {
|
||||
players: &players,
|
||||
board,
|
||||
|
||||
@@ -138,7 +138,10 @@ impl Game<'_> {
|
||||
return Err("a screenshot sequence is already running".into());
|
||||
}
|
||||
let renderer = self.renderer.as_ref().ok_or("no renderer yet")?;
|
||||
if self.pending_screenshot.is_some() || renderer.screenshot.is_some() {
|
||||
if self.pending_screenshot.is_some()
|
||||
|| self.ui.screenshot_message.is_some()
|
||||
|| renderer.screenshot.is_some()
|
||||
{
|
||||
return Err("a screenshot is already pending".into());
|
||||
}
|
||||
if let Some(refusal) = self.sequence_refusal() {
|
||||
@@ -230,7 +233,6 @@ impl Game<'_> {
|
||||
path,
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.screenshot_result = None;
|
||||
}
|
||||
|
||||
@@ -644,14 +644,11 @@ impl Game<'_> {
|
||||
/// in the level in place: where it stands up, facing the anchor (with
|
||||
/// the deplete sound) or the bed; else the point is forgotten, and the
|
||||
/// caller tells the player (`Hub::missing_respawn_block`).
|
||||
pub(super) fn resolve_server_respawn(
|
||||
&mut self,
|
||||
config: RespawnConfig,
|
||||
) -> Option<(Vec3, f32, f32)> {
|
||||
pub(super) fn resolve_server_respawn(&mut self, config: RespawnConfig) -> Option<(Vec3, f32)> {
|
||||
let [x, y, z] = config.pos;
|
||||
let anchor = self.is_anchor(self.world.block(x, y, z));
|
||||
match self.find_respawn_and_use(config, true) {
|
||||
Ok(Some((at, yaw, pitch))) => {
|
||||
Ok(Some((at, yaw))) => {
|
||||
if !config.forced && anchor {
|
||||
let [x, y, z] = config.pos.map(f64::from);
|
||||
self.audio.play(
|
||||
@@ -661,7 +658,7 @@ impl Game<'_> {
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
Some((at, yaw, pitch))
|
||||
Some((at, yaw))
|
||||
}
|
||||
Ok(None) | Err(_) => {
|
||||
let mut cancel = false;
|
||||
@@ -799,10 +796,7 @@ impl Game<'_> {
|
||||
let yaw = host.hub.guests[i].yaw;
|
||||
let (position, yaw) = if let Some(config) = trip.respawn {
|
||||
match self.as_guest(host, trip.conn, |g| g.resolve_server_respawn(config)) {
|
||||
Some(Some((at, yaw, pitch))) => {
|
||||
host.hub.guests[i].pitch = pitch;
|
||||
(at, yaw)
|
||||
}
|
||||
Some(Some(found)) => found,
|
||||
_ => {
|
||||
host.hub.missing_respawn_block(trip.conn);
|
||||
self.respawn_at_world_spawn(host, trip.conn);
|
||||
|
||||
@@ -261,8 +261,6 @@ impl<'a> Split<'a> {
|
||||
.get(i.wrapping_sub(1))
|
||||
.map(|g| g.options.clone()),
|
||||
);
|
||||
let seat = self.guests.get(i.wrapping_sub(1)).map_or(1, |g| g.seat);
|
||||
horizon_hooks::engine::set_seat(seat - 1);
|
||||
self.turn = i;
|
||||
}
|
||||
|
||||
|
||||
@@ -126,7 +126,6 @@ impl Split<'_> {
|
||||
// Until it has a profile, a scratch options file: never P1's.
|
||||
let options = Self::folder()?.join(".joining/options.txt");
|
||||
OPTIONS.set(Some(options.clone()));
|
||||
horizon_hooks::engine::set_seat(seat - 1);
|
||||
let mut settings = self.host.settings.clone();
|
||||
if pad.is_some() {
|
||||
// The Controller setting is P1's own: a player who pressed a pad to join plays
|
||||
@@ -136,7 +135,6 @@ impl Split<'_> {
|
||||
}
|
||||
let game = self.start_guest(seat, &options, settings);
|
||||
OPTIONS.set(None);
|
||||
horizon_hooks::engine::set_seat(0);
|
||||
let mut game = game?;
|
||||
if let Some(pad) = pad {
|
||||
// The press that joined it doesn't act in its game.
|
||||
@@ -301,31 +299,16 @@ impl Split<'_> {
|
||||
}
|
||||
let profile = profiles.join(&name);
|
||||
let options = profile.join("options.txt");
|
||||
let made = if options.exists() {
|
||||
None
|
||||
let mut settings = if options.exists() {
|
||||
horizon_client::settings::Settings::load(&options)
|
||||
} else {
|
||||
std::fs::create_dir_all(&profile)?;
|
||||
let mut new = self.host.settings.clone();
|
||||
// The Controller setting is P1's, as on the profile page (`join`).
|
||||
new.controller.enabled |= self.guests[i].pad.is_some();
|
||||
// On P1's seat, so the crates' settings stores write P1's values too.
|
||||
let turn = horizon_hooks::seat();
|
||||
horizon_hooks::engine::set_seat(0);
|
||||
new.save(&options);
|
||||
horizon_hooks::engine::set_seat(turn);
|
||||
Some(new)
|
||||
new
|
||||
};
|
||||
// The crates' stores load into the guest's seat, before its turn starts below.
|
||||
let turn = horizon_hooks::seat();
|
||||
horizon_hooks::engine::set_seat(self.guests[i].seat - 1);
|
||||
let mut settings = match made {
|
||||
Some(new) => {
|
||||
horizon_client::settings::Settings::load_stores(&options);
|
||||
new
|
||||
}
|
||||
None => horizon_client::settings::Settings::load(&options),
|
||||
};
|
||||
horizon_hooks::engine::set_seat(turn);
|
||||
// A profile an earlier join saved with P1's off, or the player's pad would be dead.
|
||||
settings.controller.enabled |= self.guests[i].pad.is_some();
|
||||
// P1's own world through memory (`Ok`), else P1's server over the network (`Err`).
|
||||
|
||||
@@ -1268,7 +1268,7 @@ impl Loading {
|
||||
let output = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map_or(Default::default(), |r| r.output());
|
||||
.map_or(Default::default(), |r| *r.output());
|
||||
self.menu
|
||||
.options
|
||||
.draw(&self.settings, output, batch, &gui.atlas, self.menu.cursor);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use super::{Game, PendingWorldEntry, Screen, packs};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
studio::{Metadata, ReplayDriver, TimelineAction},
|
||||
studio::{Metadata, ReplayDriver},
|
||||
text::Text,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
@@ -15,25 +15,6 @@ use std::{
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
/// Studio's state in a `Game`: the replay editor's free camera, timeline and top bar. Studio is a
|
||||
/// Horizon extra (docs/layers.md: the replay editor) on top of the platform's replay
|
||||
/// (`replay.rs`); the engine only asks it whether the free camera flies.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Studio {
|
||||
pub(super) freecam: horizon_client::studio::StudioFreecam,
|
||||
pub(super) timeline: horizon_client::studio::TimelineUi,
|
||||
pub(super) topbar: horizon_client::studio::TopBarUi,
|
||||
pub(super) rmb_down: bool,
|
||||
pub(super) camera_track_dirty: bool,
|
||||
}
|
||||
|
||||
impl Studio {
|
||||
/// The free camera flies, apart from the player.
|
||||
pub(super) fn flying(&self) -> bool {
|
||||
self.freecam.active
|
||||
}
|
||||
}
|
||||
|
||||
pub const HELP: &[&str] = &[
|
||||
"studio record start [NAME] start recording clientbound packets into .mcpr archive",
|
||||
"studio record stop stop recording and finalize .mcpr archive",
|
||||
@@ -67,11 +48,6 @@ pub(super) fn studio_screen_opens(screen: Screen, blocked: bool) -> Option<bool>
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Studio is up (its bars, its camera path, its mouse): a replay plays or the free camera flies.
|
||||
pub(super) fn studio_up(&self) -> bool {
|
||||
self.replay.playing() || self.studio.flying()
|
||||
}
|
||||
|
||||
/// Opens the Studio script screen (`Screen::Studio`) over the world.
|
||||
///
|
||||
/// Horizon Studio script windows (`ui.open`) use this slot instead of the
|
||||
@@ -133,13 +109,13 @@ impl Game<'_> {
|
||||
match words[0] {
|
||||
"freecam" => match words.get(1).copied() {
|
||||
Some("on") => {
|
||||
if self.replay.driver.is_none() {
|
||||
if self.studio_replay.is_none() {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.freecam.replay_required",
|
||||
&[],
|
||||
));
|
||||
} else if let Some((position, yaw, pitch)) = self.camera_view(1.0) {
|
||||
self.studio.freecam.activate(
|
||||
self.studio_freecam.activate(
|
||||
position,
|
||||
yaw,
|
||||
pitch,
|
||||
@@ -155,24 +131,24 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some("off") => {
|
||||
self.studio.freecam.deactivate();
|
||||
self.studio_freecam.deactivate();
|
||||
self.capture(false);
|
||||
self.system_message(Text::translate("horizon.studio.freecam.disabled", &[]));
|
||||
}
|
||||
_ => self.system_message(Text::translate("horizon.studio.freecam.usage", &[])),
|
||||
},
|
||||
"keyframe" => {
|
||||
let Some(driver) = self.replay.driver.as_ref() else {
|
||||
let Some(driver) = self.studio_replay.as_ref() else {
|
||||
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
|
||||
return true;
|
||||
};
|
||||
let time_ms = driver.position_ms();
|
||||
match words.get(1).copied() {
|
||||
Some("add") => {
|
||||
if !self.studio.freecam.active
|
||||
if !self.studio_freecam.active
|
||||
&& let Some((position, yaw, pitch)) = self.camera_view(1.0)
|
||||
{
|
||||
self.studio.freecam.activate(
|
||||
self.studio_freecam.activate(
|
||||
position,
|
||||
yaw,
|
||||
pitch,
|
||||
@@ -180,16 +156,16 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
self.capture(true);
|
||||
self.studio.freecam.add_keyframe_at_current(time_ms);
|
||||
self.studio.camera_track_dirty = true;
|
||||
self.studio_freecam.add_keyframe_at_current(time_ms);
|
||||
self.studio_camera_track_dirty = true;
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.keyframe.added",
|
||||
&[&time_ms.to_string()],
|
||||
));
|
||||
}
|
||||
Some("clear") => {
|
||||
self.studio.freecam.track.clear();
|
||||
self.studio.camera_track_dirty = true;
|
||||
self.studio_freecam.track.clear();
|
||||
self.studio_camera_track_dirty = true;
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.keyframe.cleared",
|
||||
&[],
|
||||
@@ -205,15 +181,15 @@ impl Game<'_> {
|
||||
));
|
||||
return true;
|
||||
};
|
||||
if index >= self.studio.freecam.track.keyframes.len() {
|
||||
if index >= self.studio_freecam.track.keyframes.len() {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.keyframe.index_invalid",
|
||||
&[],
|
||||
));
|
||||
return true;
|
||||
}
|
||||
self.studio.freecam.track.keyframes.remove(index);
|
||||
self.studio.camera_track_dirty = true;
|
||||
self.studio_freecam.track.keyframes.remove(index);
|
||||
self.studio_camera_track_dirty = true;
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.keyframe.removed",
|
||||
&[&index.to_string()],
|
||||
@@ -223,14 +199,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
"path" => match words.get(1).copied() {
|
||||
Some("lock") if !self.studio.freecam.track.is_empty() => {
|
||||
self.studio.freecam.active = true;
|
||||
self.studio.freecam.locked_to_path = true;
|
||||
Some("lock") if !self.studio_freecam.track.is_empty() => {
|
||||
self.studio_freecam.active = true;
|
||||
self.studio_freecam.locked_to_path = true;
|
||||
self.capture(true);
|
||||
self.system_message(Text::translate("horizon.studio.path.locked", &[]));
|
||||
}
|
||||
Some("unlock") => {
|
||||
self.studio.freecam.locked_to_path = false;
|
||||
self.studio_freecam.locked_to_path = false;
|
||||
self.system_message(Text::translate("horizon.studio.path.unlocked", &[]));
|
||||
}
|
||||
_ => self.system_message(Text::translate("horizon.studio.path.usage", &[])),
|
||||
@@ -323,7 +299,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some("pause") => {
|
||||
if let Some(r) = self.replay.driver.as_mut() {
|
||||
if let Some(r) = self.studio_replay.as_mut() {
|
||||
r.set_paused(true);
|
||||
self.system_message(Text::translate("horizon.studio.replay.paused", &[]));
|
||||
} else {
|
||||
@@ -331,7 +307,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some("resume") => {
|
||||
if let Some(r) = self.replay.driver.as_mut() {
|
||||
if let Some(r) = self.studio_replay.as_mut() {
|
||||
r.set_paused(false);
|
||||
self.system_message(Text::translate("horizon.studio.replay.resumed", &[]));
|
||||
} else {
|
||||
@@ -347,7 +323,7 @@ impl Game<'_> {
|
||||
return true;
|
||||
};
|
||||
if let Ok(spd) = spd_str.parse::<f64>() {
|
||||
let new_speed = if let Some(r) = self.replay.driver.as_mut() {
|
||||
let new_speed = if let Some(r) = self.studio_replay.as_mut() {
|
||||
r.set_speed(spd);
|
||||
Some(r.speed())
|
||||
} else {
|
||||
@@ -381,12 +357,11 @@ impl Game<'_> {
|
||||
return true;
|
||||
};
|
||||
if let Ok(ms) = ms_str.parse::<u32>() {
|
||||
if self.replay.driver.is_some() {
|
||||
if self.studio_replay.is_some() {
|
||||
match self.seek_studio_replay(ms) {
|
||||
Ok(()) => {
|
||||
let actual_ms = self
|
||||
.replay
|
||||
.driver
|
||||
.studio_replay
|
||||
.as_ref()
|
||||
.map_or(ms, |replay| ms.min(replay.duration_ms()));
|
||||
let ms_text = actual_ms.to_string();
|
||||
@@ -425,7 +400,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some("status") => {
|
||||
if let Some(r) = self.replay.driver.as_ref() {
|
||||
if let Some(r) = self.studio_replay.as_ref() {
|
||||
let position = r.position_ms().to_string();
|
||||
let duration = r.duration_ms().to_string();
|
||||
let progress = format!("{:.1}", r.progress() * 100.0);
|
||||
@@ -460,7 +435,7 @@ impl Game<'_> {
|
||||
|
||||
/// Reset playback tick accumulator and align positions on seek.
|
||||
pub(super) fn reset_studio_playback_ticks(&mut self) {
|
||||
self.replay.virtual_tick_ms = 0.0;
|
||||
self.studio_virtual_tick_ms = 0.0;
|
||||
if let Some(player) = &self.player {
|
||||
self.previous_position = player.position;
|
||||
if let Some(m) = self.player_model.as_mut() {
|
||||
@@ -477,14 +452,12 @@ impl Game<'_> {
|
||||
/// forward because its pre-replay world must remain intact.
|
||||
pub(super) fn seek_studio_replay(&mut self, target_ms: u32) -> std::io::Result<()> {
|
||||
let current_ms = self
|
||||
.replay
|
||||
.driver
|
||||
.studio_replay
|
||||
.as_ref()
|
||||
.map_or(0, ReplayDriver::position_ms);
|
||||
let packets = if self.replay.standalone {
|
||||
let packets = if self.studio_replay_standalone {
|
||||
let packets = self
|
||||
.replay
|
||||
.driver
|
||||
.studio_replay
|
||||
.as_mut()
|
||||
.expect("replay checked by caller")
|
||||
.seek_and_collect(target_ms)?;
|
||||
@@ -498,8 +471,7 @@ impl Game<'_> {
|
||||
"backward seek requires a standalone replay",
|
||||
));
|
||||
}
|
||||
self.replay
|
||||
.driver
|
||||
self.studio_replay
|
||||
.as_mut()
|
||||
.expect("replay checked by caller")
|
||||
.seek_forward(target_ms)?
|
||||
@@ -507,7 +479,7 @@ impl Game<'_> {
|
||||
|
||||
self.dispatch_studio_replay_packets(packets);
|
||||
self.reset_studio_playback_ticks();
|
||||
self.replay.last_tick = Some(std::time::Instant::now());
|
||||
self.last_replay_tick = Some(std::time::Instant::now());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -538,19 +510,19 @@ impl Game<'_> {
|
||||
|
||||
/// Advance active replay playback by elapsed frame time and dispatch ready packets.
|
||||
pub(super) fn advance_studio_replay(&mut self) -> Result<()> {
|
||||
let Some(driver) = self.replay.driver.as_mut() else {
|
||||
self.replay.last_tick = None;
|
||||
let Some(driver) = self.studio_replay.as_mut() else {
|
||||
self.last_replay_tick = None;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let now = std::time::Instant::now();
|
||||
let delta = match self.replay.last_tick {
|
||||
let delta = match self.last_replay_tick {
|
||||
Some(last) => now
|
||||
.saturating_duration_since(last)
|
||||
.min(Duration::from_millis(250)),
|
||||
None => Duration::from_millis(16),
|
||||
};
|
||||
self.replay.last_tick = Some(now);
|
||||
self.last_replay_tick = Some(now);
|
||||
|
||||
let ready_packets = match driver.advance(delta) {
|
||||
Ok(pkts) => pkts,
|
||||
@@ -561,18 +533,16 @@ impl Game<'_> {
|
||||
};
|
||||
|
||||
self.dispatch_studio_replay_packets(ready_packets);
|
||||
if self.studio.freecam.locked_to_path
|
||||
if self.studio_freecam.locked_to_path
|
||||
&& let Some(position) = self
|
||||
.replay
|
||||
.driver
|
||||
.studio_replay
|
||||
.as_ref()
|
||||
.map(|replay| replay.position_ms())
|
||||
{
|
||||
self.studio.freecam.update_spline_playback(position);
|
||||
self.studio_freecam.update_spline_playback(position);
|
||||
}
|
||||
let paused = self
|
||||
.replay
|
||||
.driver
|
||||
.studio_replay
|
||||
.as_ref()
|
||||
.is_some_and(|replay| replay.is_paused());
|
||||
if self.player_model.is_none() {
|
||||
@@ -580,14 +550,13 @@ impl Game<'_> {
|
||||
}
|
||||
if !paused {
|
||||
let speed = self
|
||||
.replay
|
||||
.driver
|
||||
.studio_replay
|
||||
.as_ref()
|
||||
.map_or(1.0, |r| r.speed() as f32)
|
||||
.max(0.1);
|
||||
self.replay.virtual_tick_ms += delta.as_secs_f32() * 1000.0 * speed;
|
||||
while self.replay.virtual_tick_ms >= 50.0 {
|
||||
self.replay.virtual_tick_ms -= 50.0;
|
||||
self.studio_virtual_tick_ms += delta.as_secs_f32() * 1000.0 * speed;
|
||||
while self.studio_virtual_tick_ms >= 50.0 {
|
||||
self.studio_virtual_tick_ms -= 50.0;
|
||||
if let Some(player) = &self.player {
|
||||
let current_pos = player.position;
|
||||
let (dx, dz) = (
|
||||
@@ -638,7 +607,7 @@ impl Game<'_> {
|
||||
m.previous_limb_speed = m.limb_speed;
|
||||
}
|
||||
if let Some((position, duration, paused, markers)) =
|
||||
self.replay.driver.as_ref().map(|replay| {
|
||||
self.studio_replay.as_ref().map(|replay| {
|
||||
(
|
||||
replay.position_ms(),
|
||||
replay.duration_ms(),
|
||||
@@ -647,10 +616,10 @@ impl Game<'_> {
|
||||
)
|
||||
})
|
||||
{
|
||||
self.studio.timeline.current_time_ms = u64::from(position);
|
||||
self.studio.timeline.duration_ms = u64::from(duration);
|
||||
self.studio.timeline.playing = !paused;
|
||||
self.studio.timeline.markers = markers;
|
||||
self.studio_timeline.current_time_ms = u64::from(position);
|
||||
self.studio_timeline.duration_ms = u64::from(duration);
|
||||
self.studio_timeline.playing = !paused;
|
||||
self.studio_timeline.markers = markers;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -670,10 +639,10 @@ impl Game<'_> {
|
||||
self.capture(false);
|
||||
}
|
||||
TopBarAction::ExitStudio => {
|
||||
if self.replay.driver.is_some() {
|
||||
if self.studio_replay.is_some() {
|
||||
self.close_studio_replay();
|
||||
} else {
|
||||
self.studio.freecam.deactivate();
|
||||
self.studio_freecam.deactivate();
|
||||
self.capture(false);
|
||||
}
|
||||
}
|
||||
@@ -699,46 +668,42 @@ impl Game<'_> {
|
||||
} else {
|
||||
(glam::DVec3::ZERO, 0.0, 0.0)
|
||||
};
|
||||
self.studio
|
||||
.freecam
|
||||
self.studio_freecam
|
||||
.activate(position, yaw, pitch, self.settings.fov);
|
||||
}
|
||||
TopBarAction::ToggleLockToPath => {
|
||||
self.studio.freecam.locked_to_path = !self.studio.freecam.locked_to_path;
|
||||
self.studio_freecam.locked_to_path = !self.studio_freecam.locked_to_path;
|
||||
}
|
||||
TopBarAction::ToggleFreecam => {
|
||||
if self.studio.freecam.active {
|
||||
self.studio.freecam.deactivate();
|
||||
if self.studio_freecam.active {
|
||||
self.studio_freecam.deactivate();
|
||||
self.capture(false);
|
||||
} else {
|
||||
let (position, yaw, pitch) =
|
||||
self.camera_view(0.0)
|
||||
.unwrap_or((glam::DVec3::ZERO, 0.0, 0.0));
|
||||
self.studio
|
||||
.freecam
|
||||
self.studio_freecam
|
||||
.activate(position, yaw, pitch, self.settings.fov);
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
TopBarAction::AddKeyframe => {
|
||||
self.studio
|
||||
.freecam
|
||||
.add_keyframe_at_current(self.studio.timeline.current_time_ms as u32);
|
||||
self.studio.camera_track_dirty = true;
|
||||
self.studio_freecam
|
||||
.add_keyframe_at_current(self.studio_timeline.current_time_ms as u32);
|
||||
self.studio_camera_track_dirty = true;
|
||||
}
|
||||
TopBarAction::AddMarker => self.add_studio_marker(),
|
||||
TopBarAction::ClearKeyframes => {
|
||||
self.studio.freecam.track.keyframes.clear();
|
||||
self.studio.timeline.selected_keyframe = None;
|
||||
self.studio.camera_track_dirty = true;
|
||||
self.studio_freecam.track.keyframes.clear();
|
||||
self.studio_timeline.selected_keyframe = None;
|
||||
self.studio_camera_track_dirty = true;
|
||||
}
|
||||
TopBarAction::ToggleTimeline => {
|
||||
self.studio.timeline.visible = !self.studio.timeline.visible;
|
||||
self.studio_timeline.visible = !self.studio_timeline.visible;
|
||||
}
|
||||
TopBarAction::SplitClip => {
|
||||
self.studio
|
||||
.timeline
|
||||
.split_at(self.studio.timeline.current_time_ms);
|
||||
self.studio_timeline
|
||||
.split_at(self.studio_timeline.current_time_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -751,97 +716,93 @@ impl Game<'_> {
|
||||
use horizon_client::studio::TimelineAction;
|
||||
match action {
|
||||
TimelineAction::PauseForSeek => {
|
||||
self.studio.timeline.playing = false;
|
||||
if let Some(replay) = self.replay.driver.as_mut() {
|
||||
self.studio_timeline.playing = false;
|
||||
if let Some(replay) = self.studio_replay.as_mut() {
|
||||
replay.set_paused(true);
|
||||
}
|
||||
}
|
||||
TimelineAction::Seek(time_ms) => {
|
||||
let time_ms = if self.replay.standalone {
|
||||
let time_ms = if self.studio_replay_standalone {
|
||||
time_ms
|
||||
} else {
|
||||
let current =
|
||||
self.replay
|
||||
.driver
|
||||
self.studio_replay
|
||||
.as_ref()
|
||||
.map_or(0, ReplayDriver::position_ms) as u64;
|
||||
time_ms.max(current)
|
||||
};
|
||||
self.studio.timeline.current_time_ms = time_ms;
|
||||
self.studio_timeline.current_time_ms = time_ms;
|
||||
if let Err(error) = self.seek_studio_replay(time_ms.min(u32::MAX as u64) as u32) {
|
||||
log::warn!("Studio timeline seek failed: {error}");
|
||||
}
|
||||
}
|
||||
TimelineAction::TogglePlay => {
|
||||
self.studio.timeline.playing = !self.studio.timeline.playing;
|
||||
if let Some(replay) = self.replay.driver.as_mut() {
|
||||
replay.set_paused(!self.studio.timeline.playing);
|
||||
self.studio_timeline.playing = !self.studio_timeline.playing;
|
||||
if let Some(replay) = self.studio_replay.as_mut() {
|
||||
replay.set_paused(!self.studio_timeline.playing);
|
||||
}
|
||||
}
|
||||
TimelineAction::StepBack => {
|
||||
let time_ms = self.studio.timeline.current_time_ms.saturating_sub(1000);
|
||||
let time_ms = self.studio_timeline.current_time_ms.saturating_sub(1000);
|
||||
self.handle_studio_timeline_action(TimelineAction::Seek(time_ms));
|
||||
}
|
||||
TimelineAction::StepForward => {
|
||||
let time_ms = self
|
||||
.studio
|
||||
.timeline
|
||||
.studio_timeline
|
||||
.current_time_ms
|
||||
.saturating_add(1000)
|
||||
.min(self.studio.timeline.duration_ms);
|
||||
.min(self.studio_timeline.duration_ms);
|
||||
self.handle_studio_timeline_action(TimelineAction::Seek(time_ms));
|
||||
}
|
||||
TimelineAction::SeekStart => {
|
||||
self.handle_studio_timeline_action(TimelineAction::Seek(0))
|
||||
}
|
||||
TimelineAction::SeekEnd => self.handle_studio_timeline_action(TimelineAction::Seek(
|
||||
self.studio.timeline.duration_ms,
|
||||
self.studio_timeline.duration_ms,
|
||||
)),
|
||||
TimelineAction::SetSpeed(speed) => {
|
||||
self.studio.timeline.speed = speed;
|
||||
if let Some(replay) = self.replay.driver.as_mut() {
|
||||
self.studio_timeline.speed = speed;
|
||||
if let Some(replay) = self.studio_replay.as_mut() {
|
||||
replay.set_speed(speed);
|
||||
}
|
||||
}
|
||||
TimelineAction::ToggleFreecam => {
|
||||
if self.studio.freecam.active {
|
||||
self.studio.freecam.deactivate();
|
||||
if self.studio_freecam.active {
|
||||
self.studio_freecam.deactivate();
|
||||
} else {
|
||||
let (position, yaw, pitch) =
|
||||
self.camera_view(0.0)
|
||||
.unwrap_or((glam::DVec3::ZERO, 0.0, 0.0));
|
||||
self.studio
|
||||
.freecam
|
||||
self.studio_freecam
|
||||
.activate(position, yaw, pitch, self.settings.fov);
|
||||
}
|
||||
}
|
||||
TimelineAction::AddCameraKeyframe => {
|
||||
self.studio
|
||||
.freecam
|
||||
.add_keyframe_at_current(self.studio.timeline.current_time_ms as u32);
|
||||
self.studio.camera_track_dirty = true;
|
||||
self.studio_freecam
|
||||
.add_keyframe_at_current(self.studio_timeline.current_time_ms as u32);
|
||||
self.studio_camera_track_dirty = true;
|
||||
}
|
||||
TimelineAction::RemoveCameraKeyframe(index) => {
|
||||
if index < self.studio.freecam.track.keyframes.len() {
|
||||
self.studio.freecam.track.keyframes.remove(index);
|
||||
self.studio.camera_track_dirty = true;
|
||||
if index < self.studio_freecam.track.keyframes.len() {
|
||||
self.studio_freecam.track.keyframes.remove(index);
|
||||
self.studio_camera_track_dirty = true;
|
||||
}
|
||||
self.studio.timeline.selected_keyframe = None;
|
||||
self.studio_timeline.selected_keyframe = None;
|
||||
}
|
||||
TimelineAction::SelectKeyframe(index) => {
|
||||
self.studio.timeline.selected_keyframe = Some(index);
|
||||
self.studio_timeline.selected_keyframe = Some(index);
|
||||
}
|
||||
TimelineAction::KeyframeMoved => {
|
||||
self.studio.camera_track_dirty = true;
|
||||
self.studio_camera_track_dirty = true;
|
||||
}
|
||||
TimelineAction::SplitClip(time_ms) => self.studio.timeline.split_at(time_ms),
|
||||
TimelineAction::SplitClip(time_ms) => self.studio_timeline.split_at(time_ms),
|
||||
TimelineAction::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a bookmark at the replay playhead and save it in the `.mcpr` archive.
|
||||
pub(super) fn add_studio_marker(&mut self) {
|
||||
let Some(replay) = self.replay.driver.as_mut() else {
|
||||
let Some(replay) = self.studio_replay.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let label = Text::translate(
|
||||
@@ -850,7 +811,7 @@ impl Game<'_> {
|
||||
)
|
||||
.plain();
|
||||
match replay.add_marker(label) {
|
||||
Ok(_) => self.studio.timeline.markers = replay.markers().to_vec(),
|
||||
Ok(_) => self.studio_timeline.markers = replay.markers().to_vec(),
|
||||
Err(error) => log::warn!("Studio: could not save replay marker: {error}"),
|
||||
}
|
||||
}
|
||||
@@ -884,7 +845,7 @@ impl Game<'_> {
|
||||
Ok(Some(res))
|
||||
}
|
||||
Some("pause") => {
|
||||
if let Some(r) = self.replay.driver.as_mut() {
|
||||
if let Some(r) = self.studio_replay.as_mut() {
|
||||
r.set_paused(true);
|
||||
Ok(Some(json!({ "ok": true, "paused": true })))
|
||||
} else {
|
||||
@@ -892,7 +853,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some("resume") => {
|
||||
if let Some(r) = self.replay.driver.as_mut() {
|
||||
if let Some(r) = self.studio_replay.as_mut() {
|
||||
r.set_paused(false);
|
||||
Ok(Some(json!({ "ok": true, "paused": false })))
|
||||
} else {
|
||||
@@ -902,7 +863,7 @@ impl Game<'_> {
|
||||
Some("speed") => {
|
||||
let spd_str = words.get(3).ok_or("missing speed value")?;
|
||||
let spd: f64 = spd_str.parse().map_err(|_| "invalid speed")?;
|
||||
if let Some(r) = self.replay.driver.as_mut() {
|
||||
if let Some(r) = self.studio_replay.as_mut() {
|
||||
r.set_speed(spd);
|
||||
Ok(Some(json!({ "ok": true, "speed": r.speed() })))
|
||||
} else {
|
||||
@@ -912,7 +873,7 @@ impl Game<'_> {
|
||||
Some("seek") => {
|
||||
let ms_str = words.get(3).ok_or("missing seek timestamp")?;
|
||||
let ms: u32 = ms_str.parse().map_err(|_| "invalid timestamp")?;
|
||||
let pos = if let Some(r) = self.replay.driver.as_mut() {
|
||||
let pos = if let Some(r) = self.studio_replay.as_mut() {
|
||||
r.seek(ms).map_err(|e| format!("{e}"))?;
|
||||
Some(r.position_ms())
|
||||
} else {
|
||||
@@ -926,7 +887,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some("status") => {
|
||||
if let Some(r) = self.replay.driver.as_ref() {
|
||||
if let Some(r) = self.studio_replay.as_ref() {
|
||||
Ok(Some(json!({
|
||||
"ok": true,
|
||||
"active": true,
|
||||
@@ -1038,13 +999,13 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn open_studio_replay(&mut self, path: &str) -> Result<Value> {
|
||||
if self.studio.camera_track_dirty
|
||||
&& let Some(active) = self.replay.driver.as_mut()
|
||||
if self.studio_camera_track_dirty
|
||||
&& let Some(active) = self.studio_replay.as_mut()
|
||||
{
|
||||
if let Err(error) = active.save_camera_track(&self.studio.freecam.track) {
|
||||
if let Err(error) = active.save_camera_track(&self.studio_freecam.track) {
|
||||
return Ok(json!({ "ok": false, "error": error.to_string() }));
|
||||
}
|
||||
self.studio.camera_track_dirty = false;
|
||||
self.studio_camera_track_dirty = false;
|
||||
}
|
||||
// The recordings browser can be opened while the title screen is
|
||||
// available but the slower world tables are still loading. Replay
|
||||
@@ -1067,7 +1028,7 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
|
||||
let standalone = self.replay.driver.is_none() && self.remote.is_none();
|
||||
let standalone = self.studio_replay.is_none() && self.remote.is_none();
|
||||
if self.remote.is_none()
|
||||
&& let Err(error) = self.init_remote_for_replay()
|
||||
{
|
||||
@@ -1077,17 +1038,17 @@ impl Game<'_> {
|
||||
|
||||
let duration_ms = driver.duration_ms();
|
||||
let packets = driver.packet_count();
|
||||
self.studio.timeline.duration_ms = u64::from(duration_ms);
|
||||
self.studio.timeline.current_time_ms = 0;
|
||||
self.studio.timeline.playing = !driver.is_paused();
|
||||
self.studio.timeline.markers = driver.markers().to_vec();
|
||||
self.studio.freecam.track = driver.camera_track().cloned().unwrap_or_default();
|
||||
self.studio.camera_track_dirty = false;
|
||||
if self.replay.driver.is_none() {
|
||||
self.replay.previous_camera_type = Some(self.camera_type);
|
||||
self.replay.standalone = standalone;
|
||||
self.studio_timeline.duration_ms = u64::from(duration_ms);
|
||||
self.studio_timeline.current_time_ms = 0;
|
||||
self.studio_timeline.playing = !driver.is_paused();
|
||||
self.studio_timeline.markers = driver.markers().to_vec();
|
||||
self.studio_freecam.track = driver.camera_track().cloned().unwrap_or_default();
|
||||
self.studio_camera_track_dirty = false;
|
||||
if self.studio_replay.is_none() {
|
||||
self.studio_replay_previous_camera_type = Some(self.camera_type);
|
||||
self.studio_replay_standalone = standalone;
|
||||
}
|
||||
self.replay.driver = Some(driver);
|
||||
self.studio_replay = Some(driver);
|
||||
self.camera_type = super::perspective::CameraType::FirstPerson;
|
||||
self.title = None;
|
||||
self.screen = Screen::Playing;
|
||||
@@ -1105,14 +1066,14 @@ impl Game<'_> {
|
||||
}))
|
||||
}
|
||||
pub(super) fn close_studio_replay(&mut self) -> bool {
|
||||
if self.studio.camera_track_dirty
|
||||
&& let Some(driver) = self.replay.driver.as_mut()
|
||||
&& let Err(error) = driver.save_camera_track(&self.studio.freecam.track)
|
||||
if self.studio_camera_track_dirty
|
||||
&& let Some(driver) = self.studio_replay.as_mut()
|
||||
&& let Err(error) = driver.save_camera_track(&self.studio_freecam.track)
|
||||
{
|
||||
log::warn!("Studio: could not save camera path: {error}");
|
||||
}
|
||||
self.studio.camera_track_dirty = false;
|
||||
let closed = self.replay.driver.take().is_some();
|
||||
self.studio_camera_track_dirty = false;
|
||||
let closed = self.studio_replay.take().is_some();
|
||||
if !closed {
|
||||
return false;
|
||||
}
|
||||
@@ -1120,23 +1081,23 @@ impl Game<'_> {
|
||||
// A standalone replay owns a synthetic Remote connection. Treat that
|
||||
// connection as replay state even if the saved origin flag was lost;
|
||||
// sending it through leave_server() opens the Multiplayer screen.
|
||||
let standalone = self.replay.standalone
|
||||
let standalone = self.studio_replay_standalone
|
||||
|| self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|remote| remote.brand == "horizon-studio");
|
||||
self.replay.standalone = false;
|
||||
if let Some(camera_type) = self.replay.previous_camera_type.take() {
|
||||
self.studio_replay_standalone = false;
|
||||
if let Some(camera_type) = self.studio_replay_previous_camera_type.take() {
|
||||
self.camera_type = camera_type;
|
||||
}
|
||||
self.replay.last_tick = None;
|
||||
self.studio.timeline.current_time_ms = 0;
|
||||
self.studio.timeline.playing = false;
|
||||
self.studio.timeline.markers.clear();
|
||||
self.studio.timeline.split_points.clear();
|
||||
self.studio.timeline.selected_keyframe = None;
|
||||
self.studio.freecam.deactivate();
|
||||
self.replay.virtual_tick_ms = 0.0;
|
||||
self.last_replay_tick = None;
|
||||
self.studio_timeline.current_time_ms = 0;
|
||||
self.studio_timeline.playing = false;
|
||||
self.studio_timeline.markers.clear();
|
||||
self.studio_timeline.split_points.clear();
|
||||
self.studio_timeline.selected_keyframe = None;
|
||||
self.studio_freecam.deactivate();
|
||||
self.studio_virtual_tick_ms = 0.0;
|
||||
self.capture(false);
|
||||
if standalone {
|
||||
if let Some(mut remote) = self.remote.take() {
|
||||
@@ -1282,198 +1243,3 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the engine's frame, render and window code hand to Studio.
|
||||
impl Game<'_> {
|
||||
/// The top bar and the timeline over the HUD, while Studio is up in play.
|
||||
pub(super) fn draw_studio_bars(&mut self, batch: &mut super::HudBatch) {
|
||||
if self.ui.hide_gui || self.screen != Screen::Playing || !self.studio_up() {
|
||||
return;
|
||||
}
|
||||
self.studio.topbar.render(
|
||||
batch,
|
||||
&self.gui.atlas,
|
||||
self.cursor,
|
||||
self.studio.freecam.locked_to_path,
|
||||
);
|
||||
self.studio
|
||||
.timeline
|
||||
.render(batch, &self.gui.atlas, self.cursor, &self.studio.freecam);
|
||||
}
|
||||
|
||||
/// The camera path among the debug lines, while Studio is up.
|
||||
pub(super) fn studio_camera_path(&self, gizmos: &mut horizon_client::gizmos::Gizmos) {
|
||||
if self.studio_up() {
|
||||
horizon_client::studio::render_camera_path(
|
||||
&self.studio.freecam.track,
|
||||
gizmos,
|
||||
None,
|
||||
100,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The pointer moved while the timeline's playhead or a keyframe is dragged: Studio took it.
|
||||
pub(super) fn studio_mouse_dragged(&mut self, width: f32) -> bool {
|
||||
if self.screen != Screen::Playing
|
||||
|| !(self.studio.timeline.dragging_playhead
|
||||
|| self.studio.timeline.dragging_keyframe.is_some())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let action =
|
||||
self.studio
|
||||
.timeline
|
||||
.handle_mouse_drag(self.cursor, width, &mut self.studio.freecam);
|
||||
if matches!(action, TimelineAction::KeyframeMoved) {
|
||||
self.studio.camera_track_dirty = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A mouse button while Studio is up: true when Studio took it (its bars, the right button's
|
||||
/// mouse look, every click while a replay plays).
|
||||
pub(super) fn studio_mouse_input(
|
||||
&mut self,
|
||||
pressed: bool,
|
||||
button: winit::event::MouseButton,
|
||||
) -> bool {
|
||||
if !pressed && button == winit::event::MouseButton::Left {
|
||||
let action = self.studio.timeline.handle_mouse_up();
|
||||
if !matches!(action, TimelineAction::None) {
|
||||
self.handle_studio_timeline_action(action);
|
||||
}
|
||||
}
|
||||
if (self.studio.freecam.active || self.replay.driver.is_some())
|
||||
&& button == winit::event::MouseButton::Right
|
||||
{
|
||||
if let Some(renderer) = &self.renderer {
|
||||
let batch = super::HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
if self.studio.timeline.visible
|
||||
&& self.cursor.1 >= batch.height - self.studio.timeline.height()
|
||||
{
|
||||
if pressed
|
||||
&& let Some(index) = self.studio.timeline.keyframe_at_cursor(
|
||||
self.cursor,
|
||||
batch.width,
|
||||
batch.height,
|
||||
&self.studio.freecam,
|
||||
)
|
||||
{
|
||||
if index < self.studio.freecam.track.keyframes.len() {
|
||||
self.studio.freecam.track.keyframes.remove(index);
|
||||
self.studio.camera_track_dirty = true;
|
||||
}
|
||||
self.studio.timeline.selected_keyframe = None;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if pressed {
|
||||
self.studio.topbar.close_dropdown();
|
||||
self.studio.rmb_down = true;
|
||||
self.capture(true);
|
||||
return true;
|
||||
} else if self.studio.rmb_down {
|
||||
self.studio.rmb_down = false;
|
||||
self.capture(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if pressed
|
||||
&& button == winit::event::MouseButton::Left
|
||||
&& self.screen == Screen::Playing
|
||||
&& (self.replay.driver.is_some() || self.studio.freecam.active)
|
||||
{
|
||||
let size = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|renderer| [renderer.config.width, renderer.config.height])
|
||||
.unwrap_or([1, 1]);
|
||||
let batch = super::HudBatch::new(size);
|
||||
let had_open_menu = self.studio.topbar.open_menu.is_some();
|
||||
let topbar_action =
|
||||
self.studio
|
||||
.topbar
|
||||
.handle_mouse_click(self.cursor, batch.width, batch.height);
|
||||
if !matches!(topbar_action, horizon_client::studio::TopBarAction::None) {
|
||||
self.handle_studio_topbar_action(topbar_action);
|
||||
return true;
|
||||
}
|
||||
if had_open_menu || self.cursor.1 <= self.studio.topbar.height {
|
||||
return true;
|
||||
}
|
||||
if self.studio.timeline.visible {
|
||||
let action = self.studio.timeline.handle_mouse_down(
|
||||
self.cursor,
|
||||
batch.width,
|
||||
batch.height,
|
||||
&self.studio.freecam,
|
||||
);
|
||||
if !matches!(action, TimelineAction::None) {
|
||||
self.handle_studio_timeline_action(action);
|
||||
return true;
|
||||
}
|
||||
if self.cursor.1 >= batch.height - self.studio.timeline.height() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.screen == Screen::Playing && self.replay.driver.is_some() {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The free camera flies by the movement keys, once per frame.
|
||||
pub(super) fn studio_fly(&mut self, elapsed: Duration) {
|
||||
if !self.studio.flying() || self.screen != Screen::Playing || self.dialog_open() {
|
||||
return;
|
||||
}
|
||||
use winit::keyboard::KeyCode;
|
||||
let held = |key| f64::from(u8::from(self.keys.raw(&key)));
|
||||
let forward = held(KeyCode::KeyW) - held(KeyCode::KeyS);
|
||||
let strafe = held(KeyCode::KeyD) - held(KeyCode::KeyA);
|
||||
let vertical = held(KeyCode::KeyE) - held(KeyCode::KeyQ) + held(KeyCode::Space);
|
||||
let fast = self.keys.raw(&KeyCode::ControlRight);
|
||||
self.studio.freecam.update_flight_motion(
|
||||
forward,
|
||||
strafe,
|
||||
vertical,
|
||||
fast,
|
||||
elapsed.as_secs_f64().min(0.1),
|
||||
);
|
||||
}
|
||||
|
||||
/// A key pressed while a replay plays: Studio's shortcuts, and nothing else reaches play.
|
||||
pub(super) fn studio_key(&mut self, key: winit::keyboard::KeyCode) {
|
||||
use winit::keyboard::KeyCode;
|
||||
match key {
|
||||
KeyCode::Escape => {
|
||||
self.close_studio_replay();
|
||||
}
|
||||
KeyCode::KeyP => {
|
||||
self.pause_focus = None;
|
||||
self.screen = Screen::Paused;
|
||||
self.pause_menu = true;
|
||||
self.capture(false);
|
||||
}
|
||||
KeyCode::KeyF => self.handle_studio_timeline_action(TimelineAction::ToggleFreecam),
|
||||
KeyCode::KeyT => self.studio.timeline.visible = !self.studio.timeline.visible,
|
||||
KeyCode::KeyM => self.add_studio_marker(),
|
||||
KeyCode::Home => self.handle_studio_topbar_action(
|
||||
horizon_client::studio::TopBarAction::ResetCameraToPlayer,
|
||||
),
|
||||
KeyCode::KeyL => {
|
||||
self.studio.freecam.locked_to_path = !self.studio.freecam.locked_to_path;
|
||||
}
|
||||
KeyCode::KeyK => self.handle_studio_timeline_action(TimelineAction::AddCameraKeyframe),
|
||||
KeyCode::Delete => {
|
||||
if let Some(index) = self.studio.timeline.selected_keyframe {
|
||||
self.handle_studio_timeline_action(TimelineAction::RemoveCameraKeyframe(index));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,7 +245,7 @@ impl Game<'_> {
|
||||
// Pause-menu and death-screen exits use the normal world-close action.
|
||||
// A standalone replay owns a synthetic remote session, so it must go
|
||||
// through replay cleanup instead of being treated like a server.
|
||||
if self.replay.playing() {
|
||||
if self.studio_replay.is_some() {
|
||||
self.close_studio_replay();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -173,10 +173,10 @@ pub struct Guest {
|
||||
/// Where the player respawns (a bed's spot; `ServerPlayer.respawnConfig`),
|
||||
/// None for the world spawn.
|
||||
pub spawn: Option<[f64; 3]>,
|
||||
/// A block-resolved personal respawn point: its dimension, block, yaw,
|
||||
/// pitch and whether it is forced, as the player's save keeps it
|
||||
/// (`respawn`).
|
||||
pub respawn_elsewhere: Option<(String, [i32; 3], f32, f32, bool)>,
|
||||
/// A respawn point outside the Overworld (a respawn anchor, or one
|
||||
/// `spawnpoint` set there): its dimension, block, yaw and whether it is
|
||||
/// forced, as the player's save keeps it (`respawn`).
|
||||
pub respawn_elsewhere: Option<(String, [i32; 3], f32, bool)>,
|
||||
/// Pending ClientboundPlayerPositionPacket id; movement is ignored until
|
||||
/// the client accepts it (ServerGamePacketListenerImpl.awaitingPositionFromClient).
|
||||
pub awaiting_teleport: Option<(i32, [f64; 3])>,
|
||||
|
||||
@@ -343,7 +343,7 @@ struct Kept {
|
||||
effects: Option<crate::effects::PlayerEffects>,
|
||||
game_mode: Option<(i32, bool, f32)>,
|
||||
position: Option<([f64; 3], [f64; 3], f32, f32, f32, bool, Option<[i32; 4]>)>,
|
||||
spawn: Option<(Option<[f64; 3]>, Option<(String, [i32; 3], f32, f32, bool)>)>,
|
||||
spawn: Option<(Option<[f64; 3]>, Option<(String, [i32; 3], f32, bool)>)>,
|
||||
recipes: Option<crate::recipe_book::Settings>,
|
||||
/// The body's values: its fall goes with the position, its air and fire with the
|
||||
/// health.
|
||||
|
||||
@@ -771,9 +771,26 @@ fn swim(p: &mut Poses, pos: f32, h: &Hum) {
|
||||
lerp(amount, x, 0.3 * (pos * 0.333_333_34).mcos()),
|
||||
);
|
||||
}
|
||||
/// A pose laid over the player model's body after its vanilla animation, and the parts it poses:
|
||||
/// the hook's own types (`horizon_hooks::players`), kept here under their old path.
|
||||
pub use horizon_hooks::players::{BODY_PARTS, BodyPose};
|
||||
/// The player model's parts a [`BodyPose`] poses, in its order.
|
||||
pub const BODY_PARTS: [&str; 6] = [
|
||||
"head",
|
||||
"body",
|
||||
"right_arm",
|
||||
"left_arm",
|
||||
"right_leg",
|
||||
"left_leg",
|
||||
];
|
||||
|
||||
/// A pose a higher layer lays over the player model's body after its vanilla animation (Horizon's
|
||||
/// emotes, docs/cosmetics.md), handed to the model through [`Mob::emote`]. The engine names no
|
||||
/// type of that layer (docs/layers.md); it downcasts its own back through [`std::any::Any`].
|
||||
pub trait BodyPose: std::any::Any + std::fmt::Debug + Send + Sync {
|
||||
/// Poses the [`BODY_PARTS`] at `age` (the mob's ticks): `at_rest` holds each part before the
|
||||
/// animation, `poses` as the animation left it; a part the model lacks holds the identity in
|
||||
/// both and is not written back. False when it poses nothing at `age` (`poses` is then
|
||||
/// ignored).
|
||||
fn pose(&self, age: f32, at_rest: &[[f32; 9]; 6], poses: &mut [[f32; 9]; 6]) -> bool;
|
||||
}
|
||||
|
||||
/// Apply the mob's model animation to `p` (at rest), then the pose laid over it, if any.
|
||||
pub fn setup(
|
||||
|
||||
@@ -21,12 +21,7 @@ use horizon_core::{
|
||||
random::RandomSource,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
borrow::{Borrow, Cow},
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
use std::{borrow::Cow, collections::HashMap, fs, path::Path};
|
||||
|
||||
pub use crate::biome::{SpawnCost, parse_costs, parse_entries};
|
||||
|
||||
@@ -884,17 +879,13 @@ impl SpawnState {
|
||||
/// `NaturalSpawner.createState`: every non-persistent entity of a
|
||||
/// spawning category in a loaded chunk adds its biome's spawn charge, its
|
||||
/// category count and (for mobs) the local caps of the players near it.
|
||||
pub fn new<'a, L: NaturalLevel, I>(
|
||||
pub fn new<L: NaturalLevel>(
|
||||
l: &L,
|
||||
spawnable_chunks: i32,
|
||||
players: Vec<SpawnPlayer>,
|
||||
entities: I,
|
||||
entities: &[Counted],
|
||||
chunk_loaded: impl Fn(i32, i32) -> bool,
|
||||
) -> Self
|
||||
where
|
||||
I: IntoIterator,
|
||||
I::Item: std::borrow::Borrow<Counted<'a>>,
|
||||
{
|
||||
) -> Self {
|
||||
let mut state = Self {
|
||||
spawnable_chunks,
|
||||
counts: [0; 8],
|
||||
@@ -907,7 +898,6 @@ impl SpawnState {
|
||||
last_checked: None,
|
||||
};
|
||||
for e in entities {
|
||||
let e = e.borrow();
|
||||
if e.is_mob && e.persistent || e.category == Category::Misc {
|
||||
continue;
|
||||
}
|
||||
@@ -925,10 +915,6 @@ impl SpawnState {
|
||||
}
|
||||
state
|
||||
}
|
||||
/// Reclaims the player buffer for reuse across passes.
|
||||
pub fn into_players(self) -> Vec<SpawnPlayer> {
|
||||
self.players
|
||||
}
|
||||
fn add_charge(&mut self, pos: [i32; 3], charge: f64) {
|
||||
if charge != 0.0 {
|
||||
self.charges.push((pos, charge));
|
||||
|
||||
@@ -149,20 +149,8 @@ pub struct Session {
|
||||
far: Arc<crate::lod_net::Inbox>,
|
||||
/// The recording running, if any.
|
||||
recorder: RecorderSlot,
|
||||
/// This connection's number, new for each one ([`Session::serial`]).
|
||||
serial: u64,
|
||||
}
|
||||
/// The next [`Session::serial`].
|
||||
static NEXT_SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
fn next_serial() -> u64 {
|
||||
NEXT_SERIAL.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
impl Session {
|
||||
/// A number of its own for each connection, never 0: entity ids are per connection, so what
|
||||
/// is kept by id goes when it changes (`horizon_hooks::players::PlayerModels::connection`).
|
||||
pub fn serial(&self) -> u64 {
|
||||
self.serial
|
||||
}
|
||||
/// The negotiated wire release, once the connection has been established.
|
||||
pub fn server_version(&self) -> horizon_protocol::adapter::ServerVersion {
|
||||
self.writer.lock().unwrap().as_ref().map_or(
|
||||
@@ -268,7 +256,6 @@ impl Session {
|
||||
debug: false,
|
||||
far: Arc::new(crate::lod_net::Inbox::default()),
|
||||
recorder: Arc::new(Mutex::new(None)),
|
||||
serial: next_serial(),
|
||||
}
|
||||
}
|
||||
fn start(
|
||||
@@ -346,7 +333,6 @@ impl Session {
|
||||
debug,
|
||||
far,
|
||||
recorder,
|
||||
serial: next_serial(),
|
||||
}
|
||||
}
|
||||
/// Everything received since the last poll.
|
||||
@@ -1843,7 +1829,6 @@ mod tests {
|
||||
debug: false,
|
||||
far: Arc::default(),
|
||||
recorder: Arc::new(Mutex::new(None)),
|
||||
serial: next_serial(),
|
||||
};
|
||||
let started = std::time::Instant::now();
|
||||
if explicit_disconnect {
|
||||
@@ -1947,7 +1932,6 @@ mod tests {
|
||||
debug: false,
|
||||
far: Arc::default(),
|
||||
recorder: Arc::new(Mutex::new(None)),
|
||||
serial: next_serial(),
|
||||
};
|
||||
let (thread_writer, thread_configuring, thread_recorder) = (
|
||||
session.writer.clone(),
|
||||
@@ -2231,7 +2215,6 @@ mod tests {
|
||||
debug: false,
|
||||
far: Arc::default(),
|
||||
recorder: Arc::new(Mutex::new(None)),
|
||||
serial: next_serial(),
|
||||
};
|
||||
let recorder = TestRecorder::default();
|
||||
session.start_recording(recorder.clone(), |_| {});
|
||||
|
||||
+15
-31
@@ -1,29 +1,12 @@
|
||||
//! Horizon's own entries in the multiplayer list: the official server, added once at the top,
|
||||
//! and the withdrawn partner server, taken back out once.
|
||||
//!
|
||||
//! A Horizon crate (docs/layers.md): it plugs into the game through the platform's hooks
|
||||
//! ([`HOOKS`], when the game starts), which the official executable (`horizon-official`)
|
||||
//! registers, and reads servers.dat through `horizon_platform::servers`. horizon-client's plain
|
||||
//! executable adds no server. Its addresses are in `horizon-addresses`.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use horizon_platform::hooks::{Hooks, Started};
|
||||
use horizon_platform::servers::{self, ServerEntry};
|
||||
//! Horizon's own entries in the multiplayer list (docs/layers.md: Horizon layer): the official
|
||||
//! server, added once at the top, and the withdrawn partner server, taken back out once. Behind
|
||||
//! `horizon-extras` (docs/forking.md), so a plain build adds no server and holds no Horizon
|
||||
//! address. It reads and writes servers.dat only through `horizon_protocol::servers`, ready to
|
||||
//! leave the engine crates for a Horizon crate.
|
||||
use horizon_protocol::servers::{self, ServerEntry};
|
||||
use std::path::Path;
|
||||
|
||||
/// The server list's hooks, for `horizon_platform::hooks::register`.
|
||||
pub static HOOKS: OfficialServers = OfficialServers;
|
||||
|
||||
pub struct OfficialServers;
|
||||
|
||||
impl Hooks for OfficialServers {
|
||||
/// Someone is playing (not a server, a benchmark or an automated run): their list, once.
|
||||
fn started(&self, game: &Started<'_>) {
|
||||
update(game.game_dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// At the start of a player's game: the official server into the list in `game_dir` and the
|
||||
/// At startup of a player's game: the official server into the list in `game_dir` and the
|
||||
/// withdrawn partner server out of it, a failure only logged.
|
||||
pub fn update(game_dir: &Path) {
|
||||
if let Err(e) = add_official_server(game_dir) {
|
||||
@@ -34,8 +17,12 @@ pub fn update(game_dir: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's official survival server: a name and an address like any entry.
|
||||
pub const OFFICIAL_SERVER: (&str, &str) = ("Horizon Official", horizon_addresses::OFFICIAL_SERVER);
|
||||
/// Horizon's official survival server: a name and an address like any entry. The address is
|
||||
/// `horizon_telemetry::server_status::OFFICIAL_ADDRESS`, the one place it is kept.
|
||||
pub const OFFICIAL_SERVER: (&str, &str) = (
|
||||
"Horizon Official",
|
||||
horizon_telemetry::server_status::OFFICIAL_ADDRESS,
|
||||
);
|
||||
/// Beside servers.dat once the official server was offered, so a player who
|
||||
/// deletes it never gets it back: the address offered, so a new one moves the
|
||||
/// list once. The launcher writes the same file.
|
||||
@@ -68,7 +55,7 @@ pub fn add_official_server(game_dir: &Path) -> std::io::Result<()> {
|
||||
let file = game_dir.join("servers.dat");
|
||||
let _guard = servers::lock();
|
||||
let mut list = servers::load(&file);
|
||||
let previous = horizon_addresses::OFFICIAL_SERVER_PREVIOUS;
|
||||
let previous = horizon_telemetry::server_status::OFFICIAL_PREVIOUS;
|
||||
if move_official(&mut list, address, previous) {
|
||||
servers::save(&file, &list)?;
|
||||
}
|
||||
@@ -84,10 +71,7 @@ pub fn add_official_server(game_dir: &Path) -> std::io::Result<()> {
|
||||
|
||||
/// Afri-Network, which the game added once below the official server (#9299) until the
|
||||
/// partnership ended: the name and address the game gave its entry.
|
||||
const WITHDRAWN_PARTNER: (&str, &str) = (
|
||||
"Afri-Network (South Africa)",
|
||||
horizon_addresses::WITHDRAWN_PARTNER_SERVER,
|
||||
);
|
||||
const WITHDRAWN_PARTNER: (&str, &str) = ("Afri-Network (South Africa)", "afri-network.co.za");
|
||||
/// Beside servers.dat: the partner addresses the game offered, one per line (#9299).
|
||||
const PARTNER_MARKER: &str = "horizon_partner_servers.txt";
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
//! game rules) is kept from the loaded files and written back unchanged.
|
||||
//! Worlds saved by older Horizon builds (`region/`, `DIM-1/`, `Player` in
|
||||
//! `level.dat`) are migrated on open.
|
||||
mod respawn;
|
||||
|
||||
use crate::{
|
||||
Result,
|
||||
beehive::BeehiveExt,
|
||||
@@ -3947,8 +3945,8 @@ pub struct Level {
|
||||
pub spawn: Option<[f64; 3]>,
|
||||
/// A respawn point resolved through its block when the player respawns
|
||||
/// (a bed, a respawn anchor, a forced point in any dimension):
|
||||
/// dimension, block, yaw, pitch and `forced`.
|
||||
pub respawn_elsewhere: Option<(String, [i32; 3], f32, f32, bool)>,
|
||||
/// dimension, block, yaw and `forced`.
|
||||
pub respawn_elsewhere: Option<(String, [i32; 3], f32, bool)>,
|
||||
pub position: Option<[f64; 3]>,
|
||||
pub rotation: [f32; 2],
|
||||
pub health: f32,
|
||||
@@ -4294,8 +4292,20 @@ impl Level {
|
||||
if let Some(d) = text(p.get("Dimension")) {
|
||||
self.dimension = d;
|
||||
}
|
||||
if let Some(respawn) = respawn::read(p) {
|
||||
self.respawn_elsewhere = Some(respawn);
|
||||
if let Some(respawn) = p.get("respawn")
|
||||
&& let Some(Tag::IntArray(pos)) = respawn.get("pos")
|
||||
&& pos.len() == 3
|
||||
{
|
||||
let dimension =
|
||||
text(respawn.get("dimension")).unwrap_or_else(|| "minecraft:overworld".into());
|
||||
let forced = number(respawn.get("forced")).is_some_and(|v| v != 0);
|
||||
// Forced points, including Overworld ones, are validated through
|
||||
// their blocks on death, just like beds and respawn anchors.
|
||||
let yaw = match respawn.get("yaw") {
|
||||
Some(Tag::Float(b)) => f32::from_bits(*b),
|
||||
_ => 0.0,
|
||||
};
|
||||
self.respawn_elsewhere = Some((dimension, [pos[0], pos[1], pos[2]], yaw, forced));
|
||||
}
|
||||
let mut inventory = Vec::new();
|
||||
let mut inventory_components = HashMap::new();
|
||||
@@ -4526,8 +4536,17 @@ impl Level {
|
||||
));
|
||||
}
|
||||
// A respawn point other than the world spawn: an anchor elsewhere, or a bed.
|
||||
if let Some(saved) = &self.respawn_elsewhere {
|
||||
entries.push(("respawn", respawn::write(saved)));
|
||||
if let Some((dimension, block, yaw, forced)) = &self.respawn_elsewhere {
|
||||
let mut respawn = vec![
|
||||
("dimension", string(dimension)),
|
||||
("pos", Tag::IntArray(block.to_vec())),
|
||||
("yaw", Tag::Float(yaw.to_bits())),
|
||||
("pitch", Tag::Float(0f32.to_bits())),
|
||||
];
|
||||
if *forced {
|
||||
respawn.push(("forced", Tag::Byte(1)));
|
||||
}
|
||||
entries.push(("respawn", Tag::compound(respawn)));
|
||||
} else if let Some(s) = self.spawn {
|
||||
let block = [
|
||||
s[0].floor() as i32,
|
||||
@@ -7679,7 +7698,7 @@ mod tests {
|
||||
assert_eq!(back.spawn, Some([8.0, 64.0, 8.0]));
|
||||
assert_eq!(
|
||||
back.respawn_elsewhere,
|
||||
Some(("minecraft:overworld".into(), [20, 70, 20], 0.0, 0.0, true))
|
||||
Some(("minecraft:overworld".into(), [20, 70, 20], 0.0, true))
|
||||
);
|
||||
assert!(back.difficulty_locked);
|
||||
assert!(back.weather.raining);
|
||||
@@ -7950,7 +7969,7 @@ mod tests {
|
||||
// A personal point coinciding with the world spawn must still keep
|
||||
// its forced flag and yaw, rather than turning into a world fallback.
|
||||
for pos in [[5, 64, 5], [0, 80, 0]] {
|
||||
let point = ("minecraft:overworld".to_owned(), pos, 37.0, 0.0, true);
|
||||
let point = ("minecraft:overworld".to_owned(), pos, 37.0, true);
|
||||
let level = Level {
|
||||
respawn_elsewhere: Some(point.clone()),
|
||||
..Level::default()
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
//! Personal respawn record leaves from Level, using its existing typed NBT rules.
|
||||
use super::{Tag, number, string, text};
|
||||
|
||||
pub(super) fn read(p: &Tag) -> Option<(String, [i32; 3], f32, f32, bool)> {
|
||||
let mut value = None;
|
||||
if let Some(respawn) = p.get("respawn")
|
||||
&& let Some(Tag::IntArray(pos)) = respawn.get("pos")
|
||||
&& pos.len() == 3
|
||||
{
|
||||
let dimension =
|
||||
text(respawn.get("dimension")).unwrap_or_else(|| "minecraft:overworld".into());
|
||||
let forced = number(respawn.get("forced")).is_some_and(|v| v != 0);
|
||||
// Forced points, including Overworld ones, are validated through
|
||||
// their blocks on death, just like beds and respawn anchors.
|
||||
let yaw = match respawn.get("yaw") {
|
||||
Some(Tag::Float(b)) => f32::from_bits(*b),
|
||||
_ => 0.0,
|
||||
};
|
||||
let pitch = match respawn.get("pitch") {
|
||||
Some(Tag::Float(b)) => f32::from_bits(*b),
|
||||
_ => 0.0,
|
||||
};
|
||||
value = Some((dimension, [pos[0], pos[1], pos[2]], yaw, pitch, forced));
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
pub(super) fn write(
|
||||
(dimension, block, yaw, pitch, forced): &(String, [i32; 3], f32, f32, bool),
|
||||
) -> Tag {
|
||||
let mut respawn = vec![
|
||||
("dimension", string(dimension)),
|
||||
("pos", Tag::IntArray(block.to_vec())),
|
||||
("yaw", Tag::Float(yaw.to_bits())),
|
||||
("pitch", Tag::Float(pitch.to_bits())),
|
||||
];
|
||||
if *forced {
|
||||
respawn.push(("forced", Tag::Byte(1)));
|
||||
}
|
||||
Tag::compound(respawn)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{Level, read_dat, write_dat};
|
||||
use super::*;
|
||||
|
||||
/// Added model control: old records without pitch retain the existing positive-zero default.
|
||||
#[test]
|
||||
fn personal_respawn_missing_pitch_loads_positive_zero() {
|
||||
let tag = Tag::compound(vec![(
|
||||
"respawn",
|
||||
Tag::compound(vec![
|
||||
("dimension", string("minecraft:overworld")),
|
||||
("pos", Tag::IntArray(vec![12, 100, 12])),
|
||||
("yaw", Tag::Float(45f32.to_bits())),
|
||||
("forced", Tag::Byte(1)),
|
||||
]),
|
||||
)]);
|
||||
let mut level = Level::default();
|
||||
level.read_player(&tag);
|
||||
let (_, pos, yaw, pitch, forced) = level.respawn_elsewhere.unwrap();
|
||||
assert_eq!(pos, [12, 100, 12]);
|
||||
assert_eq!(yaw.to_bits(), 45f32.to_bits());
|
||||
assert_eq!(pitch.to_bits(), 0.0f32.to_bits());
|
||||
assert!(forced);
|
||||
}
|
||||
|
||||
/// Added model/I/O control, separate from the connected runtime proof.
|
||||
#[test]
|
||||
fn personal_respawn_nonzero_pitch_survives_actual_player_file_write_and_read() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-personal-pitch-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let file = root.join("player.dat");
|
||||
let original = Level {
|
||||
respawn_elsewhere: Some((
|
||||
"minecraft:overworld".into(),
|
||||
[12, 100, 12],
|
||||
45.0,
|
||||
10.0,
|
||||
true,
|
||||
)),
|
||||
..Level::default()
|
||||
};
|
||||
write_dat(&file, original.player_tag()).unwrap();
|
||||
let tag = read_dat(&file).expect("actual written NBT player file");
|
||||
assert_eq!(
|
||||
tag.get("respawn").unwrap().get("pitch"),
|
||||
Some(&Tag::Float(10f32.to_bits()))
|
||||
);
|
||||
let mut loaded = Level::default();
|
||||
loaded.read_player(&tag);
|
||||
assert_eq!(loaded.respawn_elsewhere, original.respawn_elsewhere);
|
||||
println!("actual saved personal respawn {:?}", tag.get("respawn"));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -70,8 +70,7 @@ struct Shared {
|
||||
/// The game's handle. Dropping it stops the thread.
|
||||
pub struct Cosmetics {
|
||||
shared: Arc<Shared>,
|
||||
/// Behind a lock so one client can be shared (`Arc<Cosmetics>` is `Send + Sync`).
|
||||
worker: Mutex<Option<Box<dyn FnMut() + Send>>>,
|
||||
worker: Option<Box<dyn FnMut() + Send>>,
|
||||
}
|
||||
|
||||
impl Cosmetics {
|
||||
@@ -79,12 +78,7 @@ impl Cosmetics {
|
||||
/// `cache` when given.
|
||||
pub fn new(transport: impl Transport, cache: Option<PathBuf>) -> Self {
|
||||
let mut c = Self::manual(transport, cache, Arc::new(Instant::now));
|
||||
let mut work = c
|
||||
.worker
|
||||
.get_mut()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.take()
|
||||
.expect("worker");
|
||||
let mut work = c.worker.take().expect("worker");
|
||||
let shared = c.shared.clone();
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("Cosmetics".into())
|
||||
@@ -131,7 +125,7 @@ impl Cosmetics {
|
||||
});
|
||||
Self {
|
||||
shared,
|
||||
worker: Mutex::new(Some(worker)),
|
||||
worker: Some(worker),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ fn clock() -> (Clock, Arc<AtomicU64>) {
|
||||
}
|
||||
|
||||
fn run(c: &mut Cosmetics) {
|
||||
(c.worker.get_mut().unwrap().as_mut().unwrap())();
|
||||
(c.worker.as_mut().unwrap())();
|
||||
}
|
||||
|
||||
fn ids(worn: Option<Worn>) -> Vec<String> {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
//! Lang files a crate above the engine ships with its text (docs/layers.md, "Platform
|
||||
//! registries"). A crate [`register`]s its `(code, JSON)` pairs, usually `include_str!`s of its
|
||||
//! `assets/lang/<code>.json`, before the game starts. The game loads them as part of its own
|
||||
//! `horizon` namespace, right after its bundled files for the same language, so resource packs
|
||||
//! still override them; on a key both have, the engine's file wins. A regional variant reads its
|
||||
//! language's file as the engine's do (`fr_ca` reads `fr_fr`, `de_ch` reads `de_de` without `ß`).
|
||||
use crate::{TooLate, registry::Registry};
|
||||
|
||||
/// One crate's lang files: a language code (`en_us`, `fr_fr`) and that file's JSON text.
|
||||
pub type Files = [(&'static str, &'static str)];
|
||||
|
||||
static FILES: Registry<Files> = Registry::new();
|
||||
|
||||
/// Adds a crate's lang files. Before the game starts, as [`crate::register`]; a later call is
|
||||
/// refused.
|
||||
pub fn register(files: &'static Files) -> Result<(), TooLate> {
|
||||
FILES.register(files)
|
||||
}
|
||||
|
||||
pub(crate) fn all() -> &'static [&'static Files] {
|
||||
FILES.all()
|
||||
}
|
||||
|
||||
/// The engine's side. Only the engine calls it.
|
||||
pub mod engine {
|
||||
/// Every crate's files, in registration order, sealing the list on the first call.
|
||||
pub fn all() -> &'static [&'static super::Files] {
|
||||
super::all()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn files_registered_before_the_game_starts_are_kept_in_order() {
|
||||
static A: &Files = &[("en_us", r#"{"a.b.c": "A"}"#)];
|
||||
static B: &Files = &[("en_us", r#"{"d.e.f": "B"}"#), ("fr_fr", "{}")];
|
||||
assert_eq!(register(A), Ok(()));
|
||||
assert_eq!(register(B), Ok(()));
|
||||
let all = engine::all();
|
||||
assert_eq!(all.len(), 2);
|
||||
assert_eq!(all[1][1].0, "fr_fr");
|
||||
assert_eq!(register(A), Err(TooLate));
|
||||
}
|
||||
}
|
||||
@@ -14,19 +14,16 @@
|
||||
//!
|
||||
//! The platform registries (docs/layers.md, "Platform registries") have the same shape:
|
||||
//! [`settings`] for the settings each crate keeps in its own lines, [`pages`] for its settings
|
||||
//! pages, [`hud`] for what it draws on the HUD, [`lang`] for the lang files with its text.
|
||||
//! pages, [`hud`] for what it draws on the HUD.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod hud;
|
||||
pub mod lang;
|
||||
pub mod pages;
|
||||
pub mod players;
|
||||
mod registry;
|
||||
pub mod settings;
|
||||
pub mod tunnel;
|
||||
|
||||
use registry::Registry;
|
||||
use std::cell::Cell;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// What a crate above the engine hears from the game. Every method has an empty default:
|
||||
@@ -45,39 +42,6 @@ pub trait Hooks: Sync {
|
||||
/// The game is ending normally: its window closed and the world is saved. Not after a crash.
|
||||
fn shutdown(&self) {}
|
||||
|
||||
/// Once a frame, before drawing: `sprites` takes the pictures the crate's
|
||||
/// [`players::Sprites`] lacked. Only a window's game calls it (each splitscreen player in
|
||||
/// turn, [`seat`]), never the integrated server's thread or a dedicated server; so are the
|
||||
/// player methods below (not [`Hooks::tunnel`]).
|
||||
fn frame(&self, _frame: &players::Frame, _sprites: &mut dyn players::SpriteStore) {}
|
||||
|
||||
/// Once a client tick while a world is shown: the player models in view.
|
||||
fn tick(&self, _models: &mut dyn players::PlayerModels) {}
|
||||
|
||||
/// What goes by a player model's name over its head, each frame its name tag is drawn. The
|
||||
/// first hook that answers is used.
|
||||
fn name_decoration(
|
||||
&self,
|
||||
_model: &players::PlayerModel,
|
||||
_sprites: &dyn players::Sprites,
|
||||
) -> Option<players::NameDecoration> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The mark before the name of the player `uuid` in the player list, each frame it shows.
|
||||
/// The first hook that answers is used.
|
||||
fn player_mark(&self, _uuid: u128) -> Option<players::Mark> {
|
||||
None
|
||||
}
|
||||
|
||||
/// How the camera stands in third person, each frame. The first hook that answers is used.
|
||||
fn camera(&self, _own: &players::CameraView) -> Option<players::CameraOverride> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Draws on a player model, after its vanilla layers, each time it is drawn.
|
||||
fn player_layers(&self, _model: &players::PlayerModel, _draw: &mut dyn players::PlayerDraw) {}
|
||||
|
||||
/// The resolver of `friend:` addresses this crate offers ([`tunnel`]); the first hook that
|
||||
/// offers one is used. Asked on the network thread when joining and on the game thread when
|
||||
/// an address is shown, never per frame.
|
||||
@@ -91,14 +55,11 @@ pub trait Hooks: Sync {
|
||||
pub struct Started<'a> {
|
||||
/// The build: the release tag (`0.2.31`), or `dev <crate version>` for a build from source.
|
||||
pub version: &'a str,
|
||||
/// The player's game folder: servers.dat, options.txt and saves (the working directory unless
|
||||
/// `--game-dir` names another).
|
||||
pub game_dir: &'a std::path::Path,
|
||||
}
|
||||
|
||||
impl<'a> Started<'a> {
|
||||
pub fn new(version: &'a str, game_dir: &'a std::path::Path) -> Self {
|
||||
Self { version, game_dir }
|
||||
pub fn new(version: &'a str) -> Self {
|
||||
Self { version }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,27 +89,10 @@ fn all() -> &'static [&'static dyn Hooks] {
|
||||
HOOKS.all()
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SEAT: Cell<usize> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The splitscreen player whose turn it is: 0 for P1 (and without splitscreen), 1 for P2...
|
||||
/// Splitscreen players are one process, each `Game` in turn on the game's thread, so a crate's
|
||||
/// statics are shared by them: a crate that keeps per-player state (settings, pages, HUD) indexes
|
||||
/// it by this. A player keeps its seat while it plays. Other threads always see 0.
|
||||
pub fn seat() -> usize {
|
||||
SEAT.get()
|
||||
}
|
||||
|
||||
/// The engine's side: where it calls the hooks. Only the engine calls these.
|
||||
pub mod engine {
|
||||
use super::*;
|
||||
|
||||
/// The player whose turn starts on this thread ([`seat`]).
|
||||
pub fn set_seat(seat: usize) {
|
||||
SEAT.set(seat);
|
||||
}
|
||||
|
||||
/// Seals every list (hooks and the registries); their first use does it anyway. Call where
|
||||
/// registration ends.
|
||||
pub fn seal() {
|
||||
@@ -156,7 +100,6 @@ pub mod engine {
|
||||
crate::settings::all();
|
||||
crate::pages::engine::all();
|
||||
crate::hud::engine::all();
|
||||
crate::lang::all();
|
||||
}
|
||||
|
||||
/// [`Hooks::started`].
|
||||
@@ -187,50 +130,6 @@ pub mod engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any hook is registered: without one the engine builds no view to hand them.
|
||||
pub fn any() -> bool {
|
||||
!all().is_empty()
|
||||
}
|
||||
|
||||
/// [`Hooks::frame`].
|
||||
pub fn frame(frame: &players::Frame, sprites: &mut dyn players::SpriteStore) {
|
||||
for hooks in all() {
|
||||
hooks.frame(frame, sprites);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::name_decoration`].
|
||||
pub fn name_decoration(
|
||||
model: &players::PlayerModel,
|
||||
sprites: &dyn players::Sprites,
|
||||
) -> Option<players::NameDecoration> {
|
||||
all().iter().find_map(|h| h.name_decoration(model, sprites))
|
||||
}
|
||||
|
||||
/// [`Hooks::player_mark`].
|
||||
pub fn player_mark(uuid: u128) -> Option<players::Mark> {
|
||||
all().iter().find_map(|h| h.player_mark(uuid))
|
||||
}
|
||||
|
||||
/// [`Hooks::player_layers`].
|
||||
pub fn player_layers(model: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
|
||||
for hooks in all() {
|
||||
hooks.player_layers(model, draw);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::camera`].
|
||||
pub fn camera(own: &players::CameraView) -> Option<players::CameraOverride> {
|
||||
all().iter().find_map(|h| h.camera(own))
|
||||
}
|
||||
|
||||
/// [`Hooks::tick`].
|
||||
pub fn tick(models: &mut dyn players::PlayerModels) {
|
||||
for hooks in all() {
|
||||
hooks.tick(models);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::tunnel`]: the first registered hook's resolver.
|
||||
pub fn tunnel() -> Option<&'static dyn tunnel::Resolver> {
|
||||
all().iter().find_map(|hooks| hooks.tunnel())
|
||||
@@ -240,7 +139,6 @@ pub mod engine {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use players::PlayerModels as _;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
/// Records what it heard. One process-wide list, as the game has: this test is the only
|
||||
@@ -257,149 +155,6 @@ mod tests {
|
||||
fn published(&self, open: bool) {
|
||||
self.0.lock().unwrap().push(format!("published {open}"));
|
||||
}
|
||||
fn frame(&self, frame: &players::Frame, sprites: &mut dyn players::SpriteStore) {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("frame {}", frame.connection));
|
||||
if !sprites.has(1) {
|
||||
sprites.put(1, vec![0; 64 * 64 * 4]);
|
||||
}
|
||||
}
|
||||
fn name_decoration(
|
||||
&self,
|
||||
model: &players::PlayerModel,
|
||||
sprites: &dyn players::Sprites,
|
||||
) -> Option<players::NameDecoration> {
|
||||
let badge = sprites.get(1).map(|uv| players::Badge::new(uv, 0xFF0000));
|
||||
(model.kind == players::Kind::Player)
|
||||
.then(|| players::NameDecoration::new(Some(MARK), badge, Some(2.0)))
|
||||
}
|
||||
fn player_mark(&self, uuid: u128) -> Option<players::Mark> {
|
||||
(uuid == 9).then_some(MARK)
|
||||
}
|
||||
fn camera(&self, own: &players::CameraView) -> Option<players::CameraOverride> {
|
||||
Some(players::CameraOverride::new(own.age / 10.0))
|
||||
}
|
||||
/// A hat on the head, its brim turned by the model's age.
|
||||
fn player_layers(&self, _: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
|
||||
let (Some(head), Some(uv)) = (draw.bone(players::Bone::Head), draw.sprite(1)) else {
|
||||
return;
|
||||
};
|
||||
let turn = draw.ticks() / 100.0;
|
||||
draw.draw(&HAT, head, uv, &mut |p| p.pose("brim")[4] = turn);
|
||||
}
|
||||
fn tick(&self, models: &mut dyn players::PlayerModels) {
|
||||
let mut ids = Vec::new();
|
||||
models.for_each(&mut |m| ids.push(m.id));
|
||||
for id in ids {
|
||||
models.set_body_pose(id, Some(std::sync::Arc::new(Still)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Still;
|
||||
impl players::BodyPose for Still {
|
||||
fn pose(&self, _: f32, _: &[[f32; 9]; 6], _: &mut [[f32; 9]; 6]) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Two models, poses by id.
|
||||
struct Two(std::collections::BTreeMap<u32, std::sync::Arc<dyn players::BodyPose>>);
|
||||
impl players::PlayerModels for Two {
|
||||
fn connection(&self) -> u64 {
|
||||
1
|
||||
}
|
||||
fn for_each(&self, f: &mut dyn FnMut(players::PlayerModel)) {
|
||||
for id in [3, 7] {
|
||||
f(players::PlayerModel::new(
|
||||
id,
|
||||
players::Kind::Player,
|
||||
None,
|
||||
0,
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
fn body_pose(&self, id: u32) -> Option<&std::sync::Arc<dyn players::BodyPose>> {
|
||||
self.0.get(&id)
|
||||
}
|
||||
fn set_body_pose(&mut self, id: u32, pose: Option<std::sync::Arc<dyn players::BodyPose>>) {
|
||||
match pose {
|
||||
Some(p) => self.0.insert(id, p),
|
||||
None => self.0.remove(&id),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const MARK: players::Mark = &[(0, 0, 8, 8, 0xFF00_00FF)];
|
||||
|
||||
/// One sprite slot, as the game's atlas has many.
|
||||
#[derive(Default)]
|
||||
struct Atlas(Option<(u64, usize)>);
|
||||
impl players::SpriteStore for Atlas {
|
||||
fn has(&self, id: u64) -> bool {
|
||||
self.0.is_some_and(|(i, _)| i == id)
|
||||
}
|
||||
fn put(&mut self, id: u64, rgba: Vec<u8>) {
|
||||
self.0 = Some((id, rgba.len()));
|
||||
}
|
||||
}
|
||||
impl players::Sprites for Atlas {
|
||||
fn get(&self, id: u64) -> Option<[f32; 4]> {
|
||||
players::SpriteStore::has(self, id).then_some([0.0, 0.0, 0.5, 0.5])
|
||||
}
|
||||
}
|
||||
|
||||
static HAT: std::sync::LazyLock<std::sync::Arc<players::Mesh>> =
|
||||
std::sync::LazyLock::new(|| {
|
||||
let brim = players::Mesh::new("brim".into(), [0.0; 9], Vec::new(), Vec::new());
|
||||
std::sync::Arc::new(players::Mesh::new(
|
||||
"hat".into(),
|
||||
[0.0; 9],
|
||||
Vec::new(),
|
||||
vec![brim],
|
||||
))
|
||||
});
|
||||
|
||||
/// A player drawn at age 50, its head where the camera is; logs each draw.
|
||||
#[derive(Default)]
|
||||
struct Canvas(Vec<(String, [[f32; 4]; 4], [f32; 4], f32)>);
|
||||
impl players::PlayerDraw for Canvas {
|
||||
fn pass(&self) -> players::Pass {
|
||||
players::Pass::World
|
||||
}
|
||||
fn ticks(&self) -> f32 {
|
||||
50.0
|
||||
}
|
||||
fn bone(&mut self, bone: players::Bone) -> Option<[[f32; 4]; 4]> {
|
||||
(bone == players::Bone::Head).then_some([[1.0, 0.0, 0.0, 0.0]; 4])
|
||||
}
|
||||
fn body_pose(&self) -> Option<&std::sync::Arc<dyn players::BodyPose>> {
|
||||
None
|
||||
}
|
||||
fn sprite(&self, id: u64) -> Option<[f32; 4]> {
|
||||
(id == 1).then_some([0.0, 0.0, 0.5, 0.5])
|
||||
}
|
||||
fn draw(
|
||||
&mut self,
|
||||
mesh: &std::sync::Arc<players::Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
uv: [f32; 4],
|
||||
pose: &mut dyn FnMut(&mut dyn players::PartPoses),
|
||||
) {
|
||||
struct Rest([f32; 9]);
|
||||
impl players::PartPoses for Rest {
|
||||
fn pose(&mut self, _: &str) -> &mut [f32; 9] {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
let mut brim = Rest(mesh.children[0].pose);
|
||||
pose(&mut brim);
|
||||
self.0.push((mesh.name.clone(), matrix, uv, brim.0[4]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements nothing: the defaults do nothing.
|
||||
@@ -412,52 +167,15 @@ mod tests {
|
||||
fn registered_hooks_hear_the_game_and_late_ones_are_refused() {
|
||||
register(&RECORDER).unwrap();
|
||||
register(&Silent).unwrap();
|
||||
engine::started(&Started::new("0.2.31", std::path::Path::new("game")));
|
||||
engine::started(&Started::new("0.2.31"));
|
||||
// Changes only: the same state every frame is said once.
|
||||
for open in [false, true, true, true, false, false] {
|
||||
engine::published(open);
|
||||
}
|
||||
engine::shutdown();
|
||||
assert!(engine::any());
|
||||
let mut atlas = Atlas::default();
|
||||
let model = |kind| players::PlayerModel::new(5, kind, Some(9), 0, false);
|
||||
// Before the frame put the sprite in: the decoration without its badge.
|
||||
let before = engine::name_decoration(&model(players::Kind::Player), &atlas).unwrap();
|
||||
assert!(before.badge.is_none() && before.top == Some(2.0));
|
||||
engine::frame(&players::Frame::new(4, false), &mut atlas);
|
||||
assert_eq!(atlas.0, Some((1, 64 * 64 * 4)));
|
||||
let after = engine::name_decoration(&model(players::Kind::Player), &atlas).unwrap();
|
||||
assert_eq!(
|
||||
after.badge.map(|b| (b.uv, b.line)),
|
||||
Some(([0.0, 0.0, 0.5, 0.5], 0xFF0000))
|
||||
);
|
||||
assert!(engine::name_decoration(&model(players::Kind::Mannequin), &atlas).is_none());
|
||||
assert_eq!(engine::player_mark(9), Some(MARK));
|
||||
assert_eq!(engine::player_mark(8), None);
|
||||
let camera = engine::camera(&players::CameraView::new(5.0));
|
||||
assert_eq!(camera.map(|c| c.distance), Some(0.5));
|
||||
let mut canvas = Canvas::default();
|
||||
engine::player_layers(&model(players::Kind::Player), &mut canvas);
|
||||
assert_eq!(
|
||||
canvas.0,
|
||||
[(
|
||||
"hat".to_owned(),
|
||||
[[1.0, 0.0, 0.0, 0.0]; 4],
|
||||
[0.0, 0.0, 0.5, 0.5],
|
||||
0.5
|
||||
)]
|
||||
);
|
||||
let mut models = Two(Default::default());
|
||||
engine::tick(&mut models);
|
||||
assert!(models.body_pose(3).is_some() && models.body_pose(7).is_some());
|
||||
assert_eq!(
|
||||
*RECORDER.0.lock().unwrap(),
|
||||
[
|
||||
"started 0.2.31",
|
||||
"published true",
|
||||
"published false",
|
||||
"frame 4"
|
||||
]
|
||||
["started 0.2.31", "published true", "published false"]
|
||||
);
|
||||
assert_eq!(register(&Silent), Err(TooLate));
|
||||
}
|
||||
|
||||
@@ -61,93 +61,13 @@ pub enum Row {
|
||||
value: f32,
|
||||
text: Label,
|
||||
},
|
||||
/// A button that does something ([`Row::button`]). An inactive one is drawn greyed and
|
||||
/// never pressed; two half-width ones in a row sit side by side; a footer one sits at the
|
||||
/// bottom beside Done on a page drawn as its own screen, and with the others elsewhere.
|
||||
/// Horizon Settings' Extras shows only active ones.
|
||||
#[non_exhaustive]
|
||||
Button {
|
||||
label: &'static str,
|
||||
active: bool,
|
||||
half: bool,
|
||||
footer: bool,
|
||||
},
|
||||
/// Text shown as it is, wrapped to the page's width ([`Row::text`]): white, or grey when
|
||||
/// `quiet` (secondary); `groups` breaks it only between its `-`-separated groups (a code).
|
||||
#[non_exhaustive]
|
||||
Text {
|
||||
text: String,
|
||||
quiet: bool,
|
||||
groups: bool,
|
||||
},
|
||||
/// Empty space, in GUI pixels, on a page drawn as its own screen; nothing elsewhere.
|
||||
Space(u8),
|
||||
/// A button that does something.
|
||||
Button { label: &'static str },
|
||||
/// A key binding, as Key Binds shows one: `key` is its key name (`key.keyboard.z`,
|
||||
/// `key.mouse.left`). A press waits for the next key or mouse button ([`Act::Bind`]).
|
||||
Key { label: &'static str, key: String },
|
||||
}
|
||||
|
||||
impl Row {
|
||||
/// An active, full-width button in the page's body.
|
||||
pub fn button(label: &'static str) -> Self {
|
||||
Self::Button {
|
||||
label,
|
||||
active: true,
|
||||
half: false,
|
||||
footer: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// White text in the page's body, wrapped at spaces.
|
||||
pub fn text(text: impl Into<String>) -> Self {
|
||||
Self::Text {
|
||||
text: text.into(),
|
||||
quiet: false,
|
||||
groups: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A button greyed out (`false`): drawn, never pressed.
|
||||
pub fn active(mut self, on: bool) -> Self {
|
||||
if let Self::Button { active, .. } = &mut self {
|
||||
*active = on;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// A button half as wide: two in a row sit side by side.
|
||||
pub fn half(mut self) -> Self {
|
||||
if let Self::Button { half, .. } = &mut self {
|
||||
*half = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// A button in the footer, beside Done, on a page drawn as its own screen.
|
||||
pub fn footer(mut self) -> Self {
|
||||
if let Self::Button { footer, .. } = &mut self {
|
||||
*footer = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Text in grey: secondary.
|
||||
pub fn quiet(mut self) -> Self {
|
||||
if let Self::Text { quiet, .. } = &mut self {
|
||||
*quiet = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Text broken only between its `-`-separated groups, never inside one (a code).
|
||||
pub fn groups(mut self) -> Self {
|
||||
if let Self::Text { groups, .. } = &mut self {
|
||||
*groups = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A value shown on a row: a translation key, or text shown as it is (a number, a name).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Label {
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
//! The players the game shows, as a crate above the engine sees them (Horizon's emotes, spec
|
||||
//! #14091): [`PlayerModels`] in [`crate::Hooks::tick`], [`Frame`] in [`crate::Hooks::frame`],
|
||||
//! [`BodyPose`], a pose laid over a player's body after its vanilla animation, what goes by their
|
||||
//! names, and the crate's [`Sprites`]. Plain types only: no engine type crosses this line.
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The player model's parts a [`BodyPose`] poses, in its order.
|
||||
pub const BODY_PARTS: [&str; 6] = [
|
||||
"head",
|
||||
"body",
|
||||
"right_arm",
|
||||
"left_arm",
|
||||
"right_leg",
|
||||
"left_leg",
|
||||
];
|
||||
|
||||
/// A pose a crate lays over a player model's body after its vanilla animation (Horizon's emotes,
|
||||
/// docs/cosmetics.md), handed to the model with [`PlayerModels::set_body_pose`]. The crate
|
||||
/// downcasts its own back through [`std::any::Any`].
|
||||
pub trait BodyPose: std::any::Any + std::fmt::Debug + Send + Sync {
|
||||
/// Poses the [`BODY_PARTS`] at `age` (the model's ticks): `at_rest` holds each part before the
|
||||
/// animation, `poses` as the animation left it (x, y, z, x/y/z rotation, x/y/z scale); a part
|
||||
/// the model lacks holds the identity in both and is not written back. False when it poses
|
||||
/// nothing at `age` (`poses` is then ignored).
|
||||
fn pose(&self, age: f32, at_rest: &[[f32; 9]; 6], poses: &mut [[f32; 9]; 6]) -> bool;
|
||||
}
|
||||
|
||||
/// What a player model is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Kind {
|
||||
/// The player of this window, seen from outside (third person, the inventory picture).
|
||||
Own,
|
||||
/// Another player.
|
||||
Player,
|
||||
/// A mannequin: a player model with no player.
|
||||
Mannequin,
|
||||
}
|
||||
|
||||
/// One player model, as [`PlayerModels::for_each`] shows it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct PlayerModel {
|
||||
/// Its entity id on this connection ([`PlayerModels::connection`]).
|
||||
pub id: u32,
|
||||
pub kind: Kind,
|
||||
/// The player's UUID as the server gave it (the window's own: its profile); None for
|
||||
/// mannequins.
|
||||
pub uuid: Option<u128>,
|
||||
/// Its age in ticks, the clock a [`BodyPose`] is timed on.
|
||||
pub age: u32,
|
||||
/// It moved sideways since the last tick (more than 0.01 blocks).
|
||||
pub moved: bool,
|
||||
/// What it wears that covers a crate's drawing.
|
||||
pub gear: Gear,
|
||||
/// It is drawn invisible (the effect, or a trader's disguise).
|
||||
pub invisible: bool,
|
||||
}
|
||||
|
||||
impl PlayerModel {
|
||||
pub fn new(id: u32, kind: Kind, uuid: Option<u128>, age: u32, moved: bool) -> Self {
|
||||
Self {
|
||||
id,
|
||||
kind,
|
||||
uuid,
|
||||
age,
|
||||
moved,
|
||||
gear: Gear::default(),
|
||||
invisible: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// With what it wears and whether it is invisible.
|
||||
pub fn looks(self, gear: Gear, invisible: bool) -> Self {
|
||||
Self {
|
||||
gear,
|
||||
invisible,
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a player model wears where a crate may draw.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct Gear {
|
||||
/// A helmet or a head item.
|
||||
pub head: bool,
|
||||
/// A chestplate (an elytra is on the back).
|
||||
pub chest: bool,
|
||||
/// A cape or an elytra.
|
||||
pub back: bool,
|
||||
}
|
||||
|
||||
impl Gear {
|
||||
pub fn new(head: bool, chest: bool, back: bool) -> Self {
|
||||
Self { head, chest, back }
|
||||
}
|
||||
}
|
||||
|
||||
/// The player models of the world this window shows, once a tick ([`crate::Hooks::tick`]).
|
||||
pub trait PlayerModels {
|
||||
/// Changes on every join: entity ids are per connection, so what a crate keeps by id goes
|
||||
/// when this changes.
|
||||
fn connection(&self) -> u64;
|
||||
/// Each player model in view (other players and mannequins; the window's own model first).
|
||||
fn for_each(&self, f: &mut dyn FnMut(PlayerModel));
|
||||
/// The pose laid over model `id`, if any.
|
||||
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>>;
|
||||
/// Lays `pose` over model `id` (None: its vanilla animation alone). Unknown ids are ignored.
|
||||
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>);
|
||||
}
|
||||
|
||||
/// What [`crate::Hooks::frame`] gets, once a frame before drawing.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct Frame {
|
||||
/// As [`PlayerModels::connection`]; 0 with no world.
|
||||
pub connection: u64,
|
||||
/// The world is the window's own single-player one: its server names the player by an
|
||||
/// offline profile.
|
||||
pub solo: bool,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
pub fn new(connection: u64, solo: bool) -> Self {
|
||||
Self { connection, solo }
|
||||
}
|
||||
}
|
||||
|
||||
/// Rectangles drawn before a name: x, y, width, height in text pixels from the name line's top
|
||||
/// left corner (a pixel left of its background), and `0xAARRGGBB`.
|
||||
pub type Mark = &'static [(u8, u8, u8, u8, u32)];
|
||||
|
||||
/// What a crate draws by a player's name over their head ([`crate::Hooks::name_decoration`]).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
#[non_exhaustive]
|
||||
pub struct NameDecoration {
|
||||
/// Before the name.
|
||||
pub mark: Option<Mark>,
|
||||
/// After the name, and a line under it.
|
||||
pub badge: Option<Badge>,
|
||||
/// How high the crate's drawing on the body gets above the neck, in model pixels: the tag
|
||||
/// goes up over it.
|
||||
pub top: Option<f32>,
|
||||
}
|
||||
|
||||
impl NameDecoration {
|
||||
pub fn new(mark: Option<Mark>, badge: Option<Badge>, top: Option<f32>) -> Self {
|
||||
Self { mark, badge, top }
|
||||
}
|
||||
}
|
||||
|
||||
/// A badge after a name: 8 text pixels square, a pixel right of the background.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct Badge {
|
||||
/// Its picture: a part of a [`Sprites`] sprite, in atlas coordinates (u0, v0, u1, v1).
|
||||
pub uv: [f32; 4],
|
||||
/// The line under the name, `0xRRGGBB`.
|
||||
pub line: u32,
|
||||
}
|
||||
|
||||
impl Badge {
|
||||
pub fn new(uv: [f32; 4], line: u32) -> Self {
|
||||
Self { uv, line }
|
||||
}
|
||||
}
|
||||
|
||||
/// A crate's pictures in the game's atlas, by ids of its own: 64×64 RGBA each, in the slots skins
|
||||
/// use (one no frame drew for a while may go). Read while drawing.
|
||||
pub trait Sprites {
|
||||
/// Where sprite `id` is (u0, v0, u1, v1), and it is kept this frame. None until
|
||||
/// [`SpriteStore::put`] put it there.
|
||||
fn get(&self, id: u64) -> Option<[f32; 4]>;
|
||||
}
|
||||
|
||||
/// Where a crate puts its [`Sprites`], in [`crate::Hooks::frame`].
|
||||
pub trait SpriteStore {
|
||||
/// Sprite `id` is in the atlas.
|
||||
fn has(&self, id: u64) -> bool;
|
||||
/// Puts sprite `id` in the atlas (64×64 RGBA), if a slot is free.
|
||||
fn put(&mut self, id: u64, rgba: Vec<u8>);
|
||||
}
|
||||
|
||||
/// The window's own model, as [`crate::Hooks::camera`] sees it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct CameraView {
|
||||
/// Its age in ticks, with the frame's part of a tick.
|
||||
pub age: f32,
|
||||
}
|
||||
|
||||
impl CameraView {
|
||||
pub fn new(age: f32) -> Self {
|
||||
Self { age }
|
||||
}
|
||||
}
|
||||
|
||||
/// How a crate moves the camera in third person.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct CameraOverride {
|
||||
/// How far it stands back, as a share of the usual distance.
|
||||
pub distance: f32,
|
||||
}
|
||||
|
||||
impl CameraOverride {
|
||||
pub fn new(distance: f32) -> Self {
|
||||
Self { distance }
|
||||
}
|
||||
}
|
||||
|
||||
/// A model a crate draws on players, as the game's own models are: a tree of parts, 16 units a
|
||||
/// block, y down. Build it once and keep the `Arc`: the engine prepares it for the GPU once, by
|
||||
/// the `Arc`'s address, while the crate holds it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[non_exhaustive]
|
||||
pub struct Mesh {
|
||||
/// What [`PartPoses::pose`] names it by.
|
||||
pub name: String,
|
||||
/// At rest: x, y, z, x/y/z rotation (radians), x/y/z scale.
|
||||
pub pose: [f32; 9],
|
||||
pub quads: Vec<Quad>,
|
||||
pub children: Vec<Mesh>,
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
pub fn new(name: String, pose: [f32; 9], quads: Vec<Quad>, children: Vec<Mesh>) -> Self {
|
||||
Self {
|
||||
name,
|
||||
pose,
|
||||
quads,
|
||||
children,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One face of a [`Mesh`].
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct Quad {
|
||||
pub normal: [f32; 3],
|
||||
/// Its corners: x, y, z, then u, v across the sprite (0 to 1).
|
||||
pub vertices: [[f32; 5]; 4],
|
||||
}
|
||||
|
||||
impl Quad {
|
||||
pub fn new(normal: [f32; 3], vertices: [[f32; 5]; 4]) -> Self {
|
||||
Self { normal, vertices }
|
||||
}
|
||||
}
|
||||
|
||||
/// A part of the player's body a crate draws on.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Bone {
|
||||
Head,
|
||||
Body,
|
||||
RightArm,
|
||||
LeftArm,
|
||||
RightLeg,
|
||||
LeftLeg,
|
||||
}
|
||||
|
||||
impl Bone {
|
||||
/// Its name among [`BODY_PARTS`].
|
||||
pub fn part(self) -> &'static str {
|
||||
BODY_PARTS[self as usize]
|
||||
}
|
||||
}
|
||||
|
||||
/// Which picture of a player is drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Pass {
|
||||
/// In the world, seen by the camera.
|
||||
World,
|
||||
}
|
||||
|
||||
/// The poses of a [`Mesh`]'s parts for one [`PlayerDraw::draw`], each at rest to begin with.
|
||||
pub trait PartPoses {
|
||||
/// Part `name`'s pose, to change (see [`Mesh::pose`]); a name the mesh lacks gets a pose that
|
||||
/// is not used.
|
||||
fn pose(&mut self, name: &str) -> &mut [f32; 9];
|
||||
}
|
||||
|
||||
/// Where a crate draws on a player model ([`crate::Hooks::player_layers`]). Matrices are
|
||||
/// column-major, relative to the camera, in blocks.
|
||||
pub trait PlayerDraw {
|
||||
fn pass(&self) -> Pass;
|
||||
/// The model's age in ticks, with the frame's part of a tick.
|
||||
fn ticks(&self) -> f32;
|
||||
/// Where `bone` is as the body is drawn this frame; None if the model has no such part.
|
||||
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]>;
|
||||
/// The pose laid over the model's body ([`PlayerModels::set_body_pose`]), if any.
|
||||
fn body_pose(&self) -> Option<&Arc<dyn BodyPose>>;
|
||||
/// As [`Sprites::get`].
|
||||
fn sprite(&self, id: u64) -> Option<[f32; 4]>;
|
||||
/// Draws `mesh` at `matrix`, textured by the sprite part `uv` (u0, v0, u1, v1) and lit as the
|
||||
/// model is; `pose` poses its parts first.
|
||||
fn draw(
|
||||
&mut self,
|
||||
mesh: &Arc<Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
uv: [f32; 4],
|
||||
pose: &mut dyn FnMut(&mut dyn PartPoses),
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
[package]
|
||||
name = "horizon-official-servers"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Horizon's official server in the multiplayer list, and the withdrawn partner server taken out of it (docs/layers.md)"
|
||||
|
||||
[dependencies]
|
||||
horizon-addresses = { path = "../horizon-addresses" }
|
||||
horizon-platform = { path = "../horizon-platform", features = ["protocol"] }
|
||||
log = "0.4"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -15,7 +15,6 @@ horizon-game = { path = "../horizon-game" }
|
||||
horizon-platform = { path = "../horizon-platform" }
|
||||
horizon-heartbeat = { path = "../horizon-heartbeat" }
|
||||
horizon-friends = { path = "../horizon-friends", features = ["game"] }
|
||||
horizon-official-servers = { path = "../horizon-official-servers" }
|
||||
log = "0.4"
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -11,13 +11,11 @@
|
||||
use horizon_platform::hooks::{self, Hooks};
|
||||
|
||||
/// Every Horizon part in the game, in the order its hooks run.
|
||||
const PARTS: [&dyn Hooks; 3] = [
|
||||
const PARTS: [&dyn Hooks; 2] = [
|
||||
// The anonymous "I'm playing" ping.
|
||||
&horizon_heartbeat::HOOKS,
|
||||
// Play with Friends: `friend:` addresses join through its tunnel.
|
||||
&horizon_friends::game::HOOKS,
|
||||
// The official server in the player's server list.
|
||||
&horizon_official_servers::HOOKS,
|
||||
];
|
||||
|
||||
/// Registers Horizon's in-game parts with the game. The executable calls it before it runs the
|
||||
|
||||
@@ -13,7 +13,7 @@ regex = { version = "1", default-features = false, features = ["std", "unicode-p
|
||||
|
||||
[dev-dependencies]
|
||||
# `cpu_seconds` for cem_expr_bench.
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -101,7 +101,7 @@ fn main() {
|
||||
let mut slots = vec![0.0f32; NAMES.len()];
|
||||
let frames = 2_000_000u32;
|
||||
let pid = std::process::id();
|
||||
let cpu = horizon_bench::cpu_seconds(pid);
|
||||
let cpu = horizon_system::cpu_seconds(pid);
|
||||
let start = Instant::now();
|
||||
for frame in 0..frames {
|
||||
// An entity walking in and out of water.
|
||||
@@ -125,7 +125,7 @@ fn main() {
|
||||
evals / elapsed / 1e6,
|
||||
elapsed * 1e6 / f64::from(frames),
|
||||
);
|
||||
match cpu.zip(horizon_bench::cpu_seconds(pid)) {
|
||||
match cpu.zip(horizon_system::cpu_seconds(pid)) {
|
||||
Some((before, after)) => println!(
|
||||
" cpu time {:.1} ns per expression, {:.2} us per entity frame",
|
||||
(after - before) * 1e9 / evals,
|
||||
|
||||
@@ -10,7 +10,7 @@ description = "The platform's public API: what the Horizon layer and mods may us
|
||||
# Each part of the engine a Horizon crate may reach, so a crate builds only the engine it uses.
|
||||
# level.dat, servers.dat and region files: NBT, compression, region read and write.
|
||||
world = ["dep:horizon-world"]
|
||||
# A server's address after its SRV record, the offline-mode UUID of a name, and servers.dat.
|
||||
# A server's address after its SRV record, and the offline-mode UUID of a name.
|
||||
protocol = ["world", "dep:horizon-protocol"]
|
||||
# Preparing the game's files from the player's own Minecraft install.
|
||||
prepare = ["dep:horizon-prepare"]
|
||||
|
||||
@@ -46,15 +46,7 @@ pub use horizon_server_files as server_files;
|
||||
/// [`hooks::register`] it before the game starts (docs/layers.md, "How a Horizon crate plugs
|
||||
/// in"). The engine's dispatch side is not part of the API.
|
||||
pub mod hooks {
|
||||
/// The players the game shows (`horizon_hooks::players`): the views [`Hooks::frame`] and
|
||||
/// [`Hooks::tick`] get, the pose a crate lays over a player's body, what goes by their names,
|
||||
/// the crate's sprites, the camera, and what a crate draws on them.
|
||||
pub use horizon_hooks::players::{
|
||||
BODY_PARTS, Badge, BodyPose, Bone, CameraOverride, CameraView, Frame, Gear, Kind, Mark,
|
||||
Mesh, NameDecoration, PartPoses, Pass, PlayerDraw, PlayerModel, PlayerModels, Quad,
|
||||
SpriteStore, Sprites,
|
||||
};
|
||||
pub use horizon_hooks::{Hooks, Started, TooLate, register, seat, tunnel};
|
||||
pub use horizon_hooks::{Hooks, Started, TooLate, register, tunnel};
|
||||
}
|
||||
|
||||
/// The settings store (`horizon_hooks::settings`): a crate's own `key:value` lines in the game
|
||||
@@ -68,22 +60,7 @@ pub mod pages {
|
||||
pub use horizon_hooks::pages::{Act, Label, Page, Place, Row, register};
|
||||
}
|
||||
|
||||
/// The lang files with a crate's text, loaded with the game's own (docs/layers.md, "Platform
|
||||
/// registries").
|
||||
pub mod lang {
|
||||
pub use horizon_hooks::lang::{Files, register};
|
||||
}
|
||||
|
||||
/// Drawing on the HUD (docs/layers.md, "Platform registries").
|
||||
pub mod hud {
|
||||
pub use horizon_hooks::hud::{Canvas, Corner, Layer, register};
|
||||
}
|
||||
|
||||
/// The multiplayer server list as vanilla stores it (`servers.dat`): read, write, add, remove,
|
||||
/// and the lock held while it is read and written back.
|
||||
#[cfg(feature = "protocol")]
|
||||
pub mod servers {
|
||||
pub use horizon_protocol::servers::{
|
||||
ServerEntry, load, lock, parse_address, prepend, remove_where, save,
|
||||
};
|
||||
}
|
||||
@@ -50,111 +50,12 @@ pub enum BiomeColor {
|
||||
Colormap(String),
|
||||
}
|
||||
|
||||
/// A fluid modifier's fog (`fluid_modifiers`): `fog_colormap` as a flat colour (RGB), and the
|
||||
/// `fog_radius` and `fog_fade` multipliers (1 is vanilla).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FluidFog {
|
||||
pub color: Option<u32>,
|
||||
pub radius: f32,
|
||||
pub fade: f32,
|
||||
}
|
||||
|
||||
/// The fog the packs' fluid modifiers give water and lava, read with the colours.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct FluidFogs {
|
||||
pub water: Option<FluidFog>,
|
||||
pub lava: Option<FluidFog>,
|
||||
}
|
||||
|
||||
impl FluidFogs {
|
||||
/// Every `assets/<namespace>/polytone/fluid_modifiers/**.json` under a pack overlay root that
|
||||
/// sets a fog field and targets `minecraft:water` or `minecraft:lava` (still or flowing). A
|
||||
/// colormap id or an expression isn't read (a warning names it).
|
||||
pub fn read(root: &Path) -> Self {
|
||||
let mut fogs = Self::default();
|
||||
for namespace in std::fs::read_dir(root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
{
|
||||
let mut stack = vec![namespace.path().join("polytone/fluid_modifiers")];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if path.extension().is_some_and(|e| e == "json") {
|
||||
fogs.add(root, &path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fogs
|
||||
}
|
||||
|
||||
fn add(&mut self, root: &Path, path: &Path) {
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
return;
|
||||
};
|
||||
let Ok(json) = serde_json::from_str::<Json>(&text) else {
|
||||
return;
|
||||
};
|
||||
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
|
||||
let number = |key: &str| match json.get(key) {
|
||||
None => Some(1.0),
|
||||
Some(Json::Number(n)) => n.as_f64().map(|v| v as f32),
|
||||
Some(Json::String(s)) => s.trim().parse().ok().or_else(|| {
|
||||
log::warn!("{relative}: {key} expressions aren't read yet");
|
||||
None
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let color = match json.get("fog_colormap") {
|
||||
None => None,
|
||||
Some(value) => match parse_color(value) {
|
||||
Some(c) => Some(c & 0xFF_FFFF),
|
||||
None => {
|
||||
log::warn!("{relative}: only flat fog colours are read");
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
let fog = FluidFog {
|
||||
color,
|
||||
radius: number("fog_radius").unwrap_or(1.0),
|
||||
fade: number("fog_fade").unwrap_or(1.0),
|
||||
};
|
||||
if fog
|
||||
== (FluidFog {
|
||||
color: None,
|
||||
radius: 1.0,
|
||||
fade: 1.0,
|
||||
})
|
||||
{
|
||||
return;
|
||||
}
|
||||
let implicit = crate::implicit_id(&relative, "fluid_modifiers");
|
||||
let Ok(targets) = crate::Targets::parse(json.get("targets"), implicit.as_deref()) else {
|
||||
return;
|
||||
};
|
||||
let aims = |id: &str| targets.matches(id, |_| false);
|
||||
if aims("minecraft:water") || aims("minecraft:flowing_water") {
|
||||
self.water = Some(fog);
|
||||
}
|
||||
if aims("minecraft:lava") || aims("minecraft:flowing_lava") {
|
||||
self.lava = Some(fog);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every pack's custom colours, by dotted key.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PackColors {
|
||||
entries: BTreeMap<String, Json>,
|
||||
/// [`Self::environment`], read once with the packs: the fog reads it every frame.
|
||||
pub env: Environment,
|
||||
/// The packs' fluid modifiers' fog.
|
||||
pub fluid_fog: FluidFogs,
|
||||
}
|
||||
|
||||
impl PackColors {
|
||||
@@ -182,7 +83,6 @@ impl PackColors {
|
||||
}
|
||||
}
|
||||
colors.env = colors.environment();
|
||||
colors.fluid_fog = FluidFogs::read(root);
|
||||
colors
|
||||
}
|
||||
|
||||
@@ -476,31 +376,6 @@ mod tests {
|
||||
assert_eq!(c.biome_color("dry_foliage"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fluid_modifiers_give_water_and_lava_their_fog() {
|
||||
let root = std::env::temp_dir().join(format!("fluid-fog-{}", std::process::id()));
|
||||
let dir = root.join("assets/minecraft/polytone/fluid_modifiers");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("water.json"),
|
||||
r#"{"targets": ["minecraft:water", "minecraft:flowing_water"], "fog_colormap": "ff0000", "fog_radius": 0.3}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("lava.json"), r#"{"fog_fade": "0"}"#).unwrap();
|
||||
std::fs::write(dir.join("honey.json"), r#"{"colormap": "foliage_color"}"#).unwrap();
|
||||
let fogs = FluidFogs::read(&root);
|
||||
let fog = |color, radius, fade| {
|
||||
Some(FluidFog {
|
||||
color,
|
||||
radius,
|
||||
fade,
|
||||
})
|
||||
};
|
||||
assert_eq!(fogs.water, fog(Some(0xff0000), 0.3, 1.0));
|
||||
assert_eq!(fogs.lava, fog(None, 1.0, 0.0));
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dyes_name_their_colour_kind() {
|
||||
let mut c = PackColors::default();
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
//! Horizon layer (docs/layers.md#engine).
|
||||
//!
|
||||
//! Only what explains performance: no serial number, host name, user name or path is read. The
|
||||
//! slower probes (a command to run) are read once per process.
|
||||
//! slower probes (a command to run) are read once per process. [`scrub`] reads the user name only
|
||||
//! to take it out of text that leaves the machine (a telemetry item, a bug report's bundle).
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub mod scrub;
|
||||
|
||||
/// What `program args` prints, trimmed, when it succeeds and prints something. On Windows the
|
||||
/// program opens no console window: a GUI launcher or a game started without one would flash a
|
||||
/// terminal for every probe.
|
||||
@@ -229,7 +232,8 @@ fn os_version() -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn cpu() -> Option<String> {
|
||||
/// The CPU's name (`Apple M4 Pro`, `AMD Ryzen 7 7700X 8-Core Processor`).
|
||||
pub fn cpu() -> Option<String> {
|
||||
if cfg!(target_os = "linux") {
|
||||
read("/proc/cpuinfo").and_then(|t| linux_cpu_name(&t))
|
||||
} else if cfg!(target_os = "macos") {
|
||||
@@ -281,7 +285,8 @@ fn processor_name_from_reg_query(query: &str) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
/// Installed memory, in bytes.
|
||||
pub fn ram_bytes() -> Option<u64> {
|
||||
if cfg!(target_os = "linux") {
|
||||
let kib = read("/proc/meminfo").and_then(|t| field(&t, "MemTotal"))?;
|
||||
kib.split_whitespace()
|
||||
@@ -740,6 +745,64 @@ impl Machine {
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-minute load average: above about the number of threads the machine was busy.
|
||||
pub fn load_average() -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "{ 1.23 1.10 1.00 }"
|
||||
command_output("sysctl", &["-n", "vm.loadavg"])?
|
||||
.trim_start_matches('{')
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
} else {
|
||||
std::fs::read_to_string("/proc/loadavg")
|
||||
.ok()?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Resident memory of a process, in bytes (macOS, Linux).
|
||||
pub fn rss_bytes(pid: u32) -> Option<u64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
return command_output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024);
|
||||
}
|
||||
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
|
||||
line.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
|
||||
pub fn cpu_seconds(pid: u32) -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "[[dd-]hh:]mm:ss.cc"
|
||||
let time = command_output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
|
||||
let (rest, days) = match time.split_once('-') {
|
||||
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
|
||||
None => (time, 0.0),
|
||||
};
|
||||
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
|
||||
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
|
||||
})?;
|
||||
return Some(days * 86_400.0 + seconds);
|
||||
}
|
||||
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
|
||||
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
|
||||
Some(ticks / 100.0)
|
||||
}
|
||||
|
||||
/// The GPU's maker from its name and the vendor ID wgpu reports (0: none, as on Metal).
|
||||
pub fn gpu_vendor(name: &str, vendor_id: u32) -> &'static str {
|
||||
match vendor_id {
|
||||
@@ -1005,4 +1068,13 @@ mod tests {
|
||||
let dir = std::env::temp_dir();
|
||||
assert!(["", "nvme", "ssd", "hdd"].contains(&Machine::detect_in(Some(&dir)).storage));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_process_is_measured() {
|
||||
if cfg!(any(target_os = "macos", target_os = "linux")) {
|
||||
assert!(load_average().is_some());
|
||||
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
|
||||
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -18,11 +18,13 @@ pub mod consent;
|
||||
pub mod live;
|
||||
pub mod outbox;
|
||||
pub mod payload;
|
||||
pub mod scrub;
|
||||
pub mod server_status;
|
||||
/// The machine's probes, from the platform (`horizon-system`). Kept at this path for the
|
||||
/// launcher and the benchmark.
|
||||
pub use horizon_platform::system;
|
||||
/// Takes private text out before it leaves (`horizon_system::scrub`). Kept at this path for the
|
||||
/// launcher.
|
||||
pub use horizon_platform::system::scrub;
|
||||
#[cfg(feature = "upload")]
|
||||
pub mod upload;
|
||||
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ What changes in that build:
|
||||
| Extra | Where it lives | The gate |
|
||||
|---|---|---|
|
||||
| Cosmetics, emotes and the emote wheel, "Show me as a Horizon player" | `game/cosmetics.rs` (+ `cosmetics/fx.rs`, `cosmetics/emotes.rs`, `cosmetics/emote_wheel.rs`), crate `horizon-cosmetics` | `game::cosmetics::enabled()`: the service is never asked, nothing is drawn, B opens no wheel |
|
||||
| The official server added to the server list, the withdrawn partner server taken out | crate `horizon-official-servers` | registered only by the official executable (`horizon-official`); horizon-client's own executable has none |
|
||||
| The official server added to the server list, the withdrawn partner server taken out | `official_servers.rs` | the module and its call at startup (`game.rs`, `fn run`) |
|
||||
| Horizon Settings (Extras, PvP & HUD, Splitscreen, Studio, AI Agent, Discord) and Mods (with the registry browser) | `options_ui.rs` (`Page::Horizon` and below), `extras_ui.rs`, `extras.rs`, `pvp.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_hud.rs` | the Options screen shows vanilla's greyed Telemetry Data instead (`options_ui::HORIZON_EXTRAS`, set in `entry.rs`) |
|
||||
| Discord Rich Presence | `discord_presence.rs`, `game/discord.rs` | the `discord` feature, which `horizon-extras` turns on |
|
||||
| Play with Friends | hosting: `game/friends.rs`, `game/friends/screen.rs`; joining: crate `horizon-friends` (`game`) | hosting: the `friends` feature (`game/friends_off.rs` stands in); joining: only the official executable registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ Every setting applies at once, except worker threads, the graphics API and trans
|
||||
|
||||
With `HORIZON_HDR_FRAME=float`, HDR on and no shader pack or a 0.2 one, devices with independent blending draw the frame in layers ([hdr-linear-light.md](design/hdr-linear-light.md), The HUD and menus). The world's shaders write their own light into the frame instead of a mark (`hdr_written`, `hdr_light.wgsl`), a whole frame at a time, so glass, water, clouds and particles each lay their own light over the light behind them. Improved Transparency's layers write their own light too, before they are weighted and composited. No float frame holds marks: under a 0.2 pack Horizon's own draws keep their colour, alpha 1 where opaque and coverage where blended, HDR output lifts nothing, and the pack's `[tonemap]` takes the tone curve's place below (one or the other runs, never both); without the HUD layer (HDR off, or a device without independent blending) its float frame finishes on the swapchain's 8 bits before the camera overlays, which then draw with the HUD as in SDR, and HDR output stays off. The camera overlays (vignette, pumpkin, powder snow, portal, nausea), post effects and entity outlines draw on light. After the post effects and before the camera overlays, the tone curve eases that light into the peak (`Hdr::tonemap`) in place of HDR lighting's own easing and the light sources' cap at the peak: up to 60% of the peak light stays as it is, and above it ITU-R BT.2390's EETF in PQ rolls it off, on its brightest channel so it keeps its hue. The HUD and menus then draw on an 8-bit target of their own, the world clipped to white, as they found the frame before (each draw clips at white as there), and write how much of the world they hide into an R8 coverage target. The last pass lays the HUD over the world's light by that coverage, in gamma as the HUD blends: light behind see-through HUD shows as vanilla weights the HUD over it, and none shows through what covers it. The menu blur blurs that picture, light above white included, and the menu draws over the result. UI Brightness scales the HUD's own part, not the world under it. A screenshot maps the world's light into SDR, unchanged up to 0.6 of paper white in linear light and smoothly rolled towards white above it, with one scale for all RGB channels to keep their hue (where Horizon lifts nothing, with HDR Lighting off or under a 0.2 pack, the world is clipped at white as SDR draws it); the HUD's own part stays as drawn, whatever UI Brightness is, over the mapped world by its coverage. The world's icon uses the same mapping without the HUD; the `-hdr.png` beside a screenshot keeps the whole HDR picture. `HORIZON_HDR_DEBUG=coverage` tints the coverage magenta for inspection.
|
||||
|
||||
HDR also lights the world past paper white: surfaces at block or sky light levels 11 to 15 rise above it, up to 4 times for the strongest light, sky light by how a face meets the sun, easing into the peak from 60% of it so bright textures keep their detail. The frame's alpha between 0.76 and 1 carries it to the last pass, where it amounts to the lightmap going past 1. Darker light stays as in SDR. It's off while a shader pack runs. **HDR Lighting** (`hdrLighting`, on by default) is the whole of this lift: light sources, the sun and the lit surfaces above; off, HDR shows SDR's picture. Under a `shaders/` pack or a 0.2 one Horizon lifts nothing, so the row is greyed and says why; under a 0.1 pack it still lifts the light sources and the sun.
|
||||
HDR also lights the world past paper white: surfaces at block or sky light levels 11 to 15 rise above it, up to 4 times for the strongest light, sky light by how a face meets the sun, easing into the peak from 60% of it so bright textures keep their detail. The frame's alpha between 0.76 and 1 carries it to the last pass, where it amounts to the lightmap going past 1. Darker light stays as in SDR. It's off while a shader pack's post or material stage runs, and `HORIZON_HDR_LIGHTING=0` turns it off to compare.
|
||||
|
||||
HDR's light sources shine by the kind of light rather than its level (`mesher::hdr_tier`): the sun most, then glowstone, campfires and the soul fires, then flames and lava, torch and candle flames among them (particles too), then faint glowers such as a redstone torch or magma; glow lichen stays as in SDR. Each tier has its share of the headroom and the brightness its lift starts and ends at, the texel's own before lighting, so a light source shines alike at any light level and on every face (`gfx/hdr.rs` `TIERS`); `HORIZON_HDR_DEBUG=1` greys what isn't lifted and tints what is magenta.
|
||||
|
||||
|
||||
+7
-36
@@ -156,8 +156,9 @@ Horizon's own extras and its product, and every built-in address of an outside s
|
||||
benchmark uploads, releases and updates, the hosting supervisor, the launcher.
|
||||
- **Examples:** `horizon-addresses`, `horizon-cosmetics`, `horizon-friends`, `horizon-relay`, `horizon-telemetry`,
|
||||
`horizon-bench`, `horizon-release-sign`, `horizon-server`, `horizon-mod-registry`, `horizon-heartbeat`,
|
||||
`horizon-official`, `horizon-official-servers`, `launcher/`. Still in engine crates today: `game/cosmetics.rs`, `game/emotes.rs`,
|
||||
`game/discord.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_ui.rs`, `pvp.rs`.
|
||||
`horizon-official`, `launcher/`. Still in engine crates today: `game/cosmetics.rs`, `game/emotes.rs`,
|
||||
`game/discord.rs`, `game/extras.rs`, `game/tweaks.rs`, `multiplayer.rs` (`add_official_server`), `extras_ui.rs`,
|
||||
`pvp.rs`.
|
||||
- **Rules:**
|
||||
- It uses the platform's public API only, never the engine.
|
||||
- Until it moves, every Horizon feature in the game sits behind the `horizon-extras` feature
|
||||
@@ -322,8 +323,8 @@ hook (#14081), `seat()` (#14096).
|
||||
only calls it.
|
||||
- With nothing registered, the engine does no work: a load of an empty slice where it would dispatch. It makes
|
||||
no allocation, no draw call and no file. A vanilla build (horizon-client, horizon-plain) is unchanged.
|
||||
- Visible text is a translation key the registrant ships in its lang files ([Lang files](#lang-files-horizon_platformlang)),
|
||||
never English in code. Values a player typed (a name, a path) are the exception.
|
||||
- Visible text is a translation key the registrant ships in its lang files, never English in code. Values a
|
||||
player typed (a name, a path) are the exception.
|
||||
- Splitscreen players are one process, one thread, each `Game` in turn (`game/split.rs`), so a crate's statics
|
||||
are shared by them. Every call (stores, pages, layers, hooks) happens on one player's turn, and
|
||||
`horizon_platform::hooks::seat()` (0 for P1) says whose: a crate keeps per-player state indexed by it.
|
||||
@@ -378,10 +379,8 @@ pub enum Row {
|
||||
Toggle { label: &'static str, on: bool },
|
||||
Cycle { label: &'static str, value: Label },
|
||||
Slider { label: &'static str, value: f32, text: Label }, // value in 0..=1
|
||||
Button { label: &'static str, active: bool, half: bool, footer: bool }, // Row::button(..).half()...
|
||||
Button { label: &'static str },
|
||||
Key { label: &'static str, key: String }, // a binding: `key.keyboard.z`
|
||||
Text { text: String, quiet: bool, groups: bool }, // Row::text(..): white or grey, wrapped
|
||||
Space(u8), // GUI pixels, on a page drawn as a screen
|
||||
}
|
||||
pub enum Label { Key(&'static str), Value(String) }
|
||||
pub enum Act { Press, PressBack, Slide(f32), Bind(String) }
|
||||
@@ -391,10 +390,6 @@ pub enum Act { Press, PressBack, Slide(f32), Bind(String) }
|
||||
button on the title screen or the pause menu that opens the page. These buttons exist only when something
|
||||
registered, so the vanilla menus keep vanilla's look. The page is drawn with the same widgets, tooltips,
|
||||
narration, controller focus and `horizon-ctl` ids as the engine's pages.
|
||||
- `Button` and `Text` are built with `Row::button` and `Row::text` and their options: `.active(false)` greys a
|
||||
button, `.half()` puts two side by side, `.footer()` sets one beside Done on a page drawn as its own screen,
|
||||
`.quiet()` greys text, `.groups()` breaks it only between `-`-separated groups (an invite code). In the options
|
||||
shell, text is notes and space is nothing.
|
||||
- Rows are rebuilt when the page is drawn or acted on, like the engine's own pages: no cost while no page is
|
||||
open.
|
||||
- The engine keeps native mods' pages (`Page::NativeMod`) as they are. Both kinds sit in one module that
|
||||
@@ -500,30 +495,6 @@ fn container_draw(&self, c: &dyn Container, canvas: &mut dyn hud::Canvas); //
|
||||
- `order` is the only text: Sorting ranks by it. The rest is ids and numbers, so the API names no engine type
|
||||
(`ItemStack` stays in the engine).
|
||||
|
||||
### Lang files (`horizon_platform::lang`)
|
||||
|
||||
A crate keeps the text for its keys in its own folder, `crates/<name>/assets/lang/<code>.json`, with the same
|
||||
shape as the engine's `assets/horizon/lang` files (flat `"key": "text"` lines, `en_us.json` the reference).
|
||||
It registers them as `(code, JSON)` pairs, usually `include_str!`s:
|
||||
|
||||
```rust
|
||||
static LANG: &horizon_platform::lang::Files = &[
|
||||
("en_us", include_str!("../assets/lang/en_us.json")),
|
||||
("fr_fr", include_str!("../assets/lang/fr_fr.json")),
|
||||
];
|
||||
horizon_platform::lang::register(LANG)?;
|
||||
```
|
||||
|
||||
- The game loads them into its `horizon` namespace right after its own bundled file for the same language, so a
|
||||
resource pack still overrides them, and a language a crate lacks falls back to its English.
|
||||
- A key belongs to one folder. On a key the engine's file also has, the engine's text wins (and the
|
||||
`lang.rs` test fails).
|
||||
- Regional variants read their language's file as the engine's do (`fr_ca` reads `fr_fr`, `de_ch` reads `de_de`
|
||||
without `ß`).
|
||||
- `tools/lang_coverage.py` reports each crate's folder beside the engine's. A person's review of a crate's entry
|
||||
is kept in `tools/lang_review/<name>/<code>.json` ([translations.md](translations.md)).
|
||||
- With nothing registered, a language loads exactly as before.
|
||||
|
||||
## Extras that may leave the game
|
||||
|
||||
Horizon's extras are Horizon-layer code. Each one is a candidate to become an optional native mod that players add
|
||||
@@ -614,7 +585,7 @@ Horizon's own and third-party addresses (Horizon layer):
|
||||
| `cosmetics.playhorizon.dev` | cosmetics, emotes, presence | `horizon-addresses` (#12993) |
|
||||
| `telemetry.playhorizon.dev` | opt-in telemetry, the "I'm playing" ping, benchmark uploads | `horizon-addresses` (#12993) |
|
||||
| `stun.cloudflare.com`, `stun.l.google.com` | Play with Friends NAT discovery | `horizon-addresses` (#12993) |
|
||||
| `afri-network.co.za` | the withdrawn partner server, so the game can remove the entry it added | `horizon-addresses` |
|
||||
| `afri-network.co.za` | the withdrawn partner server, so the game can remove the entry it added | `multiplayer.rs` (`WITHDRAWN_PARTNER`) |
|
||||
| `horizon-rust.github.io/horizon-mods`, `github.com/horizon-rust/horizon-mods` | the mod registry index and files: a build of this repository alone asks the player for a registry | `mod_registry.rs`, `horizon-mods/src/index*.rs`, `cargo-horizon-mod` |
|
||||
| `api.modrinth.com`, `cdn.modrinth.com` | the in-game resource pack browser, modpack porting | `pack_browser.rs`, `horizon-port-pack` |
|
||||
| `github.com`, `raw.githubusercontent.com`, `gitlab.com`, `maven.neoforged.net` | modpack porting's sources | `horizon-port-pack` |
|
||||
|
||||
@@ -125,7 +125,7 @@ requires `verify` for everyone, and Ludovic's review for the files `.github/CODE
|
||||
and ships releases). Branches don't have to be up to date with main: main's own `checks` run catches the rare
|
||||
semantic conflict.
|
||||
|
||||
**Who merges.** The Horizon Team (GitHub team `horizon-team`: kacpherek, levzzz5154, rdbtCVS, Online5277, dezer-x) and
|
||||
**Who merges.** The Horizon Team (GitHub team `horizon-team`: kacpherek, levzzz5154, rdbtCVS, Online5277, dezer-x, itarqos5) and
|
||||
Ludovic. The orchestrator's agents prove only the risky pull requests (below), so a contributor's ordinary pull
|
||||
request doesn't wait for them.
|
||||
|
||||
@@ -462,7 +462,7 @@ commit's source to build them ([native-mods.md](native-mods.md)).
|
||||
### Trusted reviewers
|
||||
|
||||
One list, `.github/trusted-reviewers.txt`, governs two things for the people on it (kacpherek, rdbtCVS and
|
||||
Online5277 since 2026-10-27, levzzz5154 since 2026-10-28) and for Ludovic, with no repository role: `/nightly` (below) and triage by comment.
|
||||
Online5277 since 2026-10-27, levzzz5154 since 2026-10-28, itarqos5 since 2026-11-08) and for Ludovic, with no repository role: `/nightly` (below) and triage by comment.
|
||||
|
||||
```text
|
||||
/close closes an issue as completed; a pull request is closed without merging
|
||||
@@ -496,7 +496,7 @@ Online5277 since 2026-10-27, levzzz5154 since 2026-10-28) and for Ludovic, with
|
||||
### Trusted reviewers pick the Nightly's pull requests
|
||||
|
||||
The Nightly is where contributors test pull requests before the gate. The trusted reviewers listed in
|
||||
`.github/trusted-reviewers.txt` (kacpherek, rdbtCVS and Online5277 since 2026-10-27, levzzz5154 since 2026-10-28) and Ludovic pick them with a
|
||||
`.github/trusted-reviewers.txt` (kacpherek, rdbtCVS and Online5277 since 2026-10-27, levzzz5154 since 2026-10-28, itarqos5 since 2026-11-08) and Ludovic pick them with a
|
||||
comment on any issue or pull request (`.github/workflows/nightly-command.yml`, `tools/ci/nightly_set.py`):
|
||||
|
||||
```text
|
||||
|
||||
@@ -11,9 +11,8 @@ no difference: the mark lives in a file the game and the launcher never read.
|
||||
| | Files | Keys | Read by |
|
||||
|---|---|---|---|
|
||||
| Game | `crates/horizon-client/assets/horizon/lang/<code>.json` (`fr_fr`, `de_de`...) | `horizon.options.foo` style; `en_us.json` is the reference | `crates/horizon-client-core/src/lang.rs` (`Text::tr`, `lang::get`); a missing key falls back to English |
|
||||
| A crate's own text | `crates/<name>/assets/lang/<code>.json`, the same shape | the crate's own keys, never one of the engine's | the same, once the crate registers them ([layers.md](layers.md#lang-files-horizon_platformlang)) |
|
||||
| Launcher | `launcher/lang/<code>.json` (`fr`, `de`, `pt-br`...) | the English sentence itself | `launcher/src/i18n.rs` (`tr("…")`, `trf("… {arg} …")`) |
|
||||
| Review record | `tools/lang_review/game/<code>.json`, `tools/lang_review/<crate name>/<code>.json`, `tools/lang_review/launcher/<code>.json` | the same keys, with the text a person checked | `tools/lang_coverage.py` only |
|
||||
| Review record | `tools/lang_review/game/<code>.json`, `tools/lang_review/launcher/<code>.json` | the same keys, with the text a person checked | `tools/lang_coverage.py` only |
|
||||
|
||||
An entry is **reviewed** when the review record holds its key with exactly its present text. Anything else is
|
||||
unreviewed: an entry the record does not hold, and one whose text changed since a person checked it (so a later
|
||||
|
||||
@@ -26,9 +26,7 @@ impl View {
|
||||
}
|
||||
struct Settings { fov: f32 }
|
||||
struct StudioFreecam { active: bool, fov: f32 }
|
||||
struct Studio { freecam: StudioFreecam }
|
||||
impl Studio { fn flying(&self) -> bool { self.freecam.active } }
|
||||
struct Game { view: View, settings: Settings, override_fov: Option<f32>, studio: Studio }
|
||||
struct Game { view: View, settings: Settings, override_fov: Option<f32>, studio_freecam: StudioFreecam }
|
||||
impl Game {
|
||||
fn fov_effect_scale(&self) -> f32 { 1.0 }
|
||||
fn death_time(&self) -> Option<i32> { None }
|
||||
@@ -46,14 +44,14 @@ fn unchanged_narrow_wide_and_invalid() {
|
||||
(Some(1.0), 1.0), (Some(179.0), 179.0), (Some(0.0), 35.0), (Some(180.0), 35.0),
|
||||
(Some(-1.0), 35.0), (Some(f32::NAN), 35.0), (Some(f32::INFINITY), 35.0)] {
|
||||
let game = Game { view: View, settings: Settings { fov: 70.0 }, override_fov: value,
|
||||
studio: Studio { freecam: StudioFreecam { active: false, fov: 70.0 } } };
|
||||
studio_freecam: StudioFreecam { active: false, fov: 70.0 } };
|
||||
assert_eq!(game.fov_degrees(0.25, [1.0, 2.0, 3.0]), expected);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn studio_freecam_uses_its_fov() {
|
||||
let game = Game { view: View, settings: Settings { fov: 70.0 }, override_fov: None,
|
||||
studio: Studio { freecam: StudioFreecam { active: true, fov: 42.0 } } };
|
||||
studio_freecam: StudioFreecam { active: true, fov: 42.0 } };
|
||||
assert_eq!(game.fov_degrees(0.25, [1.0, 2.0, 3.0]), 42.0);
|
||||
}
|
||||
""".replace("METHOD", actual)
|
||||
|
||||
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user