Merge remote-tracking branch 'upstream/main' into HEAD

# Conflicts:
#	tools/port_map/rest-020.tsv
This commit is contained in:
MSedgeMC committed 2026-11-02 11:07:51 +00:00
commit 716ff3eaa1
191 files changed
+9901 -1916

No files matched your search

+1 -1
View File
@@ -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` |
+51 -12
View File
@@ -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());
}
}
+5 -1
View File
@@ -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();
+108 -64
View File
@@ -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
+131 -13
View File
@@ -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}");
}
+75 -12
View File
@@ -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,
+24 -4
View File
@@ -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]
+3 -1
View File
@@ -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();
+14 -7
View File
@@ -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/");
+1
View File
@@ -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;
+730 -30
View File
@@ -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()))
+14 -1
View File
@@ -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;
}
+2 -1
View File
@@ -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(&region).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(&region).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();
}
}
+297 -4
View File
@@ -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
+13 -11
View File
@@ -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>,
+5 -4
View File
@@ -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)))
}
+4
View File
@@ -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,
+11 -28
View File
@@ -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()?
+3 -2
View File
@@ -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 {
+5 -4
View File
@@ -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;
+24 -5
View File
@@ -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.
+33
View File
@@ -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
}
}
+195 -46
View File
@@ -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
+7 -12
View File
@@ -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(
+138 -73
View File
@@ -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) {
+2 -2
View File
@@ -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))],
+60 -7
View File
@@ -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] });
}
+4 -4
View File
@@ -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}"),
+53 -1
View File
@@ -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,
})
}
}
+186 -10
View File
@@ -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())
);
}
}
}
+18 -1
View File
@@ -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));
}
}
+106 -32
View File
@@ -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"
]
);
}
}
+18 -6
View File
@@ -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.
+5 -3
View File
@@ -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
+16 -2
View File
@@ -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.
+164 -27
View File
@@ -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:?}"),
}
}
}
+11 -1
View File
@@ -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);
}
+321 -99
View File
@@ -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)
}
}
+30
View File
@@ -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)
}
+205 -93
View File
@@ -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;
+41 -134
View File
@@ -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).
+61 -26
View File
@@ -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]
+113 -20
View File
@@ -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, &macros, &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();
+43 -7
View File
@@ -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}");
}
}
+12 -4
View File
@@ -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()
+140 -24
View File
@@ -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;
}
+17 -1
View File
@@ -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;
+28 -176
View File
@@ -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() {
+88 -35
View File
@@ -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:?}");
}
}
}
+18
View File
@@ -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,
+12 -18
View File
@@ -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;
}
+121 -5
View File
@@ -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();
+4
View File
@@ -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,
+127 -19
View File
@@ -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,
+33 -1
View File
@@ -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,
});
}
}
}
+71 -2
View File
@@ -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"
);
}
}
+50
View File
@@ -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();
}
}
+94
View File
@@ -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);
}
}
+1 -1
View File
@@ -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));
}
}
+79 -11
View File
@@ -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));
}
}
+8
View File
@@ -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);
+6
View File
@@ -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,
+22 -1
View File
@@ -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