mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 02:29:50 -10:00
Merge main into #6927
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
efb76fb19c
352 files changed
+13895
-5998
No files matched your search
@@ -47,7 +47,7 @@ line are in the output) and the Rust fn before changing anything.
|
||||
That one line is what makes the next agent find it.
|
||||
- Fix the path players run: single player is a client of the integrated server, so server logic lives in
|
||||
`game/lan*.rs` and `src/lan/`, and what only the client's copy shows (particles, local sounds, hand
|
||||
animation) must be reached from `game/remote*.rs`. A fix on the old in-frame path alone fixes nothing.
|
||||
animation) must be reached from `game/remote*.rs`.
|
||||
- Look at the neighbours the ledger lists for the same class: a `client` or `seam` gap next to your fix is
|
||||
usually the same bug on the other path.
|
||||
|
||||
|
||||
@@ -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` |
|
||||
@@ -178,28 +178,24 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
|
||||
single player on its server thread), `lan` (`LanHost`: this world is open to the network), `integrated`
|
||||
(the window's own server thread), `serving` (this `Game` *is* that server thread), `options.dedicated`.
|
||||
`fn tick` returns `tick_remote()` when `remote` is set; nothing else of the local simulation runs.
|
||||
- A server's players run through the same gameplay code as the local player: `as_guest` swaps the guest's
|
||||
`fn tick` returns `tick_remote()` when `remote` is set; a server ticks its levels (`fn tick_world`).
|
||||
- A server's players run through the shared gameplay code: `as_guest` swaps the guest's
|
||||
state (position, inventory, mode, mining, item use) into `Game` for one call (`game/lan_actions.rs`), and
|
||||
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
|
||||
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
|
||||
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
|
||||
(`pack`), both in game.rs.
|
||||
|
||||
## The two single-player paths
|
||||
## Single player runs on a server thread
|
||||
|
||||
1. **Server thread (default since #1514):** `game/integrated.rs` runs a second `Game` (`serving`) on a
|
||||
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
|
||||
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
|
||||
`[server-thread]` issues list the gaps left.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`): DEPRECATED, never use it, it
|
||||
will be removed soon.** The window's `Game` ticks the world itself, one level that follows the player
|
||||
(`fn enter_dimension`). Compatibility does not matter: don't add features or fixes for it, don't write new
|
||||
tests against it, and move code off it when you touch it. The deterministic rule fixtures still run on it
|
||||
until they are ported.
|
||||
`game/integrated.rs` runs a second `Game` (`serving`) on a "Server thread" with one level per dimension;
|
||||
the window joins it as a client through memory (`remote`), exactly as a LAN guest or a dedicated server's
|
||||
player does (#1514). A window's `Game` never ticks a world itself: the old in-frame path is gone
|
||||
(`--in-frame` and `--no-server-thread` only print a notice; `HORIZON_SERVER_THREAD` is ignored).
|
||||
Open `[server-thread]` issues list the gaps left.
|
||||
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test the server
|
||||
thread when you touch player-facing gameplay:
|
||||
Single player, LAN guests and dedicated servers all run the server code, so a fix there serves all three.
|
||||
Test the server thread when you touch player-facing gameplay:
|
||||
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
|
||||
|
||||
+3
-3
@@ -92,7 +92,7 @@ Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3914](https://
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
|
||||
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -234,11 +234,11 @@ Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggr
|
||||
|
||||
#### One single-player path: remove the in-frame world
|
||||
|
||||
Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.
|
||||
Single player has one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode is removed.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
|
||||
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) ticked the world inside the window's `Game`. [#6473](https://github.com/horizon-rust/horizon/issues/6473) removes it with the code only it reached (the window's world loader and player tick, its portal travel and loading screen, the in-frame LAN host); the harnesses run on the server thread. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -5,7 +5,6 @@ use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
fs,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
@@ -171,7 +170,9 @@ fn color(v: &Value) -> Option<u32> {
|
||||
}
|
||||
fn colormap(root: &Path, name: &str) -> Result<Vec<u32>> {
|
||||
let path = root.join(format!("assets/minecraft/textures/colormap/{name}.png"));
|
||||
let mut decoder = png::Decoder::new(std::io::BufReader::new(fs::File::open(path)?));
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_core::prepared_files::read(
|
||||
&path,
|
||||
)?));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info()?;
|
||||
@@ -277,21 +278,22 @@ impl Biomes {
|
||||
dry: colormap(root, "dry_foliage")?,
|
||||
};
|
||||
let mut list = Vec::new();
|
||||
let mut names: Vec<_> = fs::read_dir(root.join("data/minecraft/worldgen/biome"))?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "json"))
|
||||
// The folder's own files (not its subfolders'), by file name: through `json_folder`,
|
||||
// from the prepared data file when there is one.
|
||||
let folder = horizon_world::json_folder::read(&root.join("data/minecraft/worldgen/biome"))?;
|
||||
let mut files: Vec<_> = folder
|
||||
.files
|
||||
.iter()
|
||||
.filter(|f| !f.name.contains('/'))
|
||||
.collect();
|
||||
names.sort();
|
||||
for path in names {
|
||||
let name = format!(
|
||||
"minecraft:{}",
|
||||
path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or("bad biome")?
|
||||
);
|
||||
let v: Value = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
list.push(BiomeInfo::from_json(name, &v, &maps));
|
||||
files.sort_by_cached_key(|f| format!("{}.json", f.name));
|
||||
for file in files {
|
||||
let v: Value = serde_json::from_slice(file.bytes()?)?;
|
||||
list.push(BiomeInfo::from_json(
|
||||
format!("minecraft:{}", file.name),
|
||||
&v,
|
||||
&maps,
|
||||
));
|
||||
}
|
||||
let index = list
|
||||
.iter()
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -544,7 +544,7 @@ fn the_chat_option_filters_the_lines_both_ways() {
|
||||
assert_eq!(shown(&chat), ["client", "server", "player"]);
|
||||
}
|
||||
|
||||
/// The in-frame LAN host's chat: a guest's line is a player message, so the
|
||||
/// A server's own chat: a guest's line is a player message, so the
|
||||
/// Chat option's player-message permission hides it at Commands Only and
|
||||
/// Hidden; command feedback (`/list`, `/tp`) is a system message and stays
|
||||
/// at Commands Only.
|
||||
|
||||
@@ -211,6 +211,36 @@ impl SceneCollisions<'_, '_> {
|
||||
) -> Result<Vec3> {
|
||||
self.resolve_with_context(bounds, movement, max_step, on_ground, self.context(bounds))
|
||||
}
|
||||
/// [`Self::resolve`], also stopped by the world border's walls when one is
|
||||
/// given with the moving entity's x and z: `Entity.collectColliders` adds
|
||||
/// `WorldBorder.getCollisionShape` for any entity inside and close to it.
|
||||
pub fn resolve_in_border(
|
||||
self,
|
||||
bounds: Aabb,
|
||||
movement: Vec3,
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
border: Option<(&BorderGeometry, [f64; 2])>,
|
||||
) -> Result<Vec3> {
|
||||
let context = self.context(bounds);
|
||||
let mut scene = EntityCollisions {
|
||||
blocks: self,
|
||||
entities: &[],
|
||||
border,
|
||||
};
|
||||
Ok(resolve_world_movement(
|
||||
&mut scene,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
on_ground,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)?)
|
||||
}
|
||||
/// Resolve movement with the entity's captured collision context. Most
|
||||
/// callers only need the position context; entities such as falling blocks
|
||||
/// also affect contextual block shapes (for example powder snow).
|
||||
@@ -617,6 +647,25 @@ mod border_movement_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A minecart (0.98 wide, 0.7 tall) rolling east at its top speed of 0.4 a tick, 0.11
|
||||
/// blocks short of the wall: the wall takes all but the 0.11 (`move_cart` hands the
|
||||
/// cart's x and z to `with_border`, as every entity does in `Entity.move`).
|
||||
#[test]
|
||||
fn a_minecart_sized_body_stops_at_the_border_wall() {
|
||||
// Centre x = 4.4, half width 0.49: the cart's east face is at 4.89, the wall at 5.0.
|
||||
let bounds = Aabb::new(Vec3::new(3.91, 0.0, -0.49), Vec3::new(4.89, 0.7, 0.49));
|
||||
let border = border();
|
||||
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [4.4, 0.0]);
|
||||
let moved = collisions
|
||||
.resolve(bounds, Vec3::new(0.4, 0.0, 0.0), 0.0, false)
|
||||
.expect("the movement resolves");
|
||||
assert!(
|
||||
(moved.x - 0.11).abs() < 1.0e-9,
|
||||
"the cart was not stopped by the border: {moved:?}"
|
||||
);
|
||||
assert_eq!(moved.z, 0.0);
|
||||
}
|
||||
|
||||
/// Away from the border, the same body moves the whole way.
|
||||
#[test]
|
||||
fn a_body_away_from_the_border_moves_whole() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -53,7 +53,10 @@ fn namespaced(reference: &str) -> String {
|
||||
|
||||
fn read_model(root: &Path, name: &str) -> Option<Json> {
|
||||
let (ns, path) = name.split_once(':')?;
|
||||
let text = fs::read_to_string(root.join(format!("assets/{ns}/models/{path}.json"))).ok()?;
|
||||
let text = horizon_core::prepared_files::read_to_string(
|
||||
&root.join(format!("assets/{ns}/models/{path}.json")),
|
||||
)
|
||||
.ok()?;
|
||||
Json::parse(&text).ok()
|
||||
}
|
||||
|
||||
@@ -186,7 +189,7 @@ fn connecting_textures(root: &Path) -> HashMap<String, Meta> {
|
||||
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = fs::read_to_string(&path) else {
|
||||
let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
if !text.contains("fusion") {
|
||||
|
||||
@@ -90,18 +90,23 @@ fn gpu_culling_requested(info: &wgpu::AdapterInfo, env: Option<&str>) -> bool {
|
||||
|
||||
/// Whether Translucent Batching is offered (`graphics::offer_translucent_batching`): where
|
||||
/// indirect multi-draws (`multi_draw`) take its batches, but not OpenGL's, which loop over
|
||||
/// their entries on the CPU (and without them every section is a call of its own anyway);
|
||||
/// nor where vanilla frames draw flat (`flat`'s `min_sections` below `usize::MAX`: Metal,
|
||||
/// Intel Gen11/Gen12's Vulkan driver, `HORIZON_FLAT_DRAWS=1`), as flat draws read
|
||||
/// translucent quads in the section arena.
|
||||
fn translucent_batchable(
|
||||
info: &wgpu::AdapterInfo,
|
||||
multi_draw: bool,
|
||||
flat_min_sections: Option<usize>,
|
||||
) -> bool {
|
||||
multi_draw
|
||||
&& info.backend != wgpu::Backend::Gl
|
||||
&& flat_min_sections.is_none_or(|min| min == usize::MAX)
|
||||
/// their entries on the CPU (and without them every section is a call of its own anyway).
|
||||
/// Frames drawn flat (Metal, Intel Gen11/Gen12's Vulkan driver) take it too: their
|
||||
/// translucent calls then come one per page of its arena rather than about one per section.
|
||||
fn translucent_batchable(info: &wgpu::AdapterInfo, multi_draw: bool) -> bool {
|
||||
multi_draw && info.backend != wgpu::Backend::Gl
|
||||
}
|
||||
|
||||
/// The vertices flat draws bind for a call's page key: a page of the section arena, or of
|
||||
/// the translucent arena up to its last range in use (`flat::TRANSLUCENT_KEYS`).
|
||||
fn flat_vertices<'a>(
|
||||
arena: &'a arena::Arena,
|
||||
translucent: Option<&'a arena::Arena>,
|
||||
) -> impl Fn(u32) -> wgpu::BufferSlice<'a> + Copy {
|
||||
move |key| match (translucent, key.checked_sub(flat::TRANSLUCENT_KEYS)) {
|
||||
(Some(translucent), Some(page)) => translucent.binding(page),
|
||||
_ => arena.buffer(key).slice(..),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether translucent quads have an arena of their own: with Translucent Batching
|
||||
@@ -498,8 +503,11 @@ fn move_off_page<'a>(
|
||||
arena.copy(encoder, (page, start), to, vertices);
|
||||
arena.release(page, start, vertices);
|
||||
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
|
||||
flat.remove(id);
|
||||
s.flat_id = Some(flat.add(device, encoder, uploads, to.0, to.1, vertices));
|
||||
let key = match part {
|
||||
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
|
||||
_ => to.0,
|
||||
};
|
||||
flat.place(device, encoder, uploads, id, key, to.1, vertices);
|
||||
}
|
||||
part.set(s, to);
|
||||
*budget = budget.saturating_sub(bytes);
|
||||
@@ -3707,7 +3715,7 @@ impl Renderer {
|
||||
flat_wanted,
|
||||
);
|
||||
// Sections as one index list per frame where multi-draw is emulated.
|
||||
let flat = flat_wanted.then(|| {
|
||||
let mut flat = flat_wanted.then(|| {
|
||||
let buffers = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
@@ -3743,11 +3751,7 @@ impl Renderer {
|
||||
.get_downlevel_capabilities()
|
||||
.flags
|
||||
.contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION);
|
||||
let batchable = translucent_batchable(
|
||||
&adapter.get_info(),
|
||||
multi_draw,
|
||||
flat.as_ref().map(|f| f.min_sections),
|
||||
);
|
||||
let batchable = translucent_batchable(&adapter.get_info(), multi_draw);
|
||||
crate::graphics::offer_translucent_batching(batchable);
|
||||
let translucent_batching = batchable && translucent_batching;
|
||||
// A new renderer runs no shader pack: the game loads the picked one on its
|
||||
@@ -3763,6 +3767,9 @@ impl Renderer {
|
||||
flat.is_some(),
|
||||
translucent_split(translucent_batching, flat.is_some(), shader_pack, false),
|
||||
);
|
||||
if let (Some(flat), Some(arena)) = (&mut flat, &translucent_arena) {
|
||||
flat.set_translucent_page_vertices(arena.page_vertices());
|
||||
}
|
||||
// Without indirect draws (OpenGL before 4.0 without indirect execution, or
|
||||
// before 4.2 + GL_ARB_shader_draw_parameters without indirect first instance)
|
||||
// even creating an indirect buffer fails; one draw per section reads
|
||||
@@ -4085,7 +4092,11 @@ impl Renderer {
|
||||
match flat {
|
||||
Some(flat) => {
|
||||
pass.set_pipeline(&flat.translucent_mark);
|
||||
flat.draw(pass, flat.translucent_calls(), |p| self.arena.buffer(p));
|
||||
flat.draw(
|
||||
pass,
|
||||
flat.translucent_calls(),
|
||||
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
None => {
|
||||
@@ -4478,17 +4489,23 @@ impl Renderer {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Flat draws name the blocks of its range in `arena`: split, its solid
|
||||
// quads alone (no frame draws flat then: `translucent_split`).
|
||||
let flat_id = self.flat.as_mut().filter(|_| main > 0).map(|f| {
|
||||
f.add(
|
||||
&self.device,
|
||||
encoder,
|
||||
&mut self.section_uploads,
|
||||
page,
|
||||
start,
|
||||
main,
|
||||
)
|
||||
// Flat draws name the blocks of its ranges by its id: its range in `arena` and,
|
||||
// split, its translucent quads' in `translucent_arena` (`flat::TRANSLUCENT_KEYS`).
|
||||
let translucent_key = (split && translucent > 0)
|
||||
.then(|| (translucent_at.0 + flat::TRANSLUCENT_KEYS, translucent_at.1));
|
||||
let flat_id = self.flat.as_mut().and_then(|f| {
|
||||
let ranges = [(main > 0).then_some((page, start, main))]
|
||||
.into_iter()
|
||||
.chain([translucent_key.map(|(key, first)| (key, first, translucent_vertices))]);
|
||||
let mut id = None;
|
||||
for (key, first, vertices) in ranges.flatten() {
|
||||
let uploads = &mut self.section_uploads;
|
||||
match id {
|
||||
None => id = Some(f.add(&self.device, encoder, uploads, key, first, vertices)),
|
||||
Some(id) => f.place(&self.device, encoder, uploads, id, key, first, vertices),
|
||||
}
|
||||
}
|
||||
id
|
||||
});
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let translucent_arena = self.translucent_arena.as_ref().unwrap_or(&self.arena);
|
||||
@@ -4721,7 +4738,13 @@ impl Renderer {
|
||||
_ => COMPACT_BUDGET,
|
||||
};
|
||||
let mut budget = left.min(cap);
|
||||
if !move_off_page(arena, encoder, None, meshes, page, part, &mut budget) {
|
||||
// Flat draws name translucent quads' blocks by their section's id; indices have none.
|
||||
let flat = match part {
|
||||
Part::Translucent => self.flat.as_mut(),
|
||||
_ => None,
|
||||
}
|
||||
.map(|f| (f, &self.device, &mut self.section_uploads));
|
||||
if !move_off_page(arena, encoder, flat, meshes, page, part, &mut budget) {
|
||||
// New ranges took the room: both pages stay, drawn, until a later try.
|
||||
arena.reopen();
|
||||
self.shrink_gate.wait(now, SHRINK_RETRY);
|
||||
@@ -4901,6 +4924,9 @@ impl Renderer {
|
||||
self.flat.is_some(),
|
||||
split,
|
||||
);
|
||||
if let (Some(flat), Some(arena)) = (&mut self.flat, &self.translucent_arena) {
|
||||
flat.set_translucent_page_vertices(arena.page_vertices());
|
||||
}
|
||||
}
|
||||
/// Whether every section and far tile was dropped since the last call, to be
|
||||
/// laid out again (`follow_translucent_layout`): the game meshes them again,
|
||||
@@ -5536,9 +5562,11 @@ impl Renderer {
|
||||
.map(pack_timing::PassTiming::medians)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// The macros of the GL extensions this device gives shader packs: subgroups, when it
|
||||
/// has them.
|
||||
/// The macros this renderer gives shader packs: the GL extensions this device has
|
||||
/// (subgroups, when it has them), and the vendor, renderer and mipmap levels
|
||||
/// (`Macros::define_renderer`).
|
||||
pub fn define_pack_macros(&self, macros: &mut horizon_shaders::Macros) {
|
||||
macros.define_renderer(self.mipmaps);
|
||||
if self.device.features().contains(wgpu::Features::SUBGROUP) {
|
||||
for ext in [
|
||||
"basic",
|
||||
@@ -5854,7 +5882,11 @@ impl Renderer {
|
||||
arena::free_later(self.graveyard.advance());
|
||||
self.shrink_translucent(left);
|
||||
if let Some(flat) = &mut self.flat {
|
||||
flat.trim(|page| self.arena.has_page(page));
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
flat.trim(|key| match key.checked_sub(flat::TRANSLUCENT_KEYS) {
|
||||
Some(page) => translucent.is_some_and(|a| a.has_page(page)),
|
||||
None => self.arena.has_page(key),
|
||||
});
|
||||
}
|
||||
if self.suspended {
|
||||
return Ok(false);
|
||||
@@ -7218,8 +7250,6 @@ impl Renderer {
|
||||
|| pack_terrain
|
||||
|| shadow_view.is_some()) =>
|
||||
{
|
||||
// They read translucent quads in the section arena (`translucent_split`).
|
||||
debug_assert!(self.translucent_arena.is_none(), "flat draws, split");
|
||||
let sections = &self.sections;
|
||||
let lods = &self.lods;
|
||||
let sorted = &self.sorted_indices;
|
||||
@@ -7236,6 +7266,7 @@ impl Renderer {
|
||||
[shadow_first, shadow_first + shadow_solid].map(|d| d as usize),
|
||||
[opaque_end, cutout_start],
|
||||
phase2,
|
||||
self.translucent_arena.is_some(),
|
||||
visible
|
||||
.iter()
|
||||
.chain(&shadow_visible)
|
||||
@@ -7480,9 +7511,11 @@ impl Renderer {
|
||||
{
|
||||
pass.set_pipeline(pipeline);
|
||||
let [first, translucent] = flat.shadow_calls;
|
||||
flat.draw(&mut pass, &flat.calls[first..translucent], |p| {
|
||||
self.arena.buffer(p)
|
||||
});
|
||||
flat.draw(
|
||||
&mut pass,
|
||||
&flat.calls[first..translucent],
|
||||
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
|
||||
);
|
||||
} else if !flat_frame {
|
||||
pass.set_pipeline(&shadow.solid);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
@@ -7503,9 +7536,11 @@ impl Renderer {
|
||||
{
|
||||
pass.set_pipeline(pipeline);
|
||||
let [_, translucent] = flat.shadow_calls;
|
||||
flat.draw(&mut pass, &flat.calls[translucent..], |p| {
|
||||
self.arena.buffer(p)
|
||||
});
|
||||
flat.draw(
|
||||
&mut pass,
|
||||
&flat.calls[translucent..],
|
||||
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
|
||||
);
|
||||
} else if !flat_frame {
|
||||
pass.set_pipeline(&shadow.translucent);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
@@ -7668,7 +7703,7 @@ impl Renderer {
|
||||
// is phase 1's.
|
||||
if flat_frame && let Some(flat) = &self.flat {
|
||||
pass.set_pipeline(&flat.solid_mirror);
|
||||
let vertices = |p| self.arena.buffer(p);
|
||||
let vertices = flat_vertices(&self.arena, self.translucent_arena.as_ref());
|
||||
flat.draw(&mut pass, &flat.calls[..flat.solid_calls], vertices);
|
||||
} else {
|
||||
pass.set_pipeline(&self.solid_mirror);
|
||||
@@ -7856,7 +7891,7 @@ impl Renderer {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
}
|
||||
Some(flat) => {
|
||||
let vertices = |p| self.arena.buffer(p);
|
||||
let vertices = flat_vertices(&self.arena, self.translucent_arena.as_ref());
|
||||
// Without a pack, on Metal, the opaque pass, then the cutout one
|
||||
// (terrain.wgsl's `opaque_quad`), each over phase 1's lists with
|
||||
// quads for it, and over all of phase 2's.
|
||||
@@ -8331,7 +8366,11 @@ impl Renderer {
|
||||
// Flat section draws take the flat pipelines.
|
||||
if let Some((flat, pipeline)) = flat.zip(oit.flat(stage)) {
|
||||
pass.set_pipeline(pipeline);
|
||||
flat.draw(pass, flat.translucent_calls(), |p| self.arena.buffer(p));
|
||||
flat.draw(
|
||||
pass,
|
||||
flat.translucent_calls(),
|
||||
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
pass.set_pipeline(oit.terrain(stage));
|
||||
@@ -8395,9 +8434,11 @@ impl Renderer {
|
||||
}
|
||||
Some(flat) => {
|
||||
pass.set_pipeline(pack_flat.map_or(&flat.translucent, |p| &p.1));
|
||||
flat.draw(&mut pass, flat.translucent_calls(), |p| {
|
||||
self.arena.buffer(p)
|
||||
});
|
||||
flat.draw(
|
||||
&mut pass,
|
||||
flat.translucent_calls(),
|
||||
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
self.mark_translucent_terrain(
|
||||
@@ -8578,10 +8619,16 @@ impl Renderer {
|
||||
}],
|
||||
});
|
||||
rp.set_bind_group(5, &indices, &[]);
|
||||
let vertices = |p| self.arena.buffer(p);
|
||||
let vertices =
|
||||
flat_vertices(&self.arena, self.translucent_arena.as_ref());
|
||||
flat.draw_geometry(rp, calls, vertices, page, instances);
|
||||
}
|
||||
None => flat.draw_with(rp, calls, |p| self.arena.buffer(p), page),
|
||||
None => flat.draw_with(
|
||||
rp,
|
||||
calls,
|
||||
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
|
||||
page,
|
||||
),
|
||||
}
|
||||
}
|
||||
// A geometry stage reads flat draws' indices; without them it draws nothing.
|
||||
@@ -9652,23 +9699,20 @@ mod tests {
|
||||
assert!(!super::gpu_culling_requested(&discrete, Some("0")));
|
||||
}
|
||||
|
||||
/// Translucent Batching is offered with indirect multi-draws that aren't OpenGL's,
|
||||
/// and only where frames without a shader pack never draw flat.
|
||||
/// Translucent Batching is offered with indirect multi-draws that aren't OpenGL's, flat
|
||||
/// frames (Metal, Intel Gen11/Gen12 on Vulkan) included.
|
||||
#[test]
|
||||
fn translucent_batching_is_offered_where_vanilla_frames_never_draw_flat() {
|
||||
fn translucent_batching_is_offered_with_indirect_multi_draws_except_on_opengl() {
|
||||
let vulkan = adapter(wgpu::DeviceType::DiscreteGpu);
|
||||
let mut metal = adapter(wgpu::DeviceType::IntegratedGpu);
|
||||
metal.backend = wgpu::Backend::Metal;
|
||||
let mut gl = adapter(wgpu::DeviceType::DiscreteGpu);
|
||||
gl.backend = wgpu::Backend::Gl;
|
||||
let batchable = super::translucent_batchable;
|
||||
// No flat draws, or flat draws for a shader pack's terrain alone.
|
||||
assert!(batchable(&vulkan, true, None));
|
||||
assert!(batchable(&vulkan, true, Some(usize::MAX)));
|
||||
// Vanilla frames drawn flat past MIN_SECTIONS (Metal), or all of them.
|
||||
assert!(!batchable(&vulkan, true, Some(super::flat::MIN_SECTIONS)));
|
||||
assert!(!batchable(&vulkan, true, Some(0)));
|
||||
assert!(!batchable(&vulkan, false, None));
|
||||
assert!(!batchable(&gl, true, None));
|
||||
assert!(!batchable(&gl, true, Some(usize::MAX)));
|
||||
assert!(batchable(&vulkan, true));
|
||||
assert!(batchable(&metal, true));
|
||||
assert!(!batchable(&vulkan, false));
|
||||
assert!(!batchable(&gl, true));
|
||||
}
|
||||
|
||||
/// The pack-flat rule: with Translucent Batching translucent quads have an arena of
|
||||
|
||||
@@ -157,21 +157,24 @@ impl Arena {
|
||||
Self::with_usage(device, 4, capacity, usage, LARGE_PAGE_BYTES)
|
||||
}
|
||||
/// The sorted-index arena and, when `split`, the translucent arena
|
||||
/// (vertices of `stride`), both with large pages, which no shader binds;
|
||||
/// otherwise translucent quads stay in the section arena, and the indices
|
||||
/// in pages flat draws (`flat`) bind as storage.
|
||||
/// (vertices of `stride`) with large pages, which no shader binds;
|
||||
/// otherwise translucent quads stay in the section arena. The indices go in
|
||||
/// large pages too, unless flat draws (`flat`) bind their pages as storage.
|
||||
pub fn translucent_arenas(
|
||||
device: &wgpu::Device,
|
||||
stride: u64,
|
||||
flat: bool,
|
||||
split: bool,
|
||||
) -> (Self, Option<Self>) {
|
||||
if split {
|
||||
let indices = Self::translucent_indices(device, 1 << 16);
|
||||
(indices, Some(Self::translucent(device, stride, 1 << 18)))
|
||||
let indices = if split && !flat {
|
||||
Self::translucent_indices(device, 1 << 16)
|
||||
} else {
|
||||
(Self::indices(device, 1 << 16, flat), None)
|
||||
}
|
||||
Self::indices(device, 1 << 16, flat)
|
||||
};
|
||||
(
|
||||
indices,
|
||||
split.then(|| Self::translucent(device, stride, 1 << 18)),
|
||||
)
|
||||
}
|
||||
fn with_usage(
|
||||
device: &wgpu::Device,
|
||||
@@ -927,6 +930,121 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat draws find a section's origin by the id its blocks name, in the section arena's
|
||||
/// tables and, split, in the translucent arena's (`flat::TRANSLUCENT_KEYS`). Translucent
|
||||
/// quads a shrink moves (`move_off_page`) keep naming their section, or flat draws would
|
||||
/// put them at another section's origin.
|
||||
#[test]
|
||||
fn moved_translucent_quads_keep_their_section_s_flat_id() {
|
||||
use super::super::{Part, SectionGpu, flat, move_off_page, upload::Uploads};
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
"@vertex fn vs() -> @builtin(position) vec4<f32> { return vec4<f32>(0.0); }
|
||||
@fragment fn fs() -> @location(0) vec4<f32> { return vec4<f32>(0.0); }"
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let terrain_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: None,
|
||||
entries: &[],
|
||||
});
|
||||
let pipeline = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str, _, _| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline);
|
||||
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
|
||||
let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN);
|
||||
translucent.page_limit = 4 * GRAIN;
|
||||
flat.set_translucent_page_vertices(translucent.page_vertices());
|
||||
let mut uploads = Uploads::new(&device);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
// Page 0: another section's grain, then this one's two; page 1: a grain taken.
|
||||
let (_, other) = alloc(&mut translucent, &device, &queue, GRAIN).unwrap();
|
||||
let (_, at) = alloc(&mut translucent, &device, &queue, 2 * GRAIN).unwrap();
|
||||
let (_, rest) = alloc(&mut translucent, &device, &queue, GRAIN).unwrap();
|
||||
assert_eq!(
|
||||
alloc(&mut translucent, &device, &queue, GRAIN),
|
||||
Some((1, 0))
|
||||
);
|
||||
translucent.release(0, rest, GRAIN);
|
||||
let key = |page: u32| page + flat::TRANSLUCENT_KEYS;
|
||||
let other_id = flat.add(&device, &mut encoder, &mut uploads, key(0), other, GRAIN);
|
||||
// Its solid quads in the section arena's page 0, its translucent ones here.
|
||||
let id = flat.add(&device, &mut encoder, &mut uploads, 0, 0, GRAIN);
|
||||
flat.place(
|
||||
&device,
|
||||
&mut encoder,
|
||||
&mut uploads,
|
||||
id,
|
||||
key(0),
|
||||
at,
|
||||
2 * GRAIN,
|
||||
);
|
||||
let mut section = SectionGpu {
|
||||
page: 0,
|
||||
start: 0,
|
||||
translucent_at: (0, at),
|
||||
solid: 64,
|
||||
sides: crate::mesher::Sides::unsorted(64),
|
||||
translucent: 128,
|
||||
sort: super::super::translucency::SortState::new(Vec::new()),
|
||||
sorted_indices: None,
|
||||
flat_id: Some(id),
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
};
|
||||
translucent.close(0);
|
||||
let mut budget = u64::MAX;
|
||||
assert!(move_off_page(
|
||||
&mut translucent,
|
||||
&mut encoder,
|
||||
Some((&mut flat, &device, &mut uploads)),
|
||||
std::iter::once(&mut section),
|
||||
0,
|
||||
Part::Translucent,
|
||||
&mut budget
|
||||
));
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
let (page, start) = section.translucent_at;
|
||||
assert_eq!(page, 1);
|
||||
assert_eq!(section.flat_id, Some(id));
|
||||
let named =
|
||||
|page: u32, grain: u64| read(&device, &queue, flat.blocks(key(page)), grain * 4);
|
||||
for grain in start / GRAIN..(start / GRAIN + 2) {
|
||||
assert_eq!(named(1, grain), id, "grain {grain} of its new range");
|
||||
}
|
||||
// The section arena's table and the other section's grain are untouched.
|
||||
assert_eq!(read(&device, &queue, flat.blocks(0), 0), id);
|
||||
assert_eq!(named(0, other / GRAIN), other_id);
|
||||
}
|
||||
|
||||
/// Draws bind a large page up to the end of its last range in use, a grain at
|
||||
/// least: wgpu clears what a binding covers and nothing wrote when the frame is
|
||||
/// submitted, so a grown page bound whole had its new tail cleared on the GPU.
|
||||
@@ -1028,9 +1146,9 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The translucent arena comes with the split layout alone, and only then
|
||||
/// are the index pages large. Otherwise, as while a shader pack's terrain
|
||||
/// draws flat (gfx's `translucent_split`), translucent quads keep to the
|
||||
/// section arena, and flat draws (`flat`) bind the index pages as storage.
|
||||
/// are the index pages large, unless flat draws (`flat`) bind them as
|
||||
/// storage. Otherwise, as while a shader pack's terrain draws flat (gfx's
|
||||
/// `translucent_split`), translucent quads keep to the section arena.
|
||||
#[test]
|
||||
fn translucent_arenas_follow_the_layout() {
|
||||
let devices = devices();
|
||||
@@ -1050,9 +1168,9 @@ mod tests {
|
||||
assert_eq!(translucent.is_some(), split, "{case}");
|
||||
let bytes = indices.page_vertices() * 4;
|
||||
let storage = indices.usage.contains(wgpu::BufferUsages::STORAGE);
|
||||
assert_eq!(storage, flat && !split, "{case}");
|
||||
assert_eq!(storage, flat, "{case}");
|
||||
assert!(!storage || bytes <= limits.max_storage_buffer_binding_size);
|
||||
assert_eq!(bytes > PAGE_BYTES, split && large, "{case}");
|
||||
assert_eq!(bytes > PAGE_BYTES, split && !flat && large, "{case}");
|
||||
if let Some(t) = translucent {
|
||||
assert_eq!(t.page_vertices() * 24 > PAGE_BYTES, large, "{case}");
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
//!
|
||||
//! The game decides which draws come here (`SkinFrame`): models whose
|
||||
//! polygons all face an axis, drawn as ordinary opaque or cutout entities.
|
||||
//! The rest stays on the CPU path.
|
||||
//! The rest stays on the CPU path. Item models come too ([`SkinJob::item`]):
|
||||
//! their quads all face an axis, and their shading is baked into the colour.
|
||||
|
||||
use super::upload::Uploads;
|
||||
use crate::mesher::Vertex;
|
||||
@@ -35,6 +36,20 @@ pub struct SkinPart {
|
||||
pub normal: [f32; 9],
|
||||
}
|
||||
|
||||
impl SkinPart {
|
||||
/// An item layer's part: its pose, and its quads' colours by axis.
|
||||
pub fn item(matrix: glam::Mat4, colors: [u32; 6]) -> Self {
|
||||
let mut normal = [0.0; 9];
|
||||
for (word, color) in normal.iter_mut().zip(colors) {
|
||||
*word = f32::from_bits(color);
|
||||
}
|
||||
Self {
|
||||
matrix: matrix.to_cols_array(),
|
||||
normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One run of a model's vertices drawn with one texture rect, colour, light
|
||||
/// and overlay. `dst` counts from the frame's first posed vertex.
|
||||
#[repr(C)]
|
||||
@@ -51,7 +66,12 @@ pub struct SkinJob {
|
||||
pub overlay: u32,
|
||||
/// 1 when the overlay is the armour glint: the model UV rides in `glint_uv`.
|
||||
pub glint: u32,
|
||||
pub spare: [u32; 3],
|
||||
/// 1 for an item model's quads (`item_render::WorldSink`): the colour by the
|
||||
/// face's axis rides in the part's first 6 normal words ([`SkinPart::item`]),
|
||||
/// shaded on the CPU, and the normal is `mesher::CULL_BACK`.
|
||||
pub item: u32,
|
||||
/// Always 0: the shader reads the last as a zero it cannot fold away.
|
||||
pub spare: [u32; 2],
|
||||
}
|
||||
|
||||
const _: () = assert!(std::mem::size_of::<Vertex>() == 44);
|
||||
@@ -352,13 +372,19 @@ pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
|
||||
(v.uv[0] + job.scroll) % 1.0
|
||||
};
|
||||
let r = job.rect;
|
||||
let (color, normal) = if job.item != 0 {
|
||||
let color = part.normal[v.axis as usize].to_bits();
|
||||
(color, crate::mesher::CULL_BACK)
|
||||
} else {
|
||||
(job.color, [packed[0], packed[1], packed[2], -127])
|
||||
};
|
||||
out[(job.dst + k) as usize] = Vertex {
|
||||
pos: pos.to_array(),
|
||||
uv: [r[0] + (r[2] - r[0]) * u, r[1] + (r[3] - r[1]) * v.uv[1]],
|
||||
layer: job.overlay,
|
||||
color: job.color,
|
||||
color,
|
||||
light: job.light,
|
||||
normal: [packed[0], packed[1], packed[2], -127],
|
||||
normal,
|
||||
glint_uv: if job.glint != 0 { v.uv } else { [0.0, 0.0] },
|
||||
};
|
||||
}
|
||||
@@ -406,8 +432,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Two draws of a two-part model with turned, scaled parts, a scrolled
|
||||
/// texture and the armour glint: the shader writes what `expand` does,
|
||||
/// to the bit, after the vertices before them.
|
||||
/// texture and the armour glint, and an item's: the shader writes what
|
||||
/// `expand` does, to the bit, after the vertices before them.
|
||||
#[test]
|
||||
fn the_shader_writes_what_the_cpu_reference_computes() {
|
||||
let Some((device, queue)) = device() else {
|
||||
@@ -440,6 +466,18 @@ mod tests {
|
||||
part(child, normals),
|
||||
part(base * turn, glam::Mat3::from_mat4(turn * turn)),
|
||||
part(child * turn, glam::Mat3::IDENTITY),
|
||||
// Colours whose bits as floats are NaNs and denormals: copied, never computed.
|
||||
SkinPart::item(
|
||||
child * turn,
|
||||
[
|
||||
0xff80_4020,
|
||||
0x7fc0_0001,
|
||||
0x0000_0001,
|
||||
0xffff_ffff,
|
||||
0x7f80_0001,
|
||||
0x1234_5678,
|
||||
],
|
||||
),
|
||||
],
|
||||
jobs: vec![
|
||||
SkinJob {
|
||||
@@ -467,8 +505,20 @@ mod tests {
|
||||
glint: 1,
|
||||
..Default::default()
|
||||
},
|
||||
// An item's quads: colour by axis from its part, `CULL_BACK` normals.
|
||||
SkinJob {
|
||||
first: 0,
|
||||
count: 24,
|
||||
part: 4,
|
||||
dst: 72,
|
||||
rect: [0.5, 0.25, 0.5625, 0.3125],
|
||||
light: 0xa0_30,
|
||||
overlay: 2 << 16,
|
||||
item: 1,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
vertices: 72,
|
||||
vertices: 96,
|
||||
};
|
||||
let expected = expand(&model, &frame);
|
||||
// The posed vertices land after 5 others, which they leave alone.
|
||||
|
||||
@@ -15,7 +15,9 @@ const MODEL_WORDS: u32 = 7u;
|
||||
const PART_WORDS: u32 = 25u;
|
||||
// A job (16 words): first model vertex, vertex count, first part, first output
|
||||
// vertex, the texture rect (4 floats), scroll, colour, light, overlay,
|
||||
// whether the overlay is the armour glint (its model UV in `glint_uv`), 3 spare.
|
||||
// whether the overlay is the armour glint (its model UV in `glint_uv`), whether
|
||||
// it draws an item's quads (`SkinJob::item`), 2 spare (0: the last one is
|
||||
// `keep`'s).
|
||||
const JOB_WORDS: u32 = 16u;
|
||||
// `mesher::Vertex` (11 words): pos (3), uv (2), layer, color, light, normal
|
||||
// (4 x i8), glint_uv (2).
|
||||
@@ -30,6 +32,20 @@ fn f(words: u32) -> f32 {
|
||||
return bitcast<f32>(words);
|
||||
}
|
||||
|
||||
// A zero the compiler cannot see (the job's last word). Metal compiles shaders
|
||||
// with fast math: it would fuse `a * b + c` into one rounding and reorder sums,
|
||||
// a bit off the CPU's separate operations. A value xored with `keep` is opaque
|
||||
// to it, so each operation below rounds on its own, in the CPU's order.
|
||||
var<private> keep: u32;
|
||||
|
||||
fn r(v: f32) -> f32 {
|
||||
return bitcast<f32>(bitcast<u32>(v) ^ keep);
|
||||
}
|
||||
|
||||
fn r3(v: vec3<f32>) -> vec3<f32> {
|
||||
return bitcast<vec3<f32>>(bitcast<vec3<u32>>(v) ^ vec3(keep));
|
||||
}
|
||||
|
||||
fn part_f(part: u32, i: u32) -> f32 {
|
||||
return f(parts[part * PART_WORDS + i]);
|
||||
}
|
||||
@@ -40,8 +56,8 @@ fn scroll_u(u: f32, scroll: f32) -> f32 {
|
||||
return u;
|
||||
}
|
||||
// Rust's `%` on floats: the remainder with the dividend's sign.
|
||||
let s = u + scroll;
|
||||
return s - trunc(s);
|
||||
let s = r(u + scroll);
|
||||
return r(s - r(trunc(s)));
|
||||
}
|
||||
|
||||
// `Polygon::vertex_normal`'s packing: clamped, times 127, cut toward zero.
|
||||
@@ -72,33 +88,42 @@ fn skin(
|
||||
let light = jobs[j + 10u];
|
||||
let overlay = jobs[j + 11u];
|
||||
let glint = jobs[j + 12u] != 0u;
|
||||
let item = jobs[j + 13u] != 0u;
|
||||
keep = jobs[j + 15u];
|
||||
for (var k = lane; k < count; k += 64u) {
|
||||
let v = (first + k) * MODEL_WORDS;
|
||||
let p = vec3(f(model[v]), f(model[v + 1u]), f(model[v + 2u]));
|
||||
let uv = vec2(f(model[v + 3u]), f(model[v + 4u]));
|
||||
let axis = model[v + 5u];
|
||||
let part = first_part + model[v + 6u];
|
||||
// glam's Mat4::transform_point3, in its order.
|
||||
var pos = vec3(part_f(part, 0u), part_f(part, 1u), part_f(part, 2u)) * p.x;
|
||||
pos = vec3(part_f(part, 4u), part_f(part, 5u), part_f(part, 6u)) * p.y + pos;
|
||||
pos = vec3(part_f(part, 8u), part_f(part, 9u), part_f(part, 10u)) * p.z + pos;
|
||||
pos = vec3(part_f(part, 12u), part_f(part, 13u), part_f(part, 14u)) + pos;
|
||||
// glam's Mat4::transform_point3, in its order, each operation rounded.
|
||||
var pos = r3(vec3(part_f(part, 0u), part_f(part, 1u), part_f(part, 2u)) * p.x);
|
||||
pos = r3(r3(vec3(part_f(part, 4u), part_f(part, 5u), part_f(part, 6u)) * p.y) + pos);
|
||||
pos = r3(r3(vec3(part_f(part, 8u), part_f(part, 9u), part_f(part, 10u)) * p.z) + pos);
|
||||
pos = r3(vec3(part_f(part, 12u), part_f(part, 13u), part_f(part, 14u)) + pos);
|
||||
// An axis normal times the normal matrix is that column, exactly.
|
||||
let c = 16u + (axis % 3u) * 3u;
|
||||
var n = vec3(part_f(part, c), part_f(part, c + 1u), part_f(part, c + 2u));
|
||||
if axis >= 3u {
|
||||
n = -n;
|
||||
}
|
||||
// An item's colour by axis, shaded on the CPU, and `mesher::CULL_BACK`.
|
||||
var vertex_color = color;
|
||||
var normal = pack(n);
|
||||
if item {
|
||||
vertex_color = parts[part * PART_WORDS + 16u + axis];
|
||||
normal = 1u << 24u;
|
||||
}
|
||||
let o = (dst + k) * VERTEX_WORDS;
|
||||
out[o] = bitcast<u32>(pos.x);
|
||||
out[o + 1u] = bitcast<u32>(pos.y);
|
||||
out[o + 2u] = bitcast<u32>(pos.z);
|
||||
out[o + 3u] = bitcast<u32>(rect.x + (rect.z - rect.x) * scroll_u(uv.x, scroll));
|
||||
out[o + 4u] = bitcast<u32>(rect.y + (rect.w - rect.y) * uv.y);
|
||||
out[o + 3u] = bitcast<u32>(rect.x + r(r(rect.z - rect.x) * scroll_u(uv.x, scroll)));
|
||||
out[o + 4u] = bitcast<u32>(rect.y + r(r(rect.w - rect.y) * uv.y));
|
||||
out[o + 5u] = overlay;
|
||||
out[o + 6u] = color;
|
||||
out[o + 6u] = vertex_color;
|
||||
out[o + 7u] = light;
|
||||
out[o + 8u] = pack(n);
|
||||
out[o + 8u] = normal;
|
||||
if glint {
|
||||
out[o + 9u] = bitcast<u32>(uv.x);
|
||||
out[o + 10u] = bitcast<u32>(uv.y);
|
||||
|
||||
@@ -48,6 +48,10 @@ struct Plan {
|
||||
}
|
||||
const QUADS: u32 = u32::MAX;
|
||||
const GROUP_LIMIT: u32 = 65535;
|
||||
/// The page keys of the translucent arena (`Renderer::translucent_arena`, Translucent
|
||||
/// Batching): its page plus this. The section arena's pages are their own keys, all far
|
||||
/// below it. A call names its key: its vertices come from that arena's page.
|
||||
pub const TRANSLUCENT_KEYS: u32 = 1 << 10;
|
||||
|
||||
struct PageTable {
|
||||
/// Section id of every block of the page.
|
||||
@@ -67,6 +71,7 @@ pub(super) const ALPHA_ONLY: wgpu::BlendState = wgpu::BlendState {
|
||||
/// A run of the frame's index list drawn from one arena page.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Call {
|
||||
/// The page's key (`TRANSLUCENT_KEYS`).
|
||||
pub page: u32,
|
||||
pub first: u32,
|
||||
pub count: u32,
|
||||
@@ -98,6 +103,9 @@ pub struct Flat {
|
||||
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
|
||||
pub(super) draw_layout: wgpu::BindGroupLayout,
|
||||
blocks_per_page: u64,
|
||||
/// Blocks of a translucent arena page (`TRANSLUCENT_KEYS`), whose pages are larger.
|
||||
translucent_blocks_per_page: u64,
|
||||
/// Block tables by page key.
|
||||
pages: Vec<Option<PageTable>>,
|
||||
free_ids: Vec<u32>,
|
||||
next_id: u32,
|
||||
@@ -218,7 +226,14 @@ fn buffer(
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size: size.max(16),
|
||||
usage: usage | wgpu::BufferUsages::COPY_DST,
|
||||
// Tests read the tables back.
|
||||
usage: usage
|
||||
| wgpu::BufferUsages::COPY_DST
|
||||
| if cfg!(test) {
|
||||
wgpu::BufferUsages::COPY_SRC
|
||||
} else {
|
||||
wgpu::BufferUsages::empty()
|
||||
},
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
@@ -242,6 +257,7 @@ fn plan(
|
||||
shadow: [usize; 2],
|
||||
passes: [usize; 2],
|
||||
align: u64,
|
||||
key: impl Fn(u32, Option<u32>) -> u32,
|
||||
calls: &mut Vec<Call>,
|
||||
ranges: &mut Vec<Range>,
|
||||
segments: &mut Vec<(Source, u64, u64)>,
|
||||
@@ -273,6 +289,7 @@ fn plan(
|
||||
}
|
||||
let starts =
|
||||
i == phase1_draws || i == solid_draws || shadow.contains(&i) || passes.contains(&i);
|
||||
let page = key(page, sorted);
|
||||
match calls.last_mut() {
|
||||
Some(last) if last.page == page && !starts => last.count += d[0],
|
||||
_ => calls.push(Call {
|
||||
@@ -451,6 +468,7 @@ impl Flat {
|
||||
phase2_group: None,
|
||||
draw_layout,
|
||||
blocks_per_page: page_vertices.div_ceil(GRAIN),
|
||||
translucent_blocks_per_page: page_vertices.div_ceil(GRAIN),
|
||||
pages: Vec::new(),
|
||||
free_ids: Vec::new(),
|
||||
next_id: 0,
|
||||
@@ -479,8 +497,8 @@ impl Flat {
|
||||
.max(1),
|
||||
}
|
||||
}
|
||||
/// A section uploaded to `vertices` vertices from `start` of `page`: its
|
||||
/// id, which its blocks now name (written through `uploads` in `encoder`).
|
||||
/// A section uploaded to `vertices` vertices from `start` of the page keyed `page`:
|
||||
/// its id, which its blocks now name (written through `uploads` in `encoder`).
|
||||
pub fn add(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
@@ -494,6 +512,28 @@ impl Flat {
|
||||
self.next_id += 1;
|
||||
self.next_id - 1
|
||||
});
|
||||
self.place(device, encoder, uploads, id, page, start, vertices);
|
||||
id
|
||||
}
|
||||
/// Makes the blocks of `vertices` vertices from `start` of the page keyed `page` name
|
||||
/// `id`: a section's other range (its translucent quads, in the translucent arena), or
|
||||
/// a range that moved.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn place(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
id: u32,
|
||||
page: u32,
|
||||
start: u64,
|
||||
vertices: u64,
|
||||
) {
|
||||
let blocks = if page >= TRANSLUCENT_KEYS {
|
||||
self.translucent_blocks_per_page
|
||||
} else {
|
||||
self.blocks_per_page
|
||||
};
|
||||
let page = page as usize;
|
||||
if self.pages.len() <= page {
|
||||
self.pages.resize_with(page + 1, || None);
|
||||
@@ -502,7 +542,7 @@ impl Flat {
|
||||
blocks: buffer(
|
||||
device,
|
||||
"Section ids by block",
|
||||
self.blocks_per_page * 4,
|
||||
blocks * 4,
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
),
|
||||
group: None,
|
||||
@@ -515,7 +555,19 @@ impl Flat {
|
||||
first * 4,
|
||||
bytemuck::cast_slice(&ids),
|
||||
);
|
||||
id
|
||||
}
|
||||
/// The block table of the page keyed `page`, for tests that read it back.
|
||||
#[cfg(test)]
|
||||
pub(super) fn blocks(&self, page: u32) -> &wgpu::Buffer {
|
||||
&self.pages[page as usize]
|
||||
.as_ref()
|
||||
.expect("a page table")
|
||||
.blocks
|
||||
}
|
||||
/// The most vertices a page of the translucent arena holds: its block tables' size.
|
||||
/// Tables made before keep theirs, so this goes with `clear`.
|
||||
pub fn set_translucent_page_vertices(&mut self, vertices: u64) {
|
||||
self.translucent_blocks_per_page = vertices.div_ceil(GRAIN);
|
||||
}
|
||||
pub fn remove(&mut self, id: u32) {
|
||||
self.free_ids.push(id);
|
||||
@@ -525,7 +577,7 @@ impl Flat {
|
||||
self.free_ids.clear();
|
||||
self.next_id = 0;
|
||||
}
|
||||
/// Forgets the tables of pages the arena released.
|
||||
/// Forgets the tables of pages the arenas released (`live`, by page key).
|
||||
pub fn trim(&mut self, live: impl Fn(u32) -> bool) {
|
||||
for (i, page) in self.pages.iter_mut().enumerate() {
|
||||
if page.is_some() && !live(i as u32) {
|
||||
@@ -556,6 +608,7 @@ impl Flat {
|
||||
shadow: [usize; 2],
|
||||
passes: [usize; 2],
|
||||
phase2: Option<&wgpu::Buffer>,
|
||||
translucent_arena: bool,
|
||||
origins: impl Iterator<Item = (u32, [f32; 4])>,
|
||||
sorted_page: impl Fn(u32) -> wgpu::Buffer,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
@@ -609,6 +662,11 @@ impl Flat {
|
||||
shadow,
|
||||
passes,
|
||||
self.range_align,
|
||||
// Translucent draws, and only they, have sorted indices.
|
||||
|page, sorted| match sorted {
|
||||
Some(_) if translucent_arena => page + TRANSLUCENT_KEYS,
|
||||
_ => page,
|
||||
},
|
||||
&mut self.calls,
|
||||
&mut self.range_data,
|
||||
&mut self.segments,
|
||||
@@ -801,7 +859,7 @@ impl Flat {
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
calls: &[Call],
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
|
||||
) {
|
||||
self.draw_with(pass, calls, vertices, |_, _| {});
|
||||
}
|
||||
@@ -811,7 +869,7 @@ impl Flat {
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
calls: &[Call],
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
|
||||
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
|
||||
) {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
@@ -828,7 +886,7 @@ impl Flat {
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
calls: &[Call],
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
|
||||
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
|
||||
instances: u32,
|
||||
) {
|
||||
@@ -842,7 +900,7 @@ impl Flat {
|
||||
pub fn draw_phase2<'a>(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
|
||||
) {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for (k, call) in self.calls[self.phase1_calls..self.solid_calls]
|
||||
@@ -857,13 +915,13 @@ impl Flat {
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
page: u32,
|
||||
vertices: &impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
vertices: &impl Fn(u32) -> wgpu::BufferSlice<'a>,
|
||||
) {
|
||||
let table = self.pages[page as usize]
|
||||
.as_ref()
|
||||
.and_then(|t| t.group.as_ref())
|
||||
.expect("prepared page");
|
||||
pass.set_vertex_buffer(0, vertices(page).slice(..));
|
||||
pass.set_vertex_buffer(0, vertices(page));
|
||||
pass.set_bind_group(1, table, &[]);
|
||||
}
|
||||
}
|
||||
@@ -912,6 +970,7 @@ mod tests {
|
||||
[draws.len(); 2],
|
||||
[draws.len(), 0],
|
||||
4,
|
||||
|page, _| page,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
@@ -960,6 +1019,7 @@ mod tests {
|
||||
[draws.len(); 2],
|
||||
[3, 1],
|
||||
4,
|
||||
|page, _| page,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
@@ -995,6 +1055,7 @@ mod tests {
|
||||
[camera, camera + 2],
|
||||
[camera, 0],
|
||||
4,
|
||||
|page, _| page,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
@@ -1023,6 +1084,7 @@ mod tests {
|
||||
[draws.len(); 2],
|
||||
[draws.len(), 0],
|
||||
4,
|
||||
|page, _| page,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
@@ -1140,6 +1202,7 @@ mod tests {
|
||||
[draws.len(); 2],
|
||||
[draws.len(), 0],
|
||||
align,
|
||||
|page, _| page,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
|
||||
@@ -7,13 +7,19 @@ use crate::{
|
||||
mesher::SectionMesh,
|
||||
};
|
||||
use glam::{DVec3, Vec3};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// Finished tile meshes not uploaded yet, the tiles they replace (removed
|
||||
/// once every mesh is in) and the columns they leave out.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Pending {
|
||||
meshes: FxHashMap<TileKey, SectionMesh>,
|
||||
/// The order they go up in: as handed over (`Lod::update`: finer tiles
|
||||
/// first, so no tile clipped around a finer one is drawn before it).
|
||||
order: VecDeque<TileKey>,
|
||||
removed: FxHashSet<TileKey>,
|
||||
cutout: Option<FxHashSet<(i32, i32)>>,
|
||||
}
|
||||
@@ -30,7 +36,9 @@ impl Pending {
|
||||
) {
|
||||
for (key, mesh) in meshes {
|
||||
self.removed.remove(&key);
|
||||
self.meshes.insert(key, mesh);
|
||||
if self.meshes.insert(key, mesh).is_none() {
|
||||
self.order.push_back(key);
|
||||
}
|
||||
}
|
||||
for key in removed {
|
||||
self.meshes.remove(&key);
|
||||
@@ -330,7 +338,10 @@ impl Renderer {
|
||||
.copied()
|
||||
.collect();
|
||||
while let Some(pending) = &mut self.lod_pending {
|
||||
let Some(&key) = pending.meshes.keys().next() else {
|
||||
// Keys meshed again keep their place; removed ones are skipped.
|
||||
let next = std::iter::from_fn(|| pending.order.pop_front())
|
||||
.find(|key| pending.meshes.contains_key(key));
|
||||
let Some(key) = next else {
|
||||
let pending = self.lod_pending.take().expect("pending tiles");
|
||||
// A tile fades in over the ones it replaces. One over nothing
|
||||
// shows whole: its hidden chunks would open holes to the sky
|
||||
@@ -498,7 +509,16 @@ mod tests {
|
||||
let mut removed: Vec<_> = pending.removed.iter().map(|k| k.x).collect();
|
||||
removed.sort_unstable();
|
||||
assert_eq!((meshes, removed), (vec![1, 3], vec![2, 4]));
|
||||
assert!(pending.cutout.is_some_and(|c| c.contains(&(0, 0))));
|
||||
assert!(pending.cutout.as_ref().is_some_and(|c| c.contains(&(0, 0))));
|
||||
// They go up in the order handed over, each once.
|
||||
pending.add(vec![(key(1), SectionMesh::default())], vec![], None);
|
||||
let order: Vec<_> = pending
|
||||
.order
|
||||
.iter()
|
||||
.filter(|k| pending.meshes.contains_key(k))
|
||||
.map(|k| k.x)
|
||||
.collect();
|
||||
assert_eq!(order, [1, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -544,7 +544,7 @@ pub struct Graphics {
|
||||
/// How finely far terrain is drawn; Full unless the player picks Reduced.
|
||||
pub far_detail: FarDetail,
|
||||
/// Far terrain from servers' BlueMap web maps; no server has one unless
|
||||
/// the player names it (`blueMap:` lines).
|
||||
/// the player names it (`blueMap:` lines) or turns on auto-detect.
|
||||
pub blue_map: crate::bluemap::Settings,
|
||||
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
|
||||
pub cave_culling: bool,
|
||||
@@ -737,6 +737,7 @@ impl Graphics {
|
||||
g.blue_map.radius = radius;
|
||||
}
|
||||
}
|
||||
"blueMapAutoDetect" => g.blue_map.auto_detect = flag(g.blue_map.auto_detect),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
|
||||
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
|
||||
@@ -1010,6 +1011,7 @@ mod tests {
|
||||
far_detail: FarDetail::Reduced,
|
||||
blue_map: crate::bluemap::Settings {
|
||||
radius: 40,
|
||||
auto_detect: true,
|
||||
// In their own file (`bluemap::SERVERS_FILE`).
|
||||
servers: Vec::new(),
|
||||
},
|
||||
|
||||
@@ -28,6 +28,8 @@ pub enum H {
|
||||
Preset,
|
||||
LodDistance,
|
||||
FarDetail,
|
||||
BlueMapAutoDetect,
|
||||
BlueMapRadius,
|
||||
CaveCulling,
|
||||
EntityCulling,
|
||||
LeafCulling,
|
||||
@@ -190,6 +192,16 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
render,
|
||||
lod,
|
||||
far,
|
||||
h(
|
||||
H::BlueMapAutoDetect,
|
||||
None,
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.",
|
||||
),
|
||||
h(
|
||||
H::BlueMapRadius,
|
||||
None,
|
||||
"How far around you BlueMap tiles are downloaded. Next join.",
|
||||
),
|
||||
v(
|
||||
Opt::SimulationDistance,
|
||||
Medium,
|
||||
@@ -430,6 +442,8 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
rows
|
||||
}
|
||||
|
||||
/// BlueMap download radii, in chunks.
|
||||
const BLUE_MAP_RADII: [u32; 5] = [16, 32, 64, 128, 256];
|
||||
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
|
||||
/// What the LOD Distance slider stops at, in chunks: off, then each doubling
|
||||
/// up to the whole world.
|
||||
@@ -596,6 +610,11 @@ impl H {
|
||||
n => labelled("LOD Distance", lang::tr("%s chunks", &[&n])),
|
||||
},
|
||||
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
|
||||
H::BlueMapAutoDetect => labelled("Find BlueMap", on_off(g.blue_map.auto_detect)),
|
||||
H::BlueMapRadius => labelled(
|
||||
"BlueMap Radius",
|
||||
lang::tr("%s chunks", &[&g.blue_map.radius_chunks()]),
|
||||
),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
|
||||
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
|
||||
@@ -743,6 +762,10 @@ impl H {
|
||||
H::FarDetail => {
|
||||
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
|
||||
}
|
||||
H::BlueMapAutoDetect => g.blue_map.auto_detect = !g.blue_map.auto_detect,
|
||||
H::BlueMapRadius => {
|
||||
g.blue_map.radius = step(&BLUE_MAP_RADII, g.blue_map.radius_chunks(), back)
|
||||
}
|
||||
H::CaveCulling => g.cave_culling = !g.cave_culling,
|
||||
H::EntityCulling => {
|
||||
g.entity_culling = step(&EntityCullingMode::ALL, g.entity_culling, back)
|
||||
@@ -855,6 +878,7 @@ impl Item {
|
||||
pub fn switch(self, s: &Settings) -> Option<bool> {
|
||||
let g = &s.horizon;
|
||||
Some(match self {
|
||||
Item::Horizon(H::BlueMapAutoDetect) => g.blue_map.auto_detect,
|
||||
Item::Horizon(H::CaveCulling) => g.cave_culling,
|
||||
Item::Horizon(H::CacheShulkerBoxes) => g.cache_shulker_boxes,
|
||||
Item::Horizon(H::CacheSigns) => g.cache_signs,
|
||||
@@ -926,6 +950,8 @@ impl Item {
|
||||
self,
|
||||
Item::Horizon(
|
||||
H::Preset
|
||||
| H::BlueMapAutoDetect
|
||||
| H::BlueMapRadius
|
||||
| H::Overlay
|
||||
| H::ScreenshotQuality
|
||||
| H::Pacing
|
||||
@@ -1896,6 +1922,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blue_map_settings_are_advanced_rendering_options() {
|
||||
let mut s = Settings::default();
|
||||
let find = Item::Horizon(H::BlueMapAutoDetect);
|
||||
let radius = Item::Horizon(H::BlueMapRadius);
|
||||
let advanced: Vec<_> = rows(Level::Advanced)
|
||||
.into_iter()
|
||||
.filter_map(|row| match row {
|
||||
Row::Setting(setting) => Some(setting.item),
|
||||
Row::Header(_) => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(advanced.contains(&find) && advanced.contains(&radius));
|
||||
assert_eq!(find.switch(&s), Some(false), "off by default");
|
||||
assert_eq!(radius.label(&s).plain(), "BlueMap Radius: 64 chunks");
|
||||
find.cycle(&mut s, false, [854, 480]);
|
||||
assert!(s.horizon.blue_map.auto_detect);
|
||||
radius.cycle(&mut s, false, [854, 480]);
|
||||
assert_eq!(s.horizon.blue_map.radius, 128);
|
||||
radius.cycle(&mut s, true, [854, 480]);
|
||||
radius.cycle(&mut s, true, [854, 480]);
|
||||
assert_eq!(radius.label(&s).plain(), "BlueMap Radius: 32 chunks");
|
||||
assert_eq!(s.horizon.preset, Preset::Custom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_set_vanilla_and_horizon_options() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -132,7 +132,7 @@ pub const PACK_ICON_SIZE: u32 = 128;
|
||||
/// size of the largest trim texture.
|
||||
pub const TRIM_SLOTS: usize = 32;
|
||||
pub fn decode(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
decode_bytes(&fs::read(path)?)
|
||||
decode_bytes(&horizon_core::prepared_files::read(path)?)
|
||||
}
|
||||
/// A PNG in memory as RGBA pixels.
|
||||
pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
|
||||
@@ -184,9 +184,10 @@ fn within_budget(files: &[&PathBuf], budget: u64) -> HashSet<PathBuf> {
|
||||
}
|
||||
/// A PNG's width and height, from its header alone.
|
||||
fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
use std::io::Read;
|
||||
let mut header = [0u8; 24];
|
||||
fs::File::open(path).ok()?.read_exact(&mut header).ok()?;
|
||||
let header: [u8; 24] = horizon_core::prepared_files::read_head(path, 24)
|
||||
.ok()?
|
||||
.try_into()
|
||||
.ok()?;
|
||||
let field = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
|
||||
(header[..8] == *b"\x89PNG\r\n\x1a\n" && header[12..16] == *b"IHDR")
|
||||
.then(|| (field(16), field(20)))
|
||||
@@ -365,7 +366,8 @@ fn cit_sprite(root: &Path, id: &str) -> Option<(PackedImage, Option<TextureFlags
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
|
||||
let mcmeta =
|
||||
horizon_core::prepared_files::read_to_string(&path.with_extension("png.mcmeta")).ok();
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
// Only an animation is cut to its first frame: a still CIT texture is drawn whole, and
|
||||
// armour layers are twice as wide as they are tall.
|
||||
@@ -463,7 +465,10 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
|
||||
// Animated strips keep their first frame and animate,
|
||||
// like the packed files.
|
||||
if path.starts_with("block/") || path.starts_with("item/") {
|
||||
let mcmeta = fs::read_to_string(file.with_extension("png.mcmeta")).ok();
|
||||
let mcmeta = horizon_core::prepared_files::read_to_string(
|
||||
&file.with_extension("png.mcmeta"),
|
||||
)
|
||||
.ok();
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
animated = animation.is_some();
|
||||
first_frame(image, animation.as_ref())
|
||||
@@ -528,7 +533,62 @@ fn galactic_widths(
|
||||
}
|
||||
widths
|
||||
}
|
||||
/// `TextureAtlas.dumpContents` (`F3 + S`, vanilla's "dynamic textures"): the atlas
|
||||
/// written as `name.png` beside `name.txt`, every sprite in name order with its
|
||||
/// rect in texels (`dumpSpriteNames`). Vanilla names each file after its atlas
|
||||
/// with `/` and `:` as `_` (`Identifier.toDebugFileName`); Horizon packs the GUI,
|
||||
/// font, particle, map and skin sprites into one atlas, so one file stands in for
|
||||
/// the atlases vanilla dumps apart.
|
||||
pub fn dump_atlas(
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
width: u32,
|
||||
height: u32,
|
||||
pixels: &[u8],
|
||||
sprites: &HashMap<String, Rect>,
|
||||
) -> std::io::Result<PathBuf> {
|
||||
// A build whose pixels were freed (`fonts`) has the size and no texels:
|
||||
// write nothing rather than a file every reader would reject.
|
||||
if pixels.len() != (width as usize) * (height as usize) * 4 {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"wrong data size, expected {} got {}",
|
||||
(width as usize) * (height as usize) * 4,
|
||||
pixels.len()
|
||||
)));
|
||||
}
|
||||
fs::create_dir_all(dir)?;
|
||||
let png_path = dir.join(format!("{name}.png"));
|
||||
let mut encoder = png::Encoder::new(fs::File::create(&png_path)?, width, height);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().map_err(std::io::Error::other)?;
|
||||
writer
|
||||
.write_image_data(pixels)
|
||||
.map_err(std::io::Error::other)?;
|
||||
drop(writer);
|
||||
let mut names = sprites.iter().collect::<Vec<_>>();
|
||||
names.sort_by(|a, b| a.0.cmp(b.0));
|
||||
let mut list = String::new();
|
||||
for (sprite, rect) in names {
|
||||
let (w, h) = rect.texels((width, height));
|
||||
let x = (rect.u0 * width as f32).round() as u32;
|
||||
let y = (rect.v0 * height as f32).round() as u32;
|
||||
list.push_str(&format!("{sprite}\tx={x}\ty={y}\tw={w}\th={h}\n"));
|
||||
}
|
||||
fs::write(dir.join(format!("{name}.txt")), list)?;
|
||||
Ok(png_path)
|
||||
}
|
||||
|
||||
impl HudAtlas {
|
||||
/// `TextureAtlas.dumpContents` for this atlas: `dir` is `screenshots/debug`,
|
||||
/// the folder `TextureUtil.getDebugTexturePath` names.
|
||||
pub fn dump_contents(&self, dir: &Path, name: &str) -> std::io::Result<PathBuf> {
|
||||
// `pixels()`: the copy `pack_pixels` compressed once the GPU had the
|
||||
// atlas, decompressed; vanilla reads the texture back from the GPU.
|
||||
let pixels = self.pixels();
|
||||
dump_atlas(dir, name, self.width, self.height, &pixels, &self.sprites)
|
||||
}
|
||||
|
||||
fn next_revision() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
@@ -960,7 +1020,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/");
|
||||
@@ -2560,6 +2622,51 @@ impl HudRenderer {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dumping_the_atlas_writes_its_pixels_and_sprite_rects() {
|
||||
let root = std::env::temp_dir().join("horizon-atlas-dump-test");
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let pixels: Vec<u8> = (0..4u8).flat_map(|i| [i, 0, 0, 255]).collect();
|
||||
let rect = |u0: f32, v0: f32| Rect {
|
||||
u0,
|
||||
v0,
|
||||
u1: u0 + 0.5,
|
||||
v1: v0 + 0.5,
|
||||
w: 8,
|
||||
h: 8,
|
||||
};
|
||||
let sprites = HashMap::from([
|
||||
("particle/smoke".to_string(), rect(0.5, 0.0)),
|
||||
("gui/sprites/bars/experience".to_string(), rect(0.0, 0.5)),
|
||||
]);
|
||||
let path = dump_atlas(
|
||||
&root,
|
||||
"minecraft_textures_atlas_gui",
|
||||
2,
|
||||
2,
|
||||
&pixels,
|
||||
&sprites,
|
||||
)
|
||||
.expect("the dump writes");
|
||||
assert_eq!(path, root.join("minecraft_textures_atlas_gui.png"));
|
||||
let written = fs::read(&path).expect("the PNG is there");
|
||||
let (width, height, rgba) = decode_bytes(&written).expect("the PNG decodes");
|
||||
assert_eq!((width, height), (2, 2));
|
||||
assert_eq!(rgba, pixels);
|
||||
// Sorted by sprite name, rects in texels, as `dumpSpriteNames` writes them.
|
||||
let mut expected = String::from("gui/sprites/bars/experience\tx=0\ty=1\tw=1\th=1\n");
|
||||
expected.push_str("particle/smoke\tx=1\ty=0\tw=1\th=1\n");
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("minecraft_textures_atlas_gui.txt")).unwrap(),
|
||||
expected
|
||||
);
|
||||
// A build with no texels in hand writes no file at all.
|
||||
let empty = dump_atlas(&root, "empty", 2, 2, &[], &sprites);
|
||||
assert!(empty.is_err());
|
||||
assert!(!root.join("empty.png").exists());
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_bytes_ignores_crc_checksum_error() {
|
||||
let mut encoded = Vec::new();
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod block_entity_anim;
|
||||
pub mod block_entity_cem;
|
||||
pub mod block_entity_render;
|
||||
pub mod bluemap;
|
||||
pub mod bluemap_detect;
|
||||
pub mod border_render;
|
||||
pub mod breaking;
|
||||
pub mod brewing;
|
||||
|
||||
@@ -25,6 +25,9 @@ const MAX_SAVED: usize = 1024;
|
||||
/// How many resident chunks may wait for the worker's summary at once
|
||||
/// (`Lod::observing`).
|
||||
pub const OBSERVE_AHEAD: usize = 8;
|
||||
/// How long a tile whose sample waited for data (`Lod::finish_waited`) waits
|
||||
/// before it is sampled again without word of it.
|
||||
const WAIT_RETRY: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const SAMPLE_JOBS: usize = 4;
|
||||
/// Sample jobs queued per sampling thread once the near terrain is in.
|
||||
/// Results are taken and jobs started once a frame: with four in flight, a
|
||||
@@ -389,7 +392,7 @@ impl Column {
|
||||
}
|
||||
}
|
||||
/// A chunk's 256 columns (`Column::from_chunk`), on the far-terrain worker.
|
||||
fn summarize(chunk: &Chunk, kinds: &[u8]) -> Arc<[Column; 256]> {
|
||||
pub(crate) fn summarize(chunk: &Chunk, kinds: &[u8]) -> Arc<[Column; 256]> {
|
||||
let water = water_source(kinds);
|
||||
Arc::new(std::array::from_fn(|i| {
|
||||
Column::from_chunk(chunk, kinds, water, (i & 15) as i32, (i >> 4) as i32)
|
||||
@@ -414,7 +417,7 @@ const AIR: u8 = 4;
|
||||
const WATERY: u8 = 8;
|
||||
/// What `Column::from_chunk` needs of each block state, read from the facts
|
||||
/// once: matching their names for every block made observing a chunk slow.
|
||||
fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|
||||
pub(crate) fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|
||||
(0..facts.states.len())
|
||||
.map(|i| {
|
||||
let state = BlockStateId(i as u32);
|
||||
@@ -479,6 +482,41 @@ fn forget_detail(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The tiles over `chunk` to sample again once their source keeps it
|
||||
/// (`Lod::resample_over`): those whose sample is still being taken, maybe
|
||||
/// from before, and those of cells wider than a chunk sampled empty there,
|
||||
/// which a chunk's own detail can't fill (`apply_detail`). The others hold
|
||||
/// it once `finish_detail` applies it.
|
||||
fn resampled_over(
|
||||
ready: &FxHashMap<TileKey, Arc<Vec<Column>>>,
|
||||
pending: &FxHashSet<TileKey>,
|
||||
(cx, cz): (i32, i32),
|
||||
) -> Vec<TileKey> {
|
||||
(1..=MAX_LEVEL)
|
||||
.filter_map(|level| {
|
||||
let size = 16 << level;
|
||||
let key = TileKey {
|
||||
x: (cx * 16).div_euclid(size),
|
||||
z: (cz * 16).div_euclid(size),
|
||||
level,
|
||||
};
|
||||
let resample = match ready.get(&key) {
|
||||
None => pending.contains(&key),
|
||||
Some(samples) => {
|
||||
let stride = size / CELLS as i32;
|
||||
let [ox, _, oz] = key.origin();
|
||||
let cell = (cz * 16 - oz) / stride * CELLS as i32 + (cx * 16 - ox) / stride;
|
||||
stride > 16
|
||||
&& (pending.contains(&key)
|
||||
|| samples
|
||||
.get(cell as usize)
|
||||
.is_some_and(|c| *c == Column::default()))
|
||||
}
|
||||
};
|
||||
resample.then_some(key)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct TileKey {
|
||||
pub x: i32,
|
||||
@@ -518,6 +556,18 @@ impl TileKey {
|
||||
let size = self.size();
|
||||
ox < x1 && oz < z1 && ox + size > x0 && oz + size > z0
|
||||
}
|
||||
/// Whether new data for `area` drawn in cells `widths` wide changes the
|
||||
/// tile's samples: its cells are that wide, and it covers `area` or the
|
||||
/// blocks around it a cell's depth reads (`bluemap::SKIRT`: a block
|
||||
/// around a chunk, the next cells' middles past one).
|
||||
fn refreshed_by(self, area: [i32; 4], widths: (i32, i32)) -> bool {
|
||||
let cell = self.size() / CELLS as i32;
|
||||
(widths.0..=widths.1).contains(&cell) && self.overlaps(Self::around(area, cell))
|
||||
}
|
||||
fn around([x0, z0, x1, z1]: [i32; 4], cell: i32) -> [i32; 4] {
|
||||
let m = if cell <= 16 { 1 } else { cell };
|
||||
[x0 - m, z0 - m, x1 + m, z1 + m]
|
||||
}
|
||||
fn distance(self, camera: [i32; 2]) -> i32 {
|
||||
let [x, _, z] = self.origin();
|
||||
(x - camera[0])
|
||||
@@ -870,6 +920,8 @@ enum Done {
|
||||
Sample {
|
||||
key: TileKey,
|
||||
samples: Vec<Column>,
|
||||
/// Some of it is still on its way (`LodSampler::waited`).
|
||||
waited: bool,
|
||||
},
|
||||
Mesh {
|
||||
key: TileKey,
|
||||
@@ -987,6 +1039,8 @@ pub struct Lod {
|
||||
/// Sampled tiles whose source has more since (`refresh`): sampled again,
|
||||
/// their old samples drawn meanwhile.
|
||||
stale: FxHashSet<TileKey>,
|
||||
/// Stale tiles `refresh`ed again while sampled: that sample may be older.
|
||||
restale: FxHashSet<TileKey>,
|
||||
active: FxHashSet<TileKey>,
|
||||
/// Tiles needing a mesh, with the revision their job must carry.
|
||||
dirty: FxHashMap<TileKey, u64>,
|
||||
@@ -1038,6 +1092,9 @@ pub struct Lod {
|
||||
saved: Saved,
|
||||
reduced: Reduced,
|
||||
saved_order: VecDeque<(i32, i32)>,
|
||||
/// `LodSource::keeps_observed`: tiles sampled without an observed chunk
|
||||
/// are sampled again (`resample_over`).
|
||||
keeps_observed: bool,
|
||||
}
|
||||
impl Lod {
|
||||
pub fn new(source: Arc<dyn LodSource>, context: Arc<WorkerContext>) -> Self {
|
||||
@@ -1054,6 +1111,9 @@ impl Lod {
|
||||
context: Option<Arc<WorkerContext>>,
|
||||
workers: bool,
|
||||
) -> Self {
|
||||
let keeps_observed = source
|
||||
.as_ref()
|
||||
.is_some_and(|source| source.keeps_observed());
|
||||
let cores = thread::available_parallelism().map_or(1, |n| n.get());
|
||||
let shared = Arc::new(Shared {
|
||||
jobs: Mutex::new(VecDeque::new()),
|
||||
@@ -1216,7 +1276,15 @@ impl Lod {
|
||||
if shared.stop.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if tx.send(Done::Sample { key, samples }).is_err() {
|
||||
let waited = sample.waited();
|
||||
if tx
|
||||
.send(Done::Sample {
|
||||
key,
|
||||
samples,
|
||||
waited,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1291,7 +1359,12 @@ impl Lod {
|
||||
);
|
||||
let samples =
|
||||
sampler.grid(x, z, key.size() / CELLS as i32, CELLS);
|
||||
Done::Sample { key, samples }
|
||||
let waited = sampler.waited();
|
||||
Done::Sample {
|
||||
key,
|
||||
samples,
|
||||
waited,
|
||||
}
|
||||
}
|
||||
};
|
||||
if tx.send(done).is_err() {
|
||||
@@ -1346,6 +1419,7 @@ impl Lod {
|
||||
unmeshed: FxHashSet::default(),
|
||||
removed_pending: FxHashSet::default(),
|
||||
stale: FxHashSet::default(),
|
||||
restale: FxHashSet::default(),
|
||||
removed: Vec::new(),
|
||||
covered: FxHashSet::default(),
|
||||
covered_bounds: None,
|
||||
@@ -1360,6 +1434,7 @@ impl Lod {
|
||||
saved: FxHashMap::default(),
|
||||
reduced: FxHashMap::default(),
|
||||
saved_order: VecDeque::new(),
|
||||
keeps_observed,
|
||||
}
|
||||
}
|
||||
/// The solid ground at block column (x, z): its top face's height and
|
||||
@@ -1670,9 +1745,12 @@ impl Lod {
|
||||
}
|
||||
}
|
||||
/// The source has new data for the blocks `[x0, z0, x1, z1)` (a map tile
|
||||
/// downloaded since): the tiles over them are sampled again, and their
|
||||
/// chunks' detail asked again, but for the chunks seen in the world.
|
||||
pub fn refresh(&mut self, area: [i32; 4]) {
|
||||
/// downloaded since), drawn in cells `widths` `(min, max)` blocks wide:
|
||||
/// the tiles of those cells over them or beside (`refreshed_by`) are
|
||||
/// sampled again, and their chunks' detail asked again, but for the
|
||||
/// chunks seen in the world. A tile whose new samples are the old ones
|
||||
/// isn't meshed again.
|
||||
pub fn refresh(&mut self, area: [i32; 4], widths: (i32, i32)) {
|
||||
self.refreshes += 1;
|
||||
if self.refreshed.len() >= MAX_REFRESHED {
|
||||
self.refreshed.pop_front();
|
||||
@@ -1690,13 +1768,27 @@ impl Lod {
|
||||
z1.saturating_add(m),
|
||||
],
|
||||
));
|
||||
for &key in self.ready.keys() {
|
||||
if !key.overlaps(area) || (key.level == 0 && self.covered.contains(&(key.x, key.z))) {
|
||||
continue;
|
||||
}
|
||||
self.stale.insert(key);
|
||||
// Tiles waiting out a retry (`finish_waited`) are asked again now.
|
||||
let retrying = self.retry.len();
|
||||
self.retry.retain(|key, _| !key.refreshed_by(area, widths));
|
||||
if self.retry.len() != retrying {
|
||||
self.wanted_stale = true;
|
||||
}
|
||||
// A first sample in flight read the old data too.
|
||||
let keys: Vec<_> = self
|
||||
.ready
|
||||
.keys()
|
||||
.chain(self.pending.iter())
|
||||
.copied()
|
||||
.filter(|key| {
|
||||
key.refreshed_by(area, widths)
|
||||
&& !(key.level == 0 && self.covered.contains(&(key.x, key.z)))
|
||||
})
|
||||
.collect();
|
||||
for key in keys {
|
||||
self.mark_stale(key);
|
||||
if let Some(mask) = self.detail_masks.get_mut(&key) {
|
||||
let area = TileKey::around(area, key.size() / CELLS as i32);
|
||||
forget_detail(key, area, mask, |chunk| self.saved.contains_key(&chunk));
|
||||
}
|
||||
}
|
||||
@@ -1717,6 +1809,15 @@ impl Lod {
|
||||
&& (cz + 1) * 16 > z0
|
||||
})
|
||||
}
|
||||
/// The tile's samples lack data its source has now: sampled again, the
|
||||
/// old ones drawn meanwhile, and once more after a sample in flight.
|
||||
fn mark_stale(&mut self, key: TileKey) {
|
||||
self.stale.insert(key);
|
||||
self.wanted_stale = true;
|
||||
if self.pending.contains(&key) {
|
||||
self.restale.insert(key);
|
||||
}
|
||||
}
|
||||
/// Capture actual block columns, including canopy gaps and material
|
||||
/// layers: the worker summarizes the chunk (`summarize`, a few
|
||||
/// milliseconds a chunk), then `finish_observed` keeps it.
|
||||
@@ -1769,22 +1870,42 @@ impl Lod {
|
||||
self.saved_order.push_back(key);
|
||||
}
|
||||
self.saved.insert(key, columns.columns.clone());
|
||||
if self.keeps_observed {
|
||||
self.resample_over(key);
|
||||
}
|
||||
self.finish_detail(key, columns);
|
||||
}
|
||||
/// The source keeps the chunk now: the tiles over it whose samples lack
|
||||
/// it are sampled again (`stale`), their old samples drawn meanwhile.
|
||||
fn resample_over(&mut self, chunk: (i32, i32)) {
|
||||
for key in resampled_over(&self.ready, &self.pending, chunk) {
|
||||
self.mark_stale(key);
|
||||
}
|
||||
}
|
||||
/// A chunk's own columns as its level-0 tile; the same columns again
|
||||
/// (asked again after a `refresh`) mesh nothing.
|
||||
fn apply_fine(&mut self, (cx, cz): (i32, i32), columns: &[Column; 256]) {
|
||||
let fine = TileKey {
|
||||
x: cx,
|
||||
z: cz,
|
||||
level: 0,
|
||||
};
|
||||
if self.plan.nodes.contains(&fine) || self.ready.contains_key(&fine) {
|
||||
let existed = self
|
||||
.ready
|
||||
.insert(fine, Arc::new(columns.to_vec()))
|
||||
.is_some();
|
||||
self.detail_masks.insert(fine, [u64::MAX; 4]);
|
||||
self.refresh_ready(fine, existed);
|
||||
if !self.plan.nodes.contains(&fine) && !self.ready.contains_key(&fine) {
|
||||
return;
|
||||
}
|
||||
self.detail_masks.insert(fine, [u64::MAX; 4]);
|
||||
if self
|
||||
.ready
|
||||
.get(&fine)
|
||||
.is_some_and(|old| old[..] == columns[..])
|
||||
{
|
||||
return;
|
||||
}
|
||||
let existed = self
|
||||
.ready
|
||||
.insert(fine, Arc::new(columns.to_vec()))
|
||||
.is_some();
|
||||
self.refresh_ready(fine, existed);
|
||||
}
|
||||
fn apply_detail(&mut self, (cx, cz): (i32, i32), columns: &ReducedColumns) {
|
||||
self.apply_fine((cx, cz), &columns.columns);
|
||||
@@ -1811,27 +1932,59 @@ impl Lod {
|
||||
if x0 == x1 || z0 == z1 {
|
||||
continue;
|
||||
}
|
||||
let samples = Arc::make_mut(samples);
|
||||
let mask = self.detail_masks.entry(key).or_default();
|
||||
let mut changed = false;
|
||||
for z in z0..z1 {
|
||||
for x in x0..x1 {
|
||||
let index = (z * 16 + x) as usize;
|
||||
samples[index] = columns
|
||||
.get(
|
||||
(ox + x * stride).rem_euclid(16) as usize,
|
||||
(oz + z * stride).rem_euclid(16) as usize,
|
||||
stride as usize,
|
||||
)
|
||||
.clone();
|
||||
let column = columns.get(
|
||||
(ox + x * stride).rem_euclid(16) as usize,
|
||||
(oz + z * stride).rem_euclid(16) as usize,
|
||||
stride as usize,
|
||||
);
|
||||
// Copied (from a mesh job's snapshot) only to change it.
|
||||
if samples[index] != *column {
|
||||
Arc::make_mut(samples)[index] = column.clone();
|
||||
changed = true;
|
||||
}
|
||||
mask[index / 64] |= 1 << (index % 64);
|
||||
}
|
||||
}
|
||||
modified.push(key);
|
||||
if changed {
|
||||
modified.push(key);
|
||||
}
|
||||
}
|
||||
for key in modified {
|
||||
self.mark_data_changed(key);
|
||||
}
|
||||
}
|
||||
/// A sample with a hole, its data still on its way and nothing coarser
|
||||
/// to stand in: drawn, the hole stood where the coarser tile or the older
|
||||
/// sample it replaces had terrain, until the data came (`refresh`) and
|
||||
/// the tile was sampled again. So it is thrown away, and the tile sampled
|
||||
/// again then (at once if it came while this one was taken), or after
|
||||
/// `WAIT_RETRY`: a shorter wait let the retries of near tiles take every
|
||||
/// slot while the rest waited. One whose waiting cells BlueMap's coarser
|
||||
/// levels stood in for is drawn (`finish_any_sample`).
|
||||
fn finish_waited(&mut self, key: TileKey) {
|
||||
self.pending.remove(&key);
|
||||
if self.restale.remove(&key) {
|
||||
self.wanted_stale = true;
|
||||
} else if self.plan.nodes.contains(&key) {
|
||||
self.retry
|
||||
.insert(key, std::time::Instant::now() + WAIT_RETRY);
|
||||
}
|
||||
}
|
||||
/// A sample in: thrown away when a cell of it is a hole, its data on its
|
||||
/// way (`finish_waited`); one whose waiting cells a coarser level stood
|
||||
/// in for is drawn (`LodSampler::waited`).
|
||||
fn finish_any_sample(&mut self, key: TileKey, samples: Vec<Column>, waited: bool) {
|
||||
if waited {
|
||||
self.finish_waited(key);
|
||||
} else {
|
||||
self.finish_sample(key, samples);
|
||||
}
|
||||
}
|
||||
fn finish_sample(&mut self, key: TileKey, samples: Vec<Column>) {
|
||||
self.pending.remove(&key);
|
||||
if samples.len() != CELLS * CELLS {
|
||||
@@ -1840,6 +1993,7 @@ impl Lod {
|
||||
self.retry.retain(|_, at| *at > now);
|
||||
}
|
||||
self.retry.insert(key, now + RETRY);
|
||||
self.restale.remove(&key);
|
||||
return;
|
||||
}
|
||||
self.retry.remove(&key);
|
||||
@@ -1852,8 +2006,16 @@ impl Lod {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Refreshed again meanwhile: sampled once more after this.
|
||||
if self.restale.remove(&key) {
|
||||
self.wanted_stale = true;
|
||||
} else {
|
||||
self.stale.remove(&key);
|
||||
}
|
||||
if self.ready.get(&key).is_some_and(|old| **old == samples) {
|
||||
return;
|
||||
}
|
||||
let existed = self.ready.insert(key, Arc::new(samples)).is_some();
|
||||
self.stale.remove(&key);
|
||||
self.refresh_ready(key, existed);
|
||||
}
|
||||
}
|
||||
@@ -1887,6 +2049,10 @@ impl Lod {
|
||||
keep = keep.saturating_sub(1);
|
||||
kept
|
||||
});
|
||||
// Sampled again from the queue: they read what `refresh` announced.
|
||||
for key in &dropped {
|
||||
self.restale.remove(key);
|
||||
}
|
||||
// Back in line where `wanted_tiles` put them: the near terrain can
|
||||
// start and stop loading every few frames, and rebuilding the whole
|
||||
// queue each time cost more than the samples it held back.
|
||||
@@ -2025,6 +2191,10 @@ impl Lod {
|
||||
let mut meshes = Vec::new();
|
||||
crate::spans::time("lod_results", || self.receive(&mut meshes));
|
||||
crate::spans::time("lod_retiring", || self.prune_retiring());
|
||||
// Uploaded over frames in this order (`Renderer::upload_lods`): a
|
||||
// tile is meshed clipped around its finer ones, so they go first,
|
||||
// and the nearest first, where far terrain meets the near chunks.
|
||||
meshes.sort_by_key(|(key, _)| (key.level, key.distance(camera)));
|
||||
crate::spans::time("lod_covered", || self.cover(covered));
|
||||
if std::mem::take(&mut self.reselect) {
|
||||
self.rebuild_selection();
|
||||
@@ -2054,6 +2224,7 @@ impl Lod {
|
||||
self.plan.nodes.contains(key)
|
||||
});
|
||||
self.stale.retain(|key| self.plan.nodes.contains(key));
|
||||
self.restale.retain(|key| self.plan.nodes.contains(key));
|
||||
self.detail_masks
|
||||
.retain(|key, _| self.plan.nodes.contains(key));
|
||||
// Complete resident columns need no duplicate noise work.
|
||||
@@ -2134,7 +2305,11 @@ impl Lod {
|
||||
crate::spans::time("lod_detail", || self.finish_observed(key, columns))
|
||||
}
|
||||
Done::DetailRequested => self.detail_request_pending = false,
|
||||
Done::Sample { key, samples } => self.finish_sample(key, samples),
|
||||
Done::Sample {
|
||||
key,
|
||||
samples,
|
||||
waited,
|
||||
} => self.finish_any_sample(key, samples, waited),
|
||||
Done::Mesh {
|
||||
key,
|
||||
version,
|
||||
@@ -3106,6 +3281,22 @@ mod tests {
|
||||
assert_eq!(reduced.get(4, 8, 4).spans, [span(0, 4, 1)]);
|
||||
assert_eq!(reduced.get(0, 0, 16).spans, [span(0, 4, 1)]);
|
||||
}
|
||||
/// Detail read before a refresh over its chunk is dropped (asked
|
||||
/// again); elsewhere, or read after, it is kept.
|
||||
#[test]
|
||||
fn only_detail_a_later_refresh_covers_is_dropped() {
|
||||
let mut lod = Lod::scheduler_for_test();
|
||||
let asked = lod.refreshes;
|
||||
lod.refresh([0, 0, 64, 64], (1, 16));
|
||||
assert!(lod.refreshed_since((1, 1), asked));
|
||||
assert!(lod.refreshed_since((4, 0), asked), "a chunk around");
|
||||
assert!(!lod.refreshed_since((40, 40), asked), "elsewhere");
|
||||
assert!(!lod.refreshed_since((1, 1), lod.refreshes), "read after");
|
||||
for _ in 0..MAX_REFRESHED {
|
||||
lod.refresh([10_000, 10_000, 10_064, 10_064], (1, 16));
|
||||
}
|
||||
assert!(lod.refreshed_since((40, 40), asked), "too old to tell");
|
||||
}
|
||||
#[test]
|
||||
fn refreshed_areas_forget_detail_but_seen_chunks() {
|
||||
let key = TileKey {
|
||||
@@ -3114,6 +3305,17 @@ mod tests {
|
||||
level: 1,
|
||||
};
|
||||
assert!(key.overlaps([40, 0, 50, 1]) && !key.overlaps([64, 0, 80, 32]));
|
||||
// Only cells of the widths the new data is drawn in, a cell around.
|
||||
assert!(key.refreshed_by([64, 0, 80, 32], (1, 16)));
|
||||
assert!(!key.refreshed_by([65, 0, 80, 32], (1, 16)));
|
||||
assert!(!key.refreshed_by([40, 0, 50, 1], (25, i32::MAX)));
|
||||
let root = TileKey {
|
||||
x: 0,
|
||||
z: 0,
|
||||
level: MAX_LEVEL,
|
||||
};
|
||||
assert!(!root.refreshed_by([40, 0, 72, 32], (1, 16)));
|
||||
assert!(root.refreshed_by([0, 0, 800, 800], (25, i32::MAX)));
|
||||
// Level 1: 2 × 2 cells a chunk, chunks 2..4 × 0..2.
|
||||
let mut mask = [u64::MAX; 4];
|
||||
forget_detail(key, [48, 0, 64, 16], &mut mask, |_| false);
|
||||
@@ -3127,6 +3329,47 @@ mod tests {
|
||||
assert_eq!(mask.iter().map(|m| m.count_ones()).sum::<u32>(), 64);
|
||||
}
|
||||
#[test]
|
||||
fn tiles_sampled_without_a_kept_chunk_are_sampled_again() {
|
||||
let tile = |level: u8| {
|
||||
let size = 16 << level;
|
||||
TileKey {
|
||||
x: (3 * 16i32).div_euclid(size),
|
||||
z: (-5 * 16i32).div_euclid(size),
|
||||
level,
|
||||
}
|
||||
};
|
||||
let empty = || Arc::new(vec![Column::default(); CELLS * CELLS]);
|
||||
let filled = || {
|
||||
let column = Column {
|
||||
surface: None,
|
||||
spans: vec![Span {
|
||||
min: -64,
|
||||
max: 70,
|
||||
state: BlockStateId(1),
|
||||
biome: 0,
|
||||
light: 0,
|
||||
}]
|
||||
.into(),
|
||||
};
|
||||
Arc::new(vec![column; CELLS * CELLS])
|
||||
};
|
||||
let mut ready = FxHashMap::default();
|
||||
let mut pending = FxHashSet::default();
|
||||
// Cells of a chunk or less take its detail; wider ones sampled empty
|
||||
// there are sampled again, and filled ones aren't.
|
||||
ready.insert(tile(2), empty());
|
||||
ready.insert(tile(5), empty());
|
||||
ready.insert(tile(6), filled());
|
||||
assert_eq!(resampled_over(&ready, &pending, (3, -5)), [tile(5)]);
|
||||
// Samples in flight may predate it: those that detail can't patch.
|
||||
pending.extend([tile(1), tile(2), tile(6), tile(7)]);
|
||||
assert_eq!(
|
||||
resampled_over(&ready, &pending, (3, -5)),
|
||||
[tile(1), tile(5), tile(6), tile(7)]
|
||||
);
|
||||
assert!(resampled_over(&ready, &FxHashSet::default(), (40, 40)).is_empty());
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
|
||||
fn real_mesh_clips_near_chunks_and_preserves_water_and_edited_surfaces() -> crate::Result<()> {
|
||||
use crate::{
|
||||
@@ -4334,4 +4577,461 @@ mod tests {
|
||||
check(&lod);
|
||||
let _ = lod.removed();
|
||||
}
|
||||
/// The prepared resources' far-terrain worker context and block facts.
|
||||
fn test_context() -> crate::Result<(Arc<WorkerContext>, BlockFacts)> {
|
||||
use crate::{assets::Assets, biome::Biomes, light::LightTables, mesher::MeshTables};
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
|
||||
);
|
||||
let assets = Arc::new(Assets::load(&root)?);
|
||||
let render = Arc::new(assets.reload(&root)?);
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let biomes = Arc::new(Biomes::load(&root)?);
|
||||
let tables = Arc::new(MeshTables::new(
|
||||
&assets,
|
||||
&render,
|
||||
&facts,
|
||||
crate::models::load_visibility(&root, assets.registry)?,
|
||||
)?);
|
||||
let context = Arc::new(WorkerContext {
|
||||
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
|
||||
tables,
|
||||
assets,
|
||||
render,
|
||||
biomes,
|
||||
});
|
||||
Ok((context, facts))
|
||||
}
|
||||
/// A server's far terrain (`server_terrain`) in a scratch folder, and a
|
||||
/// chunk of stone at y 70 to observe.
|
||||
struct ServerLod {
|
||||
lod: Lod,
|
||||
facts: BlockFacts,
|
||||
stone: BlockStateId,
|
||||
dir: std::path::PathBuf,
|
||||
}
|
||||
impl ServerLod {
|
||||
fn new(name: &str) -> crate::Result<Self> {
|
||||
Self::with(name, |dir| {
|
||||
crate::server_terrain::ServerTerrain::new(dir.into(), None)
|
||||
})
|
||||
}
|
||||
fn with(
|
||||
name: &str,
|
||||
source: impl FnOnce(&std::path::Path) -> Arc<dyn LodSource>,
|
||||
) -> crate::Result<Self> {
|
||||
let (context, facts) = test_context()?;
|
||||
let stone = context
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("horizon-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
Ok(Self {
|
||||
lod: Lod::new(source(&dir), context),
|
||||
facts,
|
||||
stone,
|
||||
dir,
|
||||
})
|
||||
}
|
||||
fn observe(&mut self, x: i32, z: i32) {
|
||||
let mut chunk = Chunk::empty(x, z);
|
||||
for i in 0..256 {
|
||||
chunk.set(i % 16, 70, i / 16, self.stone.0);
|
||||
}
|
||||
self.lod.observe(Arc::new(chunk), &self.facts);
|
||||
}
|
||||
/// Updates until nothing is left to sample, summarize or mesh (`settle`:
|
||||
/// for a while), or only a few times.
|
||||
fn run(
|
||||
&mut self,
|
||||
camera: [f64; 3],
|
||||
detail: i32,
|
||||
distance: u32,
|
||||
covered: &FxHashSet<(i32, i32)>,
|
||||
settle: bool,
|
||||
) {
|
||||
let lod = &mut self.lod;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
|
||||
let mut covered = Some(covered.clone());
|
||||
let mut quiet = 0;
|
||||
while quiet < if settle { 50 } else { 3 } {
|
||||
lod.update(camera, detail, distance, covered.take());
|
||||
lod.removed();
|
||||
// Every tile sampled, but those waiting for their data.
|
||||
let unsampled = lod.plan.nodes.iter().any(|key| {
|
||||
!lod.plan.split.contains(key)
|
||||
&& !lod.ready.contains_key(key)
|
||||
&& !lod.retry.contains_key(key)
|
||||
});
|
||||
let idle = lod.pending.is_empty()
|
||||
&& lod.outstanding_meshes == 0
|
||||
&& lod.dirty.is_empty()
|
||||
&& lod.observing() == 0
|
||||
&& lod
|
||||
.shared
|
||||
.jobs
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|job| matches!(job, Job::RequestDetail { .. }))
|
||||
&& !unsampled;
|
||||
quiet = if idle { quiet + 1 } else { 0 };
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"far terrain never settled: {} pending, {} meshing, {} observing, {} jobs, {} unsampled, {} waiting",
|
||||
lod.pending.len(),
|
||||
lod.outstanding_meshes,
|
||||
lod.observing(),
|
||||
lod.shared.jobs.lock().unwrap().len(),
|
||||
lod.plan
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|key| !lod.plan.split.contains(key)
|
||||
&& !lod.ready.contains_key(key)
|
||||
&& !lod.retry.contains_key(key))
|
||||
.count(),
|
||||
lod.retry.len(),
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
}
|
||||
/// The chunks of `chunks` the active tiles don't draw as observed:
|
||||
/// the visited column or the tile's sample there, at the chunk's middle.
|
||||
fn missing(&self, chunks: impl Iterator<Item = (i32, i32)>) -> Vec<(i32, i32)> {
|
||||
chunks
|
||||
.filter(|&chunk| {
|
||||
self.drawn(chunk)
|
||||
.is_none_or(|c| c.spans.last().is_none_or(|s| s.max != 71))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The column the active tiles draw at a chunk's middle.
|
||||
fn drawn(&self, (cx, cz): (i32, i32)) -> Option<Column> {
|
||||
let lod = &self.lod;
|
||||
let snapshot = lod.snapshot();
|
||||
let drawn = |x: i32, z: i32| {
|
||||
let key = lod.active.iter().copied().find(|key| {
|
||||
let [ox, _, oz] = key.origin();
|
||||
(ox..ox + key.size()).contains(&x) && (oz..oz + key.size()).contains(&z)
|
||||
})?;
|
||||
let stride = key.size() / CELLS as i32;
|
||||
let [ox, _, oz] = key.origin();
|
||||
let (cx, cz) = ((x - ox) / stride, (z - oz) / stride);
|
||||
let (wx, wz) = (ox + cx * stride, oz + cz * stride);
|
||||
Some(match snapshot.visited_source(wx, wz, stride) {
|
||||
Some(source) => source.get().clone(),
|
||||
None => lod.ready[&key][(cz * CELLS as i32 + cx) as usize].clone(),
|
||||
})
|
||||
};
|
||||
drawn(cx * 16 + 8, cz * 16 + 8)
|
||||
}
|
||||
}
|
||||
impl Drop for ServerLod {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
|
||||
fn chunks_a_server_dropped_stay_drawn_as_far_terrain() -> crate::Result<()> {
|
||||
let mut far = ServerLod::new("lod-dropped")?;
|
||||
// A server's view distance of 10 (it sends a ring more), drawn to a
|
||||
// render distance of 32 (`streaming.radius`, the covered area).
|
||||
let (view, detail, distance) = (10, 32, 64);
|
||||
let mut resident = FxHashSet::default();
|
||||
let mut dropped = FxHashSet::default();
|
||||
// The player flies east past some 1800 chunks: more than `MAX_SAVED`.
|
||||
let end = 60;
|
||||
for step in 0..=end {
|
||||
let center = (step, 0);
|
||||
let now: FxHashSet<_> = (-view..=view)
|
||||
.flat_map(|dz| (-view..=view).map(move |dx| (center.0 + dx, center.1 + dz)))
|
||||
.collect();
|
||||
for &(x, z) in now.difference(&resident) {
|
||||
far.observe(x, z);
|
||||
}
|
||||
for &(x, z) in resident.difference(&now) {
|
||||
// `forget_remote_chunk`: the last of it, for far terrain.
|
||||
far.observe(x, z);
|
||||
dropped.insert((x, z));
|
||||
}
|
||||
resident = now;
|
||||
let covered = resident
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&(x, z)| crate::streaming::in_view_distance(center, detail, x, z))
|
||||
.collect();
|
||||
let camera = [f64::from(center.0 * 16 + 8), 100.0, 8.0];
|
||||
far.run(camera, detail, distance, &covered, step == end);
|
||||
}
|
||||
// Those within the LOD distance of where the player is now.
|
||||
let near = |&(x, z): &(i32, i32)| (x - end).pow(2) + z * z < (distance as i32 - 4).pow(2);
|
||||
let missing = far.missing(dropped.iter().copied().filter(near));
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"{} dropped chunks aren't drawn, as {:?}",
|
||||
missing.len(),
|
||||
&missing[..missing.len().min(8)]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
|
||||
fn far_cells_sampled_before_their_chunks_were_kept_fill_in() -> crate::Result<()> {
|
||||
let mut far = ServerLod::new("lod-late")?;
|
||||
// Seen from 5000 blocks: cells of 32 blocks and wider over them.
|
||||
let camera = [-5000.0, 100.0, 8.0];
|
||||
let (detail, distance) = (10, 512);
|
||||
let none = FxHashSet::default();
|
||||
// Every tile sampled while nothing is kept, then 32 × 32 chunks are.
|
||||
far.run(camera, detail, distance, &none, true);
|
||||
let chunks: Vec<_> = (0..32).flat_map(|z| (0..32).map(move |x| (x, z))).collect();
|
||||
assert_eq!(far.missing(chunks.iter().copied()).len(), chunks.len());
|
||||
for &(x, z) in &chunks {
|
||||
far.observe(x, z);
|
||||
}
|
||||
far.run(camera, detail, distance, &none, true);
|
||||
let missing = far.missing(chunks.iter().copied());
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"{} kept chunks aren't drawn",
|
||||
missing.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
/// A BlueMap whose coarse levels are in, its finest (cells up to a
|
||||
/// chunk) still downloading until `arrived`.
|
||||
struct Downloading {
|
||||
stone: BlockStateId,
|
||||
arrived: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
struct DownloadingSampler {
|
||||
stone: BlockStateId,
|
||||
arrived: Arc<std::sync::atomic::AtomicBool>,
|
||||
details: Vec<crate::streaming::LodDetail>,
|
||||
}
|
||||
impl LodSource for Downloading {
|
||||
fn sampler(self: Arc<Self>) -> Box<dyn crate::streaming::LodSampler> {
|
||||
Box::new(DownloadingSampler {
|
||||
stone: self.stone,
|
||||
arrived: self.arrived.clone(),
|
||||
details: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
impl crate::streaming::LodSampler for DownloadingSampler {
|
||||
fn fork(&self) -> Option<Box<dyn crate::streaming::LodSampler>> {
|
||||
Some(Box::new(Self {
|
||||
stone: self.stone,
|
||||
arrived: self.arrived.clone(),
|
||||
details: Vec::new(),
|
||||
}))
|
||||
}
|
||||
fn sample(&mut self, _x: i32, _z: i32) -> Option<Surface> {
|
||||
None
|
||||
}
|
||||
/// The coarse levels' stone, the finest's next state, and the
|
||||
/// coarse one's while the finest is on its way (`server_terrain`):
|
||||
/// stood in for, so not `waited`.
|
||||
fn reduced_column(&mut self, _x: i32, _z: i32, width: i32) -> Column {
|
||||
let state = if width > 16 {
|
||||
self.stone
|
||||
} else if self.arrived.load(Ordering::Relaxed) {
|
||||
BlockStateId(self.stone.0 + 1)
|
||||
} else {
|
||||
self.stone
|
||||
};
|
||||
Column {
|
||||
surface: None,
|
||||
spans: vec![Span {
|
||||
min: MIN_Y as i16,
|
||||
max: 71,
|
||||
state,
|
||||
biome: 0,
|
||||
light: 0xF0,
|
||||
}]
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
/// Chunks' detail once arrived, as `server_terrain`'s: asked again
|
||||
/// while on its way.
|
||||
fn request_detail(&mut self, chunks: &[(i32, i32)], _center: [i32; 2]) {
|
||||
if self.arrived.load(Ordering::Relaxed) {
|
||||
let columns = Arc::new(std::array::from_fn(|_| self.reduced_column(0, 0, 1)));
|
||||
self.details
|
||||
.extend(chunks.iter().map(|&chunk| (chunk, columns.clone())));
|
||||
}
|
||||
}
|
||||
fn drain_details(&mut self) -> Vec<crate::streaming::LodDetail> {
|
||||
std::mem::take(&mut self.details)
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
|
||||
fn coarser_stand_ins_keep_the_ring_past_the_near_chunks_drawn() -> crate::Result<()> {
|
||||
let arrived = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let mut far = ServerLod::with("lod-waiting", |_| {
|
||||
let stone = BlockStateId(1);
|
||||
Arc::new(Downloading {
|
||||
stone,
|
||||
arrived: arrived.clone(),
|
||||
})
|
||||
})?;
|
||||
let (camera, detail, distance) = ([8.0, 100.0, 8.0], 10, 64);
|
||||
let none = FxHashSet::default();
|
||||
// The ring past the near terrain, where nothing stood.
|
||||
let ring: Vec<_> = (-24..=24)
|
||||
.flat_map(|z| (-24..=24).map(move |x| (x, z)))
|
||||
.filter(|&(x, z)| x * x + z * z > 11 * 11)
|
||||
.collect();
|
||||
far.run(camera, detail, distance, &none, true);
|
||||
let missing = far.missing(ring.iter().copied());
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"{} of {} chunks past the near terrain show nothing while their tiles wait",
|
||||
missing.len(),
|
||||
ring.len()
|
||||
);
|
||||
// The map's tiles arrive (`BlueMap::take_changed`): the finest replace them.
|
||||
arrived.store(true, Ordering::Relaxed);
|
||||
far.lod.refresh([-1024, -1024, 1024, 1024], (1, 24));
|
||||
far.run(camera, detail, distance, &none, true);
|
||||
assert!(far.lod.retry.is_empty());
|
||||
let fine = BlockStateId(2);
|
||||
let coarse: Vec<_> = ring
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&chunk| {
|
||||
far.drawn(chunk)
|
||||
.is_none_or(|c| c.spans.last().is_none_or(|s| s.state != fine))
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
coarse.is_empty(),
|
||||
"{} chunks still drawn coarse",
|
||||
coarse.len()
|
||||
);
|
||||
assert!(far.lod.active.iter().any(|key| key.level == 0));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The sample jobs `request_samples` queues now, taken off the queue.
|
||||
fn queued(lod: &mut Lod) -> Vec<TileKey> {
|
||||
lod.request_samples([0, 0]);
|
||||
let mut jobs = lod.shared.jobs.lock().unwrap();
|
||||
jobs.drain(..)
|
||||
.filter_map(|job| match job {
|
||||
Job::Sample(key) => Some(key),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn samples(top: i16) -> Vec<Column> {
|
||||
let mut column = Column::default();
|
||||
column.spans.push(Span {
|
||||
min: -64,
|
||||
max: top,
|
||||
state: BlockStateId(1),
|
||||
biome: 0,
|
||||
light: 0,
|
||||
});
|
||||
vec![column; CELLS * CELLS]
|
||||
}
|
||||
const AREA: [i32; 4] = [0, 0, 128, 128];
|
||||
const ALL: (i32, i32) = (1, i32::MAX);
|
||||
|
||||
/// A first sample that read a map tile still downloading is thrown
|
||||
/// away; if the tile came while it was taken, it is sampled again at
|
||||
/// once, else after `WAIT_RETRY` or when the tile comes.
|
||||
#[test]
|
||||
fn waited_samples_are_taken_again_when_their_data_comes() {
|
||||
let mut lod = Lod::scheduler_for_test();
|
||||
let key = TileKey {
|
||||
x: 0,
|
||||
z: 0,
|
||||
level: 3,
|
||||
};
|
||||
lod.plan.nodes.insert(key);
|
||||
assert_eq!(queued(&mut lod), [key]);
|
||||
lod.finish_any_sample(key, vec![Column::default(); CELLS * CELLS], true);
|
||||
assert!(lod.retry.contains_key(&key) && !lod.ready.contains_key(&key));
|
||||
assert!(queued(&mut lod).is_empty(), "it waits for its data");
|
||||
lod.refresh(AREA, ALL);
|
||||
assert!(!lod.retry.contains_key(&key));
|
||||
assert_eq!(queued(&mut lod), [key], "the data came");
|
||||
|
||||
// The data comes while it is being sampled.
|
||||
lod.refresh(AREA, ALL);
|
||||
lod.finish_any_sample(key, vec![Column::default(); CELLS * CELLS], true);
|
||||
assert!(!lod.retry.contains_key(&key));
|
||||
assert_eq!(queued(&mut lod), [key], "sampled again at once");
|
||||
// Coarser levels stood in for what waited: drawn.
|
||||
lod.finish_any_sample(key, samples(70), false);
|
||||
assert!(lod.ready.contains_key(&key) && !lod.stale.contains(&key));
|
||||
assert!(queued(&mut lod).is_empty());
|
||||
// Refreshed, then sampled with a hole where a tile is still on its
|
||||
// way: what is drawn stays, the hole isn't.
|
||||
lod.refresh(AREA, ALL);
|
||||
assert_eq!(queued(&mut lod), [key]);
|
||||
let mut holed = samples(80);
|
||||
holed[..CELLS].fill(Column::default());
|
||||
lod.finish_any_sample(key, holed, true);
|
||||
assert_eq!(lod.ready[&key][0], samples(70)[0]);
|
||||
assert!(lod.retry.contains_key(&key));
|
||||
}
|
||||
|
||||
/// A tile refreshed while its sample is in flight is sampled once more:
|
||||
/// that sample read the old data. Samples equal to the drawn ones mesh
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn refreshed_tiles_are_sampled_again_and_only_meshed_when_changed() {
|
||||
let mut lod = Lod::scheduler_for_test();
|
||||
let key = TileKey {
|
||||
x: 0,
|
||||
z: 0,
|
||||
level: 3,
|
||||
};
|
||||
lod.plan.nodes.insert(key);
|
||||
lod.active.insert(key);
|
||||
lod.ready.insert(key, Arc::new(samples(70)));
|
||||
assert!(queued(&mut lod).is_empty());
|
||||
lod.refresh(AREA, ALL);
|
||||
assert_eq!(queued(&mut lod), [key]);
|
||||
lod.refresh(AREA, ALL);
|
||||
lod.finish_sample(key, samples(80));
|
||||
assert!(lod.stale.contains(&key), "refreshed again meanwhile");
|
||||
assert!(lod.dirty.contains_key(&key), "new samples are meshed");
|
||||
assert_eq!(queued(&mut lod), [key]);
|
||||
lod.dirty.clear();
|
||||
lod.finish_sample(key, samples(80));
|
||||
assert!(!lod.stale.contains(&key));
|
||||
assert!(lod.dirty.is_empty(), "the same samples mesh nothing");
|
||||
assert!(queued(&mut lod).is_empty());
|
||||
// Data drawn in other cell widths leaves it alone.
|
||||
lod.refresh(AREA, (17, 24));
|
||||
assert!(queued(&mut lod).is_empty());
|
||||
}
|
||||
|
||||
/// With a source that keeps what it observes (a server's chunks), a wide
|
||||
/// tile sampled empty where a chunk is now kept is sampled again.
|
||||
#[test]
|
||||
fn kept_chunks_resample_the_wide_tiles_over_them() {
|
||||
let mut lod = Lod::scheduler_for_test();
|
||||
lod.keeps_observed = true;
|
||||
let key = TileKey {
|
||||
x: 0,
|
||||
z: 0,
|
||||
level: 5,
|
||||
};
|
||||
lod.plan.nodes.insert(key);
|
||||
lod.ready
|
||||
.insert(key, Arc::new(vec![Column::default(); CELLS * CELLS]));
|
||||
let column = samples(70).swap_remove(0);
|
||||
let columns: Arc<[Column; 256]> = Arc::new(std::array::from_fn(|_| column.clone()));
|
||||
lod.finish_observed((1, 1), ReducedColumns::new(columns));
|
||||
assert!(lod.stale.contains(&key));
|
||||
assert_eq!(queued(&mut lod), [key]);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ impl ParsedModels {
|
||||
}
|
||||
}
|
||||
let parsed = crate::par::par_map(&files, |(_, path)| {
|
||||
let text = fs::read_to_string(path).ok()?;
|
||||
let text = horizon_core::prepared_files::read_to_string(path).ok()?;
|
||||
CuboidModel::from_json(&text).ok().map(Arc::new)
|
||||
});
|
||||
let models = files
|
||||
@@ -89,8 +89,8 @@ impl ParsedModels {
|
||||
let read = || -> Result<CuboidModel> {
|
||||
let (namespace, path) =
|
||||
name.split_once(':').ok_or("invalid model identifier")?;
|
||||
let text = fs::read_to_string(
|
||||
root.join(format!("assets/{namespace}/models/{path}.json")),
|
||||
let text = horizon_core::prepared_files::read_to_string(
|
||||
&root.join(format!("assets/{namespace}/models/{path}.json")),
|
||||
)?;
|
||||
Ok(CuboidModel::from_json(&text)?)
|
||||
};
|
||||
|
||||
@@ -110,7 +110,16 @@ pub(crate) fn prepare_with_models(
|
||||
.state(state)
|
||||
.ok_or("missing gallery registry state")?;
|
||||
let position = BlockPos::new((index % 4) as i32 * 4 - 7, 4, (index / 4) as i32 * 4 + 14);
|
||||
let selected = catalog.collect(state, position)?;
|
||||
// A state no definition binds (a pack's partial blockstate file
|
||||
// without vanilla's below it) is left out, as the mesher skips it,
|
||||
// instead of failing the whole pack.
|
||||
let selected = match catalog.collect(state, position) {
|
||||
Ok(selected) => selected,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: gallery block {name} has no model: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
states.push(json!({"block":actual.name,"properties":actual.properties,"state_id":state.0,
|
||||
"position":[position.x,position.y,position.z],
|
||||
"models":selected.iter().map(|(v,_)| json!({"model":v.model,"rotations":v.rotations,"uv_lock":v.uv_lock})).collect::<Vec<_>>()}));
|
||||
|
||||
@@ -105,6 +105,35 @@ fn bake_resolved(
|
||||
cube,
|
||||
})
|
||||
}
|
||||
/// The definitions a blockstate file holds, bottom first: the layers of a
|
||||
/// file the pack overlay stacked (`packs::BLOCKSTATE_LAYERS`), else the
|
||||
/// file itself.
|
||||
fn definition_layers(text: &str) -> std::result::Result<Vec<String>, String> {
|
||||
if !text.contains(crate::packs::BLOCKSTATE_LAYERS) {
|
||||
return Ok(vec![text.to_owned()]);
|
||||
}
|
||||
let value: Value = serde_json::from_str(text).map_err(|e| e.to_string())?;
|
||||
match value.get(crate::packs::BLOCKSTATE_LAYERS) {
|
||||
Some(Value::Array(layers)) => Ok(layers.iter().map(Value::to_string).collect()),
|
||||
_ => Ok(vec![text.to_owned()]),
|
||||
}
|
||||
}
|
||||
/// Each state's model from definitions bound bottom first: the topmost one
|
||||
/// that binds the state, as (its layer, its selection). A higher pack's
|
||||
/// definition replaces only the states it binds (`putAll` in
|
||||
/// `loadBlockStateDefinitionStack`).
|
||||
fn topmost(layers: Vec<Vec<Option<SelectedModels>>>) -> Vec<Option<(usize, SelectedModels)>> {
|
||||
let mut picked: Vec<Option<(usize, SelectedModels)>> = Vec::new();
|
||||
for (layer, bound) in layers.into_iter().enumerate() {
|
||||
picked.resize(picked.len().max(bound.len()), None);
|
||||
for (slot, selected) in picked.iter_mut().zip(bound) {
|
||||
if let Some(selected) = selected {
|
||||
*slot = Some((layer, selected));
|
||||
}
|
||||
}
|
||||
}
|
||||
picked
|
||||
}
|
||||
impl StateModels {
|
||||
pub fn load(
|
||||
root: &Path,
|
||||
@@ -194,21 +223,17 @@ impl StateModels {
|
||||
.collect::<Result<_>>()?;
|
||||
let possible: Vec<Properties> = serde_json::from_value(facts["states"].clone())?;
|
||||
// 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")),
|
||||
// read or bind is logged and left out; with none left the block
|
||||
// gets no model, and the other blocks still load. A file several
|
||||
// packs provide binds each pack's definition on its own, bottom
|
||||
// first (`loadBlockStateDefinitionStack`).
|
||||
let layers = match 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()))
|
||||
.and_then(|definition| {
|
||||
let bound = definition
|
||||
.bind(&schema, &possible)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok((definition, bound))
|
||||
});
|
||||
let (definition, bound) = match loaded {
|
||||
Ok(loaded) => loaded,
|
||||
.and_then(|text| definition_layers(&text))
|
||||
{
|
||||
Ok(layers) => layers,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Horizon: blockstate definition of {namespace}:{path} not loaded: {e}"
|
||||
@@ -216,6 +241,26 @@ impl StateModels {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut loaded = Vec::with_capacity(layers.len());
|
||||
for text in &layers {
|
||||
let layer = BlockStateDefinition::from_json(text)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|definition| {
|
||||
let bound = definition
|
||||
.bind(&schema, &possible)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok((definition, bound))
|
||||
});
|
||||
match layer {
|
||||
Ok(layer) => loaded.push(layer),
|
||||
Err(e) => log::warn!(
|
||||
"Horizon: blockstate definition of {namespace}:{path} not loaded: {e}"
|
||||
),
|
||||
}
|
||||
}
|
||||
if loaded.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let native_order: Vec<_> = by_name
|
||||
.get(name.as_str())
|
||||
.map(Vec::as_slice)
|
||||
@@ -233,32 +278,36 @@ impl StateModels {
|
||||
"model state definitions disagree with the imported block registry".into(),
|
||||
);
|
||||
}
|
||||
for ((id, _), selected) in native_order.into_iter().zip(bound) {
|
||||
if let Some(selected) = selected {
|
||||
states[id.0 as usize] = Some((choices.len(), selected));
|
||||
let (definitions, bound): (Vec<_>, Vec<_>) = loaded.into_iter().unzip();
|
||||
let first = choices.len();
|
||||
for ((id, _), picked) in native_order.into_iter().zip(topmost(bound)) {
|
||||
if let Some((layer, selected)) = picked {
|
||||
states[id.0 as usize] = Some((first + layer, selected));
|
||||
}
|
||||
}
|
||||
for choice in definition
|
||||
.variants
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|(_, choice)| choice)
|
||||
.chain(
|
||||
definition
|
||||
.multipart
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|selector| &selector.variant),
|
||||
)
|
||||
{
|
||||
match choice {
|
||||
VariantSet::Single(v) => {
|
||||
variants.insert(key(v));
|
||||
for definition in definitions {
|
||||
for choice in definition
|
||||
.variants
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|(_, choice)| choice)
|
||||
.chain(
|
||||
definition
|
||||
.multipart
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|selector| &selector.variant),
|
||||
)
|
||||
{
|
||||
match choice {
|
||||
VariantSet::Single(v) => {
|
||||
variants.insert(key(v));
|
||||
}
|
||||
VariantSet::Weighted(v) => variants.extend(v.iter().map(|(v, _)| key(v))),
|
||||
}
|
||||
VariantSet::Weighted(v) => variants.extend(v.iter().map(|(v, _)| key(v))),
|
||||
}
|
||||
choices.push(CompiledChoices::new(&definition)?);
|
||||
}
|
||||
choices.push(CompiledChoices::new(&definition)?);
|
||||
}
|
||||
let names: Vec<String> = variants.iter().map(|(name, _, _)| name.clone()).collect();
|
||||
let library = match models.block_library(root, names.clone()) {
|
||||
@@ -428,6 +477,44 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::model_library::ParsedModels;
|
||||
|
||||
/// #7013: a pack's blockstate file that defines only some states (here
|
||||
/// a fence gate's `powered=true` ones) keeps vanilla's model for the
|
||||
/// rest, as vanilla binds each pack's definition and lets a higher one
|
||||
/// replace only what it binds.
|
||||
#[test]
|
||||
fn a_partial_pack_blockstate_keeps_vanilla_for_the_other_states() {
|
||||
let schema: PropertySchema = BTreeMap::from([(
|
||||
"powered".to_owned(),
|
||||
PropertyValues::Named(BTreeSet::from(["false".to_owned(), "true".to_owned()])),
|
||||
)]);
|
||||
let states: Vec<Properties> = ["false", "true"]
|
||||
.iter()
|
||||
.map(|p| BTreeMap::from([("powered".to_owned(), (*p).to_owned())]))
|
||||
.collect();
|
||||
let stacked = serde_json::json!({ crate::packs::BLOCKSTATE_LAYERS: [
|
||||
{"variants":{"":{"model":"minecraft:block/oak_fence_gate"}}},
|
||||
{"variants":{"powered=true":{"model":"minecraft:block/stone"}}}
|
||||
]})
|
||||
.to_string();
|
||||
let layers = definition_layers(&stacked).unwrap();
|
||||
assert_eq!(layers.len(), 2);
|
||||
let bound = layers
|
||||
.iter()
|
||||
.map(|text| {
|
||||
BlockStateDefinition::from_json(text)
|
||||
.unwrap()
|
||||
.bind(&schema, &states)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
let picked = topmost(bound);
|
||||
assert_eq!(picked[0], Some((0, SelectedModels::Variant(0))));
|
||||
assert_eq!(picked[1], Some((1, SelectedModels::Variant(0))));
|
||||
// A file that isn't stacked is one definition.
|
||||
let plain = r#"{"variants":{"":{"model":"minecraft:block/stone"}}}"#;
|
||||
assert_eq!(definition_layers(plain).unwrap(), vec![plain.to_owned()]);
|
||||
}
|
||||
|
||||
struct Fixture(std::path::PathBuf);
|
||||
impl Fixture {
|
||||
fn write(&self, name: &str, json: &str) {
|
||||
|
||||
@@ -126,7 +126,6 @@ impl Opt {
|
||||
| Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts
|
||||
| Opt::RotateWithMinecarts
|
||||
// Saved, but no Horizon system reads them yet: no Realms, no
|
||||
// friends list, no directional audio, no now-playing toast.
|
||||
| Opt::RealmsNotifications
|
||||
@@ -937,6 +936,7 @@ impl Opt {
|
||||
| Opt::RealmsNotifications
|
||||
| Opt::InGameNotification
|
||||
| Opt::DirectionalAudio
|
||||
| Opt::RotateWithMinecarts
|
||||
| Opt::NarratorHotkey => {
|
||||
let key = self.file_key().unwrap();
|
||||
s.file
|
||||
@@ -1512,6 +1512,8 @@ pub struct OptionsUi {
|
||||
/// (`update_pack_infos`) differs from the settings'.
|
||||
pack_reselected: bool,
|
||||
pub in_world: bool,
|
||||
/// AccessibilityOptionsScreen: the loaded world's experimental feature.
|
||||
pub minecart_improvements: bool,
|
||||
/// Scroll the selected language into the middle on the next layout.
|
||||
center_language: bool,
|
||||
pub graphics: crate::graphics_ui::GraphicsUi,
|
||||
@@ -1576,6 +1578,7 @@ impl Default for OptionsUi {
|
||||
pack_focus: Instant::now(),
|
||||
pack_reselected: false,
|
||||
in_world: false,
|
||||
minecart_improvements: false,
|
||||
center_language: false,
|
||||
graphics: Default::default(),
|
||||
split_profiles: Default::default(),
|
||||
@@ -3236,6 +3239,14 @@ impl OptionsUi {
|
||||
if widget.list {
|
||||
widget.y += previous_scroll - self.scroll;
|
||||
}
|
||||
if let WKind::Button {
|
||||
act: Act::Cycle(Opt::RotateWithMinecarts),
|
||||
active,
|
||||
..
|
||||
} = &mut widget.kind
|
||||
{
|
||||
*active = self.in_world && self.minecart_improvements;
|
||||
}
|
||||
}
|
||||
self.row_bands = rows
|
||||
.bands
|
||||
@@ -3385,6 +3396,8 @@ impl OptionsUi {
|
||||
}
|
||||
if self.page() == Page::SplitSetup {
|
||||
self.split_setup.on = s.extras.splitscreen;
|
||||
// The controller picks a tile up between presses: the screen's size from the draw.
|
||||
self.split_setup.size = (batch.width, batch.height);
|
||||
self.split_setup.draw(batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -746,7 +746,8 @@ fn build_overlay_uncached(
|
||||
}
|
||||
for name in names {
|
||||
let child = format!("{prefix}{name}");
|
||||
if hidden.contains(&child) {
|
||||
// Vanilla's files in one file: it describes vanilla's root, not this mirror.
|
||||
if hidden.contains(&child) || child == horizon_core::prepared_files::FILE_NAME {
|
||||
continue;
|
||||
}
|
||||
if let Some(bytes) = merged.get(&child) {
|
||||
@@ -840,7 +841,14 @@ enum Merge {
|
||||
Font,
|
||||
/// `SpriteSourceList.load`: sources from the bottom pack up.
|
||||
Atlas,
|
||||
/// `BlockStateModelLoader.loadBlockStateDefinitionStack`: every pack's
|
||||
/// definition binds on its own, and a higher one replaces only the states
|
||||
/// it binds. Kept as its layers ([`BLOCKSTATE_LAYERS`]) for the catalog.
|
||||
BlockStates,
|
||||
}
|
||||
/// The key of a stacked blockstate file: its definitions, bottom first, each
|
||||
/// as the pack wrote it.
|
||||
pub const BLOCKSTATE_LAYERS: &str = "horizon:layers";
|
||||
fn merge_kind(name: &str) -> Option<Merge> {
|
||||
let (_, rest) = name.strip_prefix("assets/")?.split_once('/')?;
|
||||
if rest == "sounds.json" {
|
||||
@@ -851,6 +859,7 @@ fn merge_kind(name: &str) -> Option<Merge> {
|
||||
"lang" => Some(Merge::Lang),
|
||||
"font" => Some(Merge::Font),
|
||||
"atlases" => Some(Merge::Atlas),
|
||||
"blockstates" => Some(Merge::BlockStates),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -858,7 +867,7 @@ fn merge_kind(name: &str) -> Option<Merge> {
|
||||
/// does. Layers that do not parse are skipped, as vanilla logs and skips them.
|
||||
fn merge(kind: Merge, layers: &[Vec<u8>]) -> Option<Vec<u8>> {
|
||||
use serde_json::{Map, Value};
|
||||
let parsed: Vec<Map<String, Value>> = layers
|
||||
let mut parsed: Vec<Map<String, Value>> = layers
|
||||
.iter()
|
||||
// SoundManager skips a sounds.json Gson rejects, whole.
|
||||
.filter(|b| kind != Merge::Sounds || crate::sound_events::is_valid_file(b))
|
||||
@@ -901,6 +910,12 @@ fn merge(kind: Merge, layers: &[Vec<u8>]) -> Option<Vec<u8>> {
|
||||
}
|
||||
Merge::Font => list("providers", &mut parsed.iter().rev()),
|
||||
Merge::Atlas => list("sources", &mut parsed.iter()),
|
||||
// One definition is the file as it is.
|
||||
Merge::BlockStates if parsed.len() < 2 => Value::Object(parsed.pop()?),
|
||||
Merge::BlockStates => Value::Object(Map::from_iter([(
|
||||
BLOCKSTATE_LAYERS.to_owned(),
|
||||
Value::Array(parsed.into_iter().map(Value::Object).collect()),
|
||||
)])),
|
||||
};
|
||||
serde_json::to_vec(&out).ok()
|
||||
}
|
||||
@@ -1404,6 +1419,36 @@ mod tests {
|
||||
&[json(r#"{"sources":[1]}"#), json(r#"{"sources":[2]}"#)],
|
||||
);
|
||||
assert_eq!(parse(atlas.unwrap()), serde_json::json!({"sources":[1,2]}));
|
||||
// BlockStateModelLoader: every definition kept, bottom first, for the
|
||||
// catalog to bind one by one; one that does not parse is skipped.
|
||||
let gate = merge(
|
||||
Merge::BlockStates,
|
||||
&[
|
||||
json(r#"{"variants":{"":{"model":"v"}}}"#),
|
||||
b"not json".to_vec(),
|
||||
json(r#"{"variants":{"powered=true":{"model":"p"}}}"#),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
parse(gate.unwrap()),
|
||||
serde_json::json!({BLOCKSTATE_LAYERS:[
|
||||
{"variants":{"":{"model":"v"}}},
|
||||
{"variants":{"powered=true":{"model":"p"}}}
|
||||
]})
|
||||
);
|
||||
// A single definition stays the file as it is.
|
||||
let alone = merge(
|
||||
Merge::BlockStates,
|
||||
&[json(r#"{"variants":{"":{"model":"p"}}}"#)],
|
||||
);
|
||||
assert_eq!(
|
||||
parse(alone.unwrap()),
|
||||
serde_json::json!({"variants":{"":{"model":"p"}}})
|
||||
);
|
||||
assert_eq!(
|
||||
merge_kind("assets/minecraft/blockstates/oak_fence_gate.json"),
|
||||
Some(Merge::BlockStates)
|
||||
);
|
||||
assert_eq!(
|
||||
merge_kind("assets/minecraft/lang/en_us.json"),
|
||||
Some(Merge::Lang)
|
||||
|
||||
@@ -12,7 +12,8 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
|
||||
// Version 3 could omit untouched vanilla directories on Windows (#1649, #1688).
|
||||
// Version 5 read every vanilla file to stamp it on Windows.
|
||||
// Version 6 stamps vanilla's files by their metadata on Windows too.
|
||||
const VERSION: u32 = 6;
|
||||
// Version 7 stacks blockstate files (every pack's definition, bottom first).
|
||||
const VERSION: u32 = 7;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub(super) struct Dependencies {
|
||||
|
||||
@@ -1158,7 +1158,9 @@ impl ParticleEngine {
|
||||
}
|
||||
let dir = root.join("assets/minecraft/particles");
|
||||
for &ty in ParticleType::ALL {
|
||||
let Ok(bytes) = std::fs::read(dir.join(format!("{}.json", ty.name()))) else {
|
||||
let Ok(bytes) =
|
||||
horizon_core::prepared_files::read(&dir.join(format!("{}.json", ty.name())))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
//!
|
||||
//! The summaries live in Anvil region files (`r.X.Z.mca`, one NBT record a
|
||||
//! chunk), under `server-terrain/<server>/<hashed seed>/<namespace>/<level>/`.
|
||||
//! Past what it has, the server's BlueMap may fill in (`bluemap`).
|
||||
//! A chunk it doesn't have may be in the player's Bobby cache of the same
|
||||
//! level (`Bobby`), only ever read; past both, the server's BlueMap may fill
|
||||
//! in (`bluemap`).
|
||||
use crate::{
|
||||
blocks::BlockFacts,
|
||||
bluemap::{BlueMap, Found},
|
||||
chunk::Chunk,
|
||||
fast_hash::FxHashMap,
|
||||
lod::{Column, Span, Spans, Surface},
|
||||
lod::{Column, Span, Spans, Surface, state_kinds, summarize},
|
||||
streaming::{LodDetail, LodSampler, LodSource},
|
||||
};
|
||||
use horizon_core::position::ChunkPos;
|
||||
@@ -70,6 +74,67 @@ pub fn level_dir(root: &Path, address: &str, hashed_seed: i64, level: &str) -> P
|
||||
.join(level_path(level))
|
||||
}
|
||||
|
||||
/// The official launcher's game folder (`HORIZON_MINECRAFT_DIR` if set),
|
||||
/// where Bobby keeps its cache (`.bobby`): as
|
||||
/// `horizon_prepare::artifacts::minecraft_dir` finds it, which this crate
|
||||
/// can't depend on.
|
||||
pub fn minecraft_dir() -> PathBuf {
|
||||
let home = || {
|
||||
let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
|
||||
std::env::var_os(var).map_or_else(|| PathBuf::from("."), PathBuf::from)
|
||||
};
|
||||
if let Some(dir) = std::env::var_os("HORIZON_MINECRAFT_DIR").filter(|d| !d.is_empty()) {
|
||||
let dir = PathBuf::from(dir);
|
||||
if let Ok(rest) = dir.strip_prefix("~") {
|
||||
return home().join(rest);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
home().join("Library/Application Support/minecraft")
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA")
|
||||
.map_or_else(home, PathBuf::from)
|
||||
.join(".minecraft")
|
||||
} else {
|
||||
home().join(".minecraft")
|
||||
}
|
||||
}
|
||||
|
||||
/// Bobby's folder of a level under `root` (`.bobby`), as Bobby leaves it on
|
||||
/// disk: `<address as typed, ':' as '_'>/<hashed seed>/<namespace>/<path>`.
|
||||
/// Names are compared without case, and `host` and `host_25565` both stand
|
||||
/// for the default port: neither was seen, so both are taken.
|
||||
pub fn bobby_level_dir(
|
||||
root: &Path,
|
||||
address: &str,
|
||||
hashed_seed: i64,
|
||||
level: &str,
|
||||
) -> Option<PathBuf> {
|
||||
let typed = address.trim().replace(':', "_");
|
||||
let bare = typed.strip_suffix("_25565").unwrap_or(&typed);
|
||||
if bare.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let names = [bare.to_owned(), format!("{bare}_25565")];
|
||||
let mut servers: Vec<PathBuf> = std::fs::read_dir(root)
|
||||
.ok()?
|
||||
.flatten()
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|name| names.iter().any(|n| n.eq_ignore_ascii_case(name)))
|
||||
})
|
||||
.map(|entry| entry.path())
|
||||
.collect();
|
||||
servers.sort();
|
||||
servers
|
||||
.into_iter()
|
||||
.map(|server| server.join(hashed_seed.to_string()).join(level_path(level)))
|
||||
.find(|dir| dir.is_dir())
|
||||
}
|
||||
|
||||
/// `<namespace>/<path>` of a level key.
|
||||
fn level_path(level: &str) -> PathBuf {
|
||||
let (namespace, path) = level.split_once(':').unwrap_or(("minecraft", level));
|
||||
@@ -104,29 +169,73 @@ type Regions = FxHashMap<(i32, i32), Option<Region>>;
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
regions: Regions,
|
||||
/// Bobby's, opened read-only.
|
||||
bobby: Regions,
|
||||
/// `None`: the chunk isn't stored.
|
||||
chunks: FxHashMap<(i32, i32), Option<Columns>>,
|
||||
}
|
||||
|
||||
/// A saved full chunk with its stored light, or `None` (`save::decode_lit`
|
||||
/// in the game).
|
||||
pub type DecodeChunk = Box<dyn Fn(&NamedTag) -> Option<Chunk> + Send + Sync>;
|
||||
|
||||
/// The chunks the player's Bobby kept of the same level: vanilla chunk NBT
|
||||
/// in region files, as Bobby leaves them on disk. Only ever read; a chunk
|
||||
/// far terrain needs from there is kept in the level's own folder.
|
||||
pub struct Bobby {
|
||||
dir: PathBuf,
|
||||
decode: DecodeChunk,
|
||||
/// `lod::state_kinds`, to summarize its chunks as loaded ones are.
|
||||
kinds: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Bobby {
|
||||
pub fn new(dir: PathBuf, decode: DecodeChunk, facts: &BlockFacts) -> Self {
|
||||
Self {
|
||||
dir,
|
||||
decode,
|
||||
kinds: state_kinds(facts),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One level's stored terrain, shared by the far-terrain threads. A chunk
|
||||
/// it doesn't have comes from the server's BlueMap, if the player named it.
|
||||
/// it doesn't have comes from Bobby's cache, else the server's BlueMap, if
|
||||
/// the player named it or Find BlueMap found it.
|
||||
pub struct ServerTerrain {
|
||||
dir: PathBuf,
|
||||
blue_map: Option<Arc<BlueMap>>,
|
||||
bobby: Option<Bobby>,
|
||||
state: Mutex<State>,
|
||||
warned: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl ServerTerrain {
|
||||
pub fn new(dir: PathBuf, blue_map: Option<Arc<BlueMap>>) -> Arc<Self> {
|
||||
Self::with_bobby(dir, blue_map, None)
|
||||
}
|
||||
|
||||
pub fn with_bobby(
|
||||
dir: PathBuf,
|
||||
blue_map: Option<Arc<BlueMap>>,
|
||||
bobby: Option<Bobby>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
dir,
|
||||
blue_map,
|
||||
bobby,
|
||||
state: Mutex::default(),
|
||||
warned: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// BlueMap's coarser levels at block (x, z) (a void there is `Ready`).
|
||||
fn coarser(&self, x: i32, z: i32) -> Found<Column> {
|
||||
self.blue_map
|
||||
.as_ref()
|
||||
.map_or(Found::Absent, |map| map.coarser_column(x, z))
|
||||
}
|
||||
|
||||
/// A chunk's columns: stored, else BlueMap's (`Waiting`: on its way).
|
||||
fn chunk_or_map(&self, x: i32, z: i32) -> Found<Columns> {
|
||||
if let Some(columns) = self.chunk(x, z) {
|
||||
@@ -139,19 +248,35 @@ impl ServerTerrain {
|
||||
|
||||
/// The stored chunk nearest the middle of the `side` × `side` chunks
|
||||
/// from `(x, z)`, found in the region headers without reading any.
|
||||
#[cfg(test)]
|
||||
fn nearest(&self, x: i32, z: i32, side: i32) -> Option<(i32, i32)> {
|
||||
self.kept(x, z, side).1
|
||||
}
|
||||
|
||||
/// How many of the `side` × `side` chunks from `(x, z)` are stored here
|
||||
/// or in Bobby's cache, and the one nearest their middle (`nearest`).
|
||||
fn kept(&self, x: i32, z: i32, side: i32) -> (usize, Option<(i32, i32)>) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let middle = (x * 2 + side, z * 2 + side);
|
||||
let mut count = 0;
|
||||
let mut best: Option<(i64, (i32, i32))> = None;
|
||||
for rz in z >> 5..=(z + side - 1) >> 5 {
|
||||
for rx in x >> 5..=(x + side - 1) >> 5 {
|
||||
let present = region(&mut state.regions, &self.dir, (rx, rz))
|
||||
let mut present = region(&mut state.regions, &self.dir, (rx, rz))
|
||||
.map_or([false; 1024], |r| r.present);
|
||||
if let Some(bobby) = &self.bobby
|
||||
&& let Some(theirs) = read_only(&mut state.bobby, &bobby.dir, (rx, rz))
|
||||
{
|
||||
for (here, there) in present.iter_mut().zip(theirs.present) {
|
||||
*here |= there;
|
||||
}
|
||||
}
|
||||
for (i, _) in present.iter().enumerate().filter(|(_, p)| **p) {
|
||||
let (cx, cz) = (rx * 32 + (i % 32) as i32, rz * 32 + (i / 32) as i32);
|
||||
if cx < x || cz < z || cx >= x + side || cz >= z + side {
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
let (dx, dz) = (
|
||||
i64::from(cx * 2 + 1 - middle.0),
|
||||
i64::from(cz * 2 + 1 - middle.1),
|
||||
@@ -163,26 +288,15 @@ impl ServerTerrain {
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, chunk)| chunk)
|
||||
(count, best.map(|(_, chunk)| chunk))
|
||||
}
|
||||
|
||||
/// A stored chunk's columns.
|
||||
/// A stored chunk's columns, else Bobby's (kept here from then on).
|
||||
pub fn chunk(&self, x: i32, z: i32) -> Option<Columns> {
|
||||
let key = (x >> 5, z >> 5);
|
||||
let here = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
if let Some(known) = state.chunks.get(&(x, z)) {
|
||||
return known.clone();
|
||||
}
|
||||
read(region(&mut state.regions, &self.dir, key), x, z)
|
||||
};
|
||||
// A record of another game version or layout is none.
|
||||
let columns = here.and_then(|record| {
|
||||
record
|
||||
.nbt(Limits::default())
|
||||
.ok()
|
||||
.and_then(|root| decode(&root.tag))
|
||||
});
|
||||
if let Some(known) = self.state.lock().unwrap().chunks.get(&(x, z)) {
|
||||
return known.clone();
|
||||
}
|
||||
let columns = self.stored(x, z).or_else(|| self.bobby_chunk(x, z));
|
||||
let mut state = self.state.lock().unwrap();
|
||||
// Stored by another thread meanwhile (`write`): that, newer than
|
||||
// what was read here.
|
||||
@@ -193,6 +307,51 @@ impl ServerTerrain {
|
||||
columns
|
||||
}
|
||||
|
||||
/// The chunk's record in the level's own folder; one of another game
|
||||
/// version or layout is none.
|
||||
fn stored(&self, x: i32, z: i32) -> Option<Columns> {
|
||||
let record = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
read(
|
||||
region(&mut state.regions, &self.dir, (x >> 5, z >> 5)),
|
||||
x,
|
||||
z,
|
||||
)
|
||||
}?;
|
||||
record
|
||||
.nbt(Limits::default())
|
||||
.ok()
|
||||
.and_then(|root| decode(&root.tag))
|
||||
}
|
||||
|
||||
/// A chunk in Bobby's cache, summarized and kept here. One of another
|
||||
/// game version, or that doesn't decode, is none.
|
||||
fn bobby_chunk(&self, x: i32, z: i32) -> Option<Columns> {
|
||||
let bobby = self.bobby.as_ref()?;
|
||||
let (record, kept) = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let key = (x >> 5, z >> 5);
|
||||
let kept =
|
||||
region(&mut state.regions, &self.dir, key).is_some_and(|r| r.present[slot(x, z)]);
|
||||
(
|
||||
read(read_only(&mut state.bobby, &bobby.dir, key), x, z),
|
||||
kept,
|
||||
)
|
||||
};
|
||||
let record = record?;
|
||||
let root = record.nbt(Limits::default()).ok()?;
|
||||
if root.tag.get("DataVersion") != Some(&Tag::Int(DATA_VERSION)) {
|
||||
return None;
|
||||
}
|
||||
let chunk = (bobby.decode)(&root).filter(|chunk| (chunk.x, chunk.z) == (x, z))?;
|
||||
let columns = summarize(&chunk, &bobby.kinds);
|
||||
// Not over a chunk the server sent while this one was decoded.
|
||||
if let Err(e) = self.write((x, z), &columns, Some(kept)) {
|
||||
self.warn_once(&e);
|
||||
}
|
||||
Some(columns)
|
||||
}
|
||||
|
||||
/// A chunk couldn't be kept (disk full, a folder it can't write): said
|
||||
/// once a level, not for every chunk after.
|
||||
fn warn_once(&self, error: &std::io::Error) {
|
||||
@@ -203,16 +362,29 @@ impl ServerTerrain {
|
||||
|
||||
/// Keep a chunk's columns, unless they are what is stored already.
|
||||
pub fn store(&self, key: (i32, i32), columns: &Columns) -> std::io::Result<()> {
|
||||
if self
|
||||
.chunk(key.0, key.1)
|
||||
.is_some_and(|old| *old == **columns)
|
||||
{
|
||||
let known = self.state.lock().unwrap().chunks.get(&key).cloned();
|
||||
let old = known.unwrap_or_else(|| {
|
||||
let old = self.stored(key.0, key.1);
|
||||
let mut state = self.state.lock().unwrap();
|
||||
if !state.chunks.contains_key(&key) {
|
||||
remember(&mut state, key, old.clone());
|
||||
}
|
||||
old
|
||||
});
|
||||
if old.is_some_and(|old| *old == **columns) {
|
||||
return Ok(());
|
||||
}
|
||||
self.write(key, columns)
|
||||
self.write(key, columns, None)
|
||||
}
|
||||
|
||||
fn write(&self, (x, z): (i32, i32), columns: &Columns) -> std::io::Result<()> {
|
||||
/// `kept`: whether the chunk's record was in the folder before; the
|
||||
/// columns aren't written over one another thread kept meanwhile.
|
||||
fn write(
|
||||
&self,
|
||||
(x, z): (i32, i32),
|
||||
columns: &Columns,
|
||||
kept: Option<bool>,
|
||||
) -> std::io::Result<()> {
|
||||
let root = NamedTag {
|
||||
name: "".into(),
|
||||
tag: encode(x, z, columns),
|
||||
@@ -220,6 +392,14 @@ impl ServerTerrain {
|
||||
let record = ChunkRecord::from_nbt(&root, Compression::Zlib, now(), Limits::default())?;
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let key = (x >> 5, z >> 5);
|
||||
if let Some(kept) = kept
|
||||
&& (matches!(state.chunks.get(&(x, z)), Some(Some(_)))
|
||||
|| (!kept
|
||||
&& region(&mut state.regions, &self.dir, key)
|
||||
.is_some_and(|r| r.present[slot(x, z)])))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if region(&mut state.regions, &self.dir, key).is_none() {
|
||||
std::fs::create_dir_all(&self.dir)?;
|
||||
let file = RegionFile::open_writable(region_path(&self.dir, key), true)?;
|
||||
@@ -239,12 +419,26 @@ fn region_path(dir: &Path, (rx, rz): (i32, i32)) -> PathBuf {
|
||||
|
||||
/// A region of `dir`, opened on first use.
|
||||
fn region<'a>(regions: &'a mut Regions, dir: &Path, key: (i32, i32)) -> Option<&'a mut Region> {
|
||||
open(regions, key, || {
|
||||
RegionFile::open_writable(region_path(dir, key), false)
|
||||
})
|
||||
}
|
||||
|
||||
/// A region of Bobby's `dir`, opened for reading only.
|
||||
fn read_only<'a>(regions: &'a mut Regions, dir: &Path, key: (i32, i32)) -> Option<&'a mut Region> {
|
||||
open(regions, key, || RegionFile::open(region_path(dir, key)))
|
||||
}
|
||||
|
||||
fn open(
|
||||
regions: &mut Regions,
|
||||
key: (i32, i32),
|
||||
file: impl FnOnce() -> std::io::Result<RegionFile>,
|
||||
) -> Option<&mut Region> {
|
||||
if !regions.contains_key(&key) {
|
||||
if regions.len() >= MAX_REGIONS {
|
||||
regions.clear();
|
||||
}
|
||||
let file = RegionFile::open_writable(region_path(dir, key), false);
|
||||
regions.insert(key, file.ok().map(Region::new));
|
||||
regions.insert(key, file().ok().map(Region::new));
|
||||
}
|
||||
regions.get_mut(&key)?.as_mut()
|
||||
}
|
||||
@@ -378,13 +572,37 @@ impl LodSource for ServerTerrain {
|
||||
Box::new(Sampler {
|
||||
terrain: self,
|
||||
details: Vec::new(),
|
||||
waited: false,
|
||||
})
|
||||
}
|
||||
fn keeps_observed(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Sampler {
|
||||
/// For a column whose chunk of the map is on its way: its coarser
|
||||
/// levels' meanwhile, as they come first (a void there is drawn as
|
||||
/// one). None there either is a hole (`waited`: the sample isn't drawn);
|
||||
/// either way the column is sampled again when its tile arrives
|
||||
/// (`Lod::refresh`).
|
||||
fn coarser(&mut self, x: i32, z: i32) -> Column {
|
||||
match self.terrain.coarser(x, z) {
|
||||
Found::Ready(column) => column,
|
||||
Found::Waiting | Found::Absent => {
|
||||
self.waited = true;
|
||||
Column::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Sampler {
|
||||
terrain: Arc<ServerTerrain>,
|
||||
details: Vec<LodDetail>,
|
||||
/// A column was left empty for BlueMap's on its way, with no coarser
|
||||
/// level to stand in (`waited`).
|
||||
waited: bool,
|
||||
}
|
||||
|
||||
impl LodSampler for Sampler {
|
||||
@@ -392,10 +610,12 @@ impl LodSampler for Sampler {
|
||||
Some(Box::new(Self {
|
||||
terrain: self.terrain.clone(),
|
||||
details: Vec::new(),
|
||||
waited: false,
|
||||
}))
|
||||
}
|
||||
/// Stored chunks come back whole, else BlueMap's; the others are known
|
||||
/// empty. One BlueMap is still downloading is asked again later.
|
||||
/// Stored chunks come back whole, else BlueMap's (its coarser levels'
|
||||
/// where the finest is out of reach); the others are known empty. One
|
||||
/// whose BlueMap tile is still downloading is asked again later.
|
||||
fn request_detail(&mut self, chunks: &[(i32, i32)], _center: [i32; 2]) {
|
||||
for &(x, z) in chunks {
|
||||
let columns = match self.terrain.chunk_or_map(x, z) {
|
||||
@@ -420,41 +640,61 @@ impl LodSampler for Sampler {
|
||||
fn column(&mut self, x: i32, z: i32) -> Column {
|
||||
match self.terrain.chunk_or_map(x >> 4, z >> 4) {
|
||||
Found::Ready(c) => c[((z & 15) * 16 + (x & 15)) as usize].clone(),
|
||||
_ => Column::default(),
|
||||
Found::Waiting => self.coarser(x, z),
|
||||
Found::Absent => Column::default(),
|
||||
}
|
||||
}
|
||||
fn waited(&mut self) -> bool {
|
||||
std::mem::take(&mut self.waited)
|
||||
}
|
||||
/// A cell of `width` blocks from one stored chunk: its own area up to a
|
||||
/// chunk, else the stored chunk nearest the cell's middle (the server
|
||||
/// sent only the chunks a player came near). With a BlueMap, a wider
|
||||
/// sent only the chunks a player came near), when most of the cell is
|
||||
/// stored. With a BlueMap, a wider
|
||||
/// cell takes the chunk at its middle if stored, else the map's pixel
|
||||
/// there, and the nearest stored chunk only while the map has none.
|
||||
fn reduced_column(&mut self, x: i32, z: i32, width: i32) -> Column {
|
||||
if width <= 16 {
|
||||
let Found::Ready(chunk) = self.terrain.chunk_or_map(x >> 4, z >> 4) else {
|
||||
return Column::default();
|
||||
let chunk = match self.terrain.chunk_or_map(x >> 4, z >> 4) {
|
||||
Found::Ready(chunk) => chunk,
|
||||
Found::Waiting => return self.coarser(x, z),
|
||||
Found::Absent => return Column::default(),
|
||||
};
|
||||
let width = width.max(1) as usize;
|
||||
let lx = (x & 15) as usize & !(width - 1);
|
||||
let lz = (z & 15) as usize & !(width - 1);
|
||||
return Column::reduced(&chunk, lx, lz, width);
|
||||
}
|
||||
let mut waiting = false;
|
||||
if let Some(map) = &self.terrain.blue_map {
|
||||
let (mx, mz) = ((x + width / 2) >> 4, (z + width / 2) >> 4);
|
||||
if let Some(chunk) = self.terrain.chunk(mx, mz) {
|
||||
return Column::reduced(&chunk, 0, 0, 16);
|
||||
}
|
||||
if let Found::Ready(column) = map.reduced_column(x, z, width) {
|
||||
return column;
|
||||
match map.reduced_column(x, z, width) {
|
||||
Found::Ready(column) => return column,
|
||||
Found::Waiting => waiting = true,
|
||||
Found::Absent => {}
|
||||
}
|
||||
}
|
||||
// Searching wider cells would open thousands of region files.
|
||||
if width >> 4 > MAX_KEPT_SIDE {
|
||||
// Only over a cell the server sent most of, as `Column::reduced`
|
||||
// keeps what fills half a cell: one chunk drawn as a whole cell stood
|
||||
// as a flat slab far wider than anything seen there, until finer
|
||||
// tiles replaced it with the nothing they know of.
|
||||
let side = width >> 4;
|
||||
// Wider cells are never mostly sent, and counting them would open
|
||||
// thousands of region files.
|
||||
if side > MAX_KEPT_SIDE {
|
||||
self.waited |= waiting;
|
||||
return Column::default();
|
||||
}
|
||||
self.terrain
|
||||
.nearest(x >> 4, z >> 4, width >> 4)
|
||||
.and_then(|(cx, cz)| self.terrain.chunk(cx, cz))
|
||||
.map_or_else(Column::default, |chunk| Column::reduced(&chunk, 0, 0, 16))
|
||||
let (count, nearest) = self.terrain.kept(x >> 4, z >> 4, side);
|
||||
let column = (count * 2 >= (side * side) as usize)
|
||||
.then(|| nearest.and_then(|(cx, cz)| self.terrain.chunk(cx, cz)))
|
||||
.flatten()
|
||||
.map(|chunk| Column::reduced(&chunk, 0, 0, 16));
|
||||
self.waited |= waiting && column.is_none();
|
||||
column.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,8 +792,206 @@ mod tests {
|
||||
let mut sampler = terrain.clone().sampler();
|
||||
assert_eq!(sampler.sample(-33 * 16 + 1, 5 * 16), columns[1].surface);
|
||||
assert_eq!(sampler.column(0, 0), Column::default());
|
||||
// A far cell finds the stored chunk anywhere in it.
|
||||
assert_ne!(sampler.reduced_column(-64 * 16, 0, 1024), Column::default());
|
||||
// One chunk doesn't stand for a far cell of 64 × 64.
|
||||
assert_eq!(sampler.reduced_column(-64 * 16, 0, 1024), Column::default());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_cells_are_drawn_where_most_of_them_was_kept() {
|
||||
let dir = scratch("server-terrain-wide");
|
||||
let columns = columns();
|
||||
let terrain = ServerTerrain::new(dir.clone(), None);
|
||||
let mut sampler = terrain.clone().sampler();
|
||||
// A cell of 4 × 4 chunks, and a chunk of it kept.
|
||||
terrain.store((2, 1), &columns).unwrap();
|
||||
assert_eq!(sampler.reduced_column(0, 0, 64), Column::default());
|
||||
for (x, z) in [(0, 0), (1, 0), (2, 0), (3, 0), (0, 1), (1, 1)] {
|
||||
terrain.store((x, z), &columns).unwrap();
|
||||
assert_eq!(sampler.reduced_column(0, 0, 64), Column::default());
|
||||
}
|
||||
// Half of it: drawn from the kept chunk nearest its middle.
|
||||
terrain.store((2, 2), &columns).unwrap();
|
||||
assert_eq!(
|
||||
sampler.reduced_column(0, 0, 64),
|
||||
Column::reduced(&columns, 0, 0, 16)
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// Far terrain samples a cell before the server sent its chunk, then
|
||||
/// again once it is kept: nothing remembers the empty sample.
|
||||
#[test]
|
||||
fn a_chunk_kept_after_an_empty_sample_is_sampled_next_time() {
|
||||
let dir = scratch("server-terrain-late");
|
||||
let columns = columns();
|
||||
let terrain = ServerTerrain::new(dir.clone(), None);
|
||||
let mut sampler = terrain.clone().sampler();
|
||||
let mut other = sampler.fork().expect("a sampling thread's own");
|
||||
for width in [1, 4, 16] {
|
||||
assert_eq!(other.reduced_column(-16, 32, width), Column::default());
|
||||
}
|
||||
assert_eq!(other.grid(-16, 32, 1, 16), vec![Column::default(); 256]);
|
||||
// `Job::Observe` stores it on the far-terrain worker.
|
||||
sampler.observed((-1, 2), &columns);
|
||||
assert_eq!(other.grid(-16, 32, 1, 16), columns.to_vec());
|
||||
assert_eq!(
|
||||
other.reduced_column(-16, 32, 16),
|
||||
Column::reduced(&columns, 0, 0, 16)
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bobby_folders_follow_the_address_as_typed() {
|
||||
let root = scratch("bobby-folders");
|
||||
for dir in [
|
||||
"127.0.0.1/-9030034075591913594/minecraft/overworld",
|
||||
"127.0.0.1/-9030034075591913594/minecraft/the_nether",
|
||||
"127.0.0.1/4696776658964135993/minecraft/overworld",
|
||||
"localhost_25566/-7181178862026813658/minecraft/overworld",
|
||||
"Play.Example.net_25565/5/minecraft/overworld",
|
||||
] {
|
||||
std::fs::create_dir_all(root.join(dir)).unwrap();
|
||||
}
|
||||
let find = |address: &str, seed: i64, level: &str| {
|
||||
bobby_level_dir(&root, address, seed, level)
|
||||
.map(|dir| dir.strip_prefix(&root).unwrap().to_owned())
|
||||
};
|
||||
let overworld = "minecraft:overworld";
|
||||
let first = Some(PathBuf::from(
|
||||
"127.0.0.1/-9030034075591913594/minecraft/overworld",
|
||||
));
|
||||
assert_eq!(find("127.0.0.1", -9030034075591913594, overworld), first);
|
||||
assert_eq!(
|
||||
find("127.0.0.1:25565", -9030034075591913594, overworld),
|
||||
first
|
||||
);
|
||||
assert_eq!(
|
||||
find("127.0.0.1", -9030034075591913594, "minecraft:the_nether"),
|
||||
Some(PathBuf::from(
|
||||
"127.0.0.1/-9030034075591913594/minecraft/the_nether"
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
find("127.0.0.1", 4696776658964135993, overworld),
|
||||
Some(PathBuf::from(
|
||||
"127.0.0.1/4696776658964135993/minecraft/overworld"
|
||||
))
|
||||
);
|
||||
let other_port = Some(PathBuf::from(
|
||||
"localhost_25566/-7181178862026813658/minecraft/overworld",
|
||||
));
|
||||
assert_eq!(
|
||||
find("localhost:25566", -7181178862026813658, overworld),
|
||||
other_port
|
||||
);
|
||||
assert_eq!(
|
||||
find("LocalHost:25566", -7181178862026813658, overworld),
|
||||
other_port
|
||||
);
|
||||
assert_eq!(
|
||||
find("play.example.net", 5, overworld),
|
||||
Some(PathBuf::from(
|
||||
"Play.Example.net_25565/5/minecraft/overworld"
|
||||
))
|
||||
);
|
||||
// Another world behind the address, another dimension, another port.
|
||||
assert_eq!(find("127.0.0.1", 1111, overworld), None);
|
||||
assert_eq!(
|
||||
find("127.0.0.1", -9030034075591913594, "minecraft:the_end"),
|
||||
None
|
||||
);
|
||||
assert_eq!(find("localhost", -7181178862026813658, overworld), None);
|
||||
assert_eq!(
|
||||
find("localhost:25567", -7181178862026813658, overworld),
|
||||
None
|
||||
);
|
||||
assert_eq!(find("", 0, overworld), None);
|
||||
assert_eq!(
|
||||
bobby_level_dir(&root.join("none"), "127.0.0.1", 0, overworld),
|
||||
None
|
||||
);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
/// A Bobby-shaped region of chunk records, written with `version`.
|
||||
fn bobby_region(dir: &Path, chunks: &[(i32, i32)], version: i32) {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
let (rx, rz) = (chunks[0].0 >> 5, chunks[0].1 >> 5);
|
||||
let mut file = RegionFile::open_writable(region_path(dir, (rx, rz)), true).unwrap();
|
||||
for &(x, z) in chunks {
|
||||
let root = NamedTag {
|
||||
name: "".into(),
|
||||
tag: Tag::compound([
|
||||
("DataVersion", Tag::Int(version)),
|
||||
("Status", Tag::String("full".into())),
|
||||
("xPos", Tag::Int(x)),
|
||||
("zPos", Tag::Int(z)),
|
||||
]),
|
||||
};
|
||||
let record = ChunkRecord::from_nbt(&root, Compression::Zlib, 0, Limits::default());
|
||||
file.write(ChunkPos::new(x, z), &record.unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stone from y 60 to 70 in every column (the game decodes vanilla chunk NBT
|
||||
/// with `save::decode_lit`; its palettes are tested there).
|
||||
fn stone(root: &NamedTag) -> Option<Chunk> {
|
||||
let (Some(Tag::Int(x)), Some(Tag::Int(z))) = (root.tag.get("xPos"), root.tag.get("zPos"))
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let mut chunk = Chunk::empty(*x, *z);
|
||||
for i in 0..256 {
|
||||
for y in 60..70 {
|
||||
chunk.set(i & 15, y, i >> 4, 1);
|
||||
}
|
||||
}
|
||||
Some(chunk)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunks_only_bobby_has_are_read_once_and_kept() {
|
||||
let dir = scratch("server-terrain-bobby");
|
||||
let (own, theirs) = (dir.join("own"), dir.join("bobby"));
|
||||
bobby_region(&theirs, &[(-3, 5), (-2, 5), (-1, 6)], DATA_VERSION);
|
||||
bobby_region(&theirs.join("old"), &[(-3, 5)], DATA_VERSION - 1);
|
||||
let region = region_path(&theirs, (-1, 0));
|
||||
let before = std::fs::read(®ion).unwrap();
|
||||
// State 0 air, state 1 a kept block (`lod::state_kinds`).
|
||||
let bobby = |dir: &Path| Bobby {
|
||||
dir: dir.to_owned(),
|
||||
decode: Box::new(stone),
|
||||
kinds: vec![4, 1],
|
||||
};
|
||||
let terrain = ServerTerrain::with_bobby(own.clone(), None, Some(bobby(&theirs)));
|
||||
// Counted for wide cells before they are read.
|
||||
assert_eq!(terrain.kept(-4, 4, 4), (3, Some((-3, 5))));
|
||||
let expected = summarize(
|
||||
&stone(&NamedTag {
|
||||
name: "".into(),
|
||||
tag: Tag::compound([("xPos", Tag::Int(-2)), ("zPos", Tag::Int(5))]),
|
||||
})
|
||||
.unwrap(),
|
||||
&[4, 1],
|
||||
);
|
||||
assert!(expected[0].surface.is_some());
|
||||
assert_eq!(terrain.chunk(-2, 5), Some(expected.clone()));
|
||||
assert_eq!(terrain.chunk(-2, 6), None);
|
||||
let mut sampler = terrain.clone().sampler();
|
||||
assert_eq!(sampler.column(-2 * 16 + 3, 5 * 16), expected[3]);
|
||||
drop(sampler);
|
||||
drop(terrain);
|
||||
// Kept in the level's own folder; Bobby's left as it was.
|
||||
let kept = ServerTerrain::new(own.clone(), None);
|
||||
assert_eq!(kept.chunk(-2, 5), Some(expected));
|
||||
assert_eq!(kept.chunk(-3, 5), None);
|
||||
assert_eq!(std::fs::read(®ion).unwrap(), before);
|
||||
// Another game version's chunk is none.
|
||||
let old =
|
||||
ServerTerrain::with_bobby(dir.join("own-old"), None, Some(bobby(&theirs.join("old"))));
|
||||
assert_eq!(old.chunk(-3, 5), None);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Setup's monitor map and draft window choices. Apply is consumed by the game adapter.
|
||||
use crate::{
|
||||
gamepad::Button as PadButton,
|
||||
graphics_style::STYLE,
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
lang,
|
||||
@@ -48,12 +49,28 @@ pub struct SplitUi {
|
||||
/// Names: the names in the views' corners are shown (the game sets it), and pressed.
|
||||
pub names_shown: bool,
|
||||
pub toggle_names: bool,
|
||||
/// P1 has a controller (the game sets it), and Give Up Pad pressed: it's offered to join.
|
||||
pub p1_pad: bool,
|
||||
pub give_up_pad: bool,
|
||||
edit: Option<Draft>,
|
||||
selected: Option<usize>,
|
||||
/// A tile held by the mouse: whose, and where the pointer is (`None` until it moves).
|
||||
drag: Option<(usize, Option<(f32, f32)>)>,
|
||||
/// The screen's size at the last press: where a drag's release lands.
|
||||
size: (f32, f32),
|
||||
/// A player picked up with the controller (A), moved with the bumpers.
|
||||
held: Option<Held>,
|
||||
/// Dropped or put back: the player whose tile the controller's focus goes to.
|
||||
refocus: Option<usize>,
|
||||
/// The screen's size: at the last draw (the options screen sets it) or press.
|
||||
pub(crate) size: (f32, f32),
|
||||
}
|
||||
|
||||
/// A player picked up with the controller: where it could go, the one picked so far, and the
|
||||
/// draft with it there (the map shows it).
|
||||
struct Held {
|
||||
player: usize,
|
||||
spots: Vec<(String, Drop)>,
|
||||
at: Option<usize>,
|
||||
preview: Option<Draft>,
|
||||
}
|
||||
|
||||
/// Where a dragged player goes: a place (`Spot`), or a place among a window's players.
|
||||
@@ -73,6 +90,8 @@ enum Target {
|
||||
Identify,
|
||||
/// The players' names in their views' corners, on or off.
|
||||
Names,
|
||||
/// P1's controller freed and offered to join (handed to a friend).
|
||||
GiveUpPad,
|
||||
Revert,
|
||||
Apply,
|
||||
Done,
|
||||
@@ -124,9 +143,113 @@ impl SplitUi {
|
||||
self.selected = None;
|
||||
self.apply = None;
|
||||
self.drag = None;
|
||||
self.held = None;
|
||||
}
|
||||
/// The draft shown: with a held player where the controller put it, else the changes so
|
||||
/// far, else the arrangement now.
|
||||
fn draft(&self) -> Option<&Draft> {
|
||||
self.edit.as_ref().or(self.live.as_ref())
|
||||
let preview = self.held.as_ref().and_then(|h| h.preview.as_ref());
|
||||
preview.or(self.edit.as_ref()).or(self.live.as_ref())
|
||||
}
|
||||
|
||||
/// The controller (`pad_menu`), A at `at`: on a player's tile, picks it up. True when it did
|
||||
/// (the press is taken).
|
||||
pub fn pad_pick_up(&mut self, at: (f32, f32)) -> bool {
|
||||
let (w, h) = self.size;
|
||||
let hit = self
|
||||
.buttons(w, h)
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|([x, y, bw, bh], _, _, active)| *active && ui::hovered(at, *x, *y, *bw, *bh));
|
||||
let Some((_, _, Target::Player(i), _)) = hit else {
|
||||
return false;
|
||||
};
|
||||
self.selected = Some(i);
|
||||
let spots = self.stops(i);
|
||||
self.held = Some(Held {
|
||||
player: i,
|
||||
spots,
|
||||
at: None,
|
||||
preview: None,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// The controller while a player is held: the bumpers or left and right cycle where it goes,
|
||||
/// as when joining (the map shows it there), A drops it there (into the draft), B puts it
|
||||
/// back. True when the button was taken: every button, while one is held.
|
||||
pub fn pad_button(&mut self, button: PadButton) -> bool {
|
||||
let Some(held) = &self.held else {
|
||||
return false;
|
||||
};
|
||||
let player = held.player;
|
||||
match button {
|
||||
PadButton::LeftBumper
|
||||
| PadButton::RightBumper
|
||||
| PadButton::DPadLeft
|
||||
| PadButton::DPadRight => {
|
||||
let n = held.spots.len();
|
||||
if n == 0 {
|
||||
return true;
|
||||
}
|
||||
let back = matches!(button, PadButton::LeftBumper | PadButton::DPadLeft);
|
||||
let next = match held.at {
|
||||
None if back => n - 1,
|
||||
None => 0,
|
||||
Some(k) if back => (k + n - 1) % n,
|
||||
Some(k) => (k + 1) % n,
|
||||
};
|
||||
let (player, drop) = (held.player, held.spots[next].1.clone());
|
||||
let open = self.open_now();
|
||||
let preview = self.edit.clone().or(self.live.clone()).map(|mut draft| {
|
||||
match drop {
|
||||
Drop::At(spot) => draft.place_player(player, spot),
|
||||
Drop::Place(n, k) => {
|
||||
draft.place_player(player, Spot::Window(n));
|
||||
draft.put_at(player, k);
|
||||
}
|
||||
}
|
||||
draft.drop_empty(&open);
|
||||
draft
|
||||
});
|
||||
if let Some(held) = &mut self.held {
|
||||
held.at = Some(next);
|
||||
held.preview = preview;
|
||||
}
|
||||
}
|
||||
PadButton::South => {
|
||||
let held = self.held.take().expect("held");
|
||||
// One highlight after: the focus on the player where it landed, not selected too.
|
||||
(self.selected, self.refocus) = (None, Some(held.player));
|
||||
match held.at.map(|k| held.spots[k].1.clone()) {
|
||||
Some(Drop::At(spot)) => self.place(held.player, spot),
|
||||
Some(Drop::Place(n, k)) => self.place_at(held.player, n, k),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
PadButton::East => {
|
||||
(self.selected, self.refocus) = (None, Some(player));
|
||||
self.held = None;
|
||||
}
|
||||
// While one is held, the focus stays put: A drops, it presses nothing else.
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether a player is held (the controller's left and right cycle it).
|
||||
pub fn holding(&self) -> bool {
|
||||
self.held.is_some()
|
||||
}
|
||||
|
||||
/// The tile of the player just dropped or put back, for the controller's focus. Taken once.
|
||||
pub fn take_refocus(&mut self) -> Option<[f32; 4]> {
|
||||
let i = self.refocus.take()?;
|
||||
let (w, h) = self.size;
|
||||
self.buttons(w, h)
|
||||
.into_iter()
|
||||
.find(|b| b.2 == Target::Player(i))
|
||||
.map(|b| b.0)
|
||||
}
|
||||
fn map(&self, w: f32, h: f32) -> split_map::Map {
|
||||
if self.idle {
|
||||
@@ -270,8 +393,18 @@ impl SplitUi {
|
||||
Target::Switch,
|
||||
!self.on || self.names.len() <= 1,
|
||||
);
|
||||
// P1's controller handed to a friend: it's offered to join.
|
||||
let give_up = (
|
||||
[90.0, 6.0, 80.0, 20.0],
|
||||
tr("give_up_pad", &[]),
|
||||
Target::GiveUpPad,
|
||||
true,
|
||||
);
|
||||
if !self.on || self.idle {
|
||||
let mut out = vec![switch];
|
||||
if self.on && self.p1_pad {
|
||||
out.push(give_up);
|
||||
}
|
||||
row(&mut out, h - 27.0, vec![done]);
|
||||
return out;
|
||||
}
|
||||
@@ -329,6 +462,9 @@ impl SplitUi {
|
||||
true,
|
||||
);
|
||||
out.extend([switch, names]);
|
||||
if self.p1_pad {
|
||||
out.push(give_up);
|
||||
}
|
||||
// A square for how 2 (or 3) players share a window, while a window has that many (drawn
|
||||
// as the layout; the label is for the control socket and the tests).
|
||||
let mut footer = Vec::new();
|
||||
@@ -373,6 +509,7 @@ impl SplitUi {
|
||||
Target::Switch => tr("help.switch", &[]),
|
||||
Target::Identify => tr("help.identify", &[]),
|
||||
Target::Names => tr("help.names", &[]),
|
||||
Target::GiveUpPad => tr("help.give_up_pad", &[]),
|
||||
Target::Revert if *active => tr("help.revert", &[]),
|
||||
Target::Apply if *active => tr("help.apply", &[]),
|
||||
Target::Revert | Target::Apply => tr("help.unchanged", &[]),
|
||||
@@ -542,7 +679,9 @@ impl SplitUi {
|
||||
([x, y, bw, bh], label, target, active): &Button,
|
||||
) {
|
||||
let (mut x, mut y, mut bw, mut bh) = (*x, *y, *bw, *bh);
|
||||
let hover = *active && ui::hovered(cursor, x, y, bw, bh);
|
||||
// While a player is held, only its tile is lit: the controller's pointer stays where
|
||||
// the pick-up was, over whichever tile is there now.
|
||||
let hover = *active && self.held.is_none() && ui::hovered(cursor, x, y, bw, bh);
|
||||
let selected = matches!(target, Target::Player(i) if Some(*i) == self.selected);
|
||||
if matches!(target, Target::Player(_)) {
|
||||
(x, y, bw, bh) = (x + 1.0, y + 1.0, (bw - 2.0).max(4.0), (bh - 2.0).max(4.0));
|
||||
@@ -571,6 +710,16 @@ impl SplitUi {
|
||||
*active,
|
||||
hover || selected,
|
||||
);
|
||||
// The held player: "< Name >", the arrows lit, for left and right.
|
||||
if matches!(target, Target::Player(i) if self.held.as_ref().is_some_and(|h| h.player == *i))
|
||||
{
|
||||
let (half, cx) = (HudBatch::text_width(atlas, label) / 2.0, x + bw / 2.0);
|
||||
let ty = (y + (bh - 8.0) / 2.0).floor();
|
||||
let lit = rgba(255, 255, 85, 255);
|
||||
let lw = HudBatch::text_width(atlas, "<");
|
||||
batch.text(atlas, "<", (cx - half - 4.0 - lw).floor(), ty, lit, true);
|
||||
batch.text(atlas, ">", (cx + half + 4.0).ceil(), ty, lit, true);
|
||||
}
|
||||
// A layout's square: its panes, in a 14 x 10 window; Auto a letter. Its name for the
|
||||
// control socket, which lists widgets by what they say.
|
||||
if glyph.is_some() {
|
||||
@@ -601,7 +750,19 @@ impl SplitUi {
|
||||
buttons: &[Button],
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let held = self.held.as_ref().map(|h| {
|
||||
let who = self
|
||||
.names
|
||||
.get(h.player)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| tr("this_player", &[]));
|
||||
match h.at {
|
||||
Some(k) => tr("note.held_at", &[&who, &h.spots[k].0]),
|
||||
None => tr("note.held", &[&who]),
|
||||
}
|
||||
});
|
||||
let note = match self.selected {
|
||||
_ if held.is_some() => held.unwrap_or_default(),
|
||||
None => tr("note.select", &[]),
|
||||
Some(_) if self.edit.is_some() => tr("note.apply", &[]),
|
||||
Some(i) => tr(
|
||||
@@ -651,6 +812,10 @@ impl SplitUi {
|
||||
/// A press at `(x, y)` on a screen `w` x `h`, or the mouse moving with it held (`drag`).
|
||||
pub fn click(&mut self, (x, y): (f32, f32), (w, h): (f32, f32), drag: bool) -> Click {
|
||||
self.size = (w, h);
|
||||
// The mouse takes over from a player held with the controller.
|
||||
if !drag {
|
||||
self.held = None;
|
||||
}
|
||||
if drag {
|
||||
if let Some((_, at)) = &mut self.drag {
|
||||
*at = Some((x, y));
|
||||
@@ -690,6 +855,7 @@ impl SplitUi {
|
||||
}
|
||||
Target::Identify => self.identify = true,
|
||||
Target::Names => self.toggle_names = true,
|
||||
Target::GiveUpPad => self.give_up_pad = true,
|
||||
Target::Revert => self.edit = None,
|
||||
Target::Apply => {
|
||||
// Windows emptied in the draft close now, not as players moved.
|
||||
@@ -1237,6 +1403,133 @@ mod tests {
|
||||
// Alone: neither.
|
||||
assert_eq!(labels(&screen(1)).count(), 0);
|
||||
}
|
||||
|
||||
fn centre(ui: &SplitUi, label: &str) -> (f32, f32) {
|
||||
let [x, y, w, h] = rect(ui, label);
|
||||
(x + w / 2.0, y + h / 2.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_controller_picks_a_player_up_moves_it_and_drops_it() {
|
||||
let mut ui = screen(2);
|
||||
ui.size = SIZE;
|
||||
assert!(ui.pad_pick_up(centre(&ui, "P2")));
|
||||
// Nothing moves until the bumpers do; then the map shows it there: the places in Window 1
|
||||
// first, then a new window.
|
||||
assert_eq!(ui.draft().unwrap().players, [1, 1]);
|
||||
for _ in 0..3 {
|
||||
assert!(ui.pad_button(PadButton::RightBumper));
|
||||
}
|
||||
assert_eq!(ui.draft().unwrap().players, [1, 2]);
|
||||
assert_eq!(ui.edit, None);
|
||||
// Right again: filling the TV; A drops it there, into the draft.
|
||||
assert!(ui.pad_button(PadButton::RightBumper));
|
||||
assert!(ui.pad_button(PadButton::South));
|
||||
let draft = ui.edit.as_ref().unwrap();
|
||||
assert_eq!(draft.players, [1, 2]);
|
||||
assert_eq!(draft.windows[1].placement, Placement::Fill("TV".into()));
|
||||
// Not held any more: the buttons go back to the menu.
|
||||
assert!(!ui.pad_button(PadButton::RightBumper));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_controller_moves_a_player_to_a_place_among_the_others() {
|
||||
let mut ui = screen(2);
|
||||
ui.size = SIZE;
|
||||
assert!(ui.pad_pick_up(centre(&ui, "P2")));
|
||||
let stops: Vec<String> = ui
|
||||
.held
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.spots
|
||||
.iter()
|
||||
.map(|s| s.0.clone())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
&stops[..3],
|
||||
["Window 1 (1 of 2)", "Window 1 (2 of 2)", "New window"]
|
||||
);
|
||||
assert!(
|
||||
!stops.iter().any(|s| s.starts_with("Swap")),
|
||||
"no swaps: it joins"
|
||||
);
|
||||
// RB to the first place: before P1.
|
||||
assert!(ui.pad_button(PadButton::RightBumper));
|
||||
assert!(ui.pad_button(PadButton::South));
|
||||
assert_eq!(ui.edit.as_ref().unwrap().order, [1, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_replaces_preview_but_keeps_the_switch_and_pad_handoff() {
|
||||
let mut ui = screen(1);
|
||||
ui.on = true;
|
||||
ui.idle = true;
|
||||
ui.p1_pad = true;
|
||||
assert_eq!(ui.status_lines().unwrap()[0], "Splitscreen idle");
|
||||
assert!(ui.map(SIZE.0, SIZE.1).2.is_empty());
|
||||
let buttons = ui.buttons(SIZE.0, SIZE.1);
|
||||
assert!(buttons.iter().any(|b| b.2 == Target::GiveUpPad));
|
||||
assert!(
|
||||
!buttons
|
||||
.iter()
|
||||
.any(|b| matches!(b.2, Target::Player(_) | Target::Apply | Target::Revert))
|
||||
);
|
||||
press(&mut ui, "Give Up Pad");
|
||||
assert!(ui.give_up_pad);
|
||||
ui.idle = false;
|
||||
assert!(ui.status_lines().is_none());
|
||||
assert_eq!(ui.map(SIZE.0, SIZE.1).2.len(), 1);
|
||||
ui.on = false;
|
||||
assert_eq!(ui.status_lines().unwrap()[0], "Splitscreen is off.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn give_up_pad_shows_only_while_p1_has_one() {
|
||||
let mut ui = screen(1);
|
||||
ui.on = true;
|
||||
let shown = |ui: &SplitUi| {
|
||||
ui.buttons(SIZE.0, SIZE.1)
|
||||
.iter()
|
||||
.any(|b| b.2 == Target::GiveUpPad)
|
||||
};
|
||||
assert!(!shown(&ui));
|
||||
ui.p1_pad = true;
|
||||
assert!(shown(&ui));
|
||||
press(&mut ui, "Give Up Pad");
|
||||
assert!(ui.give_up_pad);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b_puts_a_held_player_back() {
|
||||
let mut ui = screen(2);
|
||||
ui.size = SIZE;
|
||||
assert!(ui.pad_pick_up(centre(&ui, "P2")));
|
||||
ui.pad_button(PadButton::RightBumper);
|
||||
assert!(ui.pad_button(PadButton::East));
|
||||
assert_eq!(ui.edit, None);
|
||||
assert_eq!(ui.draft().unwrap().players, [1, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_or_put_back_one_highlight_and_the_focus_follows() {
|
||||
let mut ui = screen(2);
|
||||
ui.size = SIZE;
|
||||
assert!(ui.pad_pick_up(centre(&ui, "P2")));
|
||||
assert!(ui.pad_button(PadButton::RightBumper)); // to a new window
|
||||
assert!(ui.pad_button(PadButton::South));
|
||||
// Not selected (the focus is its highlight), and the focus goes to its new tile.
|
||||
assert_eq!(ui.selected, None);
|
||||
assert_eq!(ui.take_refocus(), Some(rect(&ui, "P2")));
|
||||
assert_eq!(ui.take_refocus(), None);
|
||||
// Put back: the same.
|
||||
assert!(ui.pad_pick_up(centre(&ui, "P2")));
|
||||
assert!(ui.pad_button(PadButton::East));
|
||||
assert_eq!(
|
||||
(ui.selected, ui.take_refocus()),
|
||||
(None, Some(rect(&ui, "P2")))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_switch_and_pack_overrides_keep_the_same_draft() {
|
||||
// The language table is process-wide. Run this test alone in a child so
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
//! Shared nearest-sampled sprite array for block and item textures.
|
||||
use crate::Result;
|
||||
use horizon_assets::animation::{AnimationClip, AnimationMetadata};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::{self, File},
|
||||
io::BufReader,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
use std::{collections::BTreeMap, path::Path, sync::Arc};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SpriteAnimation {
|
||||
@@ -427,7 +421,9 @@ impl Sprites {
|
||||
per_tile: bool,
|
||||
) -> Result<Vec<Option<u32>>> {
|
||||
let path = crate::atlas_sources::texture_path(root, name, "textures");
|
||||
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
|
||||
let metadata = match horizon_core::prepared_files::read_to_string(
|
||||
&path.with_extension("png.mcmeta"),
|
||||
) {
|
||||
Ok(text) => Some(serde_json::from_str::<
|
||||
BTreeMap<String, Box<serde_json::value::RawValue>>,
|
||||
>(&text)?),
|
||||
@@ -536,7 +532,9 @@ impl Sprites {
|
||||
Self::decode_file(&path, name)
|
||||
}
|
||||
fn decode_file(path: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
|
||||
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
|
||||
let metadata = match horizon_core::prepared_files::read_to_string(
|
||||
&path.with_extension("png.mcmeta"),
|
||||
) {
|
||||
Ok(text) => Some(serde_json::from_str::<
|
||||
BTreeMap<String, Box<serde_json::value::RawValue>>,
|
||||
>(&text)?),
|
||||
@@ -694,9 +692,12 @@ impl Sprites {
|
||||
}
|
||||
}
|
||||
|
||||
/// A PNG's width, height and pixels as RGBA.
|
||||
/// A PNG's width, height and pixels as RGBA. From the prepared data file
|
||||
/// when it holds the texture: no file opened per texture, nor for its absent
|
||||
/// LabPBR maps.
|
||||
fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
|
||||
let bytes = horizon_core::prepared_files::read(path)?;
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info()?;
|
||||
@@ -792,6 +793,7 @@ pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, avail
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::{self, File};
|
||||
#[test]
|
||||
fn the_texture_budget_picks_the_largest_side_that_fits() {
|
||||
// 1300 layers: vanilla's count, about.
|
||||
|
||||
@@ -185,6 +185,13 @@ pub trait LodSampler: Send {
|
||||
fn reduced_column(&mut self, x: i32, z: i32, _width: i32) -> crate::lod::Column {
|
||||
self.column(x, z)
|
||||
}
|
||||
/// Whether a column since the last call was left empty because its data
|
||||
/// is still on its way (a BlueMap tile downloading, nothing coarser to
|
||||
/// stand in): that sample isn't drawn, what it would replace is
|
||||
/// (`Lod::finish_any_sample`), and it is taken again later.
|
||||
fn waited(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
/// `side` x `side` columns from (`x`, `z`), `stride` blocks apart; none
|
||||
/// when sampling failed and should be tried again later.
|
||||
fn grid(&mut self, x: i32, z: i32, stride: i32, side: usize) -> Vec<crate::lod::Column> {
|
||||
@@ -203,6 +210,11 @@ pub trait LodSampler: Send {
|
||||
}
|
||||
pub trait LodSource: Send + Sync {
|
||||
fn sampler(self: Arc<Self>) -> Box<dyn LodSampler>;
|
||||
/// Whether its samplers' `observed` chunks become its own (a server's
|
||||
/// kept terrain): tiles sampled without one are sampled again.
|
||||
fn keeps_observed(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
struct GeneratorSampler<G: ChunkGenerator> {
|
||||
generator: Arc<G>,
|
||||
@@ -427,8 +439,8 @@ pub struct Streaming {
|
||||
decoration_order: DecorationOrder,
|
||||
order_area: Option<((i32, i32), i32)>,
|
||||
/// What the queued jobs' priorities were computed from (`requeue`): the
|
||||
/// centre, the loading screen, the band, and the extra areas.
|
||||
queue_basis: (((i32, i32), bool, Option<Band>), Vec<((i32, i32), i32)>),
|
||||
/// centre, the band, and the extra areas.
|
||||
queue_basis: (((i32, i32), Option<Band>), Vec<((i32, i32), i32)>),
|
||||
finishing: FxHashSet<(i32, i32)>,
|
||||
decorates: bool,
|
||||
/// `ChunkGenerator::decorates_beside_final`.
|
||||
@@ -460,8 +472,6 @@ pub struct Streaming {
|
||||
pub forced: HashSet<(i32, i32)>,
|
||||
/// No work is scheduled while paused (title screen).
|
||||
pub paused: bool,
|
||||
/// The level loading screen is up: work goes ring by ring (`Ranges::rings`).
|
||||
pub loading: bool,
|
||||
/// Dimension flags: sky light, Nether face shading.
|
||||
pub sky_light: bool,
|
||||
pub nether_shading: bool,
|
||||
@@ -532,9 +542,9 @@ struct GenerationScan {
|
||||
plan: Vec<(i64, i32, i32)>,
|
||||
/// Entries before it are no longer missing.
|
||||
next: usize,
|
||||
/// What `plan` was made for: the ranges, the loading screen and
|
||||
/// decoration; the chunk map's revision is `chunk_revision`.
|
||||
plan_basis: Option<(bool, bool)>,
|
||||
/// What `plan` was made for: the ranges and decoration; the chunk
|
||||
/// map's revision is `chunk_revision`.
|
||||
plan_basis: Option<bool>,
|
||||
}
|
||||
impl GenerationScan {
|
||||
fn same_ranges(&self, ranges: &Ranges<'_>) -> bool {
|
||||
@@ -558,9 +568,7 @@ impl GenerationScan {
|
||||
/// Whether `plan` still holds every candidate: same ranges, and no
|
||||
/// column left the chunk map since it was made.
|
||||
fn plan_holds(&mut self, input: &GenerationInputs<'_>) -> bool {
|
||||
if self.plan_basis != Some((input.loading, input.decorates))
|
||||
|| !self.same_ranges(&input.ranges)
|
||||
{
|
||||
if self.plan_basis != Some(input.decorates) || !self.same_ranges(&input.ranges) {
|
||||
return false;
|
||||
}
|
||||
let revision = input.world.chunks.membership_revision();
|
||||
@@ -594,7 +602,6 @@ struct GenerationInputs<'a> {
|
||||
finishing: &'a FxHashSet<(i32, i32)>,
|
||||
decorates: bool,
|
||||
remote: bool,
|
||||
loading: bool,
|
||||
}
|
||||
impl GenerationScan {
|
||||
/// None means the scan was skipped; Some preserves the original candidate
|
||||
@@ -669,7 +676,7 @@ impl GenerationScan {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
wanted.push((input.ranges.priority(x, z, input.loading), x, z));
|
||||
wanted.push((input.ranges.priority(x, z), x, z));
|
||||
}
|
||||
}
|
||||
// Forced chunks and their generation neighbourhood: the ring that
|
||||
@@ -691,7 +698,7 @@ impl GenerationScan {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
wanted.push((input.ranges.priority(x, z, input.loading), x, z));
|
||||
wanted.push((input.ranges.priority(x, z), x, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -711,7 +718,7 @@ impl GenerationScan {
|
||||
// A bounded batch may leave candidates behind: the next calls
|
||||
// take them from the plan as capacity opens.
|
||||
self.exhausted = false;
|
||||
self.plan_basis = Some((input.loading, input.decorates));
|
||||
self.plan_basis = Some(input.decorates);
|
||||
}
|
||||
wanted.sort_unstable();
|
||||
wanted.dedup();
|
||||
@@ -982,7 +989,6 @@ impl Streaming {
|
||||
radius: 8,
|
||||
forced: HashSet::new(),
|
||||
paused: false,
|
||||
loading: false,
|
||||
sky_light: true,
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
@@ -1159,7 +1165,7 @@ impl Streaming {
|
||||
/// render distance 32 nothing was decorated until every proto chunk had
|
||||
/// been generated.
|
||||
fn requeue(&mut self) {
|
||||
let basis = (self.center, self.loading, self.band);
|
||||
let basis = (self.center, self.band);
|
||||
if self.queue_basis.0 == basis && self.queue_basis.1 == self.extra {
|
||||
return;
|
||||
}
|
||||
@@ -1297,7 +1303,7 @@ impl Streaming {
|
||||
}
|
||||
/// The order jobs around the player run in, lowest first.
|
||||
fn distance(&self, x: i32, z: i32) -> i64 {
|
||||
self.ranges().priority(x, z, self.loading)
|
||||
self.ranges().priority(x, z)
|
||||
}
|
||||
/// The 3x3 a light job reads: the finished columns, and for a neighbour
|
||||
/// that will not finish (past the area), its decorated proto chunk, as
|
||||
@@ -1958,7 +1964,6 @@ impl Streaming {
|
||||
finishing: &self.finishing,
|
||||
decorates: self.decorates,
|
||||
remote: self.remote,
|
||||
loading: self.loading,
|
||||
};
|
||||
if let Some(wanted) = self.generation_scan.candidates(input, workers * 3) {
|
||||
for (_, x, z) in wanted {
|
||||
@@ -2445,33 +2450,14 @@ impl ExtraCover {
|
||||
}
|
||||
}
|
||||
impl Ranges<'_> {
|
||||
/// `distance`, or `rings` while the level loading screen is up.
|
||||
fn priority(&self, x: i32, z: i32, loading: bool) -> i64 {
|
||||
let own = if loading {
|
||||
self.rings(x, z)
|
||||
} else {
|
||||
self.distance(x, z)
|
||||
};
|
||||
/// `distance`, or the band's nearer priority close to it.
|
||||
fn priority(&self, x: i32, z: i32) -> i64 {
|
||||
let own = self.distance(x, z);
|
||||
match self.band {
|
||||
Some(band) if band.within(x, z, 4) => own.min(band.priority(x, z)),
|
||||
_ => own,
|
||||
}
|
||||
}
|
||||
/// Ring by ring: the Chebyshev distance to the nearest centre first, then
|
||||
/// the squared distance. Vanilla's chunk tasks run by ticket level, which
|
||||
/// spreads one level per ring of chunks (`ChunkTaskPriorityQueue`), so
|
||||
/// `LevelLoadingScreen`'s chunk map fills squares around the spawn.
|
||||
fn rings(&self, x: i32, z: i32) -> i64 {
|
||||
let d = |c: (i32, i32)| {
|
||||
let (dx, dz) = (i64::from(x - c.0), i64::from(z - c.1));
|
||||
let ring = dx.abs().max(dz.abs());
|
||||
(ring * ring) << 20 | (dx * dx + dz * dz)
|
||||
};
|
||||
self.extra
|
||||
.iter()
|
||||
.map(|(c, _)| d(*c))
|
||||
.fold(d(self.center), i64::min)
|
||||
}
|
||||
fn distance(&self, x: i32, z: i32) -> i64 {
|
||||
let d = |c: (i32, i32)| {
|
||||
let (dx, dz) = (i64::from(x - c.0), i64::from(z - c.1));
|
||||
@@ -2855,7 +2841,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_jobs_follow_the_centre_and_the_loading_screen() {
|
||||
fn queued_jobs_follow_the_centre() {
|
||||
let forced = HashSet::new();
|
||||
let ranges = |center| Ranges {
|
||||
center,
|
||||
@@ -2872,13 +2858,13 @@ mod tests {
|
||||
order,
|
||||
job: Job::Generate(x, z),
|
||||
};
|
||||
let loading = ranges((0, 0));
|
||||
// Both queued around an earlier centre, where the farther job came first.
|
||||
let earlier = ranges((40, 0));
|
||||
let current = ranges((0, 0));
|
||||
let queued = || {
|
||||
BinaryHeap::from(vec![
|
||||
// Queued while the loading screen was up: ring order.
|
||||
generate(3, 1, loading.priority(3, 1, true) * 4, 1),
|
||||
// Queued after it closed: plain distances, far below any ring past the first.
|
||||
generate(20, 0, loading.priority(20, 0, false) * 4, 2),
|
||||
generate(3, 1, earlier.priority(3, 1) * 4, 1),
|
||||
generate(20, 0, earlier.priority(20, 0) * 4, 2),
|
||||
Queued {
|
||||
priority: i64::MIN,
|
||||
at: None,
|
||||
@@ -2899,12 +2885,12 @@ mod tests {
|
||||
"the stale order"
|
||||
);
|
||||
let mut queue = queued();
|
||||
rank_again(&mut queue, |x, z| loading.priority(x, z, false));
|
||||
rank_again(&mut queue, |x, z| current.priority(x, z));
|
||||
assert_eq!(pops(queue), [None, Some((3, 1)), Some((20, 0))]);
|
||||
// The player moved: the job near the new centre goes first.
|
||||
let mut queue = BinaryHeap::from(vec![generate(3, 1, 0, 1), generate(20, 0, 1, 2)]);
|
||||
let moved = ranges((19, 0));
|
||||
rank_again(&mut queue, |x, z| moved.priority(x, z, false));
|
||||
rank_again(&mut queue, |x, z| moved.priority(x, z));
|
||||
assert_eq!(queue.pop().and_then(|q| q.at), Some((20, 0)));
|
||||
}
|
||||
|
||||
@@ -3088,7 +3074,6 @@ mod tests {
|
||||
finishing: &self.finishing,
|
||||
decorates: self.decorates,
|
||||
remote: self.remote,
|
||||
loading: false,
|
||||
}
|
||||
}
|
||||
fn fill(&mut self) {
|
||||
@@ -3508,33 +3493,6 @@ mod tests {
|
||||
/// from the save) and is not unloaded right after: either way it would be
|
||||
/// asked for and loaded again on the next frame, forever (8 such columns
|
||||
/// after a teleport at render distance 16 held 8 of the in-flight slots).
|
||||
/// #770: while the loading screen is up, chunks go ring by ring (a
|
||||
/// square front, as vanilla's ticket levels), nearest first in a ring.
|
||||
#[test]
|
||||
fn loading_goes_ring_by_ring() {
|
||||
let forced = HashSet::new();
|
||||
let r = Ranges {
|
||||
center: (2, -1),
|
||||
radius: 12,
|
||||
extra: &[],
|
||||
forced: &forced,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
let at = |dx: i32, dz: i32| (2 + dx, -1 + dz);
|
||||
let key = |(x, z): (i32, i32), loading| r.priority(x, z, loading);
|
||||
// The corner of ring 3 comes before the edge of ring 4 only while loading.
|
||||
assert!(key(at(3, 3), true) < key(at(0, 4), true));
|
||||
assert!(key(at(3, 3), false) > key(at(0, 4), false));
|
||||
// Inside a ring, nearest first; rings never interleave.
|
||||
assert!(key(at(0, 3), true) < key(at(2, -3), true));
|
||||
assert!(key(at(-3, -3), true) < key(at(4, 0), true));
|
||||
for dx in -20..=20 {
|
||||
for dz in -20..=20 {
|
||||
assert_eq!(key(at(dx, dz), false), r.distance(2 + dx, -1 + dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn requested_columns_stay_loaded() {
|
||||
let forced = HashSet::new();
|
||||
|
||||
@@ -176,7 +176,7 @@ impl TrimTexture {
|
||||
let Some(palette) = &self.palette else {
|
||||
return Some((w, h, pixels));
|
||||
};
|
||||
let base = fs::read(path.with_extension("png.mcmeta"))
|
||||
let base = horizon_core::prepared_files::read(&path.with_extension("png.mcmeta"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
|
||||
.and_then(|m| {
|
||||
@@ -241,9 +241,10 @@ pub fn slot_size(root: &Path) -> (u32, u32) {
|
||||
|
||||
/// The width and height in a PNG's IHDR chunk.
|
||||
fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
use std::io::Read;
|
||||
let mut head = [0u8; 24];
|
||||
fs::File::open(path).ok()?.read_exact(&mut head).ok()?;
|
||||
let head: [u8; 24] = horizon_core::prepared_files::read_head(path, 24)
|
||||
.ok()?
|
||||
.try_into()
|
||||
.ok()?;
|
||||
let be = |i: usize| u32::from_be_bytes(head[i..i + 4].try_into().unwrap());
|
||||
(&head[12..16] == b"IHDR").then(|| (be(16), be(20)))
|
||||
}
|
||||
|
||||
@@ -130,6 +130,10 @@ path = "src/main.rs"
|
||||
name = "launch_api"
|
||||
path = "tests/launch_api.rs"
|
||||
|
||||
[[test]]
|
||||
name = "mod_api_hooks"
|
||||
path = "tests/mod_api_hooks.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
@@ -207,8 +207,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Pro Bild an die GPU gesendete Mesh-Daten. Weniger ist flüssiger beim Laden.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Bildrate und Bildzeiten über dem Spiel, mit Diagramm.",
|
||||
"horizon.multiplayer.finding_friend": "Die Welt deines Freundes wird gesucht …",
|
||||
"horizon.loading.opening_world": "Welt wird geöffnet",
|
||||
"horizon.loading.cancelling_world": "Wird abgebrochen",
|
||||
"horizon.title.not_affiliated": "Nicht mit Mojang oder Microsoft verbunden",
|
||||
"horizon.extras.header.lighting": "Beleuchtung",
|
||||
"horizon.extras.header.third_person": "Dritte Person",
|
||||
|
||||
@@ -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",
|
||||
@@ -211,8 +212,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Mesh data sent to the GPU per frame. Lower is smoother while loading.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph.",
|
||||
"horizon.multiplayer.finding_friend": "Finding your friend's world...",
|
||||
"horizon.loading.opening_world": "Opening world",
|
||||
"horizon.loading.cancelling_world": "Cancelling world",
|
||||
"horizon.multiplayer.not_signed_in": "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it.",
|
||||
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
|
||||
@@ -362,6 +361,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 +403,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",
|
||||
|
||||
@@ -198,8 +198,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Datos de malla enviados a la GPU por fotograma. Menos es más fluido durante la carga.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Fotogramas por segundo y tiempos de fotograma sobre el juego, con un gráfico.",
|
||||
"horizon.multiplayer.finding_friend": "Buscando el mundo de tu amigo…",
|
||||
"horizon.loading.opening_world": "Abriendo el mundo",
|
||||
"horizon.loading.cancelling_world": "Cancelando",
|
||||
"horizon.multiplayer.not_signed_in": "Este servidor está en modo en línea. Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para unirte.",
|
||||
"horizon.multiplayer.invalid_address": "Dirección de servidor no válida: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Conectado directamente con tu amigo…",
|
||||
|
||||
@@ -207,8 +207,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Données de maillage vers le GPU par image. Moins : plus fluide.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Fréquence et temps d'image par-dessus le jeu, avec un graphique.",
|
||||
"horizon.multiplayer.finding_friend": "Recherche du monde de votre ami...",
|
||||
"horizon.loading.opening_world": "Ouverture du monde",
|
||||
"horizon.loading.cancelling_world": "Annulation en cours",
|
||||
"horizon.title.not_affiliated": "Non affilié à Mojang ou à Microsoft",
|
||||
"horizon.extras.header.lighting": "Éclairage",
|
||||
"horizon.extras.header.third_person": "Troisième personne",
|
||||
@@ -347,6 +345,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 +372,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",
|
||||
|
||||
@@ -207,8 +207,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Dane siatki wysyłane do GPU na klatkę. Mniej to płynniej podczas ładowania.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Liczba klatek i czasy klatek nad grą, z wykresem.",
|
||||
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
|
||||
"horizon.loading.opening_world": "Otwieranie świata",
|
||||
"horizon.loading.cancelling_world": "Anulowanie",
|
||||
"horizon.multiplayer.not_signed_in": "Ten serwer działa w trybie online. Zaloguj się kontem Microsoft w launcherze Horizon, aby dołączyć.",
|
||||
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
|
||||
|
||||
@@ -197,8 +197,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Данных меша в видеокарту за кадр. Меньше — плавнее во время загрузки.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
|
||||
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
|
||||
"horizon.loading.opening_world": "Открытие мира",
|
||||
"horizon.loading.cancelling_world": "Отмена",
|
||||
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
|
||||
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
|
||||
|
||||
@@ -207,8 +207,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Дані сітки до GPU за кадр. Менше — плавніше завантаження.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота та час кадрів поверх гри, з графіком.",
|
||||
"horizon.multiplayer.finding_friend": "Шукаємо світ друга…",
|
||||
"horizon.loading.opening_world": "Відкриття світу",
|
||||
"horizon.loading.cancelling_world": "Скасування",
|
||||
"horizon.multiplayer.not_signed_in": "Цей сервер працює в онлайн-режимі. Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб приєднатися.",
|
||||
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
|
||||
@@ -343,5 +341,104 @@
|
||||
"horizon.pages.open.split_setup": "Розділений екран…",
|
||||
"horizon.pages.split_settings": "Налаштування розділеного екрана",
|
||||
"horizon.pages.split_setup": "Розділений екран",
|
||||
"horizon.pages.split_setup.tooltip": "Гравці на цьому комп’ютері: геймпади можуть долучитися до вашого світу."
|
||||
"horizon.pages.split_setup.tooltip": "Гравці на цьому комп’ютері: геймпади можуть долучитися до вашого світу.",
|
||||
"horizon.split.leave": "Вийти",
|
||||
"horizon.splitscreen.setup.monitor": "Монітор %s",
|
||||
"horizon.splitscreen.setup.monitor_unknown": "монітор",
|
||||
"horizon.splitscreen.setup.window": "Вікно %s",
|
||||
"horizon.splitscreen.setup.new_window": "Нове вікно",
|
||||
"horizon.splitscreen.setup.fill_monitor": "На весь %s",
|
||||
"horizon.splitscreen.setup.done": "Готово",
|
||||
"horizon.splitscreen.setup.switch": "%s: %s",
|
||||
"horizon.splitscreen.setup.on": "Увімк.",
|
||||
"horizon.splitscreen.setup.off": "Вимк.",
|
||||
"horizon.splitscreen.setup.player": "Гравець %s",
|
||||
"horizon.splitscreen.setup.revert": "Скасувати",
|
||||
"horizon.splitscreen.setup.apply": "Застосувати",
|
||||
"horizon.splitscreen.setup.warning.empty_main": "У вікні 1 ніхто не грає: воно сховається",
|
||||
"horizon.splitscreen.setup.warning.empty_close": "У вікні %s ніхто не грає: воно закриється після «Застосувати»",
|
||||
"horizon.splitscreen.setup.warning.empty_new": "У вікні %s ніхто не грає: воно не відкриється",
|
||||
"horizon.splitscreen.setup.warning.on_main": "Вікно %s займає монітор вікна 1: його гравці грають у вікні 1",
|
||||
"horizon.splitscreen.setup.warning.unplugged": "Монітор вікна %s не під’єднано: його гравці грають у вікні 1",
|
||||
"horizon.splitscreen.setup.status.off": "Розділений екран вимкнено.",
|
||||
"horizon.splitscreen.setup.status.enable_hint": "Увімкніть його, а потім у світі натисніть кнопку на іншому контролері.",
|
||||
"horizon.splitscreen.setup.status.join_hint": "Натисніть A, щоб приєднатися, і виберіть профіль.",
|
||||
"horizon.splitscreen.setup.status.idle": "Розділений екран очікує",
|
||||
"horizon.splitscreen.setup.status.alone": "Інших локальних гравців немає.",
|
||||
"horizon.splitscreen.setup.status.solo_controls": "Керування й віддача контролера для одного гравця не змінюються.",
|
||||
"horizon.splitscreen.setup.window_empty": "Вікно %s: порожнє",
|
||||
"horizon.splitscreen.setup.note.select": "Клацніть гравця або перетягніть його на вікно чи монітор.",
|
||||
"horizon.splitscreen.setup.note.apply": "«Застосувати» — грати так, «Скасувати» — повернути як було.",
|
||||
"horizon.splitscreen.setup.note.place": "Де має грати %s?",
|
||||
"horizon.splitscreen.setup.this_player": "цей гравець",
|
||||
"horizon.splitscreen.setup.switch_disabled": "Розділений екран вимкнеться, щойно інші вийдуть (через їхнє меню паузи).",
|
||||
"horizon.splitscreen.setup.window_place": "Вікно %s (%s із %s)",
|
||||
"horizon.splitscreen.setup.names": "Імена",
|
||||
"horizon.splitscreen.setup.identify": "Позначити",
|
||||
"horizon.splitscreen.setup.layout.two": "Удвох: %s",
|
||||
"horizon.splitscreen.setup.layout.three": "Утрьох: %s",
|
||||
"horizon.splitscreen.setup.layout.two.0": "Авто",
|
||||
"horizon.splitscreen.setup.layout.two.1": "Поруч",
|
||||
"horizon.splitscreen.setup.layout.two.2": "Згори й знизу",
|
||||
"horizon.splitscreen.setup.layout.three.0": "Один широкий згори",
|
||||
"horizon.splitscreen.setup.layout.three.1": "Один високий ліворуч",
|
||||
"horizon.splitscreen.setup.layout.three.2": "Поруч",
|
||||
"horizon.splitscreen.setup.help.player": "Клацніть або перетягніть %s, щоб перемістити.",
|
||||
"horizon.splitscreen.setup.help.layout_auto": "%s (поруч, коли вікно широке). Натисніть, щоб змінити.",
|
||||
"horizon.splitscreen.setup.help.layout": "%s. Натисніть, щоб змінити.",
|
||||
"horizon.splitscreen.setup.help.switch": "Розділений екран на цьому комп’ютері: увімкнено чи вимкнено.",
|
||||
"horizon.splitscreen.setup.help.identify": "Кожен гравець бачить у своєму виді, в якому він вікні.",
|
||||
"horizon.splitscreen.setup.help.names": "Ім’я кожного гравця в кутку його виду.",
|
||||
"horizon.splitscreen.setup.help.revert": "Скасувати ще не застосовані зміни.",
|
||||
"horizon.splitscreen.setup.help.apply": "Грати так, як показує схема.",
|
||||
"horizon.splitscreen.setup.help.unchanged": "Поки нічого не змінено.",
|
||||
"horizon.splitscreen.setup.give_up_pad": "Віддати контролер",
|
||||
"horizon.splitscreen.prompt.controller_connected": "Контролер під’єднано",
|
||||
"horizon.splitscreen.prompt.controller_disconnected": "Контролер від’єднано",
|
||||
"horizon.splitscreen.prompt.controller_disconnected.hint": "Під’єднайте його знову або натисніть кнопку на іншому",
|
||||
"horizon.splitscreen.prompt.join.one": "%s хоче приєднатися",
|
||||
"horizon.splitscreen.prompt.join.many": "Контролерів, що хочуть приєднатися: %s",
|
||||
"horizon.splitscreen.prompt.join.yes": "Приєднатися",
|
||||
"horizon.splitscreen.prompt.join.as_p1": "Грати як Г1",
|
||||
"horizon.splitscreen.prompt.join.no": "Ні",
|
||||
"horizon.splitscreen.prompt.lost": "%s: контролер від’єднано",
|
||||
"horizon.splitscreen.prompt.lost.use_another": "Узяти інший",
|
||||
"horizon.splitscreen.prompt.lost.leave": "Вийти",
|
||||
"horizon.splitscreen.setup.help.give_up_pad": "Ваш контролер більше не керує вами, і його можна взяти, щоб приєднатися (A).",
|
||||
"horizon.splitscreen.setup.note.held_at": "%s → %s: A — поставити тут, B — повернути назад.",
|
||||
"horizon.splitscreen.setup.note.held": "%s узято: ліворуч/праворуч — вибрати місце, A — поставити, B — повернути назад.",
|
||||
"Block Entities": "Блокові сутності",
|
||||
"Shulker Boxes": "Шалкерові коробки",
|
||||
"Reuses settled boxes; lid movement stays animated.": "Повторно використовує нерухомі коробки; рух кришки лишається анімованим.",
|
||||
"Signs": "Таблички",
|
||||
"Reuses sign text until text, light, or font changes.": "Повторно використовує текст табличок, доки не зміняться текст, світло чи шрифт.",
|
||||
"Decorated Pots": "Оздоблені горщики",
|
||||
"Reuses still pots; break wobble and sherd changes redraw.": "Повторно використовує нерухомі горщики; хитання від удару й зміна черепків перемальовуються.",
|
||||
"Bells": "Дзвони",
|
||||
"Reuses still bells; ringing stays animated.": "Повторно використовує нерухомі дзвони; дзвін лишається анімованим.",
|
||||
"Chests": "Скрині",
|
||||
"Reuses closed chests; moving lids stay animated.": "Повторно використовує закриті скрині; рухомі кришки лишаються анімованими.",
|
||||
"Banners": "Стяги",
|
||||
"Reuses poles; flags and pattern layers still update.": "Повторно використовує держаки; полотнища й шари візерунків оновлюються.",
|
||||
"Copper Golem Statues": "Статуї мідних ґолемів",
|
||||
"Reuses statue geometry until its block state changes.": "Повторно використовує геометрію статуї, доки не зміниться стан її блока.",
|
||||
"Find BlueMap": "Шукати BlueMap",
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.": "На серверах шукає їхню вебмапу BlueMap для далекого рельєфу. З наступного входу.",
|
||||
"BlueMap Radius": "Радіус BlueMap",
|
||||
"How far around you BlueMap tiles are downloaded. Next join.": "Як далеко навколо вас завантажуються плитки BlueMap. З наступного входу.",
|
||||
"%s nits": "%s ніт",
|
||||
"Auto (%s nits)": "Авто (%s ніт)",
|
||||
"%.2f nits": "%.2f ніт",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Приховує моделі за непрозорими блоками. Сутності оновлюються. Вимкнено з власними наборами ресурсів.",
|
||||
"horizon.extras.repro_keys": "Клавіші запису",
|
||||
"horizon.extras.repro_keys.tooltip": "F3+R починає або зупиняє запис для відтворення помилки, F3+E зберігає його пакет. Вимкнено за замовчуванням: запис копіює світ.",
|
||||
"horizon.agent.record_session": "Почати запис сесії",
|
||||
"horizon.agent.record_stop": "Зупинити запис сесії",
|
||||
"horizon.agent.export_repro": "Експортувати пакет відтворення",
|
||||
"horizon.repro.started": "Запис почато; F3+R зупиняє його й зберігає пакет.",
|
||||
"horizon.repro.stopped": "Запис зупинено: %s",
|
||||
"horizon.repro.bundle": "Пакет відтворення: %s",
|
||||
"horizon.repro.error": "Відтворення: %s",
|
||||
"horizon.repro.no_recording": "Поки немає запису для експорту.",
|
||||
"horizon.repro.replay_started": "Відтворення почато: %s"
|
||||
}
|
||||
@@ -201,8 +201,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "每帧发送给 GPU 的网格数据。越低加载时越流畅。",
|
||||
"Frame rate and frame times over the game, with a graph.": "游戏运行时的帧率与帧时间,附带图表。",
|
||||
"horizon.multiplayer.finding_friend": "正在查找好友的世界…",
|
||||
"horizon.loading.opening_world": "正在打开世界",
|
||||
"horizon.loading.cancelling_world": "正在取消",
|
||||
"horizon.multiplayer.not_signed_in": "该服务器为在线模式服务器。请先在 Horizon 启动器中登录 Microsoft 账户再加入。",
|
||||
"horizon.multiplayer.invalid_address": "无效的服务器地址:%s",
|
||||
"horizon.multiplayer.connected_friend_direct": "已直连好友的世界…",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! cargo run --release -p horizon-client-lib --example gen_light_bench -- \
|
||||
//! .local/horizon-client/26.3 [--seed 1234] [--center CX CZ] [--radius 12]
|
||||
//! [--threads 1,2,4,8] [--period-ms 2] [--loading] [--repeat 1] [--json]
|
||||
//! [--threads 1,2,4,8] [--period-ms 2] [--repeat 1] [--json]
|
||||
//!
|
||||
//! For each run it prints the wall time until every column within the radius
|
||||
//! (the area Streaming finishes) was lit, columns finished and lit per second, the process's CPU seconds per
|
||||
@@ -16,8 +16,7 @@
|
||||
//! covers the blocks and light of the columns within the radius: an A/B pair of
|
||||
//! builds, and every worker count, must print the same one.
|
||||
//! `--period-ms` is how long the coordinator sleeps between updates (the
|
||||
//! server thread runs one per 50 ms tick and pumps between ticks); `--loading`
|
||||
//! orders work ring by ring, as under the level loading screen.
|
||||
//! server thread runs one per 50 ms tick and pumps between ticks).
|
||||
//!
|
||||
//! `--simulate PROTO,DECORATE,FINISH` (milliseconds) swaps the generator for
|
||||
//! one whose jobs sleep that long and make empty columns: what is left is the
|
||||
@@ -117,13 +116,11 @@ fn run<G: ChunkGenerator>(
|
||||
radius: i32,
|
||||
threads: usize,
|
||||
period: Duration,
|
||||
loading: bool,
|
||||
) -> Result<Run, Error> {
|
||||
let mut streaming = Streaming::new(Arc::new(generator), context.clone(), threads);
|
||||
streaming.mesh = false;
|
||||
streaming.center = center;
|
||||
streaming.radius = radius;
|
||||
streaming.loading = loading;
|
||||
let mut world = World::new(BlockStateId(0), 0);
|
||||
let view: Vec<(i32, i32)> = (center.0 - radius - 1..=center.0 + radius + 1)
|
||||
.flat_map(|x| (center.1 - radius - 1..=center.1 + radius + 1).map(move |z| (x, z)))
|
||||
@@ -215,7 +212,6 @@ fn main() -> Result<(), Error> {
|
||||
let radius: i32 = flag(&args, "--radius", 12);
|
||||
let repeat: usize = flag(&args, "--repeat", 1);
|
||||
let period = Duration::from_millis(flag(&args, "--period-ms", 2));
|
||||
let loading = args.iter().any(|a| a == "--loading");
|
||||
let json = args.iter().any(|a| a == "--json");
|
||||
let simulate: Option<Vec<f64>> = args
|
||||
.iter()
|
||||
@@ -291,7 +287,7 @@ fn main() -> Result<(), Error> {
|
||||
render,
|
||||
});
|
||||
let terrain = || VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed);
|
||||
let go = |center, radius, n, loading| -> Result<Run, Error> {
|
||||
let go = |center, radius, n| -> Result<Run, Error> {
|
||||
match &simulate {
|
||||
Some(ms) => {
|
||||
let ms = |i: usize| Duration::from_secs_f64(ms[i] / 1e3);
|
||||
@@ -300,18 +296,18 @@ fn main() -> Result<(), Error> {
|
||||
decorate: ms(1),
|
||||
finish: ms(2),
|
||||
};
|
||||
run(generator, &context, center, radius, n, period, loading)
|
||||
run(generator, &context, center, radius, n, period)
|
||||
}
|
||||
None => run(terrain()?, &context, center, radius, n, period, loading),
|
||||
None => run(terrain()?, &context, center, radius, n, period),
|
||||
}
|
||||
};
|
||||
// Warm the shared generator data and the code away from the measured area.
|
||||
go((center.0 + 4000, center.1), 2, n, false)?;
|
||||
go((center.0 + 4000, center.1), 2, n)?;
|
||||
let pool = horizon_threads::jobs::pool().threads();
|
||||
if pool != n {
|
||||
eprintln!("the worker pool has {pool} threads, not {n} (HORIZON_JOB_THREADS?)");
|
||||
}
|
||||
let r = go(center, radius, n, loading)?;
|
||||
let r = go(center, radius, n)?;
|
||||
let busy: f64 = r.jobs.iter().map(|j| j.1 as f64).sum::<f64>() / 1e9;
|
||||
println!(
|
||||
"workers {n:2}: {:.2} s ({:.2} s to half), {} columns ({} lit), {:.1} columns/s, {:.1} lit/s, \
|
||||
|
||||
@@ -116,7 +116,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
|
||||
for (i, flags) in ticks.iter().enumerate() {
|
||||
mob.age = i as u32 + 1;
|
||||
bat::set_resting(mob, flags.as_i64().unwrap_or(0) & 1 != 0);
|
||||
if flag("@batInFrame") {
|
||||
if flag("@batServerTick") {
|
||||
bat::tick(mob);
|
||||
} else {
|
||||
bat::setup_animation_states(mob);
|
||||
|
||||
@@ -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,
|
||||
@@ -1196,6 +1197,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
items: Some(&items),
|
||||
lid_openers: &lid_openers,
|
||||
mod_effects: None,
|
||||
border: None,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,22 +104,20 @@ impl Tree {
|
||||
let Ok(namespaces) = std::fs::read_dir(data) else {
|
||||
return tree;
|
||||
};
|
||||
let mut files = Vec::new();
|
||||
for ns in namespaces.flatten() {
|
||||
let root = ns.path().join("advancement");
|
||||
collect(&root, &root, &ns.file_name().to_string_lossy(), &mut files);
|
||||
}
|
||||
// One JSON per id, the packs' over the game's own.
|
||||
// One JSON per id, the packs' over the game's own. Through `json_folder`: from
|
||||
// the prepared data file, and read once while a load holds the folder.
|
||||
let mut json: BTreeMap<String, Value> = BTreeMap::new();
|
||||
files.sort();
|
||||
for (id, path) in files {
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
for ns in namespaces.flatten() {
|
||||
let Ok(folder) = horizon_world::json_folder::read(&ns.path().join("advancement"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(v) = serde_json::from_str::<Value>(&text) else {
|
||||
continue;
|
||||
};
|
||||
json.insert(id, v);
|
||||
let ns = ns.file_name().to_string_lossy().into_owned();
|
||||
for file in &folder.files {
|
||||
if let Ok(v) = serde_json::from_slice::<Value>(file.bytes().unwrap_or_default()) {
|
||||
json.insert(format!("{ns}:{}", file.name), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, files) in overlay.kind("advancement") {
|
||||
match serde_json::from_slice::<Value>(horizon_world::loot::PackOverlay::top(files)) {
|
||||
@@ -249,21 +247,6 @@ impl InventoryIndex {
|
||||
out
|
||||
}
|
||||
}
|
||||
fn collect(root: &Path, dir: &Path, ns: &str, out: &mut Vec<(String, std::path::PathBuf)>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
collect(root, &p, ns, out);
|
||||
} else if p.extension().is_some_and(|x| x == "json") {
|
||||
let rel = p.strip_prefix(root).unwrap_or(&p).with_extension("");
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
out.push((format!("{ns}:{rel}"), p));
|
||||
}
|
||||
}
|
||||
}
|
||||
fn parse(id: &str, v: &Value) -> Option<Advancement> {
|
||||
let criteria: Vec<Criterion> = v["criteria"]
|
||||
.as_object()?
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use crate::sound_engine::{AMBIENT, InstanceId, PLAYERS, SoundEngine, SoundInstance, Sounds};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
/// `AmbientMoodSettings`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -54,17 +54,19 @@ pub struct AmbientTables {
|
||||
impl AmbientTables {
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let read = |dir: &str| -> HashMap<String, AmbientSounds> {
|
||||
let Ok(entries) = fs::read_dir(root.join("data/minecraft").join(dir)) else {
|
||||
let Ok(folder) =
|
||||
horizon_world::json_folder::read(&root.join("data/minecraft").join(dir))
|
||||
else {
|
||||
return HashMap::new();
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let path = e.path();
|
||||
let name = path.file_stem()?.to_str()?.to_owned();
|
||||
let json: Value = serde_json::from_slice(&fs::read(&path).ok()?).ok()?;
|
||||
folder
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|file| {
|
||||
let json: Value = serde_json::from_slice(file.bytes().ok()?).ok()?;
|
||||
let v = &json["attributes"]["minecraft:audio/ambient_sounds"];
|
||||
(!v.is_null()).then(|| (format!("minecraft:{name}"), AmbientSounds::parse(v)))
|
||||
(!v.is_null())
|
||||
.then(|| (format!("minecraft:{}", file.name), AmbientSounds::parse(v)))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! when the block goes, and despawning. The tick stops at an entity it hits:
|
||||
//! the game hurts the entity (`projectile_hit`), then `finish_entity_hit`
|
||||
//! spends the arrow or turns it back and ends the tick.
|
||||
use crate::entity_rules::EntityRules;
|
||||
use crate::{
|
||||
blocks::BlockFacts,
|
||||
collisions::SceneCollisions,
|
||||
@@ -777,9 +778,9 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
if !self.st.was_touching_water {
|
||||
self.p.velocity = self.p.velocity.scale(f64::from(0.99f32));
|
||||
}
|
||||
if physics && !self.st.in_ground && !self.p.no_gravity {
|
||||
if physics && !self.st.in_ground {
|
||||
// Entity.applyGravity: AbstractArrow.getDefaultGravity, none with NoGravity.
|
||||
self.add_velocity(-0.05);
|
||||
self.add_velocity(-self.p.get_gravity());
|
||||
}
|
||||
// Projectile.tick, then Entity.baseTick's fluid and fire phases.
|
||||
if !self.st.has_been_shot {
|
||||
|
||||
@@ -9,10 +9,10 @@ pub mod arrows;
|
||||
pub mod block_contact;
|
||||
pub use horizon_client_render::{
|
||||
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities,
|
||||
block_entity_anim, block_entity_render, bluemap, border_render, breaking, brewing, camera,
|
||||
chat, chunk, chunk_map, cit, cit_armor, cit_assets, cit_glint, clouds, collisions,
|
||||
command_block, containers, crash, datapack, dynamic_light, effects, environment, extras,
|
||||
fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud,
|
||||
block_entity_anim, block_entity_render, bluemap, bluemap_detect, border_render, breaking,
|
||||
brewing, camera, chat, chunk, chunk_map, cit, cit_armor, cit_assets, cit_glint, clouds,
|
||||
collisions, command_block, containers, crash, datapack, dynamic_light, effects, environment,
|
||||
extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud,
|
||||
hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block, json_folder,
|
||||
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
|
||||
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
|
||||
@@ -59,6 +59,7 @@ pub mod end_gateway;
|
||||
pub mod entities;
|
||||
pub mod entity_culling;
|
||||
pub mod entity_nbt;
|
||||
pub mod entity_rules;
|
||||
pub mod extras_hud;
|
||||
pub mod falling_block;
|
||||
pub mod fire_render;
|
||||
|
||||
@@ -311,10 +311,6 @@ pub struct FixedStep {
|
||||
/// (`ServerTickRateManager.updateStepTicks` broadcasts each one).
|
||||
step_changes: u32,
|
||||
step_requested: i32,
|
||||
/// In-frame ticks dropped since the last overload warning (`drop_ticks`).
|
||||
dropped: u32,
|
||||
/// When the last overload warning was due, on the frame clock.
|
||||
last_overload_warning: std::time::Duration,
|
||||
}
|
||||
|
||||
impl Default for FixedStep {
|
||||
@@ -332,8 +328,6 @@ impl Default for FixedStep {
|
||||
client: false,
|
||||
step_changes: 0,
|
||||
step_requested: 0,
|
||||
dropped: 0,
|
||||
last_overload_warning: std::time::Duration::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -448,41 +442,6 @@ impl FixedStep {
|
||||
pub fn sprinting(&self) -> bool {
|
||||
self.manager.is_sprinting()
|
||||
}
|
||||
/// Whether a frame that ran its world in the frame, and whose ticks so
|
||||
/// far took `spent`, may run another one. Vanilla's world ticks on the
|
||||
/// integrated server's thread, so a slow tick costs its frames nothing:
|
||||
/// the server falls behind and says so (`MinecraftServer.runServer`'s
|
||||
/// "Can't keep up!"). In the frame, a world whose ticks take longer than
|
||||
/// their length falls behind every frame, so every frame ran the client
|
||||
/// timer's ten: at 57 ms a tick, 1.7 FPS. So a frame stops once its
|
||||
/// ticks have taken a tick's length: ticks of a few milliseconds keep the
|
||||
/// full ten, and a world that cannot keep up runs one tick a frame,
|
||||
/// slower but drawn. A sprint (`/tick sprint`) runs its own frame's worth.
|
||||
pub fn tick_fits(&self, spent: std::time::Duration) -> bool {
|
||||
self.sprinting() || spent < self.tick_duration()
|
||||
}
|
||||
/// The frame skips its remaining `ticks` (`tick_fits`): the world runs
|
||||
/// slower than its tick rate. Returns vanilla's overload warning when one
|
||||
/// is due: a second's worth of ticks behind, at most every ten seconds
|
||||
/// (`OVERLOADED_THRESHOLD_NANOS`, `OVERLOADED_WARNING_INTERVAL_NANOS`).
|
||||
pub fn drop_ticks(&mut self, ticks: u32) -> Option<String> {
|
||||
self.dropped = self.dropped.saturating_add(ticks);
|
||||
let tick = self.tick_duration();
|
||||
let behind = tick * self.dropped;
|
||||
let threshold = std::time::Duration::from_secs(1) + tick * 20;
|
||||
let interval = std::time::Duration::from_secs(10) + tick * 100;
|
||||
if behind <= threshold || self.now < self.last_overload_warning + interval {
|
||||
return None;
|
||||
}
|
||||
let warning = format!(
|
||||
"Can't keep up! Is the server overloaded? Running {}ms or {} ticks behind",
|
||||
behind.as_millis(),
|
||||
self.dropped
|
||||
);
|
||||
self.dropped = 0;
|
||||
self.last_overload_warning = self.now;
|
||||
Some(warning)
|
||||
}
|
||||
/// `requestGameToSprint`: true when a sprint was already running.
|
||||
pub fn start_sprint(&mut self, ticks: u32) -> bool {
|
||||
self.manager
|
||||
@@ -554,37 +513,6 @@ impl FixedStep {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_frame_stops_ticking_after_a_ticks_length() {
|
||||
let clock = FixedStep::default();
|
||||
let ms = std::time::Duration::from_millis;
|
||||
// Ticks of 4 ms: the ten the timer allows fit in 50 ms.
|
||||
assert!(clock.tick_fits(ms(4 * 9)));
|
||||
// Ticks of 40 ms: the second one runs, not a third.
|
||||
assert!(clock.tick_fits(ms(40)));
|
||||
assert!(!clock.tick_fits(ms(80)));
|
||||
// One tick longer than its length still runs, once a frame.
|
||||
assert!(clock.tick_fits(ms(0)));
|
||||
assert!(!clock.tick_fits(ms(120)));
|
||||
}
|
||||
#[test]
|
||||
fn dropped_ticks_warn_once_a_second_behind_and_not_again_for_ten() {
|
||||
let mut clock = FixedStep::default();
|
||||
let s = std::time::Duration::from_secs;
|
||||
clock.now = s(20);
|
||||
assert_eq!(
|
||||
clock.drop_ticks(40),
|
||||
None,
|
||||
"two seconds: not past 1 s + 20 ticks"
|
||||
);
|
||||
let warning = clock.drop_ticks(1).expect("past the threshold");
|
||||
assert!(warning.contains("2050ms or 41 ticks behind"), "{warning}");
|
||||
clock.now = s(25);
|
||||
assert_eq!(clock.drop_ticks(100), None, "within ten seconds");
|
||||
clock.now = s(36);
|
||||
assert!(clock.drop_ticks(0).unwrap().contains("100 ticks behind"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_carries_fractions_and_caps_nothing_itself() {
|
||||
let mut t = Timer::new(20.0, 0);
|
||||
|
||||
@@ -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.
|
||||
@@ -1235,6 +1254,10 @@ pub struct ArmorStand {
|
||||
pub no_base_plate: bool,
|
||||
pub invisible: bool,
|
||||
pub marker: bool,
|
||||
/// `Entity.isNoGravity` (`NoGravity` in the save). `ArmorStand.hasPhysics`
|
||||
/// is false with it, as with `Marker`: `travel` does nothing, so the stand
|
||||
/// never moves, and the blocks it stands in do not touch it.
|
||||
pub no_gravity: bool,
|
||||
/// `CustomName`: the text of a hologram.
|
||||
pub custom_name: Option<crate::text::Text>,
|
||||
/// `CustomNameVisible`.
|
||||
@@ -1324,6 +1347,7 @@ impl ArmorStand {
|
||||
no_base_plate: false,
|
||||
invisible: false,
|
||||
marker: false,
|
||||
no_gravity: false,
|
||||
custom_name: None,
|
||||
name_visible: false,
|
||||
last_hit: i64::MIN / 2,
|
||||
@@ -1409,7 +1433,9 @@ impl ArmorStand {
|
||||
self.fire_ticks = self.fire_ticks.max(300);
|
||||
}
|
||||
let fire = crate::motion::environment::fire_contact(world, registry, self.bounds()) > 0.0;
|
||||
if fire && !self.invisible && !self.marker {
|
||||
// The fire block reaches a stand through `applyEffectsFromBlocks`,
|
||||
// which a NoGravity stand does not feel (measured beside the server).
|
||||
if fire && !self.invisible && !self.marker && !self.no_gravity {
|
||||
if self.fire_ticks > 0 {
|
||||
self.health -= 0.15;
|
||||
} else {
|
||||
@@ -1466,6 +1492,10 @@ impl ArmorStand {
|
||||
if v.z.abs() < 0.003 { 0.0 } else { v.z },
|
||||
);
|
||||
self.velocity = wanted;
|
||||
// ArmorStand.travel: no physics with NoGravity, the velocity stays.
|
||||
if self.no_gravity {
|
||||
return;
|
||||
}
|
||||
// `travelInFluid` reads `isFalling` before the move and any push;
|
||||
// `travelInAir` reads ground friction before the move.
|
||||
let falling = wanted.y <= 0.0;
|
||||
@@ -2625,6 +2655,36 @@ mod armor_stand_motion_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_gravity_stand_stays_put() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let start = Vec3::new(20.5, 20.0, 20.5);
|
||||
let mut stand = ArmorStand::new(1, start, 0.0);
|
||||
stand.on_ground = false;
|
||||
stand.no_gravity = true;
|
||||
// ArmorStand.travel does nothing without physics: measured on the real
|
||||
// server, Pos and Motion stay exactly as summoned for every tick.
|
||||
for _ in 0..5 {
|
||||
tick_stand(&mut stand, &world, &facts, &collision);
|
||||
assert_eq!(stand.position, start);
|
||||
assert_eq!(stand.velocity, Vec3::ZERO);
|
||||
}
|
||||
stand.no_gravity = false;
|
||||
tick_stand(&mut stand, &world, &facts, &collision);
|
||||
tick_stand(&mut stand, &world, &facts, &collision);
|
||||
assert!(stand.position.y < start.y, "gravity again once cleared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leather_boots_walk_on_powder_snow() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -2919,6 +2919,12 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"Marker".into());
|
||||
}
|
||||
// Entity.saveWithoutId writes NoGravity only when set.
|
||||
if a.no_gravity {
|
||||
put(&mut tag, "NoGravity", Tag::Byte(1));
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"NoGravity".into());
|
||||
}
|
||||
if a.name_visible {
|
||||
put(&mut tag, "CustomNameVisible", Tag::Byte(1));
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
@@ -2984,6 +2990,7 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
|
||||
a.no_base_plate = flag(tag, "NoBasePlate");
|
||||
a.invisible = flag(tag, "Invisible");
|
||||
a.marker = flag(tag, "Marker");
|
||||
a.no_gravity = flag(tag, "NoGravity");
|
||||
a.on_ground = flag(tag, "OnGround");
|
||||
// ArmorStand.readAdditionalSaveData: each part of `Pose` that is a list of
|
||||
// three numbers, the others staying at their defaults.
|
||||
@@ -3387,6 +3394,32 @@ mod tests {
|
||||
assert!(restored.name_visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armor_stand_keeps_no_gravity_through_a_save() {
|
||||
let tag = Tag::compound([
|
||||
("id", string("minecraft:armor_stand")),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double(0.0f64.to_bits()),
|
||||
Tag::Double(64.0f64.to_bits()),
|
||||
Tag::Double(0.0f64.to_bits()),
|
||||
]),
|
||||
),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
]);
|
||||
let mut a = armor_stand_from_nbt(&tag, 7).unwrap();
|
||||
assert!(a.no_gravity, "summon NBT sets the flag");
|
||||
let saved = armor_stand_to_nbt(&a, [0; 4], Some(&tag));
|
||||
assert_eq!(saved.get("NoGravity"), Some(&Tag::Byte(1)));
|
||||
assert!(armor_stand_from_nbt(&saved, 7).unwrap().no_gravity);
|
||||
// `data merge entity @s {NoGravity:0b}` clears it again.
|
||||
a.no_gravity = false;
|
||||
let cleared = armor_stand_to_nbt(&a, [0; 4], Some(&saved));
|
||||
assert!(cleared.get("NoGravity").is_none());
|
||||
assert!(!armor_stand_from_nbt(&cleared, 7).unwrap().no_gravity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falling_block_round_trip_keeps_impact_distance_and_settings() {
|
||||
let state = Tag::compound([("Name", string("minecraft:damaged_anvil"))]);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+291
-810
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,9 @@
|
||||
//! like a player (walk, look, break, place, use, click buttons). Actions that
|
||||
//! take time run as a task advanced every frame and reply when they finish or
|
||||
//! time out, so `horizon-ctl break 10 64 3` returns what the block dropped.
|
||||
#[path = "agent_navigation.rs"]
|
||||
mod navigation;
|
||||
|
||||
use super::event_loop::EventLoopCtl;
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
@@ -17,6 +20,7 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use navigation::{Cell, Node, Plan, Progress};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
@@ -206,9 +210,6 @@ fn delta(before: &BTreeMap<String, i64>, after: &BTreeMap<String, i64>) -> BTree
|
||||
out
|
||||
}
|
||||
|
||||
/// A feet position the A* path walks through.
|
||||
type Node = [i32; 3];
|
||||
|
||||
enum TaskKind {
|
||||
/// Hold keys until a deadline.
|
||||
Move {
|
||||
@@ -251,9 +252,11 @@ struct Goto {
|
||||
target: [f64; 3],
|
||||
near: f64,
|
||||
sprint: bool,
|
||||
path: Vec<Node>,
|
||||
path: Plan,
|
||||
blocked: Vec<(Node, Node)>,
|
||||
progress: Progress,
|
||||
next: usize,
|
||||
/// Tick of the last waypoint reached, and how many replans so far.
|
||||
/// Tick of the last waypoint progress, and stuck recoveries so far.
|
||||
progress_tick: u64,
|
||||
replans: u32,
|
||||
jump_until: u64,
|
||||
@@ -817,105 +820,24 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a block stops movement, for the path finder.
|
||||
fn blocks_motion(&self, pos: [i32; 3]) -> bool {
|
||||
let f = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(pos[0], pos[1], pos[2]));
|
||||
f.full_collision || f.legacy_solid
|
||||
}
|
||||
fn walkable(&self, n: Node) -> bool {
|
||||
let below = self.world.block(n[0], n[1] - 1, n[2]);
|
||||
let lava = |p: [i32; 3]| {
|
||||
&*self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(p[0], p[1], p[2]))
|
||||
.name
|
||||
== "minecraft:lava"
|
||||
};
|
||||
self.world.chunks.contains_key(&(n[0] >> 4, n[2] >> 4))
|
||||
&& (self.blocks_motion([n[0], n[1] - 1, n[2]])
|
||||
|| self.res.facts().get(below).fluid != horizon_client::blocks::Fluid::None)
|
||||
&& !self.blocks_motion(n)
|
||||
&& !self.blocks_motion([n[0], n[1] + 1, n[2]])
|
||||
&& !lava(n)
|
||||
&& !lava([n[0], n[1] - 1, n[2]])
|
||||
}
|
||||
/// A* over standable feet positions: steps up one block, drops up to three.
|
||||
fn find_path(&self, from: Node, goal: [f64; 3], near: f64) -> Option<Vec<Node>> {
|
||||
use std::{cmp::Reverse, collections::BinaryHeap};
|
||||
let reached = |n: Node| {
|
||||
let c = [
|
||||
f64::from(n[0]) + 0.5,
|
||||
f64::from(n[1]),
|
||||
f64::from(n[2]) + 0.5,
|
||||
];
|
||||
((c[0] - goal[0]).powi(2) + (c[2] - goal[2]).powi(2)).sqrt() <= near.max(0.5)
|
||||
&& (c[1] - goal[1]).abs() <= near.max(1.0) + 0.5
|
||||
};
|
||||
let h = |n: Node| {
|
||||
((f64::from(n[0]) + 0.5 - goal[0]).abs()
|
||||
+ (f64::from(n[1]) - goal[1]).abs()
|
||||
+ (f64::from(n[2]) + 0.5 - goal[2]).abs())
|
||||
* 10.0
|
||||
};
|
||||
let mut open = BinaryHeap::new();
|
||||
let mut cost: HashMap<Node, u32> = HashMap::new();
|
||||
let mut came: HashMap<Node, Node> = HashMap::new();
|
||||
cost.insert(from, 0);
|
||||
open.push(Reverse((h(from) as u32, from)));
|
||||
let mut expanded = 0;
|
||||
while let Some(Reverse((_, n))) = open.pop() {
|
||||
if reached(n) {
|
||||
let mut path = vec![n];
|
||||
let mut at = n;
|
||||
while let Some(&p) = came.get(&at) {
|
||||
path.push(p);
|
||||
at = p;
|
||||
}
|
||||
path.reverse();
|
||||
return Some(path);
|
||||
}
|
||||
expanded += 1;
|
||||
if expanded > 30_000 {
|
||||
fn find_path(
|
||||
&self,
|
||||
from: Node,
|
||||
goal: [f64; 3],
|
||||
near: f64,
|
||||
blocked: &[(Node, Node)],
|
||||
) -> Option<Plan> {
|
||||
navigation::find_path(from, goal, near, blocked, |n| {
|
||||
if !self.world.chunks.contains_key(&(n[0] >> 4, n[2] >> 4)) {
|
||||
return None;
|
||||
}
|
||||
let base = cost[&n];
|
||||
for d in [[1, 0], [-1, 0], [0, 1], [0, -1]] {
|
||||
for dy in [0, 1, -1, -2, -3] {
|
||||
let m = [n[0] + d[0], n[1] + dy, n[2] + d[1]];
|
||||
// A step up needs head room above the start; a drop needs
|
||||
// the column in front clear down to the landing.
|
||||
let clear = match dy {
|
||||
1 => !self.blocks_motion([n[0], n[1] + 2, n[2]]),
|
||||
d if d < 0 => {
|
||||
(m[1]..=n[1] + 1).all(|y| !self.blocks_motion([m[0], y, m[2]]))
|
||||
}
|
||||
_ => true,
|
||||
};
|
||||
if !clear || !self.walkable(m) {
|
||||
continue;
|
||||
}
|
||||
let water = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(m[0], m[1], m[2]))
|
||||
.fluid
|
||||
!= horizon_client::blocks::Fluid::None;
|
||||
let step = 10 + if dy != 0 { 5 } else { 0 } + if water { 30 } else { 0 };
|
||||
let c = base + step;
|
||||
if cost.get(&m).is_none_or(|&old| c < old) {
|
||||
cost.insert(m, c);
|
||||
came.insert(m, n);
|
||||
open.push(Reverse((c + h(m) as u32, m)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
let f = self.res.facts().get(self.world.block(n[0], n[1], n[2]));
|
||||
Some(Cell {
|
||||
solid: f.full_collision || f.legacy_solid,
|
||||
fluid: f.fluid != horizon_client::blocks::Fluid::None,
|
||||
lava: &*f.name == "minecraft:lava",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Point the view at `at` from the eye.
|
||||
@@ -1259,10 +1181,13 @@ impl Game<'_> {
|
||||
let timeout = a.num("timeout", 60.0)?;
|
||||
let p = self.player.as_ref().ok_or("no player")?.position;
|
||||
let from = [p.x.floor() as i32, p.y.round() as i32, p.z.floor() as i32];
|
||||
let (path, warning) = match self.find_path(from, target, near) {
|
||||
let (path, warning) = match self.find_path(from, target, near, &[]) {
|
||||
Some(path) => (path, None),
|
||||
None if a.has("direct") => (
|
||||
Vec::new(),
|
||||
Plan {
|
||||
nodes: Vec::new(),
|
||||
complete: true,
|
||||
},
|
||||
Some("no path found through loaded terrain; walking straight".to_owned()),
|
||||
),
|
||||
None => {
|
||||
@@ -1272,12 +1197,17 @@ impl Game<'_> {
|
||||
.into());
|
||||
}
|
||||
};
|
||||
let position = [p.x, p.y, p.z];
|
||||
let mut next = 1;
|
||||
let aim = path.aim(position, &mut next, target).unwrap_or(target);
|
||||
let goto = Goto {
|
||||
target,
|
||||
near,
|
||||
sprint: a.has("sprint"),
|
||||
path,
|
||||
next: 1,
|
||||
blocked: Vec::new(),
|
||||
progress: Progress::new(position, aim),
|
||||
next,
|
||||
progress_tick: self.ticks,
|
||||
replans: 0,
|
||||
jump_until: 0,
|
||||
@@ -2077,9 +2007,7 @@ impl Game<'_> {
|
||||
let on_ground = self.player.as_ref().is_some_and(|p| p.ground.on_ground);
|
||||
let horizontal =
|
||||
|a: [f64; 3], b: [f64; 3]| ((a[0] - b[0]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
|
||||
let arrived = horizontal(p, g.target) <= g.near
|
||||
&& (p[1] - g.target[1]).abs() <= g.near.max(1.0) + 1.0;
|
||||
if arrived {
|
||||
if navigation::arrived(p, g.target, g.near) {
|
||||
return Some(
|
||||
json!({ "ok": true, "arrived": true, "position": p, "target": g.target,
|
||||
"distance": dist(p, g.target), "warning": g.warning }),
|
||||
@@ -2091,48 +2019,60 @@ impl Game<'_> {
|
||||
"target": g.target, "distance": dist(p, g.target), "warning": g.warning }),
|
||||
);
|
||||
}
|
||||
// The next waypoint: path nodes, then the target itself.
|
||||
let mut aim = g.target;
|
||||
while g.next < g.path.len() {
|
||||
let n = g.path[g.next];
|
||||
let c = [
|
||||
f64::from(n[0]) + 0.5,
|
||||
f64::from(n[1]),
|
||||
f64::from(n[2]) + 0.5,
|
||||
];
|
||||
let last = g.next + 1 == g.path.len();
|
||||
if horizontal(p, c) < if last { 0.3 } else { 0.45 } && (p[1] - c[1]).abs() < 1.1 {
|
||||
g.next += 1;
|
||||
let previous_next = g.next;
|
||||
let mut aim = g.path.aim(p, &mut g.next, g.target);
|
||||
if let Some(aim) = aim {
|
||||
if g.next != previous_next {
|
||||
g.progress = Progress::new(p, aim);
|
||||
g.progress_tick = self.ticks;
|
||||
} else if g.progress.advance(p, aim) {
|
||||
g.progress_tick = self.ticks;
|
||||
continue;
|
||||
}
|
||||
aim = c;
|
||||
break;
|
||||
}
|
||||
// Stuck: jump, then replan from here.
|
||||
let stalled = self.ticks.saturating_sub(g.progress_tick);
|
||||
if stalled > 60 && on_ground {
|
||||
g.jump_until = self.ticks + 4;
|
||||
}
|
||||
if stalled > 120 {
|
||||
g.replans += 1;
|
||||
if g.replans > 3 {
|
||||
return Some(
|
||||
json!({ "error": "stuck: cannot make progress", "arrived": false, "position": p,
|
||||
"target": g.target, "distance": dist(p, g.target) }),
|
||||
);
|
||||
if aim.is_none() || stalled > 120 {
|
||||
if stalled > 120 {
|
||||
g.replans += 1;
|
||||
if g.replans > 3 {
|
||||
return Some(
|
||||
json!({ "error": "stuck: cannot make progress", "arrived": false,
|
||||
"position": p, "target": g.target, "distance": dist(p, g.target) }),
|
||||
);
|
||||
}
|
||||
if g.next > 0 && g.next < g.path.nodes.len() {
|
||||
let edge = (g.path.nodes[g.next - 1], g.path.nodes[g.next]);
|
||||
if edge.0 != edge.1 && !g.blocked.contains(&edge) {
|
||||
g.blocked.push(edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
let from = [
|
||||
p[0].floor() as i32,
|
||||
p[1].round() as i32,
|
||||
p[2].floor() as i32,
|
||||
];
|
||||
if let Some(path) = self.find_path(from, g.target, g.near) {
|
||||
g.path = path;
|
||||
g.next = 1;
|
||||
}
|
||||
let Some(path) = self.find_path(from, g.target, g.near, &g.blocked) else {
|
||||
return Some(
|
||||
json!({ "error": "no further walking progress through loaded terrain",
|
||||
"arrived": false, "position": p, "target": g.target,
|
||||
"distance": dist(p, g.target), "warning": g.warning }),
|
||||
);
|
||||
};
|
||||
g.path = path;
|
||||
g.next = 1;
|
||||
g.progress_tick = self.ticks;
|
||||
aim = g.path.aim(p, &mut g.next, g.target);
|
||||
g.progress = Progress::new(p, aim.unwrap_or(g.target));
|
||||
}
|
||||
let Some(aim) = aim else {
|
||||
return Some(
|
||||
json!({ "error": "path did not provide a new walking waypoint",
|
||||
"arrived": false, "position": p, "target": g.target }),
|
||||
);
|
||||
};
|
||||
let d = [aim[0] - p[0], aim[2] - p[2]];
|
||||
if d[0].abs() + d[1].abs() > 1e-3 {
|
||||
self.yaw = (-d[0]).atan2(d[1]).to_degrees() as f32;
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
use std::{
|
||||
cmp::Reverse,
|
||||
collections::{BinaryHeap, HashMap},
|
||||
};
|
||||
|
||||
pub(crate) type Node = [i32; 3];
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(crate) struct Cell {
|
||||
pub solid: bool,
|
||||
pub fluid: bool,
|
||||
pub lava: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Plan {
|
||||
pub nodes: Vec<Node>,
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
fn solid(query: &impl Fn(Node) -> Option<Cell>, n: Node) -> bool {
|
||||
query(n).is_none_or(|b| b.solid)
|
||||
}
|
||||
|
||||
fn walkable(query: &impl Fn(Node) -> Option<Cell>, n: Node) -> bool {
|
||||
let Some(feet) = query(n) else {
|
||||
return false;
|
||||
};
|
||||
let Some(below) = query([n[0], n[1] - 1, n[2]]) else {
|
||||
return false;
|
||||
};
|
||||
let Some(head) = query([n[0], n[1] + 1, n[2]]) else {
|
||||
return false;
|
||||
};
|
||||
(below.solid || below.fluid) && !feet.solid && !head.solid && !feet.lava && !below.lava
|
||||
}
|
||||
|
||||
pub(crate) fn find_path(
|
||||
from: Node,
|
||||
goal: [f64; 3],
|
||||
near: f64,
|
||||
blocked: &[(Node, Node)],
|
||||
query: impl Fn(Node) -> Option<Cell>,
|
||||
) -> Option<Plan> {
|
||||
let reached = |n: Node| {
|
||||
let c = [
|
||||
f64::from(n[0]) + 0.5,
|
||||
f64::from(n[1]),
|
||||
f64::from(n[2]) + 0.5,
|
||||
];
|
||||
((c[0] - goal[0]).powi(2) + (c[2] - goal[2]).powi(2)).sqrt() <= near.max(0.5)
|
||||
&& (c[1] - goal[1]).abs() <= near.max(1.0) + 0.5
|
||||
};
|
||||
let h = |n: Node| {
|
||||
((f64::from(n[0]) + 0.5 - goal[0]).abs()
|
||||
+ (f64::from(n[1]) - goal[1]).abs()
|
||||
+ (f64::from(n[2]) + 0.5 - goal[2]).abs())
|
||||
* 10.0
|
||||
};
|
||||
let mut open = BinaryHeap::new();
|
||||
let mut cost: HashMap<Node, u32> = HashMap::new();
|
||||
let mut came: HashMap<Node, Node> = HashMap::new();
|
||||
cost.insert(from, 0);
|
||||
open.push(Reverse((h(from) as u32, from)));
|
||||
let distance = |n: Node| {
|
||||
(f64::from(n[0]) + 0.5 - goal[0]).powi(2)
|
||||
+ (f64::from(n[1]) - goal[1]).powi(2)
|
||||
+ (f64::from(n[2]) + 0.5 - goal[2]).powi(2)
|
||||
};
|
||||
let mut best = from;
|
||||
let mut expanded = 0;
|
||||
while let Some(Reverse((_, n))) = open.pop() {
|
||||
if reached(n) {
|
||||
return Some(reconstruct(n, &came, true));
|
||||
}
|
||||
if distance(n) < distance(best) {
|
||||
best = n;
|
||||
}
|
||||
expanded += 1;
|
||||
if expanded >= 30_000 {
|
||||
break;
|
||||
}
|
||||
let base = cost[&n];
|
||||
for d in [[1, 0], [-1, 0], [0, 1], [0, -1]] {
|
||||
for dy in [0, 1, -1, -2, -3] {
|
||||
let m = [n[0] + d[0], n[1] + dy, n[2] + d[1]];
|
||||
// A step up needs head room above the start; a drop needs
|
||||
// the column in front clear down to the landing.
|
||||
let clear = match dy {
|
||||
1 => !solid(&query, [n[0], n[1] + 2, n[2]]),
|
||||
d if d < 0 => (m[1]..=n[1] + 1).all(|y| !solid(&query, [m[0], y, m[2]])),
|
||||
_ => true,
|
||||
};
|
||||
if !clear || !walkable(&query, m) {
|
||||
continue;
|
||||
}
|
||||
if blocked
|
||||
.iter()
|
||||
.any(|&(a, b)| (a == n && b == m) || (a == m && b == n))
|
||||
{
|
||||
break;
|
||||
}
|
||||
let water = query(m).is_some_and(|b| b.fluid);
|
||||
let step = 10 + if dy != 0 { 5 } else { 0 } + if water { 30 } else { 0 };
|
||||
let c = base + step;
|
||||
if cost.get(&m).is_none_or(|&old| c < old) {
|
||||
cost.insert(m, c);
|
||||
came.insert(m, n);
|
||||
open.push(Reverse((c + h(m) as u32, m)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
(best != from && distance(best) + 1e-6 < distance(from))
|
||||
.then(|| reconstruct(best, &came, false))
|
||||
}
|
||||
|
||||
fn reconstruct(end: Node, came: &HashMap<Node, Node>, complete: bool) -> Plan {
|
||||
let mut nodes = vec![end];
|
||||
let mut at = end;
|
||||
while let Some(&previous) = came.get(&at) {
|
||||
nodes.push(previous);
|
||||
at = previous;
|
||||
}
|
||||
nodes.reverse();
|
||||
Plan { nodes, complete }
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
pub(crate) fn aim(&self, p: [f64; 3], next: &mut usize, target: [f64; 3]) -> Option<[f64; 3]> {
|
||||
while *next < self.nodes.len() {
|
||||
let n = self.nodes[*next];
|
||||
let c = [
|
||||
f64::from(n[0]) + 0.5,
|
||||
f64::from(n[1]),
|
||||
f64::from(n[2]) + 0.5,
|
||||
];
|
||||
let last = *next + 1 == self.nodes.len();
|
||||
if ((p[0] - c[0]).powi(2) + (p[2] - c[2]).powi(2)).sqrt()
|
||||
< if last { 0.3 } else { 0.45 }
|
||||
&& (p[1] - c[1]).abs() < 1.1
|
||||
{
|
||||
*next += 1;
|
||||
} else {
|
||||
return Some(c);
|
||||
}
|
||||
}
|
||||
self.complete.then_some(target)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn arrived(p: [f64; 3], target: [f64; 3], near: f64) -> bool {
|
||||
((p[0] - target[0]).powi(2) + (p[2] - target[2]).powi(2)).sqrt() <= near
|
||||
&& (p[1] - target[1]).abs() <= near.max(1.0) + 1.0
|
||||
}
|
||||
|
||||
fn horizontal_distance(p: [f64; 3], aim: [f64; 3]) -> f64 {
|
||||
((p[0] - aim[0]).powi(2) + (p[2] - aim[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
pub(crate) struct Progress {
|
||||
best_distance: f64,
|
||||
}
|
||||
impl Progress {
|
||||
pub(crate) fn new(position: [f64; 3], aim: [f64; 3]) -> Self {
|
||||
Self {
|
||||
best_distance: horizontal_distance(position, aim),
|
||||
}
|
||||
}
|
||||
pub(crate) fn advance(&mut self, p: [f64; 3], aim: [f64; 3]) -> bool {
|
||||
let distance = horizontal_distance(p, aim);
|
||||
if self.best_distance - distance >= 0.25 {
|
||||
self.best_distance = distance;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Terrain {
|
||||
floors: HashMap<(i32, i32), i32>,
|
||||
cells: HashMap<Node, Cell>,
|
||||
}
|
||||
impl Terrain {
|
||||
fn flat(x: std::ops::RangeInclusive<i32>, z: std::ops::RangeInclusive<i32>) -> Self {
|
||||
let mut t = Self::default();
|
||||
for x in x {
|
||||
for z in z.clone() {
|
||||
t.floors.insert((x, z), 63);
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
fn query(&self, n: Node) -> Option<Cell> {
|
||||
let floor = self.floors.get(&(n[0], n[2]))?;
|
||||
Some(self.cells.get(&n).copied().unwrap_or(Cell {
|
||||
solid: n[1] <= *floor,
|
||||
..Cell::default()
|
||||
}))
|
||||
}
|
||||
fn path(&self, from: Node, goal: [f64; 3], blocked: &[(Node, Node)]) -> Option<Plan> {
|
||||
find_path(from, goal, 0.6, blocked, |n| self.query(n))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_path_is_complete() {
|
||||
let p = Terrain::flat(0..=5, 0..=0)
|
||||
.path([0, 64, 0], [5.5, 64.0, 0.5], &[])
|
||||
.unwrap();
|
||||
assert!(p.complete);
|
||||
assert_eq!(p.nodes.last(), Some(&[5, 64, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_frontier_continues_after_chunks_extend() {
|
||||
let mut t = Terrain::flat(0..=5, 0..=0);
|
||||
let goal = [10.5, 64.0, 0.5];
|
||||
let partial = t
|
||||
.path([0, 64, 0], goal, &[])
|
||||
.expect("loaded frontier should be usable");
|
||||
assert!(!partial.complete);
|
||||
assert_eq!(partial.nodes.last(), Some(&[5, 64, 0]));
|
||||
let endpoint = *partial.nodes.last().unwrap();
|
||||
assert!(
|
||||
t.path(endpoint, goal, &[]).is_none(),
|
||||
"unchanged frontier must stop"
|
||||
);
|
||||
for x in 6..=10 {
|
||||
t.floors.insert((x, 0), 63);
|
||||
}
|
||||
let complete = t.path(endpoint, goal, &[]).unwrap();
|
||||
assert!(complete.complete);
|
||||
assert_eq!(complete.nodes.last(), Some(&[10, 64, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_closer_endpoint_fails() {
|
||||
assert!(
|
||||
Terrain::flat(-4..=0, 0..=0)
|
||||
.path([0, 64, 0], [10.5, 64.0, 0.5], &[])
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_path_can_initially_move_away() {
|
||||
let mut t = Terrain::flat(-2..=0, 0..=0);
|
||||
for z in 0..=2 {
|
||||
t.floors.insert((-2, z), 63);
|
||||
}
|
||||
for x in -2..=5 {
|
||||
t.floors.insert((x, 2), 63);
|
||||
}
|
||||
for z in 0..=2 {
|
||||
t.floors.insert((5, z), 63);
|
||||
}
|
||||
let p = t.path([0, 64, 0], [5.5, 64.0, 0.5], &[]).unwrap();
|
||||
assert!(p.complete);
|
||||
assert_eq!(p.nodes[1], [-1, 64, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_edge_uses_bypass() {
|
||||
let blocked = [([0, 64, 0], [1, 64, 0])];
|
||||
let p = Terrain::flat(0..=3, 0..=1)
|
||||
.path([0, 64, 0], [3.5, 64.0, 0.5], &blocked)
|
||||
.unwrap();
|
||||
assert!(p.complete);
|
||||
assert!(
|
||||
!p.nodes
|
||||
.windows(2)
|
||||
.any(|w| w == [blocked[0].0, blocked[0].1])
|
||||
);
|
||||
assert_eq!(p.nodes[1], [0, 64, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_edge_without_bypass_stops() {
|
||||
assert!(
|
||||
Terrain::flat(0..=1, 0..=0)
|
||||
.path([0, 64, 0], [1.5, 64.0, 0.5], &[([0, 64, 0], [1, 64, 0])])
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lava_and_unknown_terrain_are_not_walked() {
|
||||
let mut t = Terrain::flat(0..=1, 0..=0);
|
||||
t.cells.insert(
|
||||
[1, 63, 0],
|
||||
Cell {
|
||||
lava: true,
|
||||
fluid: true,
|
||||
solid: false,
|
||||
},
|
||||
);
|
||||
assert!(t.path([0, 64, 0], [1.5, 64.0, 0.5], &[]).is_none());
|
||||
t.cells.remove(&[1, 63, 0]);
|
||||
t.cells.insert(
|
||||
[1, 64, 0],
|
||||
Cell {
|
||||
lava: true,
|
||||
fluid: true,
|
||||
solid: false,
|
||||
},
|
||||
);
|
||||
assert!(t.path([0, 64, 0], [1.5, 64.0, 0.5], &[]).is_none());
|
||||
assert!(
|
||||
Terrain::flat(0..=0, 0..=0)
|
||||
.path([0, 64, 0], [5.5, 64.0, 0.5], &[])
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_step_up_and_three_step_drop_only_with_clearance() {
|
||||
let mut t = Terrain::flat(0..=1, 0..=0);
|
||||
t.floors.insert((1, 0), 64);
|
||||
assert!(t.path([0, 64, 0], [1.5, 65.0, 0.5], &[]).unwrap().complete);
|
||||
t.cells.insert(
|
||||
[0, 66, 0],
|
||||
Cell {
|
||||
solid: true,
|
||||
..Cell::default()
|
||||
},
|
||||
);
|
||||
assert!(t.path([0, 64, 0], [1.5, 65.0, 0.5], &[]).is_none());
|
||||
t.cells.clear();
|
||||
t.floors.insert((1, 0), 65);
|
||||
assert!(t.path([0, 64, 0], [1.5, 66.0, 0.5], &[]).is_none());
|
||||
t.floors.insert((1, 0), 60);
|
||||
assert!(t.path([0, 64, 0], [1.5, 61.0, 0.5], &[]).unwrap().complete);
|
||||
t.cells.insert(
|
||||
[1, 64, 0],
|
||||
Cell {
|
||||
solid: true,
|
||||
..Cell::default()
|
||||
},
|
||||
);
|
||||
assert!(t.path([0, 64, 0], [1.5, 61.0, 0.5], &[]).is_none());
|
||||
t.cells.clear();
|
||||
t.floors.insert((1, 0), 59);
|
||||
assert!(t.path([0, 64, 0], [1.5, 60.0, 0.5], &[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_bypass_costs_less_than_water() {
|
||||
let mut t = Terrain::flat(0..=2, 0..=1);
|
||||
t.cells.insert(
|
||||
[1, 64, 0],
|
||||
Cell {
|
||||
fluid: true,
|
||||
..Cell::default()
|
||||
},
|
||||
);
|
||||
let p = t.path([0, 64, 0], [2.5, 64.0, 0.5], &[]).unwrap();
|
||||
assert!(p.complete);
|
||||
assert!(!p.nodes.contains(&[1, 64, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_budget_returns_progress_instead_of_discarding_it() {
|
||||
let p = Terrain::flat(0..=30_010, 0..=0)
|
||||
.path([0, 64, 0], [40_000.5, 64.0, 0.5], &[])
|
||||
.unwrap();
|
||||
assert!(!p.complete);
|
||||
assert!(p.nodes.len() > 2 && p.nodes.len() <= 30_001);
|
||||
}
|
||||
#[test]
|
||||
fn consumed_partial_never_aims_at_unknown_goal_or_arrives() {
|
||||
let plan = Plan {
|
||||
nodes: vec![[0, 64, 0], [1, 64, 0]],
|
||||
complete: false,
|
||||
};
|
||||
let p = [1.5, 64.0, 0.5];
|
||||
let goal = [10.5, 64.0, 0.5];
|
||||
let mut next = 1;
|
||||
assert_eq!(plan.aim(p, &mut next, goal), None);
|
||||
assert!(!arrived(p, goal, 0.6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_path_uses_its_new_waypoint() {
|
||||
let plan = Plan {
|
||||
nodes: vec![[0, 64, 0], [0, 64, 1], [1, 64, 1]],
|
||||
complete: false,
|
||||
};
|
||||
assert_eq!(
|
||||
plan.aim([0.5, 64.0, 0.5], &mut 1, [5.5, 64.0, 0.5]),
|
||||
Some([0.5, 64.0, 1.5])
|
||||
);
|
||||
assert!(arrived([5.5, 64.0, 0.5], [5.5, 64.0, 0.5], 0.6));
|
||||
}
|
||||
#[test]
|
||||
fn jumping_in_place_does_not_reset_stuck_timer() {
|
||||
let start = [0.5, 64.0, 0.5];
|
||||
let aim = [4.5, 64.0, 0.5];
|
||||
let mut progress = Progress::new(start, aim);
|
||||
assert!(!progress.advance([0.5, 65.0, 0.5], aim));
|
||||
assert!(!progress.advance([0.6, 64.0, 0.6], aim));
|
||||
assert!(progress.advance([0.8, 64.0, 0.5], aim));
|
||||
}
|
||||
#[test]
|
||||
fn oscillation_and_vertical_drift_do_not_count_as_progress() {
|
||||
let aim = [4.5, 64.0, 0.5];
|
||||
let mut progress = Progress::new([0.5, 64.0, 0.5], aim);
|
||||
for tick in 0..400 {
|
||||
let x = if tick % 2 == 0 { 0.2 } else { 0.5 };
|
||||
assert!(
|
||||
!progress.advance([x, 64.0, 0.5], aim),
|
||||
"oscillation reset at {tick}"
|
||||
);
|
||||
}
|
||||
assert!(!progress.advance([0.5, 66.0, 0.5], aim));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_new_best_distance_counts_and_waypoint_switch_resets_baseline() {
|
||||
let aim = [4.5, 64.0, 0.5];
|
||||
let start = [0.5, 64.0, 0.5];
|
||||
let mut progress = Progress::new(start, aim);
|
||||
assert!(!progress.advance([0.6, 64.0, 0.5], aim));
|
||||
assert!(progress.advance([0.8, 64.0, 0.5], aim));
|
||||
assert!(!progress.advance(start, aim));
|
||||
assert!(!progress.advance([0.8, 64.0, 0.5], aim));
|
||||
assert!(progress.advance([1.1, 64.0, 0.5], aim));
|
||||
let detour_aim = [1.1, 64.0, 2.5];
|
||||
let mut progress = Progress::new([1.1, 64.0, 0.5], detour_aim);
|
||||
assert!(progress.advance([1.1, 64.0, 0.8], detour_aim));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -97,7 +97,7 @@ impl Game<'_> {
|
||||
let back = self.dim;
|
||||
for (dim, head) in std::mem::take(&mut self.player_beds) {
|
||||
self.enter_level(None, dim)?;
|
||||
// The in-frame game has only the level it is in.
|
||||
// A client holds no other levels (`enter_level`).
|
||||
if self.dim == dim {
|
||||
self.set_bed_occupied(head, false);
|
||||
}
|
||||
|
||||
@@ -486,23 +486,14 @@ impl Game<'_> {
|
||||
|
||||
/// The leads on boats that hang on a player or a mob, for the entity
|
||||
/// tracker: the holder's entity id (`ClientboundSetEntityLinkPacket`).
|
||||
pub(super) fn boat_leash_link(
|
||||
&self,
|
||||
b: &Boat,
|
||||
host_entity: Option<i32>,
|
||||
guests: &[(u128, i32)],
|
||||
) -> Option<i32> {
|
||||
pub(super) fn boat_leash_link(&self, b: &Boat, guests: &[(u128, i32)]) -> Option<i32> {
|
||||
match b.leash? {
|
||||
Holder::Player => {
|
||||
let uuid = self.boat_leash_player(b);
|
||||
match guests
|
||||
guests
|
||||
.iter()
|
||||
.find(|(u, _)| Some(horizon_client::lan::playerdata::uuid_ints(*u)) == uuid)
|
||||
{
|
||||
Some(&(_, id)) => Some(id),
|
||||
None if uuid == self.level.player_uuid => host_entity,
|
||||
None => None,
|
||||
}
|
||||
.map(|&(_, id)| id)
|
||||
}
|
||||
Holder::Mob(id) | Holder::Boat(id) => Some(id as i32),
|
||||
Holder::Knot(p) => self.knot_ids.get(&p).map(|&id| id as i32),
|
||||
|
||||
@@ -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, 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(
|
||||
@@ -1068,43 +1063,6 @@ impl Game<'_> {
|
||||
max_y: b.bounds().max_position().y,
|
||||
})
|
||||
}
|
||||
/// The player's tick while riding: steer, tick the boat, sit in it.
|
||||
pub(super) fn tick_riding(&mut self, keys: Keys) -> horizon_client::Result<()> {
|
||||
if self.tick_cushion_riding(keys) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(i) = self.boats.iter().position(|b| Some(b.id) == self.riding) else {
|
||||
if self.tick_cart_riding(keys) || self.tick_mount_riding(keys) {
|
||||
return Ok(());
|
||||
}
|
||||
self.riding = None;
|
||||
return Ok(());
|
||||
};
|
||||
if keys.shift || self.boats[i].out_of_control >= 60.0 {
|
||||
self.dismount(i);
|
||||
return Ok(());
|
||||
}
|
||||
// The boat steers with the input `LocalPlayer.rideTick` latched on the
|
||||
// previous tick; this tick's keys are latched after it ticked.
|
||||
let mut b = self.tick_boat(self.boats[i].clone(), true);
|
||||
b.input = [keys.left, keys.right, keys.forward, keys.backward];
|
||||
// positionRider: the seat, turned with the boat; clampRotation to 105 degrees.
|
||||
self.yaw += b.delta_rotation;
|
||||
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
self.yaw += delta.clamp(-105.0, 105.0) - delta;
|
||||
self.boats[i] = b;
|
||||
let seat = self.player_boat_seat(i);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = seat;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
if self.boat_lava_tick(i) {
|
||||
self.riding = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `getDismountLocationForPassenger`: in front of the player if there is
|
||||
/// floor there, else on top of the boat.
|
||||
pub(super) fn dismount(&mut self, i: usize) {
|
||||
|
||||
@@ -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()) {
|
||||
@@ -746,8 +773,8 @@ impl Game<'_> {
|
||||
// The Chat option's Hidden level takes the server's messages too.
|
||||
self.local_chat_line(text, Some(SYSTEM_INDICATOR), ChatSource::Server);
|
||||
}
|
||||
/// A player's chat line on the window's own chat: a LAN guest's message
|
||||
/// while the host runs in-frame (`HostEvent::Chat`). `ChatListener`
|
||||
/// A player's chat line on the window's own chat: a guest's message on
|
||||
/// the server's own chat (`HostEvent::Chat`). `ChatListener`
|
||||
/// checks `canReceivePlayerMessages` for these, so Commands Only and
|
||||
/// Hidden drop them; command feedback (`/list`, `/tp`) is a system
|
||||
/// message and takes the system-message permission instead.
|
||||
@@ -930,7 +957,6 @@ impl Game<'_> {
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
self.lan_host_chat(&message);
|
||||
}
|
||||
}
|
||||
pub(super) fn chat_scroll(&mut self, amount: f32) {
|
||||
@@ -1335,7 +1361,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();
|
||||
@@ -1414,45 +1441,6 @@ impl Game<'_> {
|
||||
log::warn!("the world clocks were not written: {e}");
|
||||
}
|
||||
}
|
||||
/// Death message in chat (CombatTracker.getDeathMessage, as far as the
|
||||
/// cause is known to the client).
|
||||
pub(super) fn death_message(&mut self) {
|
||||
let cause = self.ui.death_cause.take();
|
||||
let player = self.native_acting_id();
|
||||
self.native_player_died(player, cause.as_deref());
|
||||
self.ui.died_at = Some(std::time::Instant::now());
|
||||
self.ui.death_text = None;
|
||||
self.ui.death_confirm = None;
|
||||
if !self.ui.rules.bool("show_death_messages") {
|
||||
return;
|
||||
}
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let below_world = p.position.y < f64::from(self.world.bottom_y() - 64);
|
||||
let name = self.player_display_text();
|
||||
let message = match cause {
|
||||
Some(key) => Text::translate(&key, &[name]),
|
||||
None if below_world => Text::translate("death.attack.outOfWorld", &[name]),
|
||||
None => self.combat_death_message(),
|
||||
};
|
||||
self.ui.death_text = Some(message.clone());
|
||||
// ServerPlayer.die: the team's death message visibility decides who
|
||||
// hears it, the dying player's own chat included (the death screen
|
||||
// still shows it).
|
||||
let board = &self.score.board;
|
||||
if let Some(host) = &self.lan {
|
||||
host.hub.system_message_to(
|
||||
horizon_client::lan::data::component_nbt(&message.component()),
|
||||
|other| {
|
||||
horizon_client::scoreboard::death_message_reaches(board, PLAYER_NAME, other)
|
||||
},
|
||||
);
|
||||
}
|
||||
if horizon_client::scoreboard::death_message_reaches(board, PLAYER_NAME, PLAYER_NAME) {
|
||||
self.system_message(message);
|
||||
}
|
||||
}
|
||||
/// F1 hides the HUD (see `hud`); the screenshot key still works.
|
||||
pub(super) fn toggle_hide_gui(&mut self) {
|
||||
self.ui.hide_gui = !self.ui.hide_gui;
|
||||
@@ -1579,6 +1567,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() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The window's own ambient effects of entities, which a client makes for
|
||||
//! itself (`level().isClientSide()` branches): endermen's and endermites'
|
||||
//! portal particles, blazes' smoke and burn, eyes of ender's trail. They run
|
||||
//! on the game that has the window, in-frame or following a server, never on
|
||||
//! on the game that has the window, following a server, never on
|
||||
//! a server: nothing of them goes over the network.
|
||||
use super::Game;
|
||||
use horizon_client::blocks::RenderShape;
|
||||
@@ -78,8 +78,7 @@ impl Game<'_> {
|
||||
runners.push((m.position, movement, None, f64::from(m.width())));
|
||||
}
|
||||
}
|
||||
let guests = self.lan.iter().flat_map(|host| host.models.values());
|
||||
for m in self.mobs.iter().chain(guests) {
|
||||
for m in self.mobs.iter() {
|
||||
// Another player's fluids aren't known here: none where they stand.
|
||||
let feet = [
|
||||
m.position.x.floor() as i32,
|
||||
@@ -175,8 +174,7 @@ impl Game<'_> {
|
||||
invisible,
|
||||
);
|
||||
}
|
||||
let guests = self.lan.iter().flat_map(|host| host.models.values());
|
||||
for m in self.mobs.iter().chain(guests) {
|
||||
for m in self.mobs.iter() {
|
||||
if !m.alive() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The player's `CombatTracker`: the damage taken lately, who dealt it and
|
||||
//! where the player fell from, turned into the vanilla death message.
|
||||
use super::{Game, chat::PLAYER_NAME, commands::Target};
|
||||
use super::{Game, commands::Target};
|
||||
use horizon_client::{
|
||||
health::HealthEvent,
|
||||
mobs::{Mob, Species},
|
||||
@@ -254,12 +254,6 @@ impl Game<'_> {
|
||||
.pending_direct
|
||||
.push((damage_type.to_owned(), name));
|
||||
}
|
||||
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
|
||||
/// source's `DamageEffects` (`Player.getHurtSound`).
|
||||
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
|
||||
// Player.getVoicePitch: always 1.0.
|
||||
self.audio.play(sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
/// `CombatTracker.recordDamage` for the motion events of this tick.
|
||||
pub(super) fn record_combat(&mut self, events: &[MotionEvent]) {
|
||||
self.recheck_combat();
|
||||
@@ -384,57 +378,6 @@ impl Game<'_> {
|
||||
damage.in_tag(id, tag) || damage.in_tag(id, &format!("minecraft:{tag}"))
|
||||
})
|
||||
}
|
||||
/// The killing blow as advancement predicates see it (`DamageSource` of the
|
||||
/// last combat entry): its type and the causing entity. The projectile
|
||||
/// damage types have the projectile as direct entity, not the shooter.
|
||||
pub(super) fn combat_killing_blow(&self) -> Option<horizon_client::advancements::DamageView> {
|
||||
use horizon_client::advancements::{DamageView, EntityView};
|
||||
let e = self.combat.entries.last()?;
|
||||
let source = e.attacker.as_ref().and_then(|(id, _)| {
|
||||
self.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == *id)
|
||||
.map(|m| self.mob_entity(m))
|
||||
});
|
||||
let projectile = match e.damage_type.as_str() {
|
||||
// The rocket is the direct entity of its `fireworks` damage.
|
||||
"minecraft:fireworks" => Some("minecraft:firework_rocket"),
|
||||
t @ ("minecraft:arrow"
|
||||
| "minecraft:trident"
|
||||
| "minecraft:mob_projectile"
|
||||
| "minecraft:fireball"
|
||||
| "minecraft:unattributed_fireball"
|
||||
| "minecraft:wither_skull"
|
||||
| "minecraft:wind_charge"
|
||||
| "minecraft:thrown"
|
||||
| "minecraft:spit") => Some(t),
|
||||
_ => None,
|
||||
};
|
||||
let direct = match projectile {
|
||||
Some(kind) => Some(EntityView {
|
||||
kind: kind.to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
None => source.clone(),
|
||||
};
|
||||
Some(DamageView {
|
||||
kind: e.damage_type.clone(),
|
||||
direct,
|
||||
source,
|
||||
is_direct: projectile.is_none(),
|
||||
original: e.damage,
|
||||
actual: e.damage,
|
||||
blocked: false,
|
||||
})
|
||||
}
|
||||
/// The type of the mob that dealt the killing blow (`killed_by`).
|
||||
pub(super) fn combat_killer_kind(&self) -> Option<String> {
|
||||
let (id, _) = self.combat.entries.last()?.attacker.clone()?;
|
||||
self.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| m.kind.id.clone())
|
||||
}
|
||||
/// `CombatTracker.getMostSignificantFall`.
|
||||
fn most_significant_fall(&self) -> Option<usize> {
|
||||
let (mut result, mut alternative) = (None, None);
|
||||
@@ -498,10 +441,6 @@ impl Game<'_> {
|
||||
.clone()
|
||||
.or_else(|| self.mob_main_hand_name(e.attacker.as_ref()?.0))
|
||||
}
|
||||
/// `CombatTracker.getDeathMessage`.
|
||||
pub(super) fn combat_death_message(&mut self) -> Text {
|
||||
self.combat_death_message_for(Text::literal(PLAYER_NAME))
|
||||
}
|
||||
/// `CombatTracker.getDeathMessage` for a player whose display name is
|
||||
/// `me`: a server's guest is not `PLAYER_NAME`.
|
||||
pub(super) fn combat_death_message_for(&mut self, me: Text) -> Text {
|
||||
|
||||
@@ -210,6 +210,12 @@ impl Game<'_> {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
"mobs" => {
|
||||
// Observe authoritative AI state without a control-socket race.
|
||||
let record = self.harness_owner(|g| Ok(g.control_mobs(128.0)))?;
|
||||
out.push_str(&record.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
"tick" => {
|
||||
self.chat_capture = Some(Vec::new());
|
||||
for _ in 0..op["n"].as_u64().unwrap_or(1) {
|
||||
|
||||
@@ -164,7 +164,7 @@ impl Game<'_> {
|
||||
if self.client_mod_command(command) {
|
||||
return true;
|
||||
}
|
||||
let d = vanilla::game_dispatcher();
|
||||
let d = vanilla::game_dispatcher_with_features(&self.level.enabled_features);
|
||||
let parse = d.parse(command, &self.ui.registries);
|
||||
let result = match parse.executable() {
|
||||
Err(e) => {
|
||||
@@ -295,8 +295,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `CommandSourceStack.broadcastToAdmins` for a command block or a command
|
||||
/// minecart called `name`: `[name: message]`, gray and italic, for every
|
||||
/// operator when `send_command_feedback` (the world's host too: no player
|
||||
/// ran the command) and for a dedicated server's console when
|
||||
/// operator when `send_command_feedback` and for a dedicated server's console when
|
||||
/// `log_admin_commands`. Queued: `send_admin_feedback` sends it.
|
||||
pub(super) fn broadcast_source_to_admins(&mut self, name: Text, message: Text) {
|
||||
if message
|
||||
@@ -308,19 +307,10 @@ impl Game<'_> {
|
||||
let line = Text::translate("chat.type.admin", &[name, message])
|
||||
.with_color(text::GRAY)
|
||||
.italic();
|
||||
// Nobody else is in this game: its player is the only operator there is.
|
||||
if self.lan.is_none()
|
||||
&& self.lan_acting.is_none()
|
||||
&& self.serving.is_none()
|
||||
&& self.options.dedicated.is_none()
|
||||
{
|
||||
self.system_message(line);
|
||||
return;
|
||||
}
|
||||
let ops = self.ui.rules.bool("send_command_feedback");
|
||||
let console = self.options.dedicated.is_some() && self.ui.rules.bool("log_admin_commands");
|
||||
if ops || console {
|
||||
self.admin_feedback.push((line, ops, console, true));
|
||||
self.admin_feedback.push((line, ops, console));
|
||||
}
|
||||
}
|
||||
/// CommandSourceStack.broadcastToAdmins (`sendSuccess` with `broadcast`)
|
||||
@@ -364,7 +354,7 @@ impl Game<'_> {
|
||||
let line = Text::translate("chat.type.admin", &[who, message])
|
||||
.with_color(text::GRAY)
|
||||
.italic();
|
||||
self.admin_feedback.push((line, ops, console, false));
|
||||
self.admin_feedback.push((line, ops, console));
|
||||
}
|
||||
/// Player.getDisplayName: the name (team-formatted), suggesting a
|
||||
/// `/tell`, showing the player on hover, inserting the name.
|
||||
@@ -1128,7 +1118,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 => {
|
||||
@@ -1614,43 +1604,6 @@ impl Game<'_> {
|
||||
self.native_entity_teleport(self.dim.id(), entity as u32, from, to);
|
||||
}
|
||||
}
|
||||
/// Teleport the local player into the command source's dimension. Unlike
|
||||
/// portal travel, `/tp` keeps the coordinates given by the command.
|
||||
fn teleport_player_to_dimension(
|
||||
&mut self,
|
||||
dimension: Dim,
|
||||
pos: [f64; 3],
|
||||
rotation: Option<[f32; 2]>,
|
||||
) -> R<()> {
|
||||
let Some(mut player) = self.player.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
let from = self.dim;
|
||||
if self.switch_level(dimension, player.position).is_err() {
|
||||
self.player = Some(player);
|
||||
return Err(err("command.failed", &[]));
|
||||
}
|
||||
let at = player.position;
|
||||
self.trigger_dimension(from, dimension, [at.x, at.y, at.z]);
|
||||
let target = Vec3::new(pos[0], pos[1], pos[2]);
|
||||
player.position = target;
|
||||
player.velocity = Vec3::ZERO;
|
||||
player.fall_distance = 0.0;
|
||||
player.ground.on_ground = false;
|
||||
if let Some([yaw, pitch]) = rotation {
|
||||
let pitch = pitch.clamp(-90.0, 90.0);
|
||||
self.yaw = yaw;
|
||||
self.pitch = pitch;
|
||||
player.yaw = yaw;
|
||||
player.head_yaw = yaw;
|
||||
player.pitch = pitch;
|
||||
}
|
||||
self.travelling = Some((player, target));
|
||||
self.portals.travel = None;
|
||||
self.end_travel = false;
|
||||
self.end_portal_travel = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A server's player teleported into another level than theirs
|
||||
/// (ServerPlayer.teleport): queued to move after the command. False
|
||||
@@ -1668,7 +1621,7 @@ impl Game<'_> {
|
||||
_ => None,
|
||||
};
|
||||
let at = self.target_level(t);
|
||||
let Some(conn) = conn.filter(|_| at != level && self.options.dedicated.is_some()) else {
|
||||
let Some(conn) = conn.filter(|_| at != level) else {
|
||||
return false;
|
||||
};
|
||||
self.level_teleports
|
||||
@@ -1693,10 +1646,7 @@ impl Game<'_> {
|
||||
pub(super) fn network_entity_id(&self, t: Target) -> Option<i32> {
|
||||
let host = self.lan.as_ref();
|
||||
match t {
|
||||
Target::Player => match self.lan_acting {
|
||||
Some(conn) => host?.hub.guest(conn).map(|g| g.entity_id),
|
||||
None => host.map(|h| h.host_entity),
|
||||
},
|
||||
Target::Player => host?.hub.guest(self.lan_acting?).map(|g| g.entity_id),
|
||||
Target::Guest(conn) => host?.hub.guest(conn).map(|g| g.entity_id),
|
||||
other => non_player_entity_id(other),
|
||||
}
|
||||
@@ -1825,12 +1775,7 @@ impl Game<'_> {
|
||||
if self.teleport_across(t, source_level, pos, rotation) {
|
||||
continue;
|
||||
}
|
||||
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
|
||||
if t == Target::Player && dimension != self.dim {
|
||||
self.teleport_player_to_dimension(dimension, pos, rotation)?;
|
||||
} else {
|
||||
self.teleport_to(t, pos, rotation);
|
||||
}
|
||||
self.teleport_to(t, pos, rotation);
|
||||
// LookAt.perform after a successful teleportTo.
|
||||
if let Some(f) = facing {
|
||||
self.send_player_look_at(t, anchored_eyes, f, facing_entity);
|
||||
@@ -3622,27 +3567,16 @@ impl Game<'_> {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// ListPlayersCommand: every online player, the host of a hosted world
|
||||
/// first and then the guests in join order, with the count and the max.
|
||||
/// ListPlayersCommand: every online player in join order (a single-player
|
||||
/// owner is one of them), with the count and the max.
|
||||
fn cmd_list(&mut self, ctx: &Context) -> R<i32> {
|
||||
// A single-player owner is a placed guest of the in-process server, so
|
||||
// it is the host and needs no entry of its own.
|
||||
let owner_listed = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.is_some_and(|h| h.hub.singleplayer_owner.is_some());
|
||||
// The host is online unless this is a dedicated server, which has none.
|
||||
let host = (self.options.dedicated.is_none() && !owner_listed).then(|| match &self.lan {
|
||||
Some(h) => (h.host_uuid, h.host_name.clone()),
|
||||
None => (self.player_uuid(), PLAYER_NAME.to_owned()),
|
||||
});
|
||||
let guests = self
|
||||
let players: Vec<(u128, String)> = self
|
||||
.lan
|
||||
.iter()
|
||||
.flat_map(|h| h.hub.guests.iter())
|
||||
.filter(|g| g.placed)
|
||||
.map(|g| (g.uuid, g.name.clone()));
|
||||
let players: Vec<(u128, String)> = host.into_iter().chain(guests).collect();
|
||||
.map(|g| (g.uuid, g.name.clone()))
|
||||
.collect();
|
||||
let max = self
|
||||
.lan
|
||||
.as_ref()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -306,10 +306,6 @@ impl Game<'_> {
|
||||
_ => self.set_camera(None),
|
||||
}
|
||||
}
|
||||
/// The eye height the camera follows (`Camera.tick`'s entity).
|
||||
pub(in crate::game) fn camera_eye_height(&self) -> Option<f32> {
|
||||
self.cmds.camera.map(|t| self.target_eye_height(t) as f32)
|
||||
}
|
||||
|
||||
// ---- /waypoint ------------------------------------------------------
|
||||
|
||||
@@ -968,11 +964,8 @@ impl Game<'_> {
|
||||
guest_commands: allow,
|
||||
force_game_mode: false,
|
||||
};
|
||||
let started = if self.serving() {
|
||||
self.publish(settings).is_ok()
|
||||
} else {
|
||||
self.lan_start(settings).is_ok()
|
||||
};
|
||||
// A dedicated server is open from the start (`published` above).
|
||||
let started = self.serving() && self.publish(settings).is_ok();
|
||||
if !started {
|
||||
return Err(err("commands.publish.failed", &[]));
|
||||
}
|
||||
@@ -1307,18 +1300,4 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// The player's profile id: the saved id, else the offline id of the
|
||||
/// player's name.
|
||||
pub(in crate::game) fn player_uuid(&self) -> u128 {
|
||||
self.level.player_uuid.map_or_else(
|
||||
|| horizon_protocol::crypto::offline_uuid(super::super::chat::PLAYER_NAME),
|
||||
|[a, b, c, d]| {
|
||||
(u128::from(a as u32) << 96)
|
||||
| (u128::from(b as u32) << 64)
|
||||
| (u128::from(c as u32) << 32)
|
||||
| u128::from(d as u32)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
impl Game<'_> {}
|
||||
@@ -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))],
|
||||
|
||||
@@ -210,8 +210,7 @@ impl Game<'_> {
|
||||
if !self.verify_test_structure(&def.structure) {
|
||||
return Ok(None);
|
||||
}
|
||||
// `server.getLevel(dimension)`: the server's levels; a client-only
|
||||
// game (deprecated in-frame mode) has the one it is in.
|
||||
// `server.getLevel(dimension)`: the server's levels.
|
||||
let dim = match def.dimension.as_str() {
|
||||
"minecraft:overworld" | "minecraft:the_nether" | "minecraft:the_end" => {
|
||||
Some(Dim::from_id(&def.dimension))
|
||||
|
||||
@@ -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,
|
||||
@@ -783,6 +784,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
border: Some(self.border.geometry()),
|
||||
// The first player in the level: a server has no player of its own.
|
||||
difficulty: self.local_difficulty(
|
||||
self.level_players()
|
||||
@@ -1105,7 +1107,6 @@ impl Game<'_> {
|
||||
self.tick_apparition();
|
||||
self.guardian_bubbles();
|
||||
self.villager_reputation_tick();
|
||||
self.merchant_tick();
|
||||
self.villager_item_sense();
|
||||
self.villager_pickups();
|
||||
for m in dead {
|
||||
@@ -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,
|
||||
@@ -7249,6 +7302,7 @@ mod pearl_tests {
|
||||
items: Some(&items),
|
||||
lid_openers: &lid_openers,
|
||||
mod_effects: None,
|
||||
border: None,
|
||||
};
|
||||
let pw = ProjWorld {
|
||||
w: &w,
|
||||
|
||||
@@ -10,7 +10,7 @@ use horizon_client::{
|
||||
gizmos::Gizmos,
|
||||
hud::HudBatch,
|
||||
hud_layout, keybinds,
|
||||
text::Text,
|
||||
text::{ClickEvent, Style, Text},
|
||||
};
|
||||
use horizon_core::geometry::{Axis, Vec3};
|
||||
use horizon_protocol::packets::play::{BlockEntityTagQuery, EntityTagQuery, Serverbound};
|
||||
@@ -722,11 +722,42 @@ impl Game<'_> {
|
||||
self.export_repro_key();
|
||||
true
|
||||
}
|
||||
DebugAction::DumpDynamicTextures => {
|
||||
// `KeyboardHandler`: `TextureUtil.getDebugTexturePath` is the game
|
||||
// folder's `screenshots/debug`; `TextureManager.dumpAllSheets` then
|
||||
// writes every dumpable texture there and the chat names the folder.
|
||||
let dir = horizon_client::logging::player_dir()
|
||||
.join("screenshots")
|
||||
.join("debug");
|
||||
match self
|
||||
.gui
|
||||
.atlas
|
||||
.dump_contents(&dir, "minecraft_textures_atlas_gui")
|
||||
{
|
||||
Ok(_) => {
|
||||
log::info!("Dumped dynamic textures to {}", dir.display());
|
||||
let link = Text::styled(
|
||||
"screenshots/debug",
|
||||
Style {
|
||||
underlined: Some(true),
|
||||
click: Some(ClickEvent::OpenFile(
|
||||
dir.to_string_lossy().into_owned(),
|
||||
)),
|
||||
..Style::default()
|
||||
},
|
||||
);
|
||||
self.debug_feedback(Text::translate(
|
||||
"debug.dump_dynamic_textures",
|
||||
&[link],
|
||||
));
|
||||
}
|
||||
Err(e) => log::error!("Failed to dump dynamic textures: {e}"),
|
||||
}
|
||||
true
|
||||
}
|
||||
// Screens and overlays Horizon does not draw yet (the profiler and
|
||||
// its chart, the texture dump): the keys are used up as in vanilla.
|
||||
DebugAction::DumpDynamicTextures
|
||||
| DebugAction::Profiling
|
||||
| DebugAction::ProfilingChart => true,
|
||||
// its chart): the keys are used up as in vanilla.
|
||||
DebugAction::Profiling | DebugAction::ProfilingChart => true,
|
||||
DebugAction::ShowHitboxes
|
||||
| DebugAction::ShowChunkBorders
|
||||
| DebugAction::SwitchGameMode
|
||||
@@ -922,8 +953,7 @@ impl Game<'_> {
|
||||
}
|
||||
let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a);
|
||||
let lerpf = |a: f32, b: f32| a + alpha * (b - a);
|
||||
let guests = self.lan.iter().flat_map(|h| h.models.values());
|
||||
for m in self.mobs.iter().chain(guests) {
|
||||
for m in self.mobs.iter() {
|
||||
if m.removed || m.invisible || m.has_effect("minecraft:invisibility") {
|
||||
continue;
|
||||
}
|
||||
@@ -1129,6 +1159,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(
|
||||
|
||||
@@ -172,8 +172,7 @@ impl Game<'_> {
|
||||
}
|
||||
"tps" => self.debug_tps_line(d),
|
||||
// DebugEntryChunkGeneration, where the world is generated
|
||||
// here: the window's server thread publishes its generators;
|
||||
// the deprecated in-frame path is not extended.
|
||||
// here: the window's server thread publishes its generators.
|
||||
"chunk_generation_stats" => {
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let terrain = self
|
||||
@@ -521,10 +520,6 @@ impl Game<'_> {
|
||||
self.dragon_hit()
|
||||
.map(|(_, d)| (d, "minecraft:ender_dragon".to_owned())),
|
||||
);
|
||||
offer(
|
||||
self.guest_hit()
|
||||
.map(|(_, d)| (d, "minecraft:player".to_owned())),
|
||||
);
|
||||
best.map(|b| b.1)
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
@@ -931,7 +933,6 @@ impl Game<'_> {
|
||||
levels: self.level_shapes,
|
||||
seed: self.level.seed,
|
||||
max_players: props.max_players.max(0) as usize,
|
||||
dedicated: true,
|
||||
integrated: false,
|
||||
};
|
||||
let settings = LanSettings {
|
||||
@@ -942,7 +943,7 @@ impl Game<'_> {
|
||||
if let Some(sync) = sync {
|
||||
sync.adopt(&self.pack_files());
|
||||
}
|
||||
if let Err(e) = self.lan_host(settings, net, config, None) {
|
||||
if let Err(e) = self.lan_host(settings, net, config) {
|
||||
log("WARN", "**** FAILED TO BIND TO PORT!");
|
||||
log("WARN", &format!("The exception was: {e}"));
|
||||
log("WARN", "Perhaps a server is already running on that port?");
|
||||
@@ -1005,6 +1006,7 @@ impl Game<'_> {
|
||||
broadcast_rcon: props.broadcast_rcon_to_ops,
|
||||
op_level: props.op_permission_level,
|
||||
game_mode: props.game_mode,
|
||||
force_game_mode: props.force_game_mode,
|
||||
report: None,
|
||||
view: (
|
||||
props.view_distance.clamp(2, 32) as u32,
|
||||
|
||||
@@ -89,6 +89,22 @@ impl Game<'_> {
|
||||
self.set_default_game_mode(state.game_mode);
|
||||
return json!({ "value": GAME_MODES[state.game_mode as usize] });
|
||||
}
|
||||
"serversettings/force_game_mode" => {
|
||||
return json!({ "value": state.force_game_mode });
|
||||
}
|
||||
"serversettings/force_game_mode/set" => {
|
||||
let Some(force) = body["value"].as_bool() else {
|
||||
return json!({ "error": "force game mode must be a boolean" });
|
||||
};
|
||||
rpc(format!(
|
||||
"Update force game mode from {} to {}",
|
||||
state.force_game_mode, force
|
||||
));
|
||||
self.store_property("force-gamemode", force.to_string());
|
||||
state.force_game_mode = force;
|
||||
self.set_force_game_mode(force);
|
||||
return json!({ "value": state.force_game_mode });
|
||||
}
|
||||
"serversettings/difficulty" => {
|
||||
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `ProjectileDeflection.AIM_DEFLECT` instead of hurting anything. It flies on
|
||||
//! along the player's view and is the player's now (`Projectile.deflect`
|
||||
//! sets the owner), so a ghast it meets dies of it (`Ghast.isReflectedFireball`).
|
||||
use super::{Game, ray_box, view_vector};
|
||||
use super::{Game, lan::LanHost, ray_box, view_vector};
|
||||
use horizon_client::{
|
||||
mobs::Who,
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
@@ -125,7 +125,7 @@ impl Game<'_> {
|
||||
self.creatures.projectiles[i].kind,
|
||||
ProjectileKind::ShulkerBullet { .. }
|
||||
) {
|
||||
self.hit_shulker_bullet(i);
|
||||
self.hit_shulker_bullet(None, i);
|
||||
} else {
|
||||
self.deflect(i);
|
||||
}
|
||||
@@ -135,7 +135,7 @@ impl Game<'_> {
|
||||
/// `ShulkerBullet.hurtServer` from a player's blow: the hurt sound, fifteen crit
|
||||
/// particles (`sendParticles`, spread 0.2) and `destroy`. The blow is
|
||||
/// `Player.attack` on a target that is not a mob: its sounds, then the swing.
|
||||
pub(super) fn hit_shulker_bullet(&mut self, i: usize) {
|
||||
pub(super) fn hit_shulker_bullet(&mut self, host: Option<&LanHost>, i: usize) {
|
||||
let p = self.creatures.projectiles.remove(i);
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
self.audio
|
||||
@@ -144,7 +144,7 @@ impl Game<'_> {
|
||||
let options = horizon_client::particles::ParticleOptions::simple(ty);
|
||||
self.particles
|
||||
.send_particles(&options, at, [0.2, 0.2, 0.2], 0.0, 15);
|
||||
self.lan_level_particles(&options, at, [0.2, 0.2, 0.2], 0.0, 15, None);
|
||||
self.lan_level_particles(host, &options, at, [0.2, 0.2, 0.2], 0.0, 15, None);
|
||||
}
|
||||
let (_, _, _, magic) = self.melee_damage("minecraft:shulker_bullet", false);
|
||||
self.vehicle_attack_sounds(p.aabb(), magic.then_some(p.id as i32));
|
||||
|
||||
@@ -1951,6 +1951,7 @@ impl Game<'_> {
|
||||
.play("minecraft:entity.mooshroom.shear", sound_at, 1.0, 1.0);
|
||||
// convertTo(COW): the cow takes its place, then the mushrooms drop.
|
||||
self.convert_mob(i, "minecraft:cow");
|
||||
self.mooshroom_shear_puff([at.x, at.y, at.z], height);
|
||||
let drops = self.sequence_loot(
|
||||
"minecraft:shearing/mooshroom",
|
||||
&format!("minecraft:shearing/mooshroom/{variant}"),
|
||||
|
||||
@@ -866,15 +866,15 @@ impl Game<'_> {
|
||||
(-2..=1).all(|cz| (-2..=1).all(|cx| self.streaming.is_lit(cx, cz)))
|
||||
}
|
||||
/// A player's entity id: the acting player's body has it, another
|
||||
/// player's comes from the LAN host (0 when it is out on a guest's turn,
|
||||
/// and for an in-frame host with none).
|
||||
/// player's comes from the hub (0 when it is out on a guest's turn, and
|
||||
/// for no player).
|
||||
fn fight_entity(&self, who: FightWho) -> i32 {
|
||||
if who == self.acting_player() {
|
||||
return self.player.as_ref().map_or(0, |p| p.entity_id);
|
||||
}
|
||||
let host = self.lan.as_ref();
|
||||
match who {
|
||||
FightWho::Host => host.map_or(0, |h| h.host_entity),
|
||||
FightWho::Host => 0,
|
||||
FightWho::Guest(conn) => host
|
||||
.and_then(|h| h.hub.guest(conn))
|
||||
.map_or(0, |g| g.entity_id),
|
||||
@@ -950,9 +950,6 @@ impl Game<'_> {
|
||||
/// `EnderDragonFight.tick` and the fight's entities, while the End is
|
||||
/// ticked (in place for its player, or on a server with players in it).
|
||||
pub(super) fn tick_fight(&mut self) {
|
||||
if self.travelling.is_some() {
|
||||
return;
|
||||
}
|
||||
// A dragon outside the End (`/summon`) has no fight: `ServerLevel.getDragonFight` is
|
||||
// null there, but `EnderDragon.aiStep` still flies it.
|
||||
if self.dim != Dim::End {
|
||||
@@ -1389,7 +1386,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 +2185,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 +2250,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 +3301,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 +3314,7 @@ impl Game<'_> {
|
||||
leash: None,
|
||||
spectator: false,
|
||||
watching: false,
|
||||
hidden: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ use horizon_client::{
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::Who,
|
||||
motion::PlayerMotion,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
vanillagen::VanillaTerrain,
|
||||
@@ -115,7 +114,6 @@ fn exit_target(exit: [i32; 3], exact: bool, find: impl FnOnce([i32; 3]) -> [i32;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct EndState {
|
||||
job: Option<GatewayJob>,
|
||||
/// Ticks until the player can use a gateway again (TheEndGatewayBlockEntity cooldown).
|
||||
gateway_cooldown: u32,
|
||||
gateways: HashMap<u32, horizon_client::end_gateway::Traveller>,
|
||||
@@ -531,43 +529,6 @@ impl Game<'_> {
|
||||
if self.dim == Dim::End {
|
||||
self.ensure_exit_portal()?;
|
||||
}
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
if self.travelling.is_some() || p.health.dead_or_dying() {
|
||||
return Ok(());
|
||||
}
|
||||
let (portal, gateway) = self.inside_end_portals(
|
||||
p.position,
|
||||
f64::from(p.dimensions.width),
|
||||
f64::from(p.dimensions.height),
|
||||
);
|
||||
if portal && self.dim == Dim::End && !self.level.seen_credits {
|
||||
// EndPortalBlock.entityInside: the first exit shows the credits.
|
||||
self.show_end_credits()?;
|
||||
} else if portal {
|
||||
if self.portals.player.cooldown == 0 {
|
||||
let target = if self.dim == Dim::End {
|
||||
Dim::Overworld
|
||||
} else {
|
||||
Dim::End
|
||||
};
|
||||
self.end_portal_travel = true;
|
||||
let result = self.enter_dimension(target);
|
||||
self.end_portal_travel = false;
|
||||
result?;
|
||||
// Player.getDimensionChangingDelay.
|
||||
self.portals.player.cooldown = 10;
|
||||
} else {
|
||||
// Entity.setAsInsidePortal while cooling down restarts the cooldown.
|
||||
self.portals.player.cooldown = 10;
|
||||
}
|
||||
} else if let Some(g) = gateway
|
||||
&& self.end.gateway_cooldown == 0
|
||||
&& self.gateway_enter(g)
|
||||
{
|
||||
self.enter_gateway(g)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -629,55 +590,6 @@ impl Game<'_> {
|
||||
Some(Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5))
|
||||
}
|
||||
|
||||
/// Arrival after End portal or gateway travel, once the destination is loaded.
|
||||
pub(super) fn place_after_end_travel(
|
||||
&mut self,
|
||||
mut player: PlayerMotion,
|
||||
target: Vec3,
|
||||
) -> Result<()> {
|
||||
self.end_travel = false;
|
||||
// Player.getDimensionChangingDelay (enter_dimension uses the Nether portal value).
|
||||
self.portals.player.cooldown = 10;
|
||||
if player.health.dead_or_dying() {
|
||||
// Respawning out of the End: a fresh player at the spawn.
|
||||
player = PlayerMotion::new(self.motion, target)?;
|
||||
self.eye = 1.62;
|
||||
self.previous_eye = 1.62;
|
||||
self.clock.reset();
|
||||
}
|
||||
let position = if let Some(job) = self.end.job.take() {
|
||||
self.resolve_gateway(job, target)?
|
||||
} else {
|
||||
let mut finder = self.player_spawn.take();
|
||||
let arrival = self.end_arrival(target, &mut finder);
|
||||
self.player_spawn = finder;
|
||||
let Some((position, yaw)) = arrival? else {
|
||||
// A spawn candidate's chunk is loading: try again next tick.
|
||||
self.travelling = Some((player, target));
|
||||
self.end_travel = true;
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(yaw) = yaw {
|
||||
self.yaw = yaw;
|
||||
player.yaw = yaw;
|
||||
player.head_yaw = yaw;
|
||||
}
|
||||
position
|
||||
};
|
||||
player.position = position;
|
||||
player.velocity = Vec3::ZERO;
|
||||
player.fall_distance = 0.0;
|
||||
self.previous_position = player.position;
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
self.sway = Default::default();
|
||||
self.apply_equipment();
|
||||
let pitch = self.world.random.next_float() * 0.4 + 0.8;
|
||||
self.audio
|
||||
.play("minecraft:block.portal.travel", None, 0.25, pitch);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where an End portal's traveller lands once `target` has loaded, and
|
||||
/// the yaw it faces there: the platform below the End's spawn point,
|
||||
/// facing west, or the top of the Overworld's spawn column.
|
||||
@@ -736,27 +648,13 @@ impl Game<'_> {
|
||||
Ok(Some((Vec3::new(target.x, y, target.z), None)))
|
||||
}
|
||||
|
||||
/// Dying away from the Overworld respawns at the world spawn there.
|
||||
pub(super) fn respawn_out_of_end(&mut self) -> Result<()> {
|
||||
if self.dim == Dim::Overworld || self.player.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
self.end_portal_travel = true;
|
||||
let result = self.enter_dimension(Dim::Overworld);
|
||||
self.end_portal_travel = false;
|
||||
// The arrival places a fresh player at the spawn.
|
||||
self.end_travel = true;
|
||||
result
|
||||
}
|
||||
|
||||
/// EnderDragonFight without a dragon: once the centre of the main island
|
||||
/// is loaded, place the active exit portal (EndPodiumFeature) and the
|
||||
/// first gateway (spawnNewGateway) if the portal is not there yet. Like
|
||||
/// scanState and spawnExitPortal, only while the fight has no exit portal
|
||||
/// location: a podium that is later broken stays broken.
|
||||
fn ensure_exit_portal(&mut self) -> Result<()> {
|
||||
if self.travelling.is_some()
|
||||
|| !(-1..=0).all(|cz| (-1..=0).all(|cx| self.streaming.is_lit(cx, cz)))
|
||||
if !(-1..=0).all(|cz| (-1..=0).all(|cx| self.streaming.is_lit(cx, cz)))
|
||||
|| !self.fight_ready()
|
||||
{
|
||||
return Ok(());
|
||||
@@ -980,21 +878,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition:
|
||||
/// travel within the End, waiting for the destination chunks to load.
|
||||
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
|
||||
// TheEndGatewayBlockEntity.triggerCooldown: 40 ticks.
|
||||
self.end.gateway_cooldown = 40;
|
||||
let (job, destination) = self.gateway_job(gateway);
|
||||
let Some(player) = self.player.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
self.end.job = Some(job);
|
||||
self.end_travel = true;
|
||||
self.travelling = Some((player, destination));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where `gateway` leads (TheEndGatewayBlockEntity.getPortalPosition):
|
||||
/// its exit, or the search for one, and the spot to load before either
|
||||
/// is resolved ([`Game::resolve_gateway`]).
|
||||
|
||||
@@ -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,82 @@ 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
|
||||
}
|
||||
/// A boat or minecart: the kinds whose portal pass opens the tick, before
|
||||
/// they move (`AbstractMinecart.tick` handles its portal first).
|
||||
fn is_vehicle(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// 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 +227,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 +409,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 +447,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 +484,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 +519,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 +551,19 @@ impl Entity for ExperienceOrb {
|
||||
|
||||
impl Entity for Boat {
|
||||
stored_motion!();
|
||||
fn is_vehicle(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// `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 +587,18 @@ impl Entity for Boat {
|
||||
|
||||
impl Entity for Minecart {
|
||||
stored_motion!();
|
||||
fn is_vehicle(&self) -> bool {
|
||||
true
|
||||
}
|
||||
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))
|
||||
}
|
||||
@@ -567,6 +677,12 @@ impl Entity for ArmorStand {
|
||||
fn is_ignoring_block_triggers(&self) -> bool {
|
||||
self.marker
|
||||
}
|
||||
/// A NoGravity stand is not swept for the blocks it stands in (plates,
|
||||
/// fire): measured beside the real server, it never presses a plate nor
|
||||
/// catches fire, and does again once `NoGravity` is cleared.
|
||||
fn sweeps_blocks_inside(&self) -> bool {
|
||||
!self.no_gravity
|
||||
}
|
||||
fn pushed_by_explosion(&self) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -582,6 +698,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 +742,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 +795,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 +820,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 +853,62 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod no_gravity_stand_tests {
|
||||
use super::Entity as _;
|
||||
use horizon_client::entities::ArmorStand;
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
#[test]
|
||||
fn a_no_gravity_stand_is_not_swept_for_the_blocks_it_is_in() {
|
||||
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 100.0, 0.5), 0.0);
|
||||
// A resting stand presses a plate (measured beside the real server)...
|
||||
assert!(stand.sweeps_blocks_inside() && stand.triggers_blocks());
|
||||
// ...a NoGravity one does not, nor does a marker.
|
||||
stand.no_gravity = true;
|
||||
assert!(!stand.sweeps_blocks_inside());
|
||||
stand.no_gravity = false;
|
||||
stand.marker = true;
|
||||
assert!(!stand.triggers_blocks());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! A Game's execution role is fixed at construction. Connecting or disconnecting
|
||||
//! changes `remote`, not this role. A client may simulate a world in-frame.
|
||||
//! changes `remote`, not this role. A client never simulates a world itself: single
|
||||
//! player's runs on its integrated server thread.
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum ExecutionRole {
|
||||
@@ -14,34 +15,10 @@ impl ExecutionRole {
|
||||
self == Self::Client
|
||||
}
|
||||
|
||||
/// The optional in-frame simulation remains a client execution mode.
|
||||
pub fn starts_integrated_server(self, server_thread: bool) -> bool {
|
||||
self.is_client() && server_thread
|
||||
}
|
||||
|
||||
/// Single-player worlds (in-frame or on the integrated server thread) keep
|
||||
/// the owner's state in `Level` and write `players/data/<singleplayer_uuid>.dat`
|
||||
/// with the world; a dedicated server saves each connected player separately.
|
||||
/// Single-player worlds (on the integrated server thread) keep the owner's state in
|
||||
/// `Level` and write `players/data/<singleplayer_uuid>.dat` with the world; a dedicated
|
||||
/// server saves each connected player separately.
|
||||
pub fn writes_singleplayer_player(self) -> bool {
|
||||
self != Self::DedicatedServer
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ExecutionRole::*;
|
||||
|
||||
#[test]
|
||||
fn world_loading_keeps_in_frame_clients_and_server_roles_separate() {
|
||||
for (role, client, threaded, writes_player) in [
|
||||
(Client, true, true, true),
|
||||
(IntegratedServer, false, false, true),
|
||||
(DedicatedServer, false, false, false),
|
||||
] {
|
||||
assert_eq!(role.is_client(), client);
|
||||
assert_eq!(role.starts_integrated_server(true), threaded);
|
||||
assert!(!role.starts_integrated_server(false));
|
||||
assert_eq!(role.writes_singleplayer_player(), writes_player);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1362,4 +1362,38 @@ mod tests {
|
||||
add_or_append_stack(&mut stacks, pick(5), [0, 0, 0], &max);
|
||||
assert_eq!(stacks.len(), 2);
|
||||
}
|
||||
|
||||
/// A blast and a burst of wind cast their rays at, and push from the eyes of,
|
||||
/// the mob as it stands: a baby's and a size-4 slime's own box and eye
|
||||
/// height, not the adult kind's (`Entity.getBoundingBox`, `getEyeHeight`).
|
||||
#[test]
|
||||
fn a_scaled_mob_is_blasted_by_its_own_box_and_eye_height() {
|
||||
use crate::game::entity::Entity;
|
||||
use horizon_client::mobs::{Mob, Species};
|
||||
let mob = |species| {
|
||||
let mut kind = (*zombie_type()).clone();
|
||||
kind.species = species;
|
||||
Mob::new(1, std::sync::Arc::new(kind), Vec3::ZERO, 0.0)
|
||||
};
|
||||
let adult = mob(Species::Zombie);
|
||||
assert_eq!(adult.explosion_box(), adult.bounds());
|
||||
let mut baby = mob(Species::Zombie);
|
||||
baby.extra.age = -24000;
|
||||
let mut slime = mob(Species::Slime);
|
||||
slime.extra.size = 4;
|
||||
for m in [&baby, &slime] {
|
||||
let b = Entity::explosion_box(m);
|
||||
assert_eq!(b, m.bounds());
|
||||
assert_eq!(Entity::explosion_eye_height(m), m.eye_height());
|
||||
assert_ne!(
|
||||
f64::from(m.height()),
|
||||
f64::from(adult.height()),
|
||||
"the case must scale the box"
|
||||
);
|
||||
assert_eq!(
|
||||
b.max_position().y - b.min_position().y,
|
||||
f64::from(m.height())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,7 +245,9 @@ impl Game<'_> {
|
||||
}
|
||||
/// `FireworkRocketItem.useOn`: launch from the clicked point.
|
||||
pub(super) fn launch_firework(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:firework_rocket" {
|
||||
if &*stack.item != "minecraft:firework_rocket"
|
||||
|| use_on_passes(self.player.as_ref().is_some_and(|p| p.fall_flying))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let at = Vec3::new(
|
||||
@@ -716,3 +718,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `FireworkRocketItem.useOn` returns PASS while the player is fall flying,
|
||||
/// leaving the boost to `use` (a guest's UseItemOn must not launch a rocket).
|
||||
fn use_on_passes(fall_flying: bool) -> bool {
|
||||
fall_flying
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn use_on_passes_only_while_gliding() {
|
||||
assert!(super::use_on_passes(true));
|
||||
assert!(!super::use_on_passes(false));
|
||||
}
|
||||
}
|
||||
@@ -803,17 +803,6 @@ impl Game<'_> {
|
||||
/// (`Projectile.canHitEntity`), the host and the guests in the level.
|
||||
pub(super) fn refresh_hook_targets(&mut self, host: &LanHost) {
|
||||
let mut targets = Vec::new();
|
||||
if self.options.dedicated.is_none()
|
||||
&& !self.spectator()
|
||||
&& let Some(p) = self.player.as_ref()
|
||||
&& !p.health.dead_or_dying()
|
||||
{
|
||||
targets.push(HookTarget {
|
||||
id: host.host_entity as u32,
|
||||
conn: None,
|
||||
bounds: p.bounds(),
|
||||
});
|
||||
}
|
||||
for g in &host.hub.guests {
|
||||
if host.hub.here(g) && g.loaded && !g.dead && !g.spectator() {
|
||||
targets.push(HookTarget {
|
||||
@@ -838,9 +827,7 @@ impl Game<'_> {
|
||||
p.velocity = p.velocity.add_vector(delta);
|
||||
}
|
||||
};
|
||||
if id == host.host_entity as u32 {
|
||||
push(self);
|
||||
} else if let Some(conn) = host
|
||||
if let Some(conn) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
@@ -1174,17 +1161,13 @@ impl Hook {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The hooks guests track: the host's and each guest's in the level.
|
||||
/// The hooks guests track: each player's in the level.
|
||||
pub(super) fn lan_hook_states(
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
play: &HashMap<u32, GuestPlay>,
|
||||
hub: &Hub,
|
||||
out: &mut States,
|
||||
) {
|
||||
if let (Some(h), Some(owner)) = (&self.hook, host_entity) {
|
||||
h.track(owner, out);
|
||||
}
|
||||
for g in hub.guests.iter().filter(|g| hub.here(g)) {
|
||||
if let Some(h) = play.get(&g.conn).and_then(|p| p.hook.as_ref()) {
|
||||
h.track(g.entity_id, out);
|
||||
|
||||
@@ -423,7 +423,6 @@ impl Game<'_> {
|
||||
|| before(self.interaction_hit().map(|h| h.1))
|
||||
|| before(self.dragon_hit().map(|h| h.1))
|
||||
|| before(self.mob_hit().map(|h| h.1))
|
||||
|| before(self.guest_hit().map(|h| h.1))
|
||||
|| before(self.redirectable_hit().map(|h| h.1))
|
||||
}
|
||||
/// `LocalPlayer.isHandsBusy`: paddling a boat.
|
||||
|
||||
@@ -6,10 +6,8 @@
|
||||
//! the world's options) with [`Integrated::call`], as vanilla's client calls
|
||||
//! into its `IntegratedServer`.
|
||||
//!
|
||||
//! On by default. `--in-frame` (or `--no-server-thread`, or
|
||||
//! `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead. That path
|
||||
//! is DEPRECATED: never use it, it gets no fixes and will be removed soon.
|
||||
//! LAN guests and dedicated servers use the same code either way.
|
||||
//! Every single-player world runs this way; LAN guests and dedicated servers
|
||||
//! use the same server code.
|
||||
use super::{
|
||||
Dim, Game, Options, PackResources, Resources, UiResources, WorldIcon,
|
||||
execution_role::ExecutionRole,
|
||||
@@ -79,6 +77,9 @@ struct Flags {
|
||||
/// What the server's `LevelLoadListener` reports, for the window's
|
||||
/// `LevelLoadingScreen` (`LevelLoadTracker.serverProgress/statusView`).
|
||||
load: Arc<horizon_client::level_loading::ServerLoadView>,
|
||||
/// What the world's upgrade has done (`UpgradeProgress`): the window shows it on
|
||||
/// `FileFixerProgressScreen` while the file fixers run, and its Cancel stops them.
|
||||
upgrade: Arc<horizon_world::datafix::progress::UpgradeProgress>,
|
||||
/// Where the window's debug entries want the server's lines for (its
|
||||
/// dimension and feet), and the lines of the last tick there.
|
||||
debug_at: Mutex<Option<(Dim, [i32; 3])>>,
|
||||
@@ -108,6 +109,10 @@ impl Integrated {
|
||||
pub fn load_view(&self) -> &Arc<horizon_client::level_loading::ServerLoadView> {
|
||||
&self.flags.load
|
||||
}
|
||||
/// The world's upgrade while the server opens it.
|
||||
pub fn upgrade(&self) -> &horizon_world::datafix::progress::UpgradeProgress {
|
||||
&self.flags.upgrade
|
||||
}
|
||||
pub fn smoothed_tick_ms(&self) -> f32 {
|
||||
f32::from_bits(self.flags.smoothed_tick_ms.load(Ordering::Relaxed))
|
||||
}
|
||||
@@ -378,7 +383,6 @@ impl Game<'_> {
|
||||
// A server without a console: no local player, no window, no control socket.
|
||||
// Compatibility for gameplay checks outside startup/world loading.
|
||||
options.dedicated = Some(Vec::new());
|
||||
options.server_thread = false;
|
||||
options.execution_role = ExecutionRole::IntegratedServer;
|
||||
options.control = None;
|
||||
options.benchmark = None;
|
||||
@@ -473,6 +477,12 @@ impl Game<'_> {
|
||||
let (pipe, world_name) = match starting.ready.try_recv() {
|
||||
Ok(Ok(ready)) => ready,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(()),
|
||||
// FileFixerProgressScreen's Cancel: the upgrade stopped, back to the title.
|
||||
Ok(Err(_)) if starting.handle.upgrade().is_canceled() => {
|
||||
self.integrated_starting = None;
|
||||
self.perf.cancel_world_opening();
|
||||
return Ok(());
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
self.integrated_starting = None;
|
||||
return Err(e.into());
|
||||
@@ -738,7 +748,6 @@ impl Game<'_> {
|
||||
seed: self.level.seed,
|
||||
max_players: MAX_PLAYERS,
|
||||
// No player of its own: the hub runs without a host.
|
||||
dedicated: true,
|
||||
integrated: true,
|
||||
};
|
||||
let owner = launch.owner;
|
||||
@@ -777,9 +786,7 @@ impl Game<'_> {
|
||||
guest_commands: false,
|
||||
force_game_mode: false,
|
||||
};
|
||||
startup::time("server.lan_host", || {
|
||||
self.lan_host(settings, net, config, None)
|
||||
})?;
|
||||
startup::time("server.lan_host", || self.lan_host(settings, net, config))?;
|
||||
let commands = self.allow_commands();
|
||||
let pipe = {
|
||||
let host = self
|
||||
@@ -1163,6 +1170,7 @@ fn serve(
|
||||
pack.clone(),
|
||||
options.clone(),
|
||||
settings.clone(),
|
||||
flags.upgrade.clone(),
|
||||
)
|
||||
});
|
||||
let opening = Opening {
|
||||
@@ -1203,7 +1211,6 @@ impl Game<'_> {
|
||||
let mut options = self.options.clone();
|
||||
options.execution_role = ExecutionRole::IntegratedServer;
|
||||
options.dedicated = Some(Vec::new());
|
||||
options.server_thread = false;
|
||||
let result = std::thread::Builder::new()
|
||||
.name("Server thread".into())
|
||||
.spawn(move || {
|
||||
|
||||
@@ -460,6 +460,38 @@ impl Game<'_> {
|
||||
&mut sink,
|
||||
);
|
||||
}
|
||||
/// [`Self::submit_item_lit`] of `state` under each of `poses` on the GPU path: the mark
|
||||
/// before its draws and their bounds, or `None` (nothing drawn) when the state cannot
|
||||
/// take it.
|
||||
pub(super) fn submit_item_skinned(
|
||||
&self,
|
||||
state: &ItemRenderState,
|
||||
poses: &[glam::Mat4],
|
||||
light: u32,
|
||||
lights: (glam::Vec3, glam::Vec3),
|
||||
) -> Option<((usize, usize, u32), Option<(glam::Vec3, glam::Vec3)>)> {
|
||||
let mut models = self.skin_models.borrow_mut();
|
||||
let mut frame = self.skin_frame.borrow_mut();
|
||||
let mark = frame.mark();
|
||||
let mut skin = horizon_client::mobs::skin::Skin {
|
||||
models: &mut models,
|
||||
frame: &mut frame,
|
||||
bounds: None,
|
||||
};
|
||||
let sink = (
|
||||
item_hdr_light(state, light),
|
||||
(state.hdr_light == 0).then_some(lights),
|
||||
state.hdr_tier,
|
||||
);
|
||||
for &pose in poses {
|
||||
let renderer = &self.pack.item_renderer;
|
||||
if !state.submit_skinned(renderer, &self.gui.atlas.sprites, pose, sink, &mut skin) {
|
||||
// Decided before any draw, the same for every pose.
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some((mark, skin.bounds))
|
||||
}
|
||||
/// `ItemEntityRenderer.submit` for every dropped item.
|
||||
pub(super) fn dropped_item_geometry(
|
||||
&self,
|
||||
@@ -488,6 +520,8 @@ impl Game<'_> {
|
||||
// position. A pickup particle is not an entity and keeps 64 blocks.
|
||||
let range =
|
||||
super::entity_culling::default_range(glam::DVec3::splat(0.25), self.view_scale());
|
||||
let skinning = self.skinning();
|
||||
let lights = diffuse_lights(self.dim == Dim::Nether);
|
||||
for (item, at, partial, entity) in live.chain(taken) {
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, (2 << 32) | u64::from(item.id));
|
||||
@@ -538,19 +572,27 @@ impl Game<'_> {
|
||||
);
|
||||
let seed = i64::from(self.pack.item_renderer.item_id(&item.stack.item))
|
||||
+ i64::from(item.stack.damage);
|
||||
for copy in item_render::cluster_poses(
|
||||
let copies = item_render::cluster_poses(
|
||||
&state,
|
||||
pose,
|
||||
item_render::rendered_amount(item.stack.count),
|
||||
seed,
|
||||
) {
|
||||
self.submit_item_world(&state, copy, vertex_light(packed), out);
|
||||
);
|
||||
let glowing = self.entity_glowing(item.id);
|
||||
// Posed on the GPU when every layer can be (the outline reads the CPU's vertices).
|
||||
let skinned = (skinning && !glowing)
|
||||
.then(|| self.submit_item_skinned(&state, &copies, vertex_light(packed), lights))
|
||||
.flatten();
|
||||
if skinned.is_none() {
|
||||
for © in &copies {
|
||||
self.submit_item_world(&state, copy, vertex_light(packed), out);
|
||||
}
|
||||
}
|
||||
if self.entity_glowing(item.id) {
|
||||
if glowing {
|
||||
let rgb = super::glowing::team_color(self.mob_team(item.id));
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
} else {
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
self.cull_skinned(out, entity_start, skinned);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +204,15 @@ pub(super) fn release_parts(canceled: bool, hand_changed: bool) -> (bool, bool)
|
||||
(!canceled && !hand_changed, !canceled)
|
||||
}
|
||||
|
||||
pub(super) fn bow_release_power(has_ammo: bool, charge: i32, canceled: bool) -> Option<f32> {
|
||||
if canceled || charge < 0 || !has_ammo {
|
||||
return None;
|
||||
}
|
||||
let f = charge as f32 / 20.0;
|
||||
let power = ((f * f + f * 2.0) / 3.0).min(1.0);
|
||||
(power >= 0.1).then_some(power)
|
||||
}
|
||||
|
||||
/// `useItemRemaining` of a use of `duration` ticks, `ticks` old.
|
||||
fn ticks_left(duration: i32, ticks: u32) -> i32 {
|
||||
duration.saturating_sub_unsigned(ticks)
|
||||
@@ -272,8 +281,8 @@ pub(super) fn use_pose(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
consumes_at_once, count_down, duration_leaving, held, release_parts, start_of, tick_left,
|
||||
ticks_left, use_pose,
|
||||
bow_release_power, consumes_at_once, count_down, duration_leaving, held, release_parts,
|
||||
start_of, tick_left, ticks_left, use_pose,
|
||||
};
|
||||
use horizon_client::first_person::ItemPose;
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
@@ -367,6 +376,16 @@ mod tests {
|
||||
assert_eq!(release_parts(false, true), (false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bow_release_respects_charge_cancel_and_ammo() {
|
||||
assert_eq!(bow_release_power(true, 20, false), Some(1.0));
|
||||
assert_eq!(bow_release_power(true, 10, false), Some(5.0 / 12.0));
|
||||
assert_eq!(bow_release_power(true, 0, false), None);
|
||||
assert_eq!(bow_release_power(true, -1, false), None);
|
||||
assert_eq!(bow_release_power(true, 20, true), None);
|
||||
assert_eq!(bow_release_power(false, 20, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moved_end_moves_the_charge() {
|
||||
// a bow held 10 ticks of 72000: 10, as vanilla's.
|
||||
|
||||
@@ -2,25 +2,22 @@
|
||||
//! server threads and the play-state hub, feeds it the world's entities each
|
||||
//! tick and turns what the guests do into gameplay. A world that is not open
|
||||
//! costs one `Option` check per tick.
|
||||
use super::{Game, shield::OFFHAND_SLOT, shoulders::shoulder_variants};
|
||||
use super::{Game, shield::OFFHAND_SLOT};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
items::ItemStack,
|
||||
lan::{
|
||||
self, Lan, MAX_PLAYERS, NetConfig,
|
||||
self, Lan, NetConfig,
|
||||
chunks::ChunkEncoder,
|
||||
data::SyncData,
|
||||
entity::{EntityState, States},
|
||||
hub::{BodyFlags, HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
hub::{HostEvent, Hub, HubConfig, TickCtx},
|
||||
playerdata,
|
||||
},
|
||||
mobs::{Mob, Species, Who, leash::Holder},
|
||||
text::Text,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::{
|
||||
crypto::offline_uuid,
|
||||
entity_data::{Value, serializer},
|
||||
packets::play::{self, Clientbound as C},
|
||||
};
|
||||
@@ -81,6 +78,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()));
|
||||
@@ -153,23 +151,15 @@ pub(super) struct LanHost {
|
||||
/// The mods heard that everyone still here leaves with the server.
|
||||
pub players_told_left: bool,
|
||||
pub settings: LanSettings,
|
||||
pub host_entity: i32,
|
||||
/// The host's swings the guests were told of (`Game::swings_started`).
|
||||
pub host_swings: u32,
|
||||
/// The host's crack the guests were told of: the block and its stage.
|
||||
pub host_crack: Option<([i32; 3], i32)>,
|
||||
pub host_uuid: u128,
|
||||
/// The host's profile name (the account's, or the offline name).
|
||||
pub host_name: String,
|
||||
/// The guests' models in the host's world.
|
||||
pub models: HashMap<u32, Mob>,
|
||||
host_mode: i32,
|
||||
pub play: HashMap<u32, super::lan_actions::GuestPlay>,
|
||||
/// Blocks broken this tick (their sound is a level event for the guests).
|
||||
pub broken: Vec<[f64; 3]>,
|
||||
/// Hurt time, death and swing time (while swinging) of the mobs guests
|
||||
/// see; the players' hurt time by entity id.
|
||||
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool, Option<i32>)>>,
|
||||
/// Each placed guest's pose as the mods last heard it
|
||||
/// (`native_entity_pose_change`), by connection.
|
||||
pub poses: HashMap<u32, horizon_client::pose::Pose>,
|
||||
/// The guest each mob sees as its player, when not the host (game/lan_mobs.rs).
|
||||
/// Looked up per mob each tick: a fast hasher (never iterated).
|
||||
pub mob_players: horizon_client::fast_hash::FxHashMap<u32, u32>,
|
||||
@@ -1107,7 +1097,7 @@ fn enforce_guest_game_mode(
|
||||
return;
|
||||
}
|
||||
let mode = host.hub.config.default_game_mode;
|
||||
let spares_operators = !host.hub.config.dedicated || host.hub.config.integrated;
|
||||
let spares_operators = host.hub.config.integrated;
|
||||
let stale: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
@@ -1168,7 +1158,8 @@ impl Game<'_> {
|
||||
match &self.lan {
|
||||
Some(h) if !self.serving() || self.published_port().is_some() => json!({
|
||||
"open": true,
|
||||
"host": h.host_name,
|
||||
// The owner's profile name.
|
||||
"host": self.options.player_name(),
|
||||
"port": h.settings.port,
|
||||
// The server's own world: its finished chunks and how many it
|
||||
// has generated or loaded so far.
|
||||
@@ -1203,17 +1194,6 @@ impl Game<'_> {
|
||||
],
|
||||
"chunks": g.sent_chunks.len(),
|
||||
"tracked": g.tracker.len(),
|
||||
// Whether the host draws this guest lying in a bed.
|
||||
"model_lying": h.models.get(&g.conn).map(|m| m.extra.ints[1] != 0),
|
||||
// The armour the host draws on this guest, head to feet.
|
||||
"model_armor": h.models.get(&g.conn).map(|m| {
|
||||
[&m.extra.head, &m.extra.chest, &m.extra.legs, &m.extra.feet]
|
||||
.map(|s| s.as_deref())
|
||||
}),
|
||||
// The host draws this guest's arm swinging.
|
||||
"model_swinging": h.models.get(&g.conn).map(|m| m.ai.swinging),
|
||||
// For how many ticks the host draws this guest using an item.
|
||||
"model_using": h.models.get(&g.conn).map(|m| m.extra.using_item),
|
||||
})).collect::<Vec<_>>(),
|
||||
}),
|
||||
_ => json!({ "open": false }),
|
||||
@@ -1229,8 +1209,7 @@ impl Game<'_> {
|
||||
} else if self.serving() {
|
||||
return self.publish(settings).map(drop).map_err(Into::into);
|
||||
} else {
|
||||
self.lan_start(settings)?;
|
||||
self.lan.as_ref().map_or(0, |l| l.settings.port)
|
||||
return Err("no world of this game to open".into());
|
||||
};
|
||||
self.system_message(Text::translate(
|
||||
"menu.multiplayerOptions.publish.started.lan",
|
||||
@@ -1238,45 +1217,6 @@ impl Game<'_> {
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
/// IntegratedServer.publishServer without the screen's announcement.
|
||||
pub(super) fn lan_start(&mut self, settings: LanSettings) -> Result<()> {
|
||||
if self.lan.is_some() || self.remote.is_some() || self.title.is_some() {
|
||||
return Err("already open".into());
|
||||
}
|
||||
// IntegratedServer.publishServer: the MOTD names the host's profile.
|
||||
let name = self.options.player_name();
|
||||
let motd = format!("{name} - {}", self.level.name);
|
||||
let host_uuid = self.level.player_uuid.map_or_else(
|
||||
|| offline_uuid(&name),
|
||||
|u| {
|
||||
u128::from(u[0] as u32) << 96
|
||||
| u128::from(u[1] as u32) << 64
|
||||
| u128::from(u[2] as u32) << 32
|
||||
| u128::from(u[3] as u32)
|
||||
},
|
||||
);
|
||||
let config = HubConfig {
|
||||
allow_flight: true,
|
||||
default_game_mode: self.level.mode(),
|
||||
guest_commands: settings.guest_commands,
|
||||
force_game_mode: settings.force_game_mode,
|
||||
hardcore: self.level.hardcore,
|
||||
view_distance: self.settings.render_distance.clamp(2, 32),
|
||||
simulation_distance: self.settings.render_distance.clamp(2, 32),
|
||||
dimension: self.dim.id().to_owned(),
|
||||
levels: self.level_shapes,
|
||||
seed: self.level.seed,
|
||||
max_players: MAX_PLAYERS,
|
||||
dedicated: false,
|
||||
integrated: false,
|
||||
};
|
||||
let net = NetConfig {
|
||||
levels: self.level_shapes,
|
||||
online: horizon_client::lan::OnlineMode::lan(),
|
||||
..NetConfig::lan(settings.port, motd, MAX_PLAYERS)
|
||||
};
|
||||
self.lan_host(settings, net, config, Some(host_uuid))
|
||||
}
|
||||
/// Start the network side and the play-state hub: for the LAN (with the
|
||||
/// host's own profile reserved) or for a dedicated server (no host).
|
||||
pub(super) fn lan_host(
|
||||
@@ -1284,9 +1224,22 @@ impl Game<'_> {
|
||||
settings: LanSettings,
|
||||
net: NetConfig,
|
||||
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
|
||||
@@ -1328,12 +1281,6 @@ impl Game<'_> {
|
||||
let far = (net.lod_distance > 0)
|
||||
.then(|| horizon_client::lod_net::Service::new(net.lod_distance, biome_names.clone()));
|
||||
let lan = Lan::start_with(sync.clone(), net)?;
|
||||
if let Some(uuid) = host {
|
||||
lan.reserve(uuid);
|
||||
}
|
||||
let host_uuid = host.unwrap_or(0);
|
||||
self.next_entity += 1;
|
||||
let host_entity = self.next_entity as i32;
|
||||
let mut hub = Hub::new(lan, sync, encoder, config);
|
||||
hub.flat_world = self.world_flat;
|
||||
hub.debug_world = self.world_debug;
|
||||
@@ -1344,16 +1291,10 @@ impl Game<'_> {
|
||||
players_told_left: false,
|
||||
hub,
|
||||
settings: LanSettings { port, ..settings },
|
||||
host_entity,
|
||||
host_swings: self.swings_started,
|
||||
host_crack: None,
|
||||
host_uuid,
|
||||
host_name: self.options.player_name(),
|
||||
models: HashMap::new(),
|
||||
host_mode: self.mode_id(),
|
||||
play: HashMap::new(),
|
||||
broken: Vec::new(),
|
||||
mob_state: HashMap::new(),
|
||||
poses: HashMap::new(),
|
||||
mob_players: Default::default(),
|
||||
owner: None,
|
||||
waypoints: Default::default(),
|
||||
@@ -1695,6 +1636,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 +1656,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();
|
||||
}
|
||||
|
||||
@@ -1724,70 +1672,23 @@ impl Game<'_> {
|
||||
self.lan.as_ref().map(|h| h.settings.port)
|
||||
}
|
||||
|
||||
/// The host as the guests see them.
|
||||
fn lan_host_state(&self, host: &LanHost) -> Option<HostState> {
|
||||
let p = self.player.as_ref()?;
|
||||
let mut equipment: [Option<ItemStack>; 6] = Default::default();
|
||||
equipment[0] = self.inventory.get(self.selected).cloned().flatten();
|
||||
equipment[1] = self.offhand.clone();
|
||||
// Feet, legs, chest, head are slots 2..=5; armor is stored head first.
|
||||
equipment[2] = self.armor[3].clone();
|
||||
equipment[3] = self.armor[2].clone();
|
||||
equipment[4] = self.armor[1].clone();
|
||||
equipment[5] = self.armor[0].clone();
|
||||
Some(HostState {
|
||||
uuid: host.host_uuid,
|
||||
name: host.host_name.clone(),
|
||||
entity_id: host.host_entity,
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
yaw: self.yaw,
|
||||
pitch: self.pitch,
|
||||
head_yaw: self.yaw,
|
||||
on_ground: p.ground.on_ground,
|
||||
game_mode: self.mode_id(),
|
||||
sneaking: p.pose == horizon_client::pose::Pose::Crouching,
|
||||
sprinting: p.sprinting,
|
||||
equipment,
|
||||
health: p.health.health(),
|
||||
attributes: horizon_client::lan::attributes::syncable(&p.attributes),
|
||||
// Bit 4: Riptide's spin (LivingEntity.isAutoSpinAttack).
|
||||
living_flags: living_flags(self.using)
|
||||
| if p.auto_spin_attack_ticks > 0 { 4 } else { 0 },
|
||||
stuck: (self.combat.stuck.arrows, self.combat.stuck.stingers),
|
||||
shoulders: shoulder_variants(self.level.player_extra.as_ref()),
|
||||
bed: self.sleeping,
|
||||
body: BodyFlags {
|
||||
gliding: p.fall_flying,
|
||||
swimming: p.swimming,
|
||||
crawling: p.pose == horizon_client::pose::Pose::Swimming && !p.swimming,
|
||||
crouching: p.pose == horizon_client::pose::Pose::Crouching,
|
||||
spin_attack: p.auto_spin_attack_ticks > 0,
|
||||
on_fire: p.fire_ticks > 0,
|
||||
// ServerPlayer.updateInvisibilityStatus: a spectator is always invisible.
|
||||
invisible: self.potions.effects.get("invisibility").is_some() || self.spectator(),
|
||||
glowing: self.potions.effects.get("glowing").is_some(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// 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,
|
||||
host_entity: Option<i32>,
|
||||
&mut self,
|
||||
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) {
|
||||
// The lead's holder: the player the mob sees (a guest, else the
|
||||
// host), another mob or a fence knot (game/lan_entities.rs).
|
||||
// The lead's holder: the player the mob sees, another mob or a
|
||||
// fence knot (game/lan_entities.rs).
|
||||
let player_entity = mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id)
|
||||
.or(host_entity);
|
||||
.map(|g| g.entity_id);
|
||||
let leash = m.extra.leash.and_then(|h| match h {
|
||||
Holder::Player => player_entity,
|
||||
Holder::Mob(id) | Holder::Boat(id) => Some(id as i32),
|
||||
@@ -1843,7 +1744,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(hub, out, drain_minecart_steps);
|
||||
}
|
||||
|
||||
/// Apply gameplay events produced while the hub handles guest packets.
|
||||
@@ -1856,7 +1757,6 @@ impl Game<'_> {
|
||||
&mut self,
|
||||
mut host: LanHost,
|
||||
events: impl IntoIterator<Item = HostEvent>,
|
||||
state: Option<&HostState>,
|
||||
entities: &[EntityState],
|
||||
spawn: [f64; 3],
|
||||
) -> Option<LanHost> {
|
||||
@@ -1912,7 +1812,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
self.lan_guest_joined(&mut host, conn, state, entities, spawn);
|
||||
self.lan_guest_joined(&mut host, conn, entities, spawn);
|
||||
if self.options.dedicated.is_some()
|
||||
&& let Some(g) = host.hub.guest(conn)
|
||||
{
|
||||
@@ -1989,7 +1889,6 @@ impl Game<'_> {
|
||||
self.take_parent_vehicle(&mut host, &mut guest);
|
||||
self.save_guest(&host, &mut guest);
|
||||
self.guest_attachments.remove(&guest.uuid);
|
||||
host.models.remove(&guest.conn);
|
||||
host.play.remove(&guest.conn);
|
||||
self.guest_combat.remove(&guest.conn);
|
||||
if self.options.dedicated.is_some() && guest.placed {
|
||||
@@ -2062,12 +1961,6 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let holding = lan::Holding::start();
|
||||
let state = self.lan_host_state(&host);
|
||||
if state.is_none() && !host.hub.config.dedicated {
|
||||
self.lan = Some(host);
|
||||
self.level_pass.packets_drained = true;
|
||||
return;
|
||||
}
|
||||
// Last tick's tracked entities: enough for place collision and joins
|
||||
// before this tick moves anyone.
|
||||
let entities = std::mem::take(&mut host.states);
|
||||
@@ -2076,7 +1969,6 @@ impl Game<'_> {
|
||||
let clock_rate = self.network_clock_rate();
|
||||
let mut ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
host: state.as_ref(),
|
||||
entities: entities.as_slice(),
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
@@ -2103,9 +1995,7 @@ impl Game<'_> {
|
||||
events.extend(host.hub.drain_packets(&mut ctx));
|
||||
events
|
||||
};
|
||||
let Some(back) =
|
||||
self.lan_process_events(host, events, state.as_ref(), entities.as_slice(), spawn)
|
||||
else {
|
||||
let Some(back) = self.lan_process_events(host, events, entities.as_slice(), spawn) else {
|
||||
return;
|
||||
};
|
||||
host = back;
|
||||
@@ -2158,7 +2048,6 @@ impl Game<'_> {
|
||||
if !self.admin_feedback.is_empty() {
|
||||
self.send_admin_feedback(&mut host, None);
|
||||
}
|
||||
let state = self.lan_host_state(&host);
|
||||
// An animal whose rider left the game (or the level) stops taking
|
||||
// its input.
|
||||
for m in &mut self.mobs {
|
||||
@@ -2177,12 +2066,10 @@ impl Game<'_> {
|
||||
if !paused {
|
||||
self.tick_guests(&mut host);
|
||||
}
|
||||
// A dedicated server runs the hub without a host player.
|
||||
if state.is_some() || host.hub.config.dedicated {
|
||||
{
|
||||
let has_guests = !host.hub.guests.is_empty();
|
||||
let mut states = std::mem::take(&mut host.states);
|
||||
if has_guests {
|
||||
let host_entity = state.as_ref().map(|s| s.entity_id);
|
||||
self.lan_assign_ids();
|
||||
let guests: Vec<(u32, i32)> = host
|
||||
.hub
|
||||
@@ -2190,12 +2077,17 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.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_hook_states(host_entity, &host.play, &host.hub, &mut states);
|
||||
self.lan_riding(&guests, &mut host.hub.riding);
|
||||
self.lan_entities(&host.mob_players, &mut host.hub, &mut states, true);
|
||||
self.lan_hook_states(&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 {
|
||||
@@ -2238,7 +2130,6 @@ impl Game<'_> {
|
||||
let clock_rate = self.network_clock_rate();
|
||||
let mut ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
host: state.as_ref(),
|
||||
entities,
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
@@ -2259,7 +2150,7 @@ impl Game<'_> {
|
||||
// IntegratedServer.getScaledTrackingDistance: the Entity Distance
|
||||
// option scales what an integrated server or a LAN host tracks (a
|
||||
// dedicated one keeps its entity-broadcast-range-percentage).
|
||||
if host.hub.config.integrated || !host.hub.config.dedicated {
|
||||
if host.hub.config.integrated {
|
||||
host.hub.entity_range_percent =
|
||||
(self.settings.entity_distance * 100.0).round() as i32;
|
||||
}
|
||||
@@ -2284,15 +2175,8 @@ impl Game<'_> {
|
||||
}
|
||||
events
|
||||
};
|
||||
if let Some(state) = &state
|
||||
&& host.host_mode != state.game_mode
|
||||
{
|
||||
host.host_mode = state.game_mode;
|
||||
host.hub.set_host_game_mode(state.uuid, state.game_mode);
|
||||
}
|
||||
self.wake_woken_guests(&mut host);
|
||||
let Some(back) = self.lan_process_events(host, events, state.as_ref(), entities, spawn)
|
||||
else {
|
||||
let Some(back) = self.lan_process_events(host, events, entities, spawn) else {
|
||||
return;
|
||||
};
|
||||
host = back;
|
||||
@@ -2351,8 +2235,8 @@ impl Game<'_> {
|
||||
self.tick_server_travel(&mut host);
|
||||
self.lan_swings(&mut host);
|
||||
self.lan_update_streaming(&host);
|
||||
self.lan_update_models(&mut host);
|
||||
self.lan_update_waypoints(&mut host, state.as_ref());
|
||||
self.lan_guest_poses(&mut host);
|
||||
self.lan_update_waypoints(&mut host);
|
||||
self.lan_sync_scoreboard(&mut host);
|
||||
self.lan_sync_border(&mut host);
|
||||
host.hub.anticheat_ping();
|
||||
@@ -2362,8 +2246,8 @@ impl Game<'_> {
|
||||
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
|
||||
self.lan = Some(host);
|
||||
self.flush_native_sounds();
|
||||
// `tick_server` resets the pass after all dimensions. The deprecated
|
||||
// in-frame path has no outer pass, so make its packet gate per-tick.
|
||||
// `tick_server` resets the pass after all dimensions; outside a pass
|
||||
// the packet gate is per-tick.
|
||||
if self.level_pass.running.is_none() {
|
||||
self.level_pass.packets_drained = false;
|
||||
}
|
||||
@@ -2373,7 +2257,6 @@ impl Game<'_> {
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
state: Option<&HostState>,
|
||||
entities: &[EntityState],
|
||||
spawn: [f64; 3],
|
||||
) {
|
||||
@@ -2412,11 +2295,10 @@ impl Game<'_> {
|
||||
g.food = 20;
|
||||
g.dimension = super::Dim::Overworld.id().to_owned();
|
||||
}
|
||||
// A server places each player in their own level
|
||||
// (`PlayerList.placeNewPlayer`); the in-frame game has one.
|
||||
// A server places each player in their own level (`PlayerList.placeNewPlayer`).
|
||||
let back = self.dim;
|
||||
let level = super::Dim::from_id(&g.dimension);
|
||||
let elsewhere = self.options.dedicated.is_some() && level != back;
|
||||
let elsewhere = level != back;
|
||||
if !elsewhere {
|
||||
g.dimension = in_place;
|
||||
}
|
||||
@@ -2430,8 +2312,8 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
// A dedicated server has no host player.
|
||||
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs);
|
||||
self.lan_hook_states(None, &host.play, &host.hub, &mut theirs);
|
||||
self.lan_entities(&host.mob_players, &mut host.hub, &mut theirs, false);
|
||||
self.lan_hook_states(&host.play, &host.hub, &mut theirs);
|
||||
self.lan_entity_uuids(&mut theirs);
|
||||
theirs.as_slice()
|
||||
} else {
|
||||
@@ -2440,7 +2322,6 @@ impl Game<'_> {
|
||||
let clock_rate = self.network_clock_rate();
|
||||
let mut ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
host: state,
|
||||
entities,
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
@@ -2566,6 +2447,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()
|
||||
@@ -2573,15 +2460,10 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let told = if self.lan_acting.is_some() {
|
||||
let told = self.lan_acting.is_some();
|
||||
if told {
|
||||
self.guest_equipment_breaks.push((event, at));
|
||||
true
|
||||
} else if let Some(host) = &self.lan {
|
||||
host.hub.entity_event(host.host_entity, event, at);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
}
|
||||
if told && !self.options.execution_role.is_client() {
|
||||
return;
|
||||
}
|
||||
@@ -2692,14 +2574,14 @@ impl Game<'_> {
|
||||
{
|
||||
use horizon_client::mobs::{DamageKind as K, Who};
|
||||
let kind = super::creatures::source_damage_type(source);
|
||||
// The player a mob sees is the guest acting, else the host.
|
||||
let cause = source.attacker.map(|who| match who {
|
||||
// The player a mob sees is the guest acting.
|
||||
let cause = source.attacker.and_then(|who| match who {
|
||||
Who::Player => host
|
||||
.mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| host.hub.guest(*conn))
|
||||
.map_or(host.host_entity, |g| g.entity_id),
|
||||
Who::Mob(id) => id as i32,
|
||||
.map(|g| g.entity_id),
|
||||
Who::Mob(id) => Some(id as i32),
|
||||
});
|
||||
// A projectile is the direct entity of its hit: not tracked here.
|
||||
let melee = matches!(
|
||||
@@ -2745,48 +2627,20 @@ impl Game<'_> {
|
||||
}
|
||||
seen.insert(id, (d.hurt_time, dead, None));
|
||||
}
|
||||
// LivingEntity.hurtServer for the in-frame LAN host's player (guests
|
||||
// broadcast directly from HealthEvent::DamageBroadcast): a hit that
|
||||
// restarted the hurt time and was not blocked by a shield broadcasts
|
||||
// the damage event, so the clients around flash it red.
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let id = host.host_entity;
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let hurt = p.health.hurt_animation_time;
|
||||
let dead = p.health.dead_or_dying();
|
||||
let old = host
|
||||
.mob_state
|
||||
.get(&self.dim)
|
||||
.and_then(|s| s.get(&(id as u32)));
|
||||
if hurt > old.map_or(0, |o| o.0) && !dead {
|
||||
let kind = p
|
||||
.health
|
||||
.hurt
|
||||
.last_source
|
||||
.and_then(|s| self.motion.health.damage.definition(s))
|
||||
.map_or("minecraft:generic", |d| d.name.as_str());
|
||||
host.hub.damage_event(id, kind, None, None, at);
|
||||
}
|
||||
seen.insert(id as u32, (hurt, dead, None));
|
||||
}
|
||||
host.mob_state.insert(self.dim, seen);
|
||||
// Level.broadcastEntityEvent(player, 2) of a spear's stab.
|
||||
for attacker in kinetic_hits {
|
||||
let hit = match attacker {
|
||||
None => self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| (host.host_entity, [p.position.x, p.position.y, p.position.z])),
|
||||
Some(conn) => host.hub.guest(conn).map(|g| (g.entity_id, g.position)),
|
||||
};
|
||||
let hit = attacker
|
||||
.and_then(|conn| host.hub.guest(conn))
|
||||
.map(|g| (g.entity_id, g.position));
|
||||
if let Some((id, at)) = hit {
|
||||
host.hub.entity_event(id, 2, at);
|
||||
}
|
||||
}
|
||||
for (attacker, target, magic) in critical_hits {
|
||||
let Some(attacker) = attacker.map_or(Some(host.host_entity), |conn| {
|
||||
host.hub.guest(conn).map(|g| g.entity_id)
|
||||
}) else {
|
||||
let Some(attacker) =
|
||||
attacker.and_then(|conn| host.hub.guest(conn).map(|g| g.entity_id))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
host.hub.critical_hit(attacker, target, magic);
|
||||
@@ -2828,153 +2682,30 @@ impl Game<'_> {
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// The guests' models, for the host to see them.
|
||||
fn lan_update_models(&mut self, host: &mut LanHost) {
|
||||
let Some(kind) = self.res.player_type().clone() else {
|
||||
/// The bed at `head` as a sleeping model lies in it (`ints[1]`,
|
||||
/// `villager::sleep_pose`): its `facing`.
|
||||
/// `Entity.setPose` for the server's players: the mods hear each placed
|
||||
/// guest's change of pose. Nothing is kept while no mod is loaded.
|
||||
fn lan_guest_poses(&mut self, host: &mut LanHost) {
|
||||
if horizon_client::mod_api::mods().is_empty() {
|
||||
host.poses.clear();
|
||||
return;
|
||||
};
|
||||
host.models
|
||||
}
|
||||
host.poses
|
||||
.retain(|conn, _| host.hub.guest(*conn).is_some_and(|g| g.placed));
|
||||
let mut changed = Vec::new();
|
||||
for g in host.hub.guests.iter().filter(|g| g.placed) {
|
||||
let at = Vec3::new(
|
||||
g.position[0],
|
||||
g.position[1] - if g.sneaking { 0.125 } else { 0.0 },
|
||||
g.position[2],
|
||||
);
|
||||
let m = host
|
||||
.models
|
||||
.entry(g.conn)
|
||||
.or_insert_with(|| Mob::new(g.entity_id as u32, kind.clone(), at, g.yaw));
|
||||
let (dx, dy, dz) = (
|
||||
at.x - m.position.x,
|
||||
at.y - m.position.y,
|
||||
at.z - m.position.z,
|
||||
);
|
||||
m.previous = m.position;
|
||||
m.position = at;
|
||||
if dx * dx + dy * dy + dz * dz > 100.0 {
|
||||
m.previous = at;
|
||||
let to = g.pose();
|
||||
let from = host.poses.insert(g.conn, to).unwrap_or_default();
|
||||
if from != to {
|
||||
changed.push((g.uuid, g.name.clone(), from, to));
|
||||
}
|
||||
m.previous_yaw = m.yaw;
|
||||
m.previous_body_yaw = m.body_yaw;
|
||||
m.previous_head_yaw = m.head_yaw;
|
||||
m.previous_head_pitch = m.head_pitch;
|
||||
m.yaw = g.yaw;
|
||||
m.head_yaw = g.head_yaw;
|
||||
m.head_pitch = g.pitch;
|
||||
let mut target = m.body_yaw;
|
||||
let wrap = |a: f32| (a + 180.0).rem_euclid(360.0) - 180.0;
|
||||
if dx * dx + dz * dz > 0.002_500_000_2 {
|
||||
// LivingEntity.tick: walking backwards the body faces away
|
||||
// from the walk direction, not along it.
|
||||
let walk = (dz.atan2(dx) as f32).to_degrees() - 90.0;
|
||||
let facing = (wrap(g.yaw) - walk).abs();
|
||||
target = if 95.0 < facing && facing < 265.0 {
|
||||
walk - 180.0
|
||||
} else {
|
||||
walk
|
||||
};
|
||||
}
|
||||
m.body_yaw += wrap(target - m.body_yaw) * 0.3;
|
||||
let diff = wrap(g.yaw - m.body_yaw);
|
||||
if diff.abs() > 50.0 {
|
||||
m.body_yaw += diff - diff.signum() * 50.0;
|
||||
}
|
||||
let dist = (dx * dx + dz * dz).sqrt() as f32;
|
||||
m.previous_limb_speed = m.limb_speed;
|
||||
m.limb_speed += ((dist * 4.0).min(1.0) - m.limb_speed) * 0.4;
|
||||
m.limb_position += m.limb_speed;
|
||||
m.age = m.age.wrapping_add(1);
|
||||
// The hurt red flash (LivingEntity.hurtTime).
|
||||
m.hurt_time = g
|
||||
.motion
|
||||
.as_ref()
|
||||
.map_or(0, |p| p.health.hurt_animation_time);
|
||||
m.extra
|
||||
.set_main_hand_stack(g.inventory.get(g.selected).cloned().flatten());
|
||||
m.extra.off_hand = g.offhand.as_ref().map(|s| s.item.clone());
|
||||
[m.extra.head, m.extra.chest, m.extra.legs, m.extra.feet] = g
|
||||
.armor
|
||||
.each_ref()
|
||||
.map(|s| s.as_ref().map(|s| s.item.clone()));
|
||||
m.extra.armor = g.armor.clone();
|
||||
m.extra.crouching = g.sneaking;
|
||||
let from_pose = m.extra.avatar_pose;
|
||||
let to_pose = g.pose();
|
||||
m.extra.avatar_pose = to_pose;
|
||||
if from_pose != to_pose {
|
||||
self.native_entity_pose_change(g.uuid, &g.name, from_pose.name(), to_pose.name());
|
||||
}
|
||||
m.extra.sprinting = g.sprinting;
|
||||
// Entity.DATA_POSE and the shared and living flags the others
|
||||
// get (hub::guest_body): gliding, swimming, Riptide's spin.
|
||||
let body = lan::hub::guest_body(g);
|
||||
m.extra.fall_flying = body.gliding;
|
||||
m.extra.synced_pose = if body.swimming || body.crawling { 3 } else { 0 };
|
||||
m.extra.auto_spin_attack = body.spin_attack;
|
||||
m.extra.previous_velocity = m.velocity;
|
||||
(m.extra.arrow_count, m.extra.stinger_count) = g.stuck;
|
||||
m.extra.shoulder_parrots = g.shoulders;
|
||||
m.velocity = g
|
||||
.motion
|
||||
.as_ref()
|
||||
.map_or(Vec3::new(dx, dy, dz), |motion| motion.velocity);
|
||||
m.tick_avatar_pose(
|
||||
g.motion
|
||||
.as_ref()
|
||||
.is_some_and(|motion| motion.fluids.was_touching_water),
|
||||
);
|
||||
// CrossbowItem.isCharged: the held crossbow's projectiles.
|
||||
m.extra.charged = g
|
||||
.inventory
|
||||
.get(g.selected)
|
||||
.and_then(|s| s.as_ref())
|
||||
.is_some_and(|s| s.data().is_some_and(|d| !d.charged.is_empty()));
|
||||
// ClientInformation.mainHand (HumanoidArm: 0 left, 1 right).
|
||||
m.extra.main_hand_left = g.info.main_hand == 0;
|
||||
m.extra.hidden_model_parts = !g.info.model_customisation & 0x7F;
|
||||
m.extra.spectator = g.game_mode == horizon_client::lan::guest::SPECTATOR;
|
||||
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
|
||||
let visible = || g.effects.list.iter().filter(|e| e.visible);
|
||||
m.extra.effect_particles = visible()
|
||||
.filter_map(|e| super::client_ambient::effect_particle(e.id, e.ambient))
|
||||
.collect();
|
||||
m.extra.effect_ambient = visible().all(|e| e.ambient);
|
||||
// Entity.isInvisible: the invisibility effect hides the body (the
|
||||
// model pass, the name tag rules and `trader::is_invisible` read it).
|
||||
m.extra.effects.retain(|e| e.0 != "minecraft:invisibility");
|
||||
if let Some(e) = g.effects.get("invisibility") {
|
||||
m.extra
|
||||
.effects
|
||||
.push(("minecraft:invisibility", e.duration, e.amplifier));
|
||||
}
|
||||
// Lying in its bed (LivingEntity.getBedOrientation).
|
||||
m.extra.ints[1] = g.bed.map_or(0, |b| self.bed_facing(b));
|
||||
// LivingEntity.updateSwingTime: the arm swing lan_swings started.
|
||||
m.anim.attack_anim_o = m.anim.attack_anim;
|
||||
horizon_client::mobs::ai::tick_swing(m);
|
||||
// LivingEntity.onSyncedDataUpdated(DATA_LIVING_ENTITY_FLAGS): the
|
||||
// item in use and its hand (the arm poses of a bow, a shield...).
|
||||
let using = host.play.get(&g.conn).and_then(|p| p.using());
|
||||
m.extra.using_item = using.map_or(0, |(_, ticks)| ticks as i32 + 1);
|
||||
m.extra.using_off_hand = using.is_some_and(|(slot, _)| slot == OFFHAND_SLOT);
|
||||
// AvatarRenderer.getArmPose: the used item's BLOCK animation (an
|
||||
// item with `blocks_attacks`) raises the arm.
|
||||
m.extra.blocking = using
|
||||
.and_then(|(slot, _)| {
|
||||
if slot == OFFHAND_SLOT {
|
||||
m.extra.off_hand.as_ref()
|
||||
} else {
|
||||
m.extra.main_hand.as_ref()
|
||||
}
|
||||
})
|
||||
.and_then(|i| self.items().get(i))
|
||||
.is_some_and(|i| i.blocks_attacks.is_some());
|
||||
}
|
||||
for (uuid, name, from, to) in changed {
|
||||
self.native_entity_pose_change(uuid, &name, from.name(), to.name());
|
||||
}
|
||||
}
|
||||
|
||||
/// The bed at `head` as a sleeping model lies in it (`ints[1]`,
|
||||
/// `villager::sleep_pose`): its `facing`.
|
||||
pub(super) fn bed_facing(&self, head: [i32; 3]) -> i32 {
|
||||
let state = self.world.block(head[0], head[1], head[2]);
|
||||
let facing = self
|
||||
@@ -2986,82 +2717,6 @@ impl Game<'_> {
|
||||
.map(String::as_str);
|
||||
horizon_client::mobs::villager::bed_facing_code(facing)
|
||||
}
|
||||
|
||||
/// The guests' models for the entity pass.
|
||||
pub(super) fn guest_model_geometry(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<horizon_client::mesher::Vertex>,
|
||||
) {
|
||||
let Some(host) = &self.lan else {
|
||||
return;
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
for (conn, m) in &host.models {
|
||||
let entity_start = out.len();
|
||||
let probe = super::perspective::entity_light_block(
|
||||
m.position,
|
||||
m.extra.crouching,
|
||||
m.eye_height(),
|
||||
);
|
||||
let light = self.world.light(probe[0], probe[1], probe[2]);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
horizon_client::mobs::render::render_with(
|
||||
m,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
&host.hub.guest(*conn).map_or_else(
|
||||
|| self.worn_armor_layers(m),
|
||||
|g| self.stack_armor_layers(&g.armor),
|
||||
),
|
||||
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);
|
||||
}
|
||||
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()) {
|
||||
let team = guest.and_then(|g| self.score.board.players_team(&g.name));
|
||||
self.mark_outline(&out[entity_start..], super::glowing::team_color(team));
|
||||
} else {
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A chat line the host typed, for the guests.
|
||||
pub(super) fn lan_host_chat(&mut self, message: &str) {
|
||||
if let Some(host) = &self.lan {
|
||||
host.hub.chat(&host.host_name, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -3097,9 +2752,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 +2781,19 @@ mod tests {
|
||||
assert_eq!(living_flags(Some((0, 12))), 1);
|
||||
assert_eq!(living_flags(Some((OFFHAND_SLOT, 12))), 3);
|
||||
}
|
||||
|
||||
/// The names a mod matches on for a broken item's slot (a contract).
|
||||
#[test]
|
||||
fn a_broken_items_slot_is_named_for_mods() {
|
||||
use horizon_world::player_inventory::EquipmentSlot::*;
|
||||
let named: Vec<_> = [MainHand, OffHand, Head, Chest, Legs, Feet, Body, Saddle]
|
||||
.map(equipment_slot_name)
|
||||
.to_vec();
|
||||
assert_eq!(
|
||||
named,
|
||||
[
|
||||
"mainhand", "offhand", "head", "chest", "legs", "feet", "body", "saddle"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -308,6 +308,16 @@ impl Game<'_> {
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
f: impl FnOnce(&mut Self) -> R,
|
||||
) -> Option<R> {
|
||||
self.as_guest_with_host(host, conn, |g, _| f(g))
|
||||
}
|
||||
|
||||
/// `as_guest`, passing the active `&LanHost` to `f`.
|
||||
pub(super) fn as_guest_with_host<R>(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
f: impl FnOnce(&mut Self, &LanHost) -> R,
|
||||
) -> Option<R> {
|
||||
let swap = self.enter_guest(host, conn)?;
|
||||
super::dedicated::crash_fixture("guest", || {
|
||||
@@ -315,7 +325,7 @@ impl Game<'_> {
|
||||
});
|
||||
let dimension =
|
||||
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(self.dim.id());
|
||||
let result = horizon_client::block_contact::in_level(dimension, || f(self));
|
||||
let result = horizon_client::block_contact::in_level(dimension, || f(self, host));
|
||||
self.leave_guest(host, swap);
|
||||
Some(result)
|
||||
}
|
||||
@@ -2374,8 +2384,10 @@ impl Game<'_> {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::GameRuleValues(play::GameRuleValues {
|
||||
entries: horizon_client::gamerules::RULES
|
||||
.iter()
|
||||
entries: self
|
||||
.ui
|
||||
.rules
|
||||
.available_rules()
|
||||
.map(|r| play::GameRuleEntry {
|
||||
name: format!("minecraft:{}", r.name),
|
||||
value: self.ui.rules.get(r.name).unwrap_or(r.default).to_string(),
|
||||
@@ -2681,39 +2693,23 @@ impl Game<'_> {
|
||||
/// player: the host's own, or a guest in the world.
|
||||
fn guest_spectate_teleport(&mut self, host: &mut LanHost, gi: usize, uuid: u128) {
|
||||
let own = host.hub.guests[gi].conn;
|
||||
let host_player = self
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|_| self.options.dedicated.is_none() && uuid == host.host_uuid)
|
||||
.map(|p| {
|
||||
(
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
self.dim,
|
||||
)
|
||||
});
|
||||
let target = host_player.or_else(|| {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.placed && g.conn != own && g.uuid == uuid)
|
||||
.map(|g| (g.position, g.yaw, g.pitch, Dim::from_id(&g.dimension)))
|
||||
});
|
||||
let target = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.placed && g.conn != own && g.uuid == uuid)
|
||||
.map(|g| (g.position, g.yaw, g.pitch, Dim::from_id(&g.dimension)));
|
||||
let Some((at, yaw, pitch, dim)) = target else {
|
||||
return;
|
||||
};
|
||||
if Dim::from_id(&host.hub.guests[gi].dimension) != dim {
|
||||
// Only a dedicated server keeps its players in different levels.
|
||||
if self.options.dedicated.is_some() {
|
||||
self.level_teleports
|
||||
.push(super::server_travel::LevelTeleport {
|
||||
conn: own,
|
||||
dim,
|
||||
at,
|
||||
rotation: Some([yaw, pitch]),
|
||||
});
|
||||
}
|
||||
self.level_teleports
|
||||
.push(super::server_travel::LevelTeleport {
|
||||
conn: own,
|
||||
dim,
|
||||
at,
|
||||
rotation: Some([yaw, pitch]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
@@ -2891,9 +2887,6 @@ impl Game<'_> {
|
||||
|
||||
pub(super) fn guest_target_box(&self, host: &LanHost, gi: usize, id: i32) -> Option<Aabb> {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
if id == host.host_entity {
|
||||
return self.player.as_ref().map(|p| p.bounds());
|
||||
}
|
||||
if let Some(g) = host
|
||||
.hub
|
||||
.guests
|
||||
@@ -2939,12 +2932,7 @@ impl Game<'_> {
|
||||
}
|
||||
// Native mods, where Player.attack starts: a canceled blow does
|
||||
// nothing (game/native_hooks.rs).
|
||||
let aimed = if id == host.host_entity {
|
||||
self.player.as_ref().map(|p| {
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
("minecraft:player".to_owned(), at)
|
||||
})
|
||||
} else if let Some(g) = host
|
||||
let aimed = if let Some(g) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
@@ -2972,8 +2960,7 @@ impl Game<'_> {
|
||||
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
|
||||
&& self.pvp_land(host, victim, &strike)
|
||||
{
|
||||
let host_on_ground = self.player.as_ref().map(|p| p.ground.on_ground);
|
||||
let on_ground = Self::pvp_victim_on_ground(host, victim, host_on_ground);
|
||||
let on_ground = Self::pvp_victim_on_ground(host, victim);
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.weapon_hit_player(on_ground);
|
||||
g.pvp_exhaustion();
|
||||
@@ -2993,13 +2980,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);
|
||||
}
|
||||
@@ -3105,12 +3092,7 @@ impl Game<'_> {
|
||||
// ListPlayersCommand needs no permission and lists every player.
|
||||
let words: Vec<&str> = command.trim_start_matches('/').split(' ').collect();
|
||||
if words[0] == "list" && (words.len() == 1 || words[1..] == ["uuids"]) {
|
||||
// A single-player owner is a placed guest already: no host entry.
|
||||
let me = (self.options.dedicated.is_none()
|
||||
&& self.player.is_some()
|
||||
&& host.hub.singleplayer_owner.is_none())
|
||||
.then_some((host.host_uuid, host.host_name.as_str()));
|
||||
let content = host.hub.list_reply(me, words.len() == 2);
|
||||
let content = host.hub.list_reply(words.len() == 2);
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
@@ -3236,14 +3218,9 @@ impl Game<'_> {
|
||||
/// operator gets them, and the console logs them as the server's own
|
||||
/// system messages (MinecraftServer.sendSystemMessage).
|
||||
pub(super) fn send_admin_feedback(&mut self, host: &mut LanHost, source: Option<u32>) {
|
||||
// PlayerList.isOp for the host of a world open to LAN: the owner when
|
||||
// the world allows commands, everyone when the LAN does.
|
||||
let host_ops =
|
||||
!host.hub.config.dedicated && (self.allow_commands() || host.hub.config.guest_commands);
|
||||
let host_op = source.is_some() && host_ops;
|
||||
// server.toml's `admin_feedback_to_ops` off: the console's log only.
|
||||
let to_ops = host.hub.admin_feedback_to_ops;
|
||||
for (line, ops, console, no_player) in std::mem::take(&mut self.admin_feedback) {
|
||||
for (line, ops, console) in std::mem::take(&mut self.admin_feedback) {
|
||||
if ops && to_ops {
|
||||
let content = horizon_client::lan::data::component_nbt(&line.component());
|
||||
for g in host
|
||||
@@ -3257,10 +3234,6 @@ impl Game<'_> {
|
||||
overlay: false,
|
||||
}));
|
||||
}
|
||||
// A line no player's command made (a command block's) reaches the host too.
|
||||
if host_op || (no_player && host_ops) {
|
||||
self.system_message(line.clone());
|
||||
}
|
||||
}
|
||||
if console {
|
||||
super::dedicated::log("INFO", &format!("System chat: {}", line.plain()));
|
||||
@@ -3300,7 +3273,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.
|
||||
@@ -3483,7 +3456,6 @@ impl Game<'_> {
|
||||
let clock_rate = self.network_clock_rate();
|
||||
let ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
host: None,
|
||||
entities: &[],
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
@@ -4653,11 +4625,9 @@ impl Game<'_> {
|
||||
g.trigger_thrown_picked_up_by_player(&item.stack, thrower);
|
||||
});
|
||||
}
|
||||
self.guest_pickup(host, id, item);
|
||||
taken.push((item_id, before));
|
||||
}
|
||||
Some(rest) if rest.count < before => {
|
||||
self.guest_pickup(host, id, self.dropped[i].clone());
|
||||
self.dropped[i].stack = rest;
|
||||
if let Some(thrower) = thrower {
|
||||
let left = self.dropped[i].stack.clone();
|
||||
@@ -4741,13 +4711,6 @@ impl Game<'_> {
|
||||
self.native_player_pickup_arrow(player, &name, shot.id, &arrow.pickup_item.item);
|
||||
}
|
||||
host.hub.take_item(shot.id as i32, id, 1, at);
|
||||
if let Some(target) = super::pickups::guest_target(host, id as u32) {
|
||||
self.add_pickup_toward(
|
||||
super::pickups::Taken::Arrow(Box::new(shot)),
|
||||
super::pickups::Collector::Guest(id as u32),
|
||||
target,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4844,14 +4807,6 @@ impl Game<'_> {
|
||||
pitch,
|
||||
);
|
||||
let value = orb.value;
|
||||
if let Some(target) = super::pickups::guest_target(host, id as u32) {
|
||||
use super::pickups::{Collector, Taken};
|
||||
self.add_pickup_toward(
|
||||
Taken::Orb(Box::new(orb)),
|
||||
Collector::Guest(id as u32),
|
||||
target,
|
||||
);
|
||||
}
|
||||
self.as_guest(host, conn, |game| {
|
||||
let rest = game.mending(value);
|
||||
if rest > 0 {
|
||||
@@ -4867,23 +4822,6 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
|
||||
/// The host's own `ItemPickupParticle` toward the guest's model.
|
||||
fn guest_pickup(
|
||||
&mut self,
|
||||
host: &LanHost,
|
||||
id: i32,
|
||||
item: horizon_client::entities::ItemEntity,
|
||||
) {
|
||||
use super::pickups::{Collector, Taken, guest_target};
|
||||
if let Some(target) = guest_target(host, id as u32) {
|
||||
self.add_pickup_toward(
|
||||
Taken::Item(Box::new(item)),
|
||||
Collector::Guest(id as u32),
|
||||
target,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `level.playSound(player, ...)` of a guest's use it predicted (`remote::hand_operated`
|
||||
/// blocks only, as the guest decides): the others hear it now, the guest already did.
|
||||
fn guest_use_sounds(&mut self, host: &mut LanHost, conn: u32, logged: usize, heard: usize) {
|
||||
@@ -5027,9 +4965,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tick's arm swings for the players around (the guests' to everyone
|
||||
/// else, the host's to the guests), and `LivingEntity.updateSwingTime`
|
||||
/// for the guests' bodies here.
|
||||
/// The tick's arm swings for the players around (each guest's to everyone
|
||||
/// else), and `LivingEntity.updateSwingTime` for the guests' bodies here.
|
||||
pub(super) fn lan_swings(&mut self, host: &mut LanHost) {
|
||||
let conns: Vec<u32> = host.play.keys().copied().collect();
|
||||
for conn in conns {
|
||||
@@ -5042,55 +4979,9 @@ impl Game<'_> {
|
||||
if let (true, true, Some(c)) = (swung, here, play.swing.current) {
|
||||
let packet = swing_packet(id, c.hand, c.animation);
|
||||
host.hub.broadcast_near(at, 128.0, &packet, Some(conn));
|
||||
if let Some(m) = host.models.get_mut(&conn) {
|
||||
// The duration already counts the guest's haste or fatigue.
|
||||
m.swing_hand(c.hand == Hand::Off, c.duration_ticks);
|
||||
}
|
||||
}
|
||||
play.swing.tick();
|
||||
}
|
||||
self.lan_host_crack(host);
|
||||
if self.swings_started != host.host_swings {
|
||||
host.host_swings = self.swings_started;
|
||||
if let (Some(p), Some(c)) = (self.player.as_ref(), self.swing.current) {
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let packet = swing_packet(host.host_entity, c.hand, c.animation);
|
||||
host.hub.broadcast_near(at, 128.0, &packet, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The host player's crack, for the guests (`ServerLevel.destroyBlockProgress` from the
|
||||
/// host's mining): each new stage, and -1 once it stops or breaks the block.
|
||||
fn lan_host_crack(&mut self, host: &mut LanHost) {
|
||||
let now = self
|
||||
.mining
|
||||
.as_ref()
|
||||
.and_then(|m| (m.progress > 0.0).then(|| (m.pos, ((m.progress * 10.0) as i32).min(9))));
|
||||
if now == host.host_crack {
|
||||
return;
|
||||
}
|
||||
let packet = |pos: [i32; 3], progress: u8| {
|
||||
C::BlockDestruction(play::BlockDestruction {
|
||||
id: host.host_entity,
|
||||
pos,
|
||||
progress,
|
||||
})
|
||||
};
|
||||
let near = |pos: [i32; 3]| [f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])];
|
||||
if let Some((pos, _)) = host.host_crack
|
||||
&& now.is_none_or(|(p, _)| p != pos)
|
||||
{
|
||||
host.hub
|
||||
.broadcast_near(near(pos), 64.0, &packet(pos, 255), None);
|
||||
}
|
||||
if let Some((pos, stage)) = now {
|
||||
host.hub
|
||||
.broadcast_near(near(pos), 64.0, &packet(pos, stage as u8), None);
|
||||
}
|
||||
host.host_crack = now;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,13 +43,6 @@ impl Game<'_> {
|
||||
{
|
||||
return (!g.dead).then_some((g.position, [g.yaw, g.pitch]));
|
||||
}
|
||||
if id == host.host_entity {
|
||||
let p = self.player.as_ref()?;
|
||||
return Some((
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
[self.yaw, self.pitch],
|
||||
));
|
||||
}
|
||||
if let Some(special) = self.camera_extra(id) {
|
||||
return Some((special.position, special.rotation));
|
||||
}
|
||||
@@ -102,9 +95,6 @@ impl Game<'_> {
|
||||
if let Some(g) = host.hub.guests.iter().find(|g| g.entity_id == id) {
|
||||
return !g.spectator() && !g.dead;
|
||||
}
|
||||
if id == host.host_entity {
|
||||
return self.player.is_some() && !self.spectator();
|
||||
}
|
||||
if self.camera_extra(id).is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ impl Game<'_> {
|
||||
/// CommandDispatcher.parse with the guest's permission level.
|
||||
fn parse_as(&mut self, permission: u8, command: &str) -> ParseResults {
|
||||
let level = std::mem::replace(&mut self.ui.registries.permission, permission);
|
||||
let parse = vanilla::game_dispatcher().parse(command, &self.ui.registries);
|
||||
let parse = vanilla::game_dispatcher_with_features(&self.level.enabled_features)
|
||||
.parse(command, &self.ui.registries);
|
||||
self.ui.registries.permission = level;
|
||||
parse
|
||||
}
|
||||
@@ -33,8 +34,9 @@ impl Game<'_> {
|
||||
permission: u8,
|
||||
command: &str,
|
||||
) -> Vec<(String, String)> {
|
||||
self.parse_as(permission, command)
|
||||
.signable_arguments(vanilla::game_dispatcher())
|
||||
self.parse_as(permission, command).signable_arguments(
|
||||
vanilla::game_dispatcher_with_features(&self.level.enabled_features),
|
||||
)
|
||||
}
|
||||
|
||||
/// ServerGamePacketListenerImpl.detectRateSpam: a chat message or command adds 20 to the
|
||||
|
||||
@@ -40,6 +40,7 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
|
||||
head_yaw: 0.0,
|
||||
on_ground,
|
||||
velocity: [0.0; 3],
|
||||
minecart_steps: None,
|
||||
data: 0,
|
||||
meta: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
@@ -52,6 +53,7 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
|
||||
leash: None,
|
||||
spectator: false,
|
||||
watching: false,
|
||||
hidden: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,14 +131,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Who rides what (`Entity.getPassengers`): mobs on mobs, and the
|
||||
/// host in its boat or minecart.
|
||||
pub(super) fn lan_riding(
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
guests: &[(u32, i32)],
|
||||
riding: &mut Riding,
|
||||
) {
|
||||
/// Who rides what (`Entity.getPassengers`): the players in their vehicles and
|
||||
/// mobs on mobs.
|
||||
pub(super) fn lan_riding(&self, guests: &[(u32, i32)], riding: &mut Riding) {
|
||||
riding.clear();
|
||||
// The server's players in their boats and minecarts.
|
||||
for &(vehicle, guest) in guests {
|
||||
@@ -147,18 +144,15 @@ impl Game<'_> {
|
||||
riding.add(vehicle as i32, m.id as i32);
|
||||
}
|
||||
}
|
||||
if let (Some(host), Some(vehicle)) = (host_entity, self.riding) {
|
||||
riding.add(vehicle as i32, host);
|
||||
}
|
||||
}
|
||||
|
||||
/// Boats, minecarts, armor stands, item frames, paintings and the
|
||||
/// entities kept unsimulated, as guests track them.
|
||||
pub(super) fn lan_object_states(
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
&mut self,
|
||||
hub: &mut Hub,
|
||||
out: &mut States,
|
||||
drain_minecart_steps: bool,
|
||||
) {
|
||||
let guests: Vec<(u128, i32)> = hub
|
||||
.guests
|
||||
@@ -170,7 +164,7 @@ impl Game<'_> {
|
||||
let p = b.position;
|
||||
let mut e = state(b.id, &b.entity_type(), [p.x, p.y, p.z], b.yaw, b.on_ground);
|
||||
// Leashable.setLeashedTo / dropLeash: who holds the boat's lead.
|
||||
e.leash = self.boat_leash_link(b, host_entity, &guests);
|
||||
e.leash = self.boat_leash_link(b, &guests);
|
||||
e.velocity = [b.velocity.x, b.velocity.y, b.velocity.z];
|
||||
e.meta = vehicle_meta(b.hurt_time, b.hurt_dir, b.damage);
|
||||
// AbstractBoat.DATA_ID_PADDLE_LEFT and DATA_ID_PADDLE_RIGHT.
|
||||
@@ -193,10 +187,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.
|
||||
@@ -396,7 +401,7 @@ impl Game<'_> {
|
||||
));
|
||||
out.push(e);
|
||||
}
|
||||
self.lan_projectile_states(host_entity, hub, out);
|
||||
self.lan_projectile_states(hub, out);
|
||||
for o in &self.others {
|
||||
let p = o.position;
|
||||
let mut e = state(o.id, &o.kind, [p.x, p.y, p.z], o.yaw, false);
|
||||
@@ -536,11 +541,10 @@ impl Game<'_> {
|
||||
|
||||
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
|
||||
/// end crystals, as guests track them.
|
||||
fn lan_projectile_states(&self, host_entity: Option<i32>, hub: &mut Hub, out: &mut States) {
|
||||
fn lan_projectile_states(&self, hub: &mut Hub, out: &mut States) {
|
||||
let owner = |who: Option<horizon_client::mobs::Who>| match who {
|
||||
Some(horizon_client::mobs::Who::Mob(id)) => id as i32,
|
||||
Some(horizon_client::mobs::Who::Player) => host_entity.unwrap_or(0),
|
||||
None => 0,
|
||||
Some(horizon_client::mobs::Who::Player) | None => 0,
|
||||
};
|
||||
let at = |p: horizon_core::geometry::Vec3| [p.x, p.y, p.z];
|
||||
for f in &self.fangs.list {
|
||||
@@ -708,7 +712,7 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|g| g.conn == conn)
|
||||
.map(|g| g.entity_id),
|
||||
None => host_entity,
|
||||
None => None,
|
||||
};
|
||||
if r.attached
|
||||
&& let Some(player) = boosted
|
||||
|
||||
@@ -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,114 @@ 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,
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
//! Players hitting players in an open world (`Player.attack` on a player):
|
||||
//! the host hits a guest, a guest hits the host or another guest. The hit
|
||||
//! Players hitting players on a server (`Player.attack` on a player). The hit
|
||||
//! is worked out as the attacker (weapon, attack strength, critical hit,
|
||||
//! enchantments, a mace's smash) and lands on the victim like any player
|
||||
//! attack (blocking, armour, knockback), when the `pvp` game rule allows it
|
||||
//! (`ServerPlayer.canHarmPlayer`, `isPvpAllowed`). A player's arrow on
|
||||
//! another player lands the same way.
|
||||
use super::{Game, lan::LanHost, lan_mobs::guest_bounds, mace::Spared, ray_box};
|
||||
use super::{Game, lan::LanHost, lan_mobs::guest_bounds, mace::Spared};
|
||||
|
||||
use horizon_client::{
|
||||
Result,
|
||||
@@ -30,19 +29,16 @@ pub(super) fn meets_players(kind: &ProjectileKind) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Who a player's attack lands on.
|
||||
/// Who a player's attack lands on: a player by connection.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum Victim {
|
||||
Host,
|
||||
Guest(u32),
|
||||
}
|
||||
|
||||
impl Victim {
|
||||
fn conn(self) -> Option<u32> {
|
||||
match self {
|
||||
Victim::Host => None,
|
||||
Victim::Guest(conn) => Some(conn),
|
||||
}
|
||||
let Victim::Guest(conn) = self;
|
||||
Some(conn)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,53 +102,9 @@ impl Strike {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The guest on the host's crosshair and how far, within reach.
|
||||
pub(super) fn guest_hit(&self) -> Option<(u32, f64)> {
|
||||
let host = self.lan.as_ref()?;
|
||||
let (eye, dir) = self.aim()?;
|
||||
let reach = self.entity_reach;
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
|
||||
.filter_map(|g| Some((g.conn, ray_box(eye, dir, reach, guest_bounds(g))?)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
}
|
||||
|
||||
/// The host's attack on the guest under the crosshair, when no mob or
|
||||
/// block is nearer.
|
||||
pub(super) fn attack_guest(&mut self) -> bool {
|
||||
let Some((conn, d)) = self.guest_hit() else {
|
||||
return false;
|
||||
};
|
||||
if self.mob_hit().is_some_and(|(_, m)| m < d) || !self.before_block(d) {
|
||||
return false;
|
||||
}
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return false;
|
||||
};
|
||||
if let Some(strike) = self.pvp_strike()
|
||||
&& self.pvp_land(&mut host, Victim::Guest(conn), &strike)
|
||||
{
|
||||
self.weapon_hit_player(Self::pvp_victim_on_ground(&host, Victim::Guest(conn), None));
|
||||
self.pvp_exhaustion();
|
||||
if strike.stops_sprint() {
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
}
|
||||
self.lan = Some(host);
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether the player a hit landed on stands on the ground (the host's
|
||||
/// `ground` is passed in as `host_on_ground` when the host is the victim).
|
||||
pub(super) fn pvp_victim_on_ground(
|
||||
host: &LanHost,
|
||||
victim: Victim,
|
||||
host_on_ground: Option<bool>,
|
||||
) -> bool {
|
||||
/// Whether the player a hit landed on stands on the ground.
|
||||
pub(super) fn pvp_victim_on_ground(host: &LanHost, victim: Victim) -> bool {
|
||||
match victim {
|
||||
Victim::Host => host_on_ground.unwrap_or(true),
|
||||
Victim::Guest(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
@@ -163,9 +115,6 @@ impl Game<'_> {
|
||||
|
||||
/// The player `id` that guest `conn` attacks, if it is one.
|
||||
pub(super) fn pvp_victim(host: &LanHost, conn: u32, id: i32) -> Option<Victim> {
|
||||
if id == host.host_entity {
|
||||
return Some(Victim::Host);
|
||||
}
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
@@ -229,8 +178,7 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The player a player's projectile hit, when it hit one: the host for a
|
||||
/// guest's shot, or a guest.
|
||||
/// The player a player's projectile hit, when it hit one.
|
||||
pub(super) fn projectile_pvp_victim(&self, p: &Projectile, hit: Hit) -> Option<Victim> {
|
||||
if p.owner != Some(Who::Player) || !meets_players(&p.kind) {
|
||||
return None;
|
||||
@@ -239,7 +187,7 @@ impl Game<'_> {
|
||||
return None;
|
||||
};
|
||||
match who {
|
||||
Who::Player => p.guest.map(|_| Victim::Host),
|
||||
Who::Player => None,
|
||||
Who::Mob(id) => self
|
||||
.lan
|
||||
.as_ref()?
|
||||
@@ -273,7 +221,6 @@ impl Game<'_> {
|
||||
None => self.player_team().cloned(),
|
||||
};
|
||||
let target = match victim {
|
||||
Victim::Host => self.player_team().cloned(),
|
||||
Victim::Guest(conn) => host.hub.guest(conn).and_then(|g| team_of(self, &g.name)),
|
||||
};
|
||||
let refused = !(self.ui.rules.bool("pvp")
|
||||
@@ -284,16 +231,6 @@ impl Game<'_> {
|
||||
};
|
||||
let shooter = self.player_attacker(&host, p.guest);
|
||||
let result = match victim {
|
||||
Victim::Host => {
|
||||
self.lan = Some(host);
|
||||
self.combat.refuse_player_hit = refused;
|
||||
self.combat.projectile_attacker = shooter;
|
||||
let r = self.projectile_hit(p, on_player);
|
||||
self.combat.refuse_player_hit = false;
|
||||
// A refused or creative hit returns before hurt_player_from takes it.
|
||||
self.combat.projectile_attacker = None;
|
||||
return r;
|
||||
}
|
||||
Victim::Guest(conn) => self.guest_hit_by(&mut host, conn, |g| {
|
||||
g.combat.refuse_player_hit = refused;
|
||||
g.combat.projectile_attacker = shooter;
|
||||
@@ -313,10 +250,7 @@ impl Game<'_> {
|
||||
let g = host.hub.guest(conn)?;
|
||||
Some((g.entity_id as u32, self.player_text(&g.name, g.uuid)))
|
||||
}
|
||||
None => self.player.is_some().then(|| {
|
||||
let name = self.player_text(&host.host_name, host.host_uuid);
|
||||
(host.host_entity as u32, name)
|
||||
}),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +260,6 @@ impl Game<'_> {
|
||||
// ServerPlayer.canHarmPlayer: the pvp game rule, then the victim's
|
||||
// team (Player.canHarmPlayer: allies only with friendly fire on).
|
||||
let team = match victim {
|
||||
Victim::Host => self.player_team(),
|
||||
Victim::Guest(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
@@ -346,16 +279,11 @@ impl Game<'_> {
|
||||
// CombatTracker: the attacking player is the death message's killer.
|
||||
// Without it the message is built with one argument and a template
|
||||
// that names a killer ("%1$s was slain by %2$s") shows as written.
|
||||
let attacker_note = match s.attacker {
|
||||
Some(conn) => host
|
||||
.hub
|
||||
let attacker_note = s.attacker.and_then(|conn| {
|
||||
host.hub
|
||||
.guest(conn)
|
||||
.map(|a| (a.entity_id, self.player_text(&a.name, a.uuid))),
|
||||
None => Some((
|
||||
host.host_entity,
|
||||
self.player_text(&host.host_name, host.host_uuid),
|
||||
)),
|
||||
};
|
||||
.map(|a| (a.entity_id, self.player_text(&a.name, a.uuid)))
|
||||
});
|
||||
let land = |g: &mut Self| {
|
||||
g.combat.player_attacker = Some((s.from, s.disable_blocking));
|
||||
if let Some((id, name)) = attacker_note.clone() {
|
||||
@@ -373,7 +301,6 @@ impl Game<'_> {
|
||||
};
|
||||
let (hurt, thorns) = if self.ui.rules.bool("pvp") && harm {
|
||||
match victim {
|
||||
Victim::Host => land(self),
|
||||
Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or_default(),
|
||||
}
|
||||
} else {
|
||||
@@ -384,10 +311,6 @@ impl Game<'_> {
|
||||
let died = match victim {
|
||||
// A totem that fired is applied on the next tick_totem, so the
|
||||
// victim is still at zero health but is not dying.
|
||||
Victim::Host => self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used),
|
||||
Victim::Guest(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
@@ -405,7 +328,6 @@ impl Game<'_> {
|
||||
}
|
||||
if hurt && (s.crit || s.magic) {
|
||||
let target = match victim {
|
||||
Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)),
|
||||
Victim::Guest(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
@@ -454,7 +376,6 @@ impl Game<'_> {
|
||||
if let Some(at) = s.sweep.filter(|_| hurt) {
|
||||
// The sweep around the player that was hit, as its attacker.
|
||||
let (area, id) = match victim {
|
||||
Victim::Host => (self.player.as_ref().map(|p| p.bounds()), host.host_entity),
|
||||
Victim::Guest(conn) => {
|
||||
let g = host.hub.guest(conn);
|
||||
(g.map(guest_bounds), g.map_or(-1, |g| g.entity_id))
|
||||
@@ -531,16 +452,6 @@ impl Game<'_> {
|
||||
pub(super) fn sweep_players(&mut self, host: &mut LanHost, sweep: &Sweep) {
|
||||
let from = Vec3::new(sweep.from[0], sweep.from[1], sweep.from[2]);
|
||||
let mut victims = Vec::new();
|
||||
if sweep.attacker.is_some()
|
||||
&& self.options.dedicated.is_none()
|
||||
&& !self.spectator()
|
||||
&& let Some(p) = self.player.as_ref()
|
||||
&& !p.health.dead_or_dying()
|
||||
&& p.bounds().intersects(sweep.hit_box)
|
||||
&& p.position.distance_squared(from) < 9.0
|
||||
{
|
||||
victims.push((Victim::Host, host.host_entity, self.player_team().cloned()));
|
||||
}
|
||||
for g in &host.hub.guests {
|
||||
if host.hub.here(g)
|
||||
&& g.loaded
|
||||
@@ -572,12 +483,8 @@ impl Game<'_> {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match victim {
|
||||
Victim::Host => land(self),
|
||||
Victim::Guest(conn) => {
|
||||
self.guest_struck_by(host, conn, land);
|
||||
}
|
||||
}
|
||||
let Victim::Guest(conn) = victim;
|
||||
self.guest_struck_by(host, conn, land);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -3,16 +3,9 @@
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
lan::{
|
||||
hub::HostState,
|
||||
waypoints::{self, HIDING_HEADS, Icon, PLAYER_RANGE},
|
||||
},
|
||||
lan::waypoints::{self, HIDING_HEADS, Icon, PLAYER_RANGE},
|
||||
};
|
||||
|
||||
/// The host player's connection id in the waypoint managers: its packets
|
||||
/// go to this client's locator bar rather than to a guest.
|
||||
const HOST_CONN: u32 = u32::MAX;
|
||||
|
||||
/// `waypoint_transmit_range`: hidden by crouching, invisibility or a head worn.
|
||||
fn transmit_range(crouching: bool, invisible: bool, head: Option<&ItemStack>) -> f64 {
|
||||
let hiding_head = head.is_some_and(|s| HIDING_HEADS.contains(&&*s.item));
|
||||
@@ -35,38 +28,8 @@ impl Game<'_> {
|
||||
/// level: a player who changes level leaves the old one's manager
|
||||
/// (`ServerLevel.removePlayerImmediately`: untracked for everyone there)
|
||||
/// and joins the new one's (`addDuringTeleport`).
|
||||
pub(super) fn lan_update_waypoints(&mut self, host: &mut LanHost, state: Option<&HostState>) {
|
||||
pub(super) fn lan_update_waypoints(&mut self, host: &mut LanHost) {
|
||||
let mut players: Vec<(&str, waypoints::Player)> = Vec::new();
|
||||
if let Some(h) = state {
|
||||
let icon = self
|
||||
.cmds
|
||||
.waypoint_icons
|
||||
.get(&super::commands::Target::Player)
|
||||
.map(|i| Icon {
|
||||
style: i.style.clone(),
|
||||
color: i.color,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
players.push((
|
||||
self.dim.id(),
|
||||
waypoints::Player {
|
||||
uuid: h.uuid,
|
||||
conn: Some(HOST_CONN),
|
||||
position: h.position,
|
||||
spectator: self.spectator(),
|
||||
transmit_range: transmit_range(
|
||||
h.sneaking,
|
||||
self.potions.effects.get("invisibility").is_some(),
|
||||
h.equipment[5].as_ref(),
|
||||
),
|
||||
receive_range: PLAYER_RANGE,
|
||||
first_tick: false,
|
||||
view_distance: host.hub.config.view_distance,
|
||||
team: self.waypoint_team(&h.name),
|
||||
icon,
|
||||
},
|
||||
));
|
||||
}
|
||||
let mut respawned = Vec::new();
|
||||
for g in host.hub.guests.iter().filter(|g| g.placed) {
|
||||
// PlayerList.respawn makes a new ServerPlayer.
|
||||
@@ -127,12 +90,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
for (conn, packet) in out {
|
||||
if conn == HOST_CONN {
|
||||
// The host's own connection: its locator bar.
|
||||
if let horizon_protocol::packets::play::Clientbound::TrackedWaypoint(w) = &packet {
|
||||
self.handle_waypoint(w);
|
||||
}
|
||||
} else if let Some(g) = host.hub.guest(conn) {
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
//! A server (the dedicated server and single player's server thread) keeps
|
||||
//! the other dimensions' levels here and ticks each one with players or forced
|
||||
//! chunks by swapping it into place (`enter_level`), the way `as_guest` swaps a player
|
||||
//! in, so the tick code runs unchanged, one level at a time. The in-frame
|
||||
//! game holds one level, which follows its player (`enter_dimension`).
|
||||
//! in, so the tick code runs unchanged, one level at a time.
|
||||
//!
|
||||
//! Shared by every level, as vanilla's derived levels share the
|
||||
//! Overworld's level data: the game time, the day time and the weather, the
|
||||
@@ -309,7 +308,7 @@ impl Game<'_> {
|
||||
mut host: Option<&mut LanHost>,
|
||||
target: Dim,
|
||||
) -> Result<()> {
|
||||
// The in-frame game keeps its one level (`enter_dimension`).
|
||||
// A client holds no levels.
|
||||
if self.options.execution_role.is_client() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -425,6 +424,7 @@ impl Game<'_> {
|
||||
let observe_level =
|
||||
running.is_some() && (self.serving.is_some() || self.options.dedicated.is_some());
|
||||
let dims = self.levels_to_tick();
|
||||
let order = dims.clone();
|
||||
let n = dims.len();
|
||||
let mut result = Ok(());
|
||||
for (k, dim) in dims.into_iter().enumerate() {
|
||||
@@ -467,6 +467,22 @@ impl Game<'_> {
|
||||
self.native_level_tick_post(dim.id());
|
||||
}
|
||||
self.publish_server_debug(dim);
|
||||
// An entity that crossed into a level that already ticked this
|
||||
// server tick is in it when the tick ends (`addDuringTeleport`).
|
||||
if running == Some(true) {
|
||||
let mut moved = false;
|
||||
for &earlier in &order[..k] {
|
||||
if !self.arrival_chunks(earlier).is_empty()
|
||||
&& self.enter_level(None, earlier).is_ok()
|
||||
{
|
||||
self.land_portal_arrivals();
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
if moved {
|
||||
self.enter_level(None, dim)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.level_pass = Pass::default();
|
||||
if result.is_err() {
|
||||
|
||||
@@ -15,7 +15,6 @@ use horizon_core::{
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
/// `MaceItem.SMASH_ATTACK_FALL_THRESHOLD`.
|
||||
const FALL_THRESHOLD: f64 = 1.5;
|
||||
@@ -585,31 +584,6 @@ impl Game<'_> {
|
||||
};
|
||||
self.sweep_other_players(sweep);
|
||||
}
|
||||
/// `ServerPlayer.checkFallDamage` after a smash on a grounded target:
|
||||
/// the landing block's particles, 50 for every block fallen (200 at most).
|
||||
pub(super) fn smash_landing_particles(
|
||||
&mut self,
|
||||
state: BlockStateId,
|
||||
pos: BlockPos,
|
||||
fall_distance: f64,
|
||||
) {
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, [pos.x, pos.y, pos.z]) else {
|
||||
return;
|
||||
};
|
||||
let count = (50.0 * fall_distance).clamp(0.0, 200.0) as u32;
|
||||
let spread = f64::from(0.3f32);
|
||||
self.particles.send_particles(
|
||||
&ParticleOptions::block(ParticleType::Block, sprite, tint),
|
||||
[
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y) + 1.0,
|
||||
f64::from(pos.z) + 0.5,
|
||||
],
|
||||
[spread; 3],
|
||||
f64::from(0.15f32),
|
||||
count,
|
||||
);
|
||||
}
|
||||
/// Level event 2013 (`ParticleUtils.spawnSmashAttackParticles`): dust
|
||||
/// pillars of the block at `pos`, a cluster over it and a ring 3.5 out.
|
||||
pub(super) fn smash_dust(&mut self, pos: BlockPos, count: i32) {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -488,7 +538,8 @@ impl Game<'_> {
|
||||
boxes.retain(|b| b.intersects(near));
|
||||
boxes
|
||||
}
|
||||
/// Entity.move for a cart: collisions zero the blocked components.
|
||||
/// Entity.move for a cart: collisions zero the blocked components, the world border's walls
|
||||
/// among them (`CollisionGetter.borderCollision`, asked for every entity).
|
||||
fn move_cart(&self, c: &mut Minecart, delta: Vec3) {
|
||||
let scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
@@ -499,7 +550,7 @@ impl Game<'_> {
|
||||
let mut scene = horizon_client::collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &entities,
|
||||
border: None,
|
||||
border: Some((self.border.geometry(), [c.position.x, c.position.z])),
|
||||
};
|
||||
if let Ok(moved) = scene.resolve(c.bounds(), delta, 0.0, c.on_ground) {
|
||||
c.position = Vec3::new(
|
||||
@@ -539,9 +590,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 +765,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 +806,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 +917,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 +956,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 +967,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 +988,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 +1039,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 +1057,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 +1074,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 +1087,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 +1115,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 +1126,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 +1156,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 +1180,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 +2223,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 +2253,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 +2329,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 +2368,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 +2390,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 +2619,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]
|
||||
|
||||
Loaded 100 of 352 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user