mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge remote-tracking branch 'upstream/main' into HEAD
# Conflicts: # tools/port_map/rest-020.tsv
This commit is contained in:
commit
716ff3eaa1
191 files changed
+9901
-1916
No files matched your search
@@ -147,7 +147,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
|---|---|
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
|
||||
@@ -125,19 +125,51 @@ pub const RULES: &[Rule] = &[
|
||||
b("universal_anger", "mobs", false),
|
||||
b("water_source_conversion", "updates", true),
|
||||
];
|
||||
/// GameRules.MAX_MINECART_SPEED (26.3), available with minecart improvements.
|
||||
pub const MAX_MINECART_SPEED: Rule = i("max_minecart_speed", "misc", 8, 1, 1000);
|
||||
pub const MINECART_IMPROVEMENTS: &str = "minecraft:minecart_improvements";
|
||||
|
||||
pub fn available_rules(minecart_improvements: bool) -> impl Iterator<Item = &'static Rule> {
|
||||
RULES
|
||||
.iter()
|
||||
.chain(minecart_improvements.then_some(&MAX_MINECART_SPEED))
|
||||
}
|
||||
|
||||
pub fn rule(name: &str) -> Option<&'static Rule> {
|
||||
let name = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
RULES.iter().find(|r| r.name == name)
|
||||
available_rules(true).find(|r| r.name == name)
|
||||
}
|
||||
|
||||
/// Current values (defaults unless set).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct GameRules {
|
||||
values: BTreeMap<&'static str, Value>,
|
||||
minecart_improvements: bool,
|
||||
}
|
||||
impl GameRules {
|
||||
pub fn with_features(features: &[String]) -> Self {
|
||||
Self {
|
||||
minecart_improvements: features.iter().any(|f| f == MINECART_IMPROVEMENTS),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
/// GameRules.copy(enabledFeatures): keep available values, add defaults for
|
||||
/// newly enabled rules and discard rules absent from the target feature set.
|
||||
pub fn copy_with_features(&self, features: &[String]) -> Self {
|
||||
let mut rules = Self::with_features(features);
|
||||
for (&name, &value) in &self.values {
|
||||
rules.set(name, value);
|
||||
}
|
||||
rules
|
||||
}
|
||||
pub fn available_rules(&self) -> impl Iterator<Item = &'static Rule> + use<> {
|
||||
available_rules(self.minecart_improvements)
|
||||
}
|
||||
pub fn get(&self, name: &str) -> Option<Value> {
|
||||
let r = rule(name)?;
|
||||
if r.name == MAX_MINECART_SPEED.name && !self.minecart_improvements {
|
||||
return None;
|
||||
}
|
||||
Some(self.values.get(r.name).copied().unwrap_or(r.default))
|
||||
}
|
||||
pub fn bool(&self, name: &str) -> bool {
|
||||
@@ -160,7 +192,9 @@ impl GameRules {
|
||||
}
|
||||
/// Set a rule; false if the name or value type is wrong.
|
||||
pub fn set(&mut self, name: &str, value: Value) -> bool {
|
||||
let Some(r) = rule(name) else { return false };
|
||||
let Some(r) = rule(name).filter(|r| self.get(r.name).is_some()) else {
|
||||
return false;
|
||||
};
|
||||
let ok = match (r.default, value) {
|
||||
(Value::Bool(_), Value::Bool(_)) => true,
|
||||
(Value::Int(_), Value::Int(v)) => (r.min..=r.max).contains(&v),
|
||||
@@ -176,8 +210,7 @@ impl GameRules {
|
||||
/// Every rule's value by name without namespace, as `save::Level` holds
|
||||
/// them (booleans as 0 or 1).
|
||||
pub fn by_name(&self) -> BTreeMap<String, i32> {
|
||||
RULES
|
||||
.iter()
|
||||
self.available_rules()
|
||||
.map(|r| {
|
||||
let v = match self.values.get(r.name).copied().unwrap_or(r.default) {
|
||||
Value::Bool(b) => i32::from(b),
|
||||
@@ -189,8 +222,8 @@ impl GameRules {
|
||||
}
|
||||
/// GameRuleMap codec: `{data: {"minecraft:<rule>": value...}, DataVersion}`.
|
||||
pub fn to_nbt(&self) -> NamedTag {
|
||||
let data: Vec<(horizon_world::nbt::JavaString, Tag)> = RULES
|
||||
.iter()
|
||||
let data: Vec<(horizon_world::nbt::JavaString, Tag)> = self
|
||||
.available_rules()
|
||||
.map(|r| {
|
||||
let v = self.values.get(r.name).copied().unwrap_or(r.default);
|
||||
let tag = match v {
|
||||
@@ -212,11 +245,14 @@ impl GameRules {
|
||||
}
|
||||
}
|
||||
pub fn from_nbt(root: &Tag) -> Self {
|
||||
let mut rules = Self::default();
|
||||
Self::from_nbt_with_features(root, &[])
|
||||
}
|
||||
pub fn from_nbt_with_features(root: &Tag, features: &[String]) -> Self {
|
||||
let mut rules = Self::with_features(features);
|
||||
let Some(data) = root.get("data") else {
|
||||
return rules;
|
||||
};
|
||||
for r in RULES {
|
||||
for r in rules.available_rules() {
|
||||
let key = format!("minecraft:{}", r.name);
|
||||
let value = data.get(&key).or_else(|| data.get(r.name));
|
||||
match (r.default, value) {
|
||||
@@ -232,15 +268,18 @@ impl GameRules {
|
||||
rules
|
||||
}
|
||||
pub fn load(world: &Path) -> Self {
|
||||
Self::load_with_features(world, &[])
|
||||
}
|
||||
pub fn load_with_features(world: &Path, features: &[String]) -> Self {
|
||||
let Ok(bytes) = fs::read(world.join("data/minecraft/game_rules.dat")) else {
|
||||
return Self::default();
|
||||
return Self::with_features(features);
|
||||
};
|
||||
let Ok(raw) = Compression::Gzip.decode(&bytes, 16 << 20) else {
|
||||
return Self::default();
|
||||
return Self::with_features(features);
|
||||
};
|
||||
match nbt::read_named(&raw, Limits::default()) {
|
||||
Ok((root, _)) => Self::from_nbt(&root.tag),
|
||||
Err(_) => Self::default(),
|
||||
Ok((root, _)) => Self::from_nbt_with_features(&root.tag, features),
|
||||
Err(_) => Self::with_features(features),
|
||||
}
|
||||
}
|
||||
pub fn save(&self, world: &Path) -> crate::Result<()> {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,452 @@
|
||||
//! Finding a server's BlueMap without the player naming it
|
||||
//! (`blueMapAutoDetect`, off by default): the BlueMap worker tries a few
|
||||
//! likely web addresses (`candidates`), taking only one whose `settings.json`
|
||||
//! is BlueMap's web-app settings, and the answer is kept per server in
|
||||
//! `server-terrain/<server>/BlueMap/detected.txt` so a rejoin asks nothing.
|
||||
//! A `blueMap:` line the player wrote always wins.
|
||||
use crate::bluemap::Settings;
|
||||
use std::{
|
||||
net::IpAddr,
|
||||
path::{Path, PathBuf},
|
||||
sync::Mutex,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
/// The answer kept per server, beside its BlueMap tiles.
|
||||
pub const FILE: &str = "detected.txt";
|
||||
/// A server found without a map is asked again after this long.
|
||||
pub const RETRY_NONE: Duration = Duration::from_secs(24 * 3600);
|
||||
/// A map found is checked again (its address first) after this long.
|
||||
pub const RETRY_FOUND: Duration = Duration::from_secs(7 * 24 * 3600);
|
||||
/// Each probe gets this long, connecting included.
|
||||
pub const TIMEOUT: Duration = Duration::from_secs(4);
|
||||
/// Probes start this far apart at least.
|
||||
pub const GAP: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Where a level's BlueMap comes from.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
/// The player's `blueMap:` line, or a map found before.
|
||||
Url {
|
||||
url: String,
|
||||
maps: Vec<(String, String)>,
|
||||
},
|
||||
/// To be looked for at these addresses, in order.
|
||||
Detect(Vec<String>),
|
||||
}
|
||||
|
||||
/// What the server at `address` draws far terrain from, its BlueMap folder
|
||||
/// being `dir`: `None` when the player named no map and either auto-detect
|
||||
/// is off or the server was found without one recently.
|
||||
pub fn source(settings: &Settings, address: &str, dir: &Path, now: SystemTime) -> Option<Source> {
|
||||
if let Some(server) = settings.server(address) {
|
||||
return Some(Source::Url {
|
||||
url: server.url.clone(),
|
||||
maps: server.maps.clone(),
|
||||
});
|
||||
}
|
||||
if !settings.auto_detect {
|
||||
return None;
|
||||
}
|
||||
let mut probes = Vec::new();
|
||||
// Only an address this server's would be looked for at: a remembered
|
||||
// one from elsewhere (a copied folder) names someone else's.
|
||||
let ours = candidates(address);
|
||||
match remembered(dir, now) {
|
||||
Remembered::Found(url) | Remembered::Stale(Some(url)) if !ours.contains(&url) => {}
|
||||
Remembered::Found(url) => {
|
||||
return Some(Source::Url {
|
||||
url,
|
||||
maps: Vec::new(),
|
||||
});
|
||||
}
|
||||
Remembered::Nothing => return None,
|
||||
Remembered::Stale(Some(url)) => probes.push(url),
|
||||
Remembered::Stale(None) => {}
|
||||
}
|
||||
for candidate in ours {
|
||||
if !probes.contains(&candidate) {
|
||||
probes.push(candidate);
|
||||
}
|
||||
}
|
||||
(!probes.is_empty()).then_some(Source::Detect(probes))
|
||||
}
|
||||
|
||||
/// The first labels a game server's host name takes beside its site's.
|
||||
const GAME_LABELS: &[&str] = &["play", "mc", "minecraft", "mine", "join", "server"];
|
||||
|
||||
/// Domains whose names belong to many customers (dynamic DNS, server
|
||||
/// hosts): `map.` of them is someone else's.
|
||||
const SHARED: &[&str] = &[
|
||||
"aternos.me",
|
||||
"ddns.net",
|
||||
"duckdns.org",
|
||||
"hopto.org",
|
||||
"minehut.gg",
|
||||
"no-ip.org",
|
||||
"playit.gg",
|
||||
"serveminecraft.net",
|
||||
"zapto.org",
|
||||
];
|
||||
|
||||
/// The web addresses a server's BlueMap likely answers at: BlueMap's own
|
||||
/// web server (port 8100), the server's host, and its `map.` subdomain, also
|
||||
/// under the parent domain of a `play.`/`mc.` style host (`GAME_LABELS`).
|
||||
pub fn candidates(address: &str) -> Vec<String> {
|
||||
let address = address.trim().to_ascii_lowercase();
|
||||
let host = if let Some(rest) = address.strip_prefix('[') {
|
||||
rest.split(']').next().unwrap_or(rest)
|
||||
} else if address.matches(':').count() > 1 {
|
||||
&address[..]
|
||||
} else {
|
||||
address.split(':').next().unwrap_or(&address)
|
||||
};
|
||||
let host = host.trim_end_matches('.');
|
||||
if host.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
// Certificates are for names: plain HTTP only.
|
||||
let host = match ip {
|
||||
IpAddr::V6(_) => format!("[{host}]"),
|
||||
IpAddr::V4(_) => host.to_owned(),
|
||||
};
|
||||
out.push(format!("http://{host}:8100"));
|
||||
out.push(format!("http://{host}"));
|
||||
return out;
|
||||
}
|
||||
if !host
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
out.push(format!("http://{host}:8100"));
|
||||
let labels = host.split('.').count();
|
||||
let map = |host: &str| (!host.starts_with("map.")).then(|| format!("map.{host}"));
|
||||
if labels >= 2
|
||||
&& let Some(map) = map(host)
|
||||
{
|
||||
out.push(format!("https://{map}"));
|
||||
}
|
||||
out.push(format!("https://{host}"));
|
||||
if labels >= 2
|
||||
&& let Some(map) = map(host)
|
||||
{
|
||||
out.push(format!("http://{map}"));
|
||||
}
|
||||
out.push(format!("http://{host}"));
|
||||
// `play.example.net` → `map.example.net`, only for a game server's
|
||||
// usual names: the parent of `name.hosting.example` is another party's.
|
||||
if labels >= 3
|
||||
&& let Some((first, parent)) = host.split_once('.')
|
||||
&& GAME_LABELS.contains(&first)
|
||||
&& !SHARED.contains(&parent)
|
||||
&& let Some(map) = map(parent)
|
||||
{
|
||||
out.push(format!("https://{map}"));
|
||||
out.push(format!("http://{map}"));
|
||||
}
|
||||
let mut seen = Vec::new();
|
||||
out.retain(|url| {
|
||||
let new = !seen.contains(url);
|
||||
seen.push(url.clone());
|
||||
new
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `json` is a BlueMap web app's `settings.json`: a list of map ids
|
||||
/// and where their data lies, not any page a web server returns.
|
||||
pub fn is_web_app_settings(json: &[u8]) -> bool {
|
||||
let Ok(v) = serde_json::from_slice::<serde_json::Value>(json) else {
|
||||
return false;
|
||||
};
|
||||
let maps = v.get("maps").and_then(|m| m.as_array());
|
||||
maps.is_some_and(|maps| !maps.is_empty() && maps.iter().all(|m| m.is_string()))
|
||||
&& v.get("mapDataRoot").is_some_and(|r| r.is_string())
|
||||
}
|
||||
|
||||
/// Whether a BlueMap web app answers at `base`.
|
||||
pub fn probe(agent: &ureq::Agent, base: &str) -> bool {
|
||||
use std::io::Read;
|
||||
let Ok(response) = agent
|
||||
.get(&format!("{base}/settings.json"))
|
||||
.timeout(TIMEOUT)
|
||||
.call()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let mut body = Vec::new();
|
||||
response
|
||||
.into_reader()
|
||||
.take(256 << 10)
|
||||
.read_to_end(&mut body)
|
||||
.is_ok()
|
||||
&& is_web_app_settings(&body)
|
||||
}
|
||||
|
||||
/// What an earlier look found.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Remembered {
|
||||
Found(String),
|
||||
/// Nothing, recently.
|
||||
Nothing,
|
||||
/// Never looked, or long enough ago to look again (first at the map
|
||||
/// found then, if any).
|
||||
Stale(Option<String>),
|
||||
}
|
||||
|
||||
pub fn remembered(dir: &Path, now: SystemTime) -> Remembered {
|
||||
let Ok(text) = std::fs::read_to_string(dir.join(FILE)) else {
|
||||
return Remembered::Stale(None);
|
||||
};
|
||||
let mut words = text.split_whitespace();
|
||||
let (kind, at) = (words.next(), words.next().and_then(|t| t.parse().ok()));
|
||||
let Some(at) = at.map(|secs| SystemTime::UNIX_EPOCH + Duration::from_secs(secs)) else {
|
||||
return Remembered::Stale(None);
|
||||
};
|
||||
// A time ahead of the clock (a clock set wrong, a copied file) is stale.
|
||||
let Ok(age) = now.duration_since(at) else {
|
||||
return Remembered::Stale(None);
|
||||
};
|
||||
match (kind, words.next()) {
|
||||
(Some("found"), Some(url)) if age < RETRY_FOUND => Remembered::Found(url.to_owned()),
|
||||
(Some("found"), Some(url)) => Remembered::Stale(Some(url.to_owned())),
|
||||
(Some("none"), _) if age < RETRY_NONE => Remembered::Nothing,
|
||||
_ => Remembered::Stale(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps what a look found (`None`: no map).
|
||||
pub fn remember(dir: &Path, found: Option<&str>, now: SystemTime) {
|
||||
let secs = now
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
let text = match found {
|
||||
Some(url) => format!("found {secs} {url}\n"),
|
||||
None => format!("none {secs}\n"),
|
||||
};
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let _ = std::fs::write(dir.join(FILE), text);
|
||||
}
|
||||
|
||||
/// The servers being looked at now, by BlueMap folder: one look a server at
|
||||
/// a time, however many levels open meanwhile.
|
||||
static LOOKING: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
|
||||
|
||||
/// The right to look for `dir`'s server's map, until dropped.
|
||||
pub struct Claim(PathBuf);
|
||||
impl Claim {
|
||||
/// `None` while another look at the same server runs.
|
||||
pub fn take(dir: &Path) -> Option<Self> {
|
||||
let mut looking = LOOKING.lock().unwrap();
|
||||
if looking.iter().any(|d| d == dir) {
|
||||
return None;
|
||||
}
|
||||
looking.push(dir.to_owned());
|
||||
Some(Self(dir.to_owned()))
|
||||
}
|
||||
}
|
||||
impl Drop for Claim {
|
||||
fn drop(&mut self) {
|
||||
LOOKING.lock().unwrap().retain(|d| *d != self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn candidates_cover_the_web_server_host_and_map_subdomains() {
|
||||
assert_eq!(
|
||||
candidates("Play.Example.net:25566"),
|
||||
[
|
||||
"http://play.example.net:8100",
|
||||
"https://map.play.example.net",
|
||||
"https://play.example.net",
|
||||
"http://map.play.example.net",
|
||||
"http://play.example.net",
|
||||
"https://map.example.net",
|
||||
"http://map.example.net",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
candidates("example.net"),
|
||||
[
|
||||
"http://example.net:8100",
|
||||
"https://map.example.net",
|
||||
"https://example.net",
|
||||
"http://map.example.net",
|
||||
"http://example.net",
|
||||
]
|
||||
);
|
||||
// Already the map's host: no `map.map.`.
|
||||
assert_eq!(
|
||||
candidates("map.example.net"),
|
||||
[
|
||||
"http://map.example.net:8100",
|
||||
"https://map.example.net",
|
||||
"http://map.example.net",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
candidates("192.0.2.7:25565"),
|
||||
["http://192.0.2.7:8100", "http://192.0.2.7"]
|
||||
);
|
||||
assert_eq!(
|
||||
candidates("[2001:db8::7]:25565"),
|
||||
["http://[2001:db8::7]:8100", "http://[2001:db8::7]"]
|
||||
);
|
||||
assert_eq!(
|
||||
candidates("2001:db8::7"),
|
||||
["http://[2001:db8::7]:8100", "http://[2001:db8::7]"]
|
||||
);
|
||||
assert_eq!(
|
||||
candidates("localhost"),
|
||||
[
|
||||
"http://localhost:8100",
|
||||
"https://localhost",
|
||||
"http://localhost"
|
||||
]
|
||||
);
|
||||
// A hosted name's parent is the host's, not the server's.
|
||||
assert!(
|
||||
!candidates("play.ddns.net")
|
||||
.iter()
|
||||
.any(|url| url.ends_with("//map.ddns.net"))
|
||||
);
|
||||
assert!(
|
||||
!candidates("name.host.example")
|
||||
.iter()
|
||||
.any(|url| url.ends_with("//map.host.example"))
|
||||
);
|
||||
assert!(candidates("").is_empty());
|
||||
assert!(candidates("evil.example.net/path?x").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_blue_maps_settings_count() {
|
||||
let web_app = br#"{"version":"5.4","useCookies":true,"maps":["world","world_nether"],
|
||||
"mapDataRoot":"maps","liveDataRoot":"maps","scripts":[],"styles":[]}"#;
|
||||
assert!(is_web_app_settings(web_app));
|
||||
assert!(!is_web_app_settings(b"<!doctype html><html></html>"));
|
||||
assert!(
|
||||
!is_web_app_settings(br#"{"maps":["world"]}"#),
|
||||
"no data root"
|
||||
);
|
||||
assert!(!is_web_app_settings(br#"{"maps":[],"mapDataRoot":"maps"}"#));
|
||||
assert!(!is_web_app_settings(
|
||||
br#"{"maps":[1],"mapDataRoot":"maps"}"#
|
||||
));
|
||||
assert!(!is_web_app_settings(
|
||||
br#"{"maps":"world","mapDataRoot":"maps"}"#
|
||||
));
|
||||
assert!(!is_web_app_settings(
|
||||
br#"[{"maps":["world"],"mapDataRoot":"maps"}]"#
|
||||
));
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-bluemap-detect-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answers_are_kept_and_looked_at_again_later() {
|
||||
let dir = scratch("remember");
|
||||
let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
|
||||
let hour = Duration::from_secs(3600);
|
||||
assert_eq!(remembered(&dir, t0), Remembered::Stale(None));
|
||||
remember(&dir, None, t0);
|
||||
assert_eq!(remembered(&dir, t0 + hour), Remembered::Nothing);
|
||||
// Written ahead of the clock: not trusted.
|
||||
assert_eq!(remembered(&dir, t0 - hour), Remembered::Stale(None));
|
||||
assert_eq!(remembered(&dir, t0 + RETRY_NONE), Remembered::Stale(None));
|
||||
remember(&dir, Some("https://map.example.net"), t0);
|
||||
assert_eq!(
|
||||
remembered(&dir, t0 + 2 * RETRY_NONE),
|
||||
Remembered::Found("https://map.example.net".into())
|
||||
);
|
||||
assert_eq!(
|
||||
remembered(&dir, t0 + RETRY_FOUND),
|
||||
Remembered::Stale(Some("https://map.example.net".into()))
|
||||
);
|
||||
std::fs::write(dir.join(FILE), "garbage").unwrap();
|
||||
assert_eq!(remembered(&dir, t0), Remembered::Stale(None));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_players_map_wins_and_detection_waits_for_the_setting() {
|
||||
let dir = scratch("source");
|
||||
let now = SystemTime::now();
|
||||
let mut settings = Settings::default();
|
||||
let address = "play.example.net";
|
||||
assert_eq!(
|
||||
source(&settings, address, &dir, now),
|
||||
None,
|
||||
"off by default"
|
||||
);
|
||||
settings.auto_detect = true;
|
||||
let Some(Source::Detect(probes)) = source(&settings, address, &dir, now) else {
|
||||
panic!("looked for");
|
||||
};
|
||||
assert_eq!(probes, candidates(address));
|
||||
remember(&dir, None, now);
|
||||
assert_eq!(
|
||||
source(&settings, address, &dir, now),
|
||||
None,
|
||||
"none, recently"
|
||||
);
|
||||
remember(&dir, Some("https://map.example.net"), now);
|
||||
assert_eq!(
|
||||
source(&settings, address, &dir, now),
|
||||
Some(Source::Url {
|
||||
url: "https://map.example.net".into(),
|
||||
maps: Vec::new()
|
||||
})
|
||||
);
|
||||
// A remembered map this server's addresses don't include (a copied
|
||||
// folder's) is no map.
|
||||
remember(&dir, Some("https://tracker.example"), now);
|
||||
let Some(Source::Detect(probes)) = source(&settings, address, &dir, now) else {
|
||||
panic!("looked for, the remembered one ignored");
|
||||
};
|
||||
assert_eq!(probes, candidates(address));
|
||||
remember(&dir, Some("https://map.example.net"), now);
|
||||
// Past a week the found map is tried first, then the rest.
|
||||
let later = now + RETRY_FOUND;
|
||||
let Some(Source::Detect(probes)) = source(&settings, address, &dir, later) else {
|
||||
panic!("looked for again");
|
||||
};
|
||||
assert_eq!(probes[0], "https://map.example.net");
|
||||
assert_eq!(probes.len(), candidates(address).len());
|
||||
settings.servers.push(
|
||||
Settings::parse_server("play.example.net http://example.net:8123 nether=n").unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
source(&settings, address, &dir, now),
|
||||
Some(Source::Url {
|
||||
url: "http://example.net:8123".into(),
|
||||
maps: vec![("minecraft:the_nether".into(), "n".into())]
|
||||
})
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_look_a_server_at_a_time() {
|
||||
let dir = scratch("claim");
|
||||
let claim = Claim::take(&dir).unwrap();
|
||||
assert!(Claim::take(&dir).is_none());
|
||||
drop(claim);
|
||||
assert!(Claim::take(&dir).is_some());
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,11 @@ impl DataPack {
|
||||
}
|
||||
/// The packs in `<world>/datapacks`, sorted by name.
|
||||
pub fn discover(world: &Path) -> Vec<DataPack> {
|
||||
let Ok(entries) = std::fs::read_dir(world.join("datapacks")) else {
|
||||
discover_folder(&world.join("datapacks"))
|
||||
}
|
||||
/// Packs in Create World's temporary pack folder, before a world exists.
|
||||
pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut packs = Vec::new();
|
||||
|
||||
@@ -90,18 +90,23 @@ fn gpu_culling_requested(info: &wgpu::AdapterInfo, env: Option<&str>) -> bool {
|
||||
|
||||
/// Whether Translucent Batching is offered (`graphics::offer_translucent_batching`): where
|
||||
/// indirect multi-draws (`multi_draw`) take its batches, but not OpenGL's, which loop over
|
||||
/// their entries on the CPU (and without them every section is a call of its own anyway);
|
||||
/// nor where vanilla frames draw flat (`flat`'s `min_sections` below `usize::MAX`: Metal,
|
||||
/// Intel Gen11/Gen12's Vulkan driver, `HORIZON_FLAT_DRAWS=1`), as flat draws read
|
||||
/// translucent quads in the section arena.
|
||||
fn translucent_batchable(
|
||||
info: &wgpu::AdapterInfo,
|
||||
multi_draw: bool,
|
||||
flat_min_sections: Option<usize>,
|
||||
) -> bool {
|
||||
multi_draw
|
||||
&& info.backend != wgpu::Backend::Gl
|
||||
&& flat_min_sections.is_none_or(|min| min == usize::MAX)
|
||||
/// their entries on the CPU (and without them every section is a call of its own anyway).
|
||||
/// Frames drawn flat (Metal, Intel Gen11/Gen12's Vulkan driver) take it too: their
|
||||
/// translucent calls then come one per page of its arena rather than about one per section.
|
||||
fn translucent_batchable(info: &wgpu::AdapterInfo, multi_draw: bool) -> bool {
|
||||
multi_draw && info.backend != wgpu::Backend::Gl
|
||||
}
|
||||
|
||||
/// The vertices flat draws bind for a call's page key: a page of the section arena, or of
|
||||
/// the translucent arena up to its last range in use (`flat::TRANSLUCENT_KEYS`).
|
||||
fn flat_vertices<'a>(
|
||||
arena: &'a arena::Arena,
|
||||
translucent: Option<&'a arena::Arena>,
|
||||
) -> impl Fn(u32) -> wgpu::BufferSlice<'a> + Copy {
|
||||
move |key| match (translucent, key.checked_sub(flat::TRANSLUCENT_KEYS)) {
|
||||
(Some(translucent), Some(page)) => translucent.binding(page),
|
||||
_ => arena.buffer(key).slice(..),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether translucent quads have an arena of their own: with Translucent Batching
|
||||
@@ -498,8 +503,11 @@ fn move_off_page<'a>(
|
||||
arena.copy(encoder, (page, start), to, vertices);
|
||||
arena.release(page, start, vertices);
|
||||
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
|
||||
flat.remove(id);
|
||||
s.flat_id = Some(flat.add(device, encoder, uploads, to.0, to.1, vertices));
|
||||
let key = match part {
|
||||
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
|
||||
_ => to.0,
|
||||
};
|
||||
flat.place(device, encoder, uploads, id, key, to.1, vertices);
|
||||
}
|
||||
part.set(s, to);
|
||||
*budget = budget.saturating_sub(bytes);
|
||||
@@ -3707,7 +3715,7 @@ impl Renderer {
|
||||
flat_wanted,
|
||||
);
|
||||
// Sections as one index list per frame where multi-draw is emulated.
|
||||
let flat = flat_wanted.then(|| {
|
||||
let mut flat = flat_wanted.then(|| {
|
||||
let buffers = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
@@ -3743,11 +3751,7 @@ impl Renderer {
|
||||
.get_downlevel_capabilities()
|
||||
.flags
|
||||
.contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION);
|
||||
let batchable = translucent_batchable(
|
||||
&adapter.get_info(),
|
||||
multi_draw,
|
||||
flat.as_ref().map(|f| f.min_sections),
|
||||
);
|
||||
let batchable = translucent_batchable(&adapter.get_info(), multi_draw);
|
||||
crate::graphics::offer_translucent_batching(batchable);
|
||||
let translucent_batching = batchable && translucent_batching;
|
||||
// A new renderer runs no shader pack: the game loads the picked one on its
|
||||
@@ -3763,6 +3767,9 @@ impl Renderer {
|
||||
flat.is_some(),
|
||||
translucent_split(translucent_batching, flat.is_some(), shader_pack, false),
|
||||
);
|
||||
if let (Some(flat), Some(arena)) = (&mut flat, &translucent_arena) {
|
||||
flat.set_translucent_page_vertices(arena.page_vertices());
|
||||
}
|
||||
// Without indirect draws (OpenGL before 4.0 without indirect execution, or
|
||||
// before 4.2 + GL_ARB_shader_draw_parameters without indirect first instance)
|
||||
// even creating an indirect buffer fails; one draw per section reads
|
||||
@@ -4085,7 +4092,11 @@ impl Renderer {
|
||||
match flat {
|
||||
Some(flat) => {
|
||||
pass.set_pipeline(&flat.translucent_mark);
|
||||
flat.draw(pass, flat.translucent_calls(), |p| self.arena.buffer(p));
|
||||
flat.draw(
|
||||
pass,
|
||||
flat.translucent_calls(),
|
||||
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
None => {
|
||||
@@ -4478,17 +4489,23 @@ impl Renderer {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Flat draws name the blocks of its range in `arena`: split, its solid
|
||||
// quads alone (no frame draws flat then: `translucent_split`).
|
||||
let flat_id = self.flat.as_mut().filter(|_| main > 0).map(|f| {
|
||||
f.add(
|
||||
&self.device,
|
||||
encoder,
|
||||
&mut self.section_uploads,
|
||||
page,
|
||||
start,
|
||||
main,
|
||||
)
|
||||
// Flat draws name the blocks of its ranges by its id: its range in `arena` and,
|
||||
// split, its translucent quads' in `translucent_arena` (`flat::TRANSLUCENT_KEYS`).
|
||||
let translucent_key = (split && translucent > 0)
|
||||
.then(|| (translucent_at.0 + flat::TRANSLUCENT_KEYS, translucent_at.1));
|
||||
let flat_id = self.flat.as_mut().and_then(|f| {
|
||||
let ranges = [(main > 0).then_some((page, start, main))]
|
||||
.into_iter()
|
||||
.chain([translucent_key.map(|(key, first)| (key, first, translucent_vertices))]);
|
||||
let mut id = None;
|
||||
for (key, first, vertices) in ranges.flatten() {
|
||||
let uploads = &mut self.section_uploads;
|
||||
match id {
|
||||
None => id = Some(f.add(&self.device, encoder, uploads, key, first, vertices)),
|
||||
Some(id) => f.place(&self.device, encoder, uploads, id, key, first, vertices),
|
||||
}
|
||||
}
|
||||
id
|
||||
});
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let translucent_arena = self.translucent_arena.as_ref().unwrap_or(&self.arena);
|
||||
@@ -4721,7 +4738,13 @@ impl Renderer {
|
||||
_ => COMPACT_BUDGET,
|
||||
};
|
||||
let mut budget = left.min(cap);
|
||||
if !move_off_page(arena, encoder, None, meshes, page, part, &mut budget) {
|
||||
// Flat draws name translucent quads' blocks by their section's id; indices have none.
|
||||
let flat = match part {
|
||||
Part::Translucent => self.flat.as_mut(),
|
||||
_ => None,
|
||||
}
|
||||
.map(|f| (f, &self.device, &mut self.section_uploads));
|
||||
if !move_off_page(arena, encoder, flat, meshes, page, part, &mut budget) {
|
||||
// New ranges took the room: both pages stay, drawn, until a later try.
|
||||
arena.reopen();
|
||||
self.shrink_gate.wait(now, SHRINK_RETRY);
|
||||
@@ -4901,6 +4924,9 @@ impl Renderer {
|
||||
self.flat.is_some(),
|
||||
split,
|
||||
);
|
||||
if let (Some(flat), Some(arena)) = (&mut self.flat, &self.translucent_arena) {
|
||||
flat.set_translucent_page_vertices(arena.page_vertices());
|
||||
}
|
||||
}
|
||||
/// Whether every section and far tile was dropped since the last call, to be
|
||||
/// laid out again (`follow_translucent_layout`): the game meshes them again,
|
||||
@@ -5536,9 +5562,11 @@ impl Renderer {
|
||||
.map(pack_timing::PassTiming::medians)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// The macros of the GL extensions this device gives shader packs: subgroups, when it
|
||||
/// has them.
|
||||
/// The macros this renderer gives shader packs: the GL extensions this device has
|
||||
/// (subgroups, when it has them), and the vendor, renderer and mipmap levels
|
||||
/// (`Macros::define_renderer`).
|
||||
pub fn define_pack_macros(&self, macros: &mut horizon_shaders::Macros) {
|
||||
macros.define_renderer(self.mipmaps);
|
||||
if self.device.features().contains(wgpu::Features::SUBGROUP) {
|
||||
for ext in [
|
||||
"basic",
|
||||
@@ -5854,7 +5882,11 @@ impl Renderer {
|
||||
arena::free_later(self.graveyard.advance());
|
||||
self.shrink_translucent(left);
|
||||
if let Some(flat) = &mut self.flat {
|
||||
flat.trim(|page| self.arena.has_page(page));
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
flat.trim(|key| match key.checked_sub(flat::TRANSLUCENT_KEYS) {
|
||||
Some(page) => translucent.is_some_and(|a| a.has_page(page)),
|
||||
None => self.arena.has_page(key),
|
||||
});
|
||||
}
|
||||
if self.suspended {
|
||||
return Ok(false);
|
||||
@@ -7218,8 +7250,6 @@ impl Renderer {
|
||||
|| pack_terrain
|
||||
|| shadow_view.is_some()) =>
|
||||
{
|
||||
// They read translucent quads in the section arena (`translucent_split`).
|
||||
debug_assert!(self.translucent_arena.is_none(), "flat draws, split");
|
||||
let sections = &self.sections;
|
||||
let lods = &self.lods;
|
||||
let sorted = &self.sorted_indices;
|
||||
@@ -7236,6 +7266,7 @@ impl Renderer {
|
||||
[shadow_first, shadow_first + shadow_solid].map(|d| d as usize),
|
||||
[opaque_end, cutout_start],
|
||||
phase2,
|
||||
self.translucent_arena.is_some(),
|
||||
visible
|
||||
.iter()
|
||||
.chain(&shadow_visible)
|
||||
@@ -7480,9 +7511,11 @@ impl Renderer {
|
||||
{
|
||||
pass.set_pipeline(pipeline);
|
||||
let [first, translucent] = flat.shadow_calls;
|
||||
flat.draw(&mut pass, &flat.calls[first..translucent], |p| {
|
||||
self.arena.buffer(p)
|
||||
});
|
||||
flat.draw(
|
||||
&mut pass,
|
||||
&flat.calls[first..translucent],
|
||||
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
|
||||
);
|
||||
} else if !flat_frame {
|
||||
pass.set_pipeline(&shadow.solid);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
@@ -7503,9 +7536,11 @@ impl Renderer {
|
||||
{
|
||||
pass.set_pipeline(pipeline);
|
||||
let [_, translucent] = flat.shadow_calls;
|
||||
flat.draw(&mut pass, &flat.calls[translucent..], |p| {
|
||||
self.arena.buffer(p)
|
||||
});
|
||||
flat.draw(
|
||||
&mut pass,
|
||||
&flat.calls[translucent..],
|
||||
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
|
||||
);
|
||||
} else if !flat_frame {
|
||||
pass.set_pipeline(&shadow.translucent);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
@@ -7668,7 +7703,7 @@ impl Renderer {
|
||||
// is phase 1's.
|
||||
if flat_frame && let Some(flat) = &self.flat {
|
||||
pass.set_pipeline(&flat.solid_mirror);
|
||||
let vertices = |p| self.arena.buffer(p);
|
||||
let vertices = flat_vertices(&self.arena, self.translucent_arena.as_ref());
|
||||
flat.draw(&mut pass, &flat.calls[..flat.solid_calls], vertices);
|
||||
} else {
|
||||
pass.set_pipeline(&self.solid_mirror);
|
||||
@@ -7856,7 +7891,7 @@ impl Renderer {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
}
|
||||
Some(flat) => {
|
||||
let vertices = |p| self.arena.buffer(p);
|
||||
let vertices = flat_vertices(&self.arena, self.translucent_arena.as_ref());
|
||||
// Without a pack, on Metal, the opaque pass, then the cutout one
|
||||
// (terrain.wgsl's `opaque_quad`), each over phase 1's lists with
|
||||
// quads for it, and over all of phase 2's.
|
||||
@@ -8331,7 +8366,11 @@ impl Renderer {
|
||||
// Flat section draws take the flat pipelines.
|
||||
if let Some((flat, pipeline)) = flat.zip(oit.flat(stage)) {
|
||||
pass.set_pipeline(pipeline);
|
||||
flat.draw(pass, flat.translucent_calls(), |p| self.arena.buffer(p));
|
||||
flat.draw(
|
||||
pass,
|
||||
flat.translucent_calls(),
|
||||
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
pass.set_pipeline(oit.terrain(stage));
|
||||
@@ -8395,9 +8434,11 @@ impl Renderer {
|
||||
}
|
||||
Some(flat) => {
|
||||
pass.set_pipeline(pack_flat.map_or(&flat.translucent, |p| &p.1));
|
||||
flat.draw(&mut pass, flat.translucent_calls(), |p| {
|
||||
self.arena.buffer(p)
|
||||
});
|
||||
flat.draw(
|
||||
&mut pass,
|
||||
flat.translucent_calls(),
|
||||
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
self.mark_translucent_terrain(
|
||||
@@ -8578,10 +8619,16 @@ impl Renderer {
|
||||
}],
|
||||
});
|
||||
rp.set_bind_group(5, &indices, &[]);
|
||||
let vertices = |p| self.arena.buffer(p);
|
||||
let vertices =
|
||||
flat_vertices(&self.arena, self.translucent_arena.as_ref());
|
||||
flat.draw_geometry(rp, calls, vertices, page, instances);
|
||||
}
|
||||
None => flat.draw_with(rp, calls, |p| self.arena.buffer(p), page),
|
||||
None => flat.draw_with(
|
||||
rp,
|
||||
calls,
|
||||
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
|
||||
page,
|
||||
),
|
||||
}
|
||||
}
|
||||
// A geometry stage reads flat draws' indices; without them it draws nothing.
|
||||
@@ -9652,23 +9699,20 @@ mod tests {
|
||||
assert!(!super::gpu_culling_requested(&discrete, Some("0")));
|
||||
}
|
||||
|
||||
/// Translucent Batching is offered with indirect multi-draws that aren't OpenGL's,
|
||||
/// and only where frames without a shader pack never draw flat.
|
||||
/// Translucent Batching is offered with indirect multi-draws that aren't OpenGL's, flat
|
||||
/// frames (Metal, Intel Gen11/Gen12 on Vulkan) included.
|
||||
#[test]
|
||||
fn translucent_batching_is_offered_where_vanilla_frames_never_draw_flat() {
|
||||
fn translucent_batching_is_offered_with_indirect_multi_draws_except_on_opengl() {
|
||||
let vulkan = adapter(wgpu::DeviceType::DiscreteGpu);
|
||||
let mut metal = adapter(wgpu::DeviceType::IntegratedGpu);
|
||||
metal.backend = wgpu::Backend::Metal;
|
||||
let mut gl = adapter(wgpu::DeviceType::DiscreteGpu);
|
||||
gl.backend = wgpu::Backend::Gl;
|
||||
let batchable = super::translucent_batchable;
|
||||
// No flat draws, or flat draws for a shader pack's terrain alone.
|
||||
assert!(batchable(&vulkan, true, None));
|
||||
assert!(batchable(&vulkan, true, Some(usize::MAX)));
|
||||
// Vanilla frames drawn flat past MIN_SECTIONS (Metal), or all of them.
|
||||
assert!(!batchable(&vulkan, true, Some(super::flat::MIN_SECTIONS)));
|
||||
assert!(!batchable(&vulkan, true, Some(0)));
|
||||
assert!(!batchable(&vulkan, false, None));
|
||||
assert!(!batchable(&gl, true, None));
|
||||
assert!(!batchable(&gl, true, Some(usize::MAX)));
|
||||
assert!(batchable(&vulkan, true));
|
||||
assert!(batchable(&metal, true));
|
||||
assert!(!batchable(&vulkan, false));
|
||||
assert!(!batchable(&gl, true));
|
||||
}
|
||||
|
||||
/// The pack-flat rule: with Translucent Batching translucent quads have an arena of
|
||||
|
||||
@@ -157,21 +157,24 @@ impl Arena {
|
||||
Self::with_usage(device, 4, capacity, usage, LARGE_PAGE_BYTES)
|
||||
}
|
||||
/// The sorted-index arena and, when `split`, the translucent arena
|
||||
/// (vertices of `stride`), both with large pages, which no shader binds;
|
||||
/// otherwise translucent quads stay in the section arena, and the indices
|
||||
/// in pages flat draws (`flat`) bind as storage.
|
||||
/// (vertices of `stride`) with large pages, which no shader binds;
|
||||
/// otherwise translucent quads stay in the section arena. The indices go in
|
||||
/// large pages too, unless flat draws (`flat`) bind their pages as storage.
|
||||
pub fn translucent_arenas(
|
||||
device: &wgpu::Device,
|
||||
stride: u64,
|
||||
flat: bool,
|
||||
split: bool,
|
||||
) -> (Self, Option<Self>) {
|
||||
if split {
|
||||
let indices = Self::translucent_indices(device, 1 << 16);
|
||||
(indices, Some(Self::translucent(device, stride, 1 << 18)))
|
||||
let indices = if split && !flat {
|
||||
Self::translucent_indices(device, 1 << 16)
|
||||
} else {
|
||||
(Self::indices(device, 1 << 16, flat), None)
|
||||
}
|
||||
Self::indices(device, 1 << 16, flat)
|
||||
};
|
||||
(
|
||||
indices,
|
||||
split.then(|| Self::translucent(device, stride, 1 << 18)),
|
||||
)
|
||||
}
|
||||
fn with_usage(
|
||||
device: &wgpu::Device,
|
||||
@@ -927,6 +930,121 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat draws find a section's origin by the id its blocks name, in the section arena's
|
||||
/// tables and, split, in the translucent arena's (`flat::TRANSLUCENT_KEYS`). Translucent
|
||||
/// quads a shrink moves (`move_off_page`) keep naming their section, or flat draws would
|
||||
/// put them at another section's origin.
|
||||
#[test]
|
||||
fn moved_translucent_quads_keep_their_section_s_flat_id() {
|
||||
use super::super::{Part, SectionGpu, flat, move_off_page, upload::Uploads};
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
"@vertex fn vs() -> @builtin(position) vec4<f32> { return vec4<f32>(0.0); }
|
||||
@fragment fn fs() -> @location(0) vec4<f32> { return vec4<f32>(0.0); }"
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let terrain_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: None,
|
||||
entries: &[],
|
||||
});
|
||||
let pipeline = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str, _, _| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline);
|
||||
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
|
||||
let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN);
|
||||
translucent.page_limit = 4 * GRAIN;
|
||||
flat.set_translucent_page_vertices(translucent.page_vertices());
|
||||
let mut uploads = Uploads::new(&device);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
// Page 0: another section's grain, then this one's two; page 1: a grain taken.
|
||||
let (_, other) = alloc(&mut translucent, &device, &queue, GRAIN).unwrap();
|
||||
let (_, at) = alloc(&mut translucent, &device, &queue, 2 * GRAIN).unwrap();
|
||||
let (_, rest) = alloc(&mut translucent, &device, &queue, GRAIN).unwrap();
|
||||
assert_eq!(
|
||||
alloc(&mut translucent, &device, &queue, GRAIN),
|
||||
Some((1, 0))
|
||||
);
|
||||
translucent.release(0, rest, GRAIN);
|
||||
let key = |page: u32| page + flat::TRANSLUCENT_KEYS;
|
||||
let other_id = flat.add(&device, &mut encoder, &mut uploads, key(0), other, GRAIN);
|
||||
// Its solid quads in the section arena's page 0, its translucent ones here.
|
||||
let id = flat.add(&device, &mut encoder, &mut uploads, 0, 0, GRAIN);
|
||||
flat.place(
|
||||
&device,
|
||||
&mut encoder,
|
||||
&mut uploads,
|
||||
id,
|
||||
key(0),
|
||||
at,
|
||||
2 * GRAIN,
|
||||
);
|
||||
let mut section = SectionGpu {
|
||||
page: 0,
|
||||
start: 0,
|
||||
translucent_at: (0, at),
|
||||
solid: 64,
|
||||
sides: crate::mesher::Sides::unsorted(64),
|
||||
translucent: 128,
|
||||
sort: super::super::translucency::SortState::new(Vec::new()),
|
||||
sorted_indices: None,
|
||||
flat_id: Some(id),
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
};
|
||||
translucent.close(0);
|
||||
let mut budget = u64::MAX;
|
||||
assert!(move_off_page(
|
||||
&mut translucent,
|
||||
&mut encoder,
|
||||
Some((&mut flat, &device, &mut uploads)),
|
||||
std::iter::once(&mut section),
|
||||
0,
|
||||
Part::Translucent,
|
||||
&mut budget
|
||||
));
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
let (page, start) = section.translucent_at;
|
||||
assert_eq!(page, 1);
|
||||
assert_eq!(section.flat_id, Some(id));
|
||||
let named =
|
||||
|page: u32, grain: u64| read(&device, &queue, flat.blocks(key(page)), grain * 4);
|
||||
for grain in start / GRAIN..(start / GRAIN + 2) {
|
||||
assert_eq!(named(1, grain), id, "grain {grain} of its new range");
|
||||
}
|
||||
// The section arena's table and the other section's grain are untouched.
|
||||
assert_eq!(read(&device, &queue, flat.blocks(0), 0), id);
|
||||
assert_eq!(named(0, other / GRAIN), other_id);
|
||||
}
|
||||
|
||||
/// Draws bind a large page up to the end of its last range in use, a grain at
|
||||
/// least: wgpu clears what a binding covers and nothing wrote when the frame is
|
||||
/// submitted, so a grown page bound whole had its new tail cleared on the GPU.
|
||||
@@ -1028,9 +1146,9 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The translucent arena comes with the split layout alone, and only then
|
||||
/// are the index pages large. Otherwise, as while a shader pack's terrain
|
||||
/// draws flat (gfx's `translucent_split`), translucent quads keep to the
|
||||
/// section arena, and flat draws (`flat`) bind the index pages as storage.
|
||||
/// are the index pages large, unless flat draws (`flat`) bind them as
|
||||
/// storage. Otherwise, as while a shader pack's terrain draws flat (gfx's
|
||||
/// `translucent_split`), translucent quads keep to the section arena.
|
||||
#[test]
|
||||
fn translucent_arenas_follow_the_layout() {
|
||||
let devices = devices();
|
||||
@@ -1050,9 +1168,9 @@ mod tests {
|
||||
assert_eq!(translucent.is_some(), split, "{case}");
|
||||
let bytes = indices.page_vertices() * 4;
|
||||
let storage = indices.usage.contains(wgpu::BufferUsages::STORAGE);
|
||||
assert_eq!(storage, flat && !split, "{case}");
|
||||
assert_eq!(storage, flat, "{case}");
|
||||
assert!(!storage || bytes <= limits.max_storage_buffer_binding_size);
|
||||
assert_eq!(bytes > PAGE_BYTES, split && large, "{case}");
|
||||
assert_eq!(bytes > PAGE_BYTES, split && !flat && large, "{case}");
|
||||
if let Some(t) = translucent {
|
||||
assert_eq!(t.page_vertices() * 24 > PAGE_BYTES, large, "{case}");
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ struct Plan {
|
||||
}
|
||||
const QUADS: u32 = u32::MAX;
|
||||
const GROUP_LIMIT: u32 = 65535;
|
||||
/// The page keys of the translucent arena (`Renderer::translucent_arena`, Translucent
|
||||
/// Batching): its page plus this. The section arena's pages are their own keys, all far
|
||||
/// below it. A call names its key: its vertices come from that arena's page.
|
||||
pub const TRANSLUCENT_KEYS: u32 = 1 << 10;
|
||||
|
||||
struct PageTable {
|
||||
/// Section id of every block of the page.
|
||||
@@ -67,6 +71,7 @@ pub(super) const ALPHA_ONLY: wgpu::BlendState = wgpu::BlendState {
|
||||
/// A run of the frame's index list drawn from one arena page.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Call {
|
||||
/// The page's key (`TRANSLUCENT_KEYS`).
|
||||
pub page: u32,
|
||||
pub first: u32,
|
||||
pub count: u32,
|
||||
@@ -98,6 +103,9 @@ pub struct Flat {
|
||||
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
|
||||
pub(super) draw_layout: wgpu::BindGroupLayout,
|
||||
blocks_per_page: u64,
|
||||
/// Blocks of a translucent arena page (`TRANSLUCENT_KEYS`), whose pages are larger.
|
||||
translucent_blocks_per_page: u64,
|
||||
/// Block tables by page key.
|
||||
pages: Vec<Option<PageTable>>,
|
||||
free_ids: Vec<u32>,
|
||||
next_id: u32,
|
||||
@@ -218,7 +226,14 @@ fn buffer(
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size: size.max(16),
|
||||
usage: usage | wgpu::BufferUsages::COPY_DST,
|
||||
// Tests read the tables back.
|
||||
usage: usage
|
||||
| wgpu::BufferUsages::COPY_DST
|
||||
| if cfg!(test) {
|
||||
wgpu::BufferUsages::COPY_SRC
|
||||
} else {
|
||||
wgpu::BufferUsages::empty()
|
||||
},
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
@@ -242,6 +257,7 @@ fn plan(
|
||||
shadow: [usize; 2],
|
||||
passes: [usize; 2],
|
||||
align: u64,
|
||||
key: impl Fn(u32, Option<u32>) -> u32,
|
||||
calls: &mut Vec<Call>,
|
||||
ranges: &mut Vec<Range>,
|
||||
segments: &mut Vec<(Source, u64, u64)>,
|
||||
@@ -273,6 +289,7 @@ fn plan(
|
||||
}
|
||||
let starts =
|
||||
i == phase1_draws || i == solid_draws || shadow.contains(&i) || passes.contains(&i);
|
||||
let page = key(page, sorted);
|
||||
match calls.last_mut() {
|
||||
Some(last) if last.page == page && !starts => last.count += d[0],
|
||||
_ => calls.push(Call {
|
||||
@@ -451,6 +468,7 @@ impl Flat {
|
||||
phase2_group: None,
|
||||
draw_layout,
|
||||
blocks_per_page: page_vertices.div_ceil(GRAIN),
|
||||
translucent_blocks_per_page: page_vertices.div_ceil(GRAIN),
|
||||
pages: Vec::new(),
|
||||
free_ids: Vec::new(),
|
||||
next_id: 0,
|
||||
@@ -479,8 +497,8 @@ impl Flat {
|
||||
.max(1),
|
||||
}
|
||||
}
|
||||
/// A section uploaded to `vertices` vertices from `start` of `page`: its
|
||||
/// id, which its blocks now name (written through `uploads` in `encoder`).
|
||||
/// A section uploaded to `vertices` vertices from `start` of the page keyed `page`:
|
||||
/// its id, which its blocks now name (written through `uploads` in `encoder`).
|
||||
pub fn add(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
@@ -494,6 +512,28 @@ impl Flat {
|
||||
self.next_id += 1;
|
||||
self.next_id - 1
|
||||
});
|
||||
self.place(device, encoder, uploads, id, page, start, vertices);
|
||||
id
|
||||
}
|
||||
/// Makes the blocks of `vertices` vertices from `start` of the page keyed `page` name
|
||||
/// `id`: a section's other range (its translucent quads, in the translucent arena), or
|
||||
/// a range that moved.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn place(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
id: u32,
|
||||
page: u32,
|
||||
start: u64,
|
||||
vertices: u64,
|
||||
) {
|
||||
let blocks = if page >= TRANSLUCENT_KEYS {
|
||||
self.translucent_blocks_per_page
|
||||
} else {
|
||||
self.blocks_per_page
|
||||
};
|
||||
let page = page as usize;
|
||||
if self.pages.len() <= page {
|
||||
self.pages.resize_with(page + 1, || None);
|
||||
@@ -502,7 +542,7 @@ impl Flat {
|
||||
blocks: buffer(
|
||||
device,
|
||||
"Section ids by block",
|
||||
self.blocks_per_page * 4,
|
||||
blocks * 4,
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
),
|
||||
group: None,
|
||||
@@ -515,7 +555,19 @@ impl Flat {
|
||||
first * 4,
|
||||
bytemuck::cast_slice(&ids),
|
||||
);
|
||||
id
|
||||
}
|
||||
/// The block table of the page keyed `page`, for tests that read it back.
|
||||
#[cfg(test)]
|
||||
pub(super) fn blocks(&self, page: u32) -> &wgpu::Buffer {
|
||||
&self.pages[page as usize]
|
||||
.as_ref()
|
||||
.expect("a page table")
|
||||
.blocks
|
||||
}
|
||||
/// The most vertices a page of the translucent arena holds: its block tables' size.
|
||||
/// Tables made before keep theirs, so this goes with `clear`.
|
||||
pub fn set_translucent_page_vertices(&mut self, vertices: u64) {
|
||||
self.translucent_blocks_per_page = vertices.div_ceil(GRAIN);
|
||||
}
|
||||
pub fn remove(&mut self, id: u32) {
|
||||
self.free_ids.push(id);
|
||||
@@ -525,7 +577,7 @@ impl Flat {
|
||||
self.free_ids.clear();
|
||||
self.next_id = 0;
|
||||
}
|
||||
/// Forgets the tables of pages the arena released.
|
||||
/// Forgets the tables of pages the arenas released (`live`, by page key).
|
||||
pub fn trim(&mut self, live: impl Fn(u32) -> bool) {
|
||||
for (i, page) in self.pages.iter_mut().enumerate() {
|
||||
if page.is_some() && !live(i as u32) {
|
||||
@@ -556,6 +608,7 @@ impl Flat {
|
||||
shadow: [usize; 2],
|
||||
passes: [usize; 2],
|
||||
phase2: Option<&wgpu::Buffer>,
|
||||
translucent_arena: bool,
|
||||
origins: impl Iterator<Item = (u32, [f32; 4])>,
|
||||
sorted_page: impl Fn(u32) -> wgpu::Buffer,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
@@ -609,6 +662,11 @@ impl Flat {
|
||||
shadow,
|
||||
passes,
|
||||
self.range_align,
|
||||
// Translucent draws, and only they, have sorted indices.
|
||||
|page, sorted| match sorted {
|
||||
Some(_) if translucent_arena => page + TRANSLUCENT_KEYS,
|
||||
_ => page,
|
||||
},
|
||||
&mut self.calls,
|
||||
&mut self.range_data,
|
||||
&mut self.segments,
|
||||
@@ -801,7 +859,7 @@ impl Flat {
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
calls: &[Call],
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
|
||||
) {
|
||||
self.draw_with(pass, calls, vertices, |_, _| {});
|
||||
}
|
||||
@@ -811,7 +869,7 @@ impl Flat {
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
calls: &[Call],
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
|
||||
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
|
||||
) {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
@@ -828,7 +886,7 @@ impl Flat {
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
calls: &[Call],
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
|
||||
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
|
||||
instances: u32,
|
||||
) {
|
||||
@@ -842,7 +900,7 @@ impl Flat {
|
||||
pub fn draw_phase2<'a>(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
|
||||
) {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for (k, call) in self.calls[self.phase1_calls..self.solid_calls]
|
||||
@@ -857,13 +915,13 @@ impl Flat {
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
page: u32,
|
||||
vertices: &impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
vertices: &impl Fn(u32) -> wgpu::BufferSlice<'a>,
|
||||
) {
|
||||
let table = self.pages[page as usize]
|
||||
.as_ref()
|
||||
.and_then(|t| t.group.as_ref())
|
||||
.expect("prepared page");
|
||||
pass.set_vertex_buffer(0, vertices(page).slice(..));
|
||||
pass.set_vertex_buffer(0, vertices(page));
|
||||
pass.set_bind_group(1, table, &[]);
|
||||
}
|
||||
}
|
||||
@@ -912,6 +970,7 @@ mod tests {
|
||||
[draws.len(); 2],
|
||||
[draws.len(), 0],
|
||||
4,
|
||||
|page, _| page,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
@@ -960,6 +1019,7 @@ mod tests {
|
||||
[draws.len(); 2],
|
||||
[3, 1],
|
||||
4,
|
||||
|page, _| page,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
@@ -995,6 +1055,7 @@ mod tests {
|
||||
[camera, camera + 2],
|
||||
[camera, 0],
|
||||
4,
|
||||
|page, _| page,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
@@ -1023,6 +1084,7 @@ mod tests {
|
||||
[draws.len(); 2],
|
||||
[draws.len(), 0],
|
||||
4,
|
||||
|page, _| page,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
@@ -1140,6 +1202,7 @@ mod tests {
|
||||
[draws.len(); 2],
|
||||
[draws.len(), 0],
|
||||
align,
|
||||
|page, _| page,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
|
||||
@@ -7,13 +7,19 @@ use crate::{
|
||||
mesher::SectionMesh,
|
||||
};
|
||||
use glam::{DVec3, Vec3};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// Finished tile meshes not uploaded yet, the tiles they replace (removed
|
||||
/// once every mesh is in) and the columns they leave out.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Pending {
|
||||
meshes: FxHashMap<TileKey, SectionMesh>,
|
||||
/// The order they go up in: as handed over (`Lod::update`: finer tiles
|
||||
/// first, so no tile clipped around a finer one is drawn before it).
|
||||
order: VecDeque<TileKey>,
|
||||
removed: FxHashSet<TileKey>,
|
||||
cutout: Option<FxHashSet<(i32, i32)>>,
|
||||
}
|
||||
@@ -30,7 +36,9 @@ impl Pending {
|
||||
) {
|
||||
for (key, mesh) in meshes {
|
||||
self.removed.remove(&key);
|
||||
self.meshes.insert(key, mesh);
|
||||
if self.meshes.insert(key, mesh).is_none() {
|
||||
self.order.push_back(key);
|
||||
}
|
||||
}
|
||||
for key in removed {
|
||||
self.meshes.remove(&key);
|
||||
@@ -330,7 +338,10 @@ impl Renderer {
|
||||
.copied()
|
||||
.collect();
|
||||
while let Some(pending) = &mut self.lod_pending {
|
||||
let Some(&key) = pending.meshes.keys().next() else {
|
||||
// Keys meshed again keep their place; removed ones are skipped.
|
||||
let next = std::iter::from_fn(|| pending.order.pop_front())
|
||||
.find(|key| pending.meshes.contains_key(key));
|
||||
let Some(key) = next else {
|
||||
let pending = self.lod_pending.take().expect("pending tiles");
|
||||
// A tile fades in over the ones it replaces. One over nothing
|
||||
// shows whole: its hidden chunks would open holes to the sky
|
||||
@@ -498,7 +509,16 @@ mod tests {
|
||||
let mut removed: Vec<_> = pending.removed.iter().map(|k| k.x).collect();
|
||||
removed.sort_unstable();
|
||||
assert_eq!((meshes, removed), (vec![1, 3], vec![2, 4]));
|
||||
assert!(pending.cutout.is_some_and(|c| c.contains(&(0, 0))));
|
||||
assert!(pending.cutout.as_ref().is_some_and(|c| c.contains(&(0, 0))));
|
||||
// They go up in the order handed over, each once.
|
||||
pending.add(vec![(key(1), SectionMesh::default())], vec![], None);
|
||||
let order: Vec<_> = pending
|
||||
.order
|
||||
.iter()
|
||||
.filter(|k| pending.meshes.contains_key(k))
|
||||
.map(|k| k.x)
|
||||
.collect();
|
||||
assert_eq!(order, [1, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -544,7 +544,7 @@ pub struct Graphics {
|
||||
/// How finely far terrain is drawn; Full unless the player picks Reduced.
|
||||
pub far_detail: FarDetail,
|
||||
/// Far terrain from servers' BlueMap web maps; no server has one unless
|
||||
/// the player names it (`blueMap:` lines).
|
||||
/// the player names it (`blueMap:` lines) or turns on auto-detect.
|
||||
pub blue_map: crate::bluemap::Settings,
|
||||
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
|
||||
pub cave_culling: bool,
|
||||
@@ -737,6 +737,7 @@ impl Graphics {
|
||||
g.blue_map.radius = radius;
|
||||
}
|
||||
}
|
||||
"blueMapAutoDetect" => g.blue_map.auto_detect = flag(g.blue_map.auto_detect),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
|
||||
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
|
||||
@@ -1010,6 +1011,7 @@ mod tests {
|
||||
far_detail: FarDetail::Reduced,
|
||||
blue_map: crate::bluemap::Settings {
|
||||
radius: 40,
|
||||
auto_detect: true,
|
||||
// In their own file (`bluemap::SERVERS_FILE`).
|
||||
servers: Vec::new(),
|
||||
},
|
||||
|
||||
@@ -28,6 +28,8 @@ pub enum H {
|
||||
Preset,
|
||||
LodDistance,
|
||||
FarDetail,
|
||||
BlueMapAutoDetect,
|
||||
BlueMapRadius,
|
||||
CaveCulling,
|
||||
EntityCulling,
|
||||
LeafCulling,
|
||||
@@ -190,6 +192,16 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
render,
|
||||
lod,
|
||||
far,
|
||||
h(
|
||||
H::BlueMapAutoDetect,
|
||||
None,
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.",
|
||||
),
|
||||
h(
|
||||
H::BlueMapRadius,
|
||||
None,
|
||||
"How far around you BlueMap tiles are downloaded. Next join.",
|
||||
),
|
||||
v(
|
||||
Opt::SimulationDistance,
|
||||
Medium,
|
||||
@@ -430,6 +442,8 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
rows
|
||||
}
|
||||
|
||||
/// BlueMap download radii, in chunks.
|
||||
const BLUE_MAP_RADII: [u32; 5] = [16, 32, 64, 128, 256];
|
||||
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
|
||||
/// What the LOD Distance slider stops at, in chunks: off, then each doubling
|
||||
/// up to the whole world.
|
||||
@@ -596,6 +610,11 @@ impl H {
|
||||
n => labelled("LOD Distance", lang::tr("%s chunks", &[&n])),
|
||||
},
|
||||
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
|
||||
H::BlueMapAutoDetect => labelled("Find BlueMap", on_off(g.blue_map.auto_detect)),
|
||||
H::BlueMapRadius => labelled(
|
||||
"BlueMap Radius",
|
||||
lang::tr("%s chunks", &[&g.blue_map.radius_chunks()]),
|
||||
),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
|
||||
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
|
||||
@@ -743,6 +762,10 @@ impl H {
|
||||
H::FarDetail => {
|
||||
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
|
||||
}
|
||||
H::BlueMapAutoDetect => g.blue_map.auto_detect = !g.blue_map.auto_detect,
|
||||
H::BlueMapRadius => {
|
||||
g.blue_map.radius = step(&BLUE_MAP_RADII, g.blue_map.radius_chunks(), back)
|
||||
}
|
||||
H::CaveCulling => g.cave_culling = !g.cave_culling,
|
||||
H::EntityCulling => {
|
||||
g.entity_culling = step(&EntityCullingMode::ALL, g.entity_culling, back)
|
||||
@@ -855,6 +878,7 @@ impl Item {
|
||||
pub fn switch(self, s: &Settings) -> Option<bool> {
|
||||
let g = &s.horizon;
|
||||
Some(match self {
|
||||
Item::Horizon(H::BlueMapAutoDetect) => g.blue_map.auto_detect,
|
||||
Item::Horizon(H::CaveCulling) => g.cave_culling,
|
||||
Item::Horizon(H::CacheShulkerBoxes) => g.cache_shulker_boxes,
|
||||
Item::Horizon(H::CacheSigns) => g.cache_signs,
|
||||
@@ -926,6 +950,8 @@ impl Item {
|
||||
self,
|
||||
Item::Horizon(
|
||||
H::Preset
|
||||
| H::BlueMapAutoDetect
|
||||
| H::BlueMapRadius
|
||||
| H::Overlay
|
||||
| H::ScreenshotQuality
|
||||
| H::Pacing
|
||||
@@ -1896,6 +1922,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blue_map_settings_are_advanced_rendering_options() {
|
||||
let mut s = Settings::default();
|
||||
let find = Item::Horizon(H::BlueMapAutoDetect);
|
||||
let radius = Item::Horizon(H::BlueMapRadius);
|
||||
let advanced: Vec<_> = rows(Level::Advanced)
|
||||
.into_iter()
|
||||
.filter_map(|row| match row {
|
||||
Row::Setting(setting) => Some(setting.item),
|
||||
Row::Header(_) => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(advanced.contains(&find) && advanced.contains(&radius));
|
||||
assert_eq!(find.switch(&s), Some(false), "off by default");
|
||||
assert_eq!(radius.label(&s).plain(), "BlueMap Radius: 64 chunks");
|
||||
find.cycle(&mut s, false, [854, 480]);
|
||||
assert!(s.horizon.blue_map.auto_detect);
|
||||
radius.cycle(&mut s, false, [854, 480]);
|
||||
assert_eq!(s.horizon.blue_map.radius, 128);
|
||||
radius.cycle(&mut s, true, [854, 480]);
|
||||
radius.cycle(&mut s, true, [854, 480]);
|
||||
assert_eq!(radius.label(&s).plain(), "BlueMap Radius: 32 chunks");
|
||||
assert_eq!(s.horizon.preset, Preset::Custom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_set_vanilla_and_horizon_options() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -132,7 +132,7 @@ pub const PACK_ICON_SIZE: u32 = 128;
|
||||
/// size of the largest trim texture.
|
||||
pub const TRIM_SLOTS: usize = 32;
|
||||
pub fn decode(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
decode_bytes(&fs::read(path)?)
|
||||
decode_bytes(&horizon_core::prepared_files::read(path)?)
|
||||
}
|
||||
/// A PNG in memory as RGBA pixels.
|
||||
pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
|
||||
@@ -184,9 +184,10 @@ fn within_budget(files: &[&PathBuf], budget: u64) -> HashSet<PathBuf> {
|
||||
}
|
||||
/// A PNG's width and height, from its header alone.
|
||||
fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
use std::io::Read;
|
||||
let mut header = [0u8; 24];
|
||||
fs::File::open(path).ok()?.read_exact(&mut header).ok()?;
|
||||
let header: [u8; 24] = horizon_core::prepared_files::read_head(path, 24)
|
||||
.ok()?
|
||||
.try_into()
|
||||
.ok()?;
|
||||
let field = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
|
||||
(header[..8] == *b"\x89PNG\r\n\x1a\n" && header[12..16] == *b"IHDR")
|
||||
.then(|| (field(16), field(20)))
|
||||
@@ -365,7 +366,8 @@ fn cit_sprite(root: &Path, id: &str) -> Option<(PackedImage, Option<TextureFlags
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
|
||||
let mcmeta =
|
||||
horizon_core::prepared_files::read_to_string(&path.with_extension("png.mcmeta")).ok();
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
// Only an animation is cut to its first frame: a still CIT texture is drawn whole, and
|
||||
// armour layers are twice as wide as they are tall.
|
||||
@@ -463,7 +465,10 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
|
||||
// Animated strips keep their first frame and animate,
|
||||
// like the packed files.
|
||||
if path.starts_with("block/") || path.starts_with("item/") {
|
||||
let mcmeta = fs::read_to_string(file.with_extension("png.mcmeta")).ok();
|
||||
let mcmeta = horizon_core::prepared_files::read_to_string(
|
||||
&file.with_extension("png.mcmeta"),
|
||||
)
|
||||
.ok();
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
animated = animation.is_some();
|
||||
first_frame(image, animation.as_ref())
|
||||
@@ -960,7 +965,9 @@ impl HudAtlas {
|
||||
let image = decode(path)
|
||||
.map_err(|e| log::warn!("skipped GUI texture {}: {e}", path.display()))
|
||||
.ok()?;
|
||||
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
|
||||
let mcmeta =
|
||||
horizon_core::prepared_files::read_to_string(&path.with_extension("png.mcmeta"))
|
||||
.ok();
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
// An animated strip keeps its first frame.
|
||||
let frames = name.starts_with("block/") || name.starts_with("item/");
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod block_entity_anim;
|
||||
pub mod block_entity_cem;
|
||||
pub mod block_entity_render;
|
||||
pub mod bluemap;
|
||||
pub mod bluemap_detect;
|
||||
pub mod border_render;
|
||||
pub mod breaking;
|
||||
pub mod brewing;
|
||||
|
||||
@@ -25,6 +25,9 @@ const MAX_SAVED: usize = 1024;
|
||||
/// How many resident chunks may wait for the worker's summary at once
|
||||
/// (`Lod::observing`).
|
||||
pub const OBSERVE_AHEAD: usize = 8;
|
||||
/// How long a tile whose sample waited for data (`Lod::finish_waited`) waits
|
||||
/// before it is sampled again without word of it.
|
||||
const WAIT_RETRY: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const SAMPLE_JOBS: usize = 4;
|
||||
/// Sample jobs queued per sampling thread once the near terrain is in.
|
||||
/// Results are taken and jobs started once a frame: with four in flight, a
|
||||
@@ -389,7 +392,7 @@ impl Column {
|
||||
}
|
||||
}
|
||||
/// A chunk's 256 columns (`Column::from_chunk`), on the far-terrain worker.
|
||||
fn summarize(chunk: &Chunk, kinds: &[u8]) -> Arc<[Column; 256]> {
|
||||
pub(crate) fn summarize(chunk: &Chunk, kinds: &[u8]) -> Arc<[Column; 256]> {
|
||||
let water = water_source(kinds);
|
||||
Arc::new(std::array::from_fn(|i| {
|
||||
Column::from_chunk(chunk, kinds, water, (i & 15) as i32, (i >> 4) as i32)
|
||||
@@ -414,7 +417,7 @@ const AIR: u8 = 4;
|
||||
const WATERY: u8 = 8;
|
||||
/// What `Column::from_chunk` needs of each block state, read from the facts
|
||||
/// once: matching their names for every block made observing a chunk slow.
|
||||
fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|
||||
pub(crate) fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|
||||
(0..facts.states.len())
|
||||
.map(|i| {
|
||||
let state = BlockStateId(i as u32);
|
||||
@@ -479,6 +482,41 @@ fn forget_detail(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The tiles over `chunk` to sample again once their source keeps it
|
||||
/// (`Lod::resample_over`): those whose sample is still being taken, maybe
|
||||
/// from before, and those of cells wider than a chunk sampled empty there,
|
||||
/// which a chunk's own detail can't fill (`apply_detail`). The others hold
|
||||
/// it once `finish_detail` applies it.
|
||||
fn resampled_over(
|
||||
ready: &FxHashMap<TileKey, Arc<Vec<Column>>>,
|
||||
pending: &FxHashSet<TileKey>,
|
||||
(cx, cz): (i32, i32),
|
||||
) -> Vec<TileKey> {
|
||||
(1..=MAX_LEVEL)
|
||||
.filter_map(|level| {
|
||||
let size = 16 << level;
|
||||
let key = TileKey {
|
||||
x: (cx * 16).div_euclid(size),
|
||||
z: (cz * 16).div_euclid(size),
|
||||
level,
|
||||
};
|
||||
let resample = match ready.get(&key) {
|
||||
None => pending.contains(&key),
|
||||
Some(samples) => {
|
||||
let stride = size / CELLS as i32;
|
||||
let [ox, _, oz] = key.origin();
|
||||
let cell = (cz * 16 - oz) / stride * CELLS as i32 + (cx * 16 - ox) / stride;
|
||||
stride > 16
|
||||
&& (pending.contains(&key)
|
||||
|| samples
|
||||
.get(cell as usize)
|
||||
.is_some_and(|c| *c == Column::default()))
|
||||
}
|
||||
};
|
||||
resample.then_some(key)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct TileKey {
|
||||
pub x: i32,
|
||||
@@ -518,6 +556,18 @@ impl TileKey {
|
||||
let size = self.size();
|
||||
ox < x1 && oz < z1 && ox + size > x0 && oz + size > z0
|
||||
}
|
||||
/// Whether new data for `area` drawn in cells `widths` wide changes the
|
||||
/// tile's samples: its cells are that wide, and it covers `area` or the
|
||||
/// blocks around it a cell's depth reads (`bluemap::SKIRT`: a block
|
||||
/// around a chunk, the next cells' middles past one).
|
||||
fn refreshed_by(self, area: [i32; 4], widths: (i32, i32)) -> bool {
|
||||
let cell = self.size() / CELLS as i32;
|
||||
(widths.0..=widths.1).contains(&cell) && self.overlaps(Self::around(area, cell))
|
||||
}
|
||||
fn around([x0, z0, x1, z1]: [i32; 4], cell: i32) -> [i32; 4] {
|
||||
let m = if cell <= 16 { 1 } else { cell };
|
||||
[x0 - m, z0 - m, x1 + m, z1 + m]
|
||||
}
|
||||
fn distance(self, camera: [i32; 2]) -> i32 {
|
||||
let [x, _, z] = self.origin();
|
||||
(x - camera[0])
|
||||
@@ -870,6 +920,8 @@ enum Done {
|
||||
Sample {
|
||||
key: TileKey,
|
||||
samples: Vec<Column>,
|
||||
/// Some of it is still on its way (`LodSampler::waited`).
|
||||
waited: bool,
|
||||
},
|
||||
Mesh {
|
||||
key: TileKey,
|
||||
@@ -987,6 +1039,8 @@ pub struct Lod {
|
||||
/// Sampled tiles whose source has more since (`refresh`): sampled again,
|
||||
/// their old samples drawn meanwhile.
|
||||
stale: FxHashSet<TileKey>,
|
||||
/// Stale tiles `refresh`ed again while sampled: that sample may be older.
|
||||
restale: FxHashSet<TileKey>,
|
||||
active: FxHashSet<TileKey>,
|
||||
/// Tiles needing a mesh, with the revision their job must carry.
|
||||
dirty: FxHashMap<TileKey, u64>,
|
||||
@@ -1038,6 +1092,9 @@ pub struct Lod {
|
||||
saved: Saved,
|
||||
reduced: Reduced,
|
||||
saved_order: VecDeque<(i32, i32)>,
|
||||
/// `LodSource::keeps_observed`: tiles sampled without an observed chunk
|
||||
/// are sampled again (`resample_over`).
|
||||
keeps_observed: bool,
|
||||
}
|
||||
impl Lod {
|
||||
pub fn new(source: Arc<dyn LodSource>, context: Arc<WorkerContext>) -> Self {
|
||||
@@ -1054,6 +1111,9 @@ impl Lod {
|
||||
context: Option<Arc<WorkerContext>>,
|
||||
workers: bool,
|
||||
) -> Self {
|
||||
let keeps_observed = source
|
||||
.as_ref()
|
||||
.is_some_and(|source| source.keeps_observed());
|
||||
let cores = thread::available_parallelism().map_or(1, |n| n.get());
|
||||
let shared = Arc::new(Shared {
|
||||
jobs: Mutex::new(VecDeque::new()),
|
||||
@@ -1216,7 +1276,15 @@ impl Lod {
|
||||
if shared.stop.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if tx.send(Done::Sample { key, samples }).is_err() {
|
||||
let waited = sample.waited();
|
||||
if tx
|
||||
.send(Done::Sample {
|
||||
key,
|
||||
samples,
|
||||
waited,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1291,7 +1359,12 @@ impl Lod {
|
||||
);
|
||||
let samples =
|
||||
sampler.grid(x, z, key.size() / CELLS as i32, CELLS);
|
||||
Done::Sample { key, samples }
|
||||
let waited = sampler.waited();
|
||||
Done::Sample {
|
||||
key,
|
||||
samples,
|
||||
waited,
|
||||
}
|
||||
}
|
||||
};
|
||||
if tx.send(done).is_err() {
|
||||
@@ -1346,6 +1419,7 @@ impl Lod {
|
||||
unmeshed: FxHashSet::default(),
|
||||
removed_pending: FxHashSet::default(),
|
||||
stale: FxHashSet::default(),
|
||||
restale: FxHashSet::default(),
|
||||
removed: Vec::new(),
|
||||
covered: FxHashSet::default(),
|
||||
covered_bounds: None,
|
||||
@@ -1360,6 +1434,7 @@ impl Lod {
|
||||
saved: FxHashMap::default(),
|
||||
reduced: FxHashMap::default(),
|
||||
saved_order: VecDeque::new(),
|
||||
keeps_observed,
|
||||
}
|
||||
}
|
||||
/// The solid ground at block column (x, z): its top face's height and
|
||||
@@ -1670,9 +1745,12 @@ impl Lod {
|
||||
}
|
||||
}
|
||||
/// The source has new data for the blocks `[x0, z0, x1, z1)` (a map tile
|
||||
/// downloaded since): the tiles over them are sampled again, and their
|
||||
/// chunks' detail asked again, but for the chunks seen in the world.
|
||||
pub fn refresh(&mut self, area: [i32; 4]) {
|
||||
/// downloaded since), drawn in cells `widths` `(min, max)` blocks wide:
|
||||
/// the tiles of those cells over them or beside (`refreshed_by`) are
|
||||
/// sampled again, and their chunks' detail asked again, but for the
|
||||
/// chunks seen in the world. A tile whose new samples are the old ones
|
||||
/// isn't meshed again.
|
||||
pub fn refresh(&mut self, area: [i32; 4], widths: (i32, i32)) {
|
||||
self.refreshes += 1;
|
||||
if self.refreshed.len() >= MAX_REFRESHED {
|
||||
self.refreshed.pop_front();
|
||||
@@ -1690,13 +1768,27 @@ impl Lod {
|
||||
z1.saturating_add(m),
|
||||
],
|
||||
));
|
||||
for &key in self.ready.keys() {
|
||||
if !key.overlaps(area) || (key.level == 0 && self.covered.contains(&(key.x, key.z))) {
|
||||
continue;
|
||||
}
|
||||
self.stale.insert(key);
|
||||
// Tiles waiting out a retry (`finish_waited`) are asked again now.
|
||||
let retrying = self.retry.len();
|
||||
self.retry.retain(|key, _| !key.refreshed_by(area, widths));
|
||||
if self.retry.len() != retrying {
|
||||
self.wanted_stale = true;
|
||||
}
|
||||
// A first sample in flight read the old data too.
|
||||
let keys: Vec<_> = self
|
||||
.ready
|
||||
.keys()
|
||||
.chain(self.pending.iter())
|
||||
.copied()
|
||||
.filter(|key| {
|
||||
key.refreshed_by(area, widths)
|
||||
&& !(key.level == 0 && self.covered.contains(&(key.x, key.z)))
|
||||
})
|
||||
.collect();
|
||||
for key in keys {
|
||||
self.mark_stale(key);
|
||||
if let Some(mask) = self.detail_masks.get_mut(&key) {
|
||||
let area = TileKey::around(area, key.size() / CELLS as i32);
|
||||
forget_detail(key, area, mask, |chunk| self.saved.contains_key(&chunk));
|
||||
}
|
||||
}
|
||||
@@ -1717,6 +1809,15 @@ impl Lod {
|
||||
&& (cz + 1) * 16 > z0
|
||||
})
|
||||
}
|
||||
/// The tile's samples lack data its source has now: sampled again, the
|
||||
/// old ones drawn meanwhile, and once more after a sample in flight.
|
||||
fn mark_stale(&mut self, key: TileKey) {
|
||||
self.stale.insert(key);
|
||||
self.wanted_stale = true;
|
||||
if self.pending.contains(&key) {
|
||||
self.restale.insert(key);
|
||||
}
|
||||
}
|
||||
/// Capture actual block columns, including canopy gaps and material
|
||||
/// layers: the worker summarizes the chunk (`summarize`, a few
|
||||
/// milliseconds a chunk), then `finish_observed` keeps it.
|
||||
@@ -1769,22 +1870,42 @@ impl Lod {
|
||||
self.saved_order.push_back(key);
|
||||
}
|
||||
self.saved.insert(key, columns.columns.clone());
|
||||
if self.keeps_observed {
|
||||
self.resample_over(key);
|
||||
}
|
||||
self.finish_detail(key, columns);
|
||||
}
|
||||
/// The source keeps the chunk now: the tiles over it whose samples lack
|
||||
/// it are sampled again (`stale`), their old samples drawn meanwhile.
|
||||
fn resample_over(&mut self, chunk: (i32, i32)) {
|
||||
for key in resampled_over(&self.ready, &self.pending, chunk) {
|
||||
self.mark_stale(key);
|
||||
}
|
||||
}
|
||||
/// A chunk's own columns as its level-0 tile; the same columns again
|
||||
/// (asked again after a `refresh`) mesh nothing.
|
||||
fn apply_fine(&mut self, (cx, cz): (i32, i32), columns: &[Column; 256]) {
|
||||
let fine = TileKey {
|
||||
x: cx,
|
||||
z: cz,
|
||||
level: 0,
|
||||
};
|
||||
if self.plan.nodes.contains(&fine) || self.ready.contains_key(&fine) {
|
||||
let existed = self
|
||||
.ready
|
||||
.insert(fine, Arc::new(columns.to_vec()))
|
||||
.is_some();
|
||||
self.detail_masks.insert(fine, [u64::MAX; 4]);
|
||||
self.refresh_ready(fine, existed);
|
||||
if !self.plan.nodes.contains(&fine) && !self.ready.contains_key(&fine) {
|
||||
return;
|
||||
}
|
||||
self.detail_masks.insert(fine, [u64::MAX; 4]);
|
||||
if self
|
||||
.ready
|
||||
.get(&fine)
|
||||
.is_some_and(|old| old[..] == columns[..])
|
||||
{
|
||||
return;
|
||||
}
|
||||
let existed = self
|
||||
.ready
|
||||
.insert(fine, Arc::new(columns.to_vec()))
|
||||
.is_some();
|
||||
self.refresh_ready(fine, existed);
|
||||
}
|
||||
fn apply_detail(&mut self, (cx, cz): (i32, i32), columns: &ReducedColumns) {
|
||||
self.apply_fine((cx, cz), &columns.columns);
|
||||
@@ -1811,27 +1932,59 @@ impl Lod {
|
||||
if x0 == x1 || z0 == z1 {
|
||||
continue;
|
||||
}
|
||||
let samples = Arc::make_mut(samples);
|
||||
let mask = self.detail_masks.entry(key).or_default();
|
||||
let mut changed = false;
|
||||
for z in z0..z1 {
|
||||
for x in x0..x1 {
|
||||
let index = (z * 16 + x) as usize;
|
||||
samples[index] = columns
|
||||
.get(
|
||||
(ox + x * stride).rem_euclid(16) as usize,
|
||||
(oz + z * stride).rem_euclid(16) as usize,
|
||||
stride as usize,
|
||||
)
|
||||
.clone();
|
||||
let column = columns.get(
|
||||
(ox + x * stride).rem_euclid(16) as usize,
|
||||
(oz + z * stride).rem_euclid(16) as usize,
|
||||
stride as usize,
|
||||
);
|
||||
// Copied (from a mesh job's snapshot) only to change it.
|
||||
if samples[index] != *column {
|
||||
Arc::make_mut(samples)[index] = column.clone();
|
||||
changed = true;
|
||||
}
|
||||
mask[index / 64] |= 1 << (index % 64);
|
||||
}
|
||||
}
|
||||
modified.push(key);
|
||||
if changed {
|
||||
modified.push(key);
|
||||
}
|
||||
}
|
||||
for key in modified {
|
||||
self.mark_data_changed(key);
|
||||
}
|
||||
}
|
||||
/// A sample with a hole, its data still on its way and nothing coarser
|
||||
/// to stand in: drawn, the hole stood where the coarser tile or the older
|
||||
/// sample it replaces had terrain, until the data came (`refresh`) and
|
||||
/// the tile was sampled again. So it is thrown away, and the tile sampled
|
||||
/// again then (at once if it came while this one was taken), or after
|
||||
/// `WAIT_RETRY`: a shorter wait let the retries of near tiles take every
|
||||
/// slot while the rest waited. One whose waiting cells BlueMap's coarser
|
||||
/// levels stood in for is drawn (`finish_any_sample`).
|
||||
fn finish_waited(&mut self, key: TileKey) {
|
||||
self.pending.remove(&key);
|
||||
if self.restale.remove(&key) {
|
||||
self.wanted_stale = true;
|
||||
} else if self.plan.nodes.contains(&key) {
|
||||
self.retry
|
||||
.insert(key, std::time::Instant::now() + WAIT_RETRY);
|
||||
}
|
||||
}
|
||||
/// A sample in: thrown away when a cell of it is a hole, its data on its
|
||||
/// way (`finish_waited`); one whose waiting cells a coarser level stood
|
||||
/// in for is drawn (`LodSampler::waited`).
|
||||
fn finish_any_sample(&mut self, key: TileKey, samples: Vec<Column>, waited: bool) {
|
||||
if waited {
|
||||
self.finish_waited(key);
|
||||
} else {
|
||||
self.finish_sample(key, samples);
|
||||
}
|
||||
}
|
||||
fn finish_sample(&mut self, key: TileKey, samples: Vec<Column>) {
|
||||
self.pending.remove(&key);
|
||||
if samples.len() != CELLS * CELLS {
|
||||
@@ -1840,6 +1993,7 @@ impl Lod {
|
||||
self.retry.retain(|_, at| *at > now);
|
||||
}
|
||||
self.retry.insert(key, now + RETRY);
|
||||
self.restale.remove(&key);
|
||||
return;
|
||||
}
|
||||
self.retry.remove(&key);
|
||||
@@ -1852,8 +2006,16 @@ impl Lod {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Refreshed again meanwhile: sampled once more after this.
|
||||
if self.restale.remove(&key) {
|
||||
self.wanted_stale = true;
|
||||
} else {
|
||||
self.stale.remove(&key);
|
||||
}
|
||||
if self.ready.get(&key).is_some_and(|old| **old == samples) {
|
||||
return;
|
||||
}
|
||||
let existed = self.ready.insert(key, Arc::new(samples)).is_some();
|
||||
self.stale.remove(&key);
|
||||
self.refresh_ready(key, existed);
|
||||
}
|
||||
}
|
||||
@@ -1887,6 +2049,10 @@ impl Lod {
|
||||
keep = keep.saturating_sub(1);
|
||||
kept
|
||||
});
|
||||
// Sampled again from the queue: they read what `refresh` announced.
|
||||
for key in &dropped {
|
||||
self.restale.remove(key);
|
||||
}
|
||||
// Back in line where `wanted_tiles` put them: the near terrain can
|
||||
// start and stop loading every few frames, and rebuilding the whole
|
||||
// queue each time cost more than the samples it held back.
|
||||
@@ -2025,6 +2191,10 @@ impl Lod {
|
||||
let mut meshes = Vec::new();
|
||||
crate::spans::time("lod_results", || self.receive(&mut meshes));
|
||||
crate::spans::time("lod_retiring", || self.prune_retiring());
|
||||
// Uploaded over frames in this order (`Renderer::upload_lods`): a
|
||||
// tile is meshed clipped around its finer ones, so they go first,
|
||||
// and the nearest first, where far terrain meets the near chunks.
|
||||
meshes.sort_by_key(|(key, _)| (key.level, key.distance(camera)));
|
||||
crate::spans::time("lod_covered", || self.cover(covered));
|
||||
if std::mem::take(&mut self.reselect) {
|
||||
self.rebuild_selection();
|
||||
@@ -2054,6 +2224,7 @@ impl Lod {
|
||||
self.plan.nodes.contains(key)
|
||||
});
|
||||
self.stale.retain(|key| self.plan.nodes.contains(key));
|
||||
self.restale.retain(|key| self.plan.nodes.contains(key));
|
||||
self.detail_masks
|
||||
.retain(|key, _| self.plan.nodes.contains(key));
|
||||
// Complete resident columns need no duplicate noise work.
|
||||
@@ -2134,7 +2305,11 @@ impl Lod {
|
||||
crate::spans::time("lod_detail", || self.finish_observed(key, columns))
|
||||
}
|
||||
Done::DetailRequested => self.detail_request_pending = false,
|
||||
Done::Sample { key, samples } => self.finish_sample(key, samples),
|
||||
Done::Sample {
|
||||
key,
|
||||
samples,
|
||||
waited,
|
||||
} => self.finish_any_sample(key, samples, waited),
|
||||
Done::Mesh {
|
||||
key,
|
||||
version,
|
||||
@@ -3106,6 +3281,22 @@ mod tests {
|
||||
assert_eq!(reduced.get(4, 8, 4).spans, [span(0, 4, 1)]);
|
||||
assert_eq!(reduced.get(0, 0, 16).spans, [span(0, 4, 1)]);
|
||||
}
|
||||
/// Detail read before a refresh over its chunk is dropped (asked
|
||||
/// again); elsewhere, or read after, it is kept.
|
||||
#[test]
|
||||
fn only_detail_a_later_refresh_covers_is_dropped() {
|
||||
let mut lod = Lod::scheduler_for_test();
|
||||
let asked = lod.refreshes;
|
||||
lod.refresh([0, 0, 64, 64], (1, 16));
|
||||
assert!(lod.refreshed_since((1, 1), asked));
|
||||
assert!(lod.refreshed_since((4, 0), asked), "a chunk around");
|
||||
assert!(!lod.refreshed_since((40, 40), asked), "elsewhere");
|
||||
assert!(!lod.refreshed_since((1, 1), lod.refreshes), "read after");
|
||||
for _ in 0..MAX_REFRESHED {
|
||||
lod.refresh([10_000, 10_000, 10_064, 10_064], (1, 16));
|
||||
}
|
||||
assert!(lod.refreshed_since((40, 40), asked), "too old to tell");
|
||||
}
|
||||
#[test]
|
||||
fn refreshed_areas_forget_detail_but_seen_chunks() {
|
||||
let key = TileKey {
|
||||
@@ -3114,6 +3305,17 @@ mod tests {
|
||||
level: 1,
|
||||
};
|
||||
assert!(key.overlaps([40, 0, 50, 1]) && !key.overlaps([64, 0, 80, 32]));
|
||||
// Only cells of the widths the new data is drawn in, a cell around.
|
||||
assert!(key.refreshed_by([64, 0, 80, 32], (1, 16)));
|
||||
assert!(!key.refreshed_by([65, 0, 80, 32], (1, 16)));
|
||||
assert!(!key.refreshed_by([40, 0, 50, 1], (25, i32::MAX)));
|
||||
let root = TileKey {
|
||||
x: 0,
|
||||
z: 0,
|
||||
level: MAX_LEVEL,
|
||||
};
|
||||
assert!(!root.refreshed_by([40, 0, 72, 32], (1, 16)));
|
||||
assert!(root.refreshed_by([0, 0, 800, 800], (25, i32::MAX)));
|
||||
// Level 1: 2 × 2 cells a chunk, chunks 2..4 × 0..2.
|
||||
let mut mask = [u64::MAX; 4];
|
||||
forget_detail(key, [48, 0, 64, 16], &mut mask, |_| false);
|
||||
@@ -3127,6 +3329,47 @@ mod tests {
|
||||
assert_eq!(mask.iter().map(|m| m.count_ones()).sum::<u32>(), 64);
|
||||
}
|
||||
#[test]
|
||||
fn tiles_sampled_without_a_kept_chunk_are_sampled_again() {
|
||||
let tile = |level: u8| {
|
||||
let size = 16 << level;
|
||||
TileKey {
|
||||
x: (3 * 16i32).div_euclid(size),
|
||||
z: (-5 * 16i32).div_euclid(size),
|
||||
level,
|
||||
}
|
||||
};
|
||||
let empty = || Arc::new(vec![Column::default(); CELLS * CELLS]);
|
||||
let filled = || {
|
||||
let column = Column {
|
||||
surface: None,
|
||||
spans: vec![Span {
|
||||
min: -64,
|
||||
max: 70,
|
||||
state: BlockStateId(1),
|
||||
biome: 0,
|
||||
light: 0,
|
||||
}]
|
||||
.into(),
|
||||
};
|
||||
Arc::new(vec![column; CELLS * CELLS])
|
||||
};
|
||||
let mut ready = FxHashMap::default();
|
||||
let mut pending = FxHashSet::default();
|
||||
// Cells of a chunk or less take its detail; wider ones sampled empty
|
||||
// there are sampled again, and filled ones aren't.
|
||||
ready.insert(tile(2), empty());
|
||||
ready.insert(tile(5), empty());
|
||||
ready.insert(tile(6), filled());
|
||||
assert_eq!(resampled_over(&ready, &pending, (3, -5)), [tile(5)]);
|
||||
// Samples in flight may predate it: those that detail can't patch.
|
||||
pending.extend([tile(1), tile(2), tile(6), tile(7)]);
|
||||
assert_eq!(
|
||||
resampled_over(&ready, &pending, (3, -5)),
|
||||
[tile(1), tile(5), tile(6), tile(7)]
|
||||
);
|
||||
assert!(resampled_over(&ready, &FxHashSet::default(), (40, 40)).is_empty());
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
|
||||
fn real_mesh_clips_near_chunks_and_preserves_water_and_edited_surfaces() -> crate::Result<()> {
|
||||
use crate::{
|
||||
@@ -4334,4 +4577,461 @@ mod tests {
|
||||
check(&lod);
|
||||
let _ = lod.removed();
|
||||
}
|
||||
/// The prepared resources' far-terrain worker context and block facts.
|
||||
fn test_context() -> crate::Result<(Arc<WorkerContext>, BlockFacts)> {
|
||||
use crate::{assets::Assets, biome::Biomes, light::LightTables, mesher::MeshTables};
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
|
||||
);
|
||||
let assets = Arc::new(Assets::load(&root)?);
|
||||
let render = Arc::new(assets.reload(&root)?);
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let biomes = Arc::new(Biomes::load(&root)?);
|
||||
let tables = Arc::new(MeshTables::new(
|
||||
&assets,
|
||||
&render,
|
||||
&facts,
|
||||
crate::models::load_visibility(&root, assets.registry)?,
|
||||
)?);
|
||||
let context = Arc::new(WorkerContext {
|
||||
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
|
||||
tables,
|
||||
assets,
|
||||
render,
|
||||
biomes,
|
||||
});
|
||||
Ok((context, facts))
|
||||
}
|
||||
/// A server's far terrain (`server_terrain`) in a scratch folder, and a
|
||||
/// chunk of stone at y 70 to observe.
|
||||
struct ServerLod {
|
||||
lod: Lod,
|
||||
facts: BlockFacts,
|
||||
stone: BlockStateId,
|
||||
dir: std::path::PathBuf,
|
||||
}
|
||||
impl ServerLod {
|
||||
fn new(name: &str) -> crate::Result<Self> {
|
||||
Self::with(name, |dir| {
|
||||
crate::server_terrain::ServerTerrain::new(dir.into(), None)
|
||||
})
|
||||
}
|
||||
fn with(
|
||||
name: &str,
|
||||
source: impl FnOnce(&std::path::Path) -> Arc<dyn LodSource>,
|
||||
) -> crate::Result<Self> {
|
||||
let (context, facts) = test_context()?;
|
||||
let stone = context
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("horizon-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
Ok(Self {
|
||||
lod: Lod::new(source(&dir), context),
|
||||
facts,
|
||||
stone,
|
||||
dir,
|
||||
})
|
||||
}
|
||||
fn observe(&mut self, x: i32, z: i32) {
|
||||
let mut chunk = Chunk::empty(x, z);
|
||||
for i in 0..256 {
|
||||
chunk.set(i % 16, 70, i / 16, self.stone.0);
|
||||
}
|
||||
self.lod.observe(Arc::new(chunk), &self.facts);
|
||||
}
|
||||
/// Updates until nothing is left to sample, summarize or mesh (`settle`:
|
||||
/// for a while), or only a few times.
|
||||
fn run(
|
||||
&mut self,
|
||||
camera: [f64; 3],
|
||||
detail: i32,
|
||||
distance: u32,
|
||||
covered: &FxHashSet<(i32, i32)>,
|
||||
settle: bool,
|
||||
) {
|
||||
let lod = &mut self.lod;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
|
||||
let mut covered = Some(covered.clone());
|
||||
let mut quiet = 0;
|
||||
while quiet < if settle { 50 } else { 3 } {
|
||||
lod.update(camera, detail, distance, covered.take());
|
||||
lod.removed();
|
||||
// Every tile sampled, but those waiting for their data.
|
||||
let unsampled = lod.plan.nodes.iter().any(|key| {
|
||||
!lod.plan.split.contains(key)
|
||||
&& !lod.ready.contains_key(key)
|
||||
&& !lod.retry.contains_key(key)
|
||||
});
|
||||
let idle = lod.pending.is_empty()
|
||||
&& lod.outstanding_meshes == 0
|
||||
&& lod.dirty.is_empty()
|
||||
&& lod.observing() == 0
|
||||
&& lod
|
||||
.shared
|
||||
.jobs
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|job| matches!(job, Job::RequestDetail { .. }))
|
||||
&& !unsampled;
|
||||
quiet = if idle { quiet + 1 } else { 0 };
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"far terrain never settled: {} pending, {} meshing, {} observing, {} jobs, {} unsampled, {} waiting",
|
||||
lod.pending.len(),
|
||||
lod.outstanding_meshes,
|
||||
lod.observing(),
|
||||
lod.shared.jobs.lock().unwrap().len(),
|
||||
lod.plan
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|key| !lod.plan.split.contains(key)
|
||||
&& !lod.ready.contains_key(key)
|
||||
&& !lod.retry.contains_key(key))
|
||||
.count(),
|
||||
lod.retry.len(),
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
}
|
||||
/// The chunks of `chunks` the active tiles don't draw as observed:
|
||||
/// the visited column or the tile's sample there, at the chunk's middle.
|
||||
fn missing(&self, chunks: impl Iterator<Item = (i32, i32)>) -> Vec<(i32, i32)> {
|
||||
chunks
|
||||
.filter(|&chunk| {
|
||||
self.drawn(chunk)
|
||||
.is_none_or(|c| c.spans.last().is_none_or(|s| s.max != 71))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The column the active tiles draw at a chunk's middle.
|
||||
fn drawn(&self, (cx, cz): (i32, i32)) -> Option<Column> {
|
||||
let lod = &self.lod;
|
||||
let snapshot = lod.snapshot();
|
||||
let drawn = |x: i32, z: i32| {
|
||||
let key = lod.active.iter().copied().find(|key| {
|
||||
let [ox, _, oz] = key.origin();
|
||||
(ox..ox + key.size()).contains(&x) && (oz..oz + key.size()).contains(&z)
|
||||
})?;
|
||||
let stride = key.size() / CELLS as i32;
|
||||
let [ox, _, oz] = key.origin();
|
||||
let (cx, cz) = ((x - ox) / stride, (z - oz) / stride);
|
||||
let (wx, wz) = (ox + cx * stride, oz + cz * stride);
|
||||
Some(match snapshot.visited_source(wx, wz, stride) {
|
||||
Some(source) => source.get().clone(),
|
||||
None => lod.ready[&key][(cz * CELLS as i32 + cx) as usize].clone(),
|
||||
})
|
||||
};
|
||||
drawn(cx * 16 + 8, cz * 16 + 8)
|
||||
}
|
||||
}
|
||||
impl Drop for ServerLod {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
|
||||
fn chunks_a_server_dropped_stay_drawn_as_far_terrain() -> crate::Result<()> {
|
||||
let mut far = ServerLod::new("lod-dropped")?;
|
||||
// A server's view distance of 10 (it sends a ring more), drawn to a
|
||||
// render distance of 32 (`streaming.radius`, the covered area).
|
||||
let (view, detail, distance) = (10, 32, 64);
|
||||
let mut resident = FxHashSet::default();
|
||||
let mut dropped = FxHashSet::default();
|
||||
// The player flies east past some 1800 chunks: more than `MAX_SAVED`.
|
||||
let end = 60;
|
||||
for step in 0..=end {
|
||||
let center = (step, 0);
|
||||
let now: FxHashSet<_> = (-view..=view)
|
||||
.flat_map(|dz| (-view..=view).map(move |dx| (center.0 + dx, center.1 + dz)))
|
||||
.collect();
|
||||
for &(x, z) in now.difference(&resident) {
|
||||
far.observe(x, z);
|
||||
}
|
||||
for &(x, z) in resident.difference(&now) {
|
||||
// `forget_remote_chunk`: the last of it, for far terrain.
|
||||
far.observe(x, z);
|
||||
dropped.insert((x, z));
|
||||
}
|
||||
resident = now;
|
||||
let covered = resident
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&(x, z)| crate::streaming::in_view_distance(center, detail, x, z))
|
||||
.collect();
|
||||
let camera = [f64::from(center.0 * 16 + 8), 100.0, 8.0];
|
||||
far.run(camera, detail, distance, &covered, step == end);
|
||||
}
|
||||
// Those within the LOD distance of where the player is now.
|
||||
let near = |&(x, z): &(i32, i32)| (x - end).pow(2) + z * z < (distance as i32 - 4).pow(2);
|
||||
let missing = far.missing(dropped.iter().copied().filter(near));
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"{} dropped chunks aren't drawn, as {:?}",
|
||||
missing.len(),
|
||||
&missing[..missing.len().min(8)]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
|
||||
fn far_cells_sampled_before_their_chunks_were_kept_fill_in() -> crate::Result<()> {
|
||||
let mut far = ServerLod::new("lod-late")?;
|
||||
// Seen from 5000 blocks: cells of 32 blocks and wider over them.
|
||||
let camera = [-5000.0, 100.0, 8.0];
|
||||
let (detail, distance) = (10, 512);
|
||||
let none = FxHashSet::default();
|
||||
// Every tile sampled while nothing is kept, then 32 × 32 chunks are.
|
||||
far.run(camera, detail, distance, &none, true);
|
||||
let chunks: Vec<_> = (0..32).flat_map(|z| (0..32).map(move |x| (x, z))).collect();
|
||||
assert_eq!(far.missing(chunks.iter().copied()).len(), chunks.len());
|
||||
for &(x, z) in &chunks {
|
||||
far.observe(x, z);
|
||||
}
|
||||
far.run(camera, detail, distance, &none, true);
|
||||
let missing = far.missing(chunks.iter().copied());
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"{} kept chunks aren't drawn",
|
||||
missing.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
/// A BlueMap whose coarse levels are in, its finest (cells up to a
|
||||
/// chunk) still downloading until `arrived`.
|
||||
struct Downloading {
|
||||
stone: BlockStateId,
|
||||
arrived: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
struct DownloadingSampler {
|
||||
stone: BlockStateId,
|
||||
arrived: Arc<std::sync::atomic::AtomicBool>,
|
||||
details: Vec<crate::streaming::LodDetail>,
|
||||
}
|
||||
impl LodSource for Downloading {
|
||||
fn sampler(self: Arc<Self>) -> Box<dyn crate::streaming::LodSampler> {
|
||||
Box::new(DownloadingSampler {
|
||||
stone: self.stone,
|
||||
arrived: self.arrived.clone(),
|
||||
details: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
impl crate::streaming::LodSampler for DownloadingSampler {
|
||||
fn fork(&self) -> Option<Box<dyn crate::streaming::LodSampler>> {
|
||||
Some(Box::new(Self {
|
||||
stone: self.stone,
|
||||
arrived: self.arrived.clone(),
|
||||
details: Vec::new(),
|
||||
}))
|
||||
}
|
||||
fn sample(&mut self, _x: i32, _z: i32) -> Option<Surface> {
|
||||
None
|
||||
}
|
||||
/// The coarse levels' stone, the finest's next state, and the
|
||||
/// coarse one's while the finest is on its way (`server_terrain`):
|
||||
/// stood in for, so not `waited`.
|
||||
fn reduced_column(&mut self, _x: i32, _z: i32, width: i32) -> Column {
|
||||
let state = if width > 16 {
|
||||
self.stone
|
||||
} else if self.arrived.load(Ordering::Relaxed) {
|
||||
BlockStateId(self.stone.0 + 1)
|
||||
} else {
|
||||
self.stone
|
||||
};
|
||||
Column {
|
||||
surface: None,
|
||||
spans: vec![Span {
|
||||
min: MIN_Y as i16,
|
||||
max: 71,
|
||||
state,
|
||||
biome: 0,
|
||||
light: 0xF0,
|
||||
}]
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
/// Chunks' detail once arrived, as `server_terrain`'s: asked again
|
||||
/// while on its way.
|
||||
fn request_detail(&mut self, chunks: &[(i32, i32)], _center: [i32; 2]) {
|
||||
if self.arrived.load(Ordering::Relaxed) {
|
||||
let columns = Arc::new(std::array::from_fn(|_| self.reduced_column(0, 0, 1)));
|
||||
self.details
|
||||
.extend(chunks.iter().map(|&chunk| (chunk, columns.clone())));
|
||||
}
|
||||
}
|
||||
fn drain_details(&mut self) -> Vec<crate::streaming::LodDetail> {
|
||||
std::mem::take(&mut self.details)
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
|
||||
fn coarser_stand_ins_keep_the_ring_past_the_near_chunks_drawn() -> crate::Result<()> {
|
||||
let arrived = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let mut far = ServerLod::with("lod-waiting", |_| {
|
||||
let stone = BlockStateId(1);
|
||||
Arc::new(Downloading {
|
||||
stone,
|
||||
arrived: arrived.clone(),
|
||||
})
|
||||
})?;
|
||||
let (camera, detail, distance) = ([8.0, 100.0, 8.0], 10, 64);
|
||||
let none = FxHashSet::default();
|
||||
// The ring past the near terrain, where nothing stood.
|
||||
let ring: Vec<_> = (-24..=24)
|
||||
.flat_map(|z| (-24..=24).map(move |x| (x, z)))
|
||||
.filter(|&(x, z)| x * x + z * z > 11 * 11)
|
||||
.collect();
|
||||
far.run(camera, detail, distance, &none, true);
|
||||
let missing = far.missing(ring.iter().copied());
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"{} of {} chunks past the near terrain show nothing while their tiles wait",
|
||||
missing.len(),
|
||||
ring.len()
|
||||
);
|
||||
// The map's tiles arrive (`BlueMap::take_changed`): the finest replace them.
|
||||
arrived.store(true, Ordering::Relaxed);
|
||||
far.lod.refresh([-1024, -1024, 1024, 1024], (1, 24));
|
||||
far.run(camera, detail, distance, &none, true);
|
||||
assert!(far.lod.retry.is_empty());
|
||||
let fine = BlockStateId(2);
|
||||
let coarse: Vec<_> = ring
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&chunk| {
|
||||
far.drawn(chunk)
|
||||
.is_none_or(|c| c.spans.last().is_none_or(|s| s.state != fine))
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
coarse.is_empty(),
|
||||
"{} chunks still drawn coarse",
|
||||
coarse.len()
|
||||
);
|
||||
assert!(far.lod.active.iter().any(|key| key.level == 0));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The sample jobs `request_samples` queues now, taken off the queue.
|
||||
fn queued(lod: &mut Lod) -> Vec<TileKey> {
|
||||
lod.request_samples([0, 0]);
|
||||
let mut jobs = lod.shared.jobs.lock().unwrap();
|
||||
jobs.drain(..)
|
||||
.filter_map(|job| match job {
|
||||
Job::Sample(key) => Some(key),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn samples(top: i16) -> Vec<Column> {
|
||||
let mut column = Column::default();
|
||||
column.spans.push(Span {
|
||||
min: -64,
|
||||
max: top,
|
||||
state: BlockStateId(1),
|
||||
biome: 0,
|
||||
light: 0,
|
||||
});
|
||||
vec![column; CELLS * CELLS]
|
||||
}
|
||||
const AREA: [i32; 4] = [0, 0, 128, 128];
|
||||
const ALL: (i32, i32) = (1, i32::MAX);
|
||||
|
||||
/// A first sample that read a map tile still downloading is thrown
|
||||
/// away; if the tile came while it was taken, it is sampled again at
|
||||
/// once, else after `WAIT_RETRY` or when the tile comes.
|
||||
#[test]
|
||||
fn waited_samples_are_taken_again_when_their_data_comes() {
|
||||
let mut lod = Lod::scheduler_for_test();
|
||||
let key = TileKey {
|
||||
x: 0,
|
||||
z: 0,
|
||||
level: 3,
|
||||
};
|
||||
lod.plan.nodes.insert(key);
|
||||
assert_eq!(queued(&mut lod), [key]);
|
||||
lod.finish_any_sample(key, vec![Column::default(); CELLS * CELLS], true);
|
||||
assert!(lod.retry.contains_key(&key) && !lod.ready.contains_key(&key));
|
||||
assert!(queued(&mut lod).is_empty(), "it waits for its data");
|
||||
lod.refresh(AREA, ALL);
|
||||
assert!(!lod.retry.contains_key(&key));
|
||||
assert_eq!(queued(&mut lod), [key], "the data came");
|
||||
|
||||
// The data comes while it is being sampled.
|
||||
lod.refresh(AREA, ALL);
|
||||
lod.finish_any_sample(key, vec![Column::default(); CELLS * CELLS], true);
|
||||
assert!(!lod.retry.contains_key(&key));
|
||||
assert_eq!(queued(&mut lod), [key], "sampled again at once");
|
||||
// Coarser levels stood in for what waited: drawn.
|
||||
lod.finish_any_sample(key, samples(70), false);
|
||||
assert!(lod.ready.contains_key(&key) && !lod.stale.contains(&key));
|
||||
assert!(queued(&mut lod).is_empty());
|
||||
// Refreshed, then sampled with a hole where a tile is still on its
|
||||
// way: what is drawn stays, the hole isn't.
|
||||
lod.refresh(AREA, ALL);
|
||||
assert_eq!(queued(&mut lod), [key]);
|
||||
let mut holed = samples(80);
|
||||
holed[..CELLS].fill(Column::default());
|
||||
lod.finish_any_sample(key, holed, true);
|
||||
assert_eq!(lod.ready[&key][0], samples(70)[0]);
|
||||
assert!(lod.retry.contains_key(&key));
|
||||
}
|
||||
|
||||
/// A tile refreshed while its sample is in flight is sampled once more:
|
||||
/// that sample read the old data. Samples equal to the drawn ones mesh
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn refreshed_tiles_are_sampled_again_and_only_meshed_when_changed() {
|
||||
let mut lod = Lod::scheduler_for_test();
|
||||
let key = TileKey {
|
||||
x: 0,
|
||||
z: 0,
|
||||
level: 3,
|
||||
};
|
||||
lod.plan.nodes.insert(key);
|
||||
lod.active.insert(key);
|
||||
lod.ready.insert(key, Arc::new(samples(70)));
|
||||
assert!(queued(&mut lod).is_empty());
|
||||
lod.refresh(AREA, ALL);
|
||||
assert_eq!(queued(&mut lod), [key]);
|
||||
lod.refresh(AREA, ALL);
|
||||
lod.finish_sample(key, samples(80));
|
||||
assert!(lod.stale.contains(&key), "refreshed again meanwhile");
|
||||
assert!(lod.dirty.contains_key(&key), "new samples are meshed");
|
||||
assert_eq!(queued(&mut lod), [key]);
|
||||
lod.dirty.clear();
|
||||
lod.finish_sample(key, samples(80));
|
||||
assert!(!lod.stale.contains(&key));
|
||||
assert!(lod.dirty.is_empty(), "the same samples mesh nothing");
|
||||
assert!(queued(&mut lod).is_empty());
|
||||
// Data drawn in other cell widths leaves it alone.
|
||||
lod.refresh(AREA, (17, 24));
|
||||
assert!(queued(&mut lod).is_empty());
|
||||
}
|
||||
|
||||
/// With a source that keeps what it observes (a server's chunks), a wide
|
||||
/// tile sampled empty where a chunk is now kept is sampled again.
|
||||
#[test]
|
||||
fn kept_chunks_resample_the_wide_tiles_over_them() {
|
||||
let mut lod = Lod::scheduler_for_test();
|
||||
lod.keeps_observed = true;
|
||||
let key = TileKey {
|
||||
x: 0,
|
||||
z: 0,
|
||||
level: 5,
|
||||
};
|
||||
lod.plan.nodes.insert(key);
|
||||
lod.ready
|
||||
.insert(key, Arc::new(vec![Column::default(); CELLS * CELLS]));
|
||||
let column = samples(70).swap_remove(0);
|
||||
let columns: Arc<[Column; 256]> = Arc::new(std::array::from_fn(|_| column.clone()));
|
||||
lod.finish_observed((1, 1), ReducedColumns::new(columns));
|
||||
assert!(lod.stale.contains(&key));
|
||||
assert_eq!(queued(&mut lod), [key]);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ impl ParsedModels {
|
||||
}
|
||||
}
|
||||
let parsed = crate::par::par_map(&files, |(_, path)| {
|
||||
let text = fs::read_to_string(path).ok()?;
|
||||
let text = horizon_core::prepared_files::read_to_string(path).ok()?;
|
||||
CuboidModel::from_json(&text).ok().map(Arc::new)
|
||||
});
|
||||
let models = files
|
||||
@@ -89,8 +89,8 @@ impl ParsedModels {
|
||||
let read = || -> Result<CuboidModel> {
|
||||
let (namespace, path) =
|
||||
name.split_once(':').ok_or("invalid model identifier")?;
|
||||
let text = fs::read_to_string(
|
||||
root.join(format!("assets/{namespace}/models/{path}.json")),
|
||||
let text = horizon_core::prepared_files::read_to_string(
|
||||
&root.join(format!("assets/{namespace}/models/{path}.json")),
|
||||
)?;
|
||||
Ok(CuboidModel::from_json(&text)?)
|
||||
};
|
||||
|
||||
@@ -196,8 +196,8 @@ impl StateModels {
|
||||
// Vanilla's BlockStateModelLoader: a definition that does not
|
||||
// read or bind is logged and its block gets no model; the other
|
||||
// blocks still load.
|
||||
let loaded = fs::read_to_string(
|
||||
root.join(format!("assets/{namespace}/blockstates/{path}.json")),
|
||||
let loaded = horizon_core::prepared_files::read_to_string(
|
||||
&root.join(format!("assets/{namespace}/blockstates/{path}.json")),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|text| BlockStateDefinition::from_json(&text).map_err(|e| e.to_string()))
|
||||
|
||||
@@ -126,7 +126,6 @@ impl Opt {
|
||||
| Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts
|
||||
| Opt::RotateWithMinecarts
|
||||
// Saved, but no Horizon system reads them yet: no Realms, no
|
||||
// friends list, no directional audio, no now-playing toast.
|
||||
| Opt::RealmsNotifications
|
||||
@@ -937,6 +936,7 @@ impl Opt {
|
||||
| Opt::RealmsNotifications
|
||||
| Opt::InGameNotification
|
||||
| Opt::DirectionalAudio
|
||||
| Opt::RotateWithMinecarts
|
||||
| Opt::NarratorHotkey => {
|
||||
let key = self.file_key().unwrap();
|
||||
s.file
|
||||
@@ -1512,6 +1512,8 @@ pub struct OptionsUi {
|
||||
/// (`update_pack_infos`) differs from the settings'.
|
||||
pack_reselected: bool,
|
||||
pub in_world: bool,
|
||||
/// AccessibilityOptionsScreen: the loaded world's experimental feature.
|
||||
pub minecart_improvements: bool,
|
||||
/// Scroll the selected language into the middle on the next layout.
|
||||
center_language: bool,
|
||||
pub graphics: crate::graphics_ui::GraphicsUi,
|
||||
@@ -1576,6 +1578,7 @@ impl Default for OptionsUi {
|
||||
pack_focus: Instant::now(),
|
||||
pack_reselected: false,
|
||||
in_world: false,
|
||||
minecart_improvements: false,
|
||||
center_language: false,
|
||||
graphics: Default::default(),
|
||||
split_profiles: Default::default(),
|
||||
@@ -3236,6 +3239,14 @@ impl OptionsUi {
|
||||
if widget.list {
|
||||
widget.y += previous_scroll - self.scroll;
|
||||
}
|
||||
if let WKind::Button {
|
||||
act: Act::Cycle(Opt::RotateWithMinecarts),
|
||||
active,
|
||||
..
|
||||
} = &mut widget.kind
|
||||
{
|
||||
*active = self.in_world && self.minecart_improvements;
|
||||
}
|
||||
}
|
||||
self.row_bands = rows
|
||||
.bands
|
||||
@@ -3385,6 +3396,8 @@ impl OptionsUi {
|
||||
}
|
||||
if self.page() == Page::SplitSetup {
|
||||
self.split_setup.on = s.extras.splitscreen;
|
||||
// The controller picks a tile up between presses: the screen's size from the draw.
|
||||
self.split_setup.size = (batch.width, batch.height);
|
||||
self.split_setup.draw(batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -746,7 +746,8 @@ fn build_overlay_uncached(
|
||||
}
|
||||
for name in names {
|
||||
let child = format!("{prefix}{name}");
|
||||
if hidden.contains(&child) {
|
||||
// Vanilla's files in one file: it describes vanilla's root, not this mirror.
|
||||
if hidden.contains(&child) || child == horizon_core::prepared_files::FILE_NAME {
|
||||
continue;
|
||||
}
|
||||
if let Some(bytes) = merged.get(&child) {
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
//!
|
||||
//! The summaries live in Anvil region files (`r.X.Z.mca`, one NBT record a
|
||||
//! chunk), under `server-terrain/<server>/<hashed seed>/<namespace>/<level>/`.
|
||||
//! Past what it has, the server's BlueMap may fill in (`bluemap`).
|
||||
//! A chunk it doesn't have may be in the player's Bobby cache of the same
|
||||
//! level (`Bobby`), only ever read; past both, the server's BlueMap may fill
|
||||
//! in (`bluemap`).
|
||||
use crate::{
|
||||
blocks::BlockFacts,
|
||||
bluemap::{BlueMap, Found},
|
||||
chunk::Chunk,
|
||||
fast_hash::FxHashMap,
|
||||
lod::{Column, Span, Spans, Surface},
|
||||
lod::{Column, Span, Spans, Surface, state_kinds, summarize},
|
||||
streaming::{LodDetail, LodSampler, LodSource},
|
||||
};
|
||||
use horizon_core::position::ChunkPos;
|
||||
@@ -70,6 +74,67 @@ pub fn level_dir(root: &Path, address: &str, hashed_seed: i64, level: &str) -> P
|
||||
.join(level_path(level))
|
||||
}
|
||||
|
||||
/// The official launcher's game folder (`HORIZON_MINECRAFT_DIR` if set),
|
||||
/// where Bobby keeps its cache (`.bobby`): as
|
||||
/// `horizon_prepare::artifacts::minecraft_dir` finds it, which this crate
|
||||
/// can't depend on.
|
||||
pub fn minecraft_dir() -> PathBuf {
|
||||
let home = || {
|
||||
let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
|
||||
std::env::var_os(var).map_or_else(|| PathBuf::from("."), PathBuf::from)
|
||||
};
|
||||
if let Some(dir) = std::env::var_os("HORIZON_MINECRAFT_DIR").filter(|d| !d.is_empty()) {
|
||||
let dir = PathBuf::from(dir);
|
||||
if let Ok(rest) = dir.strip_prefix("~") {
|
||||
return home().join(rest);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
home().join("Library/Application Support/minecraft")
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA")
|
||||
.map_or_else(home, PathBuf::from)
|
||||
.join(".minecraft")
|
||||
} else {
|
||||
home().join(".minecraft")
|
||||
}
|
||||
}
|
||||
|
||||
/// Bobby's folder of a level under `root` (`.bobby`), as Bobby leaves it on
|
||||
/// disk: `<address as typed, ':' as '_'>/<hashed seed>/<namespace>/<path>`.
|
||||
/// Names are compared without case, and `host` and `host_25565` both stand
|
||||
/// for the default port: neither was seen, so both are taken.
|
||||
pub fn bobby_level_dir(
|
||||
root: &Path,
|
||||
address: &str,
|
||||
hashed_seed: i64,
|
||||
level: &str,
|
||||
) -> Option<PathBuf> {
|
||||
let typed = address.trim().replace(':', "_");
|
||||
let bare = typed.strip_suffix("_25565").unwrap_or(&typed);
|
||||
if bare.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let names = [bare.to_owned(), format!("{bare}_25565")];
|
||||
let mut servers: Vec<PathBuf> = std::fs::read_dir(root)
|
||||
.ok()?
|
||||
.flatten()
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|name| names.iter().any(|n| n.eq_ignore_ascii_case(name)))
|
||||
})
|
||||
.map(|entry| entry.path())
|
||||
.collect();
|
||||
servers.sort();
|
||||
servers
|
||||
.into_iter()
|
||||
.map(|server| server.join(hashed_seed.to_string()).join(level_path(level)))
|
||||
.find(|dir| dir.is_dir())
|
||||
}
|
||||
|
||||
/// `<namespace>/<path>` of a level key.
|
||||
fn level_path(level: &str) -> PathBuf {
|
||||
let (namespace, path) = level.split_once(':').unwrap_or(("minecraft", level));
|
||||
@@ -104,29 +169,73 @@ type Regions = FxHashMap<(i32, i32), Option<Region>>;
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
regions: Regions,
|
||||
/// Bobby's, opened read-only.
|
||||
bobby: Regions,
|
||||
/// `None`: the chunk isn't stored.
|
||||
chunks: FxHashMap<(i32, i32), Option<Columns>>,
|
||||
}
|
||||
|
||||
/// A saved full chunk with its stored light, or `None` (`save::decode_lit`
|
||||
/// in the game).
|
||||
pub type DecodeChunk = Box<dyn Fn(&NamedTag) -> Option<Chunk> + Send + Sync>;
|
||||
|
||||
/// The chunks the player's Bobby kept of the same level: vanilla chunk NBT
|
||||
/// in region files, as Bobby leaves them on disk. Only ever read; a chunk
|
||||
/// far terrain needs from there is kept in the level's own folder.
|
||||
pub struct Bobby {
|
||||
dir: PathBuf,
|
||||
decode: DecodeChunk,
|
||||
/// `lod::state_kinds`, to summarize its chunks as loaded ones are.
|
||||
kinds: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Bobby {
|
||||
pub fn new(dir: PathBuf, decode: DecodeChunk, facts: &BlockFacts) -> Self {
|
||||
Self {
|
||||
dir,
|
||||
decode,
|
||||
kinds: state_kinds(facts),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One level's stored terrain, shared by the far-terrain threads. A chunk
|
||||
/// it doesn't have comes from the server's BlueMap, if the player named it.
|
||||
/// it doesn't have comes from Bobby's cache, else the server's BlueMap, if
|
||||
/// the player named it or Find BlueMap found it.
|
||||
pub struct ServerTerrain {
|
||||
dir: PathBuf,
|
||||
blue_map: Option<Arc<BlueMap>>,
|
||||
bobby: Option<Bobby>,
|
||||
state: Mutex<State>,
|
||||
warned: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl ServerTerrain {
|
||||
pub fn new(dir: PathBuf, blue_map: Option<Arc<BlueMap>>) -> Arc<Self> {
|
||||
Self::with_bobby(dir, blue_map, None)
|
||||
}
|
||||
|
||||
pub fn with_bobby(
|
||||
dir: PathBuf,
|
||||
blue_map: Option<Arc<BlueMap>>,
|
||||
bobby: Option<Bobby>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
dir,
|
||||
blue_map,
|
||||
bobby,
|
||||
state: Mutex::default(),
|
||||
warned: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// BlueMap's coarser levels at block (x, z) (a void there is `Ready`).
|
||||
fn coarser(&self, x: i32, z: i32) -> Found<Column> {
|
||||
self.blue_map
|
||||
.as_ref()
|
||||
.map_or(Found::Absent, |map| map.coarser_column(x, z))
|
||||
}
|
||||
|
||||
/// A chunk's columns: stored, else BlueMap's (`Waiting`: on its way).
|
||||
fn chunk_or_map(&self, x: i32, z: i32) -> Found<Columns> {
|
||||
if let Some(columns) = self.chunk(x, z) {
|
||||
@@ -139,19 +248,35 @@ impl ServerTerrain {
|
||||
|
||||
/// The stored chunk nearest the middle of the `side` × `side` chunks
|
||||
/// from `(x, z)`, found in the region headers without reading any.
|
||||
#[cfg(test)]
|
||||
fn nearest(&self, x: i32, z: i32, side: i32) -> Option<(i32, i32)> {
|
||||
self.kept(x, z, side).1
|
||||
}
|
||||
|
||||
/// How many of the `side` × `side` chunks from `(x, z)` are stored here
|
||||
/// or in Bobby's cache, and the one nearest their middle (`nearest`).
|
||||
fn kept(&self, x: i32, z: i32, side: i32) -> (usize, Option<(i32, i32)>) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let middle = (x * 2 + side, z * 2 + side);
|
||||
let mut count = 0;
|
||||
let mut best: Option<(i64, (i32, i32))> = None;
|
||||
for rz in z >> 5..=(z + side - 1) >> 5 {
|
||||
for rx in x >> 5..=(x + side - 1) >> 5 {
|
||||
let present = region(&mut state.regions, &self.dir, (rx, rz))
|
||||
let mut present = region(&mut state.regions, &self.dir, (rx, rz))
|
||||
.map_or([false; 1024], |r| r.present);
|
||||
if let Some(bobby) = &self.bobby
|
||||
&& let Some(theirs) = read_only(&mut state.bobby, &bobby.dir, (rx, rz))
|
||||
{
|
||||
for (here, there) in present.iter_mut().zip(theirs.present) {
|
||||
*here |= there;
|
||||
}
|
||||
}
|
||||
for (i, _) in present.iter().enumerate().filter(|(_, p)| **p) {
|
||||
let (cx, cz) = (rx * 32 + (i % 32) as i32, rz * 32 + (i / 32) as i32);
|
||||
if cx < x || cz < z || cx >= x + side || cz >= z + side {
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
let (dx, dz) = (
|
||||
i64::from(cx * 2 + 1 - middle.0),
|
||||
i64::from(cz * 2 + 1 - middle.1),
|
||||
@@ -163,26 +288,15 @@ impl ServerTerrain {
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, chunk)| chunk)
|
||||
(count, best.map(|(_, chunk)| chunk))
|
||||
}
|
||||
|
||||
/// A stored chunk's columns.
|
||||
/// A stored chunk's columns, else Bobby's (kept here from then on).
|
||||
pub fn chunk(&self, x: i32, z: i32) -> Option<Columns> {
|
||||
let key = (x >> 5, z >> 5);
|
||||
let here = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
if let Some(known) = state.chunks.get(&(x, z)) {
|
||||
return known.clone();
|
||||
}
|
||||
read(region(&mut state.regions, &self.dir, key), x, z)
|
||||
};
|
||||
// A record of another game version or layout is none.
|
||||
let columns = here.and_then(|record| {
|
||||
record
|
||||
.nbt(Limits::default())
|
||||
.ok()
|
||||
.and_then(|root| decode(&root.tag))
|
||||
});
|
||||
if let Some(known) = self.state.lock().unwrap().chunks.get(&(x, z)) {
|
||||
return known.clone();
|
||||
}
|
||||
let columns = self.stored(x, z).or_else(|| self.bobby_chunk(x, z));
|
||||
let mut state = self.state.lock().unwrap();
|
||||
// Stored by another thread meanwhile (`write`): that, newer than
|
||||
// what was read here.
|
||||
@@ -193,6 +307,51 @@ impl ServerTerrain {
|
||||
columns
|
||||
}
|
||||
|
||||
/// The chunk's record in the level's own folder; one of another game
|
||||
/// version or layout is none.
|
||||
fn stored(&self, x: i32, z: i32) -> Option<Columns> {
|
||||
let record = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
read(
|
||||
region(&mut state.regions, &self.dir, (x >> 5, z >> 5)),
|
||||
x,
|
||||
z,
|
||||
)
|
||||
}?;
|
||||
record
|
||||
.nbt(Limits::default())
|
||||
.ok()
|
||||
.and_then(|root| decode(&root.tag))
|
||||
}
|
||||
|
||||
/// A chunk in Bobby's cache, summarized and kept here. One of another
|
||||
/// game version, or that doesn't decode, is none.
|
||||
fn bobby_chunk(&self, x: i32, z: i32) -> Option<Columns> {
|
||||
let bobby = self.bobby.as_ref()?;
|
||||
let (record, kept) = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let key = (x >> 5, z >> 5);
|
||||
let kept =
|
||||
region(&mut state.regions, &self.dir, key).is_some_and(|r| r.present[slot(x, z)]);
|
||||
(
|
||||
read(read_only(&mut state.bobby, &bobby.dir, key), x, z),
|
||||
kept,
|
||||
)
|
||||
};
|
||||
let record = record?;
|
||||
let root = record.nbt(Limits::default()).ok()?;
|
||||
if root.tag.get("DataVersion") != Some(&Tag::Int(DATA_VERSION)) {
|
||||
return None;
|
||||
}
|
||||
let chunk = (bobby.decode)(&root).filter(|chunk| (chunk.x, chunk.z) == (x, z))?;
|
||||
let columns = summarize(&chunk, &bobby.kinds);
|
||||
// Not over a chunk the server sent while this one was decoded.
|
||||
if let Err(e) = self.write((x, z), &columns, Some(kept)) {
|
||||
self.warn_once(&e);
|
||||
}
|
||||
Some(columns)
|
||||
}
|
||||
|
||||
/// A chunk couldn't be kept (disk full, a folder it can't write): said
|
||||
/// once a level, not for every chunk after.
|
||||
fn warn_once(&self, error: &std::io::Error) {
|
||||
@@ -203,16 +362,29 @@ impl ServerTerrain {
|
||||
|
||||
/// Keep a chunk's columns, unless they are what is stored already.
|
||||
pub fn store(&self, key: (i32, i32), columns: &Columns) -> std::io::Result<()> {
|
||||
if self
|
||||
.chunk(key.0, key.1)
|
||||
.is_some_and(|old| *old == **columns)
|
||||
{
|
||||
let known = self.state.lock().unwrap().chunks.get(&key).cloned();
|
||||
let old = known.unwrap_or_else(|| {
|
||||
let old = self.stored(key.0, key.1);
|
||||
let mut state = self.state.lock().unwrap();
|
||||
if !state.chunks.contains_key(&key) {
|
||||
remember(&mut state, key, old.clone());
|
||||
}
|
||||
old
|
||||
});
|
||||
if old.is_some_and(|old| *old == **columns) {
|
||||
return Ok(());
|
||||
}
|
||||
self.write(key, columns)
|
||||
self.write(key, columns, None)
|
||||
}
|
||||
|
||||
fn write(&self, (x, z): (i32, i32), columns: &Columns) -> std::io::Result<()> {
|
||||
/// `kept`: whether the chunk's record was in the folder before; the
|
||||
/// columns aren't written over one another thread kept meanwhile.
|
||||
fn write(
|
||||
&self,
|
||||
(x, z): (i32, i32),
|
||||
columns: &Columns,
|
||||
kept: Option<bool>,
|
||||
) -> std::io::Result<()> {
|
||||
let root = NamedTag {
|
||||
name: "".into(),
|
||||
tag: encode(x, z, columns),
|
||||
@@ -220,6 +392,14 @@ impl ServerTerrain {
|
||||
let record = ChunkRecord::from_nbt(&root, Compression::Zlib, now(), Limits::default())?;
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let key = (x >> 5, z >> 5);
|
||||
if let Some(kept) = kept
|
||||
&& (matches!(state.chunks.get(&(x, z)), Some(Some(_)))
|
||||
|| (!kept
|
||||
&& region(&mut state.regions, &self.dir, key)
|
||||
.is_some_and(|r| r.present[slot(x, z)])))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if region(&mut state.regions, &self.dir, key).is_none() {
|
||||
std::fs::create_dir_all(&self.dir)?;
|
||||
let file = RegionFile::open_writable(region_path(&self.dir, key), true)?;
|
||||
@@ -239,12 +419,26 @@ fn region_path(dir: &Path, (rx, rz): (i32, i32)) -> PathBuf {
|
||||
|
||||
/// A region of `dir`, opened on first use.
|
||||
fn region<'a>(regions: &'a mut Regions, dir: &Path, key: (i32, i32)) -> Option<&'a mut Region> {
|
||||
open(regions, key, || {
|
||||
RegionFile::open_writable(region_path(dir, key), false)
|
||||
})
|
||||
}
|
||||
|
||||
/// A region of Bobby's `dir`, opened for reading only.
|
||||
fn read_only<'a>(regions: &'a mut Regions, dir: &Path, key: (i32, i32)) -> Option<&'a mut Region> {
|
||||
open(regions, key, || RegionFile::open(region_path(dir, key)))
|
||||
}
|
||||
|
||||
fn open(
|
||||
regions: &mut Regions,
|
||||
key: (i32, i32),
|
||||
file: impl FnOnce() -> std::io::Result<RegionFile>,
|
||||
) -> Option<&mut Region> {
|
||||
if !regions.contains_key(&key) {
|
||||
if regions.len() >= MAX_REGIONS {
|
||||
regions.clear();
|
||||
}
|
||||
let file = RegionFile::open_writable(region_path(dir, key), false);
|
||||
regions.insert(key, file.ok().map(Region::new));
|
||||
regions.insert(key, file().ok().map(Region::new));
|
||||
}
|
||||
regions.get_mut(&key)?.as_mut()
|
||||
}
|
||||
@@ -378,13 +572,37 @@ impl LodSource for ServerTerrain {
|
||||
Box::new(Sampler {
|
||||
terrain: self,
|
||||
details: Vec::new(),
|
||||
waited: false,
|
||||
})
|
||||
}
|
||||
fn keeps_observed(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Sampler {
|
||||
/// For a column whose chunk of the map is on its way: its coarser
|
||||
/// levels' meanwhile, as they come first (a void there is drawn as
|
||||
/// one). None there either is a hole (`waited`: the sample isn't drawn);
|
||||
/// either way the column is sampled again when its tile arrives
|
||||
/// (`Lod::refresh`).
|
||||
fn coarser(&mut self, x: i32, z: i32) -> Column {
|
||||
match self.terrain.coarser(x, z) {
|
||||
Found::Ready(column) => column,
|
||||
Found::Waiting | Found::Absent => {
|
||||
self.waited = true;
|
||||
Column::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Sampler {
|
||||
terrain: Arc<ServerTerrain>,
|
||||
details: Vec<LodDetail>,
|
||||
/// A column was left empty for BlueMap's on its way, with no coarser
|
||||
/// level to stand in (`waited`).
|
||||
waited: bool,
|
||||
}
|
||||
|
||||
impl LodSampler for Sampler {
|
||||
@@ -392,10 +610,12 @@ impl LodSampler for Sampler {
|
||||
Some(Box::new(Self {
|
||||
terrain: self.terrain.clone(),
|
||||
details: Vec::new(),
|
||||
waited: false,
|
||||
}))
|
||||
}
|
||||
/// Stored chunks come back whole, else BlueMap's; the others are known
|
||||
/// empty. One BlueMap is still downloading is asked again later.
|
||||
/// Stored chunks come back whole, else BlueMap's (its coarser levels'
|
||||
/// where the finest is out of reach); the others are known empty. One
|
||||
/// whose BlueMap tile is still downloading is asked again later.
|
||||
fn request_detail(&mut self, chunks: &[(i32, i32)], _center: [i32; 2]) {
|
||||
for &(x, z) in chunks {
|
||||
let columns = match self.terrain.chunk_or_map(x, z) {
|
||||
@@ -420,41 +640,61 @@ impl LodSampler for Sampler {
|
||||
fn column(&mut self, x: i32, z: i32) -> Column {
|
||||
match self.terrain.chunk_or_map(x >> 4, z >> 4) {
|
||||
Found::Ready(c) => c[((z & 15) * 16 + (x & 15)) as usize].clone(),
|
||||
_ => Column::default(),
|
||||
Found::Waiting => self.coarser(x, z),
|
||||
Found::Absent => Column::default(),
|
||||
}
|
||||
}
|
||||
fn waited(&mut self) -> bool {
|
||||
std::mem::take(&mut self.waited)
|
||||
}
|
||||
/// A cell of `width` blocks from one stored chunk: its own area up to a
|
||||
/// chunk, else the stored chunk nearest the cell's middle (the server
|
||||
/// sent only the chunks a player came near). With a BlueMap, a wider
|
||||
/// sent only the chunks a player came near), when most of the cell is
|
||||
/// stored. With a BlueMap, a wider
|
||||
/// cell takes the chunk at its middle if stored, else the map's pixel
|
||||
/// there, and the nearest stored chunk only while the map has none.
|
||||
fn reduced_column(&mut self, x: i32, z: i32, width: i32) -> Column {
|
||||
if width <= 16 {
|
||||
let Found::Ready(chunk) = self.terrain.chunk_or_map(x >> 4, z >> 4) else {
|
||||
return Column::default();
|
||||
let chunk = match self.terrain.chunk_or_map(x >> 4, z >> 4) {
|
||||
Found::Ready(chunk) => chunk,
|
||||
Found::Waiting => return self.coarser(x, z),
|
||||
Found::Absent => return Column::default(),
|
||||
};
|
||||
let width = width.max(1) as usize;
|
||||
let lx = (x & 15) as usize & !(width - 1);
|
||||
let lz = (z & 15) as usize & !(width - 1);
|
||||
return Column::reduced(&chunk, lx, lz, width);
|
||||
}
|
||||
let mut waiting = false;
|
||||
if let Some(map) = &self.terrain.blue_map {
|
||||
let (mx, mz) = ((x + width / 2) >> 4, (z + width / 2) >> 4);
|
||||
if let Some(chunk) = self.terrain.chunk(mx, mz) {
|
||||
return Column::reduced(&chunk, 0, 0, 16);
|
||||
}
|
||||
if let Found::Ready(column) = map.reduced_column(x, z, width) {
|
||||
return column;
|
||||
match map.reduced_column(x, z, width) {
|
||||
Found::Ready(column) => return column,
|
||||
Found::Waiting => waiting = true,
|
||||
Found::Absent => {}
|
||||
}
|
||||
}
|
||||
// Searching wider cells would open thousands of region files.
|
||||
if width >> 4 > MAX_KEPT_SIDE {
|
||||
// Only over a cell the server sent most of, as `Column::reduced`
|
||||
// keeps what fills half a cell: one chunk drawn as a whole cell stood
|
||||
// as a flat slab far wider than anything seen there, until finer
|
||||
// tiles replaced it with the nothing they know of.
|
||||
let side = width >> 4;
|
||||
// Wider cells are never mostly sent, and counting them would open
|
||||
// thousands of region files.
|
||||
if side > MAX_KEPT_SIDE {
|
||||
self.waited |= waiting;
|
||||
return Column::default();
|
||||
}
|
||||
self.terrain
|
||||
.nearest(x >> 4, z >> 4, width >> 4)
|
||||
.and_then(|(cx, cz)| self.terrain.chunk(cx, cz))
|
||||
.map_or_else(Column::default, |chunk| Column::reduced(&chunk, 0, 0, 16))
|
||||
let (count, nearest) = self.terrain.kept(x >> 4, z >> 4, side);
|
||||
let column = (count * 2 >= (side * side) as usize)
|
||||
.then(|| nearest.and_then(|(cx, cz)| self.terrain.chunk(cx, cz)))
|
||||
.flatten()
|
||||
.map(|chunk| Column::reduced(&chunk, 0, 0, 16));
|
||||
self.waited |= waiting && column.is_none();
|
||||
column.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,8 +792,206 @@ mod tests {
|
||||
let mut sampler = terrain.clone().sampler();
|
||||
assert_eq!(sampler.sample(-33 * 16 + 1, 5 * 16), columns[1].surface);
|
||||
assert_eq!(sampler.column(0, 0), Column::default());
|
||||
// A far cell finds the stored chunk anywhere in it.
|
||||
assert_ne!(sampler.reduced_column(-64 * 16, 0, 1024), Column::default());
|
||||
// One chunk doesn't stand for a far cell of 64 × 64.
|
||||
assert_eq!(sampler.reduced_column(-64 * 16, 0, 1024), Column::default());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_cells_are_drawn_where_most_of_them_was_kept() {
|
||||
let dir = scratch("server-terrain-wide");
|
||||
let columns = columns();
|
||||
let terrain = ServerTerrain::new(dir.clone(), None);
|
||||
let mut sampler = terrain.clone().sampler();
|
||||
// A cell of 4 × 4 chunks, and a chunk of it kept.
|
||||
terrain.store((2, 1), &columns).unwrap();
|
||||
assert_eq!(sampler.reduced_column(0, 0, 64), Column::default());
|
||||
for (x, z) in [(0, 0), (1, 0), (2, 0), (3, 0), (0, 1), (1, 1)] {
|
||||
terrain.store((x, z), &columns).unwrap();
|
||||
assert_eq!(sampler.reduced_column(0, 0, 64), Column::default());
|
||||
}
|
||||
// Half of it: drawn from the kept chunk nearest its middle.
|
||||
terrain.store((2, 2), &columns).unwrap();
|
||||
assert_eq!(
|
||||
sampler.reduced_column(0, 0, 64),
|
||||
Column::reduced(&columns, 0, 0, 16)
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// Far terrain samples a cell before the server sent its chunk, then
|
||||
/// again once it is kept: nothing remembers the empty sample.
|
||||
#[test]
|
||||
fn a_chunk_kept_after_an_empty_sample_is_sampled_next_time() {
|
||||
let dir = scratch("server-terrain-late");
|
||||
let columns = columns();
|
||||
let terrain = ServerTerrain::new(dir.clone(), None);
|
||||
let mut sampler = terrain.clone().sampler();
|
||||
let mut other = sampler.fork().expect("a sampling thread's own");
|
||||
for width in [1, 4, 16] {
|
||||
assert_eq!(other.reduced_column(-16, 32, width), Column::default());
|
||||
}
|
||||
assert_eq!(other.grid(-16, 32, 1, 16), vec![Column::default(); 256]);
|
||||
// `Job::Observe` stores it on the far-terrain worker.
|
||||
sampler.observed((-1, 2), &columns);
|
||||
assert_eq!(other.grid(-16, 32, 1, 16), columns.to_vec());
|
||||
assert_eq!(
|
||||
other.reduced_column(-16, 32, 16),
|
||||
Column::reduced(&columns, 0, 0, 16)
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bobby_folders_follow_the_address_as_typed() {
|
||||
let root = scratch("bobby-folders");
|
||||
for dir in [
|
||||
"127.0.0.1/-9030034075591913594/minecraft/overworld",
|
||||
"127.0.0.1/-9030034075591913594/minecraft/the_nether",
|
||||
"127.0.0.1/4696776658964135993/minecraft/overworld",
|
||||
"localhost_25566/-7181178862026813658/minecraft/overworld",
|
||||
"Play.Example.net_25565/5/minecraft/overworld",
|
||||
] {
|
||||
std::fs::create_dir_all(root.join(dir)).unwrap();
|
||||
}
|
||||
let find = |address: &str, seed: i64, level: &str| {
|
||||
bobby_level_dir(&root, address, seed, level)
|
||||
.map(|dir| dir.strip_prefix(&root).unwrap().to_owned())
|
||||
};
|
||||
let overworld = "minecraft:overworld";
|
||||
let first = Some(PathBuf::from(
|
||||
"127.0.0.1/-9030034075591913594/minecraft/overworld",
|
||||
));
|
||||
assert_eq!(find("127.0.0.1", -9030034075591913594, overworld), first);
|
||||
assert_eq!(
|
||||
find("127.0.0.1:25565", -9030034075591913594, overworld),
|
||||
first
|
||||
);
|
||||
assert_eq!(
|
||||
find("127.0.0.1", -9030034075591913594, "minecraft:the_nether"),
|
||||
Some(PathBuf::from(
|
||||
"127.0.0.1/-9030034075591913594/minecraft/the_nether"
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
find("127.0.0.1", 4696776658964135993, overworld),
|
||||
Some(PathBuf::from(
|
||||
"127.0.0.1/4696776658964135993/minecraft/overworld"
|
||||
))
|
||||
);
|
||||
let other_port = Some(PathBuf::from(
|
||||
"localhost_25566/-7181178862026813658/minecraft/overworld",
|
||||
));
|
||||
assert_eq!(
|
||||
find("localhost:25566", -7181178862026813658, overworld),
|
||||
other_port
|
||||
);
|
||||
assert_eq!(
|
||||
find("LocalHost:25566", -7181178862026813658, overworld),
|
||||
other_port
|
||||
);
|
||||
assert_eq!(
|
||||
find("play.example.net", 5, overworld),
|
||||
Some(PathBuf::from(
|
||||
"Play.Example.net_25565/5/minecraft/overworld"
|
||||
))
|
||||
);
|
||||
// Another world behind the address, another dimension, another port.
|
||||
assert_eq!(find("127.0.0.1", 1111, overworld), None);
|
||||
assert_eq!(
|
||||
find("127.0.0.1", -9030034075591913594, "minecraft:the_end"),
|
||||
None
|
||||
);
|
||||
assert_eq!(find("localhost", -7181178862026813658, overworld), None);
|
||||
assert_eq!(
|
||||
find("localhost:25567", -7181178862026813658, overworld),
|
||||
None
|
||||
);
|
||||
assert_eq!(find("", 0, overworld), None);
|
||||
assert_eq!(
|
||||
bobby_level_dir(&root.join("none"), "127.0.0.1", 0, overworld),
|
||||
None
|
||||
);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
/// A Bobby-shaped region of chunk records, written with `version`.
|
||||
fn bobby_region(dir: &Path, chunks: &[(i32, i32)], version: i32) {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
let (rx, rz) = (chunks[0].0 >> 5, chunks[0].1 >> 5);
|
||||
let mut file = RegionFile::open_writable(region_path(dir, (rx, rz)), true).unwrap();
|
||||
for &(x, z) in chunks {
|
||||
let root = NamedTag {
|
||||
name: "".into(),
|
||||
tag: Tag::compound([
|
||||
("DataVersion", Tag::Int(version)),
|
||||
("Status", Tag::String("full".into())),
|
||||
("xPos", Tag::Int(x)),
|
||||
("zPos", Tag::Int(z)),
|
||||
]),
|
||||
};
|
||||
let record = ChunkRecord::from_nbt(&root, Compression::Zlib, 0, Limits::default());
|
||||
file.write(ChunkPos::new(x, z), &record.unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stone from y 60 to 70 in every column (the game decodes vanilla chunk NBT
|
||||
/// with `save::decode_lit`; its palettes are tested there).
|
||||
fn stone(root: &NamedTag) -> Option<Chunk> {
|
||||
let (Some(Tag::Int(x)), Some(Tag::Int(z))) = (root.tag.get("xPos"), root.tag.get("zPos"))
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let mut chunk = Chunk::empty(*x, *z);
|
||||
for i in 0..256 {
|
||||
for y in 60..70 {
|
||||
chunk.set(i & 15, y, i >> 4, 1);
|
||||
}
|
||||
}
|
||||
Some(chunk)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunks_only_bobby_has_are_read_once_and_kept() {
|
||||
let dir = scratch("server-terrain-bobby");
|
||||
let (own, theirs) = (dir.join("own"), dir.join("bobby"));
|
||||
bobby_region(&theirs, &[(-3, 5), (-2, 5), (-1, 6)], DATA_VERSION);
|
||||
bobby_region(&theirs.join("old"), &[(-3, 5)], DATA_VERSION - 1);
|
||||
let region = region_path(&theirs, (-1, 0));
|
||||
let before = std::fs::read(®ion).unwrap();
|
||||
// State 0 air, state 1 a kept block (`lod::state_kinds`).
|
||||
let bobby = |dir: &Path| Bobby {
|
||||
dir: dir.to_owned(),
|
||||
decode: Box::new(stone),
|
||||
kinds: vec![4, 1],
|
||||
};
|
||||
let terrain = ServerTerrain::with_bobby(own.clone(), None, Some(bobby(&theirs)));
|
||||
// Counted for wide cells before they are read.
|
||||
assert_eq!(terrain.kept(-4, 4, 4), (3, Some((-3, 5))));
|
||||
let expected = summarize(
|
||||
&stone(&NamedTag {
|
||||
name: "".into(),
|
||||
tag: Tag::compound([("xPos", Tag::Int(-2)), ("zPos", Tag::Int(5))]),
|
||||
})
|
||||
.unwrap(),
|
||||
&[4, 1],
|
||||
);
|
||||
assert!(expected[0].surface.is_some());
|
||||
assert_eq!(terrain.chunk(-2, 5), Some(expected.clone()));
|
||||
assert_eq!(terrain.chunk(-2, 6), None);
|
||||
let mut sampler = terrain.clone().sampler();
|
||||
assert_eq!(sampler.column(-2 * 16 + 3, 5 * 16), expected[3]);
|
||||
drop(sampler);
|
||||
drop(terrain);
|
||||
// Kept in the level's own folder; Bobby's left as it was.
|
||||
let kept = ServerTerrain::new(own.clone(), None);
|
||||
assert_eq!(kept.chunk(-2, 5), Some(expected));
|
||||
assert_eq!(kept.chunk(-3, 5), None);
|
||||
assert_eq!(std::fs::read(®ion).unwrap(), before);
|
||||
// Another game version's chunk is none.
|
||||
let old =
|
||||
ServerTerrain::with_bobby(dir.join("own-old"), None, Some(bobby(&theirs.join("old"))));
|
||||
assert_eq!(old.chunk(-3, 5), None);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Setup's monitor map and draft window choices. Apply is consumed by the game adapter.
|
||||
use crate::{
|
||||
gamepad::Button as PadButton,
|
||||
graphics_style::STYLE,
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
lang,
|
||||
@@ -48,12 +49,28 @@ pub struct SplitUi {
|
||||
/// Names: the names in the views' corners are shown (the game sets it), and pressed.
|
||||
pub names_shown: bool,
|
||||
pub toggle_names: bool,
|
||||
/// P1 has a controller (the game sets it), and Give Up Pad pressed: it's offered to join.
|
||||
pub p1_pad: bool,
|
||||
pub give_up_pad: bool,
|
||||
edit: Option<Draft>,
|
||||
selected: Option<usize>,
|
||||
/// A tile held by the mouse: whose, and where the pointer is (`None` until it moves).
|
||||
drag: Option<(usize, Option<(f32, f32)>)>,
|
||||
/// The screen's size at the last press: where a drag's release lands.
|
||||
size: (f32, f32),
|
||||
/// A player picked up with the controller (A), moved with the bumpers.
|
||||
held: Option<Held>,
|
||||
/// Dropped or put back: the player whose tile the controller's focus goes to.
|
||||
refocus: Option<usize>,
|
||||
/// The screen's size: at the last draw (the options screen sets it) or press.
|
||||
pub(crate) size: (f32, f32),
|
||||
}
|
||||
|
||||
/// A player picked up with the controller: where it could go, the one picked so far, and the
|
||||
/// draft with it there (the map shows it).
|
||||
struct Held {
|
||||
player: usize,
|
||||
spots: Vec<(String, Drop)>,
|
||||
at: Option<usize>,
|
||||
preview: Option<Draft>,
|
||||
}
|
||||
|
||||
/// Where a dragged player goes: a place (`Spot`), or a place among a window's players.
|
||||
@@ -73,6 +90,8 @@ enum Target {
|
||||
Identify,
|
||||
/// The players' names in their views' corners, on or off.
|
||||
Names,
|
||||
/// P1's controller freed and offered to join (handed to a friend).
|
||||
GiveUpPad,
|
||||
Revert,
|
||||
Apply,
|
||||
Done,
|
||||
@@ -124,9 +143,113 @@ impl SplitUi {
|
||||
self.selected = None;
|
||||
self.apply = None;
|
||||
self.drag = None;
|
||||
self.held = None;
|
||||
}
|
||||
/// The draft shown: with a held player where the controller put it, else the changes so
|
||||
/// far, else the arrangement now.
|
||||
fn draft(&self) -> Option<&Draft> {
|
||||
self.edit.as_ref().or(self.live.as_ref())
|
||||
let preview = self.held.as_ref().and_then(|h| h.preview.as_ref());
|
||||
preview.or(self.edit.as_ref()).or(self.live.as_ref())
|
||||
}
|
||||
|
||||
/// The controller (`pad_menu`), A at `at`: on a player's tile, picks it up. True when it did
|
||||
/// (the press is taken).
|
||||
pub fn pad_pick_up(&mut self, at: (f32, f32)) -> bool {
|
||||
let (w, h) = self.size;
|
||||
let hit = self
|
||||
.buttons(w, h)
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|([x, y, bw, bh], _, _, active)| *active && ui::hovered(at, *x, *y, *bw, *bh));
|
||||
let Some((_, _, Target::Player(i), _)) = hit else {
|
||||
return false;
|
||||
};
|
||||
self.selected = Some(i);
|
||||
let spots = self.stops(i);
|
||||
self.held = Some(Held {
|
||||
player: i,
|
||||
spots,
|
||||
at: None,
|
||||
preview: None,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// The controller while a player is held: the bumpers or left and right cycle where it goes,
|
||||
/// as when joining (the map shows it there), A drops it there (into the draft), B puts it
|
||||
/// back. True when the button was taken: every button, while one is held.
|
||||
pub fn pad_button(&mut self, button: PadButton) -> bool {
|
||||
let Some(held) = &self.held else {
|
||||
return false;
|
||||
};
|
||||
let player = held.player;
|
||||
match button {
|
||||
PadButton::LeftBumper
|
||||
| PadButton::RightBumper
|
||||
| PadButton::DPadLeft
|
||||
| PadButton::DPadRight => {
|
||||
let n = held.spots.len();
|
||||
if n == 0 {
|
||||
return true;
|
||||
}
|
||||
let back = matches!(button, PadButton::LeftBumper | PadButton::DPadLeft);
|
||||
let next = match held.at {
|
||||
None if back => n - 1,
|
||||
None => 0,
|
||||
Some(k) if back => (k + n - 1) % n,
|
||||
Some(k) => (k + 1) % n,
|
||||
};
|
||||
let (player, drop) = (held.player, held.spots[next].1.clone());
|
||||
let open = self.open_now();
|
||||
let preview = self.edit.clone().or(self.live.clone()).map(|mut draft| {
|
||||
match drop {
|
||||
Drop::At(spot) => draft.place_player(player, spot),
|
||||
Drop::Place(n, k) => {
|
||||
draft.place_player(player, Spot::Window(n));
|
||||
draft.put_at(player, k);
|
||||
}
|
||||
}
|
||||
draft.drop_empty(&open);
|
||||
draft
|
||||
});
|
||||
if let Some(held) = &mut self.held {
|
||||
held.at = Some(next);
|
||||
held.preview = preview;
|
||||
}
|
||||
}
|
||||
PadButton::South => {
|
||||
let held = self.held.take().expect("held");
|
||||
// One highlight after: the focus on the player where it landed, not selected too.
|
||||
(self.selected, self.refocus) = (None, Some(held.player));
|
||||
match held.at.map(|k| held.spots[k].1.clone()) {
|
||||
Some(Drop::At(spot)) => self.place(held.player, spot),
|
||||
Some(Drop::Place(n, k)) => self.place_at(held.player, n, k),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
PadButton::East => {
|
||||
(self.selected, self.refocus) = (None, Some(player));
|
||||
self.held = None;
|
||||
}
|
||||
// While one is held, the focus stays put: A drops, it presses nothing else.
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether a player is held (the controller's left and right cycle it).
|
||||
pub fn holding(&self) -> bool {
|
||||
self.held.is_some()
|
||||
}
|
||||
|
||||
/// The tile of the player just dropped or put back, for the controller's focus. Taken once.
|
||||
pub fn take_refocus(&mut self) -> Option<[f32; 4]> {
|
||||
let i = self.refocus.take()?;
|
||||
let (w, h) = self.size;
|
||||
self.buttons(w, h)
|
||||
.into_iter()
|
||||
.find(|b| b.2 == Target::Player(i))
|
||||
.map(|b| b.0)
|
||||
}
|
||||
fn map(&self, w: f32, h: f32) -> split_map::Map {
|
||||
if self.idle {
|
||||
@@ -270,8 +393,18 @@ impl SplitUi {
|
||||
Target::Switch,
|
||||
!self.on || self.names.len() <= 1,
|
||||
);
|
||||
// P1's controller handed to a friend: it's offered to join.
|
||||
let give_up = (
|
||||
[90.0, 6.0, 80.0, 20.0],
|
||||
tr("give_up_pad", &[]),
|
||||
Target::GiveUpPad,
|
||||
true,
|
||||
);
|
||||
if !self.on || self.idle {
|
||||
let mut out = vec![switch];
|
||||
if self.on && self.p1_pad {
|
||||
out.push(give_up);
|
||||
}
|
||||
row(&mut out, h - 27.0, vec![done]);
|
||||
return out;
|
||||
}
|
||||
@@ -329,6 +462,9 @@ impl SplitUi {
|
||||
true,
|
||||
);
|
||||
out.extend([switch, names]);
|
||||
if self.p1_pad {
|
||||
out.push(give_up);
|
||||
}
|
||||
// A square for how 2 (or 3) players share a window, while a window has that many (drawn
|
||||
// as the layout; the label is for the control socket and the tests).
|
||||
let mut footer = Vec::new();
|
||||
@@ -373,6 +509,7 @@ impl SplitUi {
|
||||
Target::Switch => tr("help.switch", &[]),
|
||||
Target::Identify => tr("help.identify", &[]),
|
||||
Target::Names => tr("help.names", &[]),
|
||||
Target::GiveUpPad => tr("help.give_up_pad", &[]),
|
||||
Target::Revert if *active => tr("help.revert", &[]),
|
||||
Target::Apply if *active => tr("help.apply", &[]),
|
||||
Target::Revert | Target::Apply => tr("help.unchanged", &[]),
|
||||
@@ -542,7 +679,9 @@ impl SplitUi {
|
||||
([x, y, bw, bh], label, target, active): &Button,
|
||||
) {
|
||||
let (mut x, mut y, mut bw, mut bh) = (*x, *y, *bw, *bh);
|
||||
let hover = *active && ui::hovered(cursor, x, y, bw, bh);
|
||||
// While a player is held, only its tile is lit: the controller's pointer stays where
|
||||
// the pick-up was, over whichever tile is there now.
|
||||
let hover = *active && self.held.is_none() && ui::hovered(cursor, x, y, bw, bh);
|
||||
let selected = matches!(target, Target::Player(i) if Some(*i) == self.selected);
|
||||
if matches!(target, Target::Player(_)) {
|
||||
(x, y, bw, bh) = (x + 1.0, y + 1.0, (bw - 2.0).max(4.0), (bh - 2.0).max(4.0));
|
||||
@@ -571,6 +710,16 @@ impl SplitUi {
|
||||
*active,
|
||||
hover || selected,
|
||||
);
|
||||
// The held player: "< Name >", the arrows lit, for left and right.
|
||||
if matches!(target, Target::Player(i) if self.held.as_ref().is_some_and(|h| h.player == *i))
|
||||
{
|
||||
let (half, cx) = (HudBatch::text_width(atlas, label) / 2.0, x + bw / 2.0);
|
||||
let ty = (y + (bh - 8.0) / 2.0).floor();
|
||||
let lit = rgba(255, 255, 85, 255);
|
||||
let lw = HudBatch::text_width(atlas, "<");
|
||||
batch.text(atlas, "<", (cx - half - 4.0 - lw).floor(), ty, lit, true);
|
||||
batch.text(atlas, ">", (cx + half + 4.0).ceil(), ty, lit, true);
|
||||
}
|
||||
// A layout's square: its panes, in a 14 x 10 window; Auto a letter. Its name for the
|
||||
// control socket, which lists widgets by what they say.
|
||||
if glyph.is_some() {
|
||||
@@ -601,7 +750,19 @@ impl SplitUi {
|
||||
buttons: &[Button],
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let held = self.held.as_ref().map(|h| {
|
||||
let who = self
|
||||
.names
|
||||
.get(h.player)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| tr("this_player", &[]));
|
||||
match h.at {
|
||||
Some(k) => tr("note.held_at", &[&who, &h.spots[k].0]),
|
||||
None => tr("note.held", &[&who]),
|
||||
}
|
||||
});
|
||||
let note = match self.selected {
|
||||
_ if held.is_some() => held.unwrap_or_default(),
|
||||
None => tr("note.select", &[]),
|
||||
Some(_) if self.edit.is_some() => tr("note.apply", &[]),
|
||||
Some(i) => tr(
|
||||
@@ -651,6 +812,10 @@ impl SplitUi {
|
||||
/// A press at `(x, y)` on a screen `w` x `h`, or the mouse moving with it held (`drag`).
|
||||
pub fn click(&mut self, (x, y): (f32, f32), (w, h): (f32, f32), drag: bool) -> Click {
|
||||
self.size = (w, h);
|
||||
// The mouse takes over from a player held with the controller.
|
||||
if !drag {
|
||||
self.held = None;
|
||||
}
|
||||
if drag {
|
||||
if let Some((_, at)) = &mut self.drag {
|
||||
*at = Some((x, y));
|
||||
@@ -690,6 +855,7 @@ impl SplitUi {
|
||||
}
|
||||
Target::Identify => self.identify = true,
|
||||
Target::Names => self.toggle_names = true,
|
||||
Target::GiveUpPad => self.give_up_pad = true,
|
||||
Target::Revert => self.edit = None,
|
||||
Target::Apply => {
|
||||
// Windows emptied in the draft close now, not as players moved.
|
||||
@@ -1237,6 +1403,133 @@ mod tests {
|
||||
// Alone: neither.
|
||||
assert_eq!(labels(&screen(1)).count(), 0);
|
||||
}
|
||||
|
||||
fn centre(ui: &SplitUi, label: &str) -> (f32, f32) {
|
||||
let [x, y, w, h] = rect(ui, label);
|
||||
(x + w / 2.0, y + h / 2.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_controller_picks_a_player_up_moves_it_and_drops_it() {
|
||||
let mut ui = screen(2);
|
||||
ui.size = SIZE;
|
||||
assert!(ui.pad_pick_up(centre(&ui, "P2")));
|
||||
// Nothing moves until the bumpers do; then the map shows it there: the places in Window 1
|
||||
// first, then a new window.
|
||||
assert_eq!(ui.draft().unwrap().players, [1, 1]);
|
||||
for _ in 0..3 {
|
||||
assert!(ui.pad_button(PadButton::RightBumper));
|
||||
}
|
||||
assert_eq!(ui.draft().unwrap().players, [1, 2]);
|
||||
assert_eq!(ui.edit, None);
|
||||
// Right again: filling the TV; A drops it there, into the draft.
|
||||
assert!(ui.pad_button(PadButton::RightBumper));
|
||||
assert!(ui.pad_button(PadButton::South));
|
||||
let draft = ui.edit.as_ref().unwrap();
|
||||
assert_eq!(draft.players, [1, 2]);
|
||||
assert_eq!(draft.windows[1].placement, Placement::Fill("TV".into()));
|
||||
// Not held any more: the buttons go back to the menu.
|
||||
assert!(!ui.pad_button(PadButton::RightBumper));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_controller_moves_a_player_to_a_place_among_the_others() {
|
||||
let mut ui = screen(2);
|
||||
ui.size = SIZE;
|
||||
assert!(ui.pad_pick_up(centre(&ui, "P2")));
|
||||
let stops: Vec<String> = ui
|
||||
.held
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.spots
|
||||
.iter()
|
||||
.map(|s| s.0.clone())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
&stops[..3],
|
||||
["Window 1 (1 of 2)", "Window 1 (2 of 2)", "New window"]
|
||||
);
|
||||
assert!(
|
||||
!stops.iter().any(|s| s.starts_with("Swap")),
|
||||
"no swaps: it joins"
|
||||
);
|
||||
// RB to the first place: before P1.
|
||||
assert!(ui.pad_button(PadButton::RightBumper));
|
||||
assert!(ui.pad_button(PadButton::South));
|
||||
assert_eq!(ui.edit.as_ref().unwrap().order, [1, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_replaces_preview_but_keeps_the_switch_and_pad_handoff() {
|
||||
let mut ui = screen(1);
|
||||
ui.on = true;
|
||||
ui.idle = true;
|
||||
ui.p1_pad = true;
|
||||
assert_eq!(ui.status_lines().unwrap()[0], "Splitscreen idle");
|
||||
assert!(ui.map(SIZE.0, SIZE.1).2.is_empty());
|
||||
let buttons = ui.buttons(SIZE.0, SIZE.1);
|
||||
assert!(buttons.iter().any(|b| b.2 == Target::GiveUpPad));
|
||||
assert!(
|
||||
!buttons
|
||||
.iter()
|
||||
.any(|b| matches!(b.2, Target::Player(_) | Target::Apply | Target::Revert))
|
||||
);
|
||||
press(&mut ui, "Give Up Pad");
|
||||
assert!(ui.give_up_pad);
|
||||
ui.idle = false;
|
||||
assert!(ui.status_lines().is_none());
|
||||
assert_eq!(ui.map(SIZE.0, SIZE.1).2.len(), 1);
|
||||
ui.on = false;
|
||||
assert_eq!(ui.status_lines().unwrap()[0], "Splitscreen is off.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn give_up_pad_shows_only_while_p1_has_one() {
|
||||
let mut ui = screen(1);
|
||||
ui.on = true;
|
||||
let shown = |ui: &SplitUi| {
|
||||
ui.buttons(SIZE.0, SIZE.1)
|
||||
.iter()
|
||||
.any(|b| b.2 == Target::GiveUpPad)
|
||||
};
|
||||
assert!(!shown(&ui));
|
||||
ui.p1_pad = true;
|
||||
assert!(shown(&ui));
|
||||
press(&mut ui, "Give Up Pad");
|
||||
assert!(ui.give_up_pad);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b_puts_a_held_player_back() {
|
||||
let mut ui = screen(2);
|
||||
ui.size = SIZE;
|
||||
assert!(ui.pad_pick_up(centre(&ui, "P2")));
|
||||
ui.pad_button(PadButton::RightBumper);
|
||||
assert!(ui.pad_button(PadButton::East));
|
||||
assert_eq!(ui.edit, None);
|
||||
assert_eq!(ui.draft().unwrap().players, [1, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_or_put_back_one_highlight_and_the_focus_follows() {
|
||||
let mut ui = screen(2);
|
||||
ui.size = SIZE;
|
||||
assert!(ui.pad_pick_up(centre(&ui, "P2")));
|
||||
assert!(ui.pad_button(PadButton::RightBumper)); // to a new window
|
||||
assert!(ui.pad_button(PadButton::South));
|
||||
// Not selected (the focus is its highlight), and the focus goes to its new tile.
|
||||
assert_eq!(ui.selected, None);
|
||||
assert_eq!(ui.take_refocus(), Some(rect(&ui, "P2")));
|
||||
assert_eq!(ui.take_refocus(), None);
|
||||
// Put back: the same.
|
||||
assert!(ui.pad_pick_up(centre(&ui, "P2")));
|
||||
assert!(ui.pad_button(PadButton::East));
|
||||
assert_eq!(
|
||||
(ui.selected, ui.take_refocus()),
|
||||
(None, Some(rect(&ui, "P2")))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_switch_and_pack_overrides_keep_the_same_draft() {
|
||||
// The language table is process-wide. Run this test alone in a child so
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
//! Shared nearest-sampled sprite array for block and item textures.
|
||||
use crate::Result;
|
||||
use horizon_assets::animation::{AnimationClip, AnimationMetadata};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::{self, File},
|
||||
io::BufReader,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
use std::{collections::BTreeMap, path::Path, sync::Arc};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SpriteAnimation {
|
||||
@@ -427,7 +421,9 @@ impl Sprites {
|
||||
per_tile: bool,
|
||||
) -> Result<Vec<Option<u32>>> {
|
||||
let path = crate::atlas_sources::texture_path(root, name, "textures");
|
||||
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
|
||||
let metadata = match horizon_core::prepared_files::read_to_string(
|
||||
&path.with_extension("png.mcmeta"),
|
||||
) {
|
||||
Ok(text) => Some(serde_json::from_str::<
|
||||
BTreeMap<String, Box<serde_json::value::RawValue>>,
|
||||
>(&text)?),
|
||||
@@ -536,7 +532,9 @@ impl Sprites {
|
||||
Self::decode_file(&path, name)
|
||||
}
|
||||
fn decode_file(path: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
|
||||
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
|
||||
let metadata = match horizon_core::prepared_files::read_to_string(
|
||||
&path.with_extension("png.mcmeta"),
|
||||
) {
|
||||
Ok(text) => Some(serde_json::from_str::<
|
||||
BTreeMap<String, Box<serde_json::value::RawValue>>,
|
||||
>(&text)?),
|
||||
@@ -694,9 +692,12 @@ impl Sprites {
|
||||
}
|
||||
}
|
||||
|
||||
/// A PNG's width, height and pixels as RGBA.
|
||||
/// A PNG's width, height and pixels as RGBA. From the prepared data file
|
||||
/// when it holds the texture: no file opened per texture, nor for its absent
|
||||
/// LabPBR maps.
|
||||
fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
|
||||
let bytes = horizon_core::prepared_files::read(path)?;
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info()?;
|
||||
@@ -792,6 +793,7 @@ pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, avail
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::{self, File};
|
||||
#[test]
|
||||
fn the_texture_budget_picks_the_largest_side_that_fits() {
|
||||
// 1300 layers: vanilla's count, about.
|
||||
|
||||
@@ -185,6 +185,13 @@ pub trait LodSampler: Send {
|
||||
fn reduced_column(&mut self, x: i32, z: i32, _width: i32) -> crate::lod::Column {
|
||||
self.column(x, z)
|
||||
}
|
||||
/// Whether a column since the last call was left empty because its data
|
||||
/// is still on its way (a BlueMap tile downloading, nothing coarser to
|
||||
/// stand in): that sample isn't drawn, what it would replace is
|
||||
/// (`Lod::finish_any_sample`), and it is taken again later.
|
||||
fn waited(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
/// `side` x `side` columns from (`x`, `z`), `stride` blocks apart; none
|
||||
/// when sampling failed and should be tried again later.
|
||||
fn grid(&mut self, x: i32, z: i32, stride: i32, side: usize) -> Vec<crate::lod::Column> {
|
||||
@@ -203,6 +210,11 @@ pub trait LodSampler: Send {
|
||||
}
|
||||
pub trait LodSource: Send + Sync {
|
||||
fn sampler(self: Arc<Self>) -> Box<dyn LodSampler>;
|
||||
/// Whether its samplers' `observed` chunks become its own (a server's
|
||||
/// kept terrain): tiles sampled without one are sampled again.
|
||||
fn keeps_observed(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
struct GeneratorSampler<G: ChunkGenerator> {
|
||||
generator: Arc<G>,
|
||||
|
||||
@@ -176,7 +176,7 @@ impl TrimTexture {
|
||||
let Some(palette) = &self.palette else {
|
||||
return Some((w, h, pixels));
|
||||
};
|
||||
let base = fs::read(path.with_extension("png.mcmeta"))
|
||||
let base = horizon_core::prepared_files::read(&path.with_extension("png.mcmeta"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
|
||||
.and_then(|m| {
|
||||
@@ -241,9 +241,10 @@ pub fn slot_size(root: &Path) -> (u32, u32) {
|
||||
|
||||
/// The width and height in a PNG's IHDR chunk.
|
||||
fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
use std::io::Read;
|
||||
let mut head = [0u8; 24];
|
||||
fs::File::open(path).ok()?.read_exact(&mut head).ok()?;
|
||||
let head: [u8; 24] = horizon_core::prepared_files::read_head(path, 24)
|
||||
.ok()?
|
||||
.try_into()
|
||||
.ok()?;
|
||||
let be = |i: usize| u32::from_be_bytes(head[i..i + 4].try_into().unwrap());
|
||||
(&head[12..16] == b"IHDR").then(|| (be(16), be(20)))
|
||||
}
|
||||
|
||||
@@ -130,6 +130,10 @@ path = "src/main.rs"
|
||||
name = "launch_api"
|
||||
path = "tests/launch_api.rs"
|
||||
|
||||
[[test]]
|
||||
name = "mod_api_hooks"
|
||||
path = "tests/mod_api_hooks.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"horizon.pages.extras": "Horizon Extras",
|
||||
"horizon.pages.settings": "Horizon Settings",
|
||||
"horizon.pages.agent": "AI Agent",
|
||||
"horizon.split.leave": "Leave",
|
||||
"horizon.pages.split_settings": "Splitscreen Settings",
|
||||
"horizon.pages.split_profiles": "Who's playing?",
|
||||
"horizon.pages.split_setup": "Splitscreen Setup",
|
||||
@@ -362,6 +363,21 @@
|
||||
"horizon.splitscreen.setup.help.revert": "Undo the changes not applied yet.",
|
||||
"horizon.splitscreen.setup.help.apply": "Play the way the map shows.",
|
||||
"horizon.splitscreen.setup.help.unchanged": "Nothing changed yet.",
|
||||
"horizon.splitscreen.setup.give_up_pad": "Give Up Pad",
|
||||
"horizon.splitscreen.prompt.controller_connected": "Controller connected",
|
||||
"horizon.splitscreen.prompt.controller_disconnected": "Controller disconnected",
|
||||
"horizon.splitscreen.prompt.controller_disconnected.hint": "Reconnect it, or press a button on another",
|
||||
"horizon.splitscreen.prompt.join.one": "%s wants to join",
|
||||
"horizon.splitscreen.prompt.join.many": "%s controllers want to join",
|
||||
"horizon.splitscreen.prompt.join.yes": "Join",
|
||||
"horizon.splitscreen.prompt.join.as_p1": "Play as P1",
|
||||
"horizon.splitscreen.prompt.join.no": "No",
|
||||
"horizon.splitscreen.prompt.lost": "%s: controller disconnected",
|
||||
"horizon.splitscreen.prompt.lost.use_another": "Use another",
|
||||
"horizon.splitscreen.prompt.lost.leave": "Leave",
|
||||
"horizon.splitscreen.setup.help.give_up_pad": "Your controller stops driving you and is offered to join (A).",
|
||||
"horizon.splitscreen.setup.note.held_at": "%s to %s: A drops it here, B puts it back.",
|
||||
"horizon.splitscreen.setup.note.held": "%s picked up: left/right picks where, A drops, B puts it back.",
|
||||
"Culling": "Culling",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
|
||||
"Block Entities": "Block Entities",
|
||||
@@ -389,6 +405,10 @@
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Shows a made-up frame between each two real ones: smoother, a little more lag.",
|
||||
"Shadow Cache": "Shadow Cache",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Reuses the pack shadow map when sun, view and world match. Off by default.",
|
||||
"Find BlueMap": "Find BlueMap",
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.": "On servers, finds their BlueMap web map for far terrain. Next join.",
|
||||
"BlueMap Radius": "BlueMap Radius",
|
||||
"How far around you BlueMap tiles are downloaded. Next join.": "How far around you BlueMap tiles are downloaded. Next join.",
|
||||
"Synchronous": "Synchronous",
|
||||
"Asynchronous": "Asynchronous",
|
||||
"Solid": "Solid",
|
||||
|
||||
@@ -347,6 +347,21 @@
|
||||
"horizon.splitscreen.setup.help.revert": "Annuler les modifications pas encore appliquées.",
|
||||
"horizon.splitscreen.setup.help.apply": "Jouer comme le montre la carte.",
|
||||
"horizon.splitscreen.setup.help.unchanged": "Rien n'a encore changé.",
|
||||
"horizon.splitscreen.setup.give_up_pad": "Céder la manette",
|
||||
"horizon.splitscreen.prompt.controller_connected": "Manette connectée",
|
||||
"horizon.splitscreen.prompt.controller_disconnected": "Manette déconnectée",
|
||||
"horizon.splitscreen.prompt.controller_disconnected.hint": "Reconnectez-la, ou appuyez sur un bouton d'une autre",
|
||||
"horizon.splitscreen.prompt.join.one": "%s veut rejoindre",
|
||||
"horizon.splitscreen.prompt.join.many": "%s manettes veulent rejoindre",
|
||||
"horizon.splitscreen.prompt.join.yes": "Rejoindre",
|
||||
"horizon.splitscreen.prompt.join.as_p1": "Jouer en J1",
|
||||
"horizon.splitscreen.prompt.join.no": "Non",
|
||||
"horizon.splitscreen.prompt.lost": "%s : manette déconnectée",
|
||||
"horizon.splitscreen.prompt.lost.use_another": "Utiliser une autre",
|
||||
"horizon.splitscreen.prompt.lost.leave": "Quitter",
|
||||
"horizon.splitscreen.setup.help.give_up_pad": "Votre manette ne vous contrôle plus et peut rejoindre la partie (A).",
|
||||
"horizon.splitscreen.setup.note.held_at": "%s vers %s : A le dépose ici, B le remet en place.",
|
||||
"horizon.splitscreen.setup.note.held": "%s est pris : gauche/droite choisit où, A dépose, B remet en place.",
|
||||
"Culling": "Occlusion",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Masque les modèles derrière les blocs opaques. Vérification en arrière-plan ; les entités continuent.",
|
||||
"Leaf Culling": "Occlusion feuillage",
|
||||
@@ -359,6 +374,10 @@
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Insère une image calculée entre deux vraies : plus fluide, un peu plus de latence.",
|
||||
"Shadow Cache": "Cache des ombres",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Réutilise la carte d'ombres du pack si soleil, vue et monde sont identiques. Désactivé par défaut.",
|
||||
"Find BlueMap": "Trouver BlueMap",
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.": "Sur les serveurs, trouve leur carte web BlueMap pour le terrain lointain. À la prochaine connexion.",
|
||||
"BlueMap Radius": "Rayon BlueMap",
|
||||
"How far around you BlueMap tiles are downloaded. Next join.": "Distance autour de vous jusqu'où les tuiles BlueMap sont téléchargées. À la prochaine connexion.",
|
||||
"Synchronous": "Synchrone",
|
||||
"Asynchronous": "Asynchrone",
|
||||
"Solid": "Opaque",
|
||||
|
||||
@@ -1171,6 +1171,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
player: player.clone(),
|
||||
nearest_player: player.as_ref().filter(|p| !p.spectator).map(|p| p.position),
|
||||
others: $snapshot,
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
others_grid: $grid,
|
||||
others_index: None,
|
||||
|
||||
@@ -104,22 +104,20 @@ impl Tree {
|
||||
let Ok(namespaces) = std::fs::read_dir(data) else {
|
||||
return tree;
|
||||
};
|
||||
let mut files = Vec::new();
|
||||
for ns in namespaces.flatten() {
|
||||
let root = ns.path().join("advancement");
|
||||
collect(&root, &root, &ns.file_name().to_string_lossy(), &mut files);
|
||||
}
|
||||
// One JSON per id, the packs' over the game's own.
|
||||
// One JSON per id, the packs' over the game's own. Through `json_folder`: from
|
||||
// the prepared data file, and read once while a load holds the folder.
|
||||
let mut json: BTreeMap<String, Value> = BTreeMap::new();
|
||||
files.sort();
|
||||
for (id, path) in files {
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
for ns in namespaces.flatten() {
|
||||
let Ok(folder) = horizon_world::json_folder::read(&ns.path().join("advancement"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(v) = serde_json::from_str::<Value>(&text) else {
|
||||
continue;
|
||||
};
|
||||
json.insert(id, v);
|
||||
let ns = ns.file_name().to_string_lossy().into_owned();
|
||||
for file in &folder.files {
|
||||
if let Ok(v) = serde_json::from_slice::<Value>(file.bytes().unwrap_or_default()) {
|
||||
json.insert(format!("{ns}:{}", file.name), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, files) in overlay.kind("advancement") {
|
||||
match serde_json::from_slice::<Value>(horizon_world::loot::PackOverlay::top(files)) {
|
||||
@@ -249,21 +247,6 @@ impl InventoryIndex {
|
||||
out
|
||||
}
|
||||
}
|
||||
fn collect(root: &Path, dir: &Path, ns: &str, out: &mut Vec<(String, std::path::PathBuf)>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
collect(root, &p, ns, out);
|
||||
} else if p.extension().is_some_and(|x| x == "json") {
|
||||
let rel = p.strip_prefix(root).unwrap_or(&p).with_extension("");
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
out.push((format!("{ns}:{rel}"), p));
|
||||
}
|
||||
}
|
||||
}
|
||||
fn parse(id: &str, v: &Value) -> Option<Advancement> {
|
||||
let criteria: Vec<Criterion> = v["criteria"]
|
||||
.as_object()?
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! when the block goes, and despawning. The tick stops at an entity it hits:
|
||||
//! the game hurts the entity (`projectile_hit`), then `finish_entity_hit`
|
||||
//! spends the arrow or turns it back and ends the tick.
|
||||
use crate::entity_rules::EntityRules;
|
||||
use crate::{
|
||||
blocks::BlockFacts,
|
||||
collisions::SceneCollisions,
|
||||
@@ -777,9 +778,9 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
if !self.st.was_touching_water {
|
||||
self.p.velocity = self.p.velocity.scale(f64::from(0.99f32));
|
||||
}
|
||||
if physics && !self.st.in_ground && !self.p.no_gravity {
|
||||
if physics && !self.st.in_ground {
|
||||
// Entity.applyGravity: AbstractArrow.getDefaultGravity, none with NoGravity.
|
||||
self.add_velocity(-0.05);
|
||||
self.add_velocity(-self.p.get_gravity());
|
||||
}
|
||||
// Projectile.tick, then Entity.baseTick's fluid and fire phases.
|
||||
if !self.st.has_been_shot {
|
||||
|
||||
@@ -9,10 +9,10 @@ pub mod arrows;
|
||||
pub mod block_contact;
|
||||
pub use horizon_client_render::{
|
||||
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities,
|
||||
block_entity_anim, block_entity_render, bluemap, border_render, breaking, brewing, camera,
|
||||
chat, chunk, chunk_map, cit, cit_armor, cit_assets, cit_glint, clouds, collisions,
|
||||
command_block, containers, crash, datapack, dynamic_light, effects, environment, extras,
|
||||
fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud,
|
||||
block_entity_anim, block_entity_render, bluemap, bluemap_detect, border_render, breaking,
|
||||
brewing, camera, chat, chunk, chunk_map, cit, cit_armor, cit_assets, cit_glint, clouds,
|
||||
collisions, command_block, containers, crash, datapack, dynamic_light, effects, environment,
|
||||
extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud,
|
||||
hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block, json_folder,
|
||||
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
|
||||
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
|
||||
@@ -59,6 +59,7 @@ pub mod end_gateway;
|
||||
pub mod entities;
|
||||
pub mod entity_culling;
|
||||
pub mod entity_nbt;
|
||||
pub mod entity_rules;
|
||||
pub mod extras_hud;
|
||||
pub mod falling_block;
|
||||
pub mod fire_render;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! piston that is carrying slime), water buoyancy, pickup delay, 6000-tick despawn,
|
||||
//! `HoneyBlock.entityInside` when falling against a honey block's side, and
|
||||
//! `BubbleColumnBlock.entityInside` (lift / drag, including the surface jump).
|
||||
use crate::entity_rules::EntityRules;
|
||||
use crate::{
|
||||
blocks::{BlockFacts, Fluid},
|
||||
collisions::SceneCollisions,
|
||||
@@ -135,6 +136,26 @@ impl ItemEnvironmentTick {
|
||||
self.lava_hurt && (!self.alive || tick.is_multiple_of(10))
|
||||
}
|
||||
}
|
||||
impl crate::entity_rules::EntityRules for ItemEntity {
|
||||
fn is_no_gravity(&self) -> bool {
|
||||
self.no_gravity
|
||||
}
|
||||
/// `ItemEntity.getDefaultGravity`.
|
||||
fn get_default_gravity(&self) -> f64 {
|
||||
0.04
|
||||
}
|
||||
}
|
||||
impl crate::entity_rules::EntityRules for ExperienceOrb {
|
||||
fn is_no_gravity(&self) -> bool {
|
||||
self.no_gravity
|
||||
}
|
||||
/// `ExperienceOrb.getDefaultGravity`.
|
||||
fn get_default_gravity(&self) -> f64 {
|
||||
0.03
|
||||
}
|
||||
}
|
||||
/// An armor stand keeps no `NoGravity` flag yet, so it takes the defaults.
|
||||
impl crate::entity_rules::EntityRules for ArmorStand {}
|
||||
impl ItemEntity {
|
||||
pub const WIDTH: f64 = 0.25;
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
@@ -263,9 +284,7 @@ impl ItemEntity {
|
||||
self.velocity.z * 0.95,
|
||||
);
|
||||
} else {
|
||||
if !self.no_gravity {
|
||||
self.velocity.y -= 0.04;
|
||||
}
|
||||
self.velocity.y -= self.get_gravity();
|
||||
}
|
||||
let horizontal = self.velocity.x * self.velocity.x + self.velocity.z * self.velocity.z;
|
||||
// ItemEntity.tick: skip move 3 of 4 ticks while grounded with no
|
||||
@@ -856,8 +875,8 @@ impl ExperienceOrb {
|
||||
(self.velocity.y + 5.0e-4).min(0.06),
|
||||
self.velocity.z * 0.99,
|
||||
);
|
||||
} else if !colliding && !self.no_gravity {
|
||||
self.velocity.y -= 0.03;
|
||||
} else if !colliding {
|
||||
self.velocity.y -= self.get_gravity();
|
||||
}
|
||||
if facts.get(block).fluid == Fluid::Lava {
|
||||
// ExperienceOrb.tick: lava throws the orb back out.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! PURPOSE: `Entity`'s gravity and fluid-push rules, decided once for every kind.
|
||||
//! WHY: each kind used to check its own `NoGravity` flag and pick its own
|
||||
//! number, so a kind that forgot the check ignored the flag (#6481).
|
||||
//! REFS: vanilla `Entity.isNoGravity`, `getDefaultGravity`, `getGravity`,
|
||||
//! `applyGravity`, `isPushedByFluid`; issue #6509, slice #6544.
|
||||
//! LOGS: none. READS-BY: the kinds' ticks in this crate and in `game/`.
|
||||
//! ENDS-WHEN: kinds share one stored `Entity` state and this folds into it.
|
||||
|
||||
/// The rules vanilla's `Entity` answers for every kind. A kind overrides one
|
||||
/// only where its vanilla class does; the defaults are `Entity`'s.
|
||||
pub trait EntityRules {
|
||||
/// `Entity.isNoGravity` (`NoGravity` in the save).
|
||||
fn is_no_gravity(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// `Entity.getDefaultGravity`.
|
||||
fn get_default_gravity(&self) -> f64 {
|
||||
0.0
|
||||
}
|
||||
/// `Entity.getGravity`: none with `NoGravity`, else the kind's default.
|
||||
/// `applyGravity` subtracts this from the vertical velocity.
|
||||
fn get_gravity(&self) -> f64 {
|
||||
if self.is_no_gravity() {
|
||||
0.0
|
||||
} else {
|
||||
self.get_default_gravity()
|
||||
}
|
||||
}
|
||||
/// `Entity.isPushedByFluid`.
|
||||
fn is_pushed_by_fluid(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -2300,7 +2300,8 @@ struct Game<'a> {
|
||||
lod_observe: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
|
||||
/// The server level far terrain keeps the chunks of.
|
||||
lod_level: Option<String>,
|
||||
/// The server's BlueMap far terrain draws from, when the player named one.
|
||||
/// The server's BlueMap far terrain draws from, when the player named one
|
||||
/// or Find BlueMap found it.
|
||||
lod_blue_map: Option<Arc<horizon_client::bluemap::BlueMap>>,
|
||||
/// Beacon beam sections kept after their chunk unloads, so far terrain
|
||||
/// LOD still draws their beams (`beacon_geometry` refreshes them).
|
||||
@@ -2445,6 +2446,9 @@ struct Game<'a> {
|
||||
/// `SpectatorGui`: the menu a spectator opens with the hotbar keys.
|
||||
spectator_gui: horizon_client::spectator_menu::SpectatorGui,
|
||||
previous: Instant,
|
||||
/// When the last frame's work ended: what runs before the next one starts (`idle`, the
|
||||
/// event loop) shows in HORIZON_SPIKES as the time between frames.
|
||||
frame_end: Instant,
|
||||
/// FramerateLimitTracker.latestInputTime.
|
||||
last_input: Instant,
|
||||
/// Controllers (Horizon's own).
|
||||
@@ -2606,6 +2610,9 @@ struct Game<'a> {
|
||||
saving_indicator: horizon_client::hud_overlays::SavingIndicator,
|
||||
/// This game's world is autosaving this tick (`MinecraftServer.isSaving`).
|
||||
autosaving: bool,
|
||||
/// `MinecraftServer.ticksUntilAutosave`: the ticks left before the next
|
||||
/// autosave, counted down a tick at a time.
|
||||
ticks_until_autosave: i64,
|
||||
applied_modifiers: Vec<(String, String)>,
|
||||
experience: Experience,
|
||||
orbs: Vec<ExperienceOrb>,
|
||||
@@ -5550,9 +5557,7 @@ impl Game<'_> {
|
||||
fn fall_step(&mut self, f: &mut FallingBlock, entity_drops: bool) -> Result<FallStep> {
|
||||
f.previous = f.position;
|
||||
f.age = f.age.wrapping_add(1);
|
||||
if !f.no_gravity {
|
||||
f.velocity.y -= 0.04;
|
||||
}
|
||||
f.velocity.y -= horizon_client::entity_rules::EntityRules::get_gravity(f);
|
||||
let mut wanted = f.velocity;
|
||||
// Entity.move: a cobweb it was in scales this move, which then
|
||||
// starts from rest.
|
||||
@@ -7294,12 +7299,19 @@ impl Game<'_> {
|
||||
self.world.weather.tick_levels();
|
||||
self.tick_weather_effects();
|
||||
}
|
||||
self.autosaving = self.ticks.is_multiple_of(6000);
|
||||
// MinecraftServer.tickServer's countdown, then autoSave: three
|
||||
// hundred seconds of ticks at the rate the server runs at now.
|
||||
self.ticks_until_autosave -= 1;
|
||||
self.autosaving = self.ticks_until_autosave <= 0;
|
||||
if let Some(serving) = &self.serving {
|
||||
serving.set_saving(self.autosaving);
|
||||
}
|
||||
if self.autosaving {
|
||||
self.save_game_with(false)?;
|
||||
self.ticks_until_autosave = self.next_autosave_interval();
|
||||
if let Some(serving) = &self.serving {
|
||||
serving.set_saving(false);
|
||||
}
|
||||
}
|
||||
if running {
|
||||
self.advance_clock();
|
||||
@@ -7757,7 +7769,10 @@ impl Game<'_> {
|
||||
// The ride the player's file names, once the vehicle is in the world.
|
||||
self.restore_root_vehicle();
|
||||
if running {
|
||||
let mobs_before = self.mob_ids();
|
||||
horizon_client::spans::time("tick_mobs", || self.tick_mobs())?;
|
||||
// Dead, despawned or gone into a hive.
|
||||
self.native_mobs_left_since(&mobs_before);
|
||||
horizon_client::spans::time("recheck_golem_openers", || self.recheck_golem_openers());
|
||||
self.tick_mod_container_viewers();
|
||||
let before = self.orb_ids();
|
||||
@@ -11515,11 +11530,19 @@ impl Game<'_> {
|
||||
// attack strength scales the base damage.
|
||||
let enchant_bonus = self.enchanted_damage(target_kind, damage) - damage;
|
||||
let mut amount = damage * (0.2 + scale * scale * 0.8);
|
||||
// Player.attack decides the critical hit only for a hit with base or
|
||||
// enchantment damage: checked before the mace's smash bonus is added.
|
||||
let damaging = amount > 0.0 || enchant_bonus * scale > 0.0;
|
||||
// Item.getAttackDamageBonus (a mace's smash), before the critical hit.
|
||||
amount += self.smash_damage_bonus(target_kind);
|
||||
let crit = living && self.critical_attack(scale);
|
||||
let vanilla_crit = living && self.critical_attack(scale);
|
||||
let mut crit = vanilla_crit;
|
||||
let mut multiplier = if vanilla_crit { 1.5 } else { 1.0 };
|
||||
if living && damaging {
|
||||
self.native_critical_hit(target_kind, vanilla_crit, &mut crit, &mut multiplier);
|
||||
}
|
||||
if crit {
|
||||
amount *= 1.5;
|
||||
amount *= multiplier;
|
||||
}
|
||||
// Enchantment damage (sharpness, smite, bane) scaled by the attack strength.
|
||||
amount += enchant_bonus * scale;
|
||||
@@ -11598,16 +11621,18 @@ impl Game<'_> {
|
||||
/// strength, then strong or weak, or the sweep around the vehicle's
|
||||
/// box `area`. Before the hit resets the charge (`Player.onAttack`).
|
||||
pub(super) fn vehicle_attack_sounds(&mut self, area: Aabb, magic: Option<i32>) {
|
||||
self.attack_effects(area, None, mace::Spared::Nothing, magic);
|
||||
self.attack_effects(area, None, false, mace::Spared::Nothing, magic);
|
||||
}
|
||||
/// `Player.attack` after a target that is not a mob took the hit:
|
||||
/// the sprint knockback sound, a living target's critical hit (`living`:
|
||||
/// its entity id, which makes the hit critical when the player falls),
|
||||
/// the sweep around the target's box `area` and the strong or weak sound.
|
||||
/// its entity id; `crit`: whether `melee_damage` decided the hit critical,
|
||||
/// mods' `critical_hit` included), the sweep around the target's box `area`
|
||||
/// and the strong or weak sound.
|
||||
pub(super) fn attack_effects(
|
||||
&mut self,
|
||||
area: Aabb,
|
||||
living: Option<i32>,
|
||||
crit: bool,
|
||||
spared: mace::Spared,
|
||||
magic: Option<i32>,
|
||||
) {
|
||||
@@ -11619,7 +11644,7 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.knockback", from, 1.0, 1.0);
|
||||
}
|
||||
let crit = living.is_some() && self.critical_attack(scale);
|
||||
let crit = living.is_some() && crit;
|
||||
let sweep = self.sweep_attack_position(scale, crit);
|
||||
if let Some(at) = sweep {
|
||||
self.sweep_attack(at, from, Some((area, spared)), scale);
|
||||
@@ -15319,6 +15344,25 @@ impl Game<'_> {
|
||||
fn save_game(&mut self) -> Result<()> {
|
||||
self.save_game_with(true)
|
||||
}
|
||||
/// `MinecraftServer.computeNextAutosaveInterval`: three hundred seconds
|
||||
/// of ticks at the rate the server runs at now - its measured rate while
|
||||
/// a `/tick sprint` runs - never fewer than a hundred.
|
||||
fn next_autosave_interval(&self) -> i64 {
|
||||
let rate = if self.clock.sprinting() {
|
||||
1.0e9 / (self.clock.average_nanos() + 1) as f32
|
||||
} else {
|
||||
self.clock.rate()
|
||||
};
|
||||
autosave_interval(rate)
|
||||
}
|
||||
/// `MinecraftServer.onTickRateChanged`: a rate that asks for a shorter
|
||||
/// wait than the one already running shortens it.
|
||||
pub(super) fn on_tick_rate_changed(&mut self) {
|
||||
let interval = self.next_autosave_interval();
|
||||
if interval < self.ticks_until_autosave {
|
||||
self.ticks_until_autosave = interval;
|
||||
}
|
||||
}
|
||||
/// The autosave doesn't `wait` for the chunks to be on disk: every
|
||||
/// generated chunk is saved, hundreds at a time, encoded and written on
|
||||
/// the region writer meanwhile.
|
||||
@@ -16668,31 +16712,63 @@ impl Game<'_> {
|
||||
let root = home.join("server-terrain");
|
||||
let dir = terrain::level_dir(&root, address, remote.hashed_seed, &remote.level);
|
||||
self.lod_level = Some(remote.level.clone());
|
||||
// Past them, the server's BlueMap, if the player named it.
|
||||
let blue_map = self
|
||||
.settings
|
||||
.horizon
|
||||
.blue_map
|
||||
.server(address)
|
||||
.map(|server| {
|
||||
use horizon_client::bluemap;
|
||||
log::info!("Horizon: far terrain also draws {}'s BlueMap", remote.level);
|
||||
let map = Arc::new(bluemap::BlueMap::new(
|
||||
&server.url,
|
||||
&remote.level,
|
||||
server.maps.clone(),
|
||||
root.join(terrain::server_folder(address)).join("BlueMap"),
|
||||
self.settings.horizon.blue_map.radius_blocks(),
|
||||
bluemap::Palette::from_game(
|
||||
self.pack.assets.registry,
|
||||
self.res.biomes(),
|
||||
),
|
||||
));
|
||||
map.start();
|
||||
map
|
||||
});
|
||||
// Past them, the server's BlueMap, if the player named it or
|
||||
// lets Horizon look for it (on the BlueMap worker).
|
||||
let maps = root.join(terrain::server_folder(address)).join("BlueMap");
|
||||
let settings = &self.settings.horizon.blue_map;
|
||||
let blue_map = horizon_client::bluemap_detect::source(
|
||||
settings,
|
||||
address,
|
||||
&maps,
|
||||
std::time::SystemTime::now(),
|
||||
)
|
||||
.map(|source| {
|
||||
use horizon_client::{bluemap, bluemap_detect::Source};
|
||||
// The map's address only at debug: logs land in crash reports.
|
||||
log::info!("Horizon: far terrain also draws {}'s BlueMap", remote.level);
|
||||
if let Source::Url { url, .. } = &source {
|
||||
log::debug!("Horizon: BlueMap at {url}");
|
||||
}
|
||||
let map = Arc::new(bluemap::BlueMap::from_source(
|
||||
source,
|
||||
&remote.level,
|
||||
maps,
|
||||
settings.radius_blocks(),
|
||||
bluemap::Palette::from_game(self.pack.assets.registry, self.res.biomes()),
|
||||
));
|
||||
map.start();
|
||||
map
|
||||
});
|
||||
self.lod_blue_map = blue_map.clone();
|
||||
terrain::ServerTerrain::new(dir, blue_map) as _
|
||||
// And the player's Bobby cache of the level, only read.
|
||||
let bobby = terrain::bobby_level_dir(
|
||||
&terrain::minecraft_dir().join(".bobby"),
|
||||
address,
|
||||
remote.hashed_seed,
|
||||
&remote.level,
|
||||
)
|
||||
.map(|bobby| {
|
||||
log::info!(
|
||||
"Horizon: far terrain also reads Bobby's chunks of {}",
|
||||
remote.level
|
||||
);
|
||||
let registry = self.pack.assets.registry;
|
||||
let biomes = self.res.biomes().clone();
|
||||
let dimension = &remote.dimension;
|
||||
let layout = horizon_client::chunk::ColumnLayout::of_level(
|
||||
dimension.min_y,
|
||||
dimension.height,
|
||||
);
|
||||
let sky_light = dimension.has_skylight;
|
||||
let decode: terrain::DecodeChunk =
|
||||
Box::new(move |root: &horizon_world::nbt::NamedTag| {
|
||||
horizon_client::save::decode_lit(
|
||||
root, registry, &biomes, layout, sky_light,
|
||||
)
|
||||
});
|
||||
terrain::Bobby::new(bobby, decode, self.res.facts())
|
||||
});
|
||||
terrain::ServerTerrain::with_bobby(dir, blue_map, bobby) as _
|
||||
}),
|
||||
_ => Some(self.streaming.lod_source()),
|
||||
};
|
||||
@@ -16705,7 +16781,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
fn update_world(&mut self) {
|
||||
if let Err(e) = self.poll_remote() {
|
||||
if let Err(e) = horizon_client::spans::time("poll_remote", || self.poll_remote()) {
|
||||
log::warn!("Horizon: {e}");
|
||||
}
|
||||
// The renderer laid translucent quads out again (a shader pack's flat terrain came
|
||||
@@ -16726,8 +16802,8 @@ impl Game<'_> {
|
||||
}
|
||||
if let (Some(map), Some(p), Some(lod)) = (&self.lod_blue_map, &self.player, &mut self.lod) {
|
||||
map.set_center(p.position.x.floor() as i32, p.position.z.floor() as i32);
|
||||
for area in map.take_changed() {
|
||||
lod.refresh(area);
|
||||
for change in map.take_changed() {
|
||||
lod.refresh(change.area, change.widths);
|
||||
}
|
||||
}
|
||||
if let Some(p) = &self.player {
|
||||
@@ -16920,10 +16996,17 @@ impl Game<'_> {
|
||||
.collect()
|
||||
})
|
||||
});
|
||||
// Far terrain's finest tiles stand where full-detail terrain
|
||||
// ends: on a server, where it stops sending chunks, often well
|
||||
// inside the render distance. Thousands of chunk-sized tiles
|
||||
// between the two filled in long after the coarser ones.
|
||||
let detail = self.remote.as_ref().map_or(self.streaming.radius, |r| {
|
||||
self.streaming.radius.min(r.sent_radius())
|
||||
});
|
||||
let meshes = horizon_client::spans::time("lod_update", || {
|
||||
lod.update(
|
||||
[player.position.x, player.position.y, player.position.z],
|
||||
self.streaming.radius,
|
||||
detail,
|
||||
lod_distance,
|
||||
covered,
|
||||
)
|
||||
@@ -16938,6 +17021,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `computeNextAutosaveInterval`'s arithmetic: `Math.max(100, (int)(rate * 300.0F))`.
|
||||
fn autosave_interval(rate: f32) -> i64 {
|
||||
i64::from((rate * 300.0) as i32).max(100)
|
||||
}
|
||||
impl ApplicationHandler for Game<'_> {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_some() {
|
||||
@@ -18235,7 +18322,7 @@ impl Game<'_> {
|
||||
self.mapped_key_event(key, event.state == ElementState::Pressed, event.repeat)?;
|
||||
}
|
||||
WindowEvent::RedrawRequested => {
|
||||
self.recover_device()?;
|
||||
horizon_client::spans::time("recover_device", || self.recover_device())?;
|
||||
self.observe_fullscreen();
|
||||
self.sync_ime();
|
||||
let now = Instant::now();
|
||||
@@ -18244,6 +18331,7 @@ impl Game<'_> {
|
||||
.spikes
|
||||
.and_then(|_| horizon_client::spans::thread_cpu());
|
||||
let elapsed = now.duration_since(self.previous);
|
||||
let between = now.saturating_duration_since(self.frame_end);
|
||||
self.frame_ticks = elapsed.as_secs_f32() * 20.0;
|
||||
self.previous = now;
|
||||
// Arrow-key camera controls belong to gameplay; chat and
|
||||
@@ -18331,8 +18419,10 @@ impl Game<'_> {
|
||||
&& let Some(r) = self.renderer.as_ref()
|
||||
{
|
||||
let cpu = now.elapsed().saturating_sub(r.last_wait).as_secs_f32() * 1000.0;
|
||||
// Spans timed between frames (`idle`) come first.
|
||||
let spans = horizon_client::spans::take();
|
||||
if cpu > threshold {
|
||||
let between = between.as_secs_f32() * 1000.0;
|
||||
if cpu > threshold || between > threshold {
|
||||
// The render thread's own CPU time in the frame:
|
||||
// far below the wall time, the frame waited.
|
||||
let worked = horizon_client::spans::thread_cpu()
|
||||
@@ -18340,8 +18430,13 @@ impl Game<'_> {
|
||||
.map_or_else(String::new, |(end, start)| {
|
||||
format!(", cpu {:.1} ms", (end - start).as_secs_f32() * 1000.0)
|
||||
});
|
||||
let gap = if between > threshold {
|
||||
format!(", {between:.1} ms before it")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
log::info!(
|
||||
"Horizon: slow frame {cpu:.1} ms{worked} ({steps} ticks): {}",
|
||||
"Horizon: slow frame {cpu:.1} ms{worked}{gap} ({steps} ticks): {}",
|
||||
horizon_client::spans::describe(&spans)
|
||||
);
|
||||
}
|
||||
@@ -18457,6 +18552,7 @@ impl Game<'_> {
|
||||
self.fps_frames = 0;
|
||||
self.fps_at = Instant::now();
|
||||
}
|
||||
self.frame_end = Instant::now();
|
||||
if self.options.frames.is_some_and(|f| self.frames >= f)
|
||||
|| self
|
||||
.options
|
||||
@@ -18523,7 +18619,8 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Err(error) = self.resume_world_entry() {
|
||||
if let Err(error) = horizon_client::spans::time("world_entry", || self.resume_world_entry())
|
||||
{
|
||||
self.error = Some(error);
|
||||
self.exit_on_error(event_loop);
|
||||
return;
|
||||
@@ -18536,12 +18633,12 @@ impl Game<'_> {
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
self.tick_server_packs();
|
||||
self.poll_control(event_loop);
|
||||
horizon_client::spans::time("server_packs", || self.tick_server_packs());
|
||||
horizon_client::spans::time("control", || self.poll_control(event_loop));
|
||||
if self.pending_save.is_some() {
|
||||
return;
|
||||
}
|
||||
self.poll_pads(event_loop);
|
||||
horizon_client::spans::time("pads", || self.poll_pads(event_loop));
|
||||
self.pause_if_inactive();
|
||||
if self.options.frames.is_some() && self.started.elapsed() > self.options.timeout {
|
||||
self.error = Some("frame-limited run timed out".into());
|
||||
@@ -18770,6 +18867,7 @@ impl<'a> Game<'a> {
|
||||
started: now,
|
||||
spectator_gui: Default::default(),
|
||||
previous: now,
|
||||
frame_end: now,
|
||||
last_input: now,
|
||||
pad: Default::default(),
|
||||
frames: 0,
|
||||
@@ -18861,6 +18959,8 @@ impl<'a> Game<'a> {
|
||||
tool_highlight: Default::default(),
|
||||
saving_indicator: Default::default(),
|
||||
autosaving: false,
|
||||
// `computeNextAutosaveInterval` at the clock's 20 ticks a second.
|
||||
ticks_until_autosave: 6000,
|
||||
applied_modifiers: Vec::new(),
|
||||
experience: Experience::default(),
|
||||
orbs: Vec::new(),
|
||||
@@ -20034,6 +20134,7 @@ fn load_resources(
|
||||
let rest_loader = if menu {
|
||||
let resources = resources.clone();
|
||||
let root = root.clone();
|
||||
let vanilla_root = vanilla_root.to_owned();
|
||||
let rest_progress = progress.clone();
|
||||
let progress = progress.clone();
|
||||
let handle = std::thread::Builder::new()
|
||||
@@ -20062,6 +20163,7 @@ fn load_resources(
|
||||
"Horizon: rest-ready after {} ms",
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
write_prepared_files(vanilla_root);
|
||||
Ok(())
|
||||
})?;
|
||||
Some(WorldRestLoader {
|
||||
@@ -20129,6 +20231,37 @@ fn load_resources(
|
||||
})
|
||||
}
|
||||
|
||||
/// Write the prepared folder's `horizon-data.bin` when it has none the game
|
||||
/// can use: on a thread of its own, at idle priority, once the start is done.
|
||||
/// This start has just read the folder's files (they are in the disk cache);
|
||||
/// the next starts read them from one file (#1961).
|
||||
fn write_prepared_files(root: PathBuf) {
|
||||
use horizon_core::prepared_files;
|
||||
if prepared_files::open(&root).is_some() || !root.join("horizon-resources.json").is_file() {
|
||||
return;
|
||||
}
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Prepared data file".into())
|
||||
.spawn(move || {
|
||||
horizon_threads::idle_current();
|
||||
let started = Instant::now();
|
||||
match prepared_files::write(&root) {
|
||||
Ok(()) => log::info!(
|
||||
"Horizon: wrote {} in {} ms",
|
||||
prepared_files::FILE_NAME,
|
||||
started.elapsed().as_millis()
|
||||
),
|
||||
Err(e) => log::info!("Horizon: {} not written: {e}", prepared_files::FILE_NAME),
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::info!(
|
||||
"Horizon: {} not written: {e}",
|
||||
horizon_core::prepared_files::FILE_NAME
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_world_rest(
|
||||
root: &std::path::Path,
|
||||
resources: &Resources,
|
||||
@@ -21625,6 +21758,22 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_client::container_screen::{Button, Clicks, Release, Target};
|
||||
|
||||
/// `MinecraftServer.computeNextAutosaveInterval`: three hundred seconds of
|
||||
/// ticks at the rate the server runs at, never fewer than a hundred.
|
||||
#[test]
|
||||
fn the_autosave_waits_for_three_hundred_seconds_of_ticks() {
|
||||
// 20 ticks a second is the six thousand ticks a world autosaved on
|
||||
// before the rate could be changed (`/tick rate`).
|
||||
assert_eq!(autosave_interval(20.0), 6000);
|
||||
assert_eq!(autosave_interval(10.0), 3000);
|
||||
assert_eq!(autosave_interval(40.0), 12000);
|
||||
assert_eq!(autosave_interval(1000.0), 300_000);
|
||||
// `Math.max(100, ...)`: a very low rate still autosaves every hundred
|
||||
// ticks, and a frozen clock (0) does too.
|
||||
assert_eq!(autosave_interval(0.5), 150);
|
||||
assert_eq!(autosave_interval(0.0), 100);
|
||||
}
|
||||
|
||||
/// `NativeScreenView.id` (the `screen_render_post` hook) is a mod's
|
||||
/// contract: every `Screen` has its own id, and it does not change.
|
||||
/// `Screen::id`'s match makes a new variant give itself one; this list
|
||||
|
||||
@@ -660,11 +660,12 @@ impl Game<'_> {
|
||||
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
|
||||
return false;
|
||||
}
|
||||
let (_, _, _, magic) = self.melee_damage("minecraft:armor_stand", true);
|
||||
let (_, _, crit, magic) = self.melee_damage("minecraft:armor_stand", true);
|
||||
let hurt = self.hit_armor_stand(i, |g, area, id| {
|
||||
g.attack_effects(
|
||||
area,
|
||||
Some(id as i32),
|
||||
crit,
|
||||
super::mace::Spared::Stand(id),
|
||||
magic.then_some(id as i32),
|
||||
);
|
||||
@@ -729,7 +730,7 @@ impl Game<'_> {
|
||||
let spread = [width / 4.0, height / 4.0, width / 4.0];
|
||||
self.particles
|
||||
.send_particles(&options, centre, spread, 0.05, 10);
|
||||
self.lan_level_particles(&options, centre, spread, 0.05, 10, Some(state));
|
||||
self.lan_level_particles(None, &options, centre, spread, 0.05, 10, Some(state));
|
||||
}
|
||||
/// `ArmorStand.hurtServer` for a player's attack (also a sweep's): a
|
||||
/// creative player breaks it, otherwise the first hit wobbles it and a
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! paddling controls, collisions, riding (`positionRider`, `clampRotation`,
|
||||
//! dismounting), `BoatItem` placement, `VehicleEntity.hurtServer` breaking,
|
||||
//! saving and `AbstractBoatRenderer` with animated paddles.
|
||||
use super::{Game, InteractTarget, Keys, entity_culling::at, view_vector};
|
||||
use super::{Game, InteractTarget, Keys, entity::Entity, entity_culling::at, view_vector};
|
||||
use horizon_client::entity_rules::EntityRules;
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
collisions::{EntityCollisions, SceneCollisions},
|
||||
@@ -586,17 +587,10 @@ impl Game<'_> {
|
||||
}
|
||||
// `Entity.baseTick`: the boat is immersed and pushed by the current it
|
||||
// sits in (`isPushedByFluid`), before `floatBoat` scales the velocity.
|
||||
let _ = horizon_client::entities::update_entity_fluids(
|
||||
b.bounds(),
|
||||
b.position,
|
||||
b.position.y + HEIGHT,
|
||||
&mut b.velocity,
|
||||
&self.world,
|
||||
&self.res.motion().fluids,
|
||||
);
|
||||
let _ = b.push_by_fluid(&self.world, &self.res.motion().fluids);
|
||||
// floatBoat
|
||||
// `-getGravity()`: none with NoGravity.
|
||||
let mut vspeed = if b.no_gravity { 0.0 } else { -0.04 };
|
||||
let mut vspeed = 0.0 - b.get_gravity();
|
||||
let mut buoyancy = 0.0;
|
||||
let inv_friction: f32;
|
||||
let obstacles = self.boat_move_obstacles(&b);
|
||||
@@ -683,11 +677,12 @@ impl Game<'_> {
|
||||
b.velocity.z += f64::from(r.cos() * acceleration);
|
||||
b.paddling = [right && !left || up, left && !right || up];
|
||||
}
|
||||
// Entity.move: collisions zero the blocked components.
|
||||
// Entity.move: collisions zero the blocked components, the world border's
|
||||
// among them (`CollisionGetter.borderCollision`).
|
||||
let mut scene = EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &obstacles,
|
||||
border: None,
|
||||
border: Some((self.border.geometry(), [b.position.x, b.position.z])),
|
||||
};
|
||||
if let Ok(moved) = scene.resolve(b.bounds(), b.velocity, 0.0, b.on_ground) {
|
||||
b.position = Vec3::new(
|
||||
|
||||
@@ -197,20 +197,105 @@ struct ResourceIds {
|
||||
damage_types: Vec<String>,
|
||||
loot_tables: Vec<String>,
|
||||
templates: Vec<String>,
|
||||
/// `entity-types.json`'s keys, sorted (empty when the file is missing).
|
||||
entity_types: Vec<String>,
|
||||
enchantments: Vec<String>,
|
||||
structures: Vec<String>,
|
||||
biomes: Vec<String>,
|
||||
clocks: Vec<String>,
|
||||
/// The overworld clock's timelines, their periods and time markers.
|
||||
timelines: Vec<(String, Option<i64>)>,
|
||||
markers: Vec<(String, TimeMarker)>,
|
||||
}
|
||||
static RESOURCE_IDS: Mutex<Vec<(PathBuf, Arc<ResourceIds>)>> = Mutex::new(Vec::new());
|
||||
|
||||
/// The `minecraft:` ids of the `.json` files directly in `dir`, sorted.
|
||||
fn json_ids_in(dir: &Path) -> Vec<String> {
|
||||
let mut out: Vec<String> = std::fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
e.file_name()
|
||||
.to_str()?
|
||||
.strip_suffix(".json")
|
||||
.map(|n| format!("minecraft:{n}"))
|
||||
})
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// The overworld clock's timelines (with their periods) and their time
|
||||
/// markers, read from `data/minecraft/timeline`.
|
||||
fn overworld_timelines(root: &Path) -> (Vec<(String, Option<i64>)>, Vec<(String, TimeMarker)>) {
|
||||
let dir = root.join("data/minecraft/timeline");
|
||||
let (mut timelines, mut markers) = (Vec::new(), Vec::new());
|
||||
for name in json_ids_in(&dir) {
|
||||
let path = dir.join(format!("{}.json", name.trim_start_matches("minecraft:")));
|
||||
let Ok(v) =
|
||||
serde_json::from_slice::<serde_json::Value>(&std::fs::read(path).unwrap_or_default())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if v["clock"].as_str() != Some("minecraft:overworld") {
|
||||
continue;
|
||||
}
|
||||
let period = v["period_ticks"].as_i64();
|
||||
timelines.push((name.clone(), period));
|
||||
for (marker, def) in v["time_markers"].as_object().into_iter().flatten() {
|
||||
let (ticks, show) = match def {
|
||||
serde_json::Value::Number(n) => (n.as_i64().unwrap_or(0), false),
|
||||
other => (
|
||||
other["ticks"].as_i64().unwrap_or(0),
|
||||
other["show_in_commands"].as_bool().unwrap_or(false),
|
||||
),
|
||||
};
|
||||
markers.push((
|
||||
marker.clone(),
|
||||
TimeMarker {
|
||||
ticks,
|
||||
period,
|
||||
show,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
(timelines, markers)
|
||||
}
|
||||
|
||||
fn resource_ids(root: &Path) -> Arc<ResourceIds> {
|
||||
let mut cache = RESOURCE_IDS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((_, ids)) = cache.iter().find(|(cached, _)| cached == root) {
|
||||
return ids.clone();
|
||||
}
|
||||
let data = root.join("data/minecraft");
|
||||
let mut entity_types: Vec<String> = serde_json::from_slice::<serde_json::Value>(
|
||||
&std::fs::read(root.join("entity-types.json")).unwrap_or_default(),
|
||||
)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
v.as_object().map(|o| {
|
||||
o.keys()
|
||||
.map(|k| horizon_client::command::args::normalize(k))
|
||||
.collect()
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
entity_types.sort();
|
||||
let (timelines, markers) = overworld_timelines(root);
|
||||
let ids = Arc::new(ResourceIds {
|
||||
recipes: ids_under(&data.join("recipe"), "json"),
|
||||
damage_types: ids_under(&data.join("damage_type"), "json"),
|
||||
loot_tables: ids_under(&data.join("loot_table"), "json"),
|
||||
templates: templates_under(&root.join("worldgen/structure")),
|
||||
entity_types,
|
||||
enchantments: json_ids_in(&data.join("enchantment")),
|
||||
structures: json_ids_in(&data.join("worldgen/structure")),
|
||||
biomes: json_ids_in(&data.join("worldgen/biome")),
|
||||
clocks: json_ids_in(&data.join("world_clock")),
|
||||
timelines,
|
||||
markers,
|
||||
});
|
||||
cache.push((root.to_owned(), ids.clone()));
|
||||
ids
|
||||
@@ -326,10 +411,11 @@ fn ids_in(root: &Path, extension: &str, namespace: &str) -> Vec<String> {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The caches `build_registries` reads (`resource_ids`, `block_properties`,
|
||||
/// `command_export`), filled on a helper thread while the title screen is up: the
|
||||
/// first world opened or server joined built them in its frame (20 ms of the Join
|
||||
/// click on an M4 Pro). Whoever needs one before it is done waits for it, as before.
|
||||
/// The caches `build_registries` and `join_server` read (`resource_ids`,
|
||||
/// `block_properties`, `command_export`, `builtin_registries`), filled on a helper
|
||||
/// thread while the title screen is up: the first world opened or server joined
|
||||
/// built them in its frame (20 ms of the Join click on an M4 Pro). Whoever needs one
|
||||
/// before it is done waits for it, as before.
|
||||
pub(super) fn preload_registries(&self) {
|
||||
if self.options.dedicated.is_some() {
|
||||
return;
|
||||
@@ -340,6 +426,7 @@ impl Game<'_> {
|
||||
.name("Registries preload".into())
|
||||
.spawn(move || {
|
||||
horizon_threads::behind_server();
|
||||
super::remote::builtin_registries(&dirs[0]);
|
||||
resource_ids(&dirs[0]);
|
||||
block_properties(registry);
|
||||
for dir in &dirs {
|
||||
@@ -472,50 +559,23 @@ impl Game<'_> {
|
||||
summonable.sort();
|
||||
summonable.dedup();
|
||||
reg.summonable = summonable;
|
||||
let root = self.options.resources.clone();
|
||||
let mut types: Vec<String> = serde_json::from_slice::<serde_json::Value>(
|
||||
&std::fs::read(root.join("entity-types.json")).unwrap_or_default(),
|
||||
)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
v.as_object().map(|o| {
|
||||
o.keys()
|
||||
.map(|k| horizon_client::command::args::normalize(k))
|
||||
.collect()
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut types = ids.entity_types.clone();
|
||||
if types.is_empty() {
|
||||
types = reg.summonable.clone();
|
||||
types.push("minecraft:player".into());
|
||||
types.sort();
|
||||
}
|
||||
types.sort();
|
||||
reg.entity_types = types;
|
||||
let list = |dir: &str| -> Vec<String> {
|
||||
let mut out: Vec<String> = std::fs::read_dir(root.join(dir))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
e.file_name()
|
||||
.to_str()?
|
||||
.strip_suffix(".json")
|
||||
.map(|n| format!("minecraft:{n}"))
|
||||
})
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
};
|
||||
// The mob effect registry: every effect `/effect` and the
|
||||
// `mob_effect` resource arguments accept.
|
||||
reg.effects = horizon_client::brewing::all_effects()
|
||||
.map(|e| horizon_client::brewing::namespaced(e.id).to_owned())
|
||||
.collect();
|
||||
reg.effects.sort();
|
||||
reg.enchantments = list("data/minecraft/enchantment");
|
||||
reg.structures = list("data/minecraft/worldgen/structure");
|
||||
reg.biomes = list("data/minecraft/worldgen/biome");
|
||||
reg.clocks = list("data/minecraft/world_clock");
|
||||
reg.enchantments = ids.enchantments.clone();
|
||||
reg.structures = ids.structures.clone();
|
||||
reg.biomes = ids.biomes.clone();
|
||||
reg.clocks = ids.clocks.clone();
|
||||
reg.dimensions = vec![
|
||||
"minecraft:overworld".into(),
|
||||
"minecraft:the_nether".into(),
|
||||
@@ -523,41 +583,8 @@ impl Game<'_> {
|
||||
];
|
||||
reg.players = vec![PLAYER_NAME.into()];
|
||||
// Timelines of the overworld clock and their time markers.
|
||||
self.ui.markers.clear();
|
||||
self.ui.timelines.clear();
|
||||
for name in list("data/minecraft/timeline") {
|
||||
let path = root.join(format!(
|
||||
"data/minecraft/timeline/{}.json",
|
||||
name.trim_start_matches("minecraft:")
|
||||
));
|
||||
let Ok(v) = serde_json::from_slice::<serde_json::Value>(
|
||||
&std::fs::read(path).unwrap_or_default(),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if v["clock"].as_str() != Some("minecraft:overworld") {
|
||||
continue;
|
||||
}
|
||||
let period = v["period_ticks"].as_i64();
|
||||
self.ui.timelines.insert(name.clone(), period);
|
||||
for (marker, def) in v["time_markers"].as_object().into_iter().flatten() {
|
||||
let (ticks, show) = match def {
|
||||
serde_json::Value::Number(n) => (n.as_i64().unwrap_or(0), false),
|
||||
other => (
|
||||
other["ticks"].as_i64().unwrap_or(0),
|
||||
other["show_in_commands"].as_bool().unwrap_or(false),
|
||||
),
|
||||
};
|
||||
self.ui.markers.insert(
|
||||
marker.clone(),
|
||||
TimeMarker {
|
||||
ticks,
|
||||
period,
|
||||
show,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
self.ui.timelines = ids.timelines.iter().cloned().collect();
|
||||
self.ui.markers = ids.markers.iter().cloned().collect();
|
||||
// Vanilla's built-in registries (mob effects, components, dialogs...).
|
||||
for dir in [&self.options.resources, &self.ui.vanilla_root] {
|
||||
if let Some(export) = command_export(dir, self.options.dedicated.is_none()) {
|
||||
@@ -1335,7 +1362,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// Load per-world command state when a world opens.
|
||||
pub(super) fn load_world_state(&mut self) {
|
||||
self.ui.rules = GameRules::load(&self.world_root);
|
||||
self.ui.rules =
|
||||
GameRules::load_with_features(&self.world_root, &self.level.enabled_features);
|
||||
self.ui.clock = WorldClock::default();
|
||||
// PrimaryLevelData: the difficulty comes back from level.dat's difficulty_settings.
|
||||
self.ui.difficulty = self.level.difficulty_id();
|
||||
@@ -1579,6 +1607,43 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Only the overworld clock's timelines count; a marker is a bare tick
|
||||
/// count (hidden from commands) or an object that may show itself.
|
||||
#[test]
|
||||
fn overworld_timelines_and_markers() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-timelines-{}", std::process::id()));
|
||||
let dir = root.join("data/minecraft/timeline");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("day.json"),
|
||||
r#"{"clock":"minecraft:overworld","period_ticks":24000,
|
||||
"time_markers":{"minecraft:noon":6000,
|
||||
"minecraft:night":{"ticks":13000,"show_in_commands":true}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join("other.json"),
|
||||
r#"{"clock":"minecraft:the_end","time_markers":{"minecraft:x":1}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("broken.json"), "{").unwrap();
|
||||
let (timelines, markers) = overworld_timelines(&root);
|
||||
assert_eq!(timelines, [("minecraft:day".to_owned(), Some(24000))]);
|
||||
let mut markers: Vec<_> = markers
|
||||
.iter()
|
||||
.map(|(name, m)| (name.as_str(), m.ticks, m.period, m.show))
|
||||
.collect();
|
||||
markers.sort();
|
||||
assert_eq!(
|
||||
markers,
|
||||
[
|
||||
("minecraft:night", 13000, Some(24000), true),
|
||||
("minecraft:noon", 6000, Some(24000), false),
|
||||
]
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// The export is read once per folder; a folder without one stays without.
|
||||
#[test]
|
||||
fn command_exports_are_read_once_per_folder() {
|
||||
|
||||
@@ -210,6 +210,12 @@ impl Game<'_> {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
"mobs" => {
|
||||
// Observe authoritative AI state without a control-socket race.
|
||||
let record = self.harness_owner(|g| Ok(g.control_mobs(128.0)))?;
|
||||
out.push_str(&record.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
"tick" => {
|
||||
self.chat_capture = Some(Vec::new());
|
||||
for _ in 0..op["n"].as_u64().unwrap_or(1) {
|
||||
|
||||
@@ -164,7 +164,7 @@ impl Game<'_> {
|
||||
if self.client_mod_command(command) {
|
||||
return true;
|
||||
}
|
||||
let d = vanilla::game_dispatcher();
|
||||
let d = vanilla::game_dispatcher_with_features(&self.level.enabled_features);
|
||||
let parse = d.parse(command, &self.ui.registries);
|
||||
let result = match parse.executable() {
|
||||
Err(e) => {
|
||||
@@ -1128,7 +1128,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
fn cmd_help(&mut self, ctx: &Context) -> R<i32> {
|
||||
let d = vanilla::game_dispatcher();
|
||||
let d = vanilla::game_dispatcher_with_features(&self.level.enabled_features);
|
||||
let level = self.ui.registries.permission;
|
||||
match ctx.get("command") {
|
||||
None => {
|
||||
|
||||
@@ -226,7 +226,7 @@ impl Game<'_> {
|
||||
/// Run one command line with `source` (no error reporting): the entry
|
||||
/// for functions and `execute run`.
|
||||
pub(in crate::game) fn execute_line(&mut self, line: &str, source: Source) -> R<i32> {
|
||||
let d = vanilla::game_dispatcher();
|
||||
let d = vanilla::game_dispatcher_with_features(&self.level.enabled_features);
|
||||
let parse = d.parse(line, &self.ui.registries);
|
||||
let (ctx, _) = parse.executable()?;
|
||||
let ctx = ctx.clone();
|
||||
|
||||
@@ -81,6 +81,9 @@ impl Game<'_> {
|
||||
_ => 20.0,
|
||||
};
|
||||
self.clock.set_rate(rate);
|
||||
// MinecraftServer.setTickRate: the autosave wait already
|
||||
// running is shortened when the new rate asks for less.
|
||||
self.on_tick_rate_changed();
|
||||
self.feedback(t(
|
||||
"commands.tick.rate.success",
|
||||
&[lit(format_fixed(f64::from(rate), 1))],
|
||||
|
||||
@@ -753,6 +753,7 @@ impl Game<'_> {
|
||||
nearest_player: player.as_ref().filter(|p| !p.spectator).map(|p| p.position),
|
||||
player,
|
||||
others,
|
||||
dragon: self.dragon_target(),
|
||||
leash_boats: &self.leash_boats,
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
@@ -1710,6 +1711,16 @@ impl Game<'_> {
|
||||
self.creatures.projectiles.push(p);
|
||||
self.native_projectile_launch(player, &name, id, kind);
|
||||
}
|
||||
/// `MushroomCow.shear`'s `sendParticles(EXPLOSION, x, getY(0.5), z, 1, 0, 0, 0, 0)`:
|
||||
/// one puff over a sheared mooshroom, half its `height` up, shown here and to
|
||||
/// the players within range.
|
||||
pub(super) fn mooshroom_shear_puff(&mut self, at: [f64; 3], height: f64) {
|
||||
let at = [at[0], at[1] + 0.5 * height, at[2]];
|
||||
let options = ParticleOptions::simple(ParticleType::Explosion);
|
||||
self.particles
|
||||
.send_particles(&options, at, [0.0; 3], 0.0, 1);
|
||||
self.lan_level_particles(None, &options, at, [0.0; 3], 0.0, 1, None);
|
||||
}
|
||||
/// `Entity.sendBubbleColumnParticles` at the top of the column block `pos`: two
|
||||
/// splashes and two bubbles over it, which the level random places, shown here
|
||||
/// and to the players within range.
|
||||
@@ -1729,11 +1740,19 @@ impl Game<'_> {
|
||||
let splash_options = ParticleOptions::simple(ParticleType::Splash);
|
||||
self.particles
|
||||
.send_particles(&splash_options, splash, [0.0; 3], 1.0, 1);
|
||||
self.lan_level_particles(&splash_options, splash, [0.0; 3], 1.0, 1, None);
|
||||
self.lan_level_particles(None, &splash_options, splash, [0.0; 3], 1.0, 1, None);
|
||||
let bubble_options = ParticleOptions::simple(ParticleType::Bubble);
|
||||
self.particles
|
||||
.send_particles(&bubble_options, bubble, [0.0, 0.01, 0.0], 0.2, 1);
|
||||
self.lan_level_particles(&bubble_options, bubble, [0.0, 0.01, 0.0], 0.2, 1, None);
|
||||
self.lan_level_particles(
|
||||
None,
|
||||
&bubble_options,
|
||||
bubble,
|
||||
[0.0, 0.01, 0.0],
|
||||
0.2,
|
||||
1,
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
|
||||
@@ -1742,6 +1761,13 @@ impl Game<'_> {
|
||||
MobEvent::BubbleColumnParticles { pos } => {
|
||||
self.bubble_column_particles(BlockPos::new(pos[0], pos[1], pos[2]))
|
||||
}
|
||||
MobEvent::LandingDust { state, at, count } => self.landing_dust(
|
||||
None,
|
||||
state,
|
||||
Vec3::new(at[0], at[1], at[2]),
|
||||
count,
|
||||
f64::from(0.15_f32),
|
||||
),
|
||||
MobEvent::SquidCycle { mob, at } => self.lan_entity_event(mob as i32, 19, at),
|
||||
MobEvent::RabbitHop { mob, at } => self.lan_entity_event(mob as i32, 1, at),
|
||||
MobEvent::Drown { mob, at } => {
|
||||
@@ -1769,9 +1795,22 @@ impl Game<'_> {
|
||||
let options = horizon_client::particles::ParticleOptions::simple(ty);
|
||||
self.particles
|
||||
.send_particles(&options, at, spread, speed, count);
|
||||
self.lan_level_particles(&options, at, spread, speed, count, None);
|
||||
self.lan_level_particles(None, &options, at, spread, speed, count, None);
|
||||
}
|
||||
}
|
||||
// The LAN packet has no item for a particle yet: the host's window only.
|
||||
MobEvent::ItemParticles {
|
||||
item,
|
||||
at,
|
||||
spread,
|
||||
speed,
|
||||
count,
|
||||
} => {
|
||||
let options =
|
||||
self.item_particle(&horizon_client::items::ItemStack::new(item, 1));
|
||||
self.particles
|
||||
.send_particles(&options, at, spread, speed, count);
|
||||
}
|
||||
MobEvent::Sound(name, at, volume, pitch)
|
||||
| MobEvent::LocalSound(name, at, volume, pitch) => {
|
||||
self.audio
|
||||
@@ -5136,6 +5175,8 @@ impl Game<'_> {
|
||||
// `Some(true)` for the four spell particles, `Some(false)` for the two
|
||||
// smoke puffs of a cow that already holds effects.
|
||||
let mut stew_particles: Option<bool> = None;
|
||||
// `MushroomCow.shear`: where, and how tall, the mooshroom that was sheared stood.
|
||||
let mut shear_puff: Option<([f64; 3], f64)> = None;
|
||||
// `Ocelot.mobInteract`: whether the fish won its trust.
|
||||
let mut ocelot_trust: Option<bool> = None;
|
||||
match species {
|
||||
@@ -5179,6 +5220,10 @@ impl Game<'_> {
|
||||
"minecraft:red_mushroom"
|
||||
};
|
||||
drops.push((mushroom.into(), 5));
|
||||
shear_puff = Some((
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
f64::from(m.height()),
|
||||
));
|
||||
m.removed = true;
|
||||
let setup = SpawnSetup {
|
||||
yaw: m.yaw,
|
||||
@@ -5724,6 +5769,9 @@ impl Game<'_> {
|
||||
self.trigger_item_entity("minecraft:player_sheared_equipment", &stack, &target);
|
||||
self.spawn_item(stack, [center[0], center[1] + 0.5, center[2]], [0.0; 3], 10);
|
||||
}
|
||||
if let Some((at, height)) = shear_puff {
|
||||
self.mooshroom_shear_puff(at, height);
|
||||
}
|
||||
let mut drops: Vec<ItemStack> = drops
|
||||
.into_iter()
|
||||
.map(|(item, count)| ItemStack::new(&item, count))
|
||||
@@ -6449,7 +6497,9 @@ impl Game<'_> {
|
||||
v.layer |= u32::from(tier) << 16;
|
||||
}
|
||||
}
|
||||
if is_player {
|
||||
// AvatarRenderer.shouldRenderLayers: a spectator draws no layers.
|
||||
let layers = horizon_client::mobs::render::layers_shown(mob);
|
||||
if is_player && layers {
|
||||
// PlayerRenderer's CapeLayer, WingsLayer, ParrotOnShoulderLayer
|
||||
// and SpinAttackEffectLayer. Only the cape hides with the body;
|
||||
// the others have no `isInvisible` check.
|
||||
@@ -6470,9 +6520,11 @@ impl Game<'_> {
|
||||
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
|
||||
}
|
||||
// CustomHeadLayer (a pumpkin, skull or head item) included.
|
||||
self.mob_block_geometry(mob, camera, alpha, packed, out);
|
||||
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
|
||||
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
|
||||
if layers {
|
||||
self.mob_block_geometry(mob, camera, alpha, packed, out);
|
||||
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
|
||||
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
|
||||
}
|
||||
if let Some(rgb) = outline {
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
if mob.invisible {
|
||||
@@ -7225,6 +7277,7 @@ mod pearl_tests {
|
||||
player: None,
|
||||
nearest_player: None,
|
||||
others: &[],
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
|
||||
@@ -1129,6 +1129,11 @@ impl Game<'_> {
|
||||
for b in &self.bolts {
|
||||
g.hitbox(b.at, (0.0, 0.0), 0.0, false, (0.0, 0.0));
|
||||
}
|
||||
// AreaEffectCloud.getDimensions: diameter follows synced radius,
|
||||
// height is 0.5; EntityDimensions.scalable gives eyes at 85% height.
|
||||
for c in &self.potions.clouds {
|
||||
g.hitbox(c.at, (c.radius * 2.0, 0.5), 0.425, false, (0.0, 0.0));
|
||||
}
|
||||
// The dragon: 16 by 8, a living entity; its parts follow.
|
||||
if let Some(d) = &self.dragon {
|
||||
g.hitbox(
|
||||
|
||||
@@ -637,6 +637,8 @@ struct ServerState {
|
||||
max_players: usize,
|
||||
/// The mutable `gamemode` server property (`DedicatedServer.gameMode`).
|
||||
game_mode: i32,
|
||||
/// The mutable `force-gamemode` server property (`DedicatedServer.forceGameMode`).
|
||||
force_game_mode: bool,
|
||||
}
|
||||
|
||||
/// The dedicated-only commands' replies vanilla sends with `broadcast`
|
||||
@@ -1005,6 +1007,7 @@ impl Game<'_> {
|
||||
broadcast_rcon: props.broadcast_rcon_to_ops,
|
||||
op_level: props.op_permission_level,
|
||||
game_mode: props.game_mode,
|
||||
force_game_mode: props.force_game_mode,
|
||||
report: None,
|
||||
view: (
|
||||
props.view_distance.clamp(2, 32) as u32,
|
||||
|
||||
@@ -89,6 +89,22 @@ impl Game<'_> {
|
||||
self.set_default_game_mode(state.game_mode);
|
||||
return json!({ "value": GAME_MODES[state.game_mode as usize] });
|
||||
}
|
||||
"serversettings/force_game_mode" => {
|
||||
return json!({ "value": state.force_game_mode });
|
||||
}
|
||||
"serversettings/force_game_mode/set" => {
|
||||
let Some(force) = body["value"].as_bool() else {
|
||||
return json!({ "error": "force game mode must be a boolean" });
|
||||
};
|
||||
rpc(format!(
|
||||
"Update force game mode from {} to {}",
|
||||
state.force_game_mode, force
|
||||
));
|
||||
self.store_property("force-gamemode", force.to_string());
|
||||
state.force_game_mode = force;
|
||||
self.set_force_game_mode(force);
|
||||
return json!({ "value": state.force_game_mode });
|
||||
}
|
||||
"serversettings/difficulty" => {
|
||||
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `ProjectileDeflection.AIM_DEFLECT` instead of hurting anything. It flies on
|
||||
//! along the player's view and is the player's now (`Projectile.deflect`
|
||||
//! sets the owner), so a ghast it meets dies of it (`Ghast.isReflectedFireball`).
|
||||
use super::{Game, ray_box, view_vector};
|
||||
use super::{Game, lan::LanHost, ray_box, view_vector};
|
||||
use horizon_client::{
|
||||
mobs::Who,
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
@@ -125,7 +125,7 @@ impl Game<'_> {
|
||||
self.creatures.projectiles[i].kind,
|
||||
ProjectileKind::ShulkerBullet { .. }
|
||||
) {
|
||||
self.hit_shulker_bullet(i);
|
||||
self.hit_shulker_bullet(None, i);
|
||||
} else {
|
||||
self.deflect(i);
|
||||
}
|
||||
@@ -135,7 +135,7 @@ impl Game<'_> {
|
||||
/// `ShulkerBullet.hurtServer` from a player's blow: the hurt sound, fifteen crit
|
||||
/// particles (`sendParticles`, spread 0.2) and `destroy`. The blow is
|
||||
/// `Player.attack` on a target that is not a mob: its sounds, then the swing.
|
||||
pub(super) fn hit_shulker_bullet(&mut self, i: usize) {
|
||||
pub(super) fn hit_shulker_bullet(&mut self, host: Option<&LanHost>, i: usize) {
|
||||
let p = self.creatures.projectiles.remove(i);
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
self.audio
|
||||
@@ -144,7 +144,7 @@ impl Game<'_> {
|
||||
let options = horizon_client::particles::ParticleOptions::simple(ty);
|
||||
self.particles
|
||||
.send_particles(&options, at, [0.2, 0.2, 0.2], 0.0, 15);
|
||||
self.lan_level_particles(&options, at, [0.2, 0.2, 0.2], 0.0, 15, None);
|
||||
self.lan_level_particles(host, &options, at, [0.2, 0.2, 0.2], 0.0, 15, None);
|
||||
}
|
||||
let (_, _, _, magic) = self.melee_damage("minecraft:shulker_bullet", false);
|
||||
self.vehicle_attack_sounds(p.aabb(), magic.then_some(p.id as i32));
|
||||
|
||||
@@ -1951,6 +1951,7 @@ impl Game<'_> {
|
||||
.play("minecraft:entity.mooshroom.shear", sound_at, 1.0, 1.0);
|
||||
// convertTo(COW): the cow takes its place, then the mushrooms drop.
|
||||
self.convert_mob(i, "minecraft:cow");
|
||||
self.mooshroom_shear_puff([at.x, at.y, at.z], height);
|
||||
let drops = self.sequence_loot(
|
||||
"minecraft:shearing/mooshroom",
|
||||
&format!("minecraft:shearing/mooshroom/{variant}"),
|
||||
|
||||
@@ -1389,7 +1389,7 @@ impl Game<'_> {
|
||||
let hurt = self.hit_special(id, amount, true, DamageKind::PlayerAttack, None);
|
||||
// The sweep and the sounds read the charge, which the hit then spends.
|
||||
if let (Some(area), Hurt::Landed { .. }) = (area, hurt) {
|
||||
self.attack_effects(area, None, super::mace::Spared::Nothing, None);
|
||||
self.attack_effects(area, None, false, super::mace::Spared::Nothing, None);
|
||||
}
|
||||
self.attack_ticks = 0;
|
||||
true
|
||||
@@ -2188,6 +2188,27 @@ impl Game<'_> {
|
||||
set(d, 3 + i, x, y, z);
|
||||
}
|
||||
}
|
||||
/// `Level.getEntity`: revenge goals must resolve the dragon too.
|
||||
pub(super) fn dragon_target(&self) -> Option<(u32, horizon_client::mobs::TargetInfo)> {
|
||||
let d = self.dragon.as_ref().filter(|d| d.alive())?;
|
||||
let p = d.position;
|
||||
Some((
|
||||
d.id,
|
||||
horizon_client::mobs::TargetInfo {
|
||||
position: p,
|
||||
eye_y: p.y + 6.8,
|
||||
bounds: Aabb::new(
|
||||
Vec3::new(p.x - 8.0, p.y, p.z - 8.0),
|
||||
Vec3::new(p.x + 8.0, p.y + 8.0, p.z + 8.0),
|
||||
),
|
||||
alive: true,
|
||||
can_be_seen_as_enemy: true,
|
||||
is_player: false,
|
||||
creative: false,
|
||||
passenger: false,
|
||||
},
|
||||
))
|
||||
}
|
||||
/// Wing knockback, head and neck damage, and breaking blocks in the way.
|
||||
fn dragon_contacts(&mut self) {
|
||||
let Some(d) = self.dragon.as_ref() else {
|
||||
@@ -2232,6 +2253,35 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// EnderDragon.aiStep queries living entities, not only players.
|
||||
if let Some(d) = self.dragon.as_ref() {
|
||||
let frame = horizon_client::mobs::dragon_contacts::Frame {
|
||||
dragon_id: d.id,
|
||||
origin: d.position,
|
||||
center: (bc, bz),
|
||||
wings: wings.map(|w| {
|
||||
w.inflate(Vec3::new(4.0, 2.0, 4.0))
|
||||
.move_by(Vec3::new(0.0, -2.0, 0.0))
|
||||
}),
|
||||
heads: heads.map(|h| h.inflate(Vec3::new(1.0, 1.0, 1.0))),
|
||||
sitting,
|
||||
recently_hurt,
|
||||
};
|
||||
// Keep team lookup in Game (including tamed pets' owner teams).
|
||||
let allowed: Vec<bool> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.map(|m| {
|
||||
horizon_client::scoreboard::teams_allow_damage(
|
||||
self.mob_team(d.id),
|
||||
self.mob_team_of(m),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for (mob, allowed) in self.mobs.iter_mut().zip(allowed) {
|
||||
horizon_client::mobs::dragon_contacts::contact(mob, &frame, allowed);
|
||||
}
|
||||
}
|
||||
// checkWalls over head, neck and body.
|
||||
let griefing = self.ui.rules.bool("mob_griefing");
|
||||
let mut in_wall = false;
|
||||
@@ -3254,6 +3304,7 @@ impl Game<'_> {
|
||||
head_yaw: d.yaw,
|
||||
on_ground: false,
|
||||
velocity: [d.velocity.x, d.velocity.y, d.velocity.z],
|
||||
minecart_steps: None,
|
||||
data: 0,
|
||||
meta,
|
||||
defaults: Vec::new(),
|
||||
@@ -3266,6 +3317,7 @@ impl Game<'_> {
|
||||
leash: None,
|
||||
spectator: false,
|
||||
watching: false,
|
||||
hidden: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use super::{
|
||||
boats::Boat, fireworks::Rocket, minecarts::Minecart, tnt::PrimedTnt, trident::ThrownTrident,
|
||||
};
|
||||
use horizon_client::entities::{ArmorStand, ExperienceOrb, ItemEntity};
|
||||
use horizon_client::entity_rules::EntityRules;
|
||||
use horizon_client::mobs::{Mob, Species};
|
||||
use horizon_client::projectiles::Projectile;
|
||||
use horizon_core::geometry::{Aabb, Direction, Vec3};
|
||||
@@ -47,7 +48,9 @@ pub(super) struct Blast {
|
||||
pub(super) spared: Option<u32>,
|
||||
}
|
||||
|
||||
pub(super) trait Entity {
|
||||
pub(super) trait Entity: EntityRules {
|
||||
/// `Entity.getId`.
|
||||
fn id(&self) -> u32;
|
||||
fn position(&self) -> Vec3;
|
||||
fn position_mut(&mut self) -> &mut Vec3;
|
||||
fn velocity_mut(&mut self) -> &mut Vec3;
|
||||
@@ -132,10 +135,77 @@ pub(super) trait Entity {
|
||||
fn is_supported_by(&self, _pos: BlockPos) -> bool {
|
||||
false
|
||||
}
|
||||
/// `Entity.getEyeY`, where `updateFluidHeightAndDoFluidPushing` reads the
|
||||
/// eye's fluid; the top of the box unless a kind sets it.
|
||||
fn fluid_eye_y(&self) -> f64 {
|
||||
self.bounds().max_position().y
|
||||
}
|
||||
/// `Entity.updateFluidHeightAndDoFluidPushing`: its water and lava heights
|
||||
/// (`(0, 0)` for a kind that is not pushed by fluid), and the current of
|
||||
/// each fluid added to its velocity. One body for every kind.
|
||||
fn push_by_fluid(
|
||||
&mut self,
|
||||
world: &horizon_client::world::World,
|
||||
fluids: &horizon_client::motion::FluidResources,
|
||||
) -> (f64, f64) {
|
||||
if !self.is_pushed_by_fluid() {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let (bounds, at, eye) = (self.bounds(), self.position(), self.fluid_eye_y());
|
||||
horizon_client::entities::update_entity_fluids(
|
||||
bounds,
|
||||
at,
|
||||
eye,
|
||||
self.velocity_mut(),
|
||||
world,
|
||||
fluids,
|
||||
)
|
||||
}
|
||||
/// `BubbleColumnBlock.entityInside` through `applyEffectsFromBlocks`: a
|
||||
/// column lifts or drags every kind alike.
|
||||
fn push_by_bubble_column(
|
||||
&mut self,
|
||||
world: &horizon_client::world::World,
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
facts: &horizon_client::blocks::BlockFacts,
|
||||
collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>,
|
||||
) -> bool {
|
||||
let bounds = self.bounds();
|
||||
horizon_client::motion::bubble_column_push(
|
||||
world,
|
||||
registry,
|
||||
facts,
|
||||
collision,
|
||||
bounds,
|
||||
self.velocity_mut(),
|
||||
)
|
||||
}
|
||||
/// `Entity.getPistonPushReaction`.
|
||||
fn get_piston_push_reaction(&self) -> PushReaction {
|
||||
PushReaction::PushPull
|
||||
}
|
||||
/// Width and height of the box a portal sweep follows, or None for a kind
|
||||
/// the game does not carry through portals (so far armor stands and
|
||||
/// falling blocks; fishing hooks are off the walk, and
|
||||
/// `FishingHook.canUsePortal` is false in vanilla).
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
None
|
||||
}
|
||||
/// `Entity.canUsePortal(false)` for a kind in `portal_size`. A kind that
|
||||
/// cannot stays tracked, so its cooldown and process run out.
|
||||
fn can_use_portal(&self, _game: &Game<'_>) -> bool {
|
||||
true
|
||||
}
|
||||
/// Where it stood at the start of its tick, for the kinds that store it
|
||||
/// (None: `portals.before` has it).
|
||||
fn portal_from(&self) -> Option<Vec3> {
|
||||
None
|
||||
}
|
||||
/// Whether this tick's movement can have entered a portal block: a stuck
|
||||
/// arrow or a trident in the ground that did not move enters none.
|
||||
fn portal_sweeps(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// `Entity.move(MoverType.PISTON, …)` once the movement is resolved.
|
||||
fn move_by_piston(&mut self, movement: Vec3) {
|
||||
let position = self.position_mut();
|
||||
@@ -152,6 +222,9 @@ pub(super) trait Entity {
|
||||
/// The fields every kind stores under the same names.
|
||||
macro_rules! stored_motion {
|
||||
() => {
|
||||
fn id(&self) -> u32 {
|
||||
self.id
|
||||
}
|
||||
fn position(&self) -> Vec3 {
|
||||
self.position
|
||||
}
|
||||
@@ -331,6 +404,12 @@ pub(super) use with_entity_mut;
|
||||
|
||||
impl Entity for Mob {
|
||||
stored_motion!();
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
Some((self.width(), self.height()))
|
||||
}
|
||||
fn can_use_portal(&self, game: &Game<'_>) -> bool {
|
||||
game.mob_can_use_portal(self)
|
||||
}
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
(self.death_time == 0).then_some(Target::Mob(self.id))
|
||||
}
|
||||
@@ -363,16 +442,11 @@ impl Entity for Mob {
|
||||
|| (self.species() == Species::Warden
|
||||
&& horizon_client::mobs::warden::digging_or_emerging(self))
|
||||
}
|
||||
/// The kind's own box, not the baby's or the slime's.
|
||||
fn explosion_box(&self) -> Aabb {
|
||||
let (feet, half) = (self.position, self.kind.width / 2.0);
|
||||
Aabb::new(
|
||||
Vec3::new(feet.x - half, feet.y, feet.z - half),
|
||||
Vec3::new(feet.x + half, feet.y + self.kind.height, feet.z + half),
|
||||
)
|
||||
}
|
||||
/// `getEyeHeight` of the mob as it stands: a baby's, a slime's or a
|
||||
/// phantom's own scaled eye height, not the adult kind's. The blast's box
|
||||
/// is its real `bounds`, so it needs no override.
|
||||
fn explosion_eye_height(&self) -> f64 {
|
||||
self.kind.eye_height
|
||||
self.eye_height()
|
||||
}
|
||||
/// `Entity.push` sets `needsSync`.
|
||||
fn push_from_explosion(&mut self, push: Vec3) {
|
||||
@@ -405,6 +479,9 @@ impl Entity for Mob {
|
||||
|
||||
impl Entity for ItemEntity {
|
||||
stored_motion!();
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
Some((0.25, 0.25))
|
||||
}
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
Some(Target::Item(self.id))
|
||||
}
|
||||
@@ -437,6 +514,9 @@ impl Entity for ItemEntity {
|
||||
|
||||
impl Entity for ExperienceOrb {
|
||||
stored_motion!();
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
Some((0.5, 0.5))
|
||||
}
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
Some(Target::Orb(self.id))
|
||||
}
|
||||
@@ -466,6 +546,16 @@ impl Entity for ExperienceOrb {
|
||||
|
||||
impl Entity for Boat {
|
||||
stored_motion!();
|
||||
/// `Boat.getEyeY`: the top of the 0.5625 box.
|
||||
fn fluid_eye_y(&self) -> f64 {
|
||||
self.position.y + super::boats::HEIGHT
|
||||
}
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
Some((super::boats::WIDTH as f32, super::boats::HEIGHT as f32))
|
||||
}
|
||||
fn can_use_portal(&self, game: &Game<'_>) -> bool {
|
||||
game.vehicle_can_use_portal(self.id)
|
||||
}
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
Some(Target::Boat(self.id))
|
||||
}
|
||||
@@ -489,6 +579,15 @@ impl Entity for Boat {
|
||||
|
||||
impl Entity for Minecart {
|
||||
stored_motion!();
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
Some((
|
||||
super::minecarts::WIDTH as f32,
|
||||
super::minecarts::HEIGHT as f32,
|
||||
))
|
||||
}
|
||||
fn can_use_portal(&self, game: &Game<'_>) -> bool {
|
||||
game.vehicle_can_use_portal(self.id)
|
||||
}
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
Some(Target::Cart(self.id))
|
||||
}
|
||||
@@ -582,6 +681,17 @@ impl Entity for ArmorStand {
|
||||
|
||||
impl Entity for PrimedTnt {
|
||||
stored_motion!();
|
||||
/// `PrimedTnt` eye height: `0.98 * 0.85`.
|
||||
fn fluid_eye_y(&self) -> f64 {
|
||||
self.position.y + f64::from(0.98f32 * 0.85f32)
|
||||
}
|
||||
/// `PrimedTnt` is 0.98 across.
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
Some((0.98, 0.98))
|
||||
}
|
||||
fn can_use_portal(&self, game: &Game<'_>) -> bool {
|
||||
game.tnt_can_use_portal(self.id)
|
||||
}
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
Some(Target::Tnt(self.id))
|
||||
}
|
||||
@@ -615,6 +725,17 @@ impl Entity for FallingBlock {
|
||||
|
||||
impl Entity for Projectile {
|
||||
stored_motion!();
|
||||
/// Arrows and the thrown items and fireballs a chunk saves.
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
(super::nether_portal::is_arrow(self) || super::nether_portal::is_portal_projectile(self))
|
||||
.then(|| super::nether_portal::projectile_box(self))
|
||||
}
|
||||
fn portal_from(&self) -> Option<Vec3> {
|
||||
Some(self.previous)
|
||||
}
|
||||
fn portal_sweeps(&self) -> bool {
|
||||
!super::nether_portal::is_arrow(self) || !self.stuck || self.previous != self.position
|
||||
}
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
// The kinds /data can write (entity_nbt).
|
||||
@@ -657,6 +778,13 @@ impl Entity for Projectile {
|
||||
|
||||
impl Entity for Rocket {
|
||||
stored_motion!();
|
||||
/// A rocket that carries its owner stays.
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
(self.attached_to.is_none() && !self.attached).then_some((0.25, 0.25))
|
||||
}
|
||||
fn portal_from(&self) -> Option<Vec3> {
|
||||
Some(self.previous)
|
||||
}
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
Some(Target::Rocket(self.id))
|
||||
}
|
||||
@@ -675,6 +803,15 @@ impl Entity for Rocket {
|
||||
|
||||
impl Entity for ThrownTrident {
|
||||
stored_motion!();
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
Some((0.5, 0.5))
|
||||
}
|
||||
fn portal_from(&self) -> Option<Vec3> {
|
||||
Some(self.previous)
|
||||
}
|
||||
fn portal_sweeps(&self) -> bool {
|
||||
!self.in_ground || self.previous != self.position
|
||||
}
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
// No `Target` variant for it yet: `@e` does not find tridents.
|
||||
None
|
||||
@@ -699,3 +836,42 @@ impl Entity for ThrownTrident {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl EntityRules for Rocket {}
|
||||
impl EntityRules for ThrownTrident {}
|
||||
impl EntityRules for Boat {
|
||||
fn is_no_gravity(&self) -> bool {
|
||||
self.no_gravity
|
||||
}
|
||||
/// `AbstractBoat.getDefaultGravity`.
|
||||
fn get_default_gravity(&self) -> f64 {
|
||||
0.04
|
||||
}
|
||||
}
|
||||
impl EntityRules for Minecart {
|
||||
fn is_no_gravity(&self) -> bool {
|
||||
self.no_gravity
|
||||
}
|
||||
/// `AbstractMinecart.getDefaultGravity`.
|
||||
fn get_default_gravity(&self) -> f64 {
|
||||
if self.in_water { 0.005 } else { 0.04 }
|
||||
}
|
||||
}
|
||||
impl EntityRules for PrimedTnt {
|
||||
fn is_no_gravity(&self) -> bool {
|
||||
self.no_gravity
|
||||
}
|
||||
/// `PrimedTnt.getDefaultGravity`.
|
||||
fn get_default_gravity(&self) -> f64 {
|
||||
0.04
|
||||
}
|
||||
}
|
||||
impl EntityRules for FallingBlock {
|
||||
fn is_no_gravity(&self) -> bool {
|
||||
self.no_gravity
|
||||
}
|
||||
/// `FallingBlockEntity.getDefaultGravity`.
|
||||
fn get_default_gravity(&self) -> f64 {
|
||||
0.04
|
||||
}
|
||||
}
|
||||
@@ -1363,4 +1363,38 @@ mod tests {
|
||||
add_or_append_stack(&mut stacks, pick(5), [0, 0, 0], &max);
|
||||
assert_eq!(stacks.len(), 2);
|
||||
}
|
||||
|
||||
/// A blast and a burst of wind cast their rays at, and push from the eyes of,
|
||||
/// the mob as it stands: a baby's and a size-4 slime's own box and eye
|
||||
/// height, not the adult kind's (`Entity.getBoundingBox`, `getEyeHeight`).
|
||||
#[test]
|
||||
fn a_scaled_mob_is_blasted_by_its_own_box_and_eye_height() {
|
||||
use crate::game::entity::Entity;
|
||||
use horizon_client::mobs::{Mob, Species};
|
||||
let mob = |species| {
|
||||
let mut kind = (*zombie_type()).clone();
|
||||
kind.species = species;
|
||||
Mob::new(1, std::sync::Arc::new(kind), Vec3::ZERO, 0.0)
|
||||
};
|
||||
let adult = mob(Species::Zombie);
|
||||
assert_eq!(adult.explosion_box(), adult.bounds());
|
||||
let mut baby = mob(Species::Zombie);
|
||||
baby.extra.age = -24000;
|
||||
let mut slime = mob(Species::Slime);
|
||||
slime.extra.size = 4;
|
||||
for m in [&baby, &slime] {
|
||||
let b = Entity::explosion_box(m);
|
||||
assert_eq!(b, m.bounds());
|
||||
assert_eq!(Entity::explosion_eye_height(m), m.eye_height());
|
||||
assert_ne!(
|
||||
f64::from(m.height()),
|
||||
f64::from(adult.height()),
|
||||
"the case must scale the box"
|
||||
);
|
||||
assert_eq!(
|
||||
b.max_position().y - b.min_position().y,
|
||||
f64::from(m.height())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,7 +245,9 @@ impl Game<'_> {
|
||||
}
|
||||
/// `FireworkRocketItem.useOn`: launch from the clicked point.
|
||||
pub(super) fn launch_firework(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:firework_rocket" {
|
||||
if &*stack.item != "minecraft:firework_rocket"
|
||||
|| use_on_passes(self.player.as_ref().is_some_and(|p| p.fall_flying))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let at = Vec3::new(
|
||||
@@ -716,3 +718,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `FireworkRocketItem.useOn` returns PASS while the player is fall flying,
|
||||
/// leaving the boost to `use` (a guest's UseItemOn must not launch a rocket).
|
||||
fn use_on_passes(fall_flying: bool) -> bool {
|
||||
fall_flying
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn use_on_passes_only_while_gliding() {
|
||||
assert!(super::use_on_passes(true));
|
||||
assert!(!super::use_on_passes(false));
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ pub(super) fn reload_sync_data(
|
||||
let packs = !overlay.files.is_empty() || kept.has_pack_entries();
|
||||
let sync = SyncData::load(root, overlay).map(|mut sync| {
|
||||
sync.keep_registries(root, kept);
|
||||
sync.features.clone_from(&kept.features);
|
||||
Arc::new(sync)
|
||||
});
|
||||
*SYNC.lock().unwrap_or_else(|e| e.into_inner()) = Some((packs, sync.clone()));
|
||||
@@ -1286,7 +1287,21 @@ impl Game<'_> {
|
||||
config: HubConfig,
|
||||
host: Option<u128>,
|
||||
) -> Result<()> {
|
||||
let sync = sync_data(&self.options.resources, &self.pack_files())?;
|
||||
let mut sync = sync_data(&self.options.resources, &self.pack_files())?;
|
||||
let features = if self.level.enabled_features.is_empty() {
|
||||
vec!["minecraft:vanilla".to_owned()]
|
||||
} else {
|
||||
self.level.enabled_features.clone()
|
||||
};
|
||||
if sync.features.iter().any(|f| !features.contains(f))
|
||||
|| features.iter().any(|f| !sync.features.contains(f))
|
||||
{
|
||||
// The shared resource preload has vanilla flags. Feature flags belong
|
||||
// to this world, not to the cached resources or the next world opened.
|
||||
let mut world_sync = SyncData::load(&self.options.resources, &self.pack_files())?;
|
||||
world_sync.features = features;
|
||||
sync = Arc::new(world_sync);
|
||||
}
|
||||
self.sync_dialog_ids(&sync);
|
||||
// The table the world plays with (`world_biomes!`): a world with
|
||||
// biome data packs sends their ids past the built-in list, as the
|
||||
@@ -1695,6 +1710,15 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the default mode to connected players when forcing it is enabled.
|
||||
pub(super) fn set_force_game_mode(&mut self, force: bool) {
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.settings.force_game_mode = force;
|
||||
host.hub.config.force_game_mode = force;
|
||||
enforce_guest_game_mode(host, &self.world, self.collision);
|
||||
}
|
||||
}
|
||||
|
||||
/// New command access and game mode forcing settings of the open world.
|
||||
pub(super) fn lan_apply_settings(&mut self, guest_commands: bool, force: bool) {
|
||||
if let Some(server) = &self.integrated {
|
||||
@@ -1706,11 +1730,9 @@ impl Game<'_> {
|
||||
let effective = guest_commands && (!self.serving() || self.allow_commands());
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.settings.guest_commands = guest_commands;
|
||||
host.settings.force_game_mode = force;
|
||||
host.hub.config.guest_commands = effective;
|
||||
host.hub.config.force_game_mode = force;
|
||||
enforce_guest_game_mode(host, &self.world, self.collision);
|
||||
}
|
||||
self.set_force_game_mode(force);
|
||||
self.resend_commands_filtered();
|
||||
}
|
||||
|
||||
@@ -1773,11 +1795,12 @@ impl Game<'_> {
|
||||
/// The entities guests track: mobs, items and the rest (game/lan_entities.rs).
|
||||
/// `mob_players` is the guest each mob sees as its player (game/lan_mobs.rs).
|
||||
fn lan_entities(
|
||||
&self,
|
||||
&mut self,
|
||||
host_entity: Option<i32>,
|
||||
mob_players: &horizon_client::fast_hash::FxHashMap<u32, u32>,
|
||||
hub: &mut Hub,
|
||||
out: &mut States,
|
||||
drain_minecart_steps: bool,
|
||||
) {
|
||||
out.clear();
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
@@ -1843,7 +1866,7 @@ impl Game<'_> {
|
||||
e.meta
|
||||
.push((8, serializer::INT, Value::Int(o.value as i32)));
|
||||
}
|
||||
self.lan_object_states(host_entity, hub, out);
|
||||
self.lan_object_states(host_entity, hub, out, drain_minecart_steps);
|
||||
}
|
||||
|
||||
/// Apply gameplay events produced while the hub handles guest packets.
|
||||
@@ -2191,11 +2214,22 @@ impl Game<'_> {
|
||||
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
|
||||
.collect();
|
||||
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
|
||||
self.lan_entities(host_entity, &host.mob_players, &mut host.hub, &mut states);
|
||||
self.lan_entities(
|
||||
host_entity,
|
||||
&host.mob_players,
|
||||
&mut host.hub,
|
||||
&mut states,
|
||||
true,
|
||||
);
|
||||
self.lan_hook_states(host_entity, &host.play, &host.hub, &mut states);
|
||||
self.lan_entity_uuids(&mut states);
|
||||
} else {
|
||||
states.clear();
|
||||
// Nobody can receive past movement. A returning viewer pairs
|
||||
// from the cart's current position/rotation, as AddEntity does.
|
||||
for cart in &mut self.carts {
|
||||
cart.lerp_steps.clear();
|
||||
}
|
||||
}
|
||||
// ServerEntity.sendChanges clears needsSync once it has looked.
|
||||
for m in &mut self.mobs {
|
||||
@@ -2430,7 +2464,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
// A dedicated server has no host player.
|
||||
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs);
|
||||
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs, false);
|
||||
self.lan_hook_states(None, &host.play, &host.hub, &mut theirs);
|
||||
self.lan_entity_uuids(&mut theirs);
|
||||
theirs.as_slice()
|
||||
@@ -2566,6 +2600,12 @@ impl Game<'_> {
|
||||
EquipmentSlot::Body => 65,
|
||||
EquipmentSlot::Saddle => 68,
|
||||
};
|
||||
// The mods hear a server player's break (a creative player's gear takes no wear).
|
||||
if !self.creative
|
||||
&& let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
self.native_player_destroy_item(uuid, &name, &stack.item, equipment_slot_name(slot));
|
||||
}
|
||||
let Some(at) = self
|
||||
.player
|
||||
.as_ref()
|
||||
@@ -3021,30 +3061,33 @@ impl Game<'_> {
|
||||
),
|
||||
out,
|
||||
);
|
||||
self.player_elytra_geometry(
|
||||
m,
|
||||
m.extra.chest.as_deref(),
|
||||
camera,
|
||||
alpha as f32,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
if let Some(g) = host.hub.guest(*conn) {
|
||||
self.player_ears_geometry(m, &g.name, camera, alpha as f32, packed, out);
|
||||
// AvatarRenderer.shouldRenderLayers: a spectator draws no layers.
|
||||
if horizon_client::mobs::render::layers_shown(m) {
|
||||
self.player_elytra_geometry(
|
||||
m,
|
||||
m.extra.chest.as_deref(),
|
||||
camera,
|
||||
alpha as f32,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
if let Some(g) = host.hub.guest(*conn) {
|
||||
self.player_ears_geometry(m, &g.name, camera, alpha as f32, packed, out);
|
||||
}
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
if let Some(g) = host.hub.guest(*conn) {
|
||||
self.stuck_in_body_geometry(m, g.entity_id, camera, alpha as f32, packed, out);
|
||||
}
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
self.player_pumpkin_geometry(
|
||||
m,
|
||||
host.hub.guest(*conn).and_then(|g| g.armor[0].as_ref()),
|
||||
camera,
|
||||
alpha,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
}
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
if let Some(g) = host.hub.guest(*conn) {
|
||||
self.stuck_in_body_geometry(m, g.entity_id, camera, alpha as f32, packed, out);
|
||||
}
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
self.player_pumpkin_geometry(
|
||||
m,
|
||||
host.hub.guest(*conn).and_then(|g| g.armor[0].as_ref()),
|
||||
camera,
|
||||
alpha,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
// LivingEntity.isCurrentlyGlowing: the guest's Glowing effect.
|
||||
let guest = host.hub.guest(*conn);
|
||||
if m.glowing || guest.is_some_and(|g| g.effects.get("glowing").is_some()) {
|
||||
@@ -3097,9 +3140,25 @@ pub(super) fn living_flags(using: Option<(usize, u32)>) -> i8 {
|
||||
using.map_or(0, |(slot, _)| if slot == OFFHAND_SLOT { 3 } else { 1 })
|
||||
}
|
||||
|
||||
/// The name a mod hears for an equipment slot (the armor stand hook's names, plus
|
||||
/// the two slots a mount's gear uses).
|
||||
fn equipment_slot_name(slot: horizon_world::player_inventory::EquipmentSlot) -> &'static str {
|
||||
use horizon_world::player_inventory::EquipmentSlot;
|
||||
match slot {
|
||||
EquipmentSlot::MainHand => "mainhand",
|
||||
EquipmentSlot::OffHand => "offhand",
|
||||
EquipmentSlot::Head => "head",
|
||||
EquipmentSlot::Chest => "chest",
|
||||
EquipmentSlot::Legs => "legs",
|
||||
EquipmentSlot::Feet => "feet",
|
||||
EquipmentSlot::Body => "body",
|
||||
EquipmentSlot::Saddle => "saddle",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{OFFHAND_SLOT, living_flags};
|
||||
use super::{OFFHAND_SLOT, equipment_slot_name, living_flags};
|
||||
|
||||
/// `LivingEntity.startUsingItem` sets flag 1 for the use and flag 2 for
|
||||
/// the off hand, so an off-hand use is 3, not 2. A reader takes bit 0 as
|
||||
@@ -3110,4 +3169,19 @@ mod tests {
|
||||
assert_eq!(living_flags(Some((0, 12))), 1);
|
||||
assert_eq!(living_flags(Some((OFFHAND_SLOT, 12))), 3);
|
||||
}
|
||||
|
||||
/// The names a mod matches on for a broken item's slot (a contract).
|
||||
#[test]
|
||||
fn a_broken_items_slot_is_named_for_mods() {
|
||||
use horizon_world::player_inventory::EquipmentSlot::*;
|
||||
let named: Vec<_> = [MainHand, OffHand, Head, Chest, Legs, Feet, Body, Saddle]
|
||||
.map(equipment_slot_name)
|
||||
.to_vec();
|
||||
assert_eq!(
|
||||
named,
|
||||
[
|
||||
"mainhand", "offhand", "head", "chest", "legs", "feet", "body", "saddle"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -308,6 +308,16 @@ impl Game<'_> {
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
f: impl FnOnce(&mut Self) -> R,
|
||||
) -> Option<R> {
|
||||
self.as_guest_with_host(host, conn, |g, _| f(g))
|
||||
}
|
||||
|
||||
/// `as_guest`, passing the active `&LanHost` to `f`.
|
||||
pub(super) fn as_guest_with_host<R>(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
f: impl FnOnce(&mut Self, &LanHost) -> R,
|
||||
) -> Option<R> {
|
||||
let swap = self.enter_guest(host, conn)?;
|
||||
super::dedicated::crash_fixture("guest", || {
|
||||
@@ -315,7 +325,7 @@ impl Game<'_> {
|
||||
});
|
||||
let dimension =
|
||||
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(self.dim.id());
|
||||
let result = horizon_client::block_contact::in_level(dimension, || f(self));
|
||||
let result = horizon_client::block_contact::in_level(dimension, || f(self, host));
|
||||
self.leave_guest(host, swap);
|
||||
Some(result)
|
||||
}
|
||||
@@ -2374,8 +2384,10 @@ impl Game<'_> {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::GameRuleValues(play::GameRuleValues {
|
||||
entries: horizon_client::gamerules::RULES
|
||||
.iter()
|
||||
entries: self
|
||||
.ui
|
||||
.rules
|
||||
.available_rules()
|
||||
.map(|r| play::GameRuleEntry {
|
||||
name: format!("minecraft:{}", r.name),
|
||||
value: self.ui.rules.get(r.name).unwrap_or(r.default).to_string(),
|
||||
@@ -2993,13 +3005,13 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.position(|p| p.id != 0 && p.id as i32 == id && super::deflect::attackable(p))
|
||||
{
|
||||
self.as_guest(host, conn, |g| {
|
||||
self.as_guest_with_host(host, conn, |g, host| {
|
||||
if g.within_interaction_range(i) {
|
||||
if matches!(
|
||||
g.creatures.projectiles[i].kind,
|
||||
horizon_client::projectiles::ProjectileKind::ShulkerBullet { .. }
|
||||
) {
|
||||
g.hit_shulker_bullet(i);
|
||||
g.hit_shulker_bullet(Some(host), i);
|
||||
} else {
|
||||
g.deflect(i);
|
||||
}
|
||||
@@ -3300,7 +3312,7 @@ impl Game<'_> {
|
||||
};
|
||||
let skip = usize::from(text.starts_with('/'));
|
||||
let command = &text[skip..];
|
||||
let d = vanilla::game_dispatcher();
|
||||
let d = vanilla::game_dispatcher_with_features(&self.level.enabled_features);
|
||||
// The completions come from the server's players and lists, not from the host's own registries: a
|
||||
// dedicated server has no local player (they would list "Player"), and a LAN world's guests are not in
|
||||
// the host's list.
|
||||
|
||||
@@ -21,7 +21,8 @@ impl Game<'_> {
|
||||
/// CommandDispatcher.parse with the guest's permission level.
|
||||
fn parse_as(&mut self, permission: u8, command: &str) -> ParseResults {
|
||||
let level = std::mem::replace(&mut self.ui.registries.permission, permission);
|
||||
let parse = vanilla::game_dispatcher().parse(command, &self.ui.registries);
|
||||
let parse = vanilla::game_dispatcher_with_features(&self.level.enabled_features)
|
||||
.parse(command, &self.ui.registries);
|
||||
self.ui.registries.permission = level;
|
||||
parse
|
||||
}
|
||||
@@ -33,8 +34,9 @@ impl Game<'_> {
|
||||
permission: u8,
|
||||
command: &str,
|
||||
) -> Vec<(String, String)> {
|
||||
self.parse_as(permission, command)
|
||||
.signable_arguments(vanilla::game_dispatcher())
|
||||
self.parse_as(permission, command).signable_arguments(
|
||||
vanilla::game_dispatcher_with_features(&self.level.enabled_features),
|
||||
)
|
||||
}
|
||||
|
||||
/// ServerGamePacketListenerImpl.detectRateSpam: a chat message or command adds 20 to the
|
||||
|
||||
@@ -40,6 +40,7 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
|
||||
head_yaw: 0.0,
|
||||
on_ground,
|
||||
velocity: [0.0; 3],
|
||||
minecart_steps: None,
|
||||
data: 0,
|
||||
meta: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
@@ -52,6 +53,7 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
|
||||
leash: None,
|
||||
spectator: false,
|
||||
watching: false,
|
||||
hidden: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,10 +157,11 @@ impl Game<'_> {
|
||||
/// Boats, minecarts, armor stands, item frames, paintings and the
|
||||
/// entities kept unsimulated, as guests track them.
|
||||
pub(super) fn lan_object_states(
|
||||
&self,
|
||||
&mut self,
|
||||
host_entity: Option<i32>,
|
||||
hub: &mut Hub,
|
||||
out: &mut States,
|
||||
drain_minecart_steps: bool,
|
||||
) {
|
||||
let guests: Vec<(u128, i32)> = hub
|
||||
.guests
|
||||
@@ -193,10 +196,21 @@ impl Game<'_> {
|
||||
e.defaults.push((13, serializer::INT, Value::Int(0)));
|
||||
out.push(e);
|
||||
}
|
||||
for c in &self.carts {
|
||||
let improvements = self.minecart_improvements();
|
||||
for c in &mut self.carts {
|
||||
let p = c.position;
|
||||
let mut e = state(c.id, c.kind.id(), [p.x, p.y, p.z], c.yaw, c.on_ground());
|
||||
e.pitch = c.pitch;
|
||||
e.velocity = [c.velocity.x, c.velocity.y, c.velocity.z];
|
||||
if improvements {
|
||||
// Joining another dimension only takes a spawn snapshot; its
|
||||
// regular tracking tick still owns the unsent steps.
|
||||
e.minecart_steps = Some(if drain_minecart_steps {
|
||||
std::mem::take(&mut c.lerp_steps)
|
||||
} else {
|
||||
Vec::new()
|
||||
});
|
||||
}
|
||||
e.meta = vehicle_meta(c.hurt_time, c.hurt_dir, c.damage);
|
||||
match c.kind {
|
||||
// MinecartFurnace.DATA_ID_FUEL.
|
||||
|
||||
@@ -154,6 +154,44 @@ pub(super) fn send_snowflake_particle(
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerLevel.sendParticles`: sends a `ClientboundLevelParticlesPacket` to each player within 32 blocks.
|
||||
pub(super) fn send_hub_level_particles(
|
||||
hub: &Hub,
|
||||
options: &ParticleOptions,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
count: u32,
|
||||
state: Option<BlockStateId>,
|
||||
) {
|
||||
let Some(particle) = Game::hub_particle(hub, options, state) else {
|
||||
return;
|
||||
};
|
||||
let horizon_only = mod_particle_only_for_horizon(&particle);
|
||||
let packet = C::LevelParticles(play::LevelParticles {
|
||||
particle,
|
||||
override_limiter: false,
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: spread.map(|d| d as f32),
|
||||
max_speed: [speed as f32; 3],
|
||||
count: count as i32,
|
||||
randomization: 0,
|
||||
});
|
||||
for g in hub.guests.iter().filter(|g| hub.here(g)) {
|
||||
if horizon_only && g.brand != "horizon" {
|
||||
continue;
|
||||
}
|
||||
// ServerLevel compares the player's block centre, strictly within 32.
|
||||
let distance: f64 = (0..3)
|
||||
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
.sum();
|
||||
if distance < 32.0 * 32.0 {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `SonicBoom.tick` uses `ServerLevel.sendParticles` for each ray point.
|
||||
/// Keep the local event's rendering; connected players receive one at rest.
|
||||
@@ -184,9 +222,11 @@ impl Game<'_> {
|
||||
|
||||
/// `ServerLevel.sendParticles` of a mob's particles: a packet to each
|
||||
/// player within 32 blocks. The window draws its own copy. `state` is the
|
||||
/// block of a block particle.
|
||||
/// block of a block particle. `host` is the hub of a guest's turn, which
|
||||
/// is out of `self.lan` meanwhile.
|
||||
pub(super) fn lan_level_particles(
|
||||
&self,
|
||||
host: Option<&LanHost>,
|
||||
options: &ParticleOptions,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
@@ -194,33 +234,10 @@ impl Game<'_> {
|
||||
count: u32,
|
||||
state: Option<BlockStateId>,
|
||||
) {
|
||||
let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, state))
|
||||
else {
|
||||
let Some(hub) = host.or(self.lan.as_ref()).map(|h| &h.hub) else {
|
||||
return;
|
||||
};
|
||||
let horizon_only = mod_particle_only_for_horizon(&particle);
|
||||
let packet = C::LevelParticles(play::LevelParticles {
|
||||
particle,
|
||||
override_limiter: false,
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: spread.map(|d| d as f32),
|
||||
max_speed: [speed as f32; 3],
|
||||
count: count as i32,
|
||||
randomization: 0,
|
||||
});
|
||||
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
|
||||
if horizon_only && g.brand != "horizon" {
|
||||
continue;
|
||||
}
|
||||
// ServerLevel compares the player's block centre, strictly within 32.
|
||||
let distance: f64 = (0..3)
|
||||
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
.sum();
|
||||
if distance < 32.0 * 32.0 {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
send_hub_level_particles(hub, options, at, spread, speed, count, state);
|
||||
}
|
||||
|
||||
/// `LivingEntity.checkFallDamage`'s `ServerLevel.sendParticles` of the
|
||||
@@ -727,8 +744,17 @@ impl Game<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::spread;
|
||||
use super::*;
|
||||
use horizon_client::lan::{
|
||||
Joined, Lan, NetConfig, Out, PlayerSender,
|
||||
chunks::ChunkEncoder,
|
||||
data,
|
||||
guest::Guest,
|
||||
hub::{Hub, HubConfig},
|
||||
};
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use horizon_protocol::packets::play;
|
||||
|
||||
/// The Nether's evaporation smoke (ALTERNATIVE, dist 1, speed 0) lands
|
||||
/// inside the block from its corner and stands still; DEFAULT spreads
|
||||
@@ -750,4 +776,115 @@ mod tests {
|
||||
}
|
||||
assert!(below, "DEFAULT is a Gaussian around the position");
|
||||
}
|
||||
|
||||
/// `ShulkerBullet.hurtServer`: fifteen crit particles (spread 0.2, speed 0.0)
|
||||
/// are sent to tracking players within 32 blocks.
|
||||
#[test]
|
||||
fn hitting_shulker_bullet_sends_15_crit_particles_to_players() {
|
||||
let config = NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::lan(0, "ShulkerBulletTest".into(), 2)
|
||||
};
|
||||
let mut sync = data::SyncData::empty();
|
||||
sync.builtin.registries.insert(
|
||||
"minecraft:particle_type".to_string(),
|
||||
horizon_protocol::registry::Ids::from_names(vec!["minecraft:crit".to_string()]),
|
||||
);
|
||||
let sync = std::sync::Arc::new(sync);
|
||||
let lan = Lan::start_with(sync.clone(), config).unwrap();
|
||||
let encoder = ChunkEncoder::new(
|
||||
[] as [(std::sync::Arc<str>, bool); 0],
|
||||
&horizon_protocol::registry::Builtin::default(),
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let hub_config = HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: false,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 10,
|
||||
simulation_distance: 10,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 20,
|
||||
dedicated: false,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
};
|
||||
let mut hub = Hub::new(lan, sync, encoder, hub_config);
|
||||
|
||||
let (tx_near, rx_near) = std::sync::mpsc::channel();
|
||||
let mut near = Guest::new(
|
||||
Joined {
|
||||
conn: 1,
|
||||
uuid: 1,
|
||||
name: "NearPlayer".into(),
|
||||
properties: Vec::new(),
|
||||
info: horizon_protocol::packets::configuration::ClientInformation::new(8),
|
||||
brand: "vanilla".into(),
|
||||
sender: PlayerSender::new(tx_near, std::sync::Arc::default()),
|
||||
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
},
|
||||
1,
|
||||
[5.0, 64.0, 5.0],
|
||||
8,
|
||||
);
|
||||
near.placed = true;
|
||||
near.dimension = "minecraft:overworld".into();
|
||||
hub.guests.push(near);
|
||||
|
||||
let (tx_far, rx_far) = std::sync::mpsc::channel();
|
||||
let mut far = Guest::new(
|
||||
Joined {
|
||||
conn: 2,
|
||||
uuid: 2,
|
||||
name: "FarPlayer".into(),
|
||||
properties: Vec::new(),
|
||||
info: horizon_protocol::packets::configuration::ClientInformation::new(8),
|
||||
brand: "vanilla".into(),
|
||||
sender: PlayerSender::new(tx_far, std::sync::Arc::default()),
|
||||
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25566".parse().unwrap(),
|
||||
},
|
||||
2,
|
||||
[100.0, 64.0, 100.0],
|
||||
8,
|
||||
);
|
||||
far.placed = true;
|
||||
far.dimension = "minecraft:overworld".into();
|
||||
hub.guests.push(far);
|
||||
|
||||
let at = [0.0, 64.0, 0.0];
|
||||
let options = ParticleOptions::simple(ParticleType::from_name("crit").unwrap());
|
||||
send_hub_level_particles(&hub, &options, at, [0.2, 0.2, 0.2], 0.0, 15, None);
|
||||
|
||||
assert!(
|
||||
rx_far.try_recv().is_err(),
|
||||
"far guest should not receive particles"
|
||||
);
|
||||
|
||||
let out = rx_near
|
||||
.try_recv()
|
||||
.expect("near guest should receive LevelParticles packet");
|
||||
let body = match out {
|
||||
Out::Packet(body) => body,
|
||||
_ => panic!("expected Out::Packet"),
|
||||
};
|
||||
let mut r = horizon_protocol::buf::Reader::new(&body);
|
||||
let id = r.varint().unwrap();
|
||||
let packet = play::Clientbound::decode(id, r.rest()).unwrap();
|
||||
match packet {
|
||||
play::Clientbound::LevelParticles(p) => {
|
||||
assert_eq!(p.count, 15);
|
||||
assert_eq!(p.dist, [0.2, 0.2, 0.2]);
|
||||
assert_eq!(p.max_speed, [0.0; 3]);
|
||||
assert_eq!(p.pos, at);
|
||||
assert_eq!(p.particle.id, 0);
|
||||
}
|
||||
other => panic!("expected LevelParticles packet, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -561,7 +561,17 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut rules = horizon_client::gamerules::GameRules::default();
|
||||
// The response itself lists the server's available rules; the client
|
||||
// may not own this world's level.dat (including on the server thread).
|
||||
let features = if entries.iter().any(|e| {
|
||||
horizon_client::gamerules::rule(&e.name)
|
||||
.is_some_and(|r| r.name == horizon_client::gamerules::MAX_MINECART_SPEED.name)
|
||||
}) {
|
||||
vec![horizon_client::gamerules::MINECART_IMPROVEMENTS.to_owned()]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut rules = horizon_client::gamerules::GameRules::with_features(&features);
|
||||
for entry in entries {
|
||||
rules.set_string(&entry.name, &entry.value);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! `MinecartItem` placement, `VehicleEntity` breaking, the chest, hopper,
|
||||
//! furnace and TNT variants, saving, and `AbstractMinecartRenderer`'s old
|
||||
//! rail-following render with the display block.
|
||||
use super::{Game, Hit, InteractTarget, Keys};
|
||||
use super::{Game, Hit, InteractTarget, Keys, entity::Entity};
|
||||
use horizon_client::entity_rules::EntityRules;
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
collisions::SceneCollisions,
|
||||
@@ -99,7 +100,17 @@ pub(super) struct Minecart {
|
||||
pub velocity: Vec3,
|
||||
pub yaw: f32,
|
||||
pub previous_yaw: f32,
|
||||
pub pitch: f32,
|
||||
pub previous_pitch: f32,
|
||||
/// Minecart's client-only accumulated turn and the passenger's eased turn.
|
||||
pub rotation_offset: f32,
|
||||
pub player_rotation_offset: f32,
|
||||
pub passenger_yaw_delta: f32,
|
||||
/// Experimental track steps, drained by the server entity tracker.
|
||||
pub lerp_steps: Vec<horizon_protocol::packets::play::MinecartStep>,
|
||||
pub flipped: bool,
|
||||
/// `AbstractMinecart.onRails`: transitions into rails need a position snap.
|
||||
on_rails: bool,
|
||||
pub(super) on_ground: bool,
|
||||
horizontal_collision: bool,
|
||||
/// The horizontal speed the cart wanted when the collision stopped it:
|
||||
@@ -270,6 +281,33 @@ pub(super) struct Rail {
|
||||
powered: bool,
|
||||
}
|
||||
impl Minecart {
|
||||
/// `Minecart.tick`: stationary rotation does not turn the passenger.
|
||||
pub(super) fn accumulate_rotation(&mut self) {
|
||||
self.passenger_yaw_delta = 0.0;
|
||||
if self.kind == CartKind::Rideable && self.position.subtract(self.previous).length() > 0.01
|
||||
{
|
||||
self.rotation_offset +=
|
||||
((f64::from(self.yaw) - f64::from(self.previous_yaw)) % 360.0) as f32;
|
||||
self.rotation_offset %= 360.0;
|
||||
}
|
||||
}
|
||||
/// `Minecart.positionRider`: double rotLerp, then round back to float.
|
||||
/// Only yaw follows; the player's pitch and manual look remain their own.
|
||||
pub(super) fn rotate_passenger(&mut self, yaw: &mut f32) {
|
||||
let mut delta =
|
||||
(f64::from(self.rotation_offset) - f64::from(self.player_rotation_offset)) % 360.0;
|
||||
if delta >= 180.0 {
|
||||
delta -= 360.0;
|
||||
}
|
||||
if delta < -180.0 {
|
||||
delta += 360.0;
|
||||
}
|
||||
let next = (f64::from(self.player_rotation_offset) + 0.5 * delta) as f32;
|
||||
let previous = *yaw;
|
||||
*yaw -= next - self.player_rotation_offset;
|
||||
self.passenger_yaw_delta = *yaw - previous;
|
||||
self.player_rotation_offset = next;
|
||||
}
|
||||
/// `Entity.onGround`.
|
||||
pub(super) fn on_ground(&self) -> bool {
|
||||
self.on_ground
|
||||
@@ -283,7 +321,14 @@ impl Minecart {
|
||||
velocity: Vec3::ZERO,
|
||||
yaw: 0.0,
|
||||
previous_yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
previous_pitch: 0.0,
|
||||
rotation_offset: 0.0,
|
||||
player_rotation_offset: 0.0,
|
||||
passenger_yaw_delta: 0.0,
|
||||
lerp_steps: Vec::new(),
|
||||
flipped: false,
|
||||
on_rails: false,
|
||||
on_ground: false,
|
||||
horizontal_collision: false,
|
||||
collision_speed: 0.0,
|
||||
@@ -379,8 +424,13 @@ impl Game<'_> {
|
||||
/// `getCurrentBlockPosOrRailBelow`.
|
||||
fn rail_block(&self, p: Vec3) -> [i32; 3] {
|
||||
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
if self.rail(x, y - 1, z).is_some() {
|
||||
[x, y - 1, z]
|
||||
let below = if self.minecart_improvements() {
|
||||
(p.y - 0.1 - f64::from(1.0e-5_f32)).floor() as i32
|
||||
} else {
|
||||
y - 1
|
||||
};
|
||||
if self.rail(x, below, z).is_some() {
|
||||
[x, below, z]
|
||||
} else {
|
||||
[x, y, z]
|
||||
}
|
||||
@@ -539,9 +589,11 @@ impl Game<'_> {
|
||||
c.fall_distance = 0.0;
|
||||
}
|
||||
}
|
||||
// Entity.move ends with applyEffectsFromBlocks: a bubble column lifts
|
||||
// or drags the cart (tick_cart runs it a second time, like vanilla).
|
||||
self.cart_block_effects(c);
|
||||
// OldMinecartBehavior applies block effects here and at tick end.
|
||||
// NewMinecartBehavior applies them after recording each track step.
|
||||
if !self.minecart_improvements() {
|
||||
self.cart_block_effects(c);
|
||||
}
|
||||
}
|
||||
fn max_speed(&self, c: &Minecart) -> f64 {
|
||||
let base = if c.in_water { 0.2 } else { 0.4 };
|
||||
@@ -712,23 +764,26 @@ impl Game<'_> {
|
||||
}
|
||||
/// `AbstractMinecart`: `minecart_improvements` picks the smoother
|
||||
/// physics.
|
||||
fn minecart_improvements(&self) -> bool {
|
||||
self.level
|
||||
.enabled_features
|
||||
pub(super) fn minecart_improvements(&self) -> bool {
|
||||
self.remote
|
||||
.as_ref()
|
||||
.map_or(&self.level.enabled_features, |remote| &remote.features)
|
||||
.iter()
|
||||
.any(|f| f == "minecraft:minecart_improvements")
|
||||
}
|
||||
/// `NewMinecartBehavior.getMaxSpeed`: the game rule over water's half,
|
||||
/// every cart alike (the old behavior's furnace bonus is its own).
|
||||
fn max_speed_new(&self, c: &Minecart) -> f64 {
|
||||
if c.in_water { 0.2 } else { 0.4 }
|
||||
f64::from(self.ui.rules.int("max_minecart_speed")) * if c.in_water { 0.5 } else { 1.0 }
|
||||
/ 20.0
|
||||
}
|
||||
/// `AbstractMinecart.getRedstoneDirection` (over the old port's
|
||||
/// conductor proxy): which way a powered rail pushes a resting cart.
|
||||
fn redstone_direction(&self, pos: [i32; 3], shape: Shape) -> Vec3 {
|
||||
let (dx, dz) = match shape {
|
||||
Shape::NorthSouth | Shape::AscendingNorth | Shape::AscendingSouth => (0, 1),
|
||||
_ => (1, 0),
|
||||
Shape::NorthSouth => (0, 1),
|
||||
Shape::EastWest => (1, 0),
|
||||
_ => return Vec3::ZERO,
|
||||
};
|
||||
// The push points away from the signal's side (getRedstoneDirection).
|
||||
if self.conductor(pos[0] - dx, pos[1], pos[2] - dz) {
|
||||
@@ -750,12 +805,110 @@ impl Game<'_> {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// `NewMinecartBehavior.setRotation`: preserve the cart's facing when
|
||||
/// motion reverses, including the pitch sign on a slope.
|
||||
fn set_cart_rotation(c: &mut Minecart, mut yaw: f32, mut pitch: f32) {
|
||||
if (175.0..=185.0).contains(&f64::from((yaw - c.yaw).abs())) {
|
||||
c.flipped = !c.flipped;
|
||||
yaw -= 180.0;
|
||||
pitch = -pitch;
|
||||
}
|
||||
c.yaw = yaw % 360.0;
|
||||
c.pitch = pitch.clamp(-45.0, 45.0) % 360.0;
|
||||
}
|
||||
/// `NewMinecartBehavior.MinecartStep`: wire rotations use ROTATION_BYTE.
|
||||
fn record_cart_step(c: &mut Minecart, weight: f32) {
|
||||
use horizon_protocol::buf::pack_degrees;
|
||||
c.lerp_steps
|
||||
.push(horizon_protocol::packets::play::MinecartStep {
|
||||
position: c.position.components(),
|
||||
movement: c.velocity.components(),
|
||||
yaw: pack_degrees(c.yaw),
|
||||
pitch: pack_degrees(c.pitch),
|
||||
weight,
|
||||
});
|
||||
}
|
||||
/// `NewMinecartBehavior.moveAlongTrack`: each step has its own rotation
|
||||
/// and interpolation weight, including the final off-rail move.
|
||||
fn finish_cart_step(&self, c: &mut Minecart, old: Vec3, initial: Vec3) {
|
||||
let delta = old.vector_to(c.position);
|
||||
let length = delta.length();
|
||||
if length > f64::from(1.0e-5_f32) {
|
||||
if delta.horizontal_distance_squared() > f64::from(1.0e-5_f32) {
|
||||
let yaw = 180.0 - java_degrees(delta.z, delta.x) as f32
|
||||
+ if c.flipped { 180.0 } else { 0.0 };
|
||||
let pitch = if c.on_ground && !c.on_rails {
|
||||
0.0
|
||||
} else {
|
||||
(90.0 - java_degrees(delta.horizontal_distance(), delta.y) as f32)
|
||||
* if c.flipped { -1.0 } else { 1.0 }
|
||||
};
|
||||
Self::set_cart_rotation(c, yaw, pitch);
|
||||
} else if !c.on_rails {
|
||||
c.pitch = if c.on_ground {
|
||||
0.0
|
||||
} else {
|
||||
c.pitch + 0.2 * ((-c.pitch + 180.0).rem_euclid(360.0) - 180.0)
|
||||
};
|
||||
}
|
||||
Self::record_cart_step(c, length.min(self.max_speed_new(c)) as f32);
|
||||
} else if initial.horizontal_distance_squared() > 0.0 {
|
||||
Self::record_cart_step(c, 1.0);
|
||||
}
|
||||
}
|
||||
/// `NewMinecartBehavior.adjustToRails`: snap only on the first tick or
|
||||
/// when entering rails; curves project onto the segment between exits.
|
||||
fn adjust_cart_to_rails(c: &mut Minecart, pos: [i32; 3], shape: Shape) {
|
||||
let (e0, e1) = shape.exits();
|
||||
let exit =
|
||||
|e: [i32; 3]| Vec3::new(f64::from(e[0]), f64::from(e[1]), f64::from(e[2])).scale(0.5);
|
||||
let old = c.position;
|
||||
let (exit0, exit1) = (exit(e0), exit(e1));
|
||||
let center = Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
);
|
||||
let mut out = Vec3::new(exit0.x, 0.0, exit0.z);
|
||||
let mut into = Vec3::new(exit1.x, 0.0, exit1.z);
|
||||
if c.velocity.length() > f64::from(1.0e-5_f32) && c.velocity.dot(out) < c.velocity.dot(into)
|
||||
|| Self::is_descending(into, shape)
|
||||
{
|
||||
std::mem::swap(&mut out, &mut into);
|
||||
}
|
||||
let mut yaw = 180.0 - java_degrees(out.z, out.x) as f32;
|
||||
if exit0.x != exit1.x && exit0.z != exit1.z {
|
||||
let along = exit1.subtract(exit0);
|
||||
let from = c.position.subtract(center).subtract(exit0);
|
||||
let projected = along.scale(along.dot(from) / along.dot(along));
|
||||
c.position = center.add_vector(exit0).add_vector(projected);
|
||||
yaw = 180.0 - java_degrees(projected.z, projected.x) as f32;
|
||||
} else {
|
||||
if exit0.z != exit1.z {
|
||||
c.position.x = center.x;
|
||||
}
|
||||
if exit0.x != exit1.x {
|
||||
c.position.z = center.z;
|
||||
}
|
||||
c.position.y = center.y;
|
||||
}
|
||||
let pitch = if shape.is_slope() {
|
||||
c.position.y += center.add_vector(into).distance(c.position) + 0.1;
|
||||
if c.flipped { 45.0 } else { -45.0 }
|
||||
} else {
|
||||
c.position.y += 0.1;
|
||||
0.0
|
||||
};
|
||||
Self::set_cart_rotation(c, yaw + if c.flipped { 180.0 } else { 0.0 }, pitch);
|
||||
let distance = old.distance(c.position);
|
||||
if distance > 0.0 {
|
||||
Self::record_cart_step(c, if c.first_tick { 0.0 } else { distance as f32 });
|
||||
}
|
||||
}
|
||||
/// `NewMinecartBehavior.moveAlongTrack`: the iteration keeps moving
|
||||
/// while distance is left, over as many rails as it takes, the speed
|
||||
/// recomputed per rail (slope, halt, boost).
|
||||
fn move_along_track_new(&mut self, c: &mut Minecart, _rail: &Rail, intent: [f64; 2]) {
|
||||
c.fall_distance = 0.0;
|
||||
let ridden = self.cart_is_vehicle(c.id);
|
||||
fn move_along_track_new(&mut self, c: &mut Minecart, intent: [f64; 2]) {
|
||||
// `TrackIteration`: the slope, halt and boost speeds each apply once
|
||||
// per tick, and the iteration keeps moving while distance is left.
|
||||
let mut first = true;
|
||||
@@ -763,27 +916,38 @@ impl Game<'_> {
|
||||
let mut slope_done = false;
|
||||
let mut halted = false;
|
||||
let mut boosted = false;
|
||||
for _ in 0..16 {
|
||||
if !first && movement_left <= 1.0e-5 {
|
||||
break;
|
||||
}
|
||||
let mut step_start = c.previous;
|
||||
while first || movement_left > f64::from(1.0e-5_f32) {
|
||||
let initial = c.velocity;
|
||||
let pos = self.rail_block(c.position);
|
||||
let Some(rail) = self.rail(pos[0], pos[1], pos[2]) else {
|
||||
c.on_rails = false;
|
||||
self.come_off_track(c);
|
||||
self.finish_cart_step(c, step_start, initial);
|
||||
if first || c.position.distance(step_start) > f64::from(1.0e-5_f32) {
|
||||
self.cart_block_effects(c);
|
||||
self.cart_block_effects(c);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let initial_h = c.velocity.x.hypot(c.velocity.z);
|
||||
let mut v = c.velocity;
|
||||
if !c.on_rails {
|
||||
c.on_rails = true;
|
||||
Self::adjust_cart_to_rails(c, pos, rail.shape);
|
||||
}
|
||||
c.fall_distance = 0.0;
|
||||
step_start = c.position;
|
||||
if &*rail.name == "minecraft:activator_rail" {
|
||||
self.activate_cart(c, rail.powered);
|
||||
}
|
||||
let ridden = self.cart_is_vehicle(c.id);
|
||||
let initial_h = c.velocity.horizontal_distance();
|
||||
let mut v = Vec3::new(c.velocity.x, 0.0, c.velocity.z);
|
||||
// calculateSlopeSpeed (once per tick): the slide grows with the
|
||||
// speed, so a descending cart keeps its pace.
|
||||
if !slope_done {
|
||||
let base: f64 = if c.in_water {
|
||||
0.0078125 * 0.2
|
||||
} else {
|
||||
0.0078125
|
||||
};
|
||||
let slide = base.max(initial_h * 0.02);
|
||||
let before = v.x.hypot(v.z);
|
||||
let slide =
|
||||
0.0078125_f64.max(initial_h * 0.02) * if c.in_water { 0.2 } else { 1.0 };
|
||||
let before = v.horizontal_distance_squared();
|
||||
match rail.shape {
|
||||
Shape::AscendingEast => v.x -= slide,
|
||||
Shape::AscendingWest => v.x += slide,
|
||||
@@ -791,7 +955,10 @@ impl Game<'_> {
|
||||
Shape::AscendingSouth => v.z -= slide,
|
||||
_ => {}
|
||||
}
|
||||
slope_done = v.x.hypot(v.z) != before;
|
||||
slope_done = v.horizontal_distance_squared() != before;
|
||||
if !slope_done {
|
||||
v = Vec3::new(c.velocity.x, 0.0, c.velocity.z);
|
||||
}
|
||||
}
|
||||
// calculatePlayerInputSpeed (first iteration).
|
||||
if first
|
||||
@@ -799,10 +966,14 @@ impl Game<'_> {
|
||||
&& (intent[0] != 0.0 || intent[1] != 0.0)
|
||||
&& v.x * v.x + v.z * v.z < 0.01
|
||||
{
|
||||
let l = (intent[0] * intent[0] + intent[1] * intent[1]).sqrt();
|
||||
v.x += intent[0] / l * 0.001;
|
||||
v.z += intent[1] / l * 0.001;
|
||||
halted = true;
|
||||
let input = Vec3::new(intent[0], 0.0, intent[1])
|
||||
.normalize()
|
||||
.scale(0.001);
|
||||
let pushed = v.add_vector(input);
|
||||
if pushed.horizontal_distance_squared() != v.horizontal_distance_squared() {
|
||||
v = pushed;
|
||||
halted = true;
|
||||
}
|
||||
}
|
||||
// calculateHaltTrackSpeed (until the input halted).
|
||||
if !halted && &*rail.name == "minecraft:powered_rail" && !rail.powered {
|
||||
@@ -816,34 +987,45 @@ impl Game<'_> {
|
||||
}
|
||||
// applyNaturalSlowdown and the speed cap (first iteration).
|
||||
if first {
|
||||
c.velocity = v;
|
||||
v = self.natural_slowdown(c, ridden, 0.975);
|
||||
let len = v.x.hypot(v.z);
|
||||
if len > 0.0 {
|
||||
let speed = len.min(self.max_speed_new(c));
|
||||
v = Vec3::new(v.x / len * speed, v.y, v.z / len * speed);
|
||||
if v.length_squared() > 0.0 {
|
||||
let speed = v.length().min(self.max_speed_new(c));
|
||||
v = v.normalize().scale(speed);
|
||||
}
|
||||
}
|
||||
// calculateBoostTrackSpeed (once per tick).
|
||||
if !boosted && &*rail.name == "minecraft:powered_rail" && rail.powered {
|
||||
let len = v.x.hypot(v.z);
|
||||
let before = v;
|
||||
let len = v.length();
|
||||
if len > 0.01 {
|
||||
v = Vec3::new(v.x / len * (len + 0.06), v.y, v.z / len * (len + 0.06));
|
||||
v = v.normalize().scale(len + 0.06);
|
||||
} else {
|
||||
let dir = self.redstone_direction(pos, rail.shape);
|
||||
if dir != Vec3::ZERO {
|
||||
v = Vec3::new(dir.x * 0.2, v.y, dir.z * 0.2);
|
||||
v = dir.scale(len + 0.2);
|
||||
}
|
||||
}
|
||||
boosted = true;
|
||||
boosted = v.horizontal_distance_squared() != before.horizontal_distance_squared();
|
||||
if !boosted {
|
||||
v = before;
|
||||
}
|
||||
}
|
||||
c.velocity = v;
|
||||
let new_h = v.x.hypot(v.z);
|
||||
let new_h = v.horizontal_distance();
|
||||
movement_left = if first {
|
||||
new_h
|
||||
} else {
|
||||
movement_left + (new_h - initial_h)
|
||||
};
|
||||
movement_left = self.new_step_along_track(c, pos, &rail, movement_left);
|
||||
self.finish_cart_step(c, step_start, initial);
|
||||
// NewMinecartBehavior.moveAlongTrack applies block effects twice,
|
||||
// after recording the step, including a stationary first step.
|
||||
if first || c.position.distance(step_start) > f64::from(1.0e-5_f32) {
|
||||
self.cart_block_effects(c);
|
||||
self.cart_block_effects(c);
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
@@ -856,13 +1038,13 @@ impl Game<'_> {
|
||||
rail: &Rail,
|
||||
mut movement_left: f64,
|
||||
) -> f64 {
|
||||
if movement_left < 1.0e-5 {
|
||||
if movement_left < f64::from(1.0e-5_f32) {
|
||||
return 0.0;
|
||||
}
|
||||
let old = c.position;
|
||||
let (e0, e1) = rail.shape.exits();
|
||||
let movement = Vec3::new(c.velocity.x, 0.0, c.velocity.z);
|
||||
if movement.x.hypot(movement.z) < 1.0e-5 {
|
||||
if movement.length() < f64::from(1.0e-5_f32) {
|
||||
c.velocity = Vec3::ZERO;
|
||||
return 0.0;
|
||||
}
|
||||
@@ -874,9 +1056,9 @@ impl Game<'_> {
|
||||
}
|
||||
let out_len = out_dir.x.hypot(out_dir.z);
|
||||
let mut out_pos = Vec3::new(
|
||||
f64::from(pos[0]) + 0.5 + out_dir.x + out_dir.x / out_len * 1.0e-5,
|
||||
f64::from(pos[0]) + 0.5 + out_dir.x + out_dir.x / out_len * f64::from(1.0e-5_f32),
|
||||
f64::from(pos[1]) + 0.1,
|
||||
f64::from(pos[2]) + 0.5 + out_dir.z + out_dir.z / out_len * 1.0e-5,
|
||||
f64::from(pos[2]) + 0.5 + out_dir.z + out_dir.z / out_len * f64::from(1.0e-5_f32),
|
||||
);
|
||||
if in_hill && !Self::is_descending(c.velocity, rail.shape) {
|
||||
out_pos.y += 1.0;
|
||||
@@ -891,7 +1073,7 @@ impl Game<'_> {
|
||||
old.add_vector(
|
||||
movement
|
||||
.normalize()
|
||||
.scale(movement_left * std::f64::consts::SQRT_2),
|
||||
.scale(movement_left * f64::from(std::f32::consts::SQRT_2)),
|
||||
)
|
||||
} else {
|
||||
old.add_vector(movement.normalize().scale(movement_left))
|
||||
@@ -904,10 +1086,14 @@ impl Game<'_> {
|
||||
}
|
||||
self.move_cart(c, old.vector_to(new_pos));
|
||||
if in_hill {
|
||||
let new_block = self.rail_block(c.position);
|
||||
let new_block = [
|
||||
new_pos.x.floor() as i32,
|
||||
new_pos.y.floor() as i32,
|
||||
new_pos.z.floor() as i32,
|
||||
];
|
||||
if let Some(new_rail) = self.rail(new_block[0], new_block[1], new_block[2]) {
|
||||
// restAtVShape: opposing slopes rest a slow cart.
|
||||
if c.velocity.length() < 0.005_f64.sqrt()
|
||||
if c.velocity.length_squared() < 0.005
|
||||
&& new_rail.shape.is_slope()
|
||||
&& Self::is_descending(c.velocity, rail.shape)
|
||||
&& !Self::is_descending(c.velocity, new_rail.shape)
|
||||
@@ -928,7 +1114,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// The cart did not move but the step would have: it is stuck.
|
||||
if c.position.distance(old) < 1.0e-5 && new_pos.distance(old) > 1.0e-5 {
|
||||
if c.position.distance(old) < f64::from(1.0e-5_f32)
|
||||
&& new_pos.distance(old) > f64::from(1.0e-5_f32)
|
||||
{
|
||||
c.velocity = Vec3::ZERO;
|
||||
return 0.0;
|
||||
}
|
||||
@@ -937,7 +1125,11 @@ impl Game<'_> {
|
||||
}
|
||||
/// `comeOffTrack`.
|
||||
fn come_off_track(&self, c: &mut Minecart) {
|
||||
let max = self.max_speed(c);
|
||||
let max = if self.minecart_improvements() {
|
||||
self.max_speed_new(c)
|
||||
} else {
|
||||
self.max_speed(c)
|
||||
};
|
||||
c.velocity.x = c.velocity.x.clamp(-max, max);
|
||||
c.velocity.z = c.velocity.z.clamp(-max, max);
|
||||
if c.on_ground {
|
||||
@@ -963,13 +1155,11 @@ impl Game<'_> {
|
||||
/// `applyEffectsFromBlocks` for a cart: a bubble column lifts or drags it,
|
||||
/// and `onInsideBubbleColumn` resets the fall distance.
|
||||
fn cart_block_effects(&self, c: &mut Minecart) {
|
||||
if horizon_client::motion::bubble_column_push(
|
||||
if c.push_by_bubble_column(
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
c.bounds(),
|
||||
&mut c.velocity,
|
||||
) {
|
||||
c.fall_distance = 0.0;
|
||||
}
|
||||
@@ -989,62 +1179,71 @@ impl Game<'_> {
|
||||
}
|
||||
/// `AbstractMinecart.tick` for one cart; returns false once it is gone.
|
||||
fn tick_cart(&mut self, c: &mut Minecart, intent: [f64; 2]) -> bool {
|
||||
c.first_tick = false;
|
||||
c.tick_count += 1;
|
||||
c.previous = c.position;
|
||||
c.previous_yaw = c.yaw;
|
||||
c.previous_pitch = c.pitch;
|
||||
c.hurt_time = (c.hurt_time - 1).max(0);
|
||||
c.damage = (c.damage - 1.0).max(0.0);
|
||||
// applyGravity
|
||||
if !c.no_gravity {
|
||||
c.velocity.y -= if c.in_water { 0.005 } else { 0.04 };
|
||||
}
|
||||
let pos = self.rail_block(c.position);
|
||||
if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) {
|
||||
if self.minecart_improvements() {
|
||||
self.move_along_track_new(c, &rail, intent);
|
||||
// NewMinecartBehavior.tick snaps a newly created cart before gravity.
|
||||
if self.minecart_improvements() && c.first_tick {
|
||||
let pos = self.rail_block(c.position);
|
||||
if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) {
|
||||
c.on_rails = true;
|
||||
Self::adjust_cart_to_rails(c, pos, rail.shape);
|
||||
} else {
|
||||
self.move_along_track(c, pos, &rail, intent);
|
||||
c.on_rails = false;
|
||||
}
|
||||
}
|
||||
// applyGravity
|
||||
c.velocity.y -= c.get_gravity();
|
||||
let pos = self.rail_block(c.position);
|
||||
if self.minecart_improvements() {
|
||||
self.move_along_track_new(c, intent);
|
||||
} else if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) {
|
||||
self.move_along_track(c, pos, &rail, intent);
|
||||
if &*rail.name == "minecraft:activator_rail" {
|
||||
self.activate_cart(c, rail.powered);
|
||||
}
|
||||
} else {
|
||||
self.come_off_track(c);
|
||||
}
|
||||
// Yaw from the movement, flipped when it turns around.
|
||||
let (xd, zd) = (c.previous.x - c.position.x, c.previous.z - c.position.z);
|
||||
if xd * xd + zd * zd > 0.001 {
|
||||
c.yaw = zd.atan2(xd).to_degrees() as f32;
|
||||
if c.flipped {
|
||||
c.yaw += 180.0;
|
||||
c.first_tick = false;
|
||||
// The old behavior derives yaw once per tick; the experiment has
|
||||
// already recorded each step's yaw and pitch.
|
||||
if !self.minecart_improvements() {
|
||||
let (xd, zd) = (c.previous.x - c.position.x, c.previous.z - c.position.z);
|
||||
if xd * xd + zd * zd > 0.001 {
|
||||
c.yaw = zd.atan2(xd).to_degrees() as f32;
|
||||
if c.flipped {
|
||||
c.yaw += 180.0;
|
||||
}
|
||||
}
|
||||
let diff = (c.yaw - c.previous_yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
if !(-170.0..170.0).contains(&diff) {
|
||||
c.yaw += 180.0;
|
||||
c.flipped = !c.flipped;
|
||||
}
|
||||
c.yaw %= 360.0;
|
||||
}
|
||||
let diff = (c.yaw - c.previous_yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
if !(-170.0..170.0).contains(&diff) {
|
||||
c.yaw += 180.0;
|
||||
c.flipped = !c.flipped;
|
||||
if !self.minecart_improvements() {
|
||||
self.cart_block_effects(c);
|
||||
}
|
||||
c.yaw %= 360.0;
|
||||
// applyEffectsFromBlocks: a bubble column lifts or drags the cart.
|
||||
self.cart_block_effects(c);
|
||||
// OldMinecartBehavior.tick ends with pushAndPickupEntities, before
|
||||
// AbstractMinecart.tick's updateFluidInteraction.
|
||||
// AbstractMinecart.tick's updateFluidInteraction. The experiment still
|
||||
// uses this fallback until its per-move pickup/push retry is ported.
|
||||
self.push_from_ticked_cart(c);
|
||||
// AbstractMinecart.tick: updateFluidInteraction, a stream pushes the cart.
|
||||
c.in_water = horizon_client::entities::update_entity_fluids(
|
||||
c.bounds(),
|
||||
c.position,
|
||||
c.bounds().max_position().y,
|
||||
&mut c.velocity,
|
||||
&self.world,
|
||||
&self.res.motion().fluids,
|
||||
)
|
||||
.0 > 0.0;
|
||||
c.in_water = c.push_by_fluid(&self.world, &self.res.motion().fluids).0 > 0.0;
|
||||
// updateFluidInteraction: being in water resets the fall distance.
|
||||
if c.in_water {
|
||||
c.fall_distance = 0.0;
|
||||
}
|
||||
if self.minecart_improvements() {
|
||||
// MinecartTNT.tick reads the final delta after AbstractMinecart.tick,
|
||||
// including the pushes and fluid interaction after the track steps.
|
||||
c.collision_speed = c.velocity.horizontal_distance_squared();
|
||||
}
|
||||
// Variants.
|
||||
match c.kind {
|
||||
CartKind::Furnace => {
|
||||
@@ -2023,6 +2222,7 @@ impl Game<'_> {
|
||||
tint: impl Fn(i32) -> Option<u32>,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let mut normals = horizon_client::item_render::FaceNormals::new(m);
|
||||
for model in models {
|
||||
for face in &model.faces {
|
||||
let Some(s) = self
|
||||
@@ -2052,8 +2252,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
let normal = if diffuse {
|
||||
use horizon_client::item_render::{direction_vector, transform_normal};
|
||||
let n = transform_normal(m, direction_vector(face.quad.facing));
|
||||
let n = normals.face(face.quad.facing);
|
||||
let n = n.to_array().map(|v| (v.clamp(-1.0, 1.0) * 127.0) as i8);
|
||||
[n[0], n[1], n[2], 127]
|
||||
} else {
|
||||
@@ -2129,11 +2328,17 @@ impl Game<'_> {
|
||||
let Some(model) = self.cart_models.models.get(&c.kind) else {
|
||||
continue;
|
||||
};
|
||||
let (x, y, z) = (
|
||||
c.previous.x + (c.position.x - c.previous.x) * alpha,
|
||||
c.previous.y + (c.position.y - c.previous.y) * alpha,
|
||||
c.previous.z + (c.position.z - c.previous.z) * alpha,
|
||||
);
|
||||
let lerp = self.remote_minecart_lerp(c.id, a);
|
||||
let new_render = self.minecart_improvements() || lerp.is_some();
|
||||
let (x, y, z) = if let Some((p, _, _)) = lerp {
|
||||
(p[0], p[1], p[2])
|
||||
} else {
|
||||
(
|
||||
c.previous.x + (c.position.x - c.previous.x) * alpha,
|
||||
c.previous.y + (c.position.y - c.previous.y) * alpha,
|
||||
c.previous.z + (c.position.z - c.previous.z) * alpha,
|
||||
)
|
||||
};
|
||||
// LevelExtractor.isEntityVisible: the cart the player rides always draws.
|
||||
if self.riding != Some(c.id) {
|
||||
let d = glam::DVec3::new(c.position.x, c.position.y, c.position.z) - camera;
|
||||
@@ -2162,7 +2367,9 @@ impl Game<'_> {
|
||||
c.previous_yaw + ((c.yaw - c.previous_yaw + 180.0).rem_euclid(360.0) - 180.0) * a;
|
||||
let mut pitch = 0.0f32;
|
||||
let mut offset = glam::DVec3::ZERO;
|
||||
if let Some(on) = self.rail_pos(x, y, z) {
|
||||
if new_render {
|
||||
(yaw, pitch) = lerp.map_or((c.yaw, c.pitch), |(_, yaw, pitch)| (yaw, pitch));
|
||||
} else if let Some(on) = self.rail_pos(x, y, z) {
|
||||
let front = self.rail_pos_offs(x, y, z, 0.3).unwrap_or(on);
|
||||
let back = self.rail_pos_offs(x, y, z, -0.3).unwrap_or(on);
|
||||
offset = glam::DVec3::new(on[0] - x, (front[1] + back[1]) / 2.0 - y, on[2] - z);
|
||||
@@ -2182,10 +2389,19 @@ impl Game<'_> {
|
||||
.wrapping_add(seed.wrapping_mul(98_761));
|
||||
let jitter = |shift: i64| (((seed >> shift & 7) as f32 + 0.5) / 8.0 - 0.5) * 0.004;
|
||||
let p = glam::DVec3::new(x, y, z) + offset - camera;
|
||||
let mut base = glam::Mat4::from_translation(
|
||||
p.as_vec3() + glam::Vec3::new(jitter(16), jitter(20) + 0.375, jitter(24)),
|
||||
) * glam::Mat4::from_rotation_y((180.0 - yaw).to_radians())
|
||||
* glam::Mat4::from_rotation_z((-pitch).to_radians());
|
||||
let jittered = p.as_vec3() + glam::Vec3::new(jitter(16), jitter(20), jitter(24));
|
||||
let mut base = if new_render {
|
||||
// AbstractMinecartRenderer.newRender translates the model
|
||||
// after rotating, so the seat follows the pitched cart.
|
||||
glam::Mat4::from_translation(jittered)
|
||||
* glam::Mat4::from_rotation_y(yaw.to_radians())
|
||||
* glam::Mat4::from_rotation_z((-pitch).to_radians())
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.375, 0.0))
|
||||
} else {
|
||||
glam::Mat4::from_translation(jittered + glam::Vec3::new(0.0, 0.375, 0.0))
|
||||
* glam::Mat4::from_rotation_y((180.0 - yaw).to_radians())
|
||||
* glam::Mat4::from_rotation_z((-pitch).to_radians())
|
||||
};
|
||||
let hurt = c.hurt_time as f32 - a;
|
||||
if hurt > 0.0 {
|
||||
let damage = (c.damage - a).max(0.0);
|
||||
@@ -2402,6 +2618,12 @@ fn tnt_ignites_by_collision(horizontal_collision: bool, speed_sqr: f64) -> bool
|
||||
horizontal_collision && speed_sqr >= 0.009_999_999_776_482_582
|
||||
}
|
||||
|
||||
/// NewMinecartBehavior's `(float) (Math.atan2(y, x) * 180.0 / Math.PI)` before
|
||||
/// the cast: HotSpot's atan2, multiplied then divided in Java's order.
|
||||
fn java_degrees(y: f64, x: f64) -> f64 {
|
||||
horizon_core::jmath::atan2(y, x) * 180.0 / std::f64::consts::PI
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
||||
@@ -224,4 +224,29 @@ impl horizon_client::mod_api::LevelWriteHost for LevelTicks<'_, &mut super::Game
|
||||
self.level
|
||||
.native_set_block_property(self.ticks, pos, key, value, flags)
|
||||
}
|
||||
|
||||
fn play_sound(
|
||||
&mut self,
|
||||
sound: &str,
|
||||
at: [f64; 3],
|
||||
source: usize,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
) -> bool {
|
||||
self.level
|
||||
.native_play_sound(sound, at, source, volume, pitch)
|
||||
}
|
||||
|
||||
fn send_particles(
|
||||
&mut self,
|
||||
options: &horizon_client::mod_api::ParticleOptions,
|
||||
state: Option<BlockStateId>,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
count: i32,
|
||||
) -> bool {
|
||||
self.level
|
||||
.native_send_particles(options, state, at, spread, speed, count)
|
||||
}
|
||||
}
|
||||
@@ -328,6 +328,33 @@ impl Host for super::Game<'_> {
|
||||
fn built_data(&self, mod_id: &str) -> Option<PathBuf> {
|
||||
self.native_built_data(mod_id)
|
||||
}
|
||||
fn on(&self, mod_id: &str) -> bool {
|
||||
self.native_on(mod_id)
|
||||
}
|
||||
fn mod_key(&self, mod_id: &str, name: &str, default: &str) -> String {
|
||||
self.native_mod_key(mod_id, name, default)
|
||||
}
|
||||
fn setting(&self, mod_id: &str, key: &str) -> Option<String> {
|
||||
self.native_setting(mod_id, key)
|
||||
}
|
||||
fn set_setting(&self, mod_id: &str, key: &str, value: &str) {
|
||||
self.native_set_setting(mod_id, key, value)
|
||||
}
|
||||
fn set_block_property(&mut self, pos: [i32; 3], key: &str, value: &str) -> bool {
|
||||
self.native_set_block_property(pos, key, value)
|
||||
}
|
||||
fn set_block_property_with(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
key: &str,
|
||||
value: &str,
|
||||
flags: i32,
|
||||
) -> bool {
|
||||
self.native_set_block_property_with(pos, key, value, flags)
|
||||
}
|
||||
fn config_dir(&self) -> Result<PathBuf, String> {
|
||||
Self::native_config_dir()
|
||||
}
|
||||
fn panel_items(&mut self, mod_id: &str, items: Vec<NativeStack>) {
|
||||
self.native_panel_items(mod_id, items)
|
||||
}
|
||||
@@ -450,6 +477,9 @@ impl Host for super::Game<'_> {
|
||||
) -> u32 {
|
||||
self.native_move_items(dimension, from, from_face, to, to_face, item, max)
|
||||
}
|
||||
fn hide_player(&mut self, uuid: u128, level: Option<u8>) -> bool {
|
||||
self.native_hide_player(uuid, level)
|
||||
}
|
||||
fn item_stack(&self, batch: &mut HudBatch, stack: &ItemStack, at: [f32; 2], size: f32) -> bool {
|
||||
self.native_item_stack(batch, stack, at, size)
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@
|
||||
//! change a cauldron.
|
||||
//!
|
||||
//! API 53 adds entities leaving a level (`native_entity_leave_level`), for
|
||||
//! item entities and experience orbs so far.
|
||||
//! item entities, experience orbs and mobs so far.
|
||||
//!
|
||||
//! API 52 adds `native_block_entity_sync`: a mod block entity's block and update tag
|
||||
//! sent to clients again (`Level.sendBlockUpdated`).
|
||||
@@ -528,7 +528,7 @@ pub(super) use horizon_world::level::flags;
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 153;
|
||||
|
||||
impl GameLevel<'_> {
|
||||
/// A hopper at `pos` is about to pick up `count` of `item`. Set `cancel` to leave the dropped stack in the world. Server-side. @API:native_hopper_pickup_item@.
|
||||
/// A hopper at `pos` is about to pick up `count` of `item`. Set `cancel` to leave the dropped stack in the world. Server-side. @API:hopper_pickup_item@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_hopper_pickup_item(
|
||||
&self,
|
||||
@@ -550,7 +550,7 @@ impl GameLevel<'_> {
|
||||
}
|
||||
/// A broken block is about to drop `experience` points at `pos`. The
|
||||
/// vanilla block drop rule has been checked; change the amount before its
|
||||
/// orbs are awarded. Server-side. @API:native_block_exp@.
|
||||
/// orbs are awarded. Server-side. @API:block_exp@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_block_exp(
|
||||
&self,
|
||||
@@ -577,7 +577,7 @@ impl GameLevel<'_> {
|
||||
|
||||
/// A flowing fluid at `pos` is about to change level. `fluid` is `water`
|
||||
/// or `lava`; the levels are 0 through 8. Set `cancel` to keep the old
|
||||
/// level. Server-side. @API:native_fluid_level_change@.
|
||||
/// level. Server-side. @API:fluid_level_change@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_fluid_level_change(
|
||||
&self,
|
||||
@@ -601,7 +601,7 @@ impl GameLevel<'_> {
|
||||
}
|
||||
|
||||
/// A non-persistent leaf block at distance 7 is about to decay at `pos`.
|
||||
/// Set `cancel` to keep it. Server-side. @API:native_leaves_decay@.
|
||||
/// Set `cancel` to keep it. Server-side. @API:leaves_decay@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_leaves_decay(
|
||||
&self,
|
||||
@@ -614,7 +614,7 @@ impl GameLevel<'_> {
|
||||
|
||||
/// Farmland at `pos` is about to change moisture from `from` to `to`.
|
||||
/// Set `cancel` to keep its old moisture. Server-side.
|
||||
/// @API:native_moisture_change@.
|
||||
/// @API:moisture_change@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_moisture_change(
|
||||
&self,
|
||||
@@ -628,7 +628,7 @@ impl GameLevel<'_> {
|
||||
}
|
||||
|
||||
/// A sponge at `pos` absorbed water and became wet. Server-side.
|
||||
/// @API:native_sponge_absorb@.
|
||||
/// @API:sponge_absorb@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_sponge_absorb(&self, dimension: LevelDimension, pos: BlockPos) {
|
||||
mod_api::dispatch_sponge_absorb(self, mod_api::mods(), dimension, pos);
|
||||
@@ -636,7 +636,7 @@ impl GameLevel<'_> {
|
||||
|
||||
/// A block at `pos` is about to fade from `from` to `to` because of
|
||||
/// melting or coral death. Set `cancel` to keep it. Server-side.
|
||||
/// @API:native_block_fade@.
|
||||
/// @API:block_fade@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_block_fade(
|
||||
&self,
|
||||
@@ -651,7 +651,7 @@ impl GameLevel<'_> {
|
||||
|
||||
/// Precipitation is about to form or add to a block at `pos`. `from` and
|
||||
/// `to` are block state IDs. Set `cancel` to keep the old state. Server-side.
|
||||
/// @API:native_block_form@.
|
||||
/// @API:block_form@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_block_form(
|
||||
&self,
|
||||
@@ -665,7 +665,7 @@ impl GameLevel<'_> {
|
||||
}
|
||||
|
||||
/// The Ender Dragon fight is about to place its first dragon egg at `pos`.
|
||||
/// Set `cancel` to leave the block unchanged. Server-side. @API:native_dragon_egg_form@.
|
||||
/// Set `cancel` to leave the block unchanged. Server-side. @API:dragon_egg_form@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_dragon_egg_form(
|
||||
&self,
|
||||
@@ -718,7 +718,7 @@ impl GameLevel<'_> {
|
||||
/// On an authoritative server, fire is about to be placed at `pos` by
|
||||
/// flint and steel, a fire charge, lava, lightning or fire spread. Set
|
||||
/// `player` is the acting server player when there is one. Set `cancel` to
|
||||
/// leave the position unchanged. @API:native_block_ignite@.
|
||||
/// leave the position unchanged. @API:block_ignite@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_block_ignite(
|
||||
&self,
|
||||
@@ -740,7 +740,7 @@ impl GameLevel<'_> {
|
||||
}
|
||||
|
||||
/// On an authoritative server, fire is about to burn the block at `pos`
|
||||
/// away. Set `cancel` to keep that block. @API:native_block_burn@.
|
||||
/// away. Set `cancel` to keep that block. @API:block_burn@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_block_burn(
|
||||
&self,
|
||||
@@ -753,7 +753,7 @@ impl GameLevel<'_> {
|
||||
|
||||
/// On an authoritative server, a note block at `pos` is about to sound.
|
||||
/// `player` is the acting server player when there is one. Set `cancel` to silence it.
|
||||
/// @API:native_note_play@.
|
||||
/// @API:note_play@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_note_play(
|
||||
&self,
|
||||
@@ -777,7 +777,7 @@ impl GameLevel<'_> {
|
||||
}
|
||||
|
||||
/// On an authoritative server, a bell at `pos` is about to ring. Set
|
||||
/// `cancel` to stop the ring and its raider reveal. @API:native_bell_ring@.
|
||||
/// `cancel` to stop the ring and its raider reveal. @API:bell_ring@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_bell_ring(
|
||||
&self,
|
||||
@@ -791,7 +791,7 @@ impl GameLevel<'_> {
|
||||
|
||||
/// On an authoritative server, a cauldron at `pos` is about to change
|
||||
/// fill level from `from_level` to `to_level` for `reason`. Set `cancel`
|
||||
/// to leave it unchanged. @API:native_cauldron_level_changed@.
|
||||
/// to leave it unchanged. @API:cauldron_level_changed@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_cauldron_level_changed(
|
||||
&self,
|
||||
@@ -3438,7 +3438,7 @@ impl Game<'_> {
|
||||
/// A server player selected a new hotbar slot. The player's state is not
|
||||
/// swapped in, and helpers that look them up through `self.lan` may find
|
||||
/// nothing while a guest packet is being handled. Set `cancel` to keep
|
||||
/// the old slot; the client is sent that slot again. @API:native_player_item_held@.
|
||||
/// the old slot; the client is sent that slot again. @API:player_item_held@.
|
||||
pub(super) fn native_player_item_held(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -3461,7 +3461,7 @@ impl Game<'_> {
|
||||
/// the hotbar. `pos` is zero for an entity; `entity_id` is zero for a
|
||||
/// block. The player's state is swapped in, though helpers that look them
|
||||
/// up through `self.lan` may find nothing while a guest packet is handled.
|
||||
/// Set `cancel` to leave the inventory and client unchanged. @API:native_player_pick_item@.
|
||||
/// Set `cancel` to leave the inventory and client unchanged. @API:player_pick_item@.
|
||||
pub(super) fn native_player_pick_item(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -3558,7 +3558,7 @@ impl Game<'_> {
|
||||
/// after mending has taken its share. Change `amount` to change the points
|
||||
/// added. The player's state is not swapped in, so helpers that look them up
|
||||
/// through `self.lan` may find nothing while a guest packet is handled.
|
||||
/// Server-side. @API:native_player_exp_change@.
|
||||
/// Server-side. @API:player_exp_change@.
|
||||
pub(super) fn native_player_exp_change(&mut self, player: u128, name: &str, amount: &mut i32) {
|
||||
mod_api::dispatch_player_exp_change(self, mod_api::mods(), player, name, amount);
|
||||
}
|
||||
@@ -3566,7 +3566,7 @@ impl Game<'_> {
|
||||
/// item id and `ticks` its duration. The player's state is not swapped in,
|
||||
/// so helpers that look them up through `self.lan` may find nothing while a
|
||||
/// guest packet is handled. Set `cancel` to skip starting the cooldown.
|
||||
/// Server-side. @API:native_player_item_cooldown@.
|
||||
/// Server-side. @API:player_item_cooldown@.
|
||||
pub(super) fn native_player_item_cooldown(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -3588,7 +3588,7 @@ impl Game<'_> {
|
||||
/// A recipe is about to be unlocked for a server player. Set `cancel` to
|
||||
/// keep it locked. The player's state is not swapped in, so helpers that
|
||||
/// look them up through `self.lan` may find nothing while a guest packet
|
||||
/// is handled. Server-side. @API:native_player_recipe_discovered@.
|
||||
/// is handled. Server-side. @API:player_recipe_discovered@.
|
||||
pub(super) fn native_player_recipe_discovered(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -3610,7 +3610,7 @@ impl Game<'_> {
|
||||
/// position. The player's state is not swapped in, so helpers that look
|
||||
/// them up through `self.lan` may find nothing while a guest packet is
|
||||
/// handled. Set `cancel` to keep the old point. Server-side.
|
||||
/// @API:native_player_spawn_point_set@.
|
||||
/// @API:player_spawn_point_set@.
|
||||
pub(super) fn native_player_spawn_point_set(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -3636,7 +3636,7 @@ impl Game<'_> {
|
||||
/// The player's state is not swapped in, so helpers that look them up
|
||||
/// through `self.lan` may find nothing while a guest packet is handled.
|
||||
/// Set `cancel` to keep them in their current place. Server-side.
|
||||
/// @API:native_player_portal@.
|
||||
/// @API:player_portal@.
|
||||
pub(super) fn native_player_portal(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -3688,7 +3688,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// A sculk sensor or shrieker received a vibration for `event` at `pos`.
|
||||
/// Set `cancel` to suppress its response. Server-side.
|
||||
/// @API:native_block_receive_game@.
|
||||
/// @API:block_receive_game@.
|
||||
pub(super) fn native_block_receive_game(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
@@ -3709,7 +3709,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// A dispenser sheared `entity` of kind `kind` at the block in front of
|
||||
/// its own block at `pos`, after the shearing effects. Server-side.
|
||||
/// @API:native_block_shear_entity@.
|
||||
/// @API:block_shear_entity@.
|
||||
pub(super) fn native_block_shear_entity(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
@@ -3722,7 +3722,7 @@ impl Game<'_> {
|
||||
/// A dispenser equipped `item` on the first eligible `target` in front
|
||||
/// of its block at `pos`. `target` is `player`, `mob` or `armor_stand`.
|
||||
/// The equip, item consumption, sound and game event have completed.
|
||||
/// Server-side. @API:native_block_dispense_armor@.
|
||||
/// Server-side. @API:block_dispense_armor@.
|
||||
pub(super) fn native_block_dispense_armor(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
@@ -3763,7 +3763,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// A server player is about to open an inventory supplied by a block at
|
||||
/// `pos`. Set `cancel` to leave the inventory closed. The player's state
|
||||
/// is not swapped in. @API:native_inventory_block_start@.
|
||||
/// is not swapped in. @API:inventory_block_start@.
|
||||
pub(super) fn native_inventory_block_start(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -3776,19 +3776,19 @@ impl Game<'_> {
|
||||
|
||||
/// A sculk catalyst at `pos` has begun blooming after taking experience.
|
||||
/// The catalyst block state has changed. Server-side.
|
||||
/// @API:native_sculk_bloom@.
|
||||
/// @API:sculk_bloom@.
|
||||
pub(super) fn native_sculk_bloom(&mut self, dimension: &str, pos: [i32; 3]) {
|
||||
mod_api::dispatch_sculk_bloom(self, mod_api::mods(), dimension, pos);
|
||||
}
|
||||
|
||||
/// A projectile hit the target block at `pos`, which now has `strength`
|
||||
/// output power. Server-side. @API:native_target_hit@.
|
||||
/// output power. Server-side. @API:target_hit@.
|
||||
pub(super) fn native_target_hit(&mut self, dimension: &str, pos: [i32; 3], strength: i32) {
|
||||
mod_api::dispatch_target_hit(self, mod_api::mods(), dimension, pos, strength);
|
||||
}
|
||||
|
||||
/// A vault at `pos` changed to `state`. Its block state is updated.
|
||||
/// Server-side. @API:native_vault_change_state@.
|
||||
/// Server-side. @API:vault_change_state@.
|
||||
pub(super) fn native_vault_change_state(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
@@ -3799,7 +3799,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// A dispenser at `pos` failed to dispense `item`. Server-side.
|
||||
/// @API:native_block_failed_dispense@.
|
||||
/// @API:block_failed_dispense@.
|
||||
pub(super) fn native_block_failed_dispense(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
@@ -3811,7 +3811,7 @@ impl Game<'_> {
|
||||
|
||||
/// A beacon at `pos` activated or deactivated after its beam was checked.
|
||||
/// `active` says whether it now has a complete pyramid. Server-side.
|
||||
/// @API:native_beacon_state_change@.
|
||||
/// @API:beacon_state_change@.
|
||||
pub(super) fn native_beacon_state_change(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
@@ -3823,7 +3823,7 @@ impl Game<'_> {
|
||||
|
||||
/// An anvil at `pos` took damage, changing its block from `from` to `to`.
|
||||
/// `to` is air when the anvil broke. Server-side.
|
||||
/// @API:native_anvil_damaged@.
|
||||
/// @API:anvil_damaged@.
|
||||
pub(super) fn native_anvil_damaged(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
@@ -3835,7 +3835,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// A server guest's block-breaking animation changed to `progress` at
|
||||
/// `pos` (0 through 9). @API:native_block_break_progress_update@.
|
||||
/// `pos` (0 through 9). @API:block_break_progress_update@.
|
||||
pub(super) fn native_block_break_progress_update(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -3855,7 +3855,7 @@ impl Game<'_> {
|
||||
|
||||
/// A guest's block inventory lock check at `pos`. `locked` is the
|
||||
/// vanilla result and may be changed. The guest's state is not swapped in.
|
||||
/// @API:native_block_lock_check@.
|
||||
/// @API:block_lock_check@.
|
||||
pub(super) fn native_block_lock_check(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -3924,7 +3924,7 @@ impl Game<'_> {
|
||||
/// A server player changed their client option for the dominant hand. `left` is true for
|
||||
/// left-handed. Their state is not swapped in, and helpers looking them up through `self.lan`
|
||||
/// may find nothing during guest packet handling. Server-side.
|
||||
/// @API:native_player_change_main_hand@.
|
||||
/// @API:player_change_main_hand@.
|
||||
pub(super) fn native_player_change_main_hand(&mut self, player: u128, name: &str, left: bool) {
|
||||
mod_api::dispatch_player_change_main_hand(self, mod_api::mods(), player, name, left);
|
||||
}
|
||||
@@ -3944,7 +3944,7 @@ impl Game<'_> {
|
||||
/// A server player put a plant into a flower pot, after the potted block
|
||||
/// state was stored and the item consumed. `plant` is the item id and
|
||||
/// `pos` the pot position. Helpers looking up the guest through `self.lan`
|
||||
/// may find nothing during packet handling. Server-side. @API:player_flower_pot_manipulate@.
|
||||
/// may find nothing during packet handling. Server-side. @API:native_player_flower_pot_manipulate@.
|
||||
pub(super) fn native_player_flower_pot_manipulate(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -3964,7 +3964,7 @@ impl Game<'_> {
|
||||
/// A server player inserted a book into a lectern, after the block entity
|
||||
/// and inventory changed. `book` is the item id. Helpers looking up a
|
||||
/// guest through `self.lan` may find nothing during packet handling.
|
||||
/// Server-side. @API:player_insert_lectern_book@.
|
||||
/// Server-side. @API:native_player_insert_lectern_book@.
|
||||
pub(super) fn native_player_insert_lectern_book(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -3984,7 +3984,7 @@ impl Game<'_> {
|
||||
/// A server player took the book from a lectern, after the menu and block
|
||||
/// entity changed. `book` is the removed item id. Helpers looking up a
|
||||
/// guest through `self.lan` may find nothing during packet handling.
|
||||
/// Server-side. @API:player_take_lectern_book@.
|
||||
/// Server-side. @API:native_player_take_lectern_book@.
|
||||
pub(super) fn native_player_take_lectern_book(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -3997,7 +3997,7 @@ impl Game<'_> {
|
||||
/// A server player changed the page of a lectern, after its page and
|
||||
/// redstone pulse changed. `page` is the zero-based new page. Helpers
|
||||
/// looking up a guest through `self.lan` may find nothing during packet
|
||||
/// handling. Server-side. @API:player_lectern_page_change@.
|
||||
/// handling. Server-side. @API:native_player_lectern_page_change@.
|
||||
pub(super) fn native_player_lectern_page_change(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -4018,7 +4018,7 @@ impl Game<'_> {
|
||||
/// changed. `action` is `put`, `rotate`, `remove_item` or `break`; `item`
|
||||
/// is empty when no item remains, and `rotation` is the resulting step.
|
||||
/// Helpers looking up the guest through `self.lan` may find nothing during
|
||||
/// packet handling. Server-side. @API:player_item_frame_change@.
|
||||
/// packet handling. Server-side. @API:native_player_item_frame_change@.
|
||||
pub(super) fn native_player_item_frame_change(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -4042,7 +4042,7 @@ impl Game<'_> {
|
||||
/// Mending repaired a server player's item with experience from an orb.
|
||||
/// `repair` is durability restored and `experience` is the orb's value.
|
||||
/// Helpers looking up the guest through `self.lan` may find nothing during
|
||||
/// packet handling. Server-side. @API:player_item_mend@.
|
||||
/// packet handling. Server-side. @API:native_player_item_mend@.
|
||||
pub(super) fn native_player_item_mend(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -4247,7 +4247,7 @@ impl Game<'_> {
|
||||
/// A server player is about to travel through an End gateway at `gateway`.
|
||||
/// Set `cancel` to leave them at the entrance; the gateway is not consumed
|
||||
/// and its cooldown does not start. Helpers may find no guest through
|
||||
/// `self.lan` during its packet. Server-side. @API:native_player_teleport_end_gateway@.
|
||||
/// `self.lan` during its packet. Server-side. @API:player_teleport_end_gateway@.
|
||||
pub(super) fn native_player_teleport_end_gateway(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -4267,7 +4267,7 @@ impl Game<'_> {
|
||||
/// A server player's recipe book requested a known recipe be placed in
|
||||
/// their crafting grid. `recipe` is the recipe id; `all` is the shift
|
||||
/// click form. The player's state may not be available through `self.lan`
|
||||
/// while a guest packet is handled. Server-side. @API:native_player_recipe_book_click@.
|
||||
/// while a guest packet is handled. Server-side. @API:player_recipe_book_click@.
|
||||
pub(super) fn native_player_recipe_book_click(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -4287,7 +4287,7 @@ impl Game<'_> {
|
||||
/// A server player's XP orb pickup cooldown changed to `ticks`. It is
|
||||
/// reported after the value changes; the player's state may not be found
|
||||
/// through `self.lan` while a guest packet is handled. Server-side.
|
||||
/// @API:native_player_exp_cooldown_change@.
|
||||
/// @API:player_exp_cooldown_change@.
|
||||
pub(super) fn native_player_exp_cooldown_change(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -4298,7 +4298,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// A server player changed their client language option to `locale`.
|
||||
/// The player's state may not be found through `self.lan` during a guest
|
||||
/// packet. Server-side. @API:native_player_locale_change@.
|
||||
/// packet. Server-side. @API:player_locale_change@.
|
||||
pub(super) fn native_player_locale_change(&mut self, player: u128, name: &str, locale: &str) {
|
||||
mod_api::dispatch_player_locale_change(self, mod_api::mods(), player, name, locale);
|
||||
}
|
||||
@@ -4306,7 +4306,7 @@ impl Game<'_> {
|
||||
/// position changed. `entity` is its network id; `from` and `to` are
|
||||
/// coordinates in `dimension`. This hook reports command teleports that
|
||||
/// use Horizon's same-level target path; it does not change vanilla's
|
||||
/// movement. @API:native_entity_teleport@.
|
||||
/// movement. @API:entity_teleport@.
|
||||
pub(super) fn native_entity_teleport(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
@@ -4319,13 +4319,13 @@ impl Game<'_> {
|
||||
|
||||
/// A server entity finished its portal wait and is beginning to leave
|
||||
/// `dimension` through `pos`. The destination follows the portal rules.
|
||||
/// @API:native_entity_portal@.
|
||||
/// @API:entity_portal@.
|
||||
pub(super) fn native_entity_portal(&mut self, dimension: &str, entity: u32, pos: [i32; 3]) {
|
||||
mod_api::dispatch_entity_portal(self, mod_api::mods(), dimension, entity, pos);
|
||||
}
|
||||
|
||||
/// A server projectile was added to the level. `player` is set only for
|
||||
/// a player launch; other sources have an empty `name`. @API:native_projectile_launch@.
|
||||
/// a player launch; other sources have an empty `name`. @API:projectile_launch@.
|
||||
pub(super) fn native_projectile_launch(
|
||||
&mut self,
|
||||
player: Option<u128>,
|
||||
@@ -4337,7 +4337,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// A player bow or crossbow launched an arrow with `force`, after the
|
||||
/// projectile entered the level. @API:native_entity_shoot_bow@.
|
||||
/// projectile entered the level. @API:entity_shoot_bow@.
|
||||
pub(super) fn native_entity_shoot_bow(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -4349,13 +4349,13 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// A guest's fall flying state changed on the server. The change has
|
||||
/// already been applied. @API:native_entity_toggle_glide@.
|
||||
/// already been applied. @API:entity_toggle_glide@.
|
||||
pub(super) fn native_entity_toggle_glide(&mut self, player: u128, name: &str, gliding: bool) {
|
||||
mod_api::dispatch_entity_toggle_glide(self, mod_api::mods(), player, name, gliding);
|
||||
}
|
||||
|
||||
/// A guest player's synchronized pose changed from `from` to `to`.
|
||||
/// @API:native_entity_pose_change@.
|
||||
/// @API:entity_pose_change@.
|
||||
pub(super) fn native_entity_pose_change(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -4367,19 +4367,19 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// A dropped item entity was successfully added to the server level.
|
||||
/// @API:native_item_spawn@.
|
||||
/// @API:item_spawn@.
|
||||
pub(super) fn native_item_spawn(&mut self, entity: u32, item: &str, count: i32, pos: [f64; 3]) {
|
||||
mod_api::dispatch_item_spawn(self, mod_api::mods(), entity, item, count, pos);
|
||||
}
|
||||
|
||||
/// A mob became tamed after its owner and persistent state were set.
|
||||
/// @API:native_entity_tame@.
|
||||
/// @API:entity_tame@.
|
||||
pub(super) fn native_entity_tame(&mut self, entity: u32, kind: &str, owner: Option<u128>) {
|
||||
mod_api::dispatch_entity_tame(self, mod_api::mods(), entity, kind, owner);
|
||||
}
|
||||
|
||||
/// Two mobs completed a breeding action. `child` is absent when vanilla
|
||||
/// lays an egg instead of creating a baby. @API:native_entity_breed@.
|
||||
/// lays an egg instead of creating a baby. @API:entity_breed@.
|
||||
pub(super) fn native_entity_breed(&mut self, parent: u32, partner: u32, child: Option<u32>) {
|
||||
mod_api::dispatch_entity_breed(self, mod_api::mods(), parent, partner, child);
|
||||
}
|
||||
@@ -4404,7 +4404,7 @@ impl Game<'_> {
|
||||
/// A server player took an output from a furnace at `pos`; `item` is the
|
||||
/// output item id and `count` is how many were taken. The player's state is
|
||||
/// not swapped in, so helpers that look them up through `self.lan` may find
|
||||
/// nothing during guest packet handling. @API:native_furnace_extract@.
|
||||
/// nothing during guest packet handling. @API:furnace_extract@.
|
||||
pub(super) fn native_furnace_extract(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -4415,7 +4415,7 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_furnace_extract(self, mod_api::mods(), player, name, pos, item, count);
|
||||
}
|
||||
/// A server player applied an enchantment at `pos`; `item` is the finished item and `levels` the levels charged. The player's state is not swapped in. @API:native_enchant_item@.
|
||||
/// A server player applied an enchantment at `pos`; `item` is the finished item and `levels` the levels charged. The player's state is not swapped in. @API:enchant_item@.
|
||||
pub(super) fn native_enchant_item(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -4426,7 +4426,7 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_enchant_item(self, mod_api::mods(), player, name, pos, item, levels);
|
||||
}
|
||||
/// Enchanting offers were calculated for a server player at `pos`. Change `costs` before they are sent to the menu. @API:native_prepare_item_enchant@.
|
||||
/// Enchanting offers were calculated for a server player at `pos`. Change `costs` before they are sent to the menu. @API:prepare_item_enchant@.
|
||||
pub(super) fn native_prepare_item_enchant(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -4436,7 +4436,7 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_prepare_item_enchant(self, mod_api::mods(), player, name, pos, costs);
|
||||
}
|
||||
/// An anvil result was prepared for a server player at `pos`. Change `result` or its level `cost` before it is sent. @API:native_prepare_anvil@.
|
||||
/// An anvil result was prepared for a server player at `pos`. Change `result` or its level `cost` before it is sent. @API:prepare_anvil@.
|
||||
pub(super) fn native_prepare_anvil(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -4447,7 +4447,7 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_prepare_anvil(self, mod_api::mods(), player, name, pos, cost, result);
|
||||
}
|
||||
/// A grindstone result was prepared for a server player at `pos`. Change it before it is sent. @API:native_prepare_grindstone@.
|
||||
/// A grindstone result was prepared for a server player at `pos`. Change it before it is sent. @API:prepare_grindstone@.
|
||||
pub(super) fn native_prepare_grindstone(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -4457,7 +4457,7 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_prepare_grindstone(self, mod_api::mods(), player, name, pos, result);
|
||||
}
|
||||
/// A smithing result was prepared for a server player at `pos`. Change it before it is sent. @API:native_prepare_smithing@.
|
||||
/// A smithing result was prepared for a server player at `pos`. Change it before it is sent. @API:prepare_smithing@.
|
||||
pub(super) fn native_prepare_smithing(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -4467,7 +4467,7 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_prepare_smithing(self, mod_api::mods(), player, name, pos, result);
|
||||
}
|
||||
/// A crafting-grid result was prepared for a server player; `pos` is the crafting table, or None for the inventory grid. Change `result` before it is sent. @API:native_prepare_item_craft@.
|
||||
/// A crafting-grid result was prepared for a server player; `pos` is the crafting table, or None for the inventory grid. Change `result` before it is sent. @API:prepare_item_craft@.
|
||||
pub(super) fn native_prepare_item_craft(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -4477,7 +4477,7 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_prepare_item_craft(self, mod_api::mods(), player, name, pos, result);
|
||||
}
|
||||
/// A server player selected trade `index` from `villager` in the trading menu. @API:native_trade_select@.
|
||||
/// A server player selected trade `index` from `villager` in the trading menu. @API:trade_select@.
|
||||
pub(super) fn native_trade_select(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -4489,7 +4489,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// The server changed its weather parameters. `raining` and `thundering`
|
||||
/// are the requested final states. Set `cancel` to keep the previous
|
||||
/// weather parameters. Server-side. @API:native_weather_change@.
|
||||
/// weather parameters. Server-side. @API:weather_change@.
|
||||
pub(super) fn native_weather_change(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
@@ -4509,7 +4509,7 @@ impl Game<'_> {
|
||||
|
||||
/// The server changed its thunder state through `/weather`. Set `cancel`
|
||||
/// to keep the previous thunder state and timer. Server-side.
|
||||
/// @API:native_thunder_change@.
|
||||
/// @API:thunder_change@.
|
||||
pub(super) fn native_thunder_change(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
@@ -4521,7 +4521,7 @@ impl Game<'_> {
|
||||
|
||||
/// A server lightning bolt is about to be created. `cause` is `weather`,
|
||||
/// `trident` or `command`; `player` is the responsible player when known.
|
||||
/// Set `cancel` to prevent the bolt and its effects. @API:native_lightning_strike@.
|
||||
/// Set `cancel` to prevent the bolt and its effects. @API:lightning_strike@.
|
||||
pub(super) fn native_lightning_strike(
|
||||
&mut self,
|
||||
player: Option<u128>,
|
||||
@@ -4556,7 +4556,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// The overworld clock is about to jump, from sleep or `/time`. Set
|
||||
/// `cancel` to keep `from`. @API:native_time_skip@.
|
||||
/// `cancel` to keep `from`. @API:time_skip@.
|
||||
pub(super) fn native_time_skip(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
@@ -4598,13 +4598,13 @@ impl Game<'_> {
|
||||
/// A Nether portal was built for a dimension travel, or an End platform
|
||||
/// was made for an arrival. `pos` is the lower corner of the Nether
|
||||
/// portal rectangle or the End platform origin. The completed blocks are
|
||||
/// already in the world. @API:native_portal_create@.
|
||||
/// already in the world. @API:portal_create@.
|
||||
pub(super) fn native_portal_create(&mut self, dimension: &str, pos: [i32; 3]) {
|
||||
mod_api::dispatch_portal_create(self, mod_api::mods(), dimension, pos);
|
||||
}
|
||||
|
||||
/// A raid wave has been spawned at `pos`; `wave` is its one-based wave
|
||||
/// number and `raiders` counts the raiders, not the mobs they ride. @API:native_raid_spawn_wave@.
|
||||
/// number and `raiders` counts the raiders, not the mobs they ride. @API:raid_spawn_wave@.
|
||||
pub(super) fn native_raid_spawn_wave(
|
||||
&mut self,
|
||||
raid: i32,
|
||||
@@ -4616,25 +4616,25 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// A raid at `pos` changed to `state` (`triggered`, `victory`, `loss` or
|
||||
/// `stopped`). @API:native_raid_state_change@.
|
||||
/// `stopped`). @API:raid_state_change@.
|
||||
pub(super) fn native_raid_state_change(&mut self, raid: i32, pos: [i32; 3], state: &str) {
|
||||
mod_api::dispatch_raid_state_change(self, mod_api::mods(), raid, pos, state);
|
||||
}
|
||||
|
||||
/// The server completed a world save cycle. `wait` says chunk writes were
|
||||
/// flushed before returning. @API:native_world_saved@.
|
||||
/// flushed before returning. @API:world_saved@.
|
||||
pub(super) fn native_world_saved(&mut self, wait: bool) {
|
||||
mod_api::dispatch_world_saved(self, mod_api::mods(), wait);
|
||||
}
|
||||
|
||||
/// Entities from `chunk` finished loading into the server world. `count`
|
||||
/// is the number of saved entity records read. @API:native_entities_load@.
|
||||
/// is the number of saved entity records read. @API:entities_load@.
|
||||
pub(super) fn native_entities_load(&mut self, dimension: &str, chunk: [i32; 2], count: usize) {
|
||||
mod_api::dispatch_entities_load(self, mod_api::mods(), dimension, chunk, count);
|
||||
}
|
||||
|
||||
/// Entities in `chunk` left the active server world and were queued for
|
||||
/// saving. `count` is the number of entity records. @API:native_entities_unload@.
|
||||
/// saving. `count` is the number of entity records. @API:entities_unload@.
|
||||
pub(super) fn native_entities_unload(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
@@ -4644,13 +4644,13 @@ impl Game<'_> {
|
||||
mod_api::dispatch_entities_unload(self, mod_api::mods(), dimension, chunk, count);
|
||||
}
|
||||
|
||||
/// A generic world game event was fired at `pos`. @API:native_game_event@.
|
||||
/// A generic world game event was fired at `pos`. @API:game_event@.
|
||||
pub(super) fn native_game_event(&mut self, event: &str, pos: [f64; 3]) {
|
||||
mod_api::dispatch_game_event(self, mod_api::mods(), event, pos);
|
||||
}
|
||||
|
||||
/// A guest asked to complete `command`; `suggestions` may be added to or
|
||||
/// filtered. `range` is the replaced byte range. @API:native_tab_complete@.
|
||||
/// filtered. `range` is the replaced byte range. @API:tab_complete@.
|
||||
pub(super) fn native_tab_complete(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -4671,18 +4671,18 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// A server broadcast line is about to be logged and sent. Change `line`
|
||||
/// or set `cancel` to drop it. @API:native_broadcast_message@.
|
||||
/// or set `cancel` to drop it. @API:broadcast_message@.
|
||||
pub(super) fn native_broadcast_message(&self, line: &mut Text, cancel: &mut bool) {
|
||||
mod_api::dispatch_broadcast_message(self, mod_api::mods(), line, cancel);
|
||||
}
|
||||
|
||||
/// The dedicated server's whitelist changed to `enabled`. @API:native_whitelist_state_update@.
|
||||
/// The dedicated server's whitelist changed to `enabled`. @API:whitelist_state_update@.
|
||||
pub(super) fn native_whitelist_state_update(&mut self, enabled: bool) {
|
||||
mod_api::dispatch_whitelist_state_update(self, mod_api::mods(), enabled);
|
||||
}
|
||||
|
||||
/// A mob entered love mode after player interaction. `player` is the
|
||||
/// feeder when known. @API:native_entity_enter_love_mode@.
|
||||
/// feeder when known. @API:entity_enter_love_mode@.
|
||||
pub(super) fn native_entity_enter_love_mode(
|
||||
&mut self,
|
||||
entity: u32,
|
||||
@@ -4693,7 +4693,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// A mob conversion completed; both ids and entity types are reported.
|
||||
/// @API:native_entity_transform@.
|
||||
/// @API:entity_transform@.
|
||||
pub(super) fn native_entity_transform(
|
||||
&mut self,
|
||||
entity: u32,
|
||||
@@ -4712,39 +4712,39 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// A thrown splash or lingering potion applied its hit effects.
|
||||
/// @API:native_potion_splash@.
|
||||
/// @API:potion_splash@.
|
||||
pub(super) fn native_potion_splash(&mut self, item: &str, pos: [f64; 3]) {
|
||||
mod_api::dispatch_potion_splash(self, mod_api::mods(), item, pos);
|
||||
}
|
||||
|
||||
/// A firework rocket completed its explosion effects. @API:native_firework_explode@.
|
||||
/// A firework rocket completed its explosion effects. @API:firework_explode@.
|
||||
pub(super) fn native_firework_explode(&mut self, entity: u32, pos: [f64; 3], bursts: usize) {
|
||||
mod_api::dispatch_firework_explode(self, mod_api::mods(), entity, pos, bursts);
|
||||
}
|
||||
|
||||
/// An experience bottle broke and its experience was awarded.
|
||||
/// @API:native_exp_bottle@.
|
||||
/// @API:exp_bottle@.
|
||||
pub(super) fn native_exp_bottle(&mut self, pos: [f64; 3], experience: u32) {
|
||||
mod_api::dispatch_exp_bottle(self, mod_api::mods(), pos, experience);
|
||||
}
|
||||
|
||||
/// A player ignited a creeper with an item. @API:native_creeper_ignite@.
|
||||
/// A player ignited a creeper with an item. @API:creeper_ignite@.
|
||||
pub(super) fn native_creeper_ignite(&mut self, entity: u32, player: u128, item: &str) {
|
||||
mod_api::dispatch_creeper_ignite(self, mod_api::mods(), entity, player, item);
|
||||
}
|
||||
|
||||
/// Lightning charged a creeper. @API:native_creeper_power@.
|
||||
/// Lightning charged a creeper. @API:creeper_power@.
|
||||
pub(super) fn native_creeper_power(&mut self, entity: u32, lightning: [f64; 3]) {
|
||||
mod_api::dispatch_creeper_power(self, mod_api::mods(), entity, lightning);
|
||||
}
|
||||
|
||||
/// A mob broke the door at `pos`. @API:native_entity_break_door@.
|
||||
/// A mob broke the door at `pos`. @API:entity_break_door@.
|
||||
pub(super) fn native_entity_break_door(&mut self, entity: u32, kind: &str, pos: [i32; 3]) {
|
||||
mod_api::dispatch_entity_break_door(self, mod_api::mods(), entity, kind, pos);
|
||||
}
|
||||
|
||||
/// A mob changed a block at `pos`, after the new state was applied.
|
||||
/// @API:native_entity_change_block@.
|
||||
/// @API:entity_change_block@.
|
||||
pub(super) fn native_entity_change_block(
|
||||
&mut self,
|
||||
entity: u32,
|
||||
@@ -4757,7 +4757,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// A bee entered the hive at `pos` and was stored there.
|
||||
/// @API:native_entity_enter_block@.
|
||||
/// @API:entity_enter_block@.
|
||||
pub(super) fn native_entity_enter_block(
|
||||
&mut self,
|
||||
entity: u32,
|
||||
@@ -5065,6 +5065,32 @@ impl Game<'_> {
|
||||
canceled,
|
||||
);
|
||||
}
|
||||
/// NeoForge's `CriticalHitEvent`: a player's melee hit on a living
|
||||
/// `target_kind` (`minecraft:zombie`, `minecraft:armor_stand`) that deals
|
||||
/// damage, where `Player.attack` decides whether it is critical.
|
||||
/// `vanilla_critical` is vanilla's answer; `critical` starts as it and may
|
||||
/// be forced or denied (as in vanilla a critical hit prevents the sweep, so
|
||||
/// a forced crit loses it; a denied crit loses the crit sound and
|
||||
/// particles); `damage_multiplier` starts at 1.5 for vanilla's critical and
|
||||
/// 1.0 otherwise, and scales the damage only while `critical` ends true. Not fired for a
|
||||
/// target that is no living entity (a dragon part) or when the hit does
|
||||
/// no damage. Fires on the Game that runs the attack. @API:critical_hit@.
|
||||
pub(super) fn native_critical_hit(
|
||||
&mut self,
|
||||
target_kind: &str,
|
||||
vanilla_critical: bool,
|
||||
critical: &mut bool,
|
||||
damage_multiplier: &mut f32,
|
||||
) {
|
||||
mod_api::dispatch_critical_hit(
|
||||
self,
|
||||
mod_api::mods(),
|
||||
target_kind,
|
||||
vanilla_critical,
|
||||
critical,
|
||||
damage_multiplier,
|
||||
);
|
||||
}
|
||||
/// `Item.releaseUsing` of `stack`: `entity` let it go with `time_left`
|
||||
/// ticks of its `native_use_duration` left, so it was held for
|
||||
/// `duration - time_left` (a bow-like item shoots by that charge). Set
|
||||
@@ -5477,8 +5503,8 @@ impl Game<'_> {
|
||||
/// a level closing with nobody in it included. Not for the entities still
|
||||
/// loaded when the server stops, as in vanilla. `entity` is its ID and
|
||||
/// `kind` its type (`minecraft:item`); it is no longer in the level's lists.
|
||||
/// It only hears. 53 tells item entities and experience orbs; later
|
||||
/// APIs add other kinds, so check `kind`. Server-side.
|
||||
/// It only hears. 53 tells item entities, experience orbs and mobs (`kind`
|
||||
/// the mob type); later APIs add other kinds, so check `kind`. Server-side.
|
||||
pub(super) fn native_entity_leave_level(&mut self, dimension: &str, entity: u32, kind: &str) {
|
||||
mod_api::dispatch_entity_leave_level(self, mod_api::mods(), dimension, entity, kind);
|
||||
}
|
||||
@@ -5648,6 +5674,22 @@ impl Game<'_> {
|
||||
pub(super) fn native_player_totem(&mut self, player: u128, name: &str) {
|
||||
mod_api::dispatch_player_totem(self, mod_api::mods(), player, name);
|
||||
}
|
||||
/// The item `item` of the server player `player` (`name`) broke in `slot`
|
||||
/// (`mainhand`, `offhand`, `head`, `chest`, `legs`, `feet`, `body` or
|
||||
/// `saddle`): `LivingEntity.onEquippedItemBroken`, Bukkit's
|
||||
/// `PlayerItemBreakEvent`. Listen only; armour has a slot, not a hand.
|
||||
/// The player's state is swapped in. Once for each break, on a server; a
|
||||
/// creative player and the host of a LAN world are not heard.
|
||||
/// @API:native_player_destroy_item@.
|
||||
pub(super) fn native_player_destroy_item(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
item: &str,
|
||||
slot: &str,
|
||||
) {
|
||||
mod_api::dispatch_player_destroy_item(self, mod_api::mods(), player, name, item, slot);
|
||||
}
|
||||
/// `player`'s melee blow killed the mob `entity` of `kind` (other deaths
|
||||
/// aren't told yet). It only hears, after the blow landed. Server-side.
|
||||
/// API 40.
|
||||
@@ -6932,7 +6974,9 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
/// Draw a stack and its slot decorations, except the count, at `at`,
|
||||
/// `size` GUI pixels square. False for an unknown item. API @API:item_stack@.
|
||||
/// `size` GUI pixels square. False when nothing was drawn: an unknown item
|
||||
/// or an empty stack (air, or a count of 0, as `ItemStack.isEmpty`).
|
||||
/// API @API:item_stack@.
|
||||
pub(super) fn native_item_stack(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
@@ -6940,6 +6984,11 @@ impl Game<'_> {
|
||||
at: [f32; 2],
|
||||
size: f32,
|
||||
) -> bool {
|
||||
// GuiGraphicsExtractor.item and itemDecorations draw nothing for an
|
||||
// empty stack.
|
||||
if &*stack.item == "minecraft:air" || stack.count == 0 {
|
||||
return false;
|
||||
}
|
||||
if self.res.items().get(&stack.item).is_none() {
|
||||
return false;
|
||||
}
|
||||
@@ -7202,8 +7251,8 @@ impl Game<'_> {
|
||||
/// (`config/<id>.json`, or a folder of its own). The same folder on the
|
||||
/// window, its server thread and a LAN host; a dedicated server's is in
|
||||
/// its own folder, beside `server.properties`. Made when missing. No
|
||||
/// `self`, so it works from any hook (`Game::native_config_dir()`), the
|
||||
/// level's too. API 26.
|
||||
/// `self`, so a mixin helper calls `Game::native_config_dir()` and a trait
|
||||
/// mod `Host::config_dir`. API 26.
|
||||
pub(super) fn native_config_dir() -> Result<PathBuf, String> {
|
||||
config_dir(&Self::game_dir())
|
||||
}
|
||||
@@ -7337,6 +7386,22 @@ pub(super) fn mob_kind(mob: &horizon_client::mobs::Mob) -> String {
|
||||
["minecraft:", mob.kind.id.trim_start_matches("minecraft:")].concat()
|
||||
}
|
||||
|
||||
/// The ids of `before` (id, kind) that are not in `now`, grouped by kind in
|
||||
/// first-seen order: the entities that left the level.
|
||||
fn vanished_by_kind(
|
||||
before: &[(u32, String)],
|
||||
now: &std::collections::HashSet<u32>,
|
||||
) -> Vec<(String, Vec<u32>)> {
|
||||
let mut out: Vec<(String, Vec<u32>)> = Vec::new();
|
||||
for (id, kind) in before.iter().filter(|(id, _)| !now.contains(id)) {
|
||||
match out.iter_mut().find(|(k, _)| k == kind) {
|
||||
Some((_, ids)) => ids.push(*id),
|
||||
None => out.push((kind.clone(), Vec::from([*id]))),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where the game calls the hook points.
|
||||
impl Game<'_> {
|
||||
/// The acting player as the hook points name one: the guest whose turn
|
||||
@@ -7470,20 +7535,44 @@ impl Game<'_> {
|
||||
/// The IDs of the level's item entities, for `native_items_left_since`
|
||||
/// (none on a client of a server, which tells nothing).
|
||||
pub(super) fn dropped_ids(&self) -> Vec<u32> {
|
||||
if self.remote.is_some() {
|
||||
if self.remote.is_some() || mod_api::mods().is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.dropped.iter().map(|e| e.id).collect()
|
||||
}
|
||||
/// The IDs of the level's experience orbs, for `native_orbs_left_since`.
|
||||
pub(super) fn orb_ids(&self) -> Vec<u32> {
|
||||
if self.remote.is_some() {
|
||||
if self.remote.is_some() || mod_api::mods().is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.orbs.iter().map(|o| o.id).collect()
|
||||
}
|
||||
/// The (id, kind) of the level's mobs, for `native_mobs_left_since` (none
|
||||
/// on a client of a server, which tells nothing).
|
||||
pub(super) fn mob_ids(&self) -> Vec<(u32, String)> {
|
||||
if self.remote.is_some() || mod_api::mods().is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.mobs.iter().map(|m| (m.id, mob_kind(m))).collect()
|
||||
}
|
||||
/// The mobs of `before` gone since have left the level (died, despawned,
|
||||
/// entered a hive, unloaded). Mobs spawned meanwhile are not counted.
|
||||
pub(super) fn native_mobs_left_since(&mut self, before: &[(u32, String)]) {
|
||||
// Nothing was taken: no mod listens, or a client of a server.
|
||||
if before.is_empty() {
|
||||
return;
|
||||
}
|
||||
let now: std::collections::HashSet<u32> = self.mobs.iter().map(|m| m.id).collect();
|
||||
for (kind, ids) in vanished_by_kind(before, &now) {
|
||||
self.native_entities_left(&kind, &ids);
|
||||
}
|
||||
}
|
||||
/// The item entities of `before` gone since have left the level.
|
||||
pub(super) fn native_items_left_since(&mut self, before: &[u32]) {
|
||||
// Nothing was taken: no mod listens, or a client of a server.
|
||||
if before.is_empty() {
|
||||
return;
|
||||
}
|
||||
let now: std::collections::HashSet<u32> = self.dropped.iter().map(|e| e.id).collect();
|
||||
let gone: Vec<u32> = before
|
||||
.iter()
|
||||
@@ -7494,6 +7583,10 @@ impl Game<'_> {
|
||||
}
|
||||
/// The experience orbs of `before` gone since have left the level.
|
||||
pub(super) fn native_orbs_left_since(&mut self, before: &[u32]) {
|
||||
// Nothing was taken: no mod listens, or a client of a server.
|
||||
if before.is_empty() {
|
||||
return;
|
||||
}
|
||||
let now: std::collections::HashSet<u32> = self.orbs.iter().map(|o| o.id).collect();
|
||||
let gone: Vec<u32> = before
|
||||
.iter()
|
||||
@@ -8370,6 +8463,25 @@ fn mod_screen_opens(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn vanished_mobs_are_reported_by_kind() {
|
||||
use std::collections::HashSet;
|
||||
let z = || "minecraft:zombie".to_string();
|
||||
let b = || "minecraft:bee".to_string();
|
||||
let before = vec![(1, z()), (2, b()), (3, z()), (4, b())];
|
||||
let now: HashSet<u32> = [1, 4, 99].into_iter().collect(); // 99 spawned meanwhile
|
||||
assert_eq!(
|
||||
super::vanished_by_kind(&before, &now),
|
||||
vec![(b(), vec![2]), (z(), vec![3])]
|
||||
);
|
||||
let all: HashSet<u32> = [1, 2, 3, 4, 5].into_iter().collect();
|
||||
assert!(super::vanished_by_kind(&before, &all).is_empty());
|
||||
assert_eq!(
|
||||
super::vanished_by_kind(&before, &HashSet::new()),
|
||||
vec![(z(), vec![1, 3]), (b(), vec![2, 4])]
|
||||
);
|
||||
assert!(super::vanished_by_kind(&[], &now).is_empty());
|
||||
}
|
||||
|
||||
/// Exercise the gameplay constructor, where name-based entities compete
|
||||
/// with a mod block's sign template.
|
||||
|
||||
@@ -343,6 +343,26 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Hides that player (a vanish) from the players below permission
|
||||
/// `level`, or shows them again (None): from the next tick those players
|
||||
/// lose them from the Tab list and the world (their client stops
|
||||
/// tracking the player, as it does a spectator), and don't read their
|
||||
/// joining or leaving; players of `level` or more still see them, and
|
||||
/// a player made an operator meanwhile sees them again. By UUID: kept
|
||||
/// for a player who is off (who joins hidden), until a mod sets it again
|
||||
/// or the server stops. Works anywhere on a server, inside a player's
|
||||
/// hook too; false on a client and in a world nobody else can join.
|
||||
/// API @API:hide_player@.
|
||||
pub(super) fn native_hide_player(&mut self, uuid: u128, level: Option<u8>) -> bool {
|
||||
let Some(host) = &mut self.lan else {
|
||||
return false;
|
||||
};
|
||||
match level {
|
||||
Some(level) => host.hub.hidden.insert(uuid, level),
|
||||
None => host.hub.hidden.remove(&uuid),
|
||||
};
|
||||
true
|
||||
}
|
||||
/// Shows `nickname` in place of that player's name in chat (their
|
||||
/// messages, `/me`, `/msg`) and in the Tab list; None shows their name
|
||||
/// again. Hovering the shown name gives the real one, which stays in the
|
||||
@@ -400,6 +420,7 @@ impl Game<'_> {
|
||||
/// `[discord] events` lists `announce`: `name` and `uuid` say who it is
|
||||
/// about (empty and 0 for no one), `message` is the line (`§` codes colour it).
|
||||
/// Only on a dedicated server; false elsewhere and for an empty message.
|
||||
/// API @API:server_event@.
|
||||
pub(super) fn native_server_event(&self, name: &str, uuid: u128, message: &str) -> bool {
|
||||
if self.options.dedicated.is_none() || message.trim().is_empty() {
|
||||
return false;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! A server keeps one level per dimension. Entity portal processes and
|
||||
//! cooldowns follow their level; shared `Arrival`s hold each traveller's
|
||||
//! saved compound until its destination chunks are loaded.
|
||||
use super::entity::{Entity as _, entity_lists_of, for_each_entity};
|
||||
use super::{Dim, Game, Screen};
|
||||
use horizon_client::Result;
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
@@ -110,7 +111,7 @@ fn rotate_y(v: Vec3, degrees: f32) -> Vec3 {
|
||||
}
|
||||
/// Whether a projectile is an arrow, which goes through a portal like the
|
||||
/// other entities.
|
||||
fn is_arrow(p: &horizon_client::projectiles::Projectile) -> bool {
|
||||
pub(super) fn is_arrow(p: &horizon_client::projectiles::Projectile) -> bool {
|
||||
matches!(
|
||||
p.kind,
|
||||
horizon_client::projectiles::ProjectileKind::Arrow { .. }
|
||||
@@ -119,7 +120,7 @@ fn is_arrow(p: &horizon_client::projectiles::Projectile) -> bool {
|
||||
/// The projectiles besides arrows that a portal sends on: the thrown items
|
||||
/// and fireballs a chunk saves. A pearl has its own rules (it keeps its owner's
|
||||
/// dimension) and a wind charge is not saved, so both stay.
|
||||
fn is_portal_projectile(p: &horizon_client::projectiles::Projectile) -> bool {
|
||||
pub(super) fn is_portal_projectile(p: &horizon_client::projectiles::Projectile) -> bool {
|
||||
use horizon_client::projectiles::ProjectileKind;
|
||||
!is_arrow(p)
|
||||
&& !matches!(
|
||||
@@ -131,7 +132,7 @@ fn is_portal_projectile(p: &horizon_client::projectiles::Projectile) -> bool {
|
||||
&& horizon_client::entity_nbt::saved_with_chunk(p)
|
||||
}
|
||||
/// Width and height of a projectile that goes through a portal.
|
||||
fn projectile_box(p: &horizon_client::projectiles::Projectile) -> (f32, f32) {
|
||||
pub(super) fn projectile_box(p: &horizon_client::projectiles::Projectile) -> (f32, f32) {
|
||||
if is_arrow(p) {
|
||||
(
|
||||
horizon_client::arrows::WIDTH,
|
||||
@@ -685,73 +686,24 @@ impl Game<'_> {
|
||||
}
|
||||
self.load_portal_arrivals();
|
||||
let mut leaving: Vec<(u32, BlockPos)> = Vec::new();
|
||||
self.portals.before.clear();
|
||||
for e in &self.dropped {
|
||||
self.portals.before.insert(e.id, e.position);
|
||||
}
|
||||
for m in &self.mobs {
|
||||
self.portals.before.insert(m.id, m.position);
|
||||
}
|
||||
for b in &self.boats {
|
||||
self.portals.before.insert(b.id, b.position);
|
||||
}
|
||||
for c in &self.carts {
|
||||
self.portals.before.insert(c.id, c.position);
|
||||
}
|
||||
for t in &self.tnts {
|
||||
self.portals.before.insert(t.id, t.position);
|
||||
}
|
||||
for o in &self.orbs {
|
||||
self.portals.before.insert(o.id, o.position);
|
||||
}
|
||||
// Only an entity in a portal, or cooling down from one, has a state
|
||||
// here: whether the others could use a portal is not worth asking
|
||||
// for each of them on every tick.
|
||||
// Positions before the entities move, and (only for an entity in a
|
||||
// portal, or cooling down from one, which has a state here) whether it
|
||||
// can use one: asking every entity on every tick is not worth it.
|
||||
let mut before = std::mem::take(&mut self.portals.before);
|
||||
before.clear();
|
||||
let mut ids: Vec<(u32, bool)> = Vec::new();
|
||||
if !self.portals.entities.is_empty() {
|
||||
let tracked = &self.portals.entities;
|
||||
for e in &self.dropped {
|
||||
if tracked.contains_key(&e.id) {
|
||||
ids.push((e.id, true));
|
||||
let tracked = &self.portals.entities;
|
||||
for_each_entity!(entity_lists_of!(self), entity => {
|
||||
if entity.portal_size().is_some() {
|
||||
if entity.portal_from().is_none() {
|
||||
before.insert(entity.id(), entity.position());
|
||||
}
|
||||
if tracked.contains_key(&entity.id()) {
|
||||
ids.push((entity.id(), entity.can_use_portal(self)));
|
||||
}
|
||||
}
|
||||
for m in &self.mobs {
|
||||
if tracked.contains_key(&m.id) {
|
||||
ids.push((m.id, self.mob_can_use_portal(m)));
|
||||
}
|
||||
}
|
||||
for t in &self.tnts {
|
||||
if tracked.contains_key(&t.id) {
|
||||
ids.push((t.id, self.tnt_can_use_portal(t.id)));
|
||||
}
|
||||
}
|
||||
let vehicles = self.boats.iter().map(|b| b.id);
|
||||
for id in vehicles.chain(self.carts.iter().map(|c| c.id)) {
|
||||
if tracked.contains_key(&id) {
|
||||
ids.push((id, self.vehicle_can_use_portal(id)));
|
||||
}
|
||||
}
|
||||
for p in &self.creatures.projectiles {
|
||||
if tracked.contains_key(&p.id) && (is_arrow(p) || is_portal_projectile(p)) {
|
||||
ids.push((p.id, true));
|
||||
}
|
||||
}
|
||||
for o in &self.orbs {
|
||||
if tracked.contains_key(&o.id) {
|
||||
ids.push((o.id, true));
|
||||
}
|
||||
}
|
||||
for t in &self.tridents {
|
||||
if tracked.contains_key(&t.id) {
|
||||
ids.push((t.id, true));
|
||||
}
|
||||
}
|
||||
for r in &self.rockets {
|
||||
if tracked.contains_key(&r.id) && r.attached_to.is_none() && !r.attached {
|
||||
ids.push((r.id, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
self.portals.before = before;
|
||||
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
|
||||
self.portals.entities.retain(|id, _| present.contains(id));
|
||||
for (id, allowed) in ids {
|
||||
@@ -825,11 +777,11 @@ impl Game<'_> {
|
||||
/// `canUsePortal(false)` of a boat or minecart: empty, or ridden by
|
||||
/// server players only, whom it carries along.
|
||||
/// `Entity.canUsePortal(false)` for a primed TNT: it carries no mob.
|
||||
fn tnt_can_use_portal(&self, id: u32) -> bool {
|
||||
pub(super) fn tnt_can_use_portal(&self, id: u32) -> bool {
|
||||
// Id 0: not tracked yet (the in-frame path), nothing to key its portal state by.
|
||||
id != 0 && !self.mobs.iter().any(|m| m.extra.mount == Some(id))
|
||||
}
|
||||
fn vehicle_can_use_portal(&self, id: u32) -> bool {
|
||||
pub(super) fn vehicle_can_use_portal(&self, id: u32) -> bool {
|
||||
if !self.vehicle_occupied(id) {
|
||||
return true;
|
||||
}
|
||||
@@ -883,85 +835,40 @@ impl Game<'_> {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
|
||||
let items: Vec<(u32, Vec3, f32, f32)> = self
|
||||
.dropped
|
||||
.iter()
|
||||
.map(|e| (e.id, e.position, 0.25, 0.25))
|
||||
.chain(self.orbs.iter().map(|o| (o.id, o.position, 0.5, 0.5)))
|
||||
.collect();
|
||||
// PrimedTnt is 0.98 across; one that carries a mob stays (the rider would be left behind).
|
||||
let tnts: Vec<(u32, Vec3, f32, f32)> = self
|
||||
.tnts
|
||||
.iter()
|
||||
.filter(|t| self.tnt_can_use_portal(t.id))
|
||||
.map(|t| (t.id, t.position, 0.98, 0.98))
|
||||
.collect();
|
||||
let mobs: Vec<(u32, Vec3, f32, f32)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| self.mob_can_use_portal(m))
|
||||
.map(|m| (m.id, m.position, m.width(), m.height()))
|
||||
.collect();
|
||||
let vehicles: Vec<(u32, Vec3, f32, f32)> = self
|
||||
.boats
|
||||
.iter()
|
||||
.map(|b| (b.id, b.position, super::boats::WIDTH, super::boats::HEIGHT))
|
||||
.chain(self.carts.iter().map(|c| {
|
||||
(
|
||||
c.id,
|
||||
c.position,
|
||||
super::minecarts::WIDTH,
|
||||
super::minecarts::HEIGHT,
|
||||
)
|
||||
}))
|
||||
.filter(|(id, ..)| self.vehicle_can_use_portal(*id))
|
||||
.map(|(id, at, w, h)| (id, at, w as f32, h as f32))
|
||||
.collect();
|
||||
let mut portal_sections = std::mem::take(&mut self.portals.sections);
|
||||
for (id, to, width, height) in items.into_iter().chain(tnts).chain(mobs).chain(vehicles) {
|
||||
let from = self.portals.before.get(&id).copied().unwrap_or(to);
|
||||
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
|
||||
inside.push((id, pos));
|
||||
}
|
||||
}
|
||||
// The arrow's `previous` is where it stood at the start of its tick,
|
||||
// which is also where an arrow shot this tick began. A stuck arrow
|
||||
// that did not move this tick enters no block.
|
||||
for p in self.creatures.projectiles.iter().filter(|p| {
|
||||
(is_arrow(p) && (!p.stuck || p.previous != p.position)) || is_portal_projectile(p)
|
||||
}) {
|
||||
let (width, height) = projectile_box(p);
|
||||
if let Some(pos) =
|
||||
self.swept_portal(p.previous, p.position, width, height, &mut portal_sections)
|
||||
{
|
||||
inside.push((p.id, pos));
|
||||
}
|
||||
}
|
||||
// A trident (0.5 across) or rocket (0.25) in flight, or one that moved
|
||||
// this tick; a rocket that carries its owner stays.
|
||||
let mut flyers: Vec<(u32, Vec3, Vec3, f32)> = Vec::new();
|
||||
// A trident or rocket in flight gets its id here, before the walk.
|
||||
for t in self
|
||||
.tridents
|
||||
.iter_mut()
|
||||
.filter(|t| !t.in_ground || t.previous != t.position)
|
||||
{
|
||||
let id = super::lan_entities::assign_entity_id(&mut self.next_entity, &mut t.id);
|
||||
flyers.push((id, t.previous, t.position, 0.5));
|
||||
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut t.id);
|
||||
}
|
||||
for r in self
|
||||
.rockets
|
||||
.iter_mut()
|
||||
.filter(|r| r.attached_to.is_none() && !r.attached)
|
||||
{
|
||||
let id = super::lan_entities::assign_entity_id(&mut self.next_entity, &mut r.id);
|
||||
flyers.push((id, r.previous, r.position, 0.25));
|
||||
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut r.id);
|
||||
}
|
||||
for (id, from, to, size) in flyers {
|
||||
if let Some(pos) = self.swept_portal(from, to, size, size, &mut portal_sections) {
|
||||
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
|
||||
let mut portal_sections = std::mem::take(&mut self.portals.sections);
|
||||
// The first portal block each entity's box swept through since the
|
||||
// start of its tick (a stuck arrow that did not move enters none).
|
||||
for_each_entity!(entity_lists_of!(self), entity => {
|
||||
let Some((width, height)) = entity.portal_size() else {
|
||||
continue;
|
||||
};
|
||||
if !entity.portal_sweeps() || !entity.can_use_portal(self) {
|
||||
continue;
|
||||
}
|
||||
let (id, to) = (entity.id(), entity.position());
|
||||
let from = entity
|
||||
.portal_from()
|
||||
.unwrap_or_else(|| self.portals.before.get(&id).copied().unwrap_or(to));
|
||||
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
|
||||
inside.push((id, pos));
|
||||
}
|
||||
}
|
||||
});
|
||||
self.portals.sections = portal_sections;
|
||||
for (id, pos) in inside {
|
||||
self.portals
|
||||
|
||||
@@ -121,8 +121,10 @@ impl Game<'_> {
|
||||
self.screen = Screen::Options;
|
||||
self.options_from_title = from_title;
|
||||
self.options_to_game = false;
|
||||
let minecart_improvements = self.minecart_improvements();
|
||||
let ui = &mut self.ui.options;
|
||||
ui.open_at(!from_title && self.title.is_none(), page);
|
||||
ui.minecart_improvements = minecart_improvements;
|
||||
let root = self.options.resources.clone();
|
||||
ui.languages = lang::available(&[&root]);
|
||||
self.refresh_pack_lists();
|
||||
|
||||
@@ -413,6 +413,26 @@ impl Game<'_> {
|
||||
}
|
||||
return self.handle_option_events(vec![Event::Changed]);
|
||||
}
|
||||
// Splitscreen Setup: A picks up the focused player's tile, then left/right (or LB/RB)
|
||||
// cycle where it goes, A drops it, B puts it back (`split_ui`).
|
||||
if self.screen == Screen::Options && self.ui.options.page() == Page::SplitSetup {
|
||||
let setup = &mut self.ui.options.split_setup;
|
||||
if setup.pad_button(button) {
|
||||
// Dropped or put back: the focus follows the player to its tile.
|
||||
if let Some(rect) = setup.take_refocus() {
|
||||
let kind = Kind::Button;
|
||||
self.pad_focus_on(event_loop, Target { rect, kind });
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if button == Button::South
|
||||
&& self.pad_focusing()
|
||||
&& let Some(focus) = self.pad.menu.focus
|
||||
&& self.ui.options.split_setup.pad_pick_up(focus.center())
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// The on-screen keyboard takes every button while it is open.
|
||||
if let Some(keyboard) = self.pad.menu.osk.as_mut() {
|
||||
if let Some(out) = keyboard.button(button) {
|
||||
@@ -523,6 +543,14 @@ impl Game<'_> {
|
||||
keyboard.step(button);
|
||||
return;
|
||||
}
|
||||
// Splitscreen Setup with a player held: the stick cycles it as the D-pad does.
|
||||
if self.screen == Screen::Options
|
||||
&& self.ui.options.page() == Page::SplitSetup
|
||||
&& self.ui.options.split_setup.holding()
|
||||
{
|
||||
self.ui.options.split_setup.pad_button(button);
|
||||
return;
|
||||
}
|
||||
if self.pad_focus_mode() {
|
||||
let targets = self.pad_targets();
|
||||
if !targets.is_empty() {
|
||||
|
||||
@@ -1507,6 +1507,7 @@ impl Game<'_> {
|
||||
})
|
||||
.map(|i| self.mobs[i].id)
|
||||
.collect();
|
||||
let mobs_before = self.mob_ids();
|
||||
self.mobs.retain(|m| {
|
||||
let keep = !leaving.contains(&m.id);
|
||||
if !keep {
|
||||
@@ -1524,6 +1525,7 @@ impl Game<'_> {
|
||||
});
|
||||
self.orbs.retain(|o| !gone(o.position));
|
||||
// Unloaded with their chunk: PersistentEntitySectionManager.stopTracking.
|
||||
self.native_mobs_left_since(&mobs_before);
|
||||
self.native_items_left_since(&items_before);
|
||||
self.native_orbs_left_since(&orbs_before);
|
||||
// the ones saved with the chunk go with it (a trident too, unsaved).
|
||||
|
||||
@@ -246,10 +246,16 @@ impl Game<'_> {
|
||||
// another player.
|
||||
let (entity, descending) = match self.camera_mob() {
|
||||
Some(m) => (super::remote_camera::mob_camera_entity(m), false),
|
||||
None => (
|
||||
self.player_camera_entity()?,
|
||||
self.player.as_ref()?.shift_key_down,
|
||||
),
|
||||
None => {
|
||||
let mut entity = self.player_camera_entity()?;
|
||||
if let Some(position) = self.remote_minecart_rider_position(partial) {
|
||||
// Already evaluated at partial: do not linearly interpolate
|
||||
// between tick endpoints across a corner a second time.
|
||||
entity.position = position;
|
||||
entity.previous = position;
|
||||
}
|
||||
(entity, self.player.as_ref()?.shift_key_down)
|
||||
}
|
||||
};
|
||||
let mut view = self.view;
|
||||
let kind = match self.camera_type {
|
||||
@@ -352,7 +358,14 @@ impl Game<'_> {
|
||||
let ride_turn = self
|
||||
.riding
|
||||
.and_then(|id| self.boats.iter().find(|b| b.id == id))
|
||||
.map_or(0.0, |b| b.delta_rotation);
|
||||
.map_or_else(
|
||||
|| {
|
||||
self.riding
|
||||
.and_then(|id| self.carts.iter().find(|c| c.id == id))
|
||||
.map_or(0.0, |c| c.passenger_yaw_delta)
|
||||
},
|
||||
|b| b.delta_rotation,
|
||||
);
|
||||
let entity = CameraEntity {
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
previous: [
|
||||
@@ -659,28 +672,31 @@ impl Game<'_> {
|
||||
&self.player_armor_layers(),
|
||||
out,
|
||||
);
|
||||
let chest = self.armor[1].as_ref().map(|s| &*s.item);
|
||||
// CapeLayer: no cape on an invisible player (`isInvisible`, whoever looks).
|
||||
if !self.own_invisible() {
|
||||
self.player_cape_geometry(m, chest, camera, alpha as f32, packed, out);
|
||||
// AvatarRenderer.shouldRenderLayers: a spectator draws no layers.
|
||||
if horizon_client::mobs::render::layers_shown(m) {
|
||||
let chest = self.armor[1].as_ref().map(|s| &*s.item);
|
||||
// CapeLayer: no cape on an invisible player (`isInvisible`, whoever looks).
|
||||
if !self.own_invisible() {
|
||||
self.player_cape_geometry(m, chest, camera, alpha as f32, packed, out);
|
||||
}
|
||||
self.player_ears_geometry(
|
||||
m,
|
||||
&self.own_profile_name(),
|
||||
camera,
|
||||
alpha as f32,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.shoulder_parrots_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.stuck_in_body_geometry(m, self.own_entity_id(), camera, alpha as f32, packed, out);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
// CustomHeadLayer: a pumpkin, skull or other head item.
|
||||
self.head_item_geometry(m, camera, alpha, packed, true, out);
|
||||
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
|
||||
self.mod_mob_layer_geometry(m, Some(who), alpha as f32, camera, packed, out);
|
||||
}
|
||||
self.player_ears_geometry(
|
||||
m,
|
||||
&self.own_profile_name(),
|
||||
camera,
|
||||
alpha as f32,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.shoulder_parrots_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.stuck_in_body_geometry(m, self.own_entity_id(), camera, alpha as f32, packed, out);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
// CustomHeadLayer: a pumpkin, skull or other head item.
|
||||
self.head_item_geometry(m, camera, alpha, packed, true, out);
|
||||
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
|
||||
self.mod_mob_layer_geometry(m, Some(who), alpha as f32, camera, packed, out);
|
||||
// LivingEntity.isCurrentlyGlowing: the Glowing effect.
|
||||
if self.potions.effects.get("glowing").is_some() {
|
||||
let color = super::glowing::team_color(self.own_team());
|
||||
@@ -1499,6 +1515,25 @@ mod preview_tests {
|
||||
assert!(!hidden.is_empty(), "the armour still draws");
|
||||
assert!(hidden.iter().all(|v| v.uv[0] >= 0.5), "only the armour");
|
||||
assert_eq!(hidden.len() * 2, shown.len());
|
||||
// AvatarRenderer.shouldRenderLayers: a spectator has no armour layer.
|
||||
let mut ghost = m.clone();
|
||||
ghost.extra.spectator = true;
|
||||
assert!(!horizon_client::mobs::render::layers_shown(&ghost));
|
||||
let mut with_armour = Vec::new();
|
||||
let mut without = Vec::new();
|
||||
for (armour, out) in [(&armour[..], &mut with_armour), (&[][..], &mut without)] {
|
||||
horizon_client::mobs::render::render_layers(
|
||||
&ghost,
|
||||
1.0,
|
||||
glam::DVec3::ZERO,
|
||||
&atlas,
|
||||
0xF0F0,
|
||||
&data,
|
||||
armour,
|
||||
out,
|
||||
);
|
||||
}
|
||||
assert!(without.is_empty() && with_armour.is_empty(), "no armour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -54,6 +54,24 @@ use std::{
|
||||
};
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
|
||||
type BuiltinCache = Vec<(std::path::PathBuf, Arc<Builtin>)>;
|
||||
static BUILTIN: std::sync::Mutex<BuiltinCache> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
/// The prepared resources' `registries.json`, parsed once per root: the
|
||||
/// file never changes while the game runs, and parsing it took about 3 ms
|
||||
/// of the frame that joins a server (or opens a single-player world).
|
||||
/// `None` when it is missing or invalid (not cached: a fixed install can
|
||||
/// join again).
|
||||
pub(super) fn builtin_registries(root: &std::path::Path) -> Option<Arc<Builtin>> {
|
||||
let mut cache = BUILTIN.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((_, builtin)) = cache.iter().find(|(cached, _)| cached == root) {
|
||||
return Some(builtin.clone());
|
||||
}
|
||||
let builtin = Arc::new(Builtin::find(root).and_then(|p| Builtin::load(&p).ok())?);
|
||||
cache.push((root.to_owned(), builtin.clone()));
|
||||
Some(builtin)
|
||||
}
|
||||
|
||||
/// `ChatType`'s two decorations: how a message shows in the chat, and what
|
||||
/// the narrator says of it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -223,13 +241,13 @@ struct Lerp {
|
||||
steps: u32,
|
||||
}
|
||||
|
||||
/// `NewMinecartBehavior.MinecartStep` (its xRot is not kept: a minecart here
|
||||
/// pitches along its rails).
|
||||
/// `NewMinecartBehavior.MinecartStep`, including the server's rail pitch.
|
||||
#[derive(Clone, Copy)]
|
||||
struct CartStep {
|
||||
position: Vec3,
|
||||
movement: Vec3,
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
weight: f32,
|
||||
}
|
||||
|
||||
@@ -255,15 +273,16 @@ impl CartLerp {
|
||||
position: Vec3::ZERO,
|
||||
movement: Vec3::ZERO,
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
weight: 0.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `NewMinecartBehavior.getCartLerpPosition`, `getCartLerpMovements` and
|
||||
/// `getCartLerpYRot` at `partial`: the step the weights put this moment
|
||||
/// in, and how far along it, between it and the one before.
|
||||
fn at(&self, partial: f32) -> (Vec3, Vec3, f32) {
|
||||
/// `getCartLerpYRot` / `getCartLerpXRot` at `partial`: the step the weights
|
||||
/// put this moment in, and how far along it from the one before.
|
||||
fn at(&self, partial: f32) -> (Vec3, Vec3, f32, f32) {
|
||||
let alpha = ((3 - self.delay) as f32 + partial) / 3.0;
|
||||
let mut count_up = 0.0f32;
|
||||
let mut in_step = 1.0f32;
|
||||
@@ -300,6 +319,7 @@ impl CartLerp {
|
||||
lerp(previous.position, current.position),
|
||||
lerp(previous.movement, current.movement),
|
||||
previous.yaw + in_step * wrap_degrees(current.yaw - previous.yaw),
|
||||
previous.pitch + in_step * wrap_degrees(current.pitch - previous.pitch),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -314,6 +334,7 @@ impl CartLerp {
|
||||
position: c.position,
|
||||
movement: c.velocity,
|
||||
yaw: c.yaw,
|
||||
pitch: c.pitch,
|
||||
weight: 0.0,
|
||||
};
|
||||
self.current.clear();
|
||||
@@ -326,10 +347,11 @@ impl CartLerp {
|
||||
if self.current.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let (position, movement, yaw) = self.at(1.0);
|
||||
let (position, movement, yaw, pitch) = self.at(1.0);
|
||||
c.position = position;
|
||||
c.velocity = movement;
|
||||
c.yaw = yaw;
|
||||
c.pitch = pitch;
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -427,6 +449,8 @@ enum OwnProfile {
|
||||
pub(super) struct Remote {
|
||||
pub session: Session,
|
||||
pub brand: String,
|
||||
/// This connection's configured feature flags, carried through reconfiguration.
|
||||
pub(super) features: Vec<String>,
|
||||
/// The server announced it runs mods' quiet commands.
|
||||
pub quiet_commands: bool,
|
||||
/// The server announced it carries mod messages.
|
||||
@@ -725,6 +749,15 @@ impl Game<'_> {
|
||||
None => horizon_client::health::hurt_sound("hurt"),
|
||||
}
|
||||
}
|
||||
/// Offline local profiles never borrow the account that started P1's game.
|
||||
pub(super) fn auth(&self) -> Option<std::sync::Arc<horizon_client::auth::AuthSession>> {
|
||||
if self.options.split_guest {
|
||||
None
|
||||
} else {
|
||||
horizon_client::auth::current()
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave the title for a server (ConnectScreen): an empty world fed by the connection.
|
||||
pub(super) fn connect_server(&mut self, address: &str, name: &str) -> Result<()> {
|
||||
self.connect_server_with_cookies(address, name, None)
|
||||
@@ -799,9 +832,7 @@ impl Game<'_> {
|
||||
// world's packed table must not name its ids.
|
||||
self.world_biomes = None;
|
||||
let root = self.options.resources.clone();
|
||||
let builtin = Builtin::find(&root)
|
||||
.and_then(|p| Builtin::load(&p).ok())
|
||||
.ok_or("Resources are incomplete: registries.json is missing or invalid. Click \"Prepare again\" in the launcher (or run horizon-prepare), then try again.")?;
|
||||
let builtin = builtin_registries(&root).ok_or("Resources are incomplete: registries.json is missing or invalid. Click \"Prepare again\" in the launcher (or run horizon-prepare), then try again.")?;
|
||||
let lang = Arc::new(horizon_client::net::load_language(&root));
|
||||
// Options.createInformation: what the server learns about this player,
|
||||
// sent again when one of those options changes.
|
||||
@@ -815,7 +846,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
// A signed-in account always plays under its own name.
|
||||
let auth = horizon_client::auth::current();
|
||||
let auth = self.auth();
|
||||
let name = auth.as_ref().map_or(name, |a| a.name.as_str());
|
||||
let user = auth.as_ref().map_or_else(
|
||||
|| {
|
||||
@@ -948,10 +979,11 @@ impl Game<'_> {
|
||||
self.remote = Some(Remote {
|
||||
session,
|
||||
brand: "unknown".into(),
|
||||
features: vec!["minecraft:vanilla".into()],
|
||||
quiet_commands: false,
|
||||
mod_net: false,
|
||||
far_terrain: None,
|
||||
builtin: Arc::new(builtin),
|
||||
builtin,
|
||||
status: lang
|
||||
.get("connect.connecting")
|
||||
.cloned()
|
||||
@@ -1789,6 +1821,11 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::Features(features) => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.features = features;
|
||||
}
|
||||
}
|
||||
Event::Brand(brand) => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.brand = brand;
|
||||
@@ -2683,10 +2720,14 @@ impl Game<'_> {
|
||||
if r.cart_lerps.contains_key(&id) {
|
||||
// A minecart on NewMinecartBehavior keeps Entity's NO_OP
|
||||
// InterpolationHandler: a teleport puts it there at once.
|
||||
if path.is_some()
|
||||
&& let Some(c) = self.carts.iter_mut().find(|c| c.id == id as u32)
|
||||
{
|
||||
c.position = at;
|
||||
if let Some(c) = self.carts.iter_mut().find(|c| c.id == id as u32) {
|
||||
if path.is_some() {
|
||||
c.position = at;
|
||||
}
|
||||
if let Some((yaw, pitch)) = rotation {
|
||||
c.yaw = yaw;
|
||||
c.pitch = pitch;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// AbstractInterpolationHandler.interpolateTo: without a
|
||||
@@ -3209,9 +3250,17 @@ impl Game<'_> {
|
||||
horizon_client::lan::playerdata::uuid_ints(p.uuid),
|
||||
);
|
||||
self.spawn_entity(p.id, &kind, p.position, rotation);
|
||||
// Entity.recreateFromPacket: setDeltaMovement(packet.getMovement()).
|
||||
self.remote_motion(p.id, p.velocity);
|
||||
if !self.spawn_remote_object(p.id, &kind, p.position, rotation[0], p.data) {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.cart_lerps.remove(&p.id);
|
||||
r.lerps.remove(&p.id);
|
||||
}
|
||||
if !self.spawn_remote_object(
|
||||
p.id,
|
||||
&kind,
|
||||
p.position,
|
||||
(rotation[0], rotation[1]),
|
||||
p.data,
|
||||
) {
|
||||
self.spawn_remote_projectile(
|
||||
p.id,
|
||||
&kind,
|
||||
@@ -3221,6 +3270,14 @@ impl Game<'_> {
|
||||
p.data,
|
||||
);
|
||||
}
|
||||
// Entity.recreateFromPacket: objects must exist before applying motion.
|
||||
self.remote_motion(p.id, p.velocity);
|
||||
if self.minecart_improvements()
|
||||
&& self.carts.iter().any(|c| c.id == p.id as u32)
|
||||
&& let Some(r) = self.remote.as_mut()
|
||||
{
|
||||
r.cart_lerps.insert(p.id, CartLerp::new());
|
||||
}
|
||||
self.move_remote_text_display(p.id, p.position, Some((rotation[0], rotation[1])));
|
||||
self.move_remote_interaction(p.id, p.position);
|
||||
}
|
||||
@@ -4911,7 +4968,10 @@ impl Game<'_> {
|
||||
};
|
||||
match &mut r.own_profile {
|
||||
OwnProfile::Idle => {
|
||||
let Some(auth) = horizon_client::auth::current() else {
|
||||
let Some(auth) = (!self.options.split_guest)
|
||||
.then(horizon_client::auth::current)
|
||||
.flatten()
|
||||
else {
|
||||
r.own_profile = OwnProfile::Done(None);
|
||||
return;
|
||||
};
|
||||
@@ -5280,6 +5340,8 @@ impl Game<'_> {
|
||||
t.velocity = v;
|
||||
} else if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id as u32) {
|
||||
o.velocity = v;
|
||||
} else if let Some(c) = self.carts.iter_mut().find(|c| c.id == id as u32) {
|
||||
c.velocity = v;
|
||||
} else if let Some(p) = self
|
||||
.creatures
|
||||
.projectiles
|
||||
@@ -6543,10 +6605,25 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AbstractMinecartRenderer.newExtractState`: sample the same weighted
|
||||
/// steps at the frame's partial tick, including corners within one tick.
|
||||
pub(super) fn remote_minecart_lerp(
|
||||
&self,
|
||||
id: u32,
|
||||
partial: f32,
|
||||
) -> Option<([f64; 3], f32, f32)> {
|
||||
let lerp = self.remote.as_ref()?.cart_lerps.get(&(id as i32))?;
|
||||
if lerp.current.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (p, _, yaw, pitch) = lerp.at(partial);
|
||||
Some(([p.x, p.y, p.z], yaw, pitch))
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleMinecartAlongTrack`: the steps join the ones
|
||||
/// the minecart has queued, if it runs `NewMinecartBehavior`.
|
||||
fn remote_move_minecart(&mut self, p: play::MoveMinecartAlongTrack) {
|
||||
if !self.carts.iter().any(|c| c.id == p.id as u32) {
|
||||
if !self.minecart_improvements() || !self.carts.iter().any(|c| c.id == p.id as u32) {
|
||||
return;
|
||||
}
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
@@ -6561,6 +6638,7 @@ impl Game<'_> {
|
||||
position: at(s.position),
|
||||
movement: at(s.movement),
|
||||
yaw: horizon_protocol::buf::unpack_degrees(s.yaw),
|
||||
pitch: horizon_protocol::buf::unpack_degrees(s.pitch),
|
||||
weight: s.weight,
|
||||
}));
|
||||
}
|
||||
@@ -7060,6 +7138,16 @@ impl Game<'_> {
|
||||
if passes {
|
||||
return false;
|
||||
}
|
||||
// Minecart.interact resets the passenger's easing baseline on
|
||||
// a successful boarding click, not on SetPassengers (commands
|
||||
// can mount a player without this interaction).
|
||||
if let Some(c) = self
|
||||
.carts
|
||||
.iter_mut()
|
||||
.find(|c| c.id as i32 == hit.id && c.kind == super::minecarts::CartKind::Rideable)
|
||||
{
|
||||
c.player_rotation_offset = c.rotation_offset;
|
||||
}
|
||||
// MinecartCommandBlock.interact on the client: the editor opens.
|
||||
if self.carts.iter().any(|c| {
|
||||
c.id as i32 == hit.id && c.kind == super::minecarts::CartKind::CommandBlock
|
||||
@@ -10045,6 +10133,11 @@ impl Remote {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// How far the server sends chunks: its view distance and the ring
|
||||
/// around (`ChunkTrackingView`), at least two.
|
||||
pub(super) fn sent_radius(&self) -> i32 {
|
||||
self.view_distance.max(2) + 1
|
||||
}
|
||||
/// A player entity's profile name: the one it was created with, else its
|
||||
/// player-list entry's (an entity the list named after it spawned).
|
||||
fn level_player_name(&self, id: i32, uuid: &u128) -> Option<&str> {
|
||||
|
||||
@@ -180,7 +180,7 @@ impl Game<'_> {
|
||||
/// (isLocalPlayer), and only when the key pair changed: a new session
|
||||
/// (random id, chain from link 0) the server hears of at once.
|
||||
fn update_chat_session(&mut self) {
|
||||
let Some(account) = auth::current() else {
|
||||
let Some(account) = self.auth() else {
|
||||
return;
|
||||
};
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
|
||||
@@ -20,9 +20,10 @@ impl Game<'_> {
|
||||
id: i32,
|
||||
kind: &str,
|
||||
position: [f64; 3],
|
||||
yaw: f32,
|
||||
rotation: (f32, f32),
|
||||
data: i32,
|
||||
) -> bool {
|
||||
let (yaw, pitch) = rotation;
|
||||
let id = id as u32;
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
let block = position.map(|v| v.floor() as i32);
|
||||
@@ -64,6 +65,8 @@ impl Game<'_> {
|
||||
let mut c = super::minecarts::Minecart::new(id, cart, at);
|
||||
c.yaw = yaw;
|
||||
c.previous_yaw = yaw;
|
||||
c.pitch = pitch;
|
||||
c.previous_pitch = pitch;
|
||||
self.carts.push(c);
|
||||
} else if let Some((wood, chest)) = super::boats::any_boat_wood(kind)
|
||||
&& self.boat_models.has(wood, chest)
|
||||
@@ -322,9 +325,11 @@ impl Game<'_> {
|
||||
for c in &mut self.carts {
|
||||
c.previous = c.position;
|
||||
c.previous_yaw = c.yaw;
|
||||
c.previous_pitch = c.pitch;
|
||||
if !lerp_cart(c) {
|
||||
c.position = step(c.id, c.position);
|
||||
}
|
||||
c.accumulate_rotation();
|
||||
if c.kind == super::minecarts::CartKind::Furnace
|
||||
&& c.fuel > 0
|
||||
&& self.world.random.next_int_bound(4).unwrap_or(1) == 0
|
||||
|
||||
@@ -16,8 +16,8 @@ use horizon_world::registry::BlockStateId;
|
||||
/// An `AreaEffectCloud` a server tracks: what its client tick reads.
|
||||
pub(super) struct RemoteCloud {
|
||||
id: u32,
|
||||
at: [f64; 3],
|
||||
radius: f32,
|
||||
pub(super) at: [f64; 3],
|
||||
pub(super) radius: f32,
|
||||
waiting: bool,
|
||||
particle: ParticleOptions,
|
||||
}
|
||||
@@ -611,15 +611,16 @@ impl Game<'_> {
|
||||
// fuse down (the swell and flash follow it) between the server's
|
||||
// updates (every 10 ticks), and a client discards it when the fuse
|
||||
// runs out; only the server explodes it.
|
||||
let (world, collision, assets, facts) = (
|
||||
let (world, collision, assets, facts, fluids) = (
|
||||
&self.world,
|
||||
self.collision,
|
||||
&self.pack.assets,
|
||||
self.res.facts(),
|
||||
&self.res.motion().fluids,
|
||||
);
|
||||
self.tnts.retain_mut(|t| {
|
||||
// A fluid lookup that fails leaves it where its move put it.
|
||||
let _ = t.tick(world, collision, assets, facts);
|
||||
let _ = t.tick(world, collision, assets, facts, fluids);
|
||||
t.fuse > 0
|
||||
});
|
||||
// FallingBlockEntity.tick runs on a client too: the block falls by
|
||||
|
||||
@@ -162,6 +162,9 @@ impl Game<'_> {
|
||||
if passengers.contains(&me) {
|
||||
if self.riding != Some(vehicle_id) {
|
||||
self.riding = Some(vehicle_id);
|
||||
if let Some(c) = self.carts.iter_mut().find(|c| c.id == vehicle_id) {
|
||||
c.passenger_yaw_delta = 0.0;
|
||||
}
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
@@ -187,6 +190,22 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Camera.alignWithEntity's NewMinecartBehavior branch: sample the
|
||||
/// vehicle's curved path at this frame, then apply the player's seat
|
||||
/// attachment. Camera's own eye-height interpolation still runs afterwards.
|
||||
pub(super) fn remote_minecart_rider_position(&self, partial: f32) -> Option<[f64; 3]> {
|
||||
if !self.minecart_improvements() {
|
||||
return None;
|
||||
}
|
||||
let id = self.riding?;
|
||||
self.carts
|
||||
.iter()
|
||||
.find(|c| c.id == id && c.kind == super::minecarts::CartKind::Rideable)?;
|
||||
let (mut position, _, _) = self.remote_minecart_lerp(id, partial)?;
|
||||
position[1] += super::minecarts::SEAT;
|
||||
Some(position)
|
||||
}
|
||||
|
||||
/// The local player's tick in its vehicle, in place of its own movement:
|
||||
/// false when it rides nothing.
|
||||
pub(super) fn remote_ride(&mut self, keys: Keys) -> bool {
|
||||
@@ -222,6 +241,17 @@ impl Game<'_> {
|
||||
// In front of a mob that shares the boat.
|
||||
Some(self.player_boat_seat(i))
|
||||
} else {
|
||||
// Minecart.positionRider runs on the client after the cart tick.
|
||||
// The offset belongs to the cart, so switching vehicles cannot
|
||||
// transfer the previous vehicle's pending turn.
|
||||
let rotate =
|
||||
self.minecart_improvements() && self.settings.file.bool("rotateWithMinecart");
|
||||
if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
|
||||
c.passenger_yaw_delta = 0.0;
|
||||
if rotate && c.kind == super::minecarts::CartKind::Rideable {
|
||||
c.rotate_passenger(&mut self.yaw);
|
||||
}
|
||||
}
|
||||
// A minecart: the server moves it, the rider sits in it.
|
||||
let cart = self.carts.iter().find(|c| c.id == id).map(|c| {
|
||||
c.position
|
||||
|
||||
@@ -264,6 +264,9 @@ impl Game<'_> {
|
||||
let hover = active && (ui::hovered(self.cursor, x, y, bw, 20.0) || is_focused);
|
||||
let label = match (id, &additions) {
|
||||
(14, Some((label, _))) => label.clone(),
|
||||
_ if self.options.split_guest && key == "menu.disconnect" => {
|
||||
Text::translate("horizon.split.leave", &[])
|
||||
}
|
||||
_ => Text::translate(key, &[]),
|
||||
};
|
||||
ui::button(batch, atlas, x, y, bw, 20.0, &label, active, hover);
|
||||
|
||||
@@ -223,6 +223,15 @@ impl Game<'_> {
|
||||
let folder = dimension_folder(&pack, dimension);
|
||||
let inputs = renderer.pack_inputs();
|
||||
let mut post = load_post(&pack, &folder, &properties, ¯os, &inputs);
|
||||
// Textures that name one of the game's: from the resource packs over
|
||||
// the game's own resources.
|
||||
let resources = self.options.resources.clone();
|
||||
let read = |namespace: &str, path: &str| {
|
||||
std::fs::read(resources.join("assets").join(namespace).join(path)).ok()
|
||||
};
|
||||
for note in horizon_shaders::resolve_resources(&mut post.textures, &read) {
|
||||
log::warn!("shader pack {name}: {note}");
|
||||
}
|
||||
let biomes = world_biomes!(self);
|
||||
let names: Vec<&str> =
|
||||
biomes.list.iter().map(|b| b.name.as_str()).collect();
|
||||
|
||||
@@ -49,6 +49,7 @@ use winit::{
|
||||
window::WindowId,
|
||||
};
|
||||
|
||||
mod frames;
|
||||
mod hud;
|
||||
mod pads;
|
||||
mod players;
|
||||
@@ -144,9 +145,17 @@ pub(super) struct Split<'a> {
|
||||
/// Whose game runs now (0 = P1), and P1's buttons while another's does.
|
||||
turn: usize,
|
||||
host_widgets: widgets::Saved,
|
||||
/// `HORIZON_SPLIT_STATS`: when the frame times were last printed.
|
||||
stats: Option<Instant>,
|
||||
/// For the readout: where a frame's wall time goes (the turns with their renders, presents,
|
||||
/// the GPU wait, outside `about_to_wait`), summed since the last print, and when this frame
|
||||
/// started.
|
||||
spent: [Duration; 4],
|
||||
spent_frames: u32,
|
||||
frame_started: Option<Instant>,
|
||||
/// The controllers while several play (`pads`), and P1's.
|
||||
pads: PadRouter,
|
||||
p1_pad: Option<PadId>,
|
||||
p1_pad: pads::P1Pad<PadId>,
|
||||
/// Pads whose buttons still count as the press that gave them: released first.
|
||||
fresh: Vec<PadId>,
|
||||
/// Controllers offered to join, each since when: several may join at once.
|
||||
@@ -191,8 +200,12 @@ impl<'a> Split<'a> {
|
||||
res: None,
|
||||
turn: 0,
|
||||
host_widgets: Default::default(),
|
||||
stats: std::env::var_os("HORIZON_SPLIT_STATS").map(|_| Instant::now()),
|
||||
spent: Default::default(),
|
||||
spent_frames: 0,
|
||||
frame_started: None,
|
||||
pads: Default::default(),
|
||||
p1_pad: None,
|
||||
p1_pad: Default::default(),
|
||||
fresh: Vec::new(),
|
||||
offers: Vec::new(),
|
||||
lost: Vec::new(),
|
||||
@@ -505,7 +518,10 @@ impl<'a> Split<'a> {
|
||||
for pad in pad_joins {
|
||||
match self.join(Some(pad), None) {
|
||||
Ok(()) => changed = true,
|
||||
Err(e) => log::warn!("Horizon: splitscreen: a player could not join: {e}"),
|
||||
Err(e) => {
|
||||
self.restore_given_up_pad(pad);
|
||||
log::warn!("Horizon: splitscreen: a player could not join: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.in_world() && self.test_players > 0 {
|
||||
@@ -530,7 +546,8 @@ impl<'a> Split<'a> {
|
||||
|
||||
/// Each window shows its players' frames, then the GPU wait, once, for all of them. Only
|
||||
/// the game's window waits for the display (vsync): windows each waiting would divide the rate.
|
||||
fn present(&mut self) {
|
||||
/// When the windows had presented (before the GPU wait), for the frame-time readout.
|
||||
fn present(&mut self) -> Instant {
|
||||
let views = self.views();
|
||||
let renderers: Vec<_> = std::iter::once(&self.host.renderer)
|
||||
.chain(self.guests.iter().map(|g| &g.game.renderer))
|
||||
@@ -547,6 +564,7 @@ impl<'a> Split<'a> {
|
||||
surface.compositor.set_vsync(vsync && k == paced);
|
||||
surface.compositor.present(&frames);
|
||||
}
|
||||
let presented = Instant::now();
|
||||
// One player's CPU work overlapped the GPU drawing another's.
|
||||
let renderers = std::iter::once(&mut self.host.renderer)
|
||||
.chain(self.guests.iter_mut().map(|g| &mut g.game.renderer));
|
||||
@@ -554,6 +572,7 @@ impl<'a> Split<'a> {
|
||||
renderer.paced_elsewhere = true;
|
||||
renderer.wait_in_flight();
|
||||
}
|
||||
presented
|
||||
}
|
||||
}
|
||||
|
||||
@@ -722,11 +741,11 @@ impl ApplicationHandler for Split<'_> {
|
||||
// output (rumble, triggers) through the split's reader for P1's turn.
|
||||
let pad_joins = self.route_pads(true);
|
||||
self.turn(0);
|
||||
if let Some(id) = self.p1_pad {
|
||||
if let Some(id) = self.p1_pad.owner {
|
||||
self.pads.lend_output(&mut self.host.pad, id);
|
||||
}
|
||||
self.host.about_to_wait(event_loop);
|
||||
if self.p1_pad.is_some() {
|
||||
if self.p1_pad.owner.is_some() {
|
||||
self.pads.reclaim_output(&mut self.host.pad);
|
||||
}
|
||||
if self.admit(event_loop, pad_joins) {
|
||||
@@ -734,6 +753,11 @@ impl ApplicationHandler for Split<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let started = Instant::now();
|
||||
if let Some(previous) = self.frame_started.replace(started) {
|
||||
// What the last frame left after its own work: events, the limiter, the OS.
|
||||
self.spent[3] += started.saturating_duration_since(previous);
|
||||
}
|
||||
let pad_joins = self.route_pads(false);
|
||||
self.turn(0);
|
||||
self.host.about_to_wait(event_loop);
|
||||
@@ -786,7 +810,19 @@ impl ApplicationHandler for Split<'_> {
|
||||
}
|
||||
}
|
||||
self.turn(0);
|
||||
self.present();
|
||||
let rendered = Instant::now();
|
||||
let presented = self.present();
|
||||
let waited = Instant::now();
|
||||
for (k, d) in [rendered - started, presented - rendered, waited - presented]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
self.spent[k] += d;
|
||||
}
|
||||
// The rest of the frame (the limiter, the OS) counts as outside, from `waited` on.
|
||||
self.spent[3] = self.spent[3].saturating_sub(waited - started);
|
||||
self.spent_frames += 1;
|
||||
self.print_stats();
|
||||
// One pace for all: the fastest any player wants, so an idle one (vanilla's AFK limit)
|
||||
// can't slow the others. With vsync the display paces anything at or above its refresh
|
||||
// rate; the limiter on top would miss refreshes.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! The frame-time readout (`HORIZON_SPLIT_STATS=1`, docs/extras.md). The players render one
|
||||
//! after another: on threads, their renders slowed each other down (the GPU driver's per-process
|
||||
//! state) more than they overlapped.
|
||||
use super::*;
|
||||
|
||||
impl<'a> Split<'a> {
|
||||
/// `HORIZON_SPLIT_STATS=1`: every 5 s, where a frame's time goes (the players' turns with
|
||||
/// their renders, the windows' presents, the GPU wait, outside the loop) and each player's
|
||||
/// frame rate and CPU and GPU time a frame, in the log.
|
||||
pub(super) fn print_stats(&mut self) {
|
||||
let Some(since) = self.stats else {
|
||||
return;
|
||||
};
|
||||
if since.elapsed() < Duration::from_secs(5) {
|
||||
return;
|
||||
}
|
||||
self.stats = Some(Instant::now());
|
||||
let seats: Vec<usize> = (0..self.players()).map(|i| self.seat_of(i)).collect();
|
||||
let games =
|
||||
std::iter::once(&mut self.host).chain(self.guests.iter_mut().map(|g| &mut *g.game));
|
||||
let frames = self.spent_frames.max(1);
|
||||
let [turns, presents, wait, outside] =
|
||||
std::mem::take(&mut self.spent).map(|d| d.as_secs_f64() * 1000.0 / f64::from(frames));
|
||||
self.spent_frames = 0;
|
||||
let mut line = format!(
|
||||
" a frame {:.2} ms: turns {turns:.2} (with the renders), presents {presents:.2}, gpu wait {wait:.2}, outside {outside:.2}.",
|
||||
turns + presents + wait + outside,
|
||||
);
|
||||
for (i, game) in games.enumerate() {
|
||||
let s = game.frame_stats.summary();
|
||||
let ms = |k: &str| s[k]["avg_ms"].as_f64().unwrap_or(0.0);
|
||||
line += &format!(
|
||||
" P{}: {:.0} fps, cpu {:.2} ms (render {:.2} ms), gpu {:.2} ms",
|
||||
seats[i],
|
||||
s["avg_fps"].as_f64().unwrap_or(0.0),
|
||||
ms("cpu"),
|
||||
s["phases_avg_ms"]["render"].as_f64().unwrap_or(0.0),
|
||||
ms("gpu"),
|
||||
);
|
||||
game.frame_stats.reset();
|
||||
}
|
||||
log::info!("Horizon: splitscreen frames:{line}");
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ fn panel(batch: &mut HudBatch, atlas: &horizon_client::hud::HudAtlas, [px, py, p
|
||||
pub(super) struct Prompt {
|
||||
pub(super) ask: Ask,
|
||||
pub(super) title: String,
|
||||
pub(super) choices: Vec<(Button, &'static str)>,
|
||||
pub(super) choices: Vec<(Button, String)>,
|
||||
}
|
||||
|
||||
/// What a prompt asks: a controller may join (every view, a strip at the top: nobody stops
|
||||
@@ -70,12 +70,20 @@ pub(super) enum Ask {
|
||||
Lost,
|
||||
}
|
||||
|
||||
/// A splitscreen prompt's text in the selected language (`horizon.splitscreen.prompt.*`).
|
||||
pub(super) fn tr(key: &str, args: &[&dyn std::fmt::Display]) -> String {
|
||||
horizon_client::lang::tr(&format!("horizon.splitscreen.prompt.{key}"), args)
|
||||
}
|
||||
|
||||
/// The question on a waiting player's view.
|
||||
pub(super) fn lost_prompt(name: &str) -> Prompt {
|
||||
Prompt {
|
||||
ask: Ask::Lost,
|
||||
title: format!("{name}: controller disconnected"),
|
||||
choices: vec![(Button::South, "Use another"), (Button::East, "Leave")],
|
||||
title: tr("lost", &[&name]),
|
||||
choices: vec![
|
||||
(Button::South, tr("lost.use_another", &[])),
|
||||
(Button::East, tr("lost.leave", &[])),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +127,7 @@ impl Game<'_> {
|
||||
let glyphs: Vec<_> = prompt
|
||||
.choices
|
||||
.iter()
|
||||
.map(|&(b, what)| (b.label(style), b.color(style), what))
|
||||
.map(|(b, what)| (b.label(style), b.color(style), what.as_str()))
|
||||
.collect();
|
||||
let line: f32 = glyphs
|
||||
.iter()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Controllers: which player each drives, joining with one, and one disconnecting (its player
|
||||
//! waits for another). One reader for every player's game (`PadRouter`) while splitscreen
|
||||
//! watches the controllers; otherwise P1 reads them itself, as without splitscreen.
|
||||
use super::hud::tr;
|
||||
use super::*;
|
||||
use crate::game::pad::{PadEvent, PadId};
|
||||
use horizon_client::gamepad::{Button, PadState};
|
||||
@@ -8,6 +9,57 @@ use horizon_client::gamepad::{Button, PadState};
|
||||
/// How long a controller's offer to join stays up.
|
||||
const OFFER: Duration = Duration::from_secs(10);
|
||||
|
||||
/// P1's controller, and one P1 gave away (Give Up Pad) while it's offered to join: until the
|
||||
/// other player is in the world, cancelling, declining or the offer running out gives that same
|
||||
/// pad back, never another player's.
|
||||
pub(super) struct P1Pad<Id> {
|
||||
pub(super) owner: Option<Id>,
|
||||
released: Option<Id>,
|
||||
}
|
||||
impl<Id> Default for P1Pad<Id> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
owner: None,
|
||||
released: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<Id: Copy + Eq> P1Pad<Id> {
|
||||
pub(super) fn claim(&mut self, id: Id) {
|
||||
self.owner = Some(id);
|
||||
if self.released == Some(id) {
|
||||
self.released = None;
|
||||
}
|
||||
}
|
||||
fn release(&mut self) -> Option<Id> {
|
||||
let id = self.owner.take()?;
|
||||
self.released = Some(id);
|
||||
Some(id)
|
||||
}
|
||||
fn cancel(&mut self, id: Id) -> bool {
|
||||
if self.released != Some(id) {
|
||||
return false;
|
||||
}
|
||||
self.released = None;
|
||||
if self.owner.is_some() {
|
||||
return false;
|
||||
}
|
||||
self.owner = Some(id);
|
||||
true
|
||||
}
|
||||
pub(super) fn joined(&mut self, id: Id) {
|
||||
if self.released == Some(id) {
|
||||
self.released = None;
|
||||
}
|
||||
}
|
||||
fn gone(&mut self, id: Id) {
|
||||
if self.owner == Some(id) {
|
||||
self.owner = None;
|
||||
}
|
||||
self.joined(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// A player whose controller disconnected, waiting for one: its seat (1 = P1), and the
|
||||
/// controller's id and kind (`PadRouter::uuid`) to know it back.
|
||||
pub(super) struct Lost<Id> {
|
||||
@@ -72,7 +124,7 @@ impl Split<'_> {
|
||||
self.offers.clear();
|
||||
self.fresh.clear();
|
||||
self.lost.clear();
|
||||
self.p1_pad = None;
|
||||
self.p1_pad = P1Pad::default();
|
||||
self.pads = PadRouter::default();
|
||||
}
|
||||
}
|
||||
@@ -98,7 +150,7 @@ impl Split<'_> {
|
||||
/// `solo`: no compositor, P1 alone (its controller's output is lent to it after this).
|
||||
pub(super) fn route_pads(&mut self, solo: bool) -> Vec<PadId> {
|
||||
let now = Instant::now();
|
||||
let owner_before = self.p1_pad;
|
||||
let owner_before = self.p1_pad.owner;
|
||||
let offers_before = self.offers.len();
|
||||
let mut joins = Vec::new();
|
||||
let mut presses = Vec::new();
|
||||
@@ -116,7 +168,7 @@ impl Split<'_> {
|
||||
}
|
||||
self.forget_lost();
|
||||
for (id, button) in presses {
|
||||
if Some(id) == self.p1_pad || self.guests.iter().any(|g| g.pad == Some(id)) {
|
||||
if Some(id) == self.p1_pad.owner || self.guests.iter().any(|g| g.pad == Some(id)) {
|
||||
continue;
|
||||
}
|
||||
// Pressed, then gone in the same frame.
|
||||
@@ -140,10 +192,10 @@ impl Split<'_> {
|
||||
in_world,
|
||||
p1_waits,
|
||||
self.host.settings.controller.enabled,
|
||||
self.p1_pad.is_some(),
|
||||
self.p1_pad.owner.is_some(),
|
||||
);
|
||||
if claims_p1 {
|
||||
self.p1_pad = Some(id);
|
||||
self.p1_pad.claim(id);
|
||||
self.fresh.push(id);
|
||||
continue;
|
||||
}
|
||||
@@ -158,14 +210,16 @@ impl Split<'_> {
|
||||
joins.push(id);
|
||||
}
|
||||
Button::North
|
||||
if self.p1_pad.is_none() && self.host.settings.controller.enabled =>
|
||||
if self.p1_pad.owner.is_none() && self.host.settings.controller.enabled =>
|
||||
{
|
||||
self.offers.remove(k);
|
||||
self.p1_pad = Some(id);
|
||||
self.p1_pad.claim(id);
|
||||
self.fresh.push(id);
|
||||
}
|
||||
// No: a pad P1 gave up goes back to P1.
|
||||
Button::East => {
|
||||
self.offers.remove(k);
|
||||
self.restore_given_up_pad(id);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
@@ -178,18 +232,28 @@ impl Split<'_> {
|
||||
}
|
||||
}
|
||||
let in_world = self.in_world();
|
||||
// An offer that ran out: a pad P1 gave up goes back to P1.
|
||||
let expired: Vec<_> = self
|
||||
.offers
|
||||
.iter()
|
||||
.filter(|(_, since)| now - *since > OFFER || !in_world)
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
for id in expired {
|
||||
self.restore_given_up_pad(id);
|
||||
}
|
||||
self.offers
|
||||
.retain(|(_, since)| now - *since <= OFFER && in_world);
|
||||
if self.offers.is_empty() {
|
||||
self.prompt_join(None);
|
||||
} else if self.offers.len() != offers_before
|
||||
|| self.p1_pad != owner_before
|
||||
|| self.p1_pad.owner != owner_before
|
||||
|| !joins.is_empty()
|
||||
{
|
||||
self.ask_to_join();
|
||||
}
|
||||
// Each player's glyphs follow its own controller (its game never reads the pads itself).
|
||||
if let Some(id) = self.p1_pad {
|
||||
if let Some(id) = self.p1_pad.owner {
|
||||
self.host.pad.style = self.pads.style(id);
|
||||
}
|
||||
for g in &mut self.guests {
|
||||
@@ -201,10 +265,10 @@ impl Split<'_> {
|
||||
// lends it its reader for P1's turn (`lend_output`), so no second reader opens. Only
|
||||
// when nothing is lent does P1's own reader close: releasing a lent one each frame
|
||||
// would stop its rumble a frame after it starts.
|
||||
if !lends_output(solo, self.p1_pad.is_some()) {
|
||||
if !lends_output(solo, self.p1_pad.owner.is_some()) {
|
||||
self.host.pad.release_hardware();
|
||||
}
|
||||
self.host.pad.feed = Some(self.pad_state(self.p1_pad));
|
||||
self.host.pad.feed = Some(self.pad_state(self.p1_pad.owner));
|
||||
for i in 0..self.guests.len() {
|
||||
let state = self.pad_state(self.guests[i].pad);
|
||||
self.guests[i].game.pad.feed = Some(state);
|
||||
@@ -212,6 +276,27 @@ impl Split<'_> {
|
||||
joins
|
||||
}
|
||||
|
||||
/// P1's Give Up Pad: its controller stops driving P1 and is offered to join, for a friend
|
||||
/// to press A (P1 plays on with the keyboard and mouse).
|
||||
pub(super) fn give_up_pad(&mut self) {
|
||||
let Some(id) = self.p1_pad.release() else {
|
||||
return;
|
||||
};
|
||||
self.offers.retain(|(o, _)| *o != id);
|
||||
self.offers.push((id, Instant::now()));
|
||||
self.host.pad.release_hardware();
|
||||
self.host.pad.feed = Some(PadState::default());
|
||||
self.ask_to_join();
|
||||
}
|
||||
|
||||
/// A pad P1 gave up whose offer ended without a player in the world: P1's again.
|
||||
pub(super) fn restore_given_up_pad(&mut self, id: PadId) {
|
||||
if self.pads.connected(id) && self.p1_pad.cancel(id) {
|
||||
// The Cancel/No press belongs to the transfer, not P1's menu.
|
||||
self.fresh.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// A controller disconnected: its offer and fresh press go; its player waits for one
|
||||
/// (P1 keeps the keyboard and mouse).
|
||||
fn pad_gone(&mut self, id: PadId) {
|
||||
@@ -222,13 +307,14 @@ impl Split<'_> {
|
||||
self.ask_to_join();
|
||||
}
|
||||
let uuid = self.pads.uuid(id);
|
||||
if self.p1_pad == Some(id) {
|
||||
self.p1_pad = None;
|
||||
let owned = self.p1_pad.owner == Some(id);
|
||||
self.p1_pad.gone(id);
|
||||
if owned {
|
||||
if self.in_world() {
|
||||
self.lost.push(Lost { seat: 1, id, uuid });
|
||||
self.host.split_hud.notice = Some(Notice::new(
|
||||
"Controller disconnected",
|
||||
"Reconnect it, or press a button on another",
|
||||
tr("controller_disconnected", &[]),
|
||||
tr("controller_disconnected.hint", &[]),
|
||||
));
|
||||
}
|
||||
return;
|
||||
@@ -254,7 +340,7 @@ impl Split<'_> {
|
||||
fn give_pad(&mut self, seat: usize, id: PadId) {
|
||||
self.fresh.push(id);
|
||||
let hud = if seat == 1 {
|
||||
self.p1_pad = Some(id);
|
||||
self.p1_pad.claim(id);
|
||||
&mut self.host.split_hud
|
||||
} else {
|
||||
let Some(g) = self.guests.iter_mut().find(|g| g.seat == seat) else {
|
||||
@@ -266,7 +352,7 @@ impl Split<'_> {
|
||||
if hud.prompt.as_ref().is_some_and(|p| p.ask == Ask::Lost) {
|
||||
hud.prompt = None;
|
||||
}
|
||||
hud.notice = Some(Notice::new("Controller connected", ""));
|
||||
hud.notice = Some(Notice::new(tr("controller_connected", &[]), ""));
|
||||
}
|
||||
|
||||
/// A button on a controller nobody has while players wait for one: true when it answered
|
||||
@@ -300,7 +386,7 @@ impl Split<'_> {
|
||||
/// Nobody waits for a player who left or has a controller again (another way), nor for P1
|
||||
/// out of a world.
|
||||
fn forget_lost(&mut self) {
|
||||
let p1 = self.in_world() && self.p1_pad.is_none();
|
||||
let p1 = self.in_world() && self.p1_pad.owner.is_none();
|
||||
let guests = &self.guests;
|
||||
self.lost.retain(|l| match l.seat {
|
||||
1 => p1,
|
||||
@@ -311,14 +397,14 @@ impl Split<'_> {
|
||||
/// Every player sees the controllers waiting to join, whatever it is doing.
|
||||
fn ask_to_join(&mut self) {
|
||||
let title = match self.offers.as_slice() {
|
||||
[(id, _)] => format!("{} wants to join", self.pads.name(*id)),
|
||||
offers => format!("{} controllers want to join", offers.len()),
|
||||
[(id, _)] => tr("join.one", &[&self.pads.name(*id)]),
|
||||
offers => tr("join.many", &[&offers.len()]),
|
||||
};
|
||||
let mut choices = vec![(Button::South, "Join")];
|
||||
if self.p1_pad.is_none() && self.host.settings.controller.enabled {
|
||||
choices.push((Button::North, "Play as P1"));
|
||||
let mut choices = vec![(Button::South, tr("join.yes", &[]))];
|
||||
if self.p1_pad.owner.is_none() && self.host.settings.controller.enabled {
|
||||
choices.push((Button::North, tr("join.as_p1", &[])));
|
||||
}
|
||||
choices.push((Button::East, "No"));
|
||||
choices.push((Button::East, tr("join.no", &[])));
|
||||
self.prompt_join(Some(Prompt {
|
||||
ask: Ask::Join,
|
||||
title,
|
||||
@@ -474,4 +560,34 @@ mod tests {
|
||||
let waiting = [lost(2, [0; 16])];
|
||||
assert_eq!(claim(&waiting, [0; 16], Button::North, true), Claim::Wait);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pending_transfer_can_be_canceled_without_claiming_an_unrelated_pad() {
|
||||
let mut p1 = P1Pad::default();
|
||||
p1.claim(7);
|
||||
assert_eq!(p1.release(), Some(7));
|
||||
assert_eq!(p1.owner, None);
|
||||
assert!(!p1.cancel(8));
|
||||
assert!(p1.cancel(7));
|
||||
assert_eq!(p1.owner, Some(7));
|
||||
assert!(!p1.cancel(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entering_the_world_commits_the_transfer_and_reclaim_does_not_steal_a_pad() {
|
||||
let mut p1 = P1Pad::default();
|
||||
p1.claim(7);
|
||||
p1.release();
|
||||
p1.joined(7);
|
||||
assert!(!p1.cancel(7));
|
||||
assert_eq!(p1.owner, None);
|
||||
p1.claim(7);
|
||||
p1.release();
|
||||
p1.claim(8);
|
||||
assert!(!p1.cancel(7));
|
||||
assert_eq!(p1.owner, Some(8));
|
||||
p1.release();
|
||||
p1.gone(8);
|
||||
assert!(!p1.cancel(8));
|
||||
}
|
||||
}
|
||||
@@ -350,6 +350,12 @@ impl Split<'_> {
|
||||
for k in (0..self.guests.len()).rev() {
|
||||
let g = &mut self.guests[k];
|
||||
g.entered |= g.game.remote.is_some() && g.game.player.is_some();
|
||||
// In the world: a pad P1 gave up is this player's for good.
|
||||
if g.entered
|
||||
&& let Some(id) = g.pad
|
||||
{
|
||||
self.p1_pad.joined(id);
|
||||
}
|
||||
let g = &self.guests[k];
|
||||
// A player picking a profile has no connection yet.
|
||||
let playing = g.name.is_none() || g.game.remote.is_some();
|
||||
@@ -384,6 +390,12 @@ impl Split<'_> {
|
||||
self.turn(0);
|
||||
}
|
||||
let guest = self.guests.remove(k);
|
||||
// Gone before it got into the world: a pad P1 gave up goes back to P1.
|
||||
if !guest.entered
|
||||
&& let Some(id) = guest.pad
|
||||
{
|
||||
self.restore_given_up_pad(id);
|
||||
}
|
||||
if self.focus > k + 1 {
|
||||
self.focus -= 1;
|
||||
}
|
||||
|
||||
@@ -130,7 +130,16 @@ impl<'a> Split<'a> {
|
||||
self.refresh_monitors();
|
||||
let live = self.draft();
|
||||
let (monitors, main) = self.setup_monitors();
|
||||
let names = (0..self.players()).map(|i| self.name(i)).collect();
|
||||
// A player whose controller disconnected says so on its tile.
|
||||
let names = (0..self.players())
|
||||
.map(|i| {
|
||||
let name = self.name(i);
|
||||
match self.lost.iter().any(|l| l.seat == self.seat_of(i)) {
|
||||
true => format!("{name} (no controller)"),
|
||||
false => name,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let seats: Vec<usize> = (0..self.players()).map(|i| self.seat_of(i)).collect();
|
||||
let moved = std::mem::replace(&mut self.setup_seats, seats) != self.setup_seats;
|
||||
let setup = &mut self.host.ui.options.split_setup;
|
||||
@@ -140,14 +149,21 @@ impl<'a> Split<'a> {
|
||||
}
|
||||
let applied = setup.apply.take();
|
||||
let identify = std::mem::take(&mut setup.identify);
|
||||
let give_up = std::mem::take(&mut setup.give_up_pad);
|
||||
if std::mem::take(&mut setup.toggle_names) {
|
||||
let e = &mut self.host.settings.extras;
|
||||
e.splitscreen_names = !e.splitscreen_names;
|
||||
self.host.settings.save(&Game::options_path());
|
||||
}
|
||||
let names_shown = self.host.settings.extras.splitscreen_names;
|
||||
if give_up {
|
||||
self.give_up_pad();
|
||||
}
|
||||
// Give Up Pad shows while P1 has a controller in a world others can join.
|
||||
let p1_pad = self.p1_pad.owner.is_some() && self.in_world();
|
||||
let setup = &mut self.host.ui.options.split_setup;
|
||||
setup.names_shown = names_shown;
|
||||
setup.p1_pad = p1_pad;
|
||||
setup.idle = self.surfaces.is_empty();
|
||||
setup.live = Some(live);
|
||||
setup.monitors = monitors;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Primed TNT (`PrimedTnt`): 80-tick fuse, gravity and drag, explosion of
|
||||
//! power 4, `TntRenderer` swell and white flash, and `TntBlock.useItemOn`
|
||||
//! (flint and steel / fire charge).
|
||||
use super::Game;
|
||||
use super::{Game, entity::Entity};
|
||||
use horizon_client::entity_rules::EntityRules;
|
||||
use horizon_client::{
|
||||
Result, collisions::SceneCollisions, items::ItemStack, mesher::Vertex, mobs::Who, text::Text,
|
||||
};
|
||||
@@ -48,11 +49,10 @@ impl PrimedTnt {
|
||||
collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>,
|
||||
assets: &horizon_client::assets::Assets,
|
||||
facts: &horizon_client::blocks::BlockFacts,
|
||||
fluids: &horizon_client::motion::FluidResources,
|
||||
) -> Result<Vec<BlockPos>> {
|
||||
self.previous = self.position;
|
||||
if !self.no_gravity {
|
||||
self.velocity.y -= 0.04;
|
||||
}
|
||||
self.velocity.y -= self.get_gravity();
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
@@ -132,27 +132,7 @@ impl PrimedTnt {
|
||||
self.fuse -= 1;
|
||||
if self.fuse > 0 {
|
||||
// PrimedTnt updates immersion after moving and decrementing its fuse.
|
||||
let mut fluids =
|
||||
horizon_world::entity_fluid::EntityFluidInteraction::new([WATER, LAVA]);
|
||||
let mut fluid_world = TntFluidWorld {
|
||||
tnt: self,
|
||||
world,
|
||||
assets,
|
||||
facts,
|
||||
};
|
||||
fluids
|
||||
.update(&mut fluid_world, false, 4096)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
fluids.apply_current(&mut fluid_world, WATER, 0.014)?;
|
||||
fluids.apply_current(
|
||||
&mut fluid_world,
|
||||
LAVA,
|
||||
if world.fast_lava {
|
||||
0.007
|
||||
} else {
|
||||
0.0023333333333333335
|
||||
},
|
||||
)?;
|
||||
self.push_by_fluid(world, fluids);
|
||||
}
|
||||
Ok(tops)
|
||||
}
|
||||
@@ -265,6 +245,7 @@ impl Game<'_> {
|
||||
self.collision,
|
||||
&self.pack.assets,
|
||||
self.res.facts(),
|
||||
&self.res.motion().fluids,
|
||||
)?;
|
||||
for pos in tops {
|
||||
self.bubble_column_particles(pos);
|
||||
@@ -469,148 +450,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
use horizon_world::{
|
||||
entity_fluid::{
|
||||
CurrentTarget, FluidInteractionWorld, FluidMembership, FluidSections, FluidTagId,
|
||||
},
|
||||
fluid::{FluidHeightWorld, FluidKind, FluidState},
|
||||
fluid_flow::{FluidFlowWorld, flow_at},
|
||||
};
|
||||
const WATER: FluidTagId = FluidTagId(0);
|
||||
const LAVA: FluidTagId = FluidTagId(1);
|
||||
struct TntFluidWorld<'a> {
|
||||
tnt: &'a mut PrimedTnt,
|
||||
world: &'a horizon_client::world::World,
|
||||
assets: &'a horizon_client::assets::Assets,
|
||||
facts: &'a horizon_client::blocks::BlockFacts,
|
||||
}
|
||||
impl TntFluidWorld<'_> {
|
||||
fn state(&self, p: BlockPos) -> horizon_world::registry::BlockStateId {
|
||||
self.world.block(p.x, p.y, p.z)
|
||||
}
|
||||
fn fluid_at(&self, p: BlockPos) -> std::io::Result<FluidState> {
|
||||
FluidState::from_block(
|
||||
self.assets
|
||||
.registry
|
||||
.state(self.state(p))
|
||||
.ok_or_else(|| std::io::Error::other("missing TNT fluid state"))?,
|
||||
)
|
||||
}
|
||||
}
|
||||
impl FluidMembership for TntFluidWorld<'_> {
|
||||
fn fluid_in_tag(&self, kind: FluidKind, tag: FluidTagId) -> bool {
|
||||
match tag {
|
||||
WATER => matches!(kind, FluidKind::Water | FluidKind::FlowingWater),
|
||||
LAVA => matches!(kind, FluidKind::Lava | FluidKind::FlowingLava),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl CurrentTarget for TntFluidWorld<'_> {
|
||||
type Error = std::io::Error;
|
||||
fn is_player_class(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn velocity(&mut self) -> std::io::Result<Vec3> {
|
||||
Ok(self.tnt.velocity)
|
||||
}
|
||||
fn add_delta_movement(&mut self, v: Vec3) -> std::io::Result<()> {
|
||||
self.tnt.velocity = Vec3::new(
|
||||
self.tnt.velocity.x + v.x,
|
||||
self.tnt.velocity.y + v.y,
|
||||
self.tnt.velocity.z + v.z,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl FluidInteractionWorld for TntFluidWorld<'_> {
|
||||
type Error = std::io::Error;
|
||||
type Chunk = (i32, i32);
|
||||
fn interaction_box(&mut self) -> std::io::Result<Option<Aabb>> {
|
||||
Ok(Some(
|
||||
self.tnt.bounds().deflate(Vec3::new(0.001, 0.001, 0.001)),
|
||||
))
|
||||
}
|
||||
fn loaded_chunk(&mut self, x: i32, z: i32) -> std::io::Result<Option<Self::Chunk>> {
|
||||
Ok(self.world.chunks.contains_key(&(x, z)).then_some((x, z)))
|
||||
}
|
||||
fn chunk_sections(&mut self, _: Self::Chunk) -> std::io::Result<FluidSections<'_>> {
|
||||
let layout = self.world.layout();
|
||||
Ok(FluidSections {
|
||||
minimum_section_y: layout.min_y() / 16,
|
||||
has_fluid: horizon_client::chunk::every_section(layout),
|
||||
})
|
||||
}
|
||||
fn bounding_box_min_y(&self) -> f64 {
|
||||
self.tnt.position.y
|
||||
}
|
||||
fn block_x(&self) -> i32 {
|
||||
self.tnt.position.x.floor() as i32
|
||||
}
|
||||
fn block_z(&self) -> i32 {
|
||||
self.tnt.position.z.floor() as i32
|
||||
}
|
||||
fn eye_y(&mut self) -> std::io::Result<f64> {
|
||||
Ok(self.tnt.position.y + f64::from(0.98f32 * 0.85f32))
|
||||
}
|
||||
fn fluid_state(&mut self, p: BlockPos) -> std::io::Result<FluidState> {
|
||||
self.fluid_at(p)
|
||||
}
|
||||
fn fluid_height(&mut self, s: FluidState, p: BlockPos) -> std::io::Result<f32> {
|
||||
s.height_at(self, [p.x, p.y, p.z])
|
||||
}
|
||||
fn camera_height(&mut self, s: FluidState, p: BlockPos) -> std::io::Result<f32> {
|
||||
s.camera_height_at(self, [p.x, p.y, p.z])
|
||||
}
|
||||
fn flow(&mut self, s: FluidState, p: BlockPos) -> std::io::Result<Vec3> {
|
||||
flow_at(self, s, p)
|
||||
}
|
||||
}
|
||||
impl FluidHeightWorld for TntFluidWorld<'_> {
|
||||
type Error = std::io::Error;
|
||||
fn fluid(&mut self, p: [i32; 3]) -> std::io::Result<FluidState> {
|
||||
self.fluid_at(BlockPos::new(p[0], p[1], p[2]))
|
||||
}
|
||||
fn sturdy_down(&mut self, p: [i32; 3]) -> std::io::Result<bool> {
|
||||
Ok(self
|
||||
.facts
|
||||
.get(self.state(BlockPos::new(p[0], p[1], p[2])))
|
||||
.sturdy_full
|
||||
& 1
|
||||
!= 0)
|
||||
}
|
||||
}
|
||||
impl FluidFlowWorld for TntFluidWorld<'_> {
|
||||
type Error = std::io::Error;
|
||||
type Block = horizon_world::registry::BlockStateId;
|
||||
fn fluid(&mut self, p: BlockPos) -> std::io::Result<FluidState> {
|
||||
self.fluid_at(p)
|
||||
}
|
||||
fn block(&mut self, p: BlockPos) -> std::io::Result<Self::Block> {
|
||||
Ok(self.state(p))
|
||||
}
|
||||
fn blocks_fluid_flow(&self, s: Self::Block) -> bool {
|
||||
self.assets
|
||||
.blocks_fluid_flow
|
||||
.contains(&self.assets.registry.state(s).unwrap().name)
|
||||
}
|
||||
fn is_ice_class(&self, s: Self::Block) -> bool {
|
||||
self.facts
|
||||
.get(s)
|
||||
.classes
|
||||
.iter()
|
||||
.any(|c| c.as_ref() == "IceBlock")
|
||||
}
|
||||
fn is_face_sturdy(
|
||||
&mut self,
|
||||
s: Self::Block,
|
||||
_: BlockPos,
|
||||
d: horizon_core::geometry::Direction,
|
||||
) -> std::io::Result<bool> {
|
||||
Ok(self.facts.get(s).sturdy_full & (1 << d as u8) != 0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `positionRider` for the mobs riding primed TNT: the passenger attachment
|
||||
/// (the top of its 0.98 box), less the rider's own vehicle attachment.
|
||||
@@ -713,6 +552,7 @@ mod tests {
|
||||
let assets = horizon_client::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = horizon_client::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = horizon_client::motion::FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = horizon_client::collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap();
|
||||
let stone = assets.registry.default_state("minecraft:stone").unwrap();
|
||||
@@ -740,7 +580,8 @@ mod tests {
|
||||
guest: None,
|
||||
power: 4.0,
|
||||
};
|
||||
tnt.tick(&world, &collision, &assets, &facts).unwrap();
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
tnt.position.x, at.x,
|
||||
"current is applied after motion, not before"
|
||||
@@ -751,13 +592,15 @@ mod tests {
|
||||
tnt.velocity
|
||||
);
|
||||
assert_eq!(tnt.fuse, 79);
|
||||
tnt.tick(&world, &collision, &assets, &facts).unwrap();
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
.unwrap();
|
||||
assert!((tnt.position.x - at.x - 0.014).abs() < 1e-12);
|
||||
assert!(tnt.on_ground);
|
||||
tnt.position = at;
|
||||
tnt.velocity = Vec3::ZERO;
|
||||
tnt.fuse = 1;
|
||||
tnt.tick(&world, &collision, &assets, &facts).unwrap();
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
.unwrap();
|
||||
assert_eq!(tnt.fuse, 0);
|
||||
assert_eq!(
|
||||
tnt.velocity.x, 0.0,
|
||||
@@ -767,7 +610,8 @@ mod tests {
|
||||
tnt.position = at;
|
||||
tnt.velocity = Vec3::ZERO;
|
||||
tnt.fuse = 80;
|
||||
tnt.tick(&world, &collision, &assets, &facts).unwrap();
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
tnt.velocity.x, 0.0,
|
||||
"still water must not add horizontal motion"
|
||||
@@ -782,7 +626,8 @@ mod tests {
|
||||
.unwrap();
|
||||
tnt.on_ground = false;
|
||||
tnt.velocity = Vec3::ZERO;
|
||||
tnt.tick(&world, &collision, &assets, &facts).unwrap();
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
.unwrap();
|
||||
assert!(
|
||||
tnt.position.y < at.y && tnt.velocity.y < 0.0,
|
||||
"TNT sinks; water does not add buoyancy"
|
||||
@@ -800,6 +645,7 @@ mod tests {
|
||||
let assets = horizon_client::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = horizon_client::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = horizon_client::motion::FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = horizon_client::collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap();
|
||||
let water = assets.registry.default_state("minecraft:water").unwrap();
|
||||
@@ -837,7 +683,8 @@ mod tests {
|
||||
power: 4.0,
|
||||
};
|
||||
for _ in 0..10 {
|
||||
tnt.tick(&world, &collision, &assets, &facts).unwrap();
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(
|
||||
tnt.velocity.y > 0.0,
|
||||
@@ -857,7 +704,8 @@ mod tests {
|
||||
tnt.velocity = Vec3::ZERO;
|
||||
tnt.on_ground = false;
|
||||
for _ in 0..4 {
|
||||
tnt.tick(&world, &collision, &assets, &facts).unwrap();
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(
|
||||
tnt.position.y < at.y,
|
||||
@@ -880,6 +728,7 @@ mod tests {
|
||||
let assets = horizon_client::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = horizon_client::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = horizon_client::motion::FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = horizon_client::collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap();
|
||||
let water = assets.registry.default_state("minecraft:water").unwrap();
|
||||
@@ -914,7 +763,10 @@ mod tests {
|
||||
let mut tops = Vec::new();
|
||||
let mut tnt = primed();
|
||||
for _ in 0..30 {
|
||||
tops.extend(tnt.tick(&world, &collision, &assets, &facts).unwrap());
|
||||
tops.extend(
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
assert!(!tops.is_empty(), "it reaches the top of the column");
|
||||
assert!(
|
||||
@@ -926,7 +778,7 @@ mod tests {
|
||||
let mut tnt = primed();
|
||||
for _ in 0..10 {
|
||||
assert!(
|
||||
tnt.tick(&world, &collision, &assets, &facts)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
@@ -177,8 +177,6 @@ impl WorldRequest {
|
||||
o.flat_settings(),
|
||||
o.single_biome(),
|
||||
)?;
|
||||
// WorldCreationUiState.getGameRules, written with the level.
|
||||
level.game_rules = o.rules.by_name();
|
||||
// The DataPackConfig the screen chose: the picked packs on,
|
||||
// everything else disabled — the feature packs left out, and the
|
||||
// world packs the Data Packs screen held back (`install` copies
|
||||
@@ -207,16 +205,13 @@ impl WorldRequest {
|
||||
// picked feature packs over vanilla's (`FeatureFlags.REGISTRY`'s
|
||||
// codec writes the whole set, `minecraft:vanilla` included, and
|
||||
// omits the tag when it is the default).
|
||||
let mut flags = vec!["minecraft:vanilla".to_owned()];
|
||||
flags.extend(
|
||||
packs
|
||||
.iter()
|
||||
.filter(|p| o.data_packs.contains(&p.id))
|
||||
.flat_map(|p| p.features()),
|
||||
);
|
||||
flags.sort();
|
||||
flags.dedup();
|
||||
level.enabled_features = flags;
|
||||
level.enabled_features = o.enabled_features(&options.resources);
|
||||
// CreateWorldScreen copies the edited rules with the final flags,
|
||||
// even when the player never opened the rules screen.
|
||||
level.game_rules = o
|
||||
.rules
|
||||
.copy_with_features(&level.enabled_features)
|
||||
.by_name();
|
||||
}
|
||||
// Worlds made without the Create New World screen (--world, the
|
||||
// control socket) take its default: commands on for Creative only.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use crate::{
|
||||
confirm_screen::Confirm,
|
||||
font::Font,
|
||||
gamerules::{self, GameRules, RULES, Rule, Value},
|
||||
gamerules::{self, GameRules, Rule, Value},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
lang,
|
||||
text::{Style, Text, named_color},
|
||||
@@ -153,8 +153,8 @@ impl RulesScreen {
|
||||
fn build(rules: &GameRules, in_world: bool) -> Self {
|
||||
// setInitialFocus: the search box.
|
||||
let search = EditBox::new(0.0, 0.0, SEARCH_WIDTH, 15.0);
|
||||
let numbers = RULES
|
||||
.iter()
|
||||
let numbers = rules
|
||||
.available_rules()
|
||||
.filter(|r| matches!(r.default, Value::Int(_)))
|
||||
.map(|r| {
|
||||
let mut b = EditBox::new(0.0, 0.0, 44.0, 20.0);
|
||||
@@ -181,7 +181,7 @@ impl RulesScreen {
|
||||
let filter = self.search.value.to_lowercase();
|
||||
let has = |s: &str| s.to_lowercase().contains(&filter);
|
||||
let mut categories: Vec<(&'static str, Vec<&'static Rule>)> = Vec::new();
|
||||
for r in RULES {
|
||||
for r in self.values.available_rules() {
|
||||
let key = name_key(r);
|
||||
let description = format!("{key}.description");
|
||||
let matches = has(r.name)
|
||||
@@ -238,8 +238,8 @@ impl RulesScreen {
|
||||
.any(|(name, b)| gamerules::rule(name).is_none_or(|r| parse(r, &b.value).is_none()))
|
||||
}
|
||||
fn changes(&self) -> Vec<(&'static str, Value)> {
|
||||
RULES
|
||||
.iter()
|
||||
self.values
|
||||
.available_rules()
|
||||
.filter_map(|r| {
|
||||
let value = self.values.get(r.name)?;
|
||||
(Some(value) != self.initial.get(r.name)).then_some((r.name, value))
|
||||
@@ -636,6 +636,7 @@ impl RulesScreen {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::gamerules::RULES;
|
||||
|
||||
#[test]
|
||||
fn a_switch_narrates_its_name_value_description_and_next_value() {
|
||||
|
||||
@@ -283,8 +283,34 @@ pub fn direction_vector(d: Direction) -> Vec3 {
|
||||
}
|
||||
/// `PoseStack.Pose.transformNormal`: the normal matrix, renormalized.
|
||||
pub fn transform_normal(m: Mat4, n: Vec3) -> Vec3 {
|
||||
let normal = Mat3::from_mat4(m).inverse().transpose();
|
||||
(normal * n).normalize_or_zero()
|
||||
FaceNormals::new(m).transform(n)
|
||||
}
|
||||
/// [`transform_normal`] of one pose for many quads: its normal matrix inverted once, and
|
||||
/// the normal of each of the six facings worked out the first time a quad uses it.
|
||||
pub struct FaceNormals {
|
||||
normal: Mat3,
|
||||
faces: [Option<Vec3>; 6],
|
||||
}
|
||||
impl FaceNormals {
|
||||
pub fn new(m: Mat4) -> Self {
|
||||
Self {
|
||||
normal: Mat3::from_mat4(m).inverse().transpose(),
|
||||
faces: [None; 6],
|
||||
}
|
||||
}
|
||||
fn transform(&self, n: Vec3) -> Vec3 {
|
||||
(self.normal * n).normalize_or_zero()
|
||||
}
|
||||
/// The pose's normal of a face pointing `d`.
|
||||
pub fn face(&mut self, d: Direction) -> Vec3 {
|
||||
let i = d as usize;
|
||||
if let Some(n) = self.faces[i] {
|
||||
return n;
|
||||
}
|
||||
let n = self.transform(direction_vector(d));
|
||||
self.faces[i] = Some(n);
|
||||
n
|
||||
}
|
||||
}
|
||||
/// An `ARGB` int as the `u32` colour of an [`ItemQuad`].
|
||||
pub fn argb(color: i32) -> u32 {
|
||||
@@ -307,6 +333,7 @@ fn submit_quads(
|
||||
FoilType::Special => Foil::Special,
|
||||
};
|
||||
let _ = display;
|
||||
let mut normals = FaceNormals::new(m);
|
||||
// Quads of one layer mostly share a sprite: look its rect and its
|
||||
// translucency up once.
|
||||
let mut last: Option<(&str, bool)> = None;
|
||||
@@ -363,7 +390,7 @@ fn submit_quads(
|
||||
pos,
|
||||
uv,
|
||||
foil_uv: q.uv,
|
||||
normal: transform_normal(m, direction_vector(q.facing)),
|
||||
normal: normals.face(q.facing),
|
||||
color: argb(color),
|
||||
foil,
|
||||
light_emission: q.light_emission,
|
||||
@@ -442,11 +469,12 @@ impl FileSprites {
|
||||
cache.insert(sprite.to_owned(), translucent);
|
||||
}
|
||||
let alpha: Vec<u8> = argb.iter().map(|p| (p >> 24) as u8).collect();
|
||||
let animation = fs::read_to_string(png.with_extension("png.mcmeta"))
|
||||
.ok()
|
||||
.and_then(|m| serde_json::from_str::<Value>(&m).ok())
|
||||
.and_then(|m| m.get("animation").map(Value::to_string))
|
||||
.and_then(|a| AnimationMetadata::from_json(&a).ok());
|
||||
let animation =
|
||||
horizon_core::prepared_files::read_to_string(&png.with_extension("png.mcmeta"))
|
||||
.ok()
|
||||
.and_then(|m| serde_json::from_str::<Value>(&m).ok())
|
||||
.and_then(|m| m.get("animation").map(Value::to_string))
|
||||
.and_then(|a| AnimationMetadata::from_json(&a).ok());
|
||||
let (w, h) = match &animation {
|
||||
Some(a) => {
|
||||
let [w, h] = a.frame_size(iw as i32, ih as i32);
|
||||
@@ -515,40 +543,28 @@ pub struct ItemRenderer {
|
||||
pub cit: Option<cit::CitModels>,
|
||||
}
|
||||
|
||||
/// Every `items/**/*.json` client item definition, by identifier.
|
||||
/// Every `items/**/*.json` client item definition, by identifier. Through
|
||||
/// `json_folder`: from the prepared data file when the root has one.
|
||||
fn load_definitions(root: &Path) -> BTreeMap<String, ClientItem> {
|
||||
fn walk(dir: &Path, rel: &str, namespace: &str, out: &mut BTreeMap<String, ClientItem>) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned()) else {
|
||||
continue;
|
||||
};
|
||||
let rel = if rel.is_empty() {
|
||||
name
|
||||
} else {
|
||||
format!("{rel}/{name}")
|
||||
};
|
||||
if path.is_dir() {
|
||||
walk(&path, &rel, namespace, out);
|
||||
} else if let Some(id) = rel.strip_suffix(".json")
|
||||
&& let Some(item) = fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|t| ClientItem::from_json(&t).ok())
|
||||
{
|
||||
out.insert(format!("{namespace}:{id}"), item);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = BTreeMap::new();
|
||||
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
|
||||
return out;
|
||||
};
|
||||
for ns in namespaces.flatten() {
|
||||
let Ok(folder) = horizon_world::json_folder::read(&ns.path().join("items")) else {
|
||||
continue;
|
||||
};
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
walk(&ns.path().join("items"), "", &namespace, &mut out);
|
||||
for file in &folder.files {
|
||||
if let Some(item) = file
|
||||
.bytes()
|
||||
.ok()
|
||||
.and_then(|b| std::str::from_utf8(b).ok())
|
||||
.and_then(|t| ClientItem::from_json(t).ok())
|
||||
{
|
||||
out.insert(format!("{namespace}:{}", file.name), item);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1148,4 +1164,41 @@ mod tests {
|
||||
let r = Mat4::from_rotation_y(std::f32::consts::FRAC_PI_2);
|
||||
assert!((transform_normal(r, Vec3::Z) - Vec3::X).length() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_normals_of_a_pose_are_its_inverse_transpose() {
|
||||
// A dropped item's pose: spun, squashed and moved. Each face's normal is the
|
||||
// inverse transpose of the pose's 3x3 applied to the face's axis, renormalized,
|
||||
// here worked out in f64 from the cofactors.
|
||||
let m = Mat4::from_translation(Vec3::new(3.0, -1.0, 7.5))
|
||||
* Mat4::from_rotation_y(0.7)
|
||||
* Mat4::from_rotation_x(-0.3)
|
||||
* Mat4::from_scale(Vec3::new(0.5, 0.25, 2.0));
|
||||
let c = m.to_cols_array();
|
||||
let a = |row: usize, col: usize| f64::from(c[col * 4 + row]);
|
||||
let cofactor = |row: usize, col: usize| {
|
||||
let (r0, r1) = ((row + 1) % 3, (row + 2) % 3);
|
||||
let (c0, c1) = ((col + 1) % 3, (col + 2) % 3);
|
||||
a(r0, c0) * a(r1, c1) - a(r0, c1) * a(r1, c0)
|
||||
};
|
||||
let mut normals = FaceNormals::new(m);
|
||||
// Twice each: a cached face gives what it gave the first time.
|
||||
for d in Direction::ALL.into_iter().chain(Direction::ALL) {
|
||||
let axis = direction_vector(d).to_array().map(f64::from);
|
||||
// inverse^T = cofactors / det: the det's sign is the only part that survives
|
||||
// renormalizing, and it is positive here.
|
||||
let n: Vec<f64> = (0..3)
|
||||
.map(|row| (0..3).map(|col| cofactor(row, col) * axis[col]).sum())
|
||||
.collect();
|
||||
let len = n.iter().map(|v| v * v).sum::<f64>().sqrt();
|
||||
let got = normals.face(d);
|
||||
for (g, e) in got.to_array().into_iter().zip(&n) {
|
||||
assert!(
|
||||
(f64::from(g) - e / len).abs() < 1e-6,
|
||||
"{d:?}: {got} vs {n:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(got, transform_normal(m, direction_vector(d)), "{d:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,20 @@ impl CommandTree {
|
||||
builtin: &Builtin,
|
||||
extra: &Value,
|
||||
) -> Commands {
|
||||
self.packet_with_features(level, dedicated, builtin, extra, &[])
|
||||
}
|
||||
|
||||
pub fn packet_with_features(
|
||||
&self,
|
||||
level: u8,
|
||||
dedicated: bool,
|
||||
builtin: &Builtin,
|
||||
extra: &Value,
|
||||
features: &[String],
|
||||
) -> Commands {
|
||||
let minecart_improvements = features
|
||||
.iter()
|
||||
.any(|f| f == crate::gamerules::MINECART_IMPROVEMENTS);
|
||||
let mut nodes = vec![Node {
|
||||
name: String::new(),
|
||||
kind: 0,
|
||||
@@ -123,6 +137,7 @@ impl CommandTree {
|
||||
&mut Vec::new(),
|
||||
level,
|
||||
dedicated,
|
||||
minecart_improvements,
|
||||
&suggestions,
|
||||
&mut nodes,
|
||||
&mut paths,
|
||||
@@ -183,6 +198,7 @@ fn fill(
|
||||
path: &mut Vec<String>,
|
||||
level: u8,
|
||||
dedicated: bool,
|
||||
minecart_improvements: bool,
|
||||
suggestions: &HashMap<&str, &'static str>,
|
||||
nodes: &mut Vec<Node>,
|
||||
paths: &mut HashMap<Vec<String>, usize>,
|
||||
@@ -215,6 +231,7 @@ fn fill(
|
||||
if required > level
|
||||
|| path.len() == 1
|
||||
&& path[0] == "gamerule"
|
||||
&& !minecart_improvements
|
||||
&& EXPERIMENTAL_RULES.contains(&name.as_str())
|
||||
{
|
||||
continue;
|
||||
@@ -255,6 +272,7 @@ fn fill(
|
||||
path,
|
||||
level,
|
||||
dedicated,
|
||||
minecart_improvements,
|
||||
suggestions,
|
||||
nodes,
|
||||
paths,
|
||||
|
||||
@@ -91,21 +91,16 @@ fn qualified(id: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `*.json` under `dir`, as (path without extension relative to dir, value), sorted.
|
||||
fn walk(dir: &Path, prefix: &str, out: &mut BTreeMap<String, Value>) {
|
||||
let Ok(read) = std::fs::read_dir(dir) else {
|
||||
/// Every `*.json` under `dir`, as (path without extension relative to dir, value), sorted;
|
||||
/// a file that can't be read or doesn't parse is left out. Through `json_folder`: from the
|
||||
/// prepared data file, and read once for the tables a load builds from the same folder.
|
||||
fn walk(dir: &Path, out: &mut BTreeMap<String, Value>) {
|
||||
let Ok(folder) = horizon_world::json_folder::read(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in read.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if path.is_dir() {
|
||||
walk(&path, &format!("{prefix}{name}/"), out);
|
||||
} else if let Some(stem) = name.strip_suffix(".json")
|
||||
&& let Ok(text) = std::fs::read_to_string(&path)
|
||||
&& let Ok(value) = serde_json::from_str::<Value>(&text)
|
||||
{
|
||||
out.insert(format!("{prefix}{stem}"), value);
|
||||
for file in &folder.files {
|
||||
if let Ok(value) = serde_json::from_slice::<Value>(file.bytes().unwrap_or_default()) {
|
||||
out.insert(file.name.clone(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,8 +178,7 @@ pub fn component_nbt(v: &Value) -> Tag {
|
||||
|
||||
impl SyncData {
|
||||
/// No registries, tags or recipes: enough for tests of the login phase.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn empty() -> Self {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
builtin: Builtin::default(),
|
||||
dynamic: Dynamic::default(),
|
||||
@@ -227,7 +221,7 @@ impl SyncData {
|
||||
for key in SYNCHRONIZED {
|
||||
let path = key.trim_start_matches("minecraft:");
|
||||
let mut found = BTreeMap::new();
|
||||
walk(&root.join(path), "", &mut found);
|
||||
walk(&root.join(path), &mut found);
|
||||
let mut entries: Vec<_> = found
|
||||
.into_iter()
|
||||
.map(|(p, v)| (("minecraft".to_owned(), p), v, false))
|
||||
@@ -390,7 +384,7 @@ impl SyncData {
|
||||
};
|
||||
let mut files: BTreeMap<String, Vec<Value>> = BTreeMap::new();
|
||||
let mut own = BTreeMap::new();
|
||||
walk(&root.join("tags/dialog"), "", &mut own);
|
||||
walk(&root.join("tags/dialog"), &mut own);
|
||||
for (name, value) in own {
|
||||
Self::add_tag_values(files.entry(qualified(&name)).or_default(), &value);
|
||||
}
|
||||
@@ -498,7 +492,7 @@ impl SyncData {
|
||||
continue;
|
||||
}
|
||||
let mut files = BTreeMap::new();
|
||||
walk(&dir, "", &mut files);
|
||||
walk(&dir, &mut files);
|
||||
if files.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ pub struct EntityState {
|
||||
pub head_yaw: f32,
|
||||
pub on_ground: bool,
|
||||
pub velocity: [f64; 3],
|
||||
/// NewMinecartBehavior steps drained from the simulation since its last
|
||||
/// export. None selects the ordinary entity movement packets.
|
||||
pub minecart_steps: Option<Vec<play::MinecartStep>>,
|
||||
/// ClientboundAddEntityPacket.data (item frame direction, block state ...).
|
||||
pub data: i32,
|
||||
/// SynchedEntityData entries that differ from the defaults.
|
||||
@@ -80,6 +83,9 @@ pub struct EntityState {
|
||||
/// `ServerPlayer.getCamera() != this`: a spectator watching through
|
||||
/// another entity is shown to nobody (`ServerPlayer.broadcastToPlayer`).
|
||||
pub watching: bool,
|
||||
/// A player a native mod hid (`native_hide_player`): only clients whose
|
||||
/// player has at least this permission level track it.
|
||||
pub hidden: Option<u8>,
|
||||
}
|
||||
|
||||
impl EntityState {
|
||||
@@ -95,6 +101,7 @@ impl EntityState {
|
||||
(self.yaw, self.pitch, self.head_yaw) = (0.0, 0.0, 0.0);
|
||||
self.on_ground = false;
|
||||
self.velocity = [0.0; 3];
|
||||
self.minecart_steps = None;
|
||||
self.data = 0;
|
||||
self.meta.clear();
|
||||
self.defaults.clear();
|
||||
@@ -107,6 +114,7 @@ impl EntityState {
|
||||
self.leash = None;
|
||||
self.spectator = false;
|
||||
self.watching = false;
|
||||
self.hidden = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +240,8 @@ struct ServerEntity {
|
||||
head: i8,
|
||||
/// lastSentMovement.
|
||||
velocity: [f64; 3],
|
||||
/// NewMinecartBehavior.lerpSteps accumulated across tracking intervals.
|
||||
minecart_steps: Vec<play::MinecartStep>,
|
||||
/// trackedDataValues: the entity data the clients hold.
|
||||
meta: Vec<(u8, i32, Value)>,
|
||||
equipment: Vec<(u8, Option<Stack>)>,
|
||||
@@ -259,6 +269,8 @@ struct ServerEntity {
|
||||
spectator: bool,
|
||||
/// Likewise for a spectator who watches through a camera.
|
||||
watching: bool,
|
||||
/// Likewise for a player a native mod hid.
|
||||
hidden: Option<u8>,
|
||||
}
|
||||
|
||||
/// SectionPos.of(entity).
|
||||
@@ -307,6 +319,7 @@ impl ServerEntity {
|
||||
rotation: (pack_degrees(e.pitch), pack_degrees(e.yaw)),
|
||||
head: pack_degrees(e.head_yaw),
|
||||
velocity: e.velocity,
|
||||
minecart_steps: e.minecart_steps.clone().unwrap_or_default(),
|
||||
meta: e.meta.clone(),
|
||||
equipment: e.equipment.clone(),
|
||||
attributes: e.attributes.clone(),
|
||||
@@ -322,6 +335,7 @@ impl ServerEntity {
|
||||
section: section_of(e.position),
|
||||
spectator: e.spectator,
|
||||
watching: e.watching,
|
||||
hidden: e.hidden,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,6 +398,11 @@ impl ServerEntity {
|
||||
/// full position sync every 400 or when the entity lands or leaves the
|
||||
/// ground), velocity for types that track it, changed data at once.
|
||||
fn send_changes(&mut self, e: &EntityState, riding: &Riding, out: &mut Vec<play::Clientbound>) {
|
||||
if let Some(steps) = &e.minecart_steps {
|
||||
self.minecart_steps.extend_from_slice(steps);
|
||||
} else {
|
||||
self.minecart_steps.clear();
|
||||
}
|
||||
let passengers = riding.passengers(e.id);
|
||||
if passengers != self.passengers.as_slice() {
|
||||
out.push(riding.set_passengers(e.id));
|
||||
@@ -428,6 +447,14 @@ impl ServerEntity {
|
||||
self.send_equipment(e, out);
|
||||
return;
|
||||
}
|
||||
if e.minecart_steps.is_some() {
|
||||
self.send_dirty(e, dirty, out);
|
||||
self.send_minecart(e, rotation, rotate, out);
|
||||
self.send_head(e, out);
|
||||
self.tick += 1;
|
||||
self.send_equipment(e, out);
|
||||
return;
|
||||
}
|
||||
self.teleport_delay += 1;
|
||||
let d: f64 = (0..3).map(|k| (e.position[k] - self.base[k]).powi(2)).sum();
|
||||
let send_position = self.arrow || d >= 7.629_394_5e-6 || self.tick.is_multiple_of(60);
|
||||
@@ -527,6 +554,49 @@ impl ServerEntity {
|
||||
self.send_equipment(e, out);
|
||||
}
|
||||
|
||||
/// ServerEntity.handleMinecartPosRot: send every recorded substep in order.
|
||||
/// Only an empty queue uses the fallback endpoint (e.g. a stationary cart
|
||||
/// teleported by a command); this never replaces a curved track's steps.
|
||||
fn send_minecart(
|
||||
&mut self,
|
||||
e: &EntityState,
|
||||
rotation: (i8, i8),
|
||||
rotate: bool,
|
||||
out: &mut Vec<play::Clientbound>,
|
||||
) {
|
||||
if self.minecart_steps.is_empty() {
|
||||
let distance: f64 = (0..3).map(|k| (e.position[k] - self.base[k]).powi(2)).sum();
|
||||
let motion: f64 = (0..3)
|
||||
.map(|k| (e.velocity[k] - self.velocity[k]).powi(2))
|
||||
.sum();
|
||||
if distance >= 7.629_394_531_25e-6
|
||||
|| self.tick.is_multiple_of(60)
|
||||
|| rotate
|
||||
|| motion > 1.0e-7
|
||||
{
|
||||
self.minecart_steps.push(play::MinecartStep {
|
||||
position: e.position,
|
||||
movement: e.velocity,
|
||||
yaw: rotation.1,
|
||||
pitch: rotation.0,
|
||||
weight: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
if !self.minecart_steps.is_empty() {
|
||||
out.push(play::Clientbound::MoveMinecartAlongTrack(
|
||||
play::MoveMinecartAlongTrack {
|
||||
id: e.id,
|
||||
steps: std::mem::take(&mut self.minecart_steps),
|
||||
},
|
||||
));
|
||||
}
|
||||
// Vanilla updates these even when no packet was needed; its
|
||||
// lastSentMovement is deliberately unchanged in this branch.
|
||||
self.rotation = rotation;
|
||||
self.base = e.position;
|
||||
}
|
||||
|
||||
/// ServerEntity.sendDirtyEntityData: the changed entity data, then the
|
||||
/// attribute instances that changed since they were last sent
|
||||
/// (`AttributeMap.getAttributesToSync`).
|
||||
@@ -691,6 +761,9 @@ pub struct Viewer<'a> {
|
||||
pub entity_id: i32,
|
||||
/// Its player is in spectator mode: it tracks the other spectators too.
|
||||
pub spectator: bool,
|
||||
/// Its player's permission level: players a native mod hid from lower
|
||||
/// levels are not tracked by it.
|
||||
pub permission: u8,
|
||||
/// It accepted a move since the last tick (ChunkMap.move): every pair
|
||||
/// of it is checked again.
|
||||
pub moved: bool,
|
||||
@@ -717,17 +790,20 @@ struct Probe {
|
||||
chunk: u32,
|
||||
spectator: bool,
|
||||
watching: bool,
|
||||
hidden: Option<u8>,
|
||||
}
|
||||
|
||||
/// `ServerPlayer.broadcastToPlayer` (`Entity`'s is `true`): to a spectator,
|
||||
/// a player is shown only while its camera is itself; to anyone else, a
|
||||
/// spectator is hidden.
|
||||
fn broadcast_to_player(viewer_spectator: bool, p: &Probe) -> bool {
|
||||
if viewer_spectator {
|
||||
/// spectator is hidden. A player a native mod hid is shown only to players
|
||||
/// of at least its level.
|
||||
fn broadcast_to_player(v: &Viewer, p: &Probe) -> bool {
|
||||
let shown = if v.spectator {
|
||||
!p.watching
|
||||
} else {
|
||||
!p.spectator
|
||||
}
|
||||
};
|
||||
shown && p.hidden.is_none_or(|level| v.permission >= level)
|
||||
}
|
||||
|
||||
/// TrackedEntity.updatePlayer: within the type's range (capped by the view
|
||||
@@ -740,7 +816,7 @@ fn broadcast_to_player(viewer_spectator: bool, p: &Probe) -> bool {
|
||||
fn sees(v: &Viewer, p: &Probe, chunks: &[(i32, i32)], held: &mut [u8]) -> bool {
|
||||
let range = f64::from(p.range.min(v.view_distance * 16));
|
||||
let (dx, dz) = (v.position[0] - p.x, v.position[2] - p.z);
|
||||
dx * dx + dz * dz <= range * range && broadcast_to_player(v.spectator, p) && {
|
||||
dx * dx + dz * dz <= range * range && broadcast_to_player(v, p) && {
|
||||
let held = &mut held[p.chunk as usize];
|
||||
if *held == 0 {
|
||||
*held = 1 + u8::from(v.chunks.contains(&chunks[p.chunk as usize]));
|
||||
@@ -840,10 +916,12 @@ impl Entities {
|
||||
|| section != s.section
|
||||
|| e.spectator != s.spectator
|
||||
|| e.watching != s.watching
|
||||
|| e.hidden != s.hidden
|
||||
{
|
||||
s.section = section;
|
||||
s.spectator = e.spectator;
|
||||
s.watching = e.watching;
|
||||
s.hidden = e.hidden;
|
||||
self.moved.push(index);
|
||||
self.is_moved[index] = true;
|
||||
}
|
||||
@@ -870,6 +948,7 @@ impl Entities {
|
||||
chunk,
|
||||
spectator: e.spectator,
|
||||
watching: e.watching,
|
||||
hidden: e.hidden,
|
||||
});
|
||||
}
|
||||
// TrackedEntity.getEffectiveRange: a vehicle is tracked as far as its
|
||||
@@ -1077,6 +1156,7 @@ mod tests {
|
||||
head_yaw: 0.0,
|
||||
on_ground: true,
|
||||
velocity: [0.0; 3],
|
||||
minecart_steps: None,
|
||||
data: 0,
|
||||
meta: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
@@ -1089,6 +1169,7 @@ mod tests {
|
||||
leash: None,
|
||||
spectator: false,
|
||||
watching: false,
|
||||
hidden: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1531,6 +1612,7 @@ mod tests {
|
||||
positions: [[f64; 3]; 2],
|
||||
moved: [bool; 2],
|
||||
spectators: [bool; 2],
|
||||
permissions: [u8; 2],
|
||||
/// The first client's entities gone, where they were last sent.
|
||||
gone: Vec<[f64; 3]>,
|
||||
}
|
||||
@@ -1544,6 +1626,7 @@ mod tests {
|
||||
positions: [[0.0, 64.0, 0.0]; 2],
|
||||
moved: [false; 2],
|
||||
spectators: [false; 2],
|
||||
permissions: [0; 2],
|
||||
gone: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1557,6 +1640,7 @@ mod tests {
|
||||
Viewer {
|
||||
entity_id: 1,
|
||||
spectator: self.spectators[0],
|
||||
permission: self.permissions[0],
|
||||
moved: self.moved[0],
|
||||
position: self.positions[0],
|
||||
view_distance: 10,
|
||||
@@ -1568,6 +1652,7 @@ mod tests {
|
||||
Viewer {
|
||||
entity_id: 2,
|
||||
spectator: self.spectators[1],
|
||||
permission: self.permissions[1],
|
||||
moved: self.moved[1],
|
||||
position: self.positions[1],
|
||||
view_distance: 10,
|
||||
@@ -1782,6 +1867,37 @@ mod tests {
|
||||
assert!(c.trackers[0].is_tracking(2));
|
||||
}
|
||||
|
||||
/// A player a native mod hid is tracked only by clients whose player has
|
||||
/// at least its level, and by everyone again once shown.
|
||||
#[test]
|
||||
fn a_hidden_player_is_tracked_only_from_its_level_up() {
|
||||
let mut level = Entities::default();
|
||||
let mut c = Clients::new();
|
||||
c.permissions = [0, 3];
|
||||
let mut op = state(0.0);
|
||||
(op.id, op.uuid, op.kind) = (9, 9, "minecraft:player".into());
|
||||
op.hidden = Some(3);
|
||||
c.tick(&mut level, &[&op]);
|
||||
c.tick(&mut level, &[&op]);
|
||||
assert!(
|
||||
!c.trackers[0].is_tracking(9),
|
||||
"a player of level 0 doesn't see it"
|
||||
);
|
||||
assert!(c.trackers[1].is_tracking(9), "an operator of level 3 does");
|
||||
// Hidden after it was seen: the client below the level loses it.
|
||||
op.hidden = None;
|
||||
c.tick(&mut level, &[&op]);
|
||||
assert!(c.trackers[0].is_tracking(9));
|
||||
op.hidden = Some(4);
|
||||
c.tick(&mut level, &[&op]);
|
||||
assert!(!c.trackers[0].is_tracking(9) && !c.trackers[1].is_tracking(9));
|
||||
// A client whose player was made an operator (its pairs checked again).
|
||||
c.permissions = [4, 3];
|
||||
c.moved = [true, false];
|
||||
c.tick(&mut level, &[&op]);
|
||||
assert!(c.trackers[0].is_tracking(9) && !c.trackers[1].is_tracking(9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairing_and_unpairing_are_kept_for_the_game() {
|
||||
let mut level = Entities::default();
|
||||
|
||||
@@ -184,6 +184,9 @@ pub struct Guest {
|
||||
/// It accepted a move since the last entity range checks
|
||||
/// (ServerChunkCache.move), which then check all of its pairs again.
|
||||
pub moved: bool,
|
||||
/// The players a native mod hid from it (`Hub::hidden`) that its player
|
||||
/// list no longer has.
|
||||
pub tab_hidden: crate::fast_hash::FxHashSet<u128>,
|
||||
/// The client finished loading terrain (ServerboundPlayerLoadedPacket).
|
||||
pub loaded: bool,
|
||||
/// Since it joined or respawned, the client hasn't said it loaded the level
|
||||
@@ -404,6 +407,7 @@ impl Guest {
|
||||
replay: None,
|
||||
changes: Default::default(),
|
||||
moved: false,
|
||||
tab_hidden: Default::default(),
|
||||
loaded: false,
|
||||
loading: true,
|
||||
load_timeout: CLIENT_LOADED_TIMEOUT,
|
||||
|
||||
@@ -234,6 +234,10 @@ pub struct Hub {
|
||||
/// The entities players started and stopped tracking, collected by
|
||||
/// `collect_tracking`, for the game to tell the mods.
|
||||
pub native_tracking: Vec<TrackingBatch>,
|
||||
/// Players a native mod hid (`native_hide_player`), by UUID, on or off:
|
||||
/// only players of at least that permission level see them, in the
|
||||
/// player list, the world and their joining and leaving.
|
||||
pub hidden: crate::fast_hash::FxHashMap<u128, u8>,
|
||||
pub lan: Lan,
|
||||
pub config: HubConfig,
|
||||
pub encoder: ChunkEncoder,
|
||||
@@ -576,6 +580,7 @@ impl Hub {
|
||||
mod_commands: (serde_json::Value::Null, None),
|
||||
native_menu_events: Vec::new(),
|
||||
native_tracking: Vec::new(),
|
||||
hidden: Default::default(),
|
||||
teams: Vec::new(),
|
||||
nicknames: std::collections::HashMap::new(),
|
||||
}
|
||||
@@ -846,6 +851,72 @@ impl Hub {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a player of `permission` sees the player `uuid`: not one a
|
||||
/// native mod hid from players below a higher level.
|
||||
pub fn sees(&self, permission: u8, uuid: u128) -> bool {
|
||||
self.hidden
|
||||
.get(&uuid)
|
||||
.is_none_or(|&level| permission >= level)
|
||||
}
|
||||
|
||||
/// The player lists after `hidden` or a permission changed: each placed
|
||||
/// player loses the players hidden from it (ClientboundPlayerInfoRemove)
|
||||
/// and gets back those it sees again (an ADD_PLAYER entry). A change
|
||||
/// checks the client's entity pairs again, so the tracker follows too.
|
||||
/// Nothing runs while nobody is hidden.
|
||||
fn sync_hidden(&mut self) {
|
||||
if self.hidden.is_empty() && self.guests.iter().all(|g| g.tab_hidden.is_empty()) {
|
||||
return;
|
||||
}
|
||||
let candidates: Vec<usize> = (0..self.guests.len())
|
||||
.filter(|&j| {
|
||||
let u = self.guests[j].uuid;
|
||||
self.guests[j].placed
|
||||
&& (self.hidden.contains_key(&u)
|
||||
|| self.guests.iter().any(|g| g.tab_hidden.contains(&u)))
|
||||
})
|
||||
.collect();
|
||||
for i in 0..self.guests.len() {
|
||||
if !self.guests[i].placed {
|
||||
continue;
|
||||
}
|
||||
for &j in &candidates {
|
||||
let (viewer, other) = (&self.guests[i], &self.guests[j]);
|
||||
if i == j {
|
||||
continue;
|
||||
}
|
||||
let hide = !self.sees(viewer.permission, other.uuid);
|
||||
if hide == viewer.tab_hidden.contains(&other.uuid) {
|
||||
continue;
|
||||
}
|
||||
let packet = if hide {
|
||||
C::PlayerInfoRemove(play::PlayerInfoRemove {
|
||||
uuids: vec![other.uuid],
|
||||
})
|
||||
} else {
|
||||
C::PlayerInfoUpdate(play::PlayerInfoUpdate {
|
||||
actions: Self::INFO_ACTIONS,
|
||||
entries: vec![self.player_info(
|
||||
other.uuid,
|
||||
&other.name,
|
||||
other.game_mode,
|
||||
other.latency.load(Ordering::Relaxed),
|
||||
)],
|
||||
})
|
||||
};
|
||||
let uuid = other.uuid;
|
||||
let viewer = &mut self.guests[i];
|
||||
viewer.send(&packet);
|
||||
if hide {
|
||||
viewer.tab_hidden.insert(uuid);
|
||||
} else {
|
||||
viewer.tab_hidden.remove(&uuid);
|
||||
}
|
||||
viewer.moved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn player_info(&self, uuid: u128, name: &str, mode: i32, latency: i32) -> play::PlayerInfo {
|
||||
let guest = self.guests.iter().find(|g| g.uuid == uuid);
|
||||
play::PlayerInfo {
|
||||
@@ -985,17 +1056,21 @@ impl Hub {
|
||||
self.send_permission_level(i);
|
||||
self.send_initial_recipe_book(i);
|
||||
// PlayerList.placeNewPlayer broadcasts the join line before
|
||||
// players.add: the others read it, the newcomer doesn't.
|
||||
self.broadcast_except(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: yellow_names(
|
||||
"multiplayer.player.joined",
|
||||
vec![display_name(self.guests[i].uuid, &name)],
|
||||
),
|
||||
overlay: false,
|
||||
}),
|
||||
);
|
||||
// players.add: the others read it, the newcomer doesn't (nor those
|
||||
// a native mod hides it from).
|
||||
let joined = C::SystemChat(play::SystemChat {
|
||||
content: yellow_names(
|
||||
"multiplayer.player.joined",
|
||||
vec![display_name(self.guests[i].uuid, &name)],
|
||||
),
|
||||
overlay: false,
|
||||
})
|
||||
.encode();
|
||||
for g in self.guests.iter().filter(|g| g.placed && g.conn != conn) {
|
||||
if self.sees(g.permission, uuid) {
|
||||
g.sender.send(joined.clone());
|
||||
}
|
||||
}
|
||||
self.guests[i].placed = true;
|
||||
self.resend_teleport(i, None);
|
||||
let g = &self.guests[i];
|
||||
@@ -1008,7 +1083,15 @@ impl Hub {
|
||||
if let Some(host) = ctx.host {
|
||||
entries.push(self.player_info(host.uuid, &host.name, host.game_mode, 0));
|
||||
}
|
||||
// Players a native mod hid from the newcomer stay out of its list,
|
||||
// and a hidden newcomer out of theirs.
|
||||
let permission = self.guests[i].permission;
|
||||
let mut hidden = Vec::new();
|
||||
for o in self.guests.iter().filter(|o| o.conn != conn && o.placed) {
|
||||
if !self.sees(permission, o.uuid) {
|
||||
hidden.push(o.uuid);
|
||||
continue;
|
||||
}
|
||||
entries.push(self.player_info(
|
||||
o.uuid,
|
||||
&o.name,
|
||||
@@ -1016,15 +1099,24 @@ impl Hub {
|
||||
o.latency.load(Ordering::Relaxed),
|
||||
));
|
||||
}
|
||||
self.guests[i].tab_hidden.extend(hidden);
|
||||
self.guests[i].send(&C::PlayerInfoUpdate(play::PlayerInfoUpdate {
|
||||
actions: Self::INFO_ACTIONS,
|
||||
entries,
|
||||
}));
|
||||
let newcomer = play::PlayerInfoUpdate {
|
||||
let newcomer = C::PlayerInfoUpdate(play::PlayerInfoUpdate {
|
||||
actions: Self::INFO_ACTIONS,
|
||||
entries: vec![self.player_info(uuid, &name, mode, 0)],
|
||||
};
|
||||
self.broadcast(&C::PlayerInfoUpdate(newcomer));
|
||||
})
|
||||
.encode();
|
||||
let level = self.hidden.get(&uuid).copied();
|
||||
for g in self.guests.iter_mut().filter(|g| g.placed) {
|
||||
if level.is_some_and(|l| g.permission < l) {
|
||||
g.tab_hidden.insert(uuid);
|
||||
} else {
|
||||
g.sender.send(newcomer.clone());
|
||||
}
|
||||
}
|
||||
self.send_level_info(i, ctx, true);
|
||||
// ServerLevel.addNewPlayer: the chunk tracking starts at the player.
|
||||
let g = &mut self.guests[i];
|
||||
@@ -1251,11 +1343,12 @@ impl Hub {
|
||||
match &self.sync.commands {
|
||||
Some(tree) => {
|
||||
let dedicated = self.config.dedicated && !self.config.integrated;
|
||||
tree.packet_with(
|
||||
tree.packet_with_features(
|
||||
permission,
|
||||
dedicated,
|
||||
&self.sync.builtin,
|
||||
&self.mod_commands.0,
|
||||
&self.sync.features,
|
||||
)
|
||||
}
|
||||
None => play::Commands {
|
||||
@@ -1600,6 +1693,7 @@ impl Hub {
|
||||
}
|
||||
}
|
||||
self.sync_containers(ctx.world);
|
||||
self.sync_hidden();
|
||||
self.track_entities(ctx);
|
||||
for i in 0..self.guests.len() {
|
||||
if self.here(&self.guests[i]) {
|
||||
@@ -1674,10 +1768,20 @@ impl Hub {
|
||||
self.broadcast(&C::RemoveEntities(play::RemoveEntities {
|
||||
ids: vec![g.entity_id],
|
||||
}));
|
||||
self.system_message(yellow_names(
|
||||
"multiplayer.player.left",
|
||||
vec![display_name(g.uuid, &g.name)],
|
||||
));
|
||||
let left = C::SystemChat(play::SystemChat {
|
||||
content: yellow_names(
|
||||
"multiplayer.player.left",
|
||||
vec![display_name(g.uuid, &g.name)],
|
||||
),
|
||||
overlay: false,
|
||||
})
|
||||
.encode();
|
||||
for o in self.guests.iter_mut().filter(|o| o.placed) {
|
||||
if o.tab_hidden.remove(&g.uuid) {
|
||||
continue;
|
||||
}
|
||||
o.sender.send(left.clone());
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
@@ -2326,9 +2430,11 @@ impl Hub {
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
needs_sync: false,
|
||||
minecart_steps: None,
|
||||
leash: None,
|
||||
spectator: false,
|
||||
watching: false,
|
||||
hidden: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2424,6 +2530,7 @@ impl Hub {
|
||||
);
|
||||
state.spectator = g.spectator();
|
||||
state.watching = g.watching;
|
||||
state.hidden = self.hidden.get(&g.uuid).copied();
|
||||
stuck_meta(&mut state, g.stuck);
|
||||
shoulder_meta(&mut state, g.shoulders);
|
||||
state
|
||||
@@ -2452,6 +2559,7 @@ impl Hub {
|
||||
.map(|(g, (out, gone))| Viewer {
|
||||
entity_id: g.entity_id,
|
||||
spectator: g.spectator(),
|
||||
permission: g.permission,
|
||||
moved: std::mem::take(&mut g.moved),
|
||||
position: g.position,
|
||||
view_distance: g.view_distance,
|
||||
|
||||
@@ -99,7 +99,8 @@ pub mod warden_brain;
|
||||
pub mod witch;
|
||||
pub mod wither;
|
||||
mod world;
|
||||
pub use world::visual_clip;
|
||||
pub use world::{TargetInfo, visual_clip};
|
||||
pub mod dragon_contacts;
|
||||
pub mod zombie_villager;
|
||||
|
||||
use crate::{blocks::BlockFacts, mesher::Vertex, world::World};
|
||||
@@ -1211,6 +1212,11 @@ pub struct Mob {
|
||||
/// The packs' random texture variants it wears (`random_textures::choose`).
|
||||
pub random_textures: random_textures::Choice,
|
||||
}
|
||||
impl crate::entity_rules::EntityRules for Mob {
|
||||
fn is_no_gravity(&self) -> bool {
|
||||
self.no_gravity
|
||||
}
|
||||
}
|
||||
/// A mannequin's `description` and `hide_description` (`Mannequin.updateDescription`):
|
||||
/// the default `entity.minecraft.mannequin.label`, none at all, or a component.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -1283,12 +1289,28 @@ pub enum MobEvent {
|
||||
speed: f64,
|
||||
count: u32,
|
||||
},
|
||||
/// `ServerLevel.sendParticles` of an `ItemParticleOption`: `count` crumbs of
|
||||
/// `item`, drawn in this window.
|
||||
ItemParticles {
|
||||
item: &'static str,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
count: u32,
|
||||
},
|
||||
Sound(&'static str, [f64; 3], f32, f32),
|
||||
/// `Entity.sendBubbleColumnParticles`: a mob at the top of the bubble column
|
||||
/// block at `pos` (the splash and bubble particles over it).
|
||||
BubbleColumnParticles {
|
||||
pos: [i32; 3],
|
||||
},
|
||||
/// `LivingEntity.checkFallDamage`: `count` crumbs of the block `state` a mob
|
||||
/// landed on, thrown up at `at`.
|
||||
LandingDust {
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
at: [f64; 3],
|
||||
count: i32,
|
||||
},
|
||||
/// `UseBonemeal`: the villager's bone meal grows the crop at `pos`
|
||||
/// (`BoneMealItem.growCrop`, then level event 1505).
|
||||
Bonemeal {
|
||||
@@ -2433,6 +2455,8 @@ pub struct MobWorld<'a> {
|
||||
/// player in the level stands, alive or not (the despawn distance).
|
||||
pub nearest_player: Option<Vec3>,
|
||||
pub others: &'a [Seen],
|
||||
/// The separately simulated dragon, visible to revenge and melee goals.
|
||||
pub dragon: Option<(u32, TargetInfo)>,
|
||||
/// The boats and rafts a lead can hang on (`Holder::Boat`): id and state.
|
||||
pub leash_boats: &'a [(u32, leash::HolderState)],
|
||||
/// `others` by position, when the caller keeps one (the mob ticks).
|
||||
@@ -3038,6 +3062,14 @@ impl Mob {
|
||||
if crack(before) != crack(after) {
|
||||
self.outbox
|
||||
.push(MobEvent::Sound("item.wolf_armor.crack", at, 1.0, 1.0));
|
||||
// Twenty scutes fly off, a block above the wolf's feet.
|
||||
self.outbox.push(MobEvent::ItemParticles {
|
||||
item: "minecraft:armadillo_scute",
|
||||
at: [at[0], at[1] + 1.0, at[2]],
|
||||
spread: [0.2, 0.1, 0.2],
|
||||
speed: 0.1,
|
||||
count: 20,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1504,6 +1504,15 @@ fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
if m.species() == Species::Warden {
|
||||
super::warden::do_push(m, w, who, events);
|
||||
}
|
||||
// IronGolem.doPush: a monster that is not a creeper becomes its target
|
||||
// one time in 20, and the draw is made for those alone, then the push.
|
||||
if m.species() == Species::IronGolem
|
||||
&& let Who::Mob(id) = who
|
||||
&& w.seen(id).is_some_and(super::golem::golem_prey)
|
||||
&& m.random.next_int_bound(20).unwrap_or(1) == 0
|
||||
{
|
||||
m.ai.target = Some(who);
|
||||
}
|
||||
// Entity.push: passengers of the same vehicle (a rider and its mount
|
||||
// among them) never push each other.
|
||||
if same {
|
||||
@@ -2743,8 +2752,12 @@ pub fn intern_sound(s: &std::sync::Arc<str>) -> &'static str {
|
||||
set.insert(leaked);
|
||||
leaked
|
||||
}
|
||||
/// `getSwimSound`.
|
||||
pub fn swim_sound(m: &Mob) -> &'static str {
|
||||
/// `getSwimSound`. A skeleton horse's own override counts the steps it takes
|
||||
/// through the water, so it needs the mob, not just its species.
|
||||
pub fn swim_sound(m: &mut Mob) -> &'static str {
|
||||
if m.species() == Species::SkeletonHorse {
|
||||
return super::equine::skeleton_horse_swim_sound(m);
|
||||
}
|
||||
match m.species() {
|
||||
Species::Drowned => "entity.drowned.swim",
|
||||
Species::Axolotl => "entity.axolotl.swim",
|
||||
@@ -3631,3 +3644,59 @@ mod silverfish_walk_target_tests {
|
||||
let _ = ran;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod iron_golem_push_tests {
|
||||
use super::*;
|
||||
use crate::mobs::world::with_test_world;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn mob(id: u32, species: Species, category: &str) -> Mob {
|
||||
let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
|
||||
kind.species = species;
|
||||
kind.category = category.into();
|
||||
Mob::new(id, Arc::new(kind), Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
/// `IronGolem.doPush`: a monster that is not a creeper and bumps the golem
|
||||
/// becomes its target one time in 20, and the draw is made for those alone,
|
||||
/// so a creeper or an animal leaves the golem's random alone.
|
||||
#[test]
|
||||
fn an_iron_golem_targets_the_monster_that_bumps_it() {
|
||||
// CI has no prepared 26.3 data, so a world that does not build skips
|
||||
// the whole check rather than asserting the data exists.
|
||||
let hits = |victim: Mob| -> Option<u32> {
|
||||
let seen = victim.seen(None);
|
||||
let mut hits = 0;
|
||||
let ran = with_test_world(&[seen], |w, _| {
|
||||
let mut golem = mob(1, Species::IronGolem, "monster");
|
||||
for _ in 0..400 {
|
||||
let mut events = Vec::new();
|
||||
push_entities(&mut golem, w, &mut events);
|
||||
if golem.ai.target.is_some() {
|
||||
hits += 1;
|
||||
golem.ai.target = None;
|
||||
}
|
||||
}
|
||||
});
|
||||
ran.then_some(hits)
|
||||
};
|
||||
let Some(zombie) = hits(mob(2, Species::Zombie, "monster")) else {
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
zombie > 0,
|
||||
"a zombie that bumps the golem is taken as its target"
|
||||
);
|
||||
assert_eq!(
|
||||
hits(mob(3, Species::Creeper, "monster")),
|
||||
Some(0),
|
||||
"IronGolem.doPush never targets a creeper"
|
||||
);
|
||||
assert_eq!(
|
||||
hits(mob(4, Species::Cow, "creature")),
|
||||
Some(0),
|
||||
"a pushed animal is not an Enemy"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -410,6 +410,13 @@ impl Mob {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Goat.setYHeadRot: the head is clamped to `getMaxHeadYRot` (15
|
||||
// degrees) from the body on every set, so a standing goat cannot
|
||||
// swivel it further than a walking one.
|
||||
if self.species() == Species::Goat {
|
||||
self.head_yaw =
|
||||
rotate_if_necessary(self.head_yaw, self.body_yaw, self.kind.max_head_y_rot);
|
||||
}
|
||||
}
|
||||
fn look_angles(&self, at: [f64; 3]) -> (Option<f32>, Option<f32>) {
|
||||
let (xd, yd, zd) = (
|
||||
@@ -467,3 +474,46 @@ impl Mob {
|
||||
fn rotate_toward_max(from: f32, to: f32, max: f32) -> f32 {
|
||||
from + wrap(to - from).clamp(-max, max)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod goat_head_tests {
|
||||
use super::*;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn mob(species: Species) -> Mob {
|
||||
let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
|
||||
kind.species = species;
|
||||
// Mob.getMaxHeadYRot: 15 for a goat (Goat.getMaxHeadYRot), 75 otherwise.
|
||||
kind.max_head_y_rot = if species == Species::Goat { 15.0 } else { 75.0 };
|
||||
Mob::new(1, Arc::new(kind), Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
/// `Goat.setYHeadRot`: whatever the look control asks for, a goat's head
|
||||
/// stays within `getMaxHeadYRot` (15 degrees) of its body, standing still
|
||||
/// or walking; another mob turns as far as it likes.
|
||||
#[test]
|
||||
fn a_goats_head_stays_within_fifteen_degrees_of_its_body() {
|
||||
let turned = |species: Species| {
|
||||
let mut m = mob(species);
|
||||
m.body_yaw = 0.0;
|
||||
// A target 40 blocks to the side: a quarter turn away.
|
||||
m.ai.look.kind = LookKind::Default;
|
||||
m.ai.look.wanted = [40.0, 1.0, 0.0];
|
||||
m.ai.look.y_max_rot_speed = 1000.0;
|
||||
m.ai.look.x_max_rot_angle = 40.0;
|
||||
m.ai.look.cooldown = 20;
|
||||
m.look_control_tick();
|
||||
wrap(m.head_yaw - m.body_yaw)
|
||||
};
|
||||
let goat = turned(Species::Goat);
|
||||
assert!(
|
||||
goat.abs() <= 15.0 + 1.0e-3,
|
||||
"the goat's head turned {goat} degrees from its body"
|
||||
);
|
||||
assert!(
|
||||
turned(Species::Cow).abs() > 15.0,
|
||||
"a cow is not held to the goat's 15 degrees"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! `EnderDragon.knockBack` and `hurt` for living mobs.
|
||||
//! The dragon lives outside the normal mob array; its contacts must still reach them.
|
||||
// Contact rules compared with the installed vanilla Java 26.3 EnderDragon.
|
||||
use crate::mobs::{Damage, DamageKind, Mob, Who};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
pub struct Frame {
|
||||
pub dragon_id: u32,
|
||||
pub origin: Vec3,
|
||||
pub center: (f64, f64),
|
||||
pub wings: [Aabb; 2],
|
||||
pub heads: [Aabb; 2],
|
||||
pub sitting: bool,
|
||||
pub recently_hurt: bool,
|
||||
}
|
||||
|
||||
/// Wings push even while perched and even when teams forbid damage.
|
||||
/// The normal mob damage handler owns armor, invulnerability frames and last-attacker bookkeeping.
|
||||
pub fn contact(m: &mut Mob, f: &Frame, teams_allow: bool) {
|
||||
if f.recently_hurt || m.removed || m.id == f.dragon_id {
|
||||
return;
|
||||
}
|
||||
let bounds = m.bounds();
|
||||
let source = Damage {
|
||||
kind: DamageKind::MobAttack,
|
||||
attacker: Some(Who::Mob(f.dragon_id)),
|
||||
from: Some([f.origin.x, f.origin.y, f.origin.z]),
|
||||
};
|
||||
for wing in &f.wings {
|
||||
if wing.intersects(bounds) {
|
||||
let dx = m.position.x - f.center.0;
|
||||
let dz = m.position.z - f.center.1;
|
||||
let dd = (dx * dx + dz * dz).max(0.1);
|
||||
m.velocity.x += dx / dd * 4.0;
|
||||
m.velocity.y += f64::from(0.2_f32);
|
||||
m.velocity.z += dz / dd * 4.0;
|
||||
if !f.sitting
|
||||
&& teams_allow
|
||||
&& m.ai.last_hurt_by_time < (m.age as i32).saturating_sub(2)
|
||||
{
|
||||
m.damage(5.0, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
if teams_allow {
|
||||
for head in &f.heads {
|
||||
if head.intersects(bounds) {
|
||||
m.damage(10.0, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn box_at(x: f64) -> Aabb {
|
||||
Aabb::new(Vec3::new(x, -1.0, -1.0), Vec3::new(x + 2.0, 3.0, 1.0))
|
||||
}
|
||||
fn setup() -> (Mob, Frame) {
|
||||
let mut m = Mob::new(
|
||||
22,
|
||||
crate::mobs::sensor_scan_tests::kind(),
|
||||
Vec3::new(1.0, 0.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
m.health = 40.0;
|
||||
m.age = 100;
|
||||
let f = Frame {
|
||||
dragon_id: 99,
|
||||
origin: Vec3::ZERO,
|
||||
center: (0.0, 0.0),
|
||||
wings: [box_at(0.0), box_at(100.0)],
|
||||
heads: [box_at(100.0), box_at(100.0)],
|
||||
sitting: false,
|
||||
recently_hurt: false,
|
||||
};
|
||||
(m, f)
|
||||
}
|
||||
#[test]
|
||||
fn flying_wing_hurts_and_names_the_dragon() {
|
||||
let (mut m, f) = setup();
|
||||
contact(&mut m, &f, true);
|
||||
assert_eq!(m.health, 35.0);
|
||||
assert_eq!(m.ai.last_hurt_by, Some(Who::Mob(99)));
|
||||
}
|
||||
#[test]
|
||||
fn perched_wing_pushes_without_damage() {
|
||||
let (mut m, mut f) = setup();
|
||||
f.sitting = true;
|
||||
contact(&mut m, &f, true);
|
||||
assert_eq!(m.health, 40.0);
|
||||
assert_eq!(m.velocity, Vec3::new(4.0, f64::from(0.2_f32), 0.0));
|
||||
}
|
||||
#[test]
|
||||
fn team_protection_keeps_push_but_blocks_damage() {
|
||||
let (mut m, f) = setup();
|
||||
contact(&mut m, &f, false);
|
||||
assert_eq!(m.health, 40.0);
|
||||
assert_eq!(m.velocity, Vec3::new(4.0, f64::from(0.2_f32), 0.0));
|
||||
}
|
||||
#[test]
|
||||
fn hurt_dragon_does_not_contact_mobs() {
|
||||
let (mut m, mut f) = setup();
|
||||
f.recently_hurt = true;
|
||||
contact(&mut m, &f, true);
|
||||
assert_eq!(m.health, 40.0);
|
||||
assert_eq!(m.velocity, Vec3::ZERO);
|
||||
}
|
||||
#[test]
|
||||
fn head_hurts_even_when_perched() {
|
||||
let (mut m, mut f) = setup();
|
||||
f.sitting = true;
|
||||
f.wings = [box_at(100.0), box_at(100.0)];
|
||||
f.heads[0] = box_at(0.0);
|
||||
contact(&mut m, &f, true);
|
||||
assert_eq!(m.health, 30.0);
|
||||
}
|
||||
#[test]
|
||||
fn recent_mob_attack_suppresses_wing_damage() {
|
||||
let (mut m, f) = setup();
|
||||
m.ai.last_hurt_by_time = 99;
|
||||
contact(&mut m, &f, true);
|
||||
assert_eq!(m.health, 40.0);
|
||||
assert_eq!(m.velocity, Vec3::new(4.0, f64::from(0.2_f32), 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod parity {
|
||||
use super::*;
|
||||
#[test]
|
||||
#[ignore = "writes the vanilla parity transcript"]
|
||||
fn contact_transcript() {
|
||||
let mut rows = Vec::new();
|
||||
for name in [
|
||||
"flying",
|
||||
"sitting",
|
||||
"two_wings",
|
||||
"team",
|
||||
"head",
|
||||
"hurt_dragon",
|
||||
"recent_victim",
|
||||
] {
|
||||
let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
|
||||
kind.id = "minecraft:enderman".into();
|
||||
kind.species = crate::mobs::Species::Enderman;
|
||||
kind.max_health = 40.0;
|
||||
kind.width = 0.6;
|
||||
kind.height = 2.9;
|
||||
let mut m = Mob::new(22, std::sync::Arc::new(kind), Vec3::new(1.0, 0.0, 0.0), 0.0);
|
||||
m.age = 100;
|
||||
let near = Aabb::new(Vec3::new(-10.0, -10.0, -10.0), Vec3::new(10.0, 10.0, 10.0));
|
||||
let far = near.move_by(Vec3::new(100.0, 0.0, 0.0));
|
||||
let mut f = Frame {
|
||||
dragon_id: 99,
|
||||
origin: Vec3::ZERO,
|
||||
center: (0.0, 0.0),
|
||||
wings: [near, far],
|
||||
heads: [far, far],
|
||||
sitting: name == "sitting",
|
||||
recently_hurt: name == "hurt_dragon",
|
||||
};
|
||||
if name == "two_wings" {
|
||||
f.wings = [near, near];
|
||||
}
|
||||
if name == "head" {
|
||||
f.wings = [far, far];
|
||||
f.heads = [near, far];
|
||||
}
|
||||
if name == "recent_victim" {
|
||||
m.ai.last_hurt_by = Some(Who::Mob(99));
|
||||
m.ai.last_hurt_by_time = 99;
|
||||
}
|
||||
contact(&mut m, &f, name != "team");
|
||||
rows.push(serde_json::json!({"case":name,"health":m.health,"velocity":[m.velocity.x,m.velocity.y,m.velocity.z],"revenge":m.ai.last_hurt_by==Some(Who::Mob(99))}));
|
||||
}
|
||||
let path = std::env::var_os("HORIZON_DRAGON_CONTACT_OUTPUT").expect("parity output path");
|
||||
std::fs::write(path, serde_json::to_vec_pretty(&rows).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -478,6 +478,46 @@ pub fn hoof_steps(s: Species) -> bool {
|
||||
| Species::ZombieHorse
|
||||
)
|
||||
}
|
||||
/// `Entity.isVehicle`: someone on its back, the player steering it
|
||||
/// (`rider`) or a mob that boarded it (`extra.rider`).
|
||||
pub fn is_vehicle(m: &Mob) -> bool {
|
||||
m.rider.is_some() || m.extra.rider.is_some()
|
||||
}
|
||||
/// `SkeletonHorse.getSwimSound`: an undead horse splashes its own way. In the
|
||||
/// water it swims; on the ground a horse with nobody on it splashes, and a
|
||||
/// ridden one counts on the same `gallopSoundCounter` its hoof steps use, so
|
||||
/// it splashes for the first five steps, then gallops through the water every
|
||||
/// third and swims in between.
|
||||
pub fn skeleton_horse_swim_sound(m: &mut Mob) -> &'static str {
|
||||
if !m.on_ground {
|
||||
return "entity.skeleton_horse.swim";
|
||||
}
|
||||
if !is_vehicle(m) {
|
||||
return "entity.skeleton_horse.step_water";
|
||||
}
|
||||
m.ai.steps.gallop += 1;
|
||||
let count = m.ai.steps.gallop;
|
||||
if count > 5 && count % 3 == 0 {
|
||||
"entity.skeleton_horse.gallop_water"
|
||||
} else if count <= 5 {
|
||||
"entity.skeleton_horse.step_water"
|
||||
} else {
|
||||
"entity.skeleton_horse.swim"
|
||||
}
|
||||
}
|
||||
/// `SkeletonHorse.playSwimSound`: a skeleton horse standing on the ground
|
||||
/// splashes at 0.3, and one in the water at `min(0.1, f * 25.0)` - which
|
||||
/// saturates at 0.1 for any speed a swim sound plays at.
|
||||
pub fn swim_volume(m: &Mob, f: f32) -> f32 {
|
||||
if m.species() != Species::SkeletonHorse {
|
||||
return f;
|
||||
}
|
||||
if m.on_ground {
|
||||
0.3
|
||||
} else {
|
||||
(f * 25.0).min(0.1)
|
||||
}
|
||||
}
|
||||
/// `AbstractHorse.playStepSound` (and `Horse.playGallopSound`) on the block
|
||||
/// `pos`: silent on liquids; a ridden horse walks on wood for five steps,
|
||||
/// then gallops every third; unridden, wood or the plain step by age.
|
||||
@@ -1012,4 +1052,58 @@ mod tests {
|
||||
assert_eq!(rears(Species::Llama, "minecraft:llama", 60), 0);
|
||||
assert_eq!(rears(Species::Camel, "minecraft:camel", 60), 0);
|
||||
}
|
||||
|
||||
/// `SkeletonHorse.getSwimSound`: an undead horse has its own water sounds,
|
||||
/// and one with someone on it counts its water steps on the same
|
||||
/// `gallopSoundCounter` its hoof steps use - five splashes, then a gallop
|
||||
/// through the water every third step and a swim sound in between.
|
||||
#[test]
|
||||
fn a_skeleton_horse_splashes_and_swims_with_its_own_sounds() {
|
||||
let sound = |m: &mut Mob| crate::mobs::ai::swim_sound(m);
|
||||
let mut m = equine(1, Species::SkeletonHorse, "minecraft:skeleton_horse");
|
||||
// In the water it swims; every other animal's default is the generic
|
||||
// splash (Entity.getSwimSound).
|
||||
m.on_ground = false;
|
||||
assert_eq!(sound(&mut m), "entity.skeleton_horse.swim");
|
||||
// Standing on the ground with nobody on it, it splashes.
|
||||
m.on_ground = true;
|
||||
assert_eq!(sound(&mut m), "entity.skeleton_horse.step_water");
|
||||
m.extra.rider = Some(9);
|
||||
let ridden: Vec<&str> = (0..9).map(|_| sound(&mut m)).collect();
|
||||
assert_eq!(
|
||||
ridden,
|
||||
[
|
||||
"entity.skeleton_horse.step_water",
|
||||
"entity.skeleton_horse.step_water",
|
||||
"entity.skeleton_horse.step_water",
|
||||
"entity.skeleton_horse.step_water",
|
||||
"entity.skeleton_horse.step_water",
|
||||
"entity.skeleton_horse.gallop_water",
|
||||
"entity.skeleton_horse.swim",
|
||||
"entity.skeleton_horse.swim",
|
||||
"entity.skeleton_horse.gallop_water",
|
||||
]
|
||||
);
|
||||
assert_eq!(m.ai.steps.gallop, 9);
|
||||
// A plain horse keeps the generic splash.
|
||||
let mut horse = equine(2, Species::Horse, "minecraft:horse");
|
||||
assert_eq!(sound(&mut horse), "entity.generic.swim");
|
||||
}
|
||||
|
||||
/// `SkeletonHorse.playSwimSound`: 0.3 on the ground, and no more than 0.1
|
||||
/// in the water, where every other animal keeps the volume
|
||||
/// `waterSwimSound` works out from its speed.
|
||||
#[test]
|
||||
fn a_skeleton_horses_splash_is_quiet_in_the_water() {
|
||||
let mut m = equine(1, Species::SkeletonHorse, "minecraft:skeleton_horse");
|
||||
m.on_ground = true;
|
||||
assert_eq!(swim_volume(&m, 0.35), 0.3);
|
||||
m.on_ground = false;
|
||||
assert_eq!(swim_volume(&m, 0.35), 0.1);
|
||||
assert_eq!(swim_volume(&m, 1.0), 0.1);
|
||||
assert!((swim_volume(&m, 0.001) - 0.025).abs() < 1.0e-6);
|
||||
let mut horse = equine(2, Species::Horse, "minecraft:horse");
|
||||
horse.on_ground = false;
|
||||
assert_eq!(swim_volume(&horse, 0.35), 0.35);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ use crate::projectiles::{Projectile, ProjectileKind};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
|
||||
/// `Enemy` monsters an iron golem goes after (never creepers).
|
||||
fn golem_prey(s: &super::Seen) -> bool {
|
||||
pub(super) fn golem_prey(s: &super::Seen) -> bool {
|
||||
s.hostile && s.species != Species::Creeper
|
||||
}
|
||||
/// `IronGolem.registerGoals`, without `ResetUniversalAngerTargetGoal` (that
|
||||
|
||||
@@ -171,4 +171,54 @@ mod tests {
|
||||
Some("minecraft:saddle".into())
|
||||
);
|
||||
}
|
||||
|
||||
/// `Wolf.actuallyHurt`: each time the armor's crackiness level changes it
|
||||
/// plays the crack sound and sends twenty armadillo scute particles.
|
||||
#[test]
|
||||
fn a_wolfs_armor_throws_scutes_as_it_cracks() {
|
||||
use crate::mobs::MobEvent;
|
||||
let mut wolf = mob("minecraft:wolf", Species::Wolf);
|
||||
wolf.extra.head = Some("minecraft:wolf_armor".into());
|
||||
// Levels change below 95%, 69% and 32% of the 64 points left, that is
|
||||
// after 4, 20 and 44 points of wear.
|
||||
let hurt = |wolf: &mut Mob, damage: f32| {
|
||||
wolf.hurt_wolf_armor(damage);
|
||||
std::mem::take(&mut wolf.outbox)
|
||||
};
|
||||
let scutes = |events: &[MobEvent]| {
|
||||
events
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e,
|
||||
MobEvent::ItemParticles {
|
||||
item: "minecraft:armadillo_scute",
|
||||
at,
|
||||
spread,
|
||||
speed,
|
||||
count: 20,
|
||||
} if *at == [0.0, 1.0, 0.0] && *spread == [0.2, 0.1, 0.2] && *speed == 0.1
|
||||
)
|
||||
})
|
||||
.count()
|
||||
};
|
||||
let cracks = |events: &[MobEvent]| {
|
||||
events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, MobEvent::Sound("item.wolf_armor.crack", ..)))
|
||||
.count()
|
||||
};
|
||||
// Three points: still the first level.
|
||||
let events = hurt(&mut wolf, 3.0);
|
||||
assert_eq!((cracks(&events), scutes(&events)), (0, 0));
|
||||
// The fourth point cracks it, with the sound and the scutes together.
|
||||
let events = hurt(&mut wolf, 1.0);
|
||||
assert_eq!((cracks(&events), scutes(&events)), (1, 1));
|
||||
// Within the level: nothing.
|
||||
let events = hurt(&mut wolf, 10.0);
|
||||
assert_eq!((cracks(&events), scutes(&events)), (0, 0));
|
||||
// Over 69%: the next level.
|
||||
let events = hurt(&mut wolf, 6.0);
|
||||
assert_eq!((cracks(&events), scutes(&events)), (1, 1));
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,29 @@ use horizon_world::{
|
||||
registry::BlockStateId,
|
||||
};
|
||||
|
||||
/// How many block particles a landing throws up
|
||||
/// (`(int)(min(0.2F + power / 15, 2.5) * 150)`), none when the fall is within
|
||||
/// `safe_fall_distance` (`power = max(floor(fall + 1.0E-6 - safe), 0)`).
|
||||
fn landing_dust_count(fall_distance: f64, safe_fall_distance: f64) -> Option<i32> {
|
||||
let power = f64::from(((fall_distance + 1.0e-6 - safe_fall_distance).floor() as i32).max(0));
|
||||
(power > 0.0).then(|| (150.0 * (f64::from(0.2_f32) + power / 15.0).min(2.5)) as i32)
|
||||
}
|
||||
/// Where the dust starts: at the mob, or, when its own block is not the one it
|
||||
/// landed on (`landing`), on that block's face toward the mob.
|
||||
fn landing_dust_at(position: Vec3, landing: [i32; 3]) -> Vec3 {
|
||||
if landing[0] == position.x.floor() as i32 && landing[2] == position.z.floor() as i32 {
|
||||
return position;
|
||||
}
|
||||
let dx = position.x - f64::from(landing[0]) - 0.5;
|
||||
let dz = position.z - f64::from(landing[2]) - 0.5;
|
||||
let scale = dx.abs().max(dz.abs());
|
||||
Vec3::new(
|
||||
f64::from(landing[0]) + 0.5 + dx / scale * 0.5,
|
||||
position.y,
|
||||
f64::from(landing[2]) + 0.5 + dz / scale * 0.5,
|
||||
)
|
||||
}
|
||||
|
||||
/// `Entity.moveDist`, `flyDist`, `nextStep` and `Parrot.nextFlap`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Steps {
|
||||
@@ -661,11 +684,31 @@ impl Mob {
|
||||
}
|
||||
if self.on_ground {
|
||||
if self.fall_distance > 0.0 {
|
||||
self.landing_dust(w, events);
|
||||
self.fall_on_block(w, events);
|
||||
}
|
||||
self.fall_distance = 0.0;
|
||||
}
|
||||
}
|
||||
/// The block particles `LivingEntity.checkFallDamage` sends up from the
|
||||
/// block a mob lands on, by the power of the fall.
|
||||
fn landing_dust(&self, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
let pos = self.block_on_legacy(w);
|
||||
let state = w.world.block(pos[0], pos[1], pos[2]);
|
||||
let Some(count) = landing_dust_count(self.fall_distance, self.kind.safe_fall_distance)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if w.facts.is_air(state) {
|
||||
return;
|
||||
}
|
||||
let at = landing_dust_at(self.position, pos);
|
||||
events.push(MobEvent::LandingDust {
|
||||
state,
|
||||
at: [at.x, at.y, at.z],
|
||||
count,
|
||||
});
|
||||
}
|
||||
/// Entity.updateFluidInteraction: the fluid state, then touching water
|
||||
/// resets the fall and entering it (after the first tick) splashes.
|
||||
pub(super) fn update_fluids(&mut self, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
@@ -746,25 +789,23 @@ impl Mob {
|
||||
self.ai.steps.next_step = self.next_step();
|
||||
} else if self.fluids.in_water {
|
||||
self.ai.steps.next_step = self.next_step();
|
||||
// Entity.waterSwimSound
|
||||
// Entity.waterSwimSound, and SkeletonHorse.playSwimSound's
|
||||
// mapping of the volume it works out.
|
||||
let v = self.velocity;
|
||||
let volume = 1.0_f32.min(
|
||||
let speed =
|
||||
(v.x * v.x * f64::from(0.2_f32) + v.y * v.y + v.z * v.z * f64::from(0.2_f32))
|
||||
.sqrt() as f32
|
||||
* 0.35,
|
||||
);
|
||||
.sqrt() as f32;
|
||||
let volume = super::equine::swim_volume(self, 1.0_f32.min(speed * 0.35));
|
||||
if !super::ai::movement_sounds(self) {
|
||||
return;
|
||||
}
|
||||
let pitch = 1.0 + (self.random.next_float() - self.random.next_float()) * 0.4;
|
||||
// Entity.playSwimSound asks getSwimSound for its sound even
|
||||
// when the mob is silent, and a skeleton horse counts there.
|
||||
let sound = super::ai::swim_sound(self);
|
||||
if !self.silent {
|
||||
let at = [self.position.x, self.position.y, self.position.z];
|
||||
events.push(MobEvent::Sound(
|
||||
super::ai::swim_sound(self),
|
||||
at,
|
||||
volume,
|
||||
pitch,
|
||||
));
|
||||
events.push(MobEvent::Sound(sound, at, volume, pitch));
|
||||
}
|
||||
}
|
||||
} else if w.facts.is_air(state)
|
||||
@@ -1734,6 +1775,7 @@ mod tests {
|
||||
player: None,
|
||||
nearest_player: None,
|
||||
others: &[],
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
@@ -1844,6 +1886,7 @@ mod tests {
|
||||
player: None,
|
||||
nearest_player: None,
|
||||
others: &[],
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
@@ -1903,4 +1946,29 @@ mod tests {
|
||||
"a ridden horse gains 0.04 a tick, got {lift}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `LivingEntity.checkFallDamage`'s dust: nothing within the safe distance,
|
||||
/// `150 * min(0.2 + power / 15, 2.5)` block particles beyond it.
|
||||
#[test]
|
||||
fn a_landing_throws_up_dust_by_the_power_of_the_fall() {
|
||||
// Within the safe distance of 3, or by a hair over it: power 0.
|
||||
assert_eq!(landing_dust_count(2.5, 3.0), None);
|
||||
assert_eq!(landing_dust_count(3.0, 3.0), None);
|
||||
// A fall of 7: power 4, 150 * (0.2 + 4 / 15) = 70.
|
||||
assert_eq!(landing_dust_count(7.0, 3.0), Some(70));
|
||||
// A long fall is capped at 150 * 2.5.
|
||||
assert_eq!(landing_dust_count(60.0, 3.0), Some(375));
|
||||
// A mob that takes no fall damage still has a safe distance of its own.
|
||||
assert_eq!(landing_dust_count(7.0, 10.0), None);
|
||||
}
|
||||
/// Over the block it lands on the dust starts at the mob; straddling
|
||||
/// into another column it is pulled to that block's near edge.
|
||||
#[test]
|
||||
fn landing_dust_is_pulled_to_the_block_it_landed_on() {
|
||||
let on = Vec3::new(4.3, 65.0, 7.6);
|
||||
assert_eq!(landing_dust_at(on, [4, 64, 7]), on);
|
||||
// The mob stands over block (4, 7) but its feet block is (5, 7).
|
||||
let straddling = landing_dust_at(Vec3::new(5.1, 65.0, 7.5), [4, 64, 7]);
|
||||
assert_eq!(straddling, Vec3::new(5.0, 65.0, 7.5));
|
||||
}
|
||||
}
|
||||
@@ -197,6 +197,12 @@ fn climate(m: &Mob) -> &'static str {
|
||||
fn avatar(m: &Mob) -> bool {
|
||||
matches!(&*m.kind.id, "minecraft:player" | "minecraft:mannequin")
|
||||
}
|
||||
/// `AvatarRenderer.shouldRenderLayers`: a spectating player or mannequin
|
||||
/// draws no layers at all (armour, held items, head item, cape, wings,
|
||||
/// parrots, ears, stuck arrows), only its translucent head.
|
||||
pub fn layers_shown(m: &Mob) -> bool {
|
||||
!(avatar(m) && m.extra.spectator)
|
||||
}
|
||||
/// The model layer key and texture for the mob's body.
|
||||
pub fn body(m: &Mob) -> (String, String) {
|
||||
use Species as S;
|
||||
@@ -1796,6 +1802,8 @@ fn render_visible(
|
||||
out: &mut Vec<Vertex>,
|
||||
skin: Option<&mut super::skin::Skin>,
|
||||
) {
|
||||
// The armour is a layer: none for a spectator.
|
||||
let extra = if layers_shown(m) { extra } else { &[] };
|
||||
let overlay = u32::from(red_overlay(m));
|
||||
let s = anim::state(m, alpha);
|
||||
let (model_key, texture) = body(m);
|
||||
|
||||
@@ -267,6 +267,11 @@ impl MobWorld<'_> {
|
||||
if self.daylight { 0 } else { 11 }
|
||||
}
|
||||
pub fn target_info(&self, who: Who) -> Option<TargetInfo> {
|
||||
if let Some((id, target)) = self.dragon
|
||||
&& who == Who::Mob(id)
|
||||
{
|
||||
return Some(target);
|
||||
}
|
||||
match who {
|
||||
Who::Player => self.player.as_ref().map(|p| TargetInfo {
|
||||
position: p.position,
|
||||
@@ -462,6 +467,7 @@ pub(crate) fn with_resource_test_world(
|
||||
player: None,
|
||||
nearest_player: None,
|
||||
others,
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
|
||||
@@ -204,6 +204,7 @@ mod tests {
|
||||
player: None,
|
||||
nearest_player: None,
|
||||
others: &[],
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
|
||||
@@ -29,6 +29,7 @@ pub use crate::hud_layout::{GuiDraw, HealthInputs};
|
||||
pub use crate::items::ItemStack;
|
||||
pub use crate::merchant::MerchantOffer;
|
||||
pub use crate::music::{Music, MusicLayer};
|
||||
pub use crate::particles::{ParticleOptions, ParticleType};
|
||||
pub use crate::text::Text;
|
||||
pub use horizon_assets::item_model::DisplayContext;
|
||||
pub use horizon_core::{
|
||||
@@ -50,7 +51,8 @@ pub use horizon_world::{
|
||||
pub use serde_json;
|
||||
use serde_json::{Map, Value};
|
||||
use std::time::Duration;
|
||||
pub use winit::event::KeyEvent;
|
||||
/// The window events `input_key` hands over (`winit::event::KeyEvent`) and their key types.
|
||||
pub use winit;
|
||||
|
||||
/// A dispenser hook's answer: the slot's remainder (None empties it) and the
|
||||
/// level event to play, `CLICK` (1000), `FAIL` (1001) or 0 for none.
|
||||
@@ -454,6 +456,25 @@ pub trait LevelTicksHost: LevelHost {
|
||||
pub trait LevelWriteHost: LevelTicksHost + LevelRandomHost {
|
||||
/// Change a block property through `native_set_block_property`.
|
||||
fn set_block_property(&mut self, pos: [i32; 3], key: &str, value: &str, flags: i32) -> bool;
|
||||
/// Play a sound for nearby players through `native_play_sound`.
|
||||
fn play_sound(
|
||||
&mut self,
|
||||
sound: &str,
|
||||
at: [f64; 3],
|
||||
source: usize,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
) -> bool;
|
||||
/// `ServerLevel.sendParticles` through `native_send_particles`.
|
||||
fn send_particles(
|
||||
&mut self,
|
||||
options: &ParticleOptions,
|
||||
state: Option<BlockStateId>,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
count: i32,
|
||||
) -> bool;
|
||||
}
|
||||
|
||||
macro_rules! level_attachment_host {
|
||||
|
||||
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user