Merge main and preserve the upstream mimalloc allocator

Resolve the allocator and lockfile conflicts against 8f376a471. Keep
main's horizon_threads allocator, startup configuration, and dependency
settings unchanged. Retain windows-mimalloc as a compatibility no-op and
keep the independent, opt-in Segment Heap resource experiment.

Co-Authored-By: Codex (GPT-6) <[email protected]>
This commit is contained in:
Сидоренко Егор АлекчеевичandCodex committed 2026-10-30 19:50:35 +03:00
commit ca27b2de97
339 files changed
+19656 -3423

No files matched your search

+3 -3
View File
@@ -233,9 +233,9 @@ jobs:
# before the Nightly-only switch keep (they don't read Nightlies).
gh release create nightly --prerelease --latest=false --target "$SHA" \
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
"dist/horizon-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS (horizon-macos-arm64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
Generated
+9 -1
View File
@@ -561,6 +561,12 @@ dependencies = [
"typenum",
]
[[package]]
name = "cty"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35"
[[package]]
name = "cursor-icon"
version = "1.2.0"
@@ -1126,7 +1132,6 @@ dependencies = [
"horizon-threads",
"horizon-world",
"log",
"mimalloc",
"miniz_oxide 0.8.9",
"naga",
"open",
@@ -1413,6 +1418,8 @@ name = "horizon-threads"
version = "0.1.0"
dependencies = [
"libc",
"libmimalloc-sys",
"mimalloc",
"windows-sys 0.61.2",
]
@@ -1795,6 +1802,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
dependencies = [
"cc",
"cty",
]
[[package]]
+2 -2
View File
@@ -47,7 +47,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation (vanilla's heights and biomes for now).
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation, taller levels included (vanilla's biomes for now).
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** content mods add blocks, items and data, and native mods change the game's own Rust code, built by the launcher on your copy, with no Rust to install. Horizon's own (Zoom, FPS Counter, Keystrokes, Minimap, Recipes and Coordinates HUD) are native mods. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
@@ -166,7 +166,7 @@ lists every method of vanilla 26.3 next to the Rust functions that port it, with
### Known issues
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; item tooltips lack attribute lines.
## Licence
+4 -1
View File
@@ -237,7 +237,7 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
_ => None,
};
}
// HoverEvent.ShowText and ShowItem.
// HoverEvent.ShowText, ShowItem and ShowEntity.
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
match e.get("action").and_then(Value::as_str) {
Some("show_text") => {
@@ -248,6 +248,9 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
Some("show_item") if e.get("id").is_some_and(Value::is_string) => {
style.hover_item = Some(Box::new(Value::Object(e.clone())));
}
Some("show_entity") if e.get("id").is_some_and(Value::is_string) => {
style.hover_entity = Some(Box::new(Value::Object(e.clone())));
}
_ => {}
}
}
@@ -28,6 +28,10 @@ pub struct Registries {
/// Game rule name -> integer-valued.
pub game_rules: Vec<(String, bool)>,
pub players: Vec<String>,
/// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat
/// completions the server sent (ClientboundCustomChatCompletionsPacket),
/// each once.
pub custom_completions: std::collections::BTreeSet<String>,
/// Scoreboard objective names, team names and tracked score holders.
pub objectives: Vec<String>,
pub teams: Vec<String>,
@@ -87,7 +91,55 @@ pub struct RegistryIds {
pub tags: Vec<String>,
}
const EMPTY: &[String] = &[];
/// ClientboundCustomChatCompletionsPacket.Action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CustomCompletionAction {
Add,
Remove,
Set,
}
impl CustomCompletionAction {
/// `Action.BY_ID`: `ByIdMap.continuous` with `OutOfBoundsStrategy.ZERO`,
/// so an unknown id is `ADD`.
pub fn from_id(id: i32) -> Self {
match id {
1 => Self::Remove,
2 => Self::Set,
_ => Self::Add,
}
}
}
/// `ClientSuggestionProvider.modifyCustomCompletions`: the server adds,
/// removes or replaces the extra chat completions.
pub fn modify_custom_completions(
set: &mut std::collections::BTreeSet<String>,
action: CustomCompletionAction,
entries: &[String],
) {
match action {
CustomCompletionAction::Add => set.extend(entries.iter().cloned()),
CustomCompletionAction::Remove => {
for e in entries {
set.remove(e);
}
}
CustomCompletionAction::Set => {
set.clear();
set.extend(entries.iter().cloned());
}
}
}
impl Registries {
/// `ClientSuggestionProvider.getCustomTabSuggestions`: what chat (not a
/// command) completes, the online players' names with the server's custom
/// completions, each once.
pub fn custom_tab_suggestions(&self) -> std::collections::BTreeSet<&str> {
self.players
.iter()
.chain(&self.custom_completions)
.map(String::as_str)
.collect()
}
/// Fill what the game did not build itself from vanilla's exported
/// registries (`command-registries.json`, written by
/// `VanillaCommandParseOracle registries`): built-in registries such as
+10 -3
View File
@@ -96,6 +96,8 @@ pub struct Style {
pub hover: Option<Box<Text>>,
/// `HoverEvent.ShowItem`: the event's `{id, count?, components?}` object.
pub hover_item: Option<Box<Value>>,
/// `HoverEvent.ShowEntity`: the event's `{id, uuid, name?}` object.
pub hover_entity: Option<Box<Value>>,
/// `Style.insertion`: the text a Shift-click puts in the chat box.
pub insertion: Option<String>,
/// The font (`FontDescription.Resource`), a full id like
@@ -127,11 +129,12 @@ impl Style {
if self.click.is_none() {
self.click.clone_from(&base.click);
}
if self.hover.is_none() {
// One HoverEvent: its three kinds are inherited together, so a style
// with its own event never picks up another kind from `base`.
if self.hover.is_none() && self.hover_item.is_none() && self.hover_entity.is_none() {
self.hover.clone_from(&base.hover);
}
if self.hover_item.is_none() {
self.hover_item.clone_from(&base.hover_item);
self.hover_entity.clone_from(&base.hover_entity);
}
if self.insertion.is_none() {
self.insertion.clone_from(&base.insertion);
@@ -591,6 +594,10 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
let mut event = item.clone();
event.insert("action".into(), "show_item".into());
out.push(("hover_event".to_owned(), Value::Object(event)));
} else if let Some(Value::Object(entity)) = style.hover_entity.as_deref() {
let mut event = entity.clone();
event.insert("action".into(), "show_entity".into());
out.push(("hover_event".to_owned(), Value::Object(event)));
}
if let Some(insertion) = &style.insertion {
out.push(("insertion".to_owned(), insertion.clone().into()));
+11 -5
View File
@@ -192,8 +192,9 @@ impl Assets {
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load(root, self)
}
/// [`Assets::reload`] for a server, which draws nothing: the models and
/// the sprites' layers, without the textures' pixels.
/// [`Assets::reload`] for a server, which draws nothing: the item models
/// and the sprites' layers, without the block models or the textures'
/// pixels.
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load_with(root, self, false)
}
@@ -202,14 +203,19 @@ impl RenderAssets {
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
Self::load_with(root, assets, true)
}
fn load_with(root: &Path, assets: &Assets, pixels: bool) -> Result<Self> {
fn load_with(root: &Path, assets: &Assets, draws: bool) -> Result<Self> {
let registry = assets.registry;
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
for name in MATERIALS {
sprites.insert(root, &format!("minecraft:block/{name}"))?;
}
let parsed = crate::model_library::ParsedModels::load(root);
let models = crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?;
// A server draws no block: its models (about 24 MB baked) stay empty.
let models = if draws {
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
} else {
crate::models::PreparedModels::default()
};
let model_library = parsed.into_item_library();
// Fluids are drawn by the fluid renderer, not block models; breaking
// progress uses the destroy-stage overlays.
@@ -226,7 +232,7 @@ impl RenderAssets {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
sprites.save_disk_cache();
if pixels {
if draws {
sprites.finish();
} else {
sprites.finish_without_pixels();
+10
View File
@@ -165,6 +165,8 @@ pub struct Audio {
/// How many times each selection was stopped ("record *", "* minecraft:…", `*` for
/// any), with or without an audio device, as `heard`.
pub stopped: std::collections::BTreeMap<String, u32>,
/// The volume sliders' `SoundPreviewHandler`.
preview: crate::sound_preview::SoundPreview,
}
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
use symphonia::core::{
@@ -634,6 +636,7 @@ impl Audio {
capture: None,
heard: Default::default(),
stopped: Default::default(),
preview: Default::default(),
}
}
#[cfg(not(feature = "audio"))]
@@ -817,6 +820,13 @@ impl Audio {
false,
);
}
/// `SoundPreviewHandler.preview`: a category volume slider moved to
/// `volume` on the title screen's options.
pub fn preview(&mut self, source: usize, volume: f32) {
let mut preview = std::mem::take(&mut self.preview);
preview.preview(self, source, volume);
self.preview = preview;
}
/// `play` with the seed a sound packet carries.
pub fn play_seeded(
&mut self,
+6 -6
View File
@@ -52,20 +52,20 @@ impl AuthSession {
/// The session file's JSON: {"name", "uuid", "access_token", "user_type": "msa", "xuid"}.
pub fn parse(text: &str) -> Result<Self, String> {
let v: serde_json::Value =
serde_json::from_str(text).map_err(|_| "the session file is not valid JSON")?;
serde_json::from_str(text).map_err(|_| "the session is not valid JSON")?;
let field = |k: &str| v.get(k).and_then(|s| s.as_str());
let name = field("name").ok_or("the session file has no name")?;
let name = field("name").ok_or("the session has no name")?;
if name.is_empty() || name.chars().count() > 16 {
return Err("the session file's name is not a valid player name".into());
return Err("the session's name is not a valid player name".into());
}
let uuid = field("uuid")
.and_then(parse_uuid)
.ok_or("the session file has no valid uuid")?;
.ok_or("the session has no valid uuid")?;
let access_token = field("access_token")
.filter(|t| !t.is_empty())
.ok_or("the session file has no access token")?;
.ok_or("the session has no access token")?;
if field("user_type").is_some_and(|t| t != "msa") {
return Err("the session file is not a Microsoft account session".into());
return Err("the session is not a Microsoft account session".into());
}
Ok(Self {
name: name.to_owned(),
+245 -55
View File
@@ -3,7 +3,12 @@
use crate::Result;
use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
use std::{
collections::{BTreeMap, HashMap},
fs,
path::Path,
sync::Arc,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GrassModifier {
@@ -102,6 +107,9 @@ pub struct BiomeInfo {
/// The biome's own fog colour attribute; otherwise the dimension's applies.
pub fog: Option<u32>,
pub water_fog: u32,
/// `BiomeGenerationSettings.features`: the placed feature ids of each
/// `GenerationStep.Decoration` step, in order.
pub features: Vec<Vec<String>>,
}
impl BiomeInfo {
/// `Biome.getHeightAdjustedTemperature`: the frozen modifier, then cooler
@@ -130,10 +138,27 @@ impl BiomeInfo {
.then(|| self.temperature_at(BlockPos::new(x, y, z), 63) < 0.15)
}
}
/// The game's biomes, by id. The built-in (vanilla) biomes come first, in
/// file order, and keep their ids whatever a world's data packs do; a pack's
/// new biomes follow them (`with_packs`). Those are the client's own ids:
/// the network registry's are `registry_order`'s.
#[derive(Clone)]
pub struct Biomes {
pub list: Vec<BiomeInfo>,
pub index: HashMap<String, u16>,
info_noise: SimplexNoise,
colormaps: Arc<Colormaps>,
/// The built-in biomes, the first of `list` unless packs replaced them.
builtin: Arc<Vec<BiomeInfo>>,
/// The data packs' biome files (new biomes and replaced built-in ones),
/// for the tables that read more of a biome than `BiomeInfo` keeps.
packs: BTreeMap<String, Arc<Value>>,
}
/// The grass, foliage and dry foliage colormaps.
struct Colormaps {
grass: Vec<u32>,
foliage: Vec<u32>,
dry: Vec<u32>,
}
fn color(v: &Value) -> Option<u32> {
match v {
@@ -166,11 +191,90 @@ fn sample(map: &[u32], temperature: f32, downfall: f32, fallback: u32) -> u32 {
let index = (y << 8 | x) as usize;
map.get(index).copied().unwrap_or(fallback)
}
impl BiomeInfo {
/// A biome file's (`worldgen/biome/<name>.json`) values, the colours it
/// leaves out sampled from the colormaps.
fn from_json(name: String, v: &Value, maps: &Colormaps) -> Self {
let effects = &v["effects"];
let attributes = &v["attributes"];
let temperature = v["temperature"].as_f64().unwrap_or(0.5) as f32;
let downfall = v["downfall"].as_f64().unwrap_or(0.5) as f32;
let modifier = match effects["grass_color_modifier"].as_str() {
Some("dark_forest") => GrassModifier::DarkForest,
Some("swamp") => GrassModifier::Swamp,
_ => GrassModifier::None,
};
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
// A modifier object ({argument, modifier: overlay}) or the plain value;
// the overlay over the dimension's empty default is the biome's value.
let natural = if natural["argument"].is_object() {
&natural["argument"]
} else {
natural
};
let categories = &natural["spawns_by_category"];
for (category, entries) in categories.as_object().into_iter().flatten() {
let list = parse_entries(entries);
spawns.insert(category.clone(), list);
}
BiomeInfo {
spawns,
spawn_costs: parse_costs(&natural["spawn_costs"]),
creature_probability:
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
.as_f64()
.unwrap_or(0.1) as f32,
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
.as_bool()
.unwrap_or(false),
temperature,
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
downfall,
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
grass: color(&effects["grass_color"])
.unwrap_or_else(|| sample(&maps.grass, temperature, downfall, 0xFF00FF)),
modifier,
foliage: color(&effects["foliage_color"])
.unwrap_or_else(|| sample(&maps.foliage, temperature, downfall, 0x48B518)),
dry_foliage: color(&effects["dry_foliage_color"])
.unwrap_or_else(|| sample(&maps.dry, temperature, downfall, 0x5D4930)),
water: color(&effects["water_color"]).unwrap_or(0x3F76E4),
sky: color(&attributes["minecraft:visual/sky_color"]),
fog: color(&attributes["minecraft:visual/fog_color"]),
water_fog: color(&attributes["minecraft:visual/water_fog_color"]).unwrap_or(0x050533),
features: v["features"]
.as_array()
.into_iter()
.flatten()
.map(|step| {
step.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect()
})
.collect(),
name,
}
}
}
/// `Identifier.compareTo`: the path first, then the namespace.
pub fn identifier_order(a: &str, b: &str) -> std::cmp::Ordering {
fn split(id: &str) -> (&str, &str) {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
(path, ns)
}
split(a).cmp(&split(b))
}
impl Biomes {
pub fn load(root: &Path) -> Result<Self> {
let grass = colormap(root, "grass")?;
let foliage = colormap(root, "foliage")?;
let dry = colormap(root, "dry_foliage")?;
let maps = Colormaps {
grass: colormap(root, "grass")?,
foliage: colormap(root, "foliage")?,
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())
@@ -186,57 +290,7 @@ impl Biomes {
.ok_or("bad biome")?
);
let v: Value = serde_json::from_slice(&fs::read(&path)?)?;
let effects = &v["effects"];
let attributes = &v["attributes"];
let temperature = v["temperature"].as_f64().unwrap_or(0.5) as f32;
let downfall = v["downfall"].as_f64().unwrap_or(0.5) as f32;
let modifier = match effects["grass_color_modifier"].as_str() {
Some("dark_forest") => GrassModifier::DarkForest,
Some("swamp") => GrassModifier::Swamp,
_ => GrassModifier::None,
};
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
// A modifier object ({argument, modifier: overlay}) or the plain value;
// the overlay over the dimension's empty default is the biome's value.
let natural = if natural["argument"].is_object() {
&natural["argument"]
} else {
natural
};
let categories = &natural["spawns_by_category"];
for (category, entries) in categories.as_object().into_iter().flatten() {
let list = parse_entries(entries);
spawns.insert(category.clone(), list);
}
list.push(BiomeInfo {
spawns,
spawn_costs: parse_costs(&natural["spawn_costs"]),
creature_probability:
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
.as_f64()
.unwrap_or(0.1) as f32,
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
.as_bool()
.unwrap_or(false),
temperature,
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
downfall,
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
grass: color(&effects["grass_color"])
.unwrap_or_else(|| sample(&grass, temperature, downfall, 0xFF00FF)),
modifier,
foliage: color(&effects["foliage_color"])
.unwrap_or_else(|| sample(&foliage, temperature, downfall, 0x48B518)),
dry_foliage: color(&effects["dry_foliage_color"])
.unwrap_or_else(|| sample(&dry, temperature, downfall, 0x5D4930)),
water: color(&effects["water_color"]).unwrap_or(0x3F76E4),
sky: color(&attributes["minecraft:visual/sky_color"]),
fog: color(&attributes["minecraft:visual/fog_color"]),
water_fog: color(&attributes["minecraft:visual/water_fog_color"])
.unwrap_or(0x050533),
name,
});
list.push(BiomeInfo::from_json(name, &v, &maps));
}
let index = list
.iter()
@@ -244,11 +298,65 @@ impl Biomes {
.map(|(i, b)| (b.name.clone(), i as u16))
.collect();
Ok(Self {
builtin: Arc::new(list.clone()),
list,
index,
colormaps: Arc::new(maps),
packs: BTreeMap::new(),
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
})
}
/// The built-in biomes with a world's data packs' biome files (id →
/// JSON) over them, as `RegistryDataLoader` builds the registry: a file
/// for a built-in biome replaces it, keeping its id, and new biomes join
/// after the built-in ones, in registry order. Starts from the built-in
/// table whatever packs `self` has.
pub fn with_packs<'a>(&self, files: impl IntoIterator<Item = (&'a str, &'a Value)>) -> Self {
let mut out = self.builtin_only();
let mut files: Vec<(&str, &Value)> = files.into_iter().collect();
files.sort_by(|a, b| identifier_order(a.0, b.0));
for (name, json) in files {
let info = BiomeInfo::from_json(name.to_owned(), json, &out.colormaps);
match out.index.get(name) {
Some(&id) => out.list[usize::from(id)] = info,
None if out.list.len() < usize::from(u16::MAX) => {
out.index.insert(name.to_owned(), out.list.len() as u16);
out.list.push(info);
}
None => continue,
}
out.packs.insert(name.to_owned(), Arc::new(json.clone()));
}
out
}
/// The built-in biomes alone: this table without what packs gave it.
pub fn builtin_only(&self) -> Self {
let mut out = self.clone();
if self.packs.is_empty() {
return out;
}
out.list = self.builtin.to_vec();
let builtin = out.list.len();
out.index.retain(|_, id| usize::from(*id) < builtin);
out.packs.clear();
out
}
/// The data pack file a world's biome `name` came from, if a pack added
/// or replaced it.
pub fn pack_file(&self, name: &str) -> Option<&Value> {
self.packs.get(name).map(|v| &**v)
}
/// Whether data packs added or replaced any biome.
pub fn has_packs(&self) -> bool {
!self.packs.is_empty()
}
/// The biome names in the network registry's order: `RegistryDataLoader`
/// sorts a registry's files by `Identifier` (path, then namespace).
pub fn registry_order(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.list.iter().map(|b| b.name.as_str()).collect();
names.sort_by(|a, b| identifier_order(a, b));
names
}
pub fn id(&self, name: &str) -> u16 {
self.index
.get(name)
@@ -344,6 +452,88 @@ pub fn blend_rgb(weights: &[(u16, f64)], mut value: impl FnMut(u16) -> [f32; 3])
result.unwrap_or([0.0; 3])
}
#[cfg(test)]
mod pack_tests {
use super::*;
use serde_json::json;
/// A built-in table of `names` (no colormaps: sampled colours fall back).
fn builtin(names: &[&str]) -> Biomes {
let maps = Colormaps {
grass: Vec::new(),
foliage: Vec::new(),
dry: Vec::new(),
};
let list: Vec<BiomeInfo> = names
.iter()
.map(|n| BiomeInfo::from_json((*n).to_owned(), &json!({}), &maps))
.collect();
Biomes {
builtin: Arc::new(list.clone()),
index: list
.iter()
.enumerate()
.map(|(i, b)| (b.name.clone(), i as u16))
.collect(),
list,
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
colormaps: Arc::new(maps),
packs: BTreeMap::new(),
}
}
#[test]
fn registry_order_is_path_then_namespace() {
use std::cmp::Ordering::*;
assert_eq!(identifier_order("minecraft:a", "minecraft:b"), Less);
assert_eq!(identifier_order("z:a", "a:b"), Less);
assert_eq!(identifier_order("b:same", "a:same"), Greater);
assert_eq!(identifier_order("minecraft:a", "minecraft:a_b"), Less);
assert_eq!(identifier_order("minecraft:a-b", "minecraft:a"), Greater);
}
#[test]
fn packs_replace_in_place_and_add_after_the_builtin_biomes() {
let base = builtin(&["minecraft:meadow", "minecraft:plains", "minecraft:river"]);
let red = json!({"temperature": 2.0, "effects": {"water_color": "#ff0000"}});
let hot = json!({"effects": {"grass_color": 0x123456}});
let files = [
("pack:zeta", &hot),
("minecraft:molten_peaks", &hot),
("minecraft:river", &red),
];
let world = base.with_packs(files);
// The built-in ids stay; the river takes the pack's values.
assert_eq!(world.id("minecraft:plains"), 1);
assert_eq!(world.id("minecraft:river"), 2);
assert_eq!(world.get(2).water, 0xFF0000);
assert_eq!(world.get(2).temperature, 2.0);
// New biomes follow, in registry order.
assert_eq!(world.id("minecraft:molten_peaks"), 3);
assert_eq!(world.id("pack:zeta"), 4);
assert_eq!(world.get(3).grass, 0x123456);
assert!(world.pack_file("minecraft:river").is_some());
assert!(world.pack_file("minecraft:plains").is_none());
// The network registry sorts every name by path, then namespace.
assert_eq!(
world.registry_order(),
[
"minecraft:meadow",
"minecraft:molten_peaks",
"minecraft:plains",
"minecraft:river",
"pack:zeta"
]
);
// Another world starts again from the built-in biomes.
let other = world.with_packs([]);
assert_eq!(other.list.len(), 3);
assert!(!other.contains("minecraft:molten_peaks"));
assert_eq!(other.get(2).water, 0x3F76E4);
assert!(!other.has_packs());
}
}
#[cfg(test)]
mod blend_tests {
use super::*;
@@ -1114,7 +1114,11 @@ impl BlockEntityRenderer {
pub fn renders(&self, state: u32) -> bool {
!matches!(self.kind(state), Kind::None)
}
/// ChestRenderer and BannerRenderer submit their animated models for crumbling.
/// `submitCrumblingOverlay` of ChestRenderer.submit, BannerRenderer.submit,
/// SkullBlockRenderer.submitSkull, ShulkerBoxRenderer.submit,
/// BellRenderer.submit, LecternRenderer.submit (the book) and
/// CopperGolemStatueBlockRenderer.submit: the model drawn again under the
/// destroy stage.
pub fn render_breaking(
&self,
scene: &Scene,
@@ -1123,7 +1127,16 @@ impl BlockEntityRenderer {
stage: [f32; 2],
out: &mut Vec<Vertex>,
) {
if !matches!(self.kind(state), Kind::Banner { .. } | Kind::Chest { .. }) {
if !matches!(
self.kind(state),
Kind::Banner { .. }
| Kind::Chest { .. }
| Kind::Skull { .. }
| Kind::Shulker { .. }
| Kind::Bell
| Kind::Lectern { .. }
| Kind::CopperGolem { .. }
) {
return;
}
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
@@ -36,6 +36,22 @@ pub fn potion_name_key(item: &str, potion: Option<&str>) -> String {
let suffix = potion.and_then(self::potion).map_or("empty", |p| p.name);
format!("item.minecraft.{prefix}.effect.{suffix}")
}
/// `PotionContents.getAllEffects`: the potion's effects, then the stack's
/// `custom_effects` (id, duration, amplifier). Unknown effect ids are dropped.
pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> {
let potion = stack.potion().and_then(self::potion);
let custom = stack.custom_potion_effects();
potion
.map_or(&[][..], |d| d.effects)
.iter()
.copied()
.chain(
custom
.iter()
.filter_map(|(id, duration, amp)| Some((effect(id)?.id, *duration, *amp))),
)
.collect()
}
/// Roman amplifier suffix (`potion.potency.N`) and duration lines of a potion.
pub fn tooltip(
potion: Option<&str>,
+121 -10
View File
@@ -21,6 +21,12 @@ const COMMAND_HISTORY_FILE: &str = "command_history.txt";
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
/// GuiMessageTag.systemSinglePlayer's indicator colour.
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
/// A player message's signature (MessageSignature), which names it to a
/// server's deletion.
pub type Signature = horizon_protocol::secure_chat::Signature;
/// How long a message shows before a deletion replaces it
/// (`ChatComponent.deleteMessageOrDelay`: `addedTime + 60`).
const DELETION_DELAY: u64 = 60;
/// `ChatVisiblity`: the Chat option's three values.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -165,6 +171,8 @@ struct Message {
content: Text,
indicator: Option<u32>,
source: ChatSource,
/// `GuiMessage.signature`: a signed player message's, for deletion.
signature: Option<Box<Signature>>,
}
/// A wrapped line (`GuiMessage.Line`).
#[derive(Clone, Debug)]
@@ -205,6 +213,9 @@ pub struct Chat {
pub received: u64,
/// The Chat option's `visibleMessageFilter` (`set_abilities`).
abilities: ChatAbilities,
/// `ChatComponent.messageDeletionQueue`: deletions of messages still
/// too new to replace, with the tick from which they may be.
deletions: Vec<(Box<Signature>, u64)>,
}
impl Chat {
pub fn lines_per_page(&self, focused: bool) -> usize {
@@ -227,6 +238,38 @@ impl Chat {
) {
self.add_message(&atlas.font, content, indicator, source, ticks, focused);
}
/// `add` of a player message with its signature, which a server's
/// deletion names it by (`GuiMessage.signature`).
pub fn add_signed(
&mut self,
atlas: &HudAtlas,
content: Text,
signature: Option<Signature>,
ticks: u64,
focused: bool,
) {
self.add_signed_message(&atlas.font, content, signature, ticks, focused);
}
fn add_signed_message(
&mut self,
font: &crate::font::Font,
content: Text,
signature: Option<Signature>,
ticks: u64,
focused: bool,
) {
self.push_message(
font,
Message {
added: ticks,
content,
indicator: None,
source: ChatSource::Player,
signature: signature.map(Box::new),
},
focused,
);
}
fn add_message(
&mut self,
font: &crate::font::Font,
@@ -236,20 +279,28 @@ impl Chat {
ticks: u64,
focused: bool,
) {
if !self.abilities.shows(source) {
self.push_message(
font,
Message {
added: ticks,
content,
indicator,
source,
signature: None,
},
focused,
);
}
fn push_message(&mut self, font: &crate::font::Font, message: Message, focused: bool) {
if !self.abilities.shows(message.source) {
return;
}
// ChatComponent.logChatMessage: every message shown is in the log.
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {}", content.plain()));
let plain = message.content.plain();
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {plain}"));
if self.log {
println!("[CHAT] {}", content.plain());
println!("[CHAT] {plain}");
}
let message = Message {
added: ticks,
content,
indicator,
source,
};
self.add_lines(font, &message, focused);
self.messages.insert(0, message);
self.messages.truncate(100);
@@ -319,8 +370,66 @@ impl Chat {
pub fn clear(&mut self) {
self.messages.clear();
self.lines.clear();
self.deletions.clear();
self.scroll = 0;
}
/// `ChatComponent.deleteMessage`: the server deleted a message. One
/// shown for 60 ticks becomes the grey "deleted" marker now; a newer
/// one waits until then (`messageDeletionQueue`).
pub fn delete_message(&mut self, atlas: &HudAtlas, signature: &Signature, ticks: u64) {
self.delete_message_with(&atlas.font, signature, ticks);
}
fn delete_message_with(&mut self, font: &crate::font::Font, signature: &Signature, ticks: u64) {
if let Some(delayed) = self.delete_or_delay(font, signature, ticks) {
self.deletions.push((Box::new(*signature), delayed));
}
}
/// `ChatComponent.tick` (`processMessageDeletionQueue`): the delayed
/// deletions whose message has shown long enough.
pub fn tick(&mut self, atlas: &HudAtlas, ticks: u64) {
self.process_deletions(&atlas.font, ticks);
}
fn process_deletions(&mut self, font: &crate::font::Font, ticks: u64) {
let mut deletions = std::mem::take(&mut self.deletions);
deletions.retain(|(signature, after)| {
ticks < *after || self.delete_or_delay(font, signature, ticks).is_some()
});
self.deletions = deletions;
}
/// `ChatComponent.deleteMessageOrDelay`: the newest message with this
/// signature, replaced by `createDeletedMarker` once 60 ticks have
/// passed since it was added; otherwise the tick to retry from.
fn delete_or_delay(
&mut self,
font: &crate::font::Font,
signature: &Signature,
ticks: u64,
) -> Option<u64> {
let message = self
.messages
.iter_mut()
.find(|m| m.signature.as_deref() == Some(signature))?;
let deletable = message.added + DELETION_DELAY;
if ticks < deletable {
return Some(deletable);
}
*message = Message {
added: message.added,
content: Self::deleted_marker(),
indicator: Some(SYSTEM_INDICATOR),
source: ChatSource::Server,
signature: None,
};
// refreshTrimmedMessages.
self.rewrap(font);
None
}
/// `ChatComponent.DELETED_CHAT_MESSAGE`: grey, italic.
fn deleted_marker() -> Text {
Text::translate("chat.deleted_marker", &[])
.with_color(text::GRAY)
.italic()
}
/// `new CommandHistory(gameFolder)`: the commands kept from earlier runs
/// start the chat's history (`ChatComponent`'s constructor).
pub fn load_history(&mut self, game_folder: &std::path::Path) {
@@ -929,7 +1038,9 @@ impl ChatScreen {
} else if !command.chars().all(char::is_whitespace) {
let partial = &command[..cursor_b];
let mut builder = SuggestionsBuilder::new(partial, last_word_index(partial));
builder.suggest_matching(c.registries.players.iter().map(String::as_str));
// ClientSuggestionProvider.getCustomTabSuggestions: the players
// and the server's custom chat completions.
builder.suggest_matching(c.registries.custom_tab_suggestions());
let (range, list) = merge_suggestions(partial, builder.out);
self.awaiting = None;
self.pending = Some((range.0, list));
@@ -244,6 +244,58 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
assert_eq!(screen.value(), "/coords");
}
#[test]
fn chat_completes_the_servers_custom_completions_with_the_players() {
// CommandSuggestions.updateCommandInfo completes chat from
// ClientSuggestionProvider.getCustomTabSuggestions: the online players
// and what ClientboundCustomChatCompletionsPacket added.
use crate::command::{CustomCompletionAction as Action, modify_custom_completions};
let font = font();
let m = Measure(&font);
let mut registries = Registries {
players: vec!["Steve".into()],
..Registries::default()
};
let entries = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let set = &mut registries.custom_completions;
modify_custom_completions(set, Action::from_id(0), &entries(&["Alex", "#trade"]));
modify_custom_completions(set, Action::Add, &entries(&["Notch", "Steve"]));
modify_custom_completions(set, Action::Remove, &entries(&["Alex"]));
// An id past the last action is ADD (OutOfBoundsStrategy.ZERO).
modify_custom_completions(set, Action::from_id(7), &entries(&["Zuri"]));
assert_eq!(
registries
.custom_tab_suggestions()
.into_iter()
.collect::<Vec<_>>(),
["#trade", "Notch", "Steve", "Zuri"]
);
let completed = |registries: &Registries, line: &str| {
let dispatcher = crate::command::vanilla::game_dispatcher();
let c = Commands {
dispatcher,
registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
screen.type_text(&m, &c, line);
// Chat shows no list by itself: Tab shows it, Tab again uses it.
for _ in 0..2 {
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
}
screen.value().to_owned()
};
assert_eq!(completed(&registries, "hi no"), "hi Notch");
assert_eq!(completed(&registries, "sell #tr"), "sell #trade");
assert_eq!(completed(&registries, "hi st"), "hi Steve");
assert_eq!(completed(&registries, "hi al"), "hi al");
// SET replaces them all; the players stay.
let set = &mut registries.custom_completions;
modify_custom_completions(set, Action::Set, &entries(&["Alex"]));
assert_eq!(completed(&registries, "hi no"), "hi no");
assert_eq!(completed(&registries, "hi al"), "hi Alex");
assert_eq!(completed(&registries, "hi st"), "hi Steve");
}
#[test]
fn ask_server_nodes_wait_for_the_servers_completions() {
// ClientSuggestionProvider.customSuggestion: an `ask_server` node at the
@@ -467,3 +519,62 @@ fn a_guests_line_follows_the_player_message_permission() {
);
}
}
/// `ChatComponent.deleteMessageOrDelay`: a deleted message shows for 60
/// ticks from when it was added, then becomes the grey italic
/// `chat.deleted_marker` line with the system bar; the other lines stay.
#[test]
fn a_deleted_message_shows_60_ticks_then_becomes_the_marker() {
let font = font();
let mut chat = Chat::default();
let secret = [7; 256];
chat.add_signed_message(
&font,
Text::literal("<Steve> secret"),
Some(secret),
100,
false,
);
chat.add_signed_message(
&font,
Text::literal("<Alex> hi"),
Some([8; 256]),
110,
false,
);
let lines =
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
let marker = Chat::deleted_marker().plain();
chat.delete_message_with(&font, &secret, 130);
assert_eq!(
lines(&chat),
["<Alex> hi", "<Steve> secret"],
"too new: delayed"
);
chat.process_deletions(&font, 159);
assert_eq!(lines(&chat), ["<Alex> hi", "<Steve> secret"]);
chat.process_deletions(&font, 160);
assert_eq!(lines(&chat), ["<Alex> hi".to_owned(), marker.clone()]);
assert!(chat.deletions.is_empty());
let line = &chat.lines[1];
assert_eq!(line.indicator, Some(SYSTEM_INDICATOR));
assert_eq!(line.added, 100, "the marker keeps the message's time");
assert!(
line.content
.spans
.iter()
.all(|s| s.style.color == Some(text::GRAY) && s.style.is_italic())
);
assert_eq!(
chat.messages[1].content.translation_key(),
Some("chat.deleted_marker")
);
// A message shown 60 ticks already is replaced at once; an unknown or
// already deleted signature changes nothing.
chat.delete_message_with(&font, &[8; 256], 170);
assert_eq!(lines(&chat), [marker.clone(), marker.clone()]);
chat.delete_message_with(&font, &secret, 200);
chat.delete_message_with(&font, &[9; 256], 200);
assert_eq!(lines(&chat), [marker.clone(), marker]);
assert!(chat.deletions.is_empty());
}
+99 -8
View File
@@ -82,13 +82,45 @@ impl LevelShape {
/// vanilla Overworld's `-64..320` so that vanilla's Nether and End keep
/// the 24 sections they have always had.
pub fn column(self) -> ColumnLayout {
let low = self.min_y.min(MIN_Y);
let high = self.max_y().max(MIN_Y + HEIGHT);
ColumnLayout {
min_y: low,
sections: ((high - low) / 16) as usize,
ColumnLayout::of_level(self.min_y, self.height)
}
}
/// The shapes of a world's three levels, from its dimension types.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LevelShapes {
pub overworld: LevelShape,
pub nether: LevelShape,
pub end: LevelShape,
}
impl LevelShapes {
pub const VANILLA: Self = Self {
overworld: LevelShape::OVERWORLD,
nether: LevelShape::NETHER,
end: LevelShape::END,
};
/// The shape of the level `id` (`minecraft:overworld`,
/// `minecraft:the_nether`, `minecraft:the_end`); others get the
/// Overworld's.
pub fn get(&self, id: &str) -> LevelShape {
match id {
"minecraft:the_nether" => self.nether,
"minecraft:the_end" => self.end,
_ => self.overworld,
}
}
pub fn get_mut(&mut self, id: &str) -> &mut LevelShape {
match id {
"minecraft:the_nether" => &mut self.nether,
"minecraft:the_end" => &mut self.end,
_ => &mut self.overworld,
}
}
}
impl Default for LevelShapes {
fn default() -> Self {
Self::VANILLA
}
}
/// The span of block Y a chunk holds: from `min_y`, `sections` sections up
@@ -103,6 +135,16 @@ impl ColumnLayout {
min_y: MIN_Y,
sections: SECTIONS,
};
/// The column of a level from `min_y`, `height` high
/// (`LevelShape::column`).
pub fn of_level(min_y: i32, height: i32) -> Self {
let low = min_y.min(MIN_Y);
let high = (min_y + height).max(MIN_Y + HEIGHT);
Self {
min_y: low,
sections: ((high - low) / 16) as usize,
}
}
/// `DimensionType.MIN_Y`, `MAX_Y`: 12 bits of `BlockPos` Y, less 32.
pub const LOWEST_Y: i32 = -2032;
pub const HIGHEST_Y: i32 = 2031;
@@ -155,6 +197,13 @@ impl ColumnLayout {
self.min_y + index as i32 * 16
}
}
/// `true` for each section of `layout`: what callers that keep no
/// per-section record (of fluids, say) pass as "every section may have it".
pub fn every_section(layout: ColumnLayout) -> &'static [bool] {
static ALL: [bool; crate::section_mask::SectionMask::CAPACITY] =
[true; crate::section_mask::SectionMask::CAPACITY];
&ALL[..layout.sections()]
}
impl Default for ColumnLayout {
fn default() -> Self {
Self::VANILLA
@@ -194,6 +243,47 @@ pub fn free_columns<T: Send + 'static>(columns: T, count: usize) {
pub fn set_block_states(states: usize) {
let bits = horizon_core::paletted::ceil_log2(states).max(16);
BLOCK_BITS.store(bits, std::sync::atomic::Ordering::Relaxed);
forget_shared();
}
/// One allocation per kind of uniform section (a single block, a single
/// biome and one light value), shared by every column that has it, keyed by
/// (block, biome, light). A tall column is mostly such sections: air with
/// full sky light above the terrain. Edits copy them (`Arc::make_mut`).
type SharedSections = std::collections::HashMap<(u32, u32, u8), Arc<Section>>;
static SHARED: std::sync::Mutex<Option<SharedSections>> = std::sync::Mutex::new(None);
/// Replaces the uniform sections of `sections` with the shared ones.
pub fn share_uniform(sections: &mut [Arc<Section>]) {
let key = |s: &Section| -> Option<(u32, u32, u8)> {
let Light::Uniform(light) = s.light else {
return None;
};
if s.blocks.bits() != 0 || s.biomes.bits() != 0 {
return None;
}
Some((s.blocks.uniform()?, s.biomes.uniform()?, light))
};
let Ok(mut shared) = SHARED.lock() else {
return;
};
let shared = shared.get_or_insert_with(Default::default);
for section in sections {
let Some(k) = key(section) else {
continue;
};
let one = shared.entry(k).or_insert_with(|| section.clone());
if !Arc::ptr_eq(one, section) {
*section = one.clone();
}
}
}
/// Forgets the shared sections, when the palette changes.
fn forget_shared() {
if let Ok(mut shared) = SHARED.lock() {
*shared = None;
}
}
/// Packed light per block: sky light in the high nibble, block light in the low one.
#[derive(Clone)]
@@ -325,13 +415,14 @@ impl Chunk {
Self::with_layout(x, z, ColumnLayout::VANILLA)
}
pub fn with_layout(x: i32, z: i32, layout: ColumnLayout) -> Self {
let mut one = [Arc::new(Section::default())];
share_uniform(&mut one);
let [empty] = one;
Self {
x,
z,
min_y: layout.min_y(),
sections: (0..layout.sections())
.map(|_| Arc::new(Section::default()))
.collect(),
sections: (0..layout.sections()).map(|_| empty.clone()).collect(),
surface: OnceLock::new(),
motion_blocking: OnceLock::new(),
lit: false,
@@ -305,7 +305,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
/// From the section's palette, without reading its blocks; a palette
/// of the whole registry reads every cell.
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
let blocks = usize::try_from(section_y - (crate::chunk::MIN_Y >> 4))
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
+5 -1
View File
@@ -320,6 +320,10 @@ fn thread_name() -> String {
}
}
/// What starts the stderr line naming a saved report, the path follows: the Launch API's
/// promise to launchers (docs/api/launch.md), so it never changes within a line of that API.
pub const SAVED_LINE: &str = "Horizon: crash report saved to ";
/// Writes the report and tells where it is; the log gets the error too.
fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
if REPORTED.swap(true, Ordering::SeqCst) {
@@ -343,7 +347,7 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
crate::telemetry::keep_crash(home, &report);
match report.save(home) {
Ok(path) => {
eprintln!("Horizon: crash report saved to {}", path.display());
eprintln!("{SAVED_LINE}{}", path.display());
Some(path)
}
Err(e) => {
+7 -9
View File
@@ -636,11 +636,6 @@ pub fn worldgen_overrides(
let read = || String::from_utf8(pack.read(&file)?).ok();
match out.add_file(&pack.id, &file, read) {
PackFile::Read | PackFile::Other => {}
PackFile::DimensionType { id, min_y, height } => warnings.push(format!(
"{}: dimension type {id} (min_y {min_y}, height {height}) is not used: \
the dimension keeps its vanilla build height",
pack.id
)),
PackFile::NotRead(kind) => {
*not_read.entry(format!("{}: {kind}", pack.id)).or_default() += 1
}
@@ -713,10 +708,13 @@ mod tests {
);
let tag_packs: Vec<&str> = o.tags("biome_tags").map(|(_, p, _)| p).collect();
assert_eq!(tag_packs, ["file/b", "file/a"]);
assert_eq!(warnings.len(), 3, "{warnings:?}");
assert!(warnings[0].contains("height 2096"));
assert!(warnings[1].contains("minecraft:new_peaks"));
assert!(warnings[2].contains("worldgen/world_preset"));
assert_eq!(warnings.len(), 2, "{warnings:?}");
assert!(warnings[0].contains("minecraft:new_peaks"));
assert!(warnings[1].contains("worldgen/world_preset"));
assert_eq!(
o.level_height("minecraft:overworld").map(|h| h.height),
Some(2096)
);
let _ = std::fs::remove_dir_all(&dir);
}
use horizon_world::nbt::parse_snbt;
+29 -10
View File
@@ -148,6 +148,15 @@ pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
_ => None,
}
}
/// `MobEffectInstance.withScaledDuration`: max(floor(d * scale), 1), leaving
/// an infinite (-1) or zero duration alone.
pub fn scaled_duration(duration: i32, scale: f32) -> i32 {
if duration == -1 || duration == 0 {
duration
} else {
((duration as f32 * scale).floor() as i32).max(1)
}
}
/// What an omen's `applyEffectTick` decided.
#[derive(Clone, Debug, PartialEq)]
pub enum OmenTick {
@@ -308,19 +317,29 @@ impl PlayerEffects {
pub fn potion_effects(
potion: Option<&str>,
scale: f32,
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
let effects = potion
.and_then(potions::potion)
.map_or(&[][..], |d| d.effects);
Self::contents_effects(effects, scale)
}
/// `PotionContents.forEachEffect`'s effects, as `potion_effects`.
pub fn contents_effects(
effects: &[(&'static str, i32, i32)],
scale: f32,
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
let mut lasting = Vec::new();
let mut now = Vec::new();
if let Some(def) = potion.and_then(potions::potion) {
for (id, duration, amp) in def.effects {
let id = potions::effect(id).map_or("speed", |e| e.id);
if potions::effect(id).is_some_and(|e| e.instant) {
now.extend(instant(id, *amp, 1.0));
} else {
// withScaledDuration: max(floor(d * scale), 1).
let d = ((*duration as f32) * scale).floor() as i32;
lasting.push(EffectInstance::new(id, d.max(1), *amp));
}
for (id, duration, amp) in effects {
let id = potions::effect(id).map_or("speed", |e| e.id);
if potions::effect(id).is_some_and(|e| e.instant) {
now.extend(instant(id, *amp, 1.0));
} else {
lasting.push(EffectInstance::new(
id,
scaled_duration(*duration, scale),
*amp,
));
}
}
(lasting, now)
+8 -4
View File
@@ -311,10 +311,14 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
fog.cloud_end = fog.environmental_end;
}
FogType::PowderSnow => {
fog.environmental_start = 0.0;
fog.environmental_end = 2.0;
fog.sky_end = 2.0;
fog.cloud_end = 2.0;
// PowderedSnowFogEnvironment.setupFog.
(fog.environmental_start, fog.environmental_end) = if input.spectator {
(-8.0, rd * 0.5)
} else {
(0.0, 2.0)
};
fog.sky_end = fog.environmental_end;
fog.cloud_end = fog.environmental_end;
}
_ if input.blindness.is_some() => {
let (duration, infinite) = input.blindness.unwrap_or((0, true));
+205 -89
View File
@@ -590,6 +590,10 @@ pub struct Renderer {
readback: readback::Readback,
pub info: wgpu::AdapterInfo,
pub suspended: bool,
/// Consecutive frames whose surface acquisition failed validation. The
/// first gets the swapchain a fresh configuration; later ones reattach
/// the window, as a lost surface does.
acquire_validation_failures: u32,
pub drawn_sections: usize,
/// Last frame's section draws (face ranges and translucent sections),
/// and the draw calls the backend issued for them.
@@ -792,9 +796,14 @@ fn mip_chain(
/// pass is also what passes each draw's first instance to shaders reading
/// `instance_index`, and DirectX 12 is the fallback, where working matters
/// more than CPU time.
/// `allow_nonconformant` offers the adapters wgpu hides for reporting no Vulkan
/// conformance version: Mesa's hasvk, which drives Ivy Bridge and Haswell,
/// reports 0.0.0.0 while it works. `open_device` asks for them only on a second
/// try at a backend that had no adapter at all.
fn instance_descriptor(
backends: wgpu::Backends,
window: Option<&Arc<Window>>,
allow_nonconformant: bool,
) -> wgpu::InstanceDescriptor {
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
if let Some(window) = window {
@@ -807,7 +816,13 @@ fn instance_descriptor(
.flags
.remove(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL);
}
descriptor.with_env()
let mut descriptor = descriptor.with_env();
if allow_nonconformant {
descriptor
.flags
.insert(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER);
}
descriptor
}
/// The window's display connection, so the OpenGL backend creates its EGL
/// display from the X11 or Wayland connection the window lives on. Without
@@ -946,96 +961,147 @@ async fn open_device(
if attempt > 0 {
log::warn!("Horizon renderer: trying {name} ({})", failures.join("; "));
}
let instance = wgpu::Instance::new(instance_descriptor(backends, Some(window)));
let surface = if offscreen {
None
} else {
match instance.create_surface(window.clone()) {
Ok(surface) => Some(surface),
Err(e) => {
failures.push(format!("{name} surface: {e}"));
continue;
}
}
};
let adapter = match instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: surface.as_ref(),
force_fallback_adapter: false,
..Default::default()
})
.await
{
Ok(adapter) => adapter,
Err(e) => {
failures.push(format!("{name}: {e}"));
continue;
}
};
describe_adapter(&adapter);
let info = adapter.get_info();
let limits = adapter.limits();
if let Err(e) = check_layers(&limits, layers) {
failures.push(format!("{name}: {e}"));
continue;
}
// Mappable primary buffers: section pages that no longer fit in
// video memory go to system memory. Shader packs' 16-bit normalized buffers (render
// targets with the adapter's own format features) and R11F_G11F_B10F: a float
// stand-in neither clamps nor drops NaN.
let features = timing::GpuTimer::features(&adapter)
| cull::Cull::features(&adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
| wgpu::Features::RG11B10UFLOAT_RENDERABLE));
let driver = format!("{} {}", info.driver, info.driver_info);
startup_step(format_args!(
"requesting the device (driver {}) with features [{features}] and the adapter's \
limits (buffers up to {} bytes, storage bindings up to {} bytes, {} texture layers)",
match driver.trim() {
"" => "unnamed",
driver => driver,
},
limits.max_buffer_size,
limits.max_storage_buffer_binding_size,
limits.max_texture_array_layers,
));
let desc = wgpu::DeviceDescriptor {
label: Some("Horizon device"),
required_features: features,
required_limits: limits,
..Default::default()
};
#[cfg(not(target_os = "macos"))]
let opened = match info.backend {
wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc),
_ => None,
};
#[cfg(target_os = "macos")]
let opened = None;
let opened = match opened {
Some(opened) => opened,
None => adapter.request_device(&desc).await,
};
match opened {
Ok((device, queue)) => {
return Ok(Opened {
instance,
surface,
adapter,
device,
queue,
});
}
Err(e) => failures.push(format!("{name} device: {e}")),
match open_with_retry(backends, window, offscreen, layers).await {
Ok(opened) => return Ok(opened),
Err(e) => failures.push(e),
}
}
Err(format!("{NO_GPU_DRIVER} ({})", failures.join("; ")).into())
}
/// Why a backend gave no device: no adapter at all, which a second try with the
/// adapters wgpu hides can still find, or a failure after an adapter.
enum NoDevice {
NoAdapter(String),
Failed(String),
}
/// Opens a device on one backend, and on a backend whose adapters wgpu all hid
/// for reporting no Vulkan conformance version tries it again with them shown:
/// Mesa's hasvk drives Ivy Bridge and Haswell, works, and reports 0.0.0.0, so
/// without this the player is told they have no graphics driver while Vulkan
/// runs (a `gl not compatible with provided surface` fallback, or nothing, is
/// all they get). Returns why the backend gave no device.
async fn open_with_retry(
backends: wgpu::Backends,
window: &Arc<Window>,
offscreen: bool,
layers: u32,
) -> std::result::Result<Opened, String> {
match open_backend(backends, window, offscreen, false, layers).await {
Err(NoDevice::NoAdapter(hidden)) => {
log::warn!(
"Horizon renderer: {} has no conformant adapter ({hidden}); asking again for the \
ones wgpu hides as non-conformant",
backends_name(backends)
);
match open_backend(backends, window, offscreen, true, layers).await {
Ok(opened) => {
log::warn!(
"Horizon renderer: using {} although its driver reports no Vulkan \
conformance version",
opened.adapter.get_info().name
);
Ok(opened)
}
Err(NoDevice::NoAdapter(e)) | Err(NoDevice::Failed(e)) => Err(e),
}
}
Err(NoDevice::Failed(e)) => Err(e),
Ok(opened) => Ok(opened),
}
}
/// Opens a device on `backends` alone: its surface, adapter and device, or what
/// stopped it. `allow_nonconformant` is `open_with_retry`'s second try.
async fn open_backend(
backends: wgpu::Backends,
window: &Arc<Window>,
offscreen: bool,
allow_nonconformant: bool,
layers: u32,
) -> std::result::Result<Opened, NoDevice> {
let name = backends_name(backends);
let instance = wgpu::Instance::new(instance_descriptor(
backends,
Some(window),
allow_nonconformant,
));
let surface = if offscreen {
None
} else {
match instance.create_surface(window.clone()) {
Ok(surface) => Some(surface),
Err(e) => return Err(NoDevice::Failed(format!("{name} surface: {e}"))),
}
};
let adapter = match instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: surface.as_ref(),
force_fallback_adapter: false,
..Default::default()
})
.await
{
Ok(adapter) => adapter,
Err(e) => return Err(NoDevice::NoAdapter(format!("{name}: {e}"))),
};
describe_adapter(&adapter);
let info = adapter.get_info();
let limits = adapter.limits();
if let Err(e) = check_layers(&limits, layers) {
return Err(NoDevice::Failed(format!("{name}: {e}")));
}
// Mappable primary buffers: section pages that no longer fit in
// video memory go to system memory. Shader packs' 16-bit normalized buffers (render
// targets with the adapter's own format features) and R11F_G11F_B10F: a float
// stand-in neither clamps nor drops NaN.
let features = timing::GpuTimer::features(&adapter)
| cull::Cull::features(&adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
| wgpu::Features::RG11B10UFLOAT_RENDERABLE));
let driver = format!("{} {}", info.driver, info.driver_info);
startup_step(format_args!(
"requesting the device (driver {}) with features [{features}] and the adapter's \
limits (buffers up to {} bytes, storage bindings up to {} bytes, {} texture layers)",
match driver.trim() {
"" => "unnamed",
driver => driver,
},
limits.max_buffer_size,
limits.max_storage_buffer_binding_size,
limits.max_texture_array_layers,
));
let desc = wgpu::DeviceDescriptor {
label: Some("Horizon device"),
required_features: features,
required_limits: limits,
..Default::default()
};
#[cfg(not(target_os = "macos"))]
let opened = match info.backend {
wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc),
_ => None,
};
#[cfg(target_os = "macos")]
let opened = None;
let opened = match opened {
Some(opened) => opened,
None => adapter.request_device(&desc).await,
};
match opened {
Ok((device, queue)) => Ok(Opened {
instance,
surface,
adapter,
device,
queue,
}),
Err(e) => Err(NoDevice::Failed(format!("{name} device: {e}"))),
}
}
/// What to tell a player whose system offers no usable graphics adapter.
/// Vulkan is loaded at run time (the game links no graphics library), so a
/// missing driver shows here rather than as a missing-DLL dialog.
@@ -2530,10 +2596,19 @@ impl Renderer {
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &TERRAIN_ATTRIBUTES,
})];
let info = adapter.get_info();
let min_sections = if flat::is_broken_intel_vulkan(&info)
|| std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
{
0
} else {
flat::MIN_SECTIONS
};
flat::Flat::new(
&device,
&bindings,
arena.page_vertices(),
min_sections,
|layout, label, fragment, blend| {
pipeline_in(
layout,
@@ -2764,6 +2839,7 @@ impl Renderer {
readback: readback::Readback::default(),
info,
suspended: !drawable_size(size.width, size.height, max_side),
acquire_validation_failures: 0,
drawn_sections: 0,
section_draws: 0,
section_draw_calls: 0,
@@ -3765,9 +3841,32 @@ impl Renderer {
return Ok(false);
}
Some(wgpu::CurrentSurfaceTexture::Validation) => {
return Err("surface acquisition failed validation".into());
// Acquire caught a validation error: the swapchain cannot be
// used as configured (wgpu reports a configuration raced with
// a submission this way). Give it a fresh configuration like
// an Outdated one, and once that has not helped, reattach the
// window like a Lost one — a fullscreen loading screen then
// skips frames instead of failing every one of them.
if self.acquire_validation_failures == 0 {
if let Some(surface) = &self.surface {
Self::configure(
&self.device,
surface,
&self.surface_config(),
self.hdr.as_ref(),
);
}
} else {
self.recreate_surface()?;
}
self.acquire_validation_failures =
self.acquire_validation_failures.saturating_add(1);
return Ok(false);
}
};
if surface_frame.is_some() {
self.acquire_validation_failures = 0;
}
let size = [self.config.width, self.config.height];
let target_texture = match (&surface_frame, &mut self.hdr) {
(Some(_), Some(hdr)) => hdr.frame(&self.device, size),
@@ -5947,6 +6046,23 @@ mod tests {
assert_eq!(order(api, "macos"), [Backends::default()]);
}
}
/// The second try at a backend shows the adapters wgpu hides for reporting
/// no Vulkan conformance version (Mesa's hasvk, which drives Ivy Bridge and
/// Haswell, reports 0.0.0.0 and works).
#[test]
fn the_second_try_at_a_backend_shows_nonconformant_adapters() {
let shown = |allow| {
super::instance_descriptor(wgpu::Backends::VULKAN, None, allow)
.flags
.contains(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER)
};
assert!(shown(true));
// The first try keeps wgpu's own filtering, unless `with_env` turns the
// flag on from the environment (WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER).
if std::env::var_os("WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER").is_none() {
assert!(!shown(false));
}
}
/// Every WGSL shader file translates to HLSL as wgpu's DirectX 12 backend
/// writes it for FXC (shader model 5.1), entry point by entry point.
#[test]
@@ -6102,7 +6218,7 @@ mod tests {
#[test]
fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() {
let validated = |backends| {
super::instance_descriptor(backends, None)
super::instance_descriptor(backends, None, false)
.flags
.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)
};
@@ -147,7 +147,7 @@ impl SpriteAnimator {
group,
});
}
let Some(pipeline) = pipeline else {
let Some((pipeline, layout)) = pipeline else {
let groups = keys
.iter()
.zip(&counts)
@@ -193,7 +193,6 @@ impl SpriteAnimator {
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let layout = pipeline.get_bind_group_layout(0);
let groups = keys
.iter()
.zip(&counts)
@@ -364,11 +363,15 @@ impl SpriteAnimator {
}
}
/// `animate.wgsl`'s pipeline, unless the device fails to make it: an OpenGL
/// driver can list compute shaders among its extensions while wgpu writes
/// the GLSL version of its core version, which has none (Mesa's
/// `MESA_GL_VERSION_OVERRIDE`, older drivers).
fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
/// `animate.wgsl`'s pipeline and the bind group layout of its group 0,
/// unless the device fails to make the pipeline: an OpenGL driver can list
/// compute shaders among its extensions while wgpu writes the GLSL version
/// of its core version, which has none (Mesa's `MESA_GL_VERSION_OVERRIDE`,
/// older drivers). A device lost between the scopes' pushes answers pipeline
/// creation with a silent placeholder and reports its invalidity only when
/// the layout is asked for — inside the scopes that error is caught, outside
/// them it is uncaptured and panics (#3866).
fn pipeline(device: &wgpu::Device) -> Option<(wgpu::ComputePipeline, wgpu::BindGroupLayout)> {
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -383,9 +386,10 @@ fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
compilation_options: Default::default(),
cache: None,
});
let layout = pipeline.get_bind_group_layout(0);
let internal = pollster::block_on(internal.pop());
match internal.or(pollster::block_on(validation.pop())) {
None => Some(pipeline),
None => Some((pipeline, layout)),
Some(e) => {
log::warn!("Horizon renderer: animated sprites drawn on the CPU ({e})");
None
+17 -7
View File
@@ -115,7 +115,7 @@ pub struct Flat {
/// (9,300 draws) the CPU saves 1.3 ms. The bar was 4,096 draws when they
/// were 2.2 to 2.5 per section; a section's draw count now depends on the
/// sides it shows the eye (`mesher::Sides`), so it counts sections.
const MIN_SECTIONS: usize = 1600;
pub const MIN_SECTIONS: usize = 1600;
/// Whether flat draws apply: the backend emulates multi-draw, and vertex
/// shaders can read storage buffers large enough for an index page.
@@ -126,7 +126,20 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
Ok("1") => return supported(adapter, device),
_ => {}
}
adapter.get_info().backend == wgpu::Backend::Metal && supported(adapter, device)
let info = adapter.get_info();
(info.backend == wgpu::Backend::Metal || is_broken_intel_vulkan(&info))
&& supported(adapter, device)
}
/// Intel Gen11 (Ice Lake) ANV Vulkan driver has broken `first_instance` for instance-step
/// vertex attributes (#3529), causing terrain sections to be drawn with another section's origin
/// and flicker.
pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Vulkan
&& info.vendor == 0x8086
&& (info.name.contains("ICL")
|| info.name.contains("Ice Lake")
|| (info.device >= 0x8a50 && info.device <= 0x8a71))
}
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
@@ -254,6 +267,7 @@ impl Flat {
device: &wgpu::Device,
terrain_layout: &wgpu::BindGroupLayout,
page_vertices: u64,
min_sections: usize,
pipeline: impl Fn(
&wgpu::PipelineLayout,
&str,
@@ -357,11 +371,7 @@ impl Flat {
phase1_calls: 0,
solid_calls: 0,
shadow_calls: [0; 2],
min_sections: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
0
} else {
MIN_SECTIONS
},
min_sections,
range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16)
.max(1),
}
+108 -5
View File
@@ -10,19 +10,36 @@ const CHUNK: u64 = 1 << 20;
pub struct Uploads {
belt: wgpu::util::StagingBelt,
chunk: u64,
}
impl Uploads {
pub fn new(device: &wgpu::Device) -> Self {
Self::with_chunk(device, CHUNK)
}
/// A belt whose staging buffers hold `chunk` bytes (larger writes get
/// a buffer of their own).
/// A belt whose staging buffers hold `chunk` bytes (a larger write gets
/// a buffer of its own, rounded up to a power of two).
pub fn with_chunk(device: &wgpu::Device, chunk: u64) -> Self {
Self {
belt: wgpu::util::StagingBelt::new(device.clone(), chunk),
chunk,
}
}
/// A mapped staging slice of at least `size` bytes. The belt gives a
/// write larger than its chunk a buffer of exactly that size, keeps it for
/// good and only reuses it for writes that fit: an upload that grows a
/// little every frame (the entity batch while mobs appear) took a new
/// buffer each frame until the game ran out of memory (#2826). Rounded up
/// to a power of two, the large buffers it keeps are reused, one per
/// doubling.
fn allocate(&mut self, size: NonZeroU64, alignment: NonZeroU64) -> wgpu::BufferSlice<'_> {
let size = if size.get() > self.chunk {
size.checked_next_power_of_two().unwrap_or(size)
} else {
size
};
self.belt.allocate(size, alignment)
}
/// Copy tightly packed RGBA8 `pixels` (`width` by `height`) into one mip
/// level of one array layer, padding rows to the copy alignment.
pub fn texture(
@@ -41,7 +58,7 @@ impl Uploads {
};
let alignment = NonZeroU64::new(u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT))
.expect("non-zero alignment");
let slice = self.belt.allocate(size, alignment);
let slice = self.allocate(size, alignment);
{
let mut view = slice
.get_mapped_range_mut()
@@ -80,8 +97,20 @@ impl Uploads {
let Some(size) = NonZeroU64::new(bytes.len() as u64) else {
return;
};
self.belt
.write_buffer(encoder, target, offset, size)
// The belt's own `write_buffer` checks, kept now that a large write's
// slice is rounded up past them.
assert!(
offset.is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT)
&& size.get().is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT),
"buffer upload at {offset} of {size} bytes must be 4-byte aligned"
);
let alignment = NonZeroU64::new(wgpu::COPY_BUFFER_ALIGNMENT).expect("non-zero alignment");
let slice = self.allocate(size, alignment).slice(..size.get());
encoder.copy_buffer_to_buffer(slice.buffer(), slice.offset(), target, offset, size.get());
slice
.get_mapped_range_mut()
.expect("staging belt slices are mapped")
.slice(..)
.copy_from_slice(bytes);
}
/// Close this frame's staging buffers: they come back for reuse once the
@@ -217,6 +246,80 @@ mod tests {
}
}
/// Staging buffers the belt holds, once every submitted frame is done
/// with them. The belt only reports its buffers through `Debug`.
fn kept_buffers(device: &wgpu::Device, uploads: &mut Uploads) -> usize {
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
// Finishing an empty frame takes back every recalled buffer.
uploads.finish(&device.create_command_encoder(&Default::default()));
let belt = format!("{:?}", uploads.belt);
let count = |field: &str| -> usize {
let at = belt.find(&format!("{field}: ")).expect(&belt) + field.len() + 2;
let digits = belt[at..].split(|c: char| !c.is_ascii_digit()).next();
digits.and_then(|d| d.parse().ok()).expect(&belt)
};
count("active_chunks") + count("closed_chunks") + count("free_chunks")
}
/// #2826: a write larger than the chunk that grows a little every frame
/// (the entity batch while mobs appear) got a new exact-size buffer each
/// frame, all of them kept, until the game ran out of memory.
#[test]
fn growing_large_writes_reuse_the_belt_buffers() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
const CHUNK: u64 = 1 << 16;
const TARGET: usize = 4 * CHUNK as usize;
let target = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: TARGET as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: TARGET as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut uploads = Uploads::with_chunk(&device, CHUNK);
let mut expected = vec![0u8; TARGET];
let frames = 64;
for frame in 0..frames {
let mut encoder = device.create_command_encoder(&Default::default());
// One small write, then the large one, 1.5 to 3.5 chunks long.
uploads.buffer(&mut encoder, &target, TARGET as u64 - 4, &[9; 4]);
expected[TARGET - 4..].fill(9);
let len = (3 * CHUNK as usize / 2 + frame * 2048).next_multiple_of(4);
let bytes: Vec<u8> = (0..len).map(|i| (i + frame) as u8).collect();
uploads.buffer(&mut encoder, &target, 0, &bytes);
expected[..len].copy_from_slice(&bytes);
encoder.copy_buffer_to_buffer(&target, 0, &readback, 0, TARGET as u64);
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
readback
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
// Only the written bytes land: the rest of the target is untouched.
assert!(
*readback.slice(..).get_mapped_range().unwrap() == expected[..],
"frame {frame}"
);
readback.unmap();
}
// A chunk for the small writes and a buffer per doubling of the large
// one (2 and 4 chunks), maybe one more where a small write took the
// start of a buffer the large one needed whole: not one per frame.
let kept = kept_buffers(&device, &mut uploads);
assert!(
kept <= 4,
"the belt kept {kept} buffers over {frames} frames"
);
}
#[test]
fn belt_buffer_writes_land_in_order_at_their_offsets() {
let Some((device, queue)) = device() else {
+1
View File
@@ -71,6 +71,7 @@ pub mod signs;
pub mod skins;
pub mod sound_engine;
pub mod sound_events;
pub mod sound_preview;
pub mod spans;
pub mod split_layout;
mod sprite_cache;
+115 -2
View File
@@ -467,8 +467,15 @@ fn pack_lighting(
/// edits and immutable snapshots retained by other workers.
pub fn apply_lighting(chunk: &mut Chunk, lights: Vec<Light>) {
for (section, light) in chunk.sections.iter_mut().zip(lights) {
// A shared section already lit so stays as it is.
if let (Light::Uniform(a), Light::Uniform(b)) = (&section.light, &light)
&& a == b
{
continue;
}
Arc::make_mut(section).light = light;
}
crate::chunk::share_uniform(&mut chunk.sections);
}
/// `apply_lighting` for a chunk lit before: writes only the sections whose
@@ -535,6 +542,7 @@ pub fn light_from_layers<L: AsRef<[u8]>>(
section.light = Light::Full(data);
section.compact();
}
crate::chunk::share_uniform(&mut chunk.sections);
chunk.lit = true;
chunk.light_loaded = true;
}
@@ -658,6 +666,12 @@ impl<'w> Region<'w> {
let slot = self.slot(x, y, z)?;
Some(self.slots[slot].section.get(index(x, y, z)))
}
/// The one state of the section holding `y`, when it has a single one.
fn uniform_state(&mut self, x: i32, y: i32, z: i32) -> Option<u32> {
let slot = self.slot(x, y, z)?;
let section = &self.slots[slot].section;
(section.blocks.bits() == 0).then(|| section.get(0))
}
/// Packed light (sky << 4 | block), None outside loaded, lit chunks.
fn light(&mut self, x: i32, y: i32, z: i32) -> Option<u8> {
let slot = self.slot(x, y, z)?;
@@ -826,6 +840,15 @@ fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) -
return min_y;
};
while y > min_y {
// A section of one state that lets light through itself (the air of
// a tall column) holds no source edge: go to its bottom block.
if let Some(state) = world.uniform_state(x, y - 1, z)
&& state == top
&& !tables.edge_occluded(top as usize, top as usize)
{
y = ((y - 1 - min_y) & !15) + min_y;
continue;
}
let Some(bottom) = world.state(x, y - 1, z) else {
break;
};
@@ -856,6 +879,23 @@ pub fn relight(
relight_region(&mut region, tables, sky_light, positions, force, changed);
region.finish();
}
/// The columns `changed` touched that border a loaded column not lit yet
/// (`unlit`). `relight` reads and writes only lit columns, so an edit's
/// darkening can take away light such a neighbour sends in and never put it
/// back, and nothing relights the column when that neighbour's own light
/// lands: an air block at 0 beside one at 6 (#2786). Vanilla's light engine
/// keeps one light storage for every chunk, so its edits and its chunk
/// lighting see each other. These columns need a full light job, which
/// lights them from the blocks of their whole 3x3, that neighbour's included.
pub fn beside_unlit(changed: &LightChanges, unlit: impl Fn(i32, i32) -> bool) -> Vec<(i32, i32)> {
let mut columns: Vec<(i32, i32)> = changed.sections().map(|(x, _, z)| (x, z)).collect();
columns.sort_unstable();
columns.dedup();
columns.retain(|&(x, z)| {
(-1..=1).any(|dz| (-1..=1).any(|dx| (dx, dz) != (0, 0) && unlit(x + dx, z + dz)))
});
columns
}
fn relight_region(
world: &mut Region,
tables: &LightTables,
@@ -1109,6 +1149,70 @@ mod tests {
// Without the shape test the room would be lit (slabs have no dampening).
assert_eq!(sky(&w, 8, 2, 8), 0, "the full bottom face stops sky light");
}
/// #2786: (1, 0) is loaded but its own light job hasn't landed; (0, 0)
/// was lit from the 3x3's blocks, its torch's light and the far torch's
/// in (1, 0) both. Taking its torch away darkens everything the torch
/// lit, the far torch's light at the border too, and `relight` cannot
/// bring that back from a column it doesn't read. Columns like that are
/// lit again whole; without it the border stays at 0 for good once
/// (1, 0)'s light lands beside it.
#[test]
fn an_edit_beside_a_column_not_lit_yet_lights_the_column_again() {
let tables = tables();
let mut w: HashMap<(i32, i32), Arc<Chunk>> = HashMap::new();
for z in -1..=1 {
for x in -1..=1 {
let mut c = Chunk::empty(x, z);
for y in MIN_Y..0 {
for i in 0..256 {
c.set(i & 15, y, i >> 4, STONE);
}
}
if (x, z) == (0, 0) {
c.set(14, 0, 8, TORCH);
} else if (x, z) == (1, 0) {
c.set(6, 0, 8, TORCH);
}
w.insert((x, z), Arc::new(c));
}
}
let light = |w: &HashMap<(i32, i32), Arc<Chunk>>, (x, z): (i32, i32)| {
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| {
w.get(&(x + dx as i32 - 1, z + dz as i32 - 1)).map(|c| &**c)
})
});
light_chunk(&region, &tables, true)
};
let keys: Vec<_> = w.keys().copied().collect();
for key in &keys {
let lights = light(&w, *key);
let c = Arc::make_mut(w.get_mut(key).unwrap());
apply_lighting(c, lights);
c.lit = *key != (1, 0);
}
// The far torch at x 22 gives 7 at x 15; (0, 0)'s own torch gives 13.
assert_eq!(block(&w, 15, 0, 8), 13);
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(14, 0, 8, AIR);
let mut changed = LightChanges::default();
relight(&mut w, &tables, true, &[(14, 0, 8)], false, &mut changed);
let again = beside_unlit(&changed, |x, z| w.get(&(x, z)).is_some_and(|c| !c.lit));
for &key in &again {
let lights = light(&w, key);
let c = Arc::make_mut(w.get_mut(&key).unwrap());
apply_relighting(c, lights, &mut LightChanges::default());
}
// (1, 0)'s own job lands.
let lights = light(&w, (1, 0));
let c = Arc::make_mut(w.get_mut(&(1, 0)).unwrap());
apply_lighting(c, lights);
c.lit = true;
assert_eq!(block(&w, 16, 0, 8), 8);
assert_eq!(block(&w, 15, 0, 8), 7, "air beside 8 across the border");
assert_eq!(block(&w, 14, 0, 8), 6);
assert!(again.contains(&(0, 0)), "{again:?}");
}
#[test]
fn relight_keeps_unchanged_and_unlit_sections() {
let mut w = world(|_| {});
@@ -1271,6 +1375,14 @@ mod tests {
assert_eq!(chunk.light(8, 1601, 8) >> 4, 15);
assert_eq!(chunk.light(8, 1599, 8) >> 4, 0, "under the roof");
assert_eq!(chunk.light(8, 2031, 8), 0xF0);
// The empty sections above are one shared allocation.
let top = chunk.sections.len() - 1;
assert!(Arc::ptr_eq(&chunk.sections[top], &chunk.sections[top - 10]));
let other = Chunk::with_layout(1, 0, layout);
apply_lighting(&mut other.clone(), vec![Light::Uniform(0xF0); 131]);
let mut lit = other.clone();
apply_lighting(&mut lit, vec![Light::Uniform(0xF0); 131]);
assert!(Arc::ptr_eq(&chunk.sections[top], &lit.sections[top]));
}
#[test]
fn a_tall_column_relights_as_its_full_light_and_keys_its_sections() {
@@ -1545,9 +1657,10 @@ mod tests {
.iter()
.all(|v| matches!(v, Light::Uniform(n) if *n == if sky { 0xF0 } else { 0 }))
);
let section = Arc::as_ptr(&chunk.sections[0]);
apply_lighting(&mut chunk, lights);
assert_eq!(section, Arc::as_ptr(&chunk.sections[0]));
// Uniform sections are one shared allocation (`share_uniform`).
let first = &chunk.sections[0];
assert!(chunk.sections.iter().all(|s| Arc::ptr_eq(s, first)));
}
}
+194 -12
View File
@@ -186,8 +186,15 @@ impl Column {
self.push(span);
}
}
/// `kinds`: `state_kinds`, per block state.
fn from_chunk(chunk: &Chunk, kinds: &[u8], x: i32, z: i32) -> Self {
/// `kinds`: `state_kinds`, per block state; `water`: `water_source(kinds)`,
/// what blocks holding water (kelp, seagrass, waterlogged ones) are drawn as.
fn from_chunk(
chunk: &Chunk,
kinds: &[u8],
water: Option<BlockStateId>,
x: i32,
z: i32,
) -> Self {
let kind = |state: BlockStateId| kinds.get(state.0 as usize).copied().unwrap_or(0);
let face_light = |y: i32| {
let mut sky = 0;
@@ -212,7 +219,7 @@ impl Column {
};
let mut column = Self::default();
for (sy, section) in chunk.sections.iter().enumerate() {
let min = MIN_Y + sy as i32 * 16;
let min = chunk.min_y() + sy as i32 * 16;
let uniform = section.blocks.bits() == 0
&& section.biomes.bits() == 0
&& matches!(section.light, crate::chunk::Light::Uniform(_));
@@ -222,11 +229,17 @@ impl Column {
let step = if uniform { 16 } else { 1 };
for offset in (0..16).step_by(step) {
let y = min + offset;
let state = BlockStateId(chunk.get(x, y, z));
let kind = kind(state);
let mut state = BlockStateId(chunk.get(x, y, z));
let mut kind = kind(state);
if kind & KEPT == 0 {
continue;
}
if kind & WATERY != 0
&& let Some(water) = water
{
state = water;
kind = kinds[water.0 as usize];
}
let light = face_light(y);
column.push(Span {
min: y as i16,
@@ -256,7 +269,7 @@ impl Column {
&& let Some((height, state)) = water
{
column.surface = Some(Surface {
height: MIN_Y,
height: chunk.min_y(),
state: BlockStateId(
kinds.iter().position(|&kind| kind & AIR != 0).unwrap_or(0) as u32
),
@@ -269,15 +282,28 @@ impl Column {
}
/// A chunk's 256 columns (`Column::from_chunk`), on the far-terrain worker.
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, (i & 15) as i32, (i >> 4) as i32)
Column::from_chunk(chunk, kinds, water, (i & 15) as i32, (i >> 4) as i32)
}))
}
/// `state_kinds` bits: kept in columns, a fluid, the air state; the light
/// emission is in the high nibble.
/// The water source state: the first plain kept fluid without light
/// (`minecraft:water` comes before lava, and its level 0 first).
fn water_source(kinds: &[u8]) -> Option<BlockStateId> {
kinds
.iter()
.position(|&kind| kind == KEPT | FLUID)
.map(|i| BlockStateId(i as u32))
}
/// `state_kinds` bits: kept in columns, a fluid, the air state, a block
/// holding water; the light emission is in the high nibble.
const KEPT: u8 = 1;
const FLUID: u8 = 2;
const AIR: u8 = 4;
/// A block holding water that draws as none (kelp, seagrass, bubble columns,
/// waterlogged blocks that aren't kept): far terrain draws water there, or
/// kelp reaching the surface leaves holes in the sea.
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> {
@@ -296,14 +322,29 @@ fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|| f.full_collision
|| f.name.ends_with("_leaves")
|| chorus
|| fluid);
(u8::from(kept) * KEPT)
| (u8::from(fluid) * FLUID)
|| fluid
|| nearly_full(&f.name));
let watery = !kept && !facts.is_air(state) && f.fluid == crate::blocks::Fluid::Water;
(u8::from(kept || watery) * KEPT)
| (u8::from(fluid || watery) * FLUID)
| (u8::from(watery) * WATERY)
| (u8::from(state == facts.air) * AIR)
| (f.emission.min(15) << 4)
})
.collect()
}
/// Blocks that fill their cell but neither draw solid nor collide as a full
/// block: far terrain draws them as blocks, or patches of powder snow leave
/// holes in mountains (and paths and fields dips).
fn nearly_full(name: &str) -> bool {
matches!(
name,
"minecraft:powder_snow"
| "minecraft:dirt_path"
| "minecraft:farmland"
| "minecraft:honey_block"
) || name.ends_with("_stairs")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct TileKey {
pub x: i32,
@@ -1085,6 +1126,16 @@ impl Lod {
})
.copied()
.collect();
// Retiring fine columns are empty stand-ins, excluded from `active`
// while their normal meshes still cover the gap. Request their detail
// explicitly: neither the active-tile requests nor sampling an already
// ready stand-in can fill it, so its GPU sections would never retire.
keys.extend(retiring_detail(
&self.plan,
&self.retiring,
&self.saved,
&self.detail_masks,
));
keys.sort_unstable_by_key(|key| (key.distance(camera), *key));
let mut chunks = FxHashSet::default();
for key in keys {
@@ -1589,11 +1640,84 @@ impl Drop for Lod {
}
}
}
/// Fine retiring stand-ins need actual column detail before `Plan::replaces`
/// can release their normal meshes. The caller keeps the usual 32-column
/// request budget and waits for the replacement mesh before changing cutout.
fn retiring_detail<'a>(
plan: &'a Plan,
retiring: &'a FxHashSet<(i32, i32)>,
saved: &'a Saved,
masks: &'a FxHashMap<TileKey, [u64; 4]>,
) -> impl Iterator<Item = TileKey> + 'a {
retiring.iter().filter_map(move |&(x, z)| {
let key = TileKey { x, z, level: 0 };
(plan.nodes.contains(&key)
&& !saved.contains_key(&(x, z))
&& masks.get(&key) != Some(&[u64::MAX; 4]))
.then_some(key)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mesher::TerrainVertex;
#[test]
fn retiring_fine_stand_ins_request_detail_before_their_gpu_meshes_can_retire() {
let column = (12, 0);
let fine = TileKey {
x: 12,
z: 0,
level: 0,
};
let mut plan = Plan::default();
plan.nodes.insert(fine);
let ready: FxHashMap<TileKey, ()> = [(fine, ())].into_iter().collect();
let retiring = [column].into_iter().collect();
let saved = Saved::default();
let mut masks = FxHashMap::default();
// A covered stand-in is ready, but empty: ordinary sampling skips it,
// and selection leaves it out of `active`. It cannot yet replace the
// old full-detail column, even though the near world has unloaded it.
assert!(!plan.replaces(column, &ready, |key| masks.get(&key)
== Some(&[u64::MAX; 4])));
assert_eq!(
retiring_detail(&plan, &retiring, &saved, &masks).collect::<Vec<_>>(),
[fine]
);
// The existing RequestDetail -> apply_fine path marks complete detail;
// only then may selection retire it and publish the new mesh cutout.
masks.insert(fine, [u64::MAX; 4]);
assert!(
retiring_detail(&plan, &retiring, &saved, &masks)
.next()
.is_none()
);
assert!(plan.replaces(column, &ready, |key| masks.get(&key)
== Some(&[u64::MAX; 4])));
}
#[test]
fn retiring_detail_requests_only_planned_columns_still_missing_detail() {
let fine = |x| TileKey { x, z: 0, level: 0 };
let mut plan = Plan::default();
plan.nodes.extend((0..4).map(fine));
plan.nodes.insert(fine(5));
let retiring = (0..5).map(|x| (x, 0)).collect();
let saved = [((1, 0), Arc::new(std::array::from_fn(|_| Column::default())))]
.into_iter()
.collect();
let masks = [
(fine(2), [u64::MAX; 4]),
(fine(3), [u64::MAX, u64::MAX, u64::MAX, u64::MAX - 1]),
]
.into_iter()
.collect();
let mut requested: Vec<_> = retiring_detail(&plan, &retiring, &saved, &masks).collect();
requested.sort_unstable();
// Saved or complete detail needs no request; an unplanned retiree and
// a planned column that is not retiring must not consume the budget.
assert_eq!(requested, [fine(0), fine(3)]);
}
#[test]
fn reduced_far_detail_only_merges_tiles_away_from_the_near_terrain() {
let leaves = |plan: &Plan| {
plan.nodes
@@ -1840,6 +1964,40 @@ mod tests {
}
}
#[test]
fn nearly_full_blocks_are_kept() {
assert!(nearly_full("minecraft:powder_snow"));
assert!(nearly_full("minecraft:dirt_path"));
assert!(nearly_full("minecraft:oak_stairs"));
assert!(!nearly_full("minecraft:oak_fence"));
assert!(!nearly_full("minecraft:wheat"));
}
#[test]
fn kelp_to_the_surface_is_drawn_as_water() {
// 0 air, 1 stone, 2 water, 3 kelp (holds water, draws as none).
let kinds = [AIR, KEPT, KEPT | FLUID, KEPT | FLUID | WATERY];
let mut chunk = Chunk::empty(0, 0);
for x in [3, 4] {
for y in MIN_Y..40 {
chunk.set(x, y, 3, 1);
}
}
for y in 40..63 {
chunk.set(3, y, 3, 3);
chunk.set(4, y, 3, 2);
}
let water = water_source(&kinds);
assert_eq!(water, Some(BlockStateId(2)));
let kelp = Column::from_chunk(&chunk, &kinds, water, 3, 3);
let sea = Column::from_chunk(&chunk, &kinds, water, 4, 3);
assert_eq!(kelp.spans, sea.spans);
assert_eq!(
kelp.spans.last().map(|s| (s.min, s.max, s.state)),
Some((40, 63, BlockStateId(2)))
);
assert_eq!(kelp.surface.unwrap().water, Some((63, BlockStateId(2))));
assert_eq!(kelp.surface.unwrap().height, 40);
}
#[test]
fn coarse_voxels_reduce_the_area_instead_of_inflating_one_leaf() {
let span = |min, max, state| Span {
min,
@@ -2035,6 +2193,24 @@ mod tests {
chunk.set(8, 71, 8, chorus.0);
chunk.set(8, 72, 8, chorus.0);
chunk.set(8, 73, 8, flower.0);
// Kelp to the surface is water; powder snow and a waterlogged stair
// are kept as themselves.
let state = |name: &str| assets.registry.default_state(name).unwrap();
let (kelp, powder_snow) = (state("minecraft:kelp"), state("minecraft:powder_snow"));
let stair = facts
.states
.iter()
.position(|f| &*f.name == "minecraft:oak_stairs" && f.waterlogged)
.map(|i| BlockStateId(i as u32))
.unwrap();
chunk.set(2, 54, 2, stone.0);
for y in 55..64 {
chunk.set(2, y, 2, kelp.0);
}
chunk.set(6, 90, 6, stone.0);
chunk.set(6, 91, 6, powder_snow.0);
chunk.set(10, 54, 10, stone.0);
chunk.set(10, 55, 10, stair.0);
lod.observe(Arc::new(chunk.clone()), &facts);
receive_observed(&mut lod);
let observed = &lod.saved[&(-1, -1)];
@@ -2052,6 +2228,12 @@ mod tests {
.collect::<Vec<_>>(),
vec![(70, 71, end_stone), (71, 73, chorus), (73, 74, flower)]
);
let kelp_column = &observed[2 * 16 + 2];
assert_eq!(kelp_column.surface.unwrap().height, 55);
assert_eq!(kelp_column.surface.unwrap().water, Some((64, water)));
assert_eq!(observed[6 * 16 + 6].surface.unwrap().state, powder_snow);
assert_eq!(observed[6 * 16 + 6].surface.unwrap().height, 92);
assert_eq!(observed[10 * 16 + 10].surface.unwrap().state, stair);
let fine = TileKey {
x: -1,
+13 -2
View File
@@ -45,9 +45,20 @@ pub fn data_dir() -> PathBuf {
std::env::temp_dir()
}
/// Where the launcher runs the game and keeps its saves: `HORIZON_HOME`, else `horizon` in the
/// platform data folder (launcher/src/backend.rs `game_home`).
/// `--home DIR`, set once at start; it wins over `HORIZON_HOME`.
static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// Makes `game_home` return `home` for the rest of the run (`--home`).
pub fn set_game_home(home: PathBuf) {
let _ = HOME.set(home);
}
/// Where the launcher runs the game and keeps its saves: `--home`, else `HORIZON_HOME`, else
/// `horizon` in the platform data folder (launcher/src/backend.rs `game_home`).
pub fn game_home() -> PathBuf {
if let Some(home) = HOME.get() {
return home.clone();
}
match std::env::var_os("HORIZON_HOME").filter(|h| !h.is_empty()) {
Some(h) => PathBuf::from(h),
None => data_dir().join("horizon"),
+8 -7
View File
@@ -8,7 +8,7 @@
//! changed.
use crate::{
blocks::{BlockFacts, Fluid, MAP_COLORS},
chunk::{Chunk, MIN_Y, SECTIONS},
chunk::Chunk,
fast_hash::FxHashMap,
world::World,
};
@@ -113,7 +113,8 @@ fn scan_column(
return (color | WALL, head as i16, 0);
}
}
while y > MIN_Y {
let bottom = chunk.min_y();
while y > bottom {
let (mut color, flags) = palette.get(chunk.get(x, y, z));
if color == 0 {
y -= 1;
@@ -123,7 +124,7 @@ fn scan_column(
let fluid = flags & FLUID;
if fluid != 0 {
let mut below = y - 1;
while below > MIN_Y && palette.get(chunk.get(x, below, z)).1 & FLUID != 0 {
while below > bottom && palette.get(chunk.get(x, below, z)).1 & FLUID != 0 {
depth = depth.saturating_add(1);
below -= 1;
}
@@ -134,11 +135,11 @@ fn scan_column(
}
return (color, y as i16, depth);
}
(0, MIN_Y as i16, 0)
(0, bottom as i16, 0)
}
/// The highest block of a chunk worth scanning from.
fn top_of(chunk: &Chunk, air: u32, layer: Layer) -> i32 {
let surface = (0..SECTIONS)
let surface = (0..chunk.sections.len())
.rev()
.find(|&s| {
chunk
@@ -146,7 +147,7 @@ fn top_of(chunk: &Chunk, air: u32, layer: Layer) -> i32 {
.get(s)
.is_some_and(|sec| !sec.is_uniform(air))
})
.map_or(MIN_Y, |s| MIN_Y + s as i32 * 16 + 15);
.map_or(chunk.min_y(), |s| chunk.min_y() + s as i32 * 16 + 15);
match layer {
Layer::Surface => surface,
Layer::Below(head) => surface.min(head),
@@ -156,7 +157,7 @@ fn scan_tile(chunk: &Chunk, palette: &Palette, air: u32, layer: Layer, revision:
let top = top_of(chunk, air, layer);
let mut tile = Tile {
color: [0; 256],
height: [MIN_Y as i16; 256],
height: [chunk.min_y() as i16; 256],
depth: [0; 256],
revision,
layer,
@@ -45,6 +45,7 @@ pub struct ModelInstance {
pub layer: u32,
pub shade: f32,
}
#[derive(Default)]
pub struct PreparedModels {
pub catalog: StateModels,
pub placements: Vec<(BlockPos, BlockStateId)>,
@@ -42,6 +42,9 @@ pub struct BakedModel {
pub cube: Option<[super::cube::CubeStyle; 6]>,
}
type VariantKey = (String, [u8; 3], bool);
/// Empty by default: no state has a model (a dedicated server, which draws
/// nothing).
#[derive(Default)]
pub struct StateModels {
/// Each definition's choices, a variant standing as its index in `baked`.
choices: Vec<CompiledChoices<u32>>,
+64 -8
View File
@@ -141,9 +141,7 @@ impl Opt {
| Opt::ChunkFade
| Opt::ImprovedTransparency
| Opt::ChunkBuilder
// Saved, but the creative tabs, the quit keys and the mouse
// don't read them yet.
| Opt::OperatorItemsTab
// Saved, but the quit keys and the mouse don't read them yet.
| Opt::QuitShortcuts
| Opt::CtrlClickEmulatesRightClick
)
@@ -952,6 +950,24 @@ pub fn set_slider(opt: Opt, s: &mut Settings, v: f32) {
apply_preset(s);
}
}
/// A slider pressed or dragged to `v`: apply it live, and tell the game when
/// a category volume changed (`Options.createSoundSliderOptionInstance`'s
/// callback, which `OptionInstance.set` runs only for a new value).
fn drag_slider(opt: Opt, s: &mut Settings, v: f32, events: &mut Vec<Event>) {
let volume = |s: &Settings| match opt {
Opt::Volume(0) => Some(s.master_volume),
Opt::Volume(i) => Some(s.volumes[i]),
_ => None,
};
let before = volume(s);
set_slider(opt, s, v);
events.push(Event::SliderDrag);
if let (Opt::Volume(source), Some(after)) = (opt, volume(s))
&& before != Some(after)
{
events.push(Event::Volume(source, after));
}
}
/// GraphicsPreset.apply: the options Fast, Fancy and Fabulous set (texture
/// filtering and anisotropy have no option here yet).
pub fn apply_preset(s: &mut Settings) {
@@ -1106,6 +1122,9 @@ pub enum Event {
Changed,
/// A slider is being dragged: apply settings live but defer saving.
SliderDrag,
/// A category volume slider (`SoundSource` index) moved to a new value,
/// after its `SliderDrag` (the sound slider's `onValueUpdate`).
Volume(usize, f32),
/// The language changed (reload translations).
Language(String),
/// The pack selection changed (reload resources).
@@ -2747,8 +2766,7 @@ impl OptionsUi {
{
let v = ui::slider_value(wd.x, wd.w, x);
self.slider_value = Some((opt, v, Instant::now()));
set_slider(opt, s, v);
events.push(Event::SliderDrag);
drag_slider(opt, s, v, &mut events);
}
return events;
}
@@ -2861,8 +2879,7 @@ impl OptionsUi {
self.dragging = Some(opt);
let v = ui::slider_value(wd.x, wd.w, x);
self.slider_value = Some((opt, v, Instant::now()));
set_slider(opt, s, v);
events.push(Event::SliderDrag);
drag_slider(opt, s, v, &mut events);
}
WKind::LanguageRow { code, .. } => {
if self.language_choice.as_deref() == Some(code.as_str()) {
@@ -3415,6 +3432,16 @@ mod tests {
assert!(loaded.toggle_attack && loaded.toggle_use);
}
#[test]
fn operator_items_tab_option_can_be_switched() {
// The creative screen reads it (CreativeModeInventoryScreen.hasPermissions).
assert!(Opt::OperatorItemsTab.available());
let mut s = Settings::default();
assert!(!s.operator_items_tab);
Opt::OperatorItemsTab.cycle(&mut s, false, [854, 480]);
assert!(s.operator_items_tab);
}
#[test]
fn render_distance_slider_round_trips_through_extended_options_range() {
let mut s = Settings::default();
@@ -4093,7 +4120,7 @@ mod tests {
for cx in [-100.0, 250.0, 1000.0] {
assert_eq!(
ui.click(&mut s, &atlas, cx, -100.0, size, [854, 480], true, false),
vec![Event::SliderDrag]
vec![Event::SliderDrag, Event::Volume(0, s.master_volume)]
);
}
assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]);
@@ -4103,6 +4130,35 @@ mod tests {
);
}
#[test]
fn a_moved_volume_slider_reports_its_category_and_value() {
let atlas = atlas();
let size = (480.0, 320.0);
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Sound);
let hostile = SOUND_SOURCES.iter().position(|n| *n == "hostile").unwrap();
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
let slider = widgets
.iter()
.find(|wd| matches!(wd.kind, WKind::Slider { opt: Opt::Volume(i), .. } if i == hostile))
.unwrap();
let y = slider.y + 1.0;
let x = slider.x + 4.0 + (slider.w - 8.0) * 0.5;
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
let volume = s.volumes[hostile];
assert!((volume - 0.5).abs() < 1e-3, "{volume}");
assert_eq!(
events,
vec![Event::SliderDrag, Event::Volume(hostile, volume)]
);
// OptionInstance.set: an unchanged value runs no callback.
assert_eq!(
ui.click(&mut s, &atlas, x, -50.0, size, [854, 480], true, false),
vec![Event::SliderDrag]
);
}
#[test]
fn classic_sliders_follow_the_pointer_and_snap_on_release() {
let atlas = atlas();
+25 -1
View File
@@ -1042,6 +1042,8 @@ pub struct ParticleEngine {
level_random: LegacyRandom,
/// `SpellParticle.RANDOM`.
spell_random: LegacyRandom,
/// The local player's eye while it looks through a spyglass in first person.
scope_eye: Option<[f64; 3]>,
/// Seeds for each particle's own random (`RandomSource.create()`).
seeds: LegacyRandom,
/// When set, particles ask the level for other particles here instead
@@ -1080,6 +1082,7 @@ impl ParticleEngine {
random: LegacyRandom::new(0x7061_7274),
level_random: LegacyRandom::new(0x6c65_7665),
spell_random: LegacyRandom::new(0x7370_656c),
scope_eye: None,
seeds: LegacyRandom::new(0x7365_6564),
requested: None,
names: Vec::new(),
@@ -1437,6 +1440,16 @@ impl ParticleEngine {
}
}
}
/// Set each frame to the local player's eye while it scopes in first person.
pub fn set_scope_eye(&mut self, eye: Option<[f64; 3]>) {
self.scope_eye = eye;
}
/// `SpellParticle.isCloseToScopingPlayer`: within 3 blocks of that eye.
fn close_to_scoping_player(&self, pos: [f64; 3]) -> bool {
self.scope_eye.is_some_and(|eye| {
(eye[0] - pos[0]).powi(2) + (eye[1] - pos[1]).powi(2) + (eye[2] - pos[2]).powi(2) <= 9.0
})
}
/// `ClientPacketListener.handleParticleEvent` for `ServerLevel.sendParticles`.
pub fn send_particles(
&mut self,
@@ -2636,6 +2649,12 @@ impl ParticleEngine {
q.size *= 0.75;
q.lifetime = (8.0 / (f64::from(r.next_float()) * 0.8 + 0.2)) as i32;
q.has_physics = false;
// SpellParticle's constructor: setAlpha(0.0F), which also sets originalAlpha.
// MobEffectProvider sets the option's alpha after it, so an entity
// effect starts visible and the first tick hides it.
if self.close_to_scoping_player(q.position) {
q.rgba[3] = 0.0;
}
match ty {
T::Witch => {
let b = self.random.next_float() * 0.5 + 0.35;
@@ -3743,7 +3762,12 @@ impl ParticleEngine {
Behaviour::Spell { original_alpha } => {
p.base_tick(level);
self.sprite_from_age(p);
p.rgba[3] += (original_alpha - p.rgba[3]) * 0.05;
// SpellParticle.tick: alpha 0 while close, else eased back to originalAlpha.
if self.close_to_scoping_player(p.position) {
p.rgba[3] = 0.0;
} else {
p.rgba[3] += (original_alpha - p.rgba[3]) * 0.05;
}
}
Behaviour::Cloud => {
p.base_tick(level);
@@ -50,6 +50,9 @@ pub fn sound_source(event: &str) -> usize {
index("ambient")
} else if e.starts_with("music_disc.") {
index("record")
} else if e.starts_with("item.goat_horn.") {
// InstrumentItem.play: SoundSource.RECORDS.
index("record")
} else if e.starts_with("music.") {
index("music")
} else if e.starts_with("weather.") || e.starts_with("entity.lightning_bolt") {
@@ -0,0 +1,179 @@
//! `SoundPreviewHandler`: the sound a category volume slider plays while it
//! moves on the title screen's options (`Options.createSoundSliderOptionInstance`
//! calls `preview` when there is no level).
use crate::settings::SOUND_SOURCES;
use crate::sound_engine::{InstanceId, SoundEngine, SoundInstance, Sounds};
/// The sound `SoundPreviewHandler.preview` plays for a `SoundSource` index,
/// or `None` (`SoundEvents.EMPTY`) for master, music and voice.
pub fn preview_event(source: usize) -> Option<&'static str> {
Some(match *SOUND_SOURCES.get(source)? {
"record" => "minecraft:block.note_block.guitar",
"weather" => "minecraft:entity.lightning_bolt.thunder",
"block" => "minecraft:block.grass.place",
"hostile" => "minecraft:entity.zombie.ambient",
// SoundEvents.COW_SOUNDS.get(CowSoundVariants.SoundSet.CLASSIC).ambientSound()
"neutral" => "minecraft:entity.cow.ambient",
"player" => "minecraft:entity.generic.eat",
"ambient" => "minecraft:ambient.cave",
"ui" => "minecraft:ui.button.click",
_ => return None,
})
}
/// `SoundPreviewHandler`'s `activePreview` and `previousCategory`.
#[derive(Debug, Default)]
pub struct SoundPreview {
active: Option<InstanceId>,
previous: Option<usize>,
}
impl SoundPreview {
/// `SoundPreviewHandler.preview(soundManager, category, volume)`: stop the
/// preview of another category, then, unless the last preview still
/// plays, play this category's sound (`SimpleSoundInstance.forUI` at
/// pitch 1 and the slider's volume).
pub fn preview(&mut self, sounds: &mut impl Sounds, source: usize, volume: f32) {
self.stop_other_category_preview(sounds, source);
if !self.can_play_sound(sounds) {
return;
}
let Some(event) = preview_event(source) else {
return;
};
if let Some(old) = self.active.take() {
sounds.release(old);
}
let instance = SoundInstance::for_ui(event, 1.0, volume, SoundEngine::unseeded());
self.active = Some(sounds.play_instance(instance, true).0);
}
/// The preview playing now, if any.
pub fn active(&self) -> Option<InstanceId> {
self.active
}
/// `stopOtherCategoryPreview`.
fn stop_other_category_preview(&mut self, sounds: &mut impl Sounds, source: usize) {
if self.previous != Some(source) {
self.previous = Some(source);
if let Some(active) = self.active {
sounds.stop_instance(active);
}
}
}
/// `canPlaySound`: no preview yet, or the last one has finished.
fn can_play_sound(&self, sounds: &mut impl Sounds) -> bool {
self.active.is_none_or(|active| !sounds.is_active(active))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sound_engine::{Bound, TickContext, UI, test_backend::Null};
use crate::sound_events::SoundRegistry;
fn engine() -> SoundEngine {
let mut registry = SoundRegistry::default();
registry.add_file(
"minecraft",
br#"{"entity.zombie.ambient": {"sounds": ["mob/zombie/say1"]},
"entity.cow.ambient": {"sounds": ["mob/cow/say1"]},
"ui.button.click": {"sounds": ["random/click"]}}"#,
&|_, _| true,
);
SoundEngine::new(registry)
}
fn source(name: &str) -> usize {
SOUND_SOURCES.iter().position(|s| *s == name).unwrap()
}
#[test]
fn each_category_previews_its_vanilla_sound() {
let expected = [
("master", None),
("music", None),
("record", Some("minecraft:block.note_block.guitar")),
("weather", Some("minecraft:entity.lightning_bolt.thunder")),
("block", Some("minecraft:block.grass.place")),
("hostile", Some("minecraft:entity.zombie.ambient")),
("neutral", Some("minecraft:entity.cow.ambient")),
("player", Some("minecraft:entity.generic.eat")),
("ambient", Some("minecraft:ambient.cave")),
("voice", None),
("ui", Some("minecraft:ui.button.click")),
];
for (name, event) in expected {
assert_eq!(preview_event(source(name)), event, "{name}");
}
}
fn slide(
preview: &mut SoundPreview,
engine: &mut SoundEngine,
backend: &mut Null,
name: &str,
volume: f32,
) {
preview.preview(&mut Bound { engine, backend }, source(name), volume);
}
/// Ticks enough for the engine to drop finished or stopped channels.
fn settle(engine: &mut SoundEngine, backend: &mut Null) {
for _ in 0..40 {
engine.tick(false, &TickContext::default(), backend);
}
}
#[test]
fn a_moving_slider_plays_one_preview_at_a_time_and_switching_stops_it() {
let (mut engine, mut backend) = (engine(), Null::default());
let mut preview = SoundPreview::default();
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.4);
let first = preview
.active()
.expect("the hostile slider plays a preview");
let instance = engine.instance(first).unwrap();
assert_eq!(instance.identifier, "minecraft:entity.zombie.ambient");
assert_eq!(instance.source, UI);
assert_eq!((instance.volume, instance.pitch), (0.4, 1.0));
assert!(instance.relative);
assert!(engine.is_active(first));
// canPlaySound: still playing, so dragging further does not restart it.
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.6);
assert_eq!(preview.active(), Some(first));
assert_eq!(engine.active_instances(), vec![first]);
// Once it has finished, the next move plays it again.
for stopped in backend.stopped.values_mut() {
*stopped = true;
}
settle(&mut engine, &mut backend);
assert!(!engine.is_active(first));
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.6);
let second = preview.active().unwrap();
assert_ne!(second, first);
assert!(engine.is_active(second));
// Another category stops the playing preview (SoundManager.stop); the
// engine lets its channel go on the next ticks, then that category's
// own sound plays.
slide(&mut preview, &mut engine, &mut backend, "neutral", 1.0);
assert!(backend.stopped.values().all(|stopped| *stopped));
settle(&mut engine, &mut backend);
assert!(engine.active_instances().is_empty());
slide(&mut preview, &mut engine, &mut backend, "neutral", 1.0);
let third = preview.active().unwrap();
assert_eq!(
engine.instance(third).unwrap().identifier,
"minecraft:entity.cow.ambient"
);
// Master has no sound but still stops the other category's preview.
slide(&mut preview, &mut engine, &mut backend, "master", 1.0);
settle(&mut engine, &mut backend);
assert!(engine.active_instances().is_empty());
slide(&mut preview, &mut engine, &mut backend, "master", 1.0);
assert!(engine.active_instances().is_empty());
}
}
@@ -3,6 +3,7 @@
//! hands immutable `Arc` snapshots to workers; results carry a sequence number
//! so stale work is dropped.
use crate::fast_hash::{FxHashMap, FxHashSet};
use crate::section_mask::SectionMask;
use crate::{
chunk::Chunk,
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
@@ -81,6 +82,12 @@ pub trait ChunkGenerator: Send + Sync + 'static {
fn light_view(&self, _proto: &Proto) -> Option<Chunk> {
None
}
/// A decorated proto chunk's column sections (`Chunk::sections`
/// indexes) holding a block other than air: what its neighbours' light
/// layers count once it is at INITIALIZE_LIGHT.
fn non_air_sections(&self, _proto: &Proto) -> Option<SectionMask> {
None
}
fn saved_proto(&self, _proto: &Proto) -> Option<crate::chunk::SavedProto> {
None
}
@@ -275,6 +282,7 @@ impl Mailbox {
pub struct Streaming {
shared: Arc<Shared>,
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
threads: Vec<thread::JoinHandle<()>>,
order: u64,
/// Ticks since the columns and protos that fell out of range were swept.
@@ -653,9 +661,14 @@ impl Streaming {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.light_view(p))
};
let non_air_sections = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.non_air_sections(p))
};
Self {
shared,
light_view,
non_air_sections,
threads,
order: 0,
scan_age: 0,
@@ -956,6 +969,15 @@ impl Streaming {
pub fn is_lit(&self, x: i32, z: i32) -> bool {
self.lit.contains(&(x, z))
}
/// The non-air sections of a column that is a decorated proto chunk
/// here (vanilla's INITIALIZE_LIGHT and on), if it is one: a level
/// chunk's light layers count them (#4294).
pub fn proto_sections(&self, x: i32, z: i32) -> Option<SectionMask> {
if !self.decorated.contains(&(x, z)) {
return None;
}
(self.non_air_sections)(self.protos.get(&(x, z))?.as_ref()?)
}
/// `ChunkMap.getLatestStatus` for the loading screen's chunk map: the
/// furthest step this column has reached, `None` before it is asked for.
pub fn latest_status(
@@ -1475,6 +1497,7 @@ impl Streaming {
&mut changed,
);
Self::mark_light_changes(world, &changed);
Self::relight_beside_unlit(world, &changed);
if !self.light_snapshots.is_empty() {
for p in positions {
self.light_edit_sequence += 1;
@@ -1504,6 +1527,7 @@ impl Streaming {
&mut changed,
);
Self::mark_light_changes(world, &changed);
Self::relight_beside_unlit(world, &changed);
}
let oldest = self.light_snapshots.values().copied().min();
match oldest {
@@ -1511,6 +1535,14 @@ impl Streaming {
None => self.light_log.clear(),
}
}
/// A column whose light an edit changed beside a column not lit yet gets
/// a full light job ([`crate::light::beside_unlit`]).
fn relight_beside_unlit(world: &mut World, changed: &LightChanges) {
let columns = crate::light::beside_unlit(changed, |x, z| {
world.chunks.get(&(x, z)).is_some_and(|c| !c.lit)
});
world.dirty_light.extend(columns);
}
/// Sections whose meshes sample a changed light value (faces and corners
/// read light from neighbouring blocks).
fn mark_light_changes(world: &mut World, changed: &LightChanges) {
@@ -262,6 +262,14 @@ impl World {
pub fn layout(&self) -> ColumnLayout {
self.shape.column()
}
/// The lowest block Y the level's chunks hold.
pub fn bottom_y(&self) -> i32 {
self.layout().min_y()
}
/// One above the highest block Y the level's chunks hold.
pub fn top_y(&self) -> i32 {
self.layout().max_y()
}
/// Resident storage write; marks the touched sections (and neighbours on
/// section borders) for remeshing and the surrounding chunks for relighting.
/// Neighbour updates, drops and block entities belong to the gameplay layer.
+3 -4
View File
@@ -68,8 +68,6 @@ unicode-bidi = "0.3.18"
rustix = { version = "1", default-features = false, features = ["std", "time"] }
[target.'cfg(windows)'.dependencies]
# Opt-in Rust allocator experiment; the system allocator remains the default.
mimalloc = { version = "0.1.52", optional = true }
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
@@ -95,8 +93,9 @@ audio = ["horizon-client-render/audio"]
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Independent Windows allocator experiments; see docs/windows-allocators.md.
windows-mimalloc = ["dep:mimalloc"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
windows-segment-heap = []
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
@@ -153,6 +153,9 @@ pub fn main() -> Result<()> {
bits(position),
bits(velocity)
)),
ArrowEvent::PotionPuff { id, color } => {
events.push(format!("potion-puff {id} {color:08x}"))
}
ArrowEvent::Sound {
sound,
volume,
@@ -1,9 +1,10 @@
//! The creative inventory, for tools/test_creative_parity.py: loads an
//! exported `creative-inventory.json` into `horizon_client::creative::Creative`
//! and prints the tabs it shows (canonical stacks) and the search tab's
//! results for every query of the vanilla oracle's output.
//! results for every query of the vanilla oracle's output; with `permissions`,
//! as an operator with the Operator Items Tab option on.
//!
//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT
//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT [permissions]
use horizon_client::{
creative::{Creative, Names},
item_data::canon_stack,
@@ -13,11 +14,13 @@ use std::{fs, path::Path};
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let usage = "usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT";
let usage =
"usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT [permissions]";
let names = Names::load(Path::new(args.get(1).ok_or(usage)?));
let creative = Creative::from_json(&serde_json::from_slice(&fs::read(
let mut creative = Creative::from_json(&serde_json::from_slice(&fs::read(
args.get(2).ok_or(usage)?,
)?)?)?;
creative.set_permissions(args.get(5).is_some_and(|a| a == "permissions"), &names);
let vanilla: Value = serde_json::from_slice(&fs::read(args.get(3).ok_or(usage)?)?)?;
let tabs: Vec<Value> = creative
.tabs
@@ -4,7 +4,12 @@
//!
//! usage: entity_death_loot_fixture RESOURCES INPUT OUTPUT
use horizon_client::{
mobs::{Mob, MobTypes, Species, cube, drops, gear, raider, render::DYE_NAMES, sulfur_cube},
assets::Assets,
blocks::BlockFacts,
items::Items,
mobs::{
Mob, MobTypes, Species, cube, drops, enderman, gear, raider, render::DYE_NAMES, sulfur_cube,
},
random_sequences::RandomSequences,
};
use horizon_core::{geometry::Vec3, random::LegacyRandom};
@@ -65,6 +70,13 @@ pub fn main() -> horizon_client::Result<()> {
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let types = MobTypes::load(root)?;
let loot = LootData::load(root, &PackOverlay::NONE)?;
// Block loot for an enderman's carried block (BlockState.getDrops).
let assets = Assets::load(root)?;
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let block_items = Items::load(root, registry, &facts, &PackOverlay::NONE)?;
// The stand-in level's random (RandomSource.create(0)), never reset.
let mut level_random = LegacyRandom::new(0);
let mut out = String::new();
for raw in input.lines() {
if raw.trim().is_empty() || raw.starts_with('#') {
@@ -121,6 +133,13 @@ pub fn main() -> horizon_client::Result<()> {
m.extra.color = DYE_NAMES.iter().position(|d| d == color).unwrap_or(0) as u8;
}
m.extra.sheared = get("sheared", "0") == "1";
if let Some(block) = c.get("carried") {
m.extra.carried = Some(
registry
.default_state(block)
.ok_or_else(|| format!("unknown block {block}"))?,
);
}
if get("leader", "0") == "1" {
raider::state_mut(&mut m).patrol_leader = true;
}
@@ -272,6 +291,23 @@ pub fn main() -> horizon_client::Result<()> {
for d in items {
let _ = match d {
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
drops::Drop::CarriedBlock(state) => {
let stacks = enderman::carried_block_drops(
&block_items,
registry,
state,
&mut level_random,
Some((&mut sequences, world_seed)),
);
for s in stacks {
let _ = write!(out, "drop {} {}", s.item, s.count);
if s.damage != 0 {
let _ = write!(out, " damage={}", s.damage);
}
let _ = writeln!(out);
}
Ok(())
}
drops::Drop::Orb(v) => writeln!(out, "orb {v}"),
};
}
@@ -99,6 +99,7 @@ mod toggle_keys_probe;
mod tooltip_display_fixture;
mod trading_fixture;
mod translucency_fixture;
mod trident_probe;
mod weather_fixture;
mod world_border_render_fixture;
mod world_options_fixture;
@@ -191,6 +192,7 @@ probes! {
text_display_fixture,
text_display_render_fixture,
trading_fixture,
trident_probe,
translucency_fixture,
weather_fixture,
world_border_render_fixture,
@@ -76,6 +76,7 @@ pub fn main() -> Result<()> {
raining_at: &|_| false,
height_no_leaves: &|_| 0,
firefly_bush_sounds: false,
creaking_active: false,
sky_darken: 0,
marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)),
model_offset: &|_, _, _| None,
@@ -0,0 +1,130 @@
//! Thrown tridents over the client's resident scene (`arrows::clip_block_stop`),
//! for tools/test_trident_parity.py: `AbstractArrow.tick`'s block step and
//! `AbstractArrow.onHitBlock`, one line of state per fixture.
#![forbid(unsafe_code)]
use horizon_client::{
Result, arrows, assets::Assets, blocks::BlockFacts, collisions, world::World,
};
use horizon_core::geometry::Vec3;
use serde_json::Value;
use std::{
fs,
io::{BufRead, Write},
path::PathBuf,
};
fn number(v: &Value) -> f64 {
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn vector(v: &Value) -> Vec3 {
Vec3::new(number(&v[0]), number(&v[1]), number(&v[2]))
}
fn bits(v: Vec3) -> String {
format!(
"{:016x},{:016x},{:016x}",
v.x.to_bits(),
v.y.to_bits(),
v.z.to_bits()
)
}
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().skip(1).collect();
if args.len() != 3 {
return Err("usage: trident_probe RESOURCES FIXTURE_DIRECTORY --describe|REFERENCE".into());
}
let root = PathBuf::from(&args[0]);
let dir = PathBuf::from(&args[1]);
let assets = Assets::load(&root)?;
let render = assets.reload(&root)?;
let mut scene = World::test_scene(&assets, &render, 1)?;
fs::write(
dir.join("scene.json"),
serde_json::to_vec(
&serde_json::json!({"radius":1,"resident_chunks":scene.chunks.len(),"model_gallery":render.model_gallery}),
)?,
)?;
if args[2] == "--describe" {
return Ok(());
}
let binding = collisions::load(&root, assets.registry)?;
let facts = BlockFacts::load(&root, assets.registry)?;
let side = &args[2];
let mut out = std::io::BufWriter::new(fs::File::create(dir.join(format!("rust-{side}.txt")))?);
for line in std::io::BufReader::new(fs::File::open(dir.join("inputs.txt"))?).lines() {
let f: Value = serde_json::from_str(&line?)?;
let mut position = vector(&f["position"]);
let mut velocity = vector(&f["velocity"]);
let mut in_ground = false;
let mut shake = 0u32;
for op in f["ops"].as_array().unwrap() {
match op["mode"].as_str().unwrap() {
"block" => {
let p = &op["position"];
let mut state = assets
.registry
.default_state(op["name"].as_str().unwrap())
.ok_or("missing fixture block")?;
if let Some(properties) = op["properties"].as_object() {
let mut definition = assets.registry.state(state).unwrap().clone();
for (name, value) in properties {
definition
.properties
.insert(name.clone(), value.as_str().unwrap().into());
}
state = assets
.registry
.id(&definition)
.ok_or("missing fixture state")?;
}
scene.set(
p[0].as_i64().unwrap() as i32,
p[1].as_i64().unwrap() as i32,
p[2].as_i64().unwrap() as i32,
state,
)?;
}
// Ops only the vanilla oracle needs.
"level_seed" | "owner" | "pickup" | "touch" | "target" | "velocity" | "ignite" => {}
"tick" => {
// AbstractArrow.tick: shakeTime down, then the block step.
shake = shake.saturating_sub(1);
if in_ground {
continue;
}
if let Some((stop, _)) =
arrows::clip_block_stop(&scene, &facts, &binding, position, velocity)
{
// onHitBlock: pulled back, stopped, stuck, shaking 7 ticks.
position = stop;
velocity = Vec3::ZERO;
in_ground = true;
shake = 7;
} else {
// A MISS moves the whole movement, then air drag and gravity.
position = Vec3::new(
position.x + velocity.x,
position.y + velocity.y,
position.z + velocity.z,
);
velocity = Vec3::new(
velocity.x * f64::from(0.99f32),
velocity.y * f64::from(0.99f32) - 0.05,
velocity.z * f64::from(0.99f32),
);
}
}
other => return Err(format!("unknown operation {other}").into()),
}
}
out.write_all(
format!(
"trident {} {} {in_ground} {shake}\n",
bits(position),
bits(velocity)
)
.as_bytes(),
)?;
}
Ok(())
}
+40
View File
@@ -101,6 +101,8 @@ enum Animate {
DeadBush,
/// `ShortDryGrassBlock` and `TallDryGrassBlock`.
DryGrass,
/// A creaking heart that is not uprooted (`CreakingHeartBlock.animateTick`).
CreakingHeart,
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -236,6 +238,9 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"firefly_bush" => Animate::FireflyBush,
"open_eyeblossom" => Animate::OpenEyeblossom,
"pale_hanging_moss" => Animate::PaleHangingMoss,
"creaking_heart" if prop("creaking_heart_state").as_deref() != Some("uprooted") => {
Animate::CreakingHeart
}
"dead_bush" => Animate::DeadBush,
"short_dry_grass" | "tall_dry_grass" => Animate::DryGrass,
"sculk_sensor" | "calibrated_sculk_sensor"
@@ -442,6 +447,8 @@ pub struct AnimateContext<'a> {
pub height_no_leaves: &'a dyn Fn([i32; 3]) -> i32,
/// `EnvironmentAttributes.FIREFLY_BUSH_SOUNDS` (overworld day timeline).
pub firefly_bush_sounds: bool,
/// `EnvironmentAttributes.CREAKING_ACTIVE` (overworld day timeline).
pub creaking_active: bool,
/// `Level.getSkyDarken`, for `getMaxLocalRawBrightness`.
pub sky_darken: i32,
/// Creative main-hand marker block and a resolver for each placed state's sprite.
@@ -1259,6 +1266,39 @@ fn block_animate_tick(
}
}
}
Animate::CreakingHeart => {
// CreakingHeartBlock.animateTick: the idle creak is drawn only while
// the attribute is on, then needs pale oak logs on all six sides
// (`CreakingHeartBlock.isSurroundedByLogs`).
if ctx.creaking_active
&& next_int(engine.random(), 16) == 0
&& [
[-1, 0, 0],
[1, 0, 0],
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
]
.iter()
.all(|d| {
let p = [pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]];
tables
.pale_oak_logs
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
})
{
sound(
engine,
"minecraft:block.creaking_heart.idle",
[fx, fy, fz],
1.0,
1.0,
);
}
}
Animate::DeadBush => {
// AmbientDesertBlockSoundsPlayer.playAmbientDeadBushSounds.
if next_int(engine.random(), 130) == 0 {
+90 -4
View File
@@ -94,6 +94,13 @@ pub enum ArrowEvent {
position: Vec3,
velocity: Vec3,
},
/// `Level.broadcastEntityEvent(arrow, 0)`: a tipped arrow stuck for 600
/// ticks. The clients burst its particles (`Arrow.handleEntityEvent`);
/// the arrow itself is a plain one from now on.
PotionPuff {
id: u32,
color: u32,
},
Sound {
sound: ArrowSound,
volume: f32,
@@ -186,6 +193,8 @@ pub struct ArrowState {
/// The arrow's own random (`Entity.random`).
pub random: LegacyRandom,
pub events: Vec<ArrowEvent>,
/// The speed (`deltaMovement.length()`) it hit a block at, before it stuck.
pub hit_speed: f64,
}
impl fmt::Debug for ArrowState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
@@ -230,8 +239,15 @@ impl ArrowState {
removed: false,
random,
events: Vec::new(),
hit_speed: 0.0,
}
}
/// `Arrow.getColor` of the server's own arrow: the colour of the potion on
/// its pickup item, None for a plain arrow (`PotionContents.EMPTY`).
pub fn potion_color(&self) -> Option<u32> {
let potion = self.pickup_item.potion()?;
Some(crate::brewing::potion_color(Some(potion)))
}
/// The `AbstractArrow` constructor's `pickupItemStack`: the drawn ammo
/// (one item). Ammo drawn without being used up (`INTANGIBLE_PROJECTILE`)
/// makes the arrow CREATIVE_ONLY; vanilla takes the component off the
@@ -375,6 +391,61 @@ impl ArrowScene<'_, '_> {
true
}
}
/// `AbstractArrow.tick`'s block phase for a thrown trident: the zero-width
/// `level().clip(new ClipContext(position(), position().add(movement),
/// ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this))`, then
/// `AbstractArrow.onHitBlock`'s `position().subtract(signum * 0.05)`. Returns
/// the position to stop at (pull-back included) and the block that was hit.
pub fn clip_block_stop(
world: &World,
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
from: Vec3,
movement: Vec3,
) -> Option<(Vec3, BlockPos)> {
let to = Vec3::new(
from.x + movement.x,
from.y + movement.y,
from.z + movement.z,
);
let mut scene = SceneCollisions {
world,
binding: collision,
subject: None,
};
let hit = horizon_core::geometry::traverse_blocks(from, to, 1024, |p: BlockPos| {
let state = world.block(p.x, p.y, p.z);
if facts.is_air(state) {
return None;
}
let shape = collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.ok()?;
shape.clip(from, to, p)
})
.ok()
.flatten()?;
Some((back_off(hit.location, movement), hit.block))
}
/// `AbstractArrow.onHitBlock`'s back-off: vanilla scales `(double) 0.05f` by
/// `Math.signum` per axis, whose value at zero is zero (not Rust's -1.0).
pub fn back_off(location: Vec3, movement: Vec3) -> Vec3 {
fn signum(v: f64) -> f64 {
if v > 0.0 {
1.0
} else if v < 0.0 {
-1.0
} else {
0.0
}
}
let offset = f64::from(0.05f32);
Vec3::new(
location.x - signum(movement.x) * offset,
location.y - signum(movement.y) * offset,
location.z - signum(movement.z) * offset,
)
}
fn bounds(at: Vec3) -> Aabb {
// EntityDimensions.makeBoundingBox: half the float width.
let w = f64::from(WIDTH / 2.0);
@@ -576,6 +647,19 @@ impl Ticker<'_, '_, '_, '_> {
let at = self.position();
self.apply_effects_from_blocks(at, at);
}
// Arrow.tick on the server: a tipped arrow stuck for 600 ticks sends
// entity event 0 (the clients' burst of particles in its colour,
// `handleEntityEvent`) and turns into a plain arrow. The flight
// and ground particles are the clients' own (`Level.isClientSide`).
if self.st.in_ground_time >= 600
&& let Some(color) = self.st.potion_color()
{
self.st.events.push(ArrowEvent::PotionPuff {
id: self.p.id,
color,
});
self.st.pickup_item = crate::items::ItemStack::new("minecraft:arrow", 1);
}
return Hit::None;
}
self.st.in_ground_time = 0;
@@ -664,7 +748,7 @@ impl Ticker<'_, '_, '_, '_> {
if !self.st.first_tick && self.st.fluids.is_in_fluid(&Tags, LAVA) {
self.st.fall_distance *= 0.5;
}
if self.position().y < f64::from(crate::chunk::MIN_Y - 64) {
if self.position().y < f64::from(self.scene.world.bottom_y() - 64) {
self.st.removed = true;
}
self.st.first_tick = false;
@@ -848,6 +932,8 @@ impl Ticker<'_, '_, '_, '_> {
/// it moved on, stop, and stick.
fn on_hit_block(&mut self, pos: BlockPos, state: BlockStateId) {
self.st.last_state = Some(state);
// BlockState.onProjectileHit still sees the deltaMovement it hit at.
self.st.hit_speed = self.p.velocity.length();
// Projectile.onHitBlock: BlockState.onProjectileHit.
if self.st.on_fire()
&& let Some(reaction) = self.block_reaction(state)
@@ -1159,10 +1245,10 @@ impl FluidInteractionWorld for Ticker<'_, '_, '_, '_> {
Ok(self.scene.world.chunks.contains_key(&(x, z)).then_some(()))
}
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
let layout = self.scene.world.layout();
Ok(FluidSections {
minimum_section_y: crate::chunk::MIN_Y >> 4,
has_fluid: &SECTIONS,
minimum_section_y: layout.min_y() >> 4,
has_fluid: crate::chunk::every_section(layout),
})
}
fn bounding_box_min_y(&self) -> f64 {
+239 -55
View File
@@ -24,6 +24,31 @@ pub struct Tab {
/// tab is selected (`set_saved_hotbars`).
pub hotbar: bool,
}
impl Tab {
/// The tab without its stacks.
fn header(&self) -> Self {
Self {
id: self.id.clone(),
title: self.title.clone(),
icon: self.icon.clone(),
items: Vec::new(),
top: self.top,
column: self.column,
search: self.search,
inventory: self.inventory,
hotbar: self.hotbar,
}
}
}
/// A tab as exported, built with permissions: `operator` marks the stacks a
/// build without them leaves out (the export's `permissions_only`).
#[derive(Clone, Debug)]
struct Exported {
tab: Tab,
/// `CreativeModeTab.Type.CATEGORY`: shown only with a stack (`shouldDisplay`).
category: bool,
operator: Vec<bool>,
}
/// What the client's creative search trees (SessionSearchTrees) index for one
/// stack of the search tab: its creative tooltip lines and its item tags.
#[derive(Clone, Debug, Default)]
@@ -37,6 +62,8 @@ struct SearchEntry {
tags: Vec<(String, String)>,
}
pub struct Creative {
/// The tabs shown (`CreativeModeTabs.tabs()`), with the stacks the current
/// permissions put in them.
pub tabs: Vec<Tab>,
pub selected: usize,
/// Scroll position 0..1 (scrollOffs).
@@ -48,6 +75,13 @@ pub struct Creative {
/// The search tab's stacks, in its display order.
search_items: Vec<ItemStack>,
search_index: Vec<SearchEntry>,
/// Every exported tab, and the search tab's stacks with their entries and
/// whether only permissions list them: `rebuild` keeps what is shown.
exported: Vec<Exported>,
exported_search: Vec<(ItemStack, SearchEntry, bool)>,
/// The `hasPermissions` the tabs were last built with
/// (`CreativeModeTabs.tryRebuildTabContents`).
permissions: bool,
/// ItemPickerMenu.items: what the grid shows, filled when the tab or the
/// search changes (selectTab, refreshSearchResults), not on every frame.
shown: Mutex<Option<Shown>>,
@@ -202,14 +236,22 @@ impl Creative {
}
pub fn from_json(raw: &Value) -> Result<Self> {
let mut tabs = Vec::new();
let mut search_items = Vec::new();
let mut search_index = Vec::new();
let mut search = Vec::new();
for t in raw["tabs"].as_array().into_iter().flatten() {
// The stacks only a build with permissions lists, by index.
let only: Vec<u64> = t["permissions_only"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_u64)
.collect();
let mut items = Vec::new();
for v in t["items"].as_array().into_iter().flatten() {
let mut operator = Vec::new();
for (i, v) in t["items"].as_array().into_iter().flatten().enumerate() {
let snbt = v.as_str().unwrap_or("-");
if let Some(stack) = crate::item_data::parse_stack(snbt)? {
items.push(stack);
operator.push(only.contains(&(i as u64)));
}
}
let kind = t["type"].as_str().unwrap_or("");
@@ -223,34 +265,35 @@ impl Creative {
};
for (i, stack) in items.iter().enumerate() {
let (namespace, path) = split_id(&java_lower(&stack.item));
search_index.push(SearchEntry {
let entry = SearchEntry {
namespace,
path,
lines: strings(&t["lines"][i]),
tags: strings(&t["tags"][i]).iter().map(|t| split_id(t)).collect(),
});
};
search.push((stack.clone(), entry, operator[i]));
}
search_items = items.clone();
}
if t["visible"] == false {
continue;
}
let icon = t["icon"]
.as_str()
.and_then(|s| crate::item_data::parse_stack(s).ok().flatten());
tabs.push(Tab {
id: t["id"].as_str().unwrap_or("").to_owned(),
title: t["title"].as_str().unwrap_or("").to_owned(),
icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()),
items,
top: t["row"] == "TOP",
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
hotbar: kind == "HOTBAR",
tabs.push(Exported {
tab: Tab {
id: t["id"].as_str().unwrap_or("").to_owned(),
title: t["title"].as_str().unwrap_or("").to_owned(),
icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()),
items,
top: t["row"] == "TOP",
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
hotbar: kind == "HOTBAR",
},
category: kind == "CATEGORY",
operator,
});
}
Ok(Self::new(tabs, search_items, search_index))
Ok(Self::new(tabs, search))
}
/// The old export: item ids per tab, no components, tooltips or tags.
fn from_legacy(raw: &Value) -> Result<Self> {
@@ -272,34 +315,39 @@ impl Creative {
}
}
let kind = t["type"].as_str().unwrap_or("");
tabs.push(Tab {
id: t["id"].as_str().unwrap_or("").to_owned(),
title: t["title"].as_str().unwrap_or("").to_owned(),
icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(),
items,
top: t["row"] == "TOP",
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
hotbar: false,
tabs.push(Exported {
operator: vec![false; items.len()],
tab: Tab {
id: t["id"].as_str().unwrap_or("").to_owned(),
title: t["title"].as_str().unwrap_or("").to_owned(),
icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(),
items,
top: t["row"] == "TOP",
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
hotbar: false,
},
category: false,
});
}
let search_index = search_items
.iter()
let search = search_items
.into_iter()
.map(|s| {
let (namespace, path) = split_id(&java_lower(&s.item));
SearchEntry {
let entry = SearchEntry {
namespace,
path,
..Default::default()
}
};
(s, entry, false)
})
.collect();
Ok(Self::new(tabs, search_items, search_index))
Ok(Self::new(tabs, search))
}
fn new(tabs: Vec<Tab>, search_items: Vec<ItemStack>, search_index: Vec<SearchEntry>) -> Self {
Self {
tabs,
fn new(exported: Vec<Exported>, exported_search: Vec<(ItemStack, SearchEntry, bool)>) -> Self {
let mut creative = Self {
tabs: Vec::new(),
selected: 0,
scroll: 0.0,
scrolling: false,
@@ -310,10 +358,81 @@ impl Creative {
search.text_color = u32::MAX;
search
},
search_items,
search_index,
search_items: Vec::new(),
search_index: Vec::new(),
exported,
exported_search,
permissions: false,
shown: Mutex::new(None),
};
creative.rebuild();
creative
}
/// `CreativeModeTabs.buildAllTabContents` with `permissions`: the operator
/// tab's display items generator lists its stacks only with them
/// (`hasPermissions`), and the search tab with it; a category tab is
/// shown with a stack (`CreativeModeTab.shouldDisplay`).
fn rebuild(&mut self) {
let permissions = self.permissions;
let keep = |operator: bool| permissions || !operator;
self.tabs = self
.exported
.iter()
.filter_map(|e| {
let items: Vec<ItemStack> = (e.tab.items.iter().zip(&e.operator))
.filter(|(_, o)| keep(**o))
.map(|(s, _)| s.clone())
.collect();
(!e.category || !items.is_empty()).then(|| Tab {
items,
..e.tab.header()
})
})
.collect();
(self.search_items, self.search_index) = self
.exported_search
.iter()
.filter(|(_, _, o)| keep(*o))
.map(|(s, e, _)| (s.clone(), e.clone()))
.unzip();
self.forget_items();
}
/// `CreativeModeInventoryScreen.tryRefreshInvalidatedTabs`, which its
/// constructor and `containerTick` run with `hasPermissions`
/// (`Player.canUseGameMasterBlocks` and the Operator Items Tab option):
/// when that changed, the tabs are built again. A selected tab left
/// without stacks (the operator tab) gives way to the default tab; any
/// other keeps its first row (`refreshCurrentTabContents`). True when the
/// tabs were rebuilt.
pub fn set_permissions(&mut self, permissions: bool, names: &Names) -> bool {
if permissions == self.permissions {
return false;
}
let selected = self.tab().id.clone();
let row = self.first_row(names);
self.permissions = permissions;
self.rebuild();
match self.tabs.iter().position(|t| t.id == selected) {
Some(tab) => {
self.selected = tab;
// ItemPickerMenu.getScrollForRowIndex.
let hidden = self.hidden_rows(names);
self.scroll = if hidden > 0 {
(row as f32 / hidden as f32).clamp(0.0, 1.0)
} else {
0.0
};
}
None => {
// CreativeModeTabs.getDefaultTab.
let default = self
.tabs
.iter()
.position(|t| t.id == "minecraft:building_blocks");
self.select(default.unwrap_or(0));
}
}
true
}
/// Where a stack comes in the creative tabs (the search tab lists them
/// all, in tab order): the same stack's place, else its item's first.
@@ -327,20 +446,7 @@ impl Creative {
}
/// The tabs without their stacks: what the screen draws of them each frame.
pub fn tab_headers(&self) -> Vec<Tab> {
self.tabs
.iter()
.map(|t| Tab {
id: t.id.clone(),
title: t.title.clone(),
icon: t.icon.clone(),
items: Vec::new(),
top: t.top,
column: t.column,
search: t.search,
inventory: t.inventory,
hotbar: t.hotbar,
})
.collect()
self.tabs.iter().map(Tab::header).collect()
}
pub fn tab(&self) -> &Tab {
&self.tabs[self.selected.min(self.tabs.len() - 1)]
@@ -601,7 +707,7 @@ impl Creative {
pub fn tab_position(tab: &Tab) -> (f32, f32) {
let mut x = tab.column as f32 * 27.0;
if tab.column >= 5 {
// Right-aligned tabs (search, inventory).
// Right-aligned tabs (search, operator, inventory).
x = Self::WIDTH - 27.0 * (7 - tab.column) as f32 + 1.0;
}
let y = if tab.top { -28.0 } else { Self::HEIGHT - 4.0 };
@@ -1083,6 +1189,84 @@ mod tests {
assert_eq!(c.search.value, "");
}
/// An export built with permissions: the operator tab's stacks, and their
/// place at the end of the search tab, only with them.
fn with_operator_tab() -> Creative {
let stack = |id: &str| format!("{{count:1,id:\"minecraft:{id}\"}}");
let raw = serde_json::json!({"schema": 3, "tabs": [
{"id": "minecraft:building_blocks", "type": "CATEGORY", "row": "TOP", "column": 0, "title": "Building Blocks",
"icon": stack("bricks"), "visible": true, "items": [stack("stone")]},
{"id": "minecraft:op_blocks", "type": "CATEGORY", "row": "BOTTOM", "column": 5, "title": "Operator Utilities",
"icon": stack("command_block"), "visible": false,
"items": [stack("command_block"), stack("barrier")], "permissions_only": [0, 1]},
{"id": "minecraft:search", "type": "SEARCH", "row": "TOP", "column": 6, "title": "Search Items",
"icon": stack("compass"), "visible": true,
"items": [stack("stone"), stack("command_block"), stack("barrier")],
"lines": [["Stone"], ["Command Block"], ["Barrier"]], "tags": [[], [], []],
"permissions_only": [1, 2]},
{"id": "minecraft:inventory", "type": "INVENTORY", "row": "BOTTOM", "column": 6, "title": "Survival Inventory",
"icon": stack("chest"), "visible": true, "items": []}
]});
Creative::from_json(&raw).unwrap()
}
fn tab_ids(c: &Creative) -> Vec<&str> {
c.tabs.iter().map(|t| t.id.as_str()).collect()
}
#[test]
fn operator_stacks_stay_out_without_permissions() {
let c = with_operator_tab();
let names = Names::default();
assert_eq!(
tab_ids(&c),
[
"minecraft:building_blocks",
"minecraft:search",
"minecraft:inventory"
]
);
assert_eq!(c.search_items().len(), 1);
assert!(c.search_results("command", &names).is_empty());
assert_eq!(c.search_results("", &names), [0]);
}
#[test]
fn operator_tab_comes_and_goes_with_permissions() {
let mut c = with_operator_tab();
let names = Names::default();
assert!(!c.set_permissions(false, &names));
assert!(c.set_permissions(true, &names));
assert!(!c.set_permissions(true, &names));
assert_eq!(
tab_ids(&c),
[
"minecraft:building_blocks",
"minecraft:op_blocks",
"minecraft:search",
"minecraft:inventory"
]
);
let op: Vec<&str> = c.tabs[1].items.iter().map(|s| s.item.as_ref()).collect();
assert_eq!(op, ["minecraft:command_block", "minecraft:barrier"]);
assert_eq!(c.search_results("command", &names), [1]);
assert_eq!(c.search_results("", &names), [0, 1, 2]);
// The search tab keeps its place across the rebuild.
c.select(2);
c.search.set_value("block");
assert!(c.set_permissions(false, &names));
assert!(c.tab().search);
assert_eq!(c.search.value, "block");
assert!(c.items(&names).is_empty());
// The selected operator tab is left empty: the default tab instead.
c.set_permissions(true, &names);
c.select(1);
assert_eq!(c.items(&names).len(), 2);
c.set_permissions(false, &names);
assert_eq!(c.tab().id, "minecraft:building_blocks");
assert_eq!(c.items(&names)[0].item.as_ref(), "minecraft:stone");
}
#[test]
fn saved_hotbars_tab_shows_its_rows_and_locks_the_hint() {
let mut c = Creative::from_json(&serde_json::json!({"schema": 2, "tabs": [
+99
View File
@@ -4,6 +4,7 @@
//! (the entity goes through on the next tick), and
//! `TheEndGatewayBlockEntity.getPortalPosition` (where it lands).
use crate::nether_portal::{ENTITY_DELAY, PortalState};
use crate::projectiles::{Projectile, ProjectileKind};
use horizon_core::{geometry::Vec3, position::BlockPos};
/// The End gateway travel of one entity.
@@ -65,6 +66,35 @@ pub fn swept_gateway(
found
}
/// The End gateway a thrown ender pearl passed through on its last move, from
/// where it started the tick to where it is (`Entity.checkInsideBlocks` from
/// `ThrowableProjectile.tick`'s `applyEffectsFromBlocks`). Other projectiles
/// are not looked at.
pub fn pearl_gateway(
pearl: &Projectile,
is_gateway: impl FnMut(BlockPos) -> bool,
) -> Option<BlockPos> {
if !matches!(pearl.kind, ProjectileKind::EnderPearl) {
return None;
}
let size = pearl.kind.size() as f32;
swept_gateway(pearl.previous, pearl.position, size, size, is_gateway)
}
/// A thrown ender pearl at its gateway's exit `at`
/// (`EndGatewayBlock.getPortalDestination` for a `ThrownEnderpearl`: a
/// `TeleportTransition` with no motion, rotation 0 and nothing relative).
/// It falls from there and takes its owner where it lands.
pub fn place_pearl(pearl: &mut Projectile, at: Vec3) {
pearl.position = at;
pearl.previous = at;
pearl.velocity = Vec3::ZERO;
pearl.yaw = 0.0;
pearl.pitch = 0.0;
// A server tells its clients of the jump at once.
pearl.needs_sync = true;
}
/// The bottom centre of a block (`Vec3.atBottomCenterOf`): where a traveller
/// lands at a gateway's exit.
pub fn bottom_center(p: [i32; 3]) -> Vec3 {
@@ -150,6 +180,75 @@ mod tests {
assert_eq!(traveller.travel(false), None);
}
/// A world with no blocks in the way and nothing to hit: the gateway
/// block has no collision shape, so a projectile flies into it.
struct Open;
impl crate::projectiles::ProjectileWorld for Open {
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
None
}
fn in_water(&self, _: horizon_core::geometry::Aabb) -> bool {
false
}
fn targets(&self) -> &[(crate::mobs::Who, horizon_core::geometry::Aabb)] {
&[]
}
}
/// The ticks a projectile flies, thrown east at the gateway, until its
/// move goes through one (`None` if it never does in 5 ticks).
fn thrown_at_gateway(kind: ProjectileKind) -> (Projectile, Option<BlockPos>) {
let mut shot = Projectile::new(kind, at(699.5, 100.3, 0.5), None);
shot.velocity = at(1.5, 0.0, 0.0);
for _ in 0..5 {
shot.tick(&Open);
if let Some(g) = pearl_gateway(&shot, is_gateway) {
return (shot, Some(g));
}
}
(shot, None)
}
#[test]
fn a_thrown_pearl_goes_through_the_gateway_and_falls_from_its_exit() {
let (mut pearl, found) = thrown_at_gateway(ProjectileKind::EnderPearl);
let gateway = BlockPos::new(702, 100, 0);
// ThrownEnderpearl in the gateway block: EndGatewayBlock.entityInside
// takes it in, and it goes through on the next tick.
assert_eq!(found, Some(gateway));
let mut traveller = Traveller::default();
traveller.enter(gateway);
assert_eq!(traveller.travel(true), Some(gateway));
// getPortalDestination: at the exit, no motion and rotation 0.
let exit = bottom_center([8, 100, 0]);
place_pearl(&mut pearl, exit);
assert_eq!(pearl.position, exit);
assert_eq!(pearl.previous, exit);
assert_eq!(pearl.velocity, Vec3::ZERO);
assert_eq!((pearl.yaw, pearl.pitch), (0.0, 0.0));
// It falls straight down from the exit (ThrowableProjectile gravity,
// then inertia), where it lands and takes its owner along.
pearl.tick(&Open);
assert_eq!((pearl.position.x, pearl.position.z), (8.5, 0.5));
assert!((pearl.position.y - (100.0 - 0.03 * 0.99)).abs() < 1e-9);
// The pearl is out of the gateway: it does not go back at once.
assert_eq!(traveller.travel(true), None);
assert_eq!(traveller.cooldown(), ENTITY_DELAY - 1);
}
#[test]
fn a_pearl_flying_past_the_gateway_stays_out_of_it() {
// Thrown one block higher, over the gateway block.
let mut pearl = Projectile::new(ProjectileKind::EnderPearl, at(699.5, 101.3, 0.5), None);
pearl.velocity = at(1.5, 0.0, 0.0);
for _ in 0..5 {
pearl.tick(&Open);
assert_eq!(pearl_gateway(&pearl, is_gateway), None);
}
// Only the pearl travels: a snowball on the same path is not taken in.
assert_eq!(thrown_at_gateway(ProjectileKind::Snowball).1, None);
}
#[test]
fn a_traveller_lands_at_the_bottom_centre_of_the_exit() {
// TheEndGatewayBlockEntity.getPortalPosition: the exit's bottom
+3 -3
View File
@@ -83,10 +83,10 @@ impl FluidInteractionWorld for ItemFluidWorld<'_> {
Ok(self.world.chunks.contains_key(&(x, z)).then_some(()))
}
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
let layout = self.world.layout();
Ok(FluidSections {
minimum_section_y: crate::chunk::MIN_Y >> 4,
has_fluid: &SECTIONS,
minimum_section_y: layout.min_y() >> 4,
has_fluid: crate::chunk::every_section(layout),
})
}
fn bounding_box_min_y(&self) -> f64 {
+16
View File
@@ -1986,6 +1986,8 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
power: number(tag.get("ExplosionPower")).unwrap_or(1) as i32,
},
"minecraft:small_fireball" => ProjectileKind::SmallFireball,
"minecraft:wind_charge" => ProjectileKind::WindCharge,
"minecraft:breeze_wind_charge" => ProjectileKind::BreezeWindCharge,
_ => return None,
};
let owner = uuid_from_tag(tag.get("Owner"));
@@ -3419,6 +3421,20 @@ mod fireball_nbt_tests {
(p.kind, p.acceleration),
(ProjectileKind::LargeFireball { power: 1 }, 0.1)
);
// `/summon` and a saved world give back both wind charges.
for (id, kind) in [
("minecraft:wind_charge", ProjectileKind::WindCharge),
(
"minecraft:breeze_wind_charge",
ProjectileKind::BreezeWindCharge,
),
] {
let tag = Tag::compound([
("id", Tag::String(id.into())),
("Pos", doubles(&[0.0, 0.0, 0.0])),
]);
assert_eq!(fireball_from_nbt(&tag, None).unwrap().kind, kind);
}
}
}
+33
View File
@@ -28,6 +28,12 @@ pub enum ItemPose {
Bow {
ticks_used: f32,
},
/// A brush in use (`ItemUseAnimation.BRUSH`): the ticks left of the use
/// and the partial tick.
Brush {
remaining: f32,
partial: f32,
},
/// A spear being charged (`ItemUseAnimation.SPEAR`): the time held and
/// the ticks since its last hit's feedback.
Spear {
@@ -78,6 +84,9 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
if matches!(pose, ItemPose::Using) {
return m;
}
if let ItemPose::Brush { remaining, partial } = pose {
return m * brush_transform(left, remaining, partial);
}
// The spear's own place in the hand, without the equip height.
if let ItemPose::Spear {
kinetic,
@@ -177,6 +186,30 @@ impl MapHold {
}
}
/// `FirstPersonHandsAndItemsRenderer.applyBrushTransform`: the brush in use,
/// swept back and forth by the ticks left of its use (`remaining`).
fn brush_transform(left: bool, remaining: f32, partial: f32) -> glam::Mat4 {
use glam::{Mat4, Vec3};
use horizon_core::geometry::trig::Mth;
let ticks = remaining % 10.0;
let delta = ticks - partial + 1.0;
let scaled = 1.0 - delta / 10.0;
let swipe = -15.0 + 75.0 * (scaled * 2.0 * std::f32::consts::PI).mcos();
let sweep = Mat4::from_rotation_x(swipe.to_radians());
if left {
Mat4::from_translation(Vec3::new(0.1, 0.83, 0.35))
* Mat4::from_rotation_x((-80.0f32).to_radians())
* Mat4::from_rotation_y((-90.0f32).to_radians())
* sweep
* Mat4::from_translation(Vec3::new(-0.3, 0.22, 0.35))
} else {
Mat4::from_translation(Vec3::new(-0.25, 0.22, 0.35))
* Mat4::from_rotation_x((-80.0f32).to_radians())
* Mat4::from_rotation_y(90.0f32.to_radians())
* sweep
}
}
/// `renderPlayerArm` up to `renderPlayerHand`: the pose of the bare `left`
/// or right arm.
pub fn player_arm_pose(left: bool, inverse_height: f32, attack: f32) -> glam::Mat4 {
+210 -15
View File
@@ -1,21 +1,80 @@
//! FlatLevelSource: superflat worlds (`minecraft:flat` generator).
use crate::{chunk::Chunk, streaming::ChunkGenerator};
use horizon_world::{nbt::Tag, registry::BlockRegistry};
use crate::{
chunk::{Chunk, LevelShape},
streaming::ChunkGenerator,
};
use horizon_world::{
nbt::Tag,
registry::{BlockRegistry, BlockStateId},
worldgen::feature::{BasicFeature, FeatureBlocks, FeatureWorld},
};
/// The layers of a flat generator, bottom up from the world's minimum Y.
pub struct FlatTerrain {
/// The Overworld's shape: the layers start at its minimum Y and stop at
/// its top.
shape: LevelShape,
layers: Vec<u32>,
biome: u16,
surface: Option<crate::lod::Surface>,
/// The blocks of `minecraft:void_start_platform`, when the adjusted
/// generation settings place it (the Void preset).
void_platform: Option<FeatureBlocks>,
}
/// `GenerationStep.Decoration` ordinals `adjustGenerationSettings` leaves out.
const LAKES: usize = 1;
const UNDERGROUND_STRUCTURES: usize = 3;
const SURFACE_STRUCTURES: usize = 4;
/// FlatLevelGeneratorSettings.adjustGenerationSettings for the world's own
/// biome: its placed features, by step, when `features` is on and the world
/// isn't only air (`voidGen`) or its biome is the_void. The structure steps
/// are left out, and the lakes step when `lakes` puts the lava lakes there.
fn decoration_features(
biome_features: &[Vec<String>],
features: bool,
lakes: bool,
void_gen: bool,
the_void: bool,
) -> impl Iterator<Item = &str> {
let decorate = (!void_gen || the_void) && features;
biome_features
.iter()
.enumerate()
.filter(move |&(step, _)| {
decorate
&& step != UNDERGROUND_STRUCTURES
&& step != SURFACE_STRUCTURES
&& !(lakes && step == LAKES)
})
.flat_map(|(_, step)| step.iter().map(String::as_str))
}
/// The proto chunk as the world a feature writes in.
struct ChunkWorld<'a>(&'a mut Chunk);
impl FeatureWorld for ChunkWorld<'_> {
type Error = std::convert::Infallible;
fn block(&mut self, [x, y, z]: [i32; 3]) -> Result<BlockStateId, Self::Error> {
Ok(self.0.block(x, y, z))
}
fn set_block(
&mut self,
[x, y, z]: [i32; 3],
state: BlockStateId,
_: i32,
) -> Result<bool, Self::Error> {
self.0.set(x, y, z, state.0);
Ok(true)
}
}
impl FlatTerrain {
/// The overworld generator of `world_gen_settings` (`data.dimensions`), if it
/// is flat. Its `features` and `lakes` decorate the world; a world without
/// them (the default) gets only its layers, as FlatLevelSource places them.
/// is flat. Its layers, as FlatLevelSource places them, and of the features
/// its settings decorate the world with, `void_start_platform`; the other
/// features and the lakes are not placed yet.
pub fn from_settings(
settings: &Tag,
registry: &BlockRegistry,
biomes: &crate::biome::Biomes,
shape: LevelShape,
) -> Option<Self> {
let generator = settings
.get("dimensions")?
@@ -36,14 +95,32 @@ impl FlatTerrain {
}
}
let biome = biomes.id(&biome);
// FlatLevelGeneratorSettings.updateLayers: every layer is Blocks.AIR.
let void_gen = layers.iter().all(|&id| {
registry
.state(BlockStateId(id))
.is_some_and(|s| s.name == "minecraft:air")
});
let flag = |key| crate::save::number(s.get(key)).is_some_and(|v| v != 0);
let info = biomes.get(biome);
let void_platform = if decoration_features(
&info.features,
flag("features"),
flag("lakes"),
void_gen,
info.name == "minecraft:the_void",
)
.any(|f| f == "minecraft:void_start_platform")
{
FeatureBlocks::from_registry(registry)
.inspect_err(|e| log::warn!("Horizon: no void_start_platform: {e}"))
.ok()
} else {
None
};
let mut water = None;
let mut surface = None;
for (i, &id) in layers
.iter()
.take(crate::chunk::HEIGHT as usize)
.enumerate()
.rev()
{
for (i, &id) in layers.iter().take(shape.height as usize).enumerate().rev() {
let state = horizon_world::registry::BlockStateId(id);
let name = registry.state(state)?.name.as_str();
if matches!(
@@ -52,7 +129,7 @@ impl FlatTerrain {
) {
continue;
}
let height = crate::chunk::MIN_Y + i as i32 + 1;
let height = shape.min_y + i as i32 + 1;
if matches!(name, "minecraft:water" | "minecraft:lava") {
water.get_or_insert((height, state));
} else {
@@ -66,14 +143,16 @@ impl FlatTerrain {
}
}
Some(Self {
shape,
layers,
biome,
surface,
void_platform,
})
}
/// FlatLevelSource.getSpawnHeight: the top of the layers.
pub fn spawn_height(&self) -> i32 {
crate::chunk::MIN_Y + self.layers.len().min(crate::chunk::HEIGHT as usize) as i32
self.shape.min_y + self.layers.len().min(self.shape.height as usize) as i32
}
}
/// DimensionType.Y_SIZE: the most layers FlatLevelGeneratorSettings takes.
@@ -190,23 +269,39 @@ impl ChunkGenerator for FlatTerrain {
self.surface
}
fn proto(&self, _: &mut (), cx: i32, cz: i32) -> crate::streaming::Proto {
let mut chunk = Chunk::empty(cx, cz);
let mut chunk = Chunk::with_layout(cx, cz, self.shape.column());
for s in &mut chunk.sections {
std::sync::Arc::make_mut(s).biomes = horizon_core::paletted::PalettedContainer::new(
crate::chunk::BIOME_STRATEGY,
u32::from(self.biome),
);
}
for (i, &block) in self.layers.iter().enumerate().take(384) {
let height = self.shape.height as usize;
for (i, &block) in self.layers.iter().enumerate().take(height) {
if block == 0 {
continue;
}
for z in 0..16 {
for x in 0..16 {
chunk.set(x, -64 + i as i32, z, block);
chunk.set(x, self.shape.min_y + i as i32, z, block);
}
}
}
// ChunkGenerator.applyBiomeDecoration at the chunk's section origin.
// VoidStartPlatformFeature writes only inside its own chunk and draws
// no random numbers, so placing it here gives the FEATURES result.
if let Some(blocks) = self.void_platform {
let origin = [cx * 16, self.shape.min_y, cz * 16];
let mut random = horizon_core::random::LegacyRandom::new(0);
if let Err(e) = BasicFeature::VoidStartPlatform.place(
&mut ChunkWorld(&mut chunk),
&mut random,
origin,
blocks,
) {
log::warn!("Horizon: placing void_start_platform failed: {e}");
}
}
for s in &mut chunk.sections {
std::sync::Arc::make_mut(s).compact();
}
@@ -233,6 +328,106 @@ mod tests {
)
}
/// A world_gen_settings with a flat overworld of `layers` (block, height).
fn flat_world(biome: &str, features: bool, layers: &[(&str, i32)]) -> Tag {
let layers = layers
.iter()
.map(|&(block, height)| {
Tag::compound([
("block", Tag::String(block.into())),
("height", Tag::Int(height)),
])
})
.collect();
let settings = Tag::compound([
("biome", Tag::String(biome.into())),
("features", Tag::Byte(i8::from(features))),
("lakes", Tag::Byte(0)),
("layers", Tag::List(layers)),
]);
let generator = Tag::compound([
("type", Tag::String("minecraft:flat".into())),
("settings", settings),
]);
Tag::compound([(
"dimensions",
Tag::compound([(
"minecraft:overworld",
Tag::compound([("generator", generator)]),
)]),
)])
}
#[test]
fn the_void_preset_places_the_start_platform() {
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 registry = assets.registry;
let biomes = crate::biome::Biomes::load(&root).unwrap();
let terrain = |biome, features, layers: &[(&str, i32)]| {
FlatTerrain::from_settings(
&flat_world(biome, features, layers),
registry,
&biomes,
LevelShape::OVERWORLD,
)
.unwrap()
};
let block = |terrain: &FlatTerrain, [x, y, z]: [i32; 3]| {
let chunk = terrain.finish(terrain.proto(&mut (), x >> 4, z >> 4));
registry.state(chunk.block(x, y, z)).unwrap().name.clone()
};
// The Void preset (flat_level_generator_preset/the_void): the_void's
// only feature, VoidStartPlatformFeature, at the minimum Y + 3: a
// 33×33 stone square around 8, 8 with cobblestone at its centre, in
// the chunks within one of chunk 0, 0.
let void = terrain("minecraft:the_void", true, &[("minecraft:air", 1)]);
for (pos, expected) in [
([8, -61, 8], "minecraft:cobblestone"),
([0, -61, 0], "minecraft:stone"),
([-8, -61, -8], "minecraft:stone"),
([24, -61, 24], "minecraft:stone"),
([24, -61, -8], "minecraft:stone"),
([-9, -61, 8], "minecraft:air"),
([25, -61, 8], "minecraft:air"),
([8, -60, 8], "minecraft:air"),
([8, -62, 8], "minecraft:air"),
([40, -61, 8], "minecraft:air"),
] {
assert_eq!(block(&void, pos), expected, "{pos:?}");
}
// The decoration is off without `features`.
let plain = terrain("minecraft:the_void", false, &[("minecraft:air", 1)]);
assert_eq!(block(&plain, [8, -61, 8]), "minecraft:air");
// the_void over solid layers still gets its platform.
let floor = terrain("minecraft:the_void", true, &[("minecraft:bedrock", 1)]);
assert_eq!(block(&floor, [8, -64, 8]), "minecraft:bedrock");
assert_eq!(block(&floor, [8, -61, 8]), "minecraft:cobblestone");
}
#[test]
fn adjusted_generation_settings_keep_the_biome_features_outside_structures() {
let steps: Vec<Vec<String>> = (0..11).map(|step| vec![format!("f{step}")]).collect();
let kept = |features, lakes, void_gen, the_void| {
decoration_features(&steps, features, lakes, void_gen, the_void)
.map(str::to_owned)
.collect::<Vec<_>>()
};
let all_but_structures = ["f0", "f1", "f2", "f5", "f6", "f7", "f8", "f9", "f10"];
assert_eq!(kept(true, false, false, false), all_but_structures);
// The lava lakes take the LAKES step's place.
assert_eq!(kept(true, true, false, false)[1..], all_but_structures[2..]);
// An all-air world is decorated only in the_void.
assert!(kept(true, false, true, false).is_empty());
assert_eq!(kept(true, false, true, true), all_but_structures);
assert!(kept(false, false, false, true).is_empty());
}
#[test]
fn generator_settings_read_as_flat_level_generator_settings() {
// server.properties' default: the preset's own layers, and the codec's error.
File diff suppressed because it is too large. Load diff
+15 -3
View File
@@ -7,7 +7,6 @@ use super::{Game, Screen};
use horizon_client::{
Result,
block_entity_anim::Ticker,
chunk::{MIN_Y, SECTIONS},
items::ItemStack,
mobs::{
Species,
@@ -549,7 +548,7 @@ impl Game<'_> {
selected,
} => json!({
"mode": "select-world",
"worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1 })).collect::<Vec<_>>(),
"worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1, "corrupted": w.corrupted })).collect::<Vec<_>>(),
"selected": selected,
}),
Mode::Create {
@@ -603,6 +602,17 @@ impl Game<'_> {
Mode::BackupBeforeUpgrade { world, .. } => {
json!({ "mode": "backup-before-upgrade", "folder": world.folder, "name": world.name })
}
Mode::Recover { world, issues } => {
let issue = |i: &Option<horizon_client::save::DatIssue>| match i {
None => json!(null),
Some(horizon_client::save::DatIssue::Missing) => json!("missing file"),
Some(horizon_client::save::DatIssue::Unreadable(why)) => json!(why),
};
json!({ "mode": "recover-world", "folder": world.folder, "level_dat": issue(&issues[0]), "level_dat_old": issue(&issues[1]) })
}
Mode::Recovered { world, success } => {
json!({ "mode": "recovery-done", "folder": world.folder, "success": success })
}
Mode::Error { message, .. } => {
json!({ "mode": "world-operation-error", "message": message })
}
@@ -1088,7 +1098,9 @@ impl Game<'_> {
.world
.chunks
.get(&(x >> 4, z >> 4))
.filter(|_| (MIN_Y..MIN_Y + SECTIONS as i32 * 16).contains(&y))
.filter(|_| {
(self.world.bottom_y()..self.world.top_y()).contains(&y)
})
.map(|c| c.light(x & 15, y, z & 15));
let digit = |v: u8| char::from_digit(u32::from(v), 16).unwrap_or('.');
sky.push(packed.map_or('.', |v| digit(v >> 4)));
+22 -2
View File
@@ -47,14 +47,34 @@ impl Game<'_> {
.items()
.get(&held)
.map_or(64, |i| i.max_stack as i32);
// GoToWantedItem / wantsToPickUp: the same item within 32.
// GoToWantedItem / wantsToPickUp: the same item within 32 that the
// allay sees (NearestItemSensor's hasLineOfSight, eye to eye).
let wanted = if griefing && carried < max && self.mobs[i].extra.ints[3] <= 0 {
let w = self.mob_world_base(&[]);
let eye = horizon_core::geometry::Vec3::new(pos.x, self.mobs[i].eye_y(), pos.z);
// The sensor looks in the allay's box grown by (32, 16, 32).
let b = self.mobs[i].bounds();
let (low, high) = (b.min_position().y - 16.0, b.max_position().y + 16.0);
self.dropped
.iter()
.enumerate()
.filter(|(_, e)| e.stack.item == held && e.pickup_delay == 0)
.map(|(j, e)| (j, e.position.distance_squared(pos)))
.filter(|(_, d)| *d <= 32.0 * 32.0)
.filter(|(_, d)| *d < 32.0 * 32.0)
.filter(|(j, _)| {
let y = self.dropped[*j].position.y;
y <= high && y + 0.25 >= low
})
.filter(|(j, _)| {
// ItemEntity's eye height is 0.2125.
let e = &self.dropped[*j];
let at = horizon_core::geometry::Vec3::new(
e.position.x,
e.position.y + 0.2125,
e.position.z,
);
w.line_of_sight(eye, at)
})
.min_by(|a, b| a.1.total_cmp(&b.1))
} else {
None
+146 -54
View File
@@ -39,8 +39,8 @@ pub(super) struct ArmorStandAssets {
pub layers: HashMap<(&'static str, bool), Part>,
/// The player's armour models by slot (helmet, chestplate, leggings, boots).
pub player: HashMap<&'static str, Part>,
/// asset id -> layer kind (humanoid / humanoid_leggings) -> (texture, undyed colour).
pub equipment: HashMap<String, HashMap<String, (String, Option<u32>)>>,
/// asset id -> layer kind (humanoid / humanoid_leggings) -> each layer's (texture, undyed colour), in order.
pub equipment: HashMap<String, HashMap<String, Vec<(String, Option<u32>)>>>,
/// asset id -> `trim_overrides`.
pub trim_overrides: HashMap<String, Vec<TrimOverride>>,
pub trims: TrimRegistry,
@@ -89,18 +89,24 @@ impl ArmorStandAssets {
let Ok(v) = v else { continue };
let mut kinds = HashMap::new();
for kind in ["humanoid", "humanoid_leggings", "wings"] {
if let Some(layer) = v["layers"][kind].get(0) {
let tex = layer["texture"]
.as_str()
.unwrap_or(name)
.trim_start_matches("minecraft:");
let undyed = layer["dyeable"]["color_when_undyed"]
.as_i64()
.map(|c| c as u32 & 0xFF_FFFF);
kinds.insert(
kind.to_owned(),
(format!("entity/equipment/{kind}/{tex}"), undyed),
);
// Every layer of the kind: leather's overlay is layer 1.
let layers: Vec<(String, Option<u32>)> = v["layers"][kind]
.as_array()
.into_iter()
.flatten()
.map(|layer| {
let tex = layer["texture"]
.as_str()
.unwrap_or(name)
.trim_start_matches("minecraft:");
let undyed = layer["dyeable"]["color_when_undyed"]
.as_i64()
.map(|c| c as u32 & 0xFF_FFFF);
(format!("entity/equipment/{kind}/{tex}"), undyed)
})
.collect();
if !layers.is_empty() {
kinds.insert(kind.to_owned(), layers);
}
}
trim_overrides.insert(format!("minecraft:{name}"), TrimOverride::list(&v));
@@ -195,19 +201,14 @@ impl Game<'_> {
} else {
"humanoid"
};
let Some((texture, undyed)) = assets.equipment.get(&asset).and_then(|k| k.get(kind))
else {
let Some(layers) = assets.equipment.get(&asset).and_then(|k| k.get(kind)) else {
continue;
};
let (Some(r), Some(part)) = (
self.gui.atlas.sprites.get(texture.as_str()),
assets.player.get(layer),
) else {
let Some(part) = assets.player.get(layer) else {
continue;
};
let color = layer_color(*undyed, stack);
// EquipmentLayerRenderer.renderLayers: NO_OVERLAY, the armour
// glint on the layer and its trim when `stack.hasFoil()`.
// glint on the first layer and on the trim when `stack.hasFoil()`.
let item_glint = self
.res
.items()
@@ -220,8 +221,20 @@ impl Game<'_> {
} else {
0
};
out.push((part, [r.u0, r.v0, r.u1, r.v1], color, overlay));
// EquipmentLayerRenderer: the trim layer over the armour.
// Every layer of the asset, the leather overlay too, then the
// trim layer over the armour.
for (i, (texture, undyed)) in layers.iter().enumerate() {
let Some(r) = self.gui.atlas.sprites.get(texture.as_str()) else {
continue;
};
let color = layer_color(*undyed, stack);
out.push((
part,
[r.u0, r.v0, r.u1, r.v1],
color,
if i == 0 { overlay } else { 0 },
));
}
if let Some(r) = stack
.and_then(|s| assets.trim_texture(&asset, kind, s))
.and_then(|t| self.gui.atlas.sprites.get(&t.sprite()))
@@ -337,7 +350,11 @@ impl Game<'_> {
/// `ArmorStandItem.useOn` up to its `FAIL`s, which a client checks too:
/// not on a bottom face, and nothing in the new stand's box. The block it
/// stands in and the stand.
pub(super) fn armor_stand_spawn(&self, hit: &super::Hit) -> Option<([i32; 3], ArmorStand)> {
pub(super) fn armor_stand_spawn(
&self,
hit: &super::Hit,
stack: &ItemStack,
) -> Option<([i32; 3], ArmorStand)> {
if hit.face == [0, -1, 0] {
return None;
}
@@ -358,7 +375,11 @@ impl Game<'_> {
);
let yaw =
(((self.yaw - 180.0 + 180.0).rem_euclid(360.0) - 180.0 + 22.5) / 45.0).floor() * 45.0;
let stand = ArmorStand::new(self.next_entity + 1, foot, yaw);
let mut stand = ArmorStand::new(self.next_entity + 1, foot, yaw);
// Entity.applyImplicitComponents: a name from an anvil names the stand.
if let Some(name) = stack.custom_name() {
stand.custom_name = Some(horizon_client::text::Text::literal(name));
}
let b = stand.bounds();
// Touching the floor is not a collision.
let (lo, hi) = (b.min_position(), b.max_position());
@@ -386,7 +407,7 @@ impl Game<'_> {
if &*stack.item != "minecraft:armor_stand" {
return false;
}
let Some((pos, stand)) = self.armor_stand_spawn(hit) else {
let Some((pos, stand)) = self.armor_stand_spawn(hit, stack) else {
return false;
};
let foot = stand.position;
@@ -559,6 +580,37 @@ impl Game<'_> {
}
hurt
}
/// `ArmorStand.showBreakingParticles`: ten oak plank crumbs from the box,
/// spread by a quarter of its width and height, sent like `sendParticles`.
fn show_breaking_particles(&mut self, at: [f64; 3], width: f64, height: f64) {
let Some(state) = self
.pack
.assets
.registry
.default_state("minecraft:oak_planks")
else {
return;
};
let block = [
at[0].floor() as i32,
at[1].floor() as i32,
at[2].floor() as i32,
];
let Some((sprite, tint)) = self.particle_sprite(state, block) else {
return;
};
let options = horizon_client::particles::ParticleOptions::block(
horizon_client::particles::ParticleType::Block,
sprite,
tint,
);
// Entity.getY(2/3): the box's height is scaled by 2/3 from the feet.
let centre = [at[0], at[1] + height * (2.0 / 3.0), at[2]];
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));
}
/// `ArmorStand.hurtServer` for a player's attack (also a sweep's): a
/// creative player breaks it, otherwise the first hit wobbles it and a
/// second within five ticks breaks it.
@@ -567,9 +619,11 @@ impl Game<'_> {
let at = self.armor_stands[i].position;
let at = [at.x, at.y, at.z];
if self.creative {
let (width, height) = stand_box(&self.armor_stands[i]);
self.armor_stands.remove(i);
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.show_breaking_particles(at, f64::from(width), f64::from(height));
return true;
}
if time - self.armor_stands[i].last_hit > 5 {
@@ -581,8 +635,10 @@ impl Game<'_> {
}
// brokenByPlayer: the stand item, then brokenByAnything: the equipment.
let a = self.armor_stands.remove(i);
let (width, height) = stand_box(&a);
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.show_breaking_particles(at, f64::from(width), f64::from(height));
let center = [at[0], at[1] + if a.small { 0.4875 } else { 0.9875 }, at[2]];
let stand = ItemStack {
item: Arc::from("minecraft:armor_stand"),
@@ -635,17 +691,23 @@ impl Game<'_> {
}
}
for i in dead.into_iter().rev() {
let a = self.armor_stands.remove(i);
let at = [a.position.x, a.position.y, a.position.z];
let (pos, drops) = burnt_armor_stand_equipment(a);
for stack in drops {
self.pop_resource(pos, stack);
}
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
self.break_armor_stand_by_anything(i);
}
}
/// `ArmorStand.brokenByAnything`, then `kill`, as burning out or a blast
/// breaks a stand: its equipment pops above its block, the stand item does
/// not drop.
pub(super) fn break_armor_stand_by_anything(&mut self, i: usize) {
let a = self.armor_stands.remove(i);
let at = [a.position.x, a.position.y, a.position.z];
let (pos, drops) = broken_armor_stand_equipment(a);
for stack in drops {
self.pop_resource(pos, stack);
}
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
/// ArmorStandRenderer: the stand with its default pose, then armor layers.
pub(super) fn armor_stand_geometry(
&self,
@@ -770,16 +832,12 @@ impl Game<'_> {
} else {
"humanoid"
};
let Some((texture, undyed)) =
assets.equipment.get(&asset).and_then(|k| k.get(kind))
else {
let Some(layers) = assets.equipment.get(&asset).and_then(|k| k.get(kind)) else {
continue;
};
let (Some(uv), Some(part)) = (uv_of(texture), assets.layers.get(&(layer, a.small)))
else {
let Some(part) = assets.layers.get(&(layer, a.small)) else {
continue;
};
let color = layer_color(*undyed, Some(stack));
// ArmorStandRenderer's HumanoidArmorLayer: the armour glint
// when `stack.hasFoil()`, as on a living entity.
let item_glint = self
@@ -797,16 +855,24 @@ impl Game<'_> {
// ArmorStandArmorModel.setupAnim: the same pose as the stand.
let mut layer_poses = Poses::new(part);
apply_pose(&mut layer_poses, &a.pose);
horizon_client::mobs::render::draw(
part,
base,
&layer_poses,
uv,
color,
packed,
overlay,
out,
);
// EquipmentLayerRenderer: every layer of the asset, the leather overlay
// too; the shader glint goes on the first layer only (renderShaderGlint).
for (i, (texture, undyed)) in layers.iter().enumerate() {
let Some(uv) = uv_of(texture) else {
continue;
};
let color = layer_color(*undyed, Some(stack));
horizon_client::mobs::render::draw(
part,
base,
&layer_poses,
uv,
color,
packed,
if i == 0 { overlay } else { 0 },
out,
);
}
// EquipmentLayerRenderer: the trim layer over the armour.
if let Some(trim) = assets
.trim_texture(&asset, kind, stack)
@@ -1041,6 +1107,14 @@ impl Game<'_> {
}
}
}
/// `ArmorStand.getBbWidth` and `getBbHeight`: a marker has no box, a small stand is half size.
fn stand_box(a: &ArmorStand) -> (f32, f32) {
match (a.marker, a.small) {
(true, _) => (0.0, 0.0),
(false, true) => (0.25, 0.9875),
(false, false) => (0.5, 1.975),
}
}
/// `AbstractSkullBlock.getType` of a skull item.
pub(super) fn skull_kind(item: &str) -> Option<&'static str> {
Some(match item {
@@ -1055,7 +1129,15 @@ pub(super) fn skull_kind(item: &str) -> Option<&'static str> {
})
}
fn burnt_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec<ItemStack>) {
/// `ArmorStand.ignoreExplosion` (an invisible stand), and `hurtServer`
/// refusing an invisible or marker stand: such a stand outlasts any blast.
pub(super) fn spared_by_blast(a: &ArmorStand) -> bool {
a.invisible || a.marker
}
/// `ArmorStand.brokenByAnything`: the equipment, less what curse of vanishing
/// keeps (`PREVENT_EQUIPMENT_DROP`), popped at `blockPosition().above()`.
fn broken_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec<ItemStack>) {
let pos = [
a.position.x.floor() as i32,
a.position.y.floor() as i32 + 1,
@@ -1073,6 +1155,16 @@ fn burnt_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec<ItemStack>) {
mod burn_drop_tests {
use super::*;
#[test]
fn only_visible_non_marker_stands_are_broken_by_blasts() {
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 64.0, 0.5), 0.0);
assert!(!spared_by_blast(&stand));
stand.invisible = true;
assert!(spared_by_blast(&stand));
stand.invisible = false;
stand.marker = true;
assert!(spared_by_blast(&stand));
}
#[test]
fn burnt_equipment_drops_above_the_block_and_vanishing_items_do_not_drop() {
let mut stand = ArmorStand::new(1, Vec3::new(-0.1, 4.8, 0.0), 0.0);
let kept = ItemStack::new("minecraft:diamond_boots", 1);
@@ -1083,7 +1175,7 @@ mod burn_drop_tests {
.push(("minecraft:vanishing_curse".into(), 1));
stand.equipment[0] = Some(kept.clone());
stand.equipment[1] = Some(vanished);
let (pos, drops) = burnt_armor_stand_equipment(stand);
let (pos, drops) = broken_armor_stand_equipment(stand);
assert_eq!(pos, [-1, 5, 0]);
assert_eq!(drops, vec![kept]);
}
+2 -2
View File
@@ -177,7 +177,7 @@ impl Game<'_> {
}
}
if b.last_check_y >= surface {
b.last_check_y = super::MIN_Y - 1;
b.last_check_y = self.world.bottom_y() - 1;
b.sections = std::mem::take(&mut b.checking);
let (was, is) = (previous > 0, b.levels > 0);
if !was && is {
@@ -231,7 +231,7 @@ impl Game<'_> {
let mut levels = 0;
for step in 1..=4 {
let ly = y - step;
if ly < super::MIN_Y {
if ly < self.world.bottom_y() {
break;
}
let ok = (x - step..=x + step).all(|lx| {
@@ -461,7 +461,7 @@ impl Game<'_> {
if is_conduit(section.get(i)) {
found.push([
cx * 16 + (i & 15) as i32,
horizon_client::chunk::MIN_Y + si as i32 * 16 + (i >> 8) as i32,
self.world.bottom_y() + si as i32 * 16 + (i >> 8) as i32,
cz * 16 + ((i >> 4) & 15) as i32,
]);
}
+65 -5
View File
@@ -8,7 +8,8 @@ use horizon_client::{
items::ItemStack, save,
};
use horizon_world::{
nbt::{Compound, Tag},
level::classes::Class,
nbt::{Compound, JavaString, Tag},
registry::BlockStateId,
};
@@ -42,10 +43,32 @@ impl Game<'_> {
let key = (pos[0], pos[1], pos[2]);
let state = self.world.block(pos[0], pos[1], pos[2]);
let facts = self.res.facts().get(state);
if &*facts.item == "minecraft:air" {
// PistonHeadBlock.getCloneItemStack: the piston the head belongs to.
let head = self
.pack
.assets
.registry
.state(state)
.filter(|s| s.name.as_str() == "minecraft:piston_head")
.map(|s| match s.properties.get("type") {
Some(t) if t == "sticky" => "minecraft:sticky_piston",
_ => "minecraft:piston",
});
let item = head.unwrap_or(&*facts.item);
if item == "minecraft:air" {
return None;
}
let mut stack = ItemStack::new(&facts.item, 1);
// FlowerPotBlock.getCloneItemStack: a potted plant picks as that plant
// (`new ItemStack(potted)`), an empty pot as the pot itself.
let behaviours = self.res.behaviours();
let plant = behaviours.data(state);
let plant_item = plant
.is(Class::FlowerPot)
.then_some(plant.link)
.flatten()
.map(|block| behaviours.block_data(block).item.clone())
.filter(|item| &**item != "minecraft:air");
let mut stack = ItemStack::new(plant_item.as_deref().unwrap_or(item), 1);
let mut patch = Compound::new();
// BeehiveBlock.getCloneItemStack: the honey level as the item's block state.
if include_data
@@ -62,16 +85,53 @@ impl Game<'_> {
Tag::compound([("honey_level", Tag::String(level.as_str().into()))]),
);
}
// CopperGolemStatueBlock.getCloneItemStack: the statue's pose as the item's
// block_state (`CopperGolemStatueBlockEntity.getItem`), data or not.
if let Some(pose) = self
.pack
.assets
.registry
.state(state)
.filter(|s| s.name.ends_with("copper_golem_statue"))
.and_then(|s| s.properties.get("copper_golem_pose"))
{
patch.insert(
"minecraft:block_state".into(),
Tag::compound([("copper_golem_pose", Tag::String(pose.as_str().into()))]),
);
}
// LightBlock.getCloneItemStack, setLightOnStack: the light's level as
// the item's block state, whether or not the block data is picked.
if let Some(level) = self
.pack
.assets
.registry
.state(state)
.filter(|s| s.name.as_str() == "minecraft:light")
.and_then(|s| s.properties.get("level"))
{
patch.insert(
"minecraft:block_state".into(),
Tag::compound([("level", Tag::String(level.as_str().into()))]),
);
}
// AbstractBannerBlock.getCloneItemStack: the banner's own item
// (BannerBlockEntity.getItem), data or not.
let banner = matches!(
self.world.block_entities.get(&key),
Some(BlockEntity::Banner(_))
);
if (include_data || banner)
// DecoratedPotBlock.getCloneItemStack: createDecoratedPotInstance, the
// sherds only; the pot's item comes with include_data.
let pot = &*facts.name == "minecraft:decorated_pot";
if (include_data || banner || pot)
&& let Some(saved) = self.saved_block_entity(key)
{
patch.extend(block_entity_item::pick(&saved));
let mut picked = block_entity_item::pick(&saved);
if pot && !include_data {
picked.retain(|k, _| *k == JavaString::from("minecraft:pot_decorations"));
}
patch.extend(picked);
}
if !patch.is_empty() {
let mut all = components(&stack);
+52 -1
View File
@@ -474,6 +474,7 @@ impl Game<'_> {
if let Some(names) = self.remote_online_names() {
self.ui.registries.players = names;
}
self.ui.registries.custom_completions = self.remote_custom_completions();
}
/// `ChatComponent.createScreen`: T or / opens the chat with the draft kept
/// last time, when this method can restore it (a command draft only for
@@ -978,6 +979,41 @@ impl Game<'_> {
.chat
.draw(batch, &self.gui.atlas, self.ticks, focused);
}
/// `HoverEvent.EntityTooltipInfo.getTooltipLines`: the name when the
/// event has one, the entity type and the UUID.
fn entity_tooltip_lines(entity: &serde_json::Value) -> Vec<(Text, u32)> {
let mut lines = Vec::new();
if let Some(name) = entity.get("name") {
let name = horizon_client::component::to_text(name, &mut |s| Text::literal(s));
lines.push((name, u32::MAX));
}
let id = entity
.get("id")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let kind = Text::translate(&format!("entity.{}", id.replace(':', ".")), &[]);
lines.push((
Text::translate("gui.entity_tooltip.type", &[kind]),
u32::MAX,
));
if let Some(uuid) = entity.get("uuid").and_then(Self::entity_uuid) {
lines.push((Text::literal(uuid), u32::MAX));
}
lines
}
/// The UUID of a ShowEntity event: its string, or the int array
/// `UUIDUtil.CODEC` writes (most significant word first).
fn entity_uuid(v: &serde_json::Value) -> Option<String> {
if let Some(s) = v.as_str() {
return Some(s.to_owned());
}
let words = v.as_array().filter(|w| w.len() == 4)?;
let mut bits = 0u128;
for w in words {
bits = (bits << 32) | u128::from(w.as_i64()? as u32);
}
Some(horizon_protocol::crypto::uuid_string(bits))
}
pub(super) fn draw_chat_screen(&mut self, batch: &mut HudBatch) {
let Some(mut screen) = self.ui.chat_screen.take() else {
return;
@@ -1028,6 +1064,13 @@ impl Game<'_> {
let (cx, cy) = (f64::from(self.cursor.0), f64::from(self.cursor.1));
let wrap = (batch.width as i32 / 2).max(200);
if let Some(style) = self.ui.chat.style_at(&self.gui.atlas.font, height, cx, cy) {
// DrawingFocusedGraphicsAccess.handleMessage: the pointing hand over
// insertion text, which ChatScreen only asks for while Shift is held.
if style.insertion.is_some() && self.has_shift_down() {
horizon_client::widgets::request_cursor(
horizon_client::widgets::Cursor::PointingHand,
);
}
if let Some(hover) = style.hover {
let lines = self.wrapped_rows(&hover, wrap);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
@@ -1038,6 +1081,14 @@ impl Game<'_> {
.filter(|s| self.items().get(&s.item).is_some())
{
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
} else if let Some(entity) = style
.hover_entity
.as_deref()
.filter(|_| self.settings.file.bool("advancedItemTooltips"))
{
// ChatScreen: a ShowEntity tooltip only with advanced tooltips on.
let lines = Self::entity_tooltip_lines(entity);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
} else if self.ui.chat.indicator_at(height, cx, cy) {
// GuiMessageTag.systemSinglePlayer's text, split at 210.
@@ -1204,7 +1255,7 @@ impl Game<'_> {
let Some(p) = self.player.as_ref() else {
return;
};
let below_world = p.position.y < f64::from(horizon_client::chunk::MIN_Y - 64);
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]),
@@ -144,7 +144,7 @@ impl Game<'_> {
.filter_map(|e| effect_particle(e.id, e.ambient))
.collect();
let ambient = effects.iter().filter(|e| e.visible).all(|e| e.ambient);
let invisible = effects.iter().any(|e| e.id == "invisibility");
let invisible = self.own_invisible();
let size = (
f64::from(p.dimensions.width),
f64::from(p.dimensions.height),
+136 -1
View File
@@ -1,8 +1,9 @@
//! 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};
use super::{Game, chat::PLAYER_NAME, commands::Target};
use horizon_client::{
health::HealthEvent,
mobs::{Mob, Species},
motion::MotionEvent,
particles::{ParticleOptions, ParticleType},
text::Text,
@@ -72,6 +73,10 @@ pub(super) struct CombatTracker {
/// a head yet (`Creeper.droppedSkulls`): they are gone from the mob list,
/// but the mobs they killed still ask for their loot.
pub(super) charged_exploders: Vec<u32>,
/// The mobs whose blast went off this tick and that left the mob list
/// (`MobEvent::ExplosionSource`): their names, for the death messages of
/// the mobs the blast killed.
pub(super) exploded: Vec<(u32, Text)>,
/// The player's fall distance before this tick's movement (as of the
/// end of the last one).
fall_before: f64,
@@ -391,4 +396,134 @@ impl Game<'_> {
// template that names a killer then shows as written).
Text::translate(&key, &[me])
}
/// `TamableAnimal.die`: with `showDeathMessages`, a tame wolf, cat,
/// parrot or nautilus's owner, when online in any dimension
/// (`EntityReference.getEntity` looks through every level), is sent how it
/// died. `killer` is the player who last hurt it (`None` inside for the
/// host), from `last_hurt_by_player`.
pub(super) fn tell_owner_pet_died(&mut self, m: &Mob, killer: Option<Option<u32>>) {
let tamable = matches!(
m.species(),
Species::Wolf
| Species::Cat
| Species::Parrot
| Species::Nautilus
| Species::ZombieNautilus
);
let Some(owner) = m.extra.owner.filter(|_| tamable && m.extra.tame) else {
return;
};
if !self.ui.rules.bool("show_death_messages") {
return;
}
let host = self.player.is_some()
&& self.options.dedicated.is_none()
&& horizon_client::mobs::tame::owned_by(
Some(owner),
self.level.player_uuid.unwrap_or_default(),
);
let uuid = horizon_client::lan::playerdata::ints_uuid(owner);
let guest = !host
&& self
.lan
.as_ref()
.is_some_and(|l| l.hub.guests.iter().any(|g| g.placed && g.uuid == uuid));
if !host && !guest {
return;
}
let message = self.mob_death_message(m, killer);
if host {
self.system_message(message);
} else if let Some(lan) = &self.lan {
let content = horizon_client::lan::data::component_nbt(&message.component());
lan.hub.system_message_to_player(uuid, content);
}
}
/// `CombatTracker.getDeathMessage` for a mob, from the hits it recorded
/// (`Mob::combat`), told the way the player's own tracker tells them.
pub(super) fn mob_death_message(&mut self, m: &Mob, killer: Option<Option<u32>>) -> Text {
use horizon_client::mobs::{DamageKind, Who};
let me = mob_name(m);
// The player behind a `Who::Player` hit: the last one to hurt it, or
// the one in place (a server with nobody acting has none).
let player = match killer {
Some(Some(conn)) => Some((u32::MAX - 1 - conn, self.target_name(Target::Guest(conn)))),
Some(None) => Some((u32::MAX, self.target_name(Target::Player))),
None if self.player.is_some() || self.lan_acting.is_some() => {
Some((u32::MAX, self.target_name(Target::Player)))
}
None => None,
};
// The cause's display name: a mob that is gone (a creeper that blew
// up) is still named by its blast.
let name = |g: &Self, who: Who| match who {
Who::Player => player.clone(),
Who::Mob(id) => g
.mob_display_name(id)
.or_else(|| {
g.combat
.exploded
.iter()
.find(|(e, _)| *e == id)
.map(|(_, n)| n.clone())
})
.map(|n| (id, n)),
};
let entries = m
.combat
.entries
.iter()
.map(|e| {
let attacker = e.source.attacker.and_then(|w| name(self, w));
// AbstractArrow.onHitEntity: an arrow nobody shot is its own
// cause.
let direct = (attacker.is_none() && e.source.kind == DamageKind::Arrow)
.then(|| Text::translate("entity.minecraft.arrow", &[]));
Entry {
// DamageSources.explosion: player_explosion whenever
// something caused the blast (the only kind that asks).
damage_type: super::creatures::damage_type(
e.source.kind,
e.source.attacker.is_some(),
)
.to_owned(),
damage: e.damage,
fall_location: e.in_water.then_some("water"),
fall_distance: e.fall_distance,
attacker,
direct,
}
})
.collect();
// LivingEntity.getKillCredit: the player who hurt it lately, else the
// last mob to hurt it within 100 ticks.
let credit = if m.ai.last_hurt_by_player > 0 && player.is_some() {
player.clone()
} else {
m.ai.last_hurt_by
.filter(|_| m.age as i32 - m.ai.last_hurt_by_time <= 100)
.and_then(|w| name(self, w))
};
let now = self.world.game_time;
let mut tracker = CombatTracker {
entries,
last_damage: now,
in_combat: m.combat.in_combat,
last_hurt_by: credit.map(|c| (c, now)),
..CombatTracker::default()
};
std::mem::swap(&mut self.combat, &mut tracker);
let text = self.combat_death_message_for(me);
std::mem::swap(&mut self.combat, &mut tracker);
text
}
}
/// `Entity.getDisplayName` of a mob: its custom name or its type's name.
fn mob_name(m: &Mob) -> Text {
match (&m.custom_name_text, &m.custom_name) {
(Some(text), _) => text.clone(),
(None, Some(name)) => Text::literal(name.clone()),
(None, None) => Text::translate(&format!("entity.{}", m.kind.id.replace(':', ".")), &[]),
}
}
@@ -30,7 +30,7 @@ type Pos = (i32, i32, i32);
/// The open `CommandBlockEditScreen`.
pub(super) struct CommandBlockScreen {
pub(super) pos: Pos,
pos: Pos,
/// The command box with its `CommandSuggestions`.
command: ChatScreen,
/// When the command box was focused, for its cursor's blink.
@@ -112,6 +112,7 @@ impl CommandBlockScreen {
self.previous.set_value(&shown);
}
}
fn state_of(registry: &BlockRegistry, state: BlockStateId) -> Option<&BlockState> {
registry.state(state)
}
@@ -240,21 +241,21 @@ impl Game<'_> {
let at = BlockPos::new(pos.0, pos.1, pos.2);
self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at));
}
/// `CommandBlockEditScreen.updateGui`: the server's block-entity data
/// arrived while the screen is open; refresh the fields from the entity.
/// The mode and conditional live in the block state here (vanilla's
/// `CommandBlockEntity.Mode`).
pub(super) fn update_command_block_screen(&mut self) {
let Some(screen_pos) = self.command_block_screen.as_ref().map(|s| s.pos) else {
/// `ClientPacketListener.handleBlockEntityData` then
/// `CommandBlockEditScreen.updateGui`: new data for the command block the
/// edit screen shows refreshes its fields. The mode and conditional live in
/// the block state here (vanilla's `CommandBlockEntity.Mode`).
pub(super) fn command_block_data_arrived(&mut self, pos: Pos) {
if self.command_block_screen.as_ref().map(|s| s.pos) != Some(pos) {
return;
};
let state = self.block_state_at(screen_pos).map(|s| {
}
let state = self.block_state_at(pos).map(|s| {
(
Mode::of_block(&s.name).unwrap_or(Mode::Redstone),
conditional(s),
)
});
let block = self.command_block(screen_pos).cloned();
let block = self.command_block(pos).cloned();
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote),
@@ -825,18 +826,6 @@ impl Game<'_> {
}),
);
}
/// `ClientPacketListener.handleBlockEntityData`: new data for the command
/// block the edit screen shows refills it (`CommandBlockEditScreen.updateGui`).
pub(super) fn command_block_data_arrived(&mut self, pos: Pos) {
let open = self.screen == Screen::CommandBlock
&& self
.command_block_screen
.as_ref()
.is_some_and(|s| s.pos == pos);
if open {
self.open_command_block(pos);
}
}
/// What `handleSetCommandBlock` does once the player may edit the block.
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
let pos = s.pos;
+98 -8
View File
@@ -28,6 +28,7 @@ use horizon_client::{
text::{self, Style, Text},
};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_protocol::packets::play::{self, Clientbound as C};
use horizon_world::registry::BlockStateId;
pub(super) type R<T> = Result<T, CommandError>;
@@ -87,6 +88,30 @@ fn java_float(v: f64) -> String {
fn format_double(v: f64) -> String {
format!("{v:.6}")
}
/// `EntitySelectorParser.createRotationPredicate`: the wrapped angle lies in
/// the wrapped range, which runs through 180 when its minimum is above its
/// maximum (defaults 0 and 359).
fn rotation_in((lo, hi): (Option<f64>, Option<f64>), angle: f32) -> bool {
let min = wrap_degrees(lo.unwrap_or(0.0) as f32);
let max = wrap_degrees(hi.unwrap_or(359.0) as f32);
let angle = wrap_degrees(angle);
if min > max {
angle >= min || angle <= max
} else {
angle >= min && angle <= max
}
}
/// `Mth.wrapDegrees`.
fn wrap_degrees(angle: f32) -> f32 {
let wrapped = angle % 360.0;
if wrapped >= 180.0 {
wrapped - 360.0
} else if wrapped < -180.0 {
wrapped + 360.0
} else {
wrapped
}
}
impl Game<'_> {
/// Commands.performCommand for a line without its slash.
@@ -301,9 +326,7 @@ impl Game<'_> {
if dimension != self.dim || !self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4)) {
return Err(err("argument.pos.unloaded", &[]));
}
if p[1] < horizon_client::chunk::MIN_Y
|| p[1] >= horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT
{
if p[1] < self.world.bottom_y() || p[1] >= self.world.top_y() {
return Err(err("argument.pos.outofworld", &[]));
}
Ok(p)
@@ -655,6 +678,18 @@ impl Game<'_> {
{
return None;
}
// x_rotation is the pitch, y_rotation the yaw (getXRot, getYRot).
let [yaw, pitch] = self.target_rotation(t);
if let Some(range) = s.x_rotation
&& !rotation_in(range, pitch)
{
return None;
}
if let Some(range) = s.y_rotation
&& !rotation_in(range, yaw)
{
return None;
}
if s.delta.iter().any(Option::is_some) {
let d = s.delta.map(|v| v.unwrap_or(0.0));
for i in 0..3 {
@@ -689,7 +724,14 @@ impl Game<'_> {
.iter()
// The kinds /data can write (entity_nbt).
.filter(|p| {
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
p.is_arrow()
|| matches!(
p.kind,
K::LargeFireball { .. }
| K::SmallFireball
| K::WindCharge
| K::BreezeWindCharge
)
})
.map(|p| Target::Projectile(p.id)),
);
@@ -719,6 +761,27 @@ impl Game<'_> {
});
dims
}
/// `Level.getEntity`: the entity of the level in place with a network
/// id, players aside.
pub(in crate::game) fn level_entity(&self, id: i32) -> Option<Target> {
let id = u32::try_from(id).ok()?;
[
Target::Mob(id),
Target::Item(id),
Target::Orb(id),
Target::Projectile(id),
Target::Stand(id),
Target::Frame(id),
Target::Painting(id),
Target::Boat(id),
Target::Cart(id),
Target::Other(id),
Target::Crystal(id),
Target::Dragon(id),
]
.into_iter()
.find(|t| self.level_holds(*t))
}
/// Whether the level in place holds the entity a target names.
fn level_holds(&self, t: Target) -> bool {
match t {
@@ -1034,6 +1097,11 @@ impl Game<'_> {
let custom = s.data().and_then(|data| data.custom_name.as_ref());
let hover = if let Some(c) = custom {
horizon_client::component::name_text(c)
} else if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) {
// `ItemStack.getItemName`: the `item_name` component wins over the
// item's own name (`Item.getName`), so a datapack's or a server's
// renaming shows here as it does on the tooltip's first line.
horizon_client::component::name_text(name)
} else if let Some(title) = s
.data()
.and_then(|d| d.written.as_ref())
@@ -1418,7 +1486,10 @@ impl Game<'_> {
let targets = if id.starts_with("teleport.targets") {
self.entities(ctx, "targets", false)?
} else {
vec![self.implied_player()]
vec![
self.source_entity()
.ok_or_else(|| err("permissions.requires.entity", &[]))?,
]
};
if id.ends_with("destination") {
let dest = self.entities(ctx, "destination", false)?[0];
@@ -1731,6 +1802,16 @@ impl Game<'_> {
}
Target::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
// LivingEntity.kill: a generic_kill hit of Float.MAX_VALUE,
// which its death message tells.
if m.alive() {
m.record_damage(
horizon_client::mobs::Damage::of(
horizon_client::mobs::DamageKind::GenericKill,
),
f32::MAX,
);
}
m.health = 0.0;
if m.death_time == 0 {
m.death_time = 1;
@@ -2617,6 +2698,15 @@ impl Game<'_> {
// Peaceful removes hostile mobs.
self.mobs.retain(|m| !m.hostile());
}
// MinecraftServer.setDifficulty: every player, guests included, is
// sent the new difficulty.
if let Some(host) = self.lan.as_ref() {
host.hub
.broadcast(&C::ChangeDifficulty(play::ChangeDifficulty {
difficulty: i32::from(i),
locked: self.level.difficulty_locked,
}));
}
self.feedback(Text::translate("commands.difficulty.success", &[name(i)]));
Ok(0)
}
@@ -2966,7 +3056,7 @@ impl Game<'_> {
if !section.blocks.maybe_has(&mut wanted_state) {
continue;
}
let base_y = horizon_client::chunk::MIN_Y + 16 * i as i32;
let base_y = self.world.bottom_y() + 16 * i as i32;
for j in 0..4096 {
let state = section.get(j);
let Some(kind) = cache.get(&state).copied().flatten() else {
@@ -3028,8 +3118,8 @@ impl Game<'_> {
radius: 6400,
horizontal: 32,
vertical: 64,
min_y: horizon_client::chunk::MIN_Y,
max_y: horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT - 1,
min_y: self.world.bottom_y(),
max_y: self.world.top_y() - 1,
};
let found = self
.end
@@ -318,6 +318,9 @@ impl Game<'_> {
self.take_loot_table(key, &tag, &mut new);
self.world.block_entities.insert(key, new);
self.update_spawner_display(key);
// BlockDataAccessor.setData: setChanged and sendBlockUpdated(3),
// even when the block state itself stays the same.
self.mark_block_entity_updated(key);
let state = self.world.block(p[0], p[1], p[2]);
// Redraw what shows the block entity (signs, banners).
let _ = self.world.set(p[0], p[1], p[2], state);
@@ -785,8 +785,8 @@ impl Game<'_> {
}
/// The highest block of a heightmap type at a column (`Level.getHeight`).
fn heightmap_at(&self, kind: &str, x: i32, z: i32) -> i32 {
let top = horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT;
for y in (horizon_client::chunk::MIN_Y..top).rev() {
let top = self.world.top_y();
for y in (self.world.bottom_y()..top).rev() {
let state = self.world.block(x, y, z);
if state == self.world.air {
continue;
@@ -809,7 +809,7 @@ impl Game<'_> {
return y + 1;
}
}
horizon_client::chunk::MIN_Y
self.world.bottom_y()
}
/// A `store` modifier: a new callback in the source's chain.
fn store_source(&mut self, c: &Context, s: Source) -> R<Source> {
@@ -310,12 +310,19 @@ impl Game<'_> {
None => (0, None),
};
let count = Self::int(ctx, "count").unwrap_or(1).max(1) as u32;
Ok(Some(ItemStack {
let stack = ItemStack {
item: id.as_str().into(),
count,
damage,
extra,
}))
};
// ItemInput.createItemStack: a count above the stack's max (its
// max_stack_size component, else the item's) is refused.
let max_stack = self.stack_max_size(&stack);
if count > max_stack {
return Err(err("arguments.item.overstacked", &[&id, &max_stack]));
}
Ok(Some(stack))
}
/// One item modifier (a typed object, a list or a datapack reference)
/// applied to a stack; `None` when the stack ends up empty.
@@ -1,7 +1,6 @@
//! `/forceload`, `/fillbiome`, `/worldborder` and `/loot`.
use super::{Arg, Context, Game, R, Target, err, items::Owner};
use horizon_client::{
chunk::{HEIGHT, MIN_Y},
command::CommandError,
items::ItemStack,
scoreboard_cmd::{Holder, Tracker},
@@ -213,7 +212,8 @@ impl Game<'_> {
if usize::from(current) == target {
continue;
}
let qy = ((y - MIN_Y) >> 2).clamp(0, HEIGHT / 4 - 1);
let qy = ((y - self.world.bottom_y()) >> 2)
.clamp(0, self.world.layout().height() / 4 - 1);
if let Some(chunk) = self.world.chunks.get_mut(&key) {
let chunk = std::sync::Arc::make_mut(chunk);
chunk.set_biome_cell((x & 15) >> 2, qy, (z & 15) >> 2, target as u16);
@@ -228,10 +228,20 @@ impl Game<'_> {
}
for key in &touched {
self.world.modified.insert(*key);
for sy in 0..horizon_client::chunk::SECTIONS as i32 {
for sy in 0..self.world.layout().sections() as i32 {
self.world.dirty_sections.insert((key.0, sy, key.1));
}
}
// ChunkMap.resendBiomesForChunks: the players who see the changed
// columns are sent their new biomes, in the order the columns were
// gathered (z, then x).
if let Some(host) = self.lan.as_ref() {
let columns: Vec<(i32, i32)> = ((lo[2] >> 4)..=(hi[2] >> 4))
.flat_map(|cz| ((lo[0] >> 4)..=(hi[0] >> 4)).map(move |cx| (cx, cz)))
.filter(|key| touched.contains(key))
.collect();
host.hub.resend_biomes(&self.world, &columns);
}
self.feedback(Text::translate(
"commands.fillbiome.success.count",
&[
@@ -113,7 +113,8 @@ impl Game<'_> {
"explosion" | "player_explosion" | "bad_respawn_point" => DamageKind::Explosion,
"fall" | "ender_pearl" | "fly_into_wall" => DamageKind::Fall,
"on_fire" => DamageKind::OnFire,
"in_fire" | "hot_floor" => DamageKind::InFire,
"in_fire" => DamageKind::InFire,
"hot_floor" => DamageKind::HotFloor,
"campfire" => DamageKind::Campfire,
"lava" => DamageKind::Lava,
"drown" => DamageKind::Drown,
@@ -202,10 +203,15 @@ impl Game<'_> {
attacker,
from,
};
self.mobs
let hurt = self
.mobs
.iter_mut()
.find(|m| m.id == id)
.is_some_and(|m| m.damage(amount, damage))
.is_some_and(|m| m.damage(amount, damage));
if hurt {
self.resolve_player_responsible(id, damage);
}
hurt
}
_ => false,
};
@@ -1278,12 +1284,12 @@ impl Game<'_> {
let respect = matches!(ctx.get("respectTeams"), Some(Arg::Bool(true)));
let max_y = match ctx.get("maxHeight") {
Some(Arg::Int(h)) => *h,
_ => horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT,
_ => self.world.top_y(),
};
if max_y < horizon_client::chunk::MIN_Y {
if max_y < self.world.bottom_y() {
return Err(err(
"commands.spreadplayers.failed.invalid.height",
&[&max_y, &horizon_client::chunk::MIN_Y],
&[&max_y, &self.world.bottom_y()],
));
}
let targets = self.entities(ctx, "targets", false)?;
@@ -1484,7 +1490,7 @@ impl Game<'_> {
let mut air2 = air(y);
y -= 1;
let mut air1 = air(y);
while y > horizon_client::chunk::MIN_Y {
while y > self.world.bottom_y() {
y -= 1;
let current = air(y);
if !current && air1 && air2 {
+3 -3
View File
@@ -128,7 +128,7 @@ impl Game<'_> {
if !self.world.loaded(pos.x, pos.z) {
return false;
}
while pos.y > self.dim.min_y() {
while pos.y > self.world.shape.min_y {
pos.y -= 1;
let state = self.world.block(pos.x, pos.y, pos.z);
if self.state_in_tag("minecraft:entities_can_teleport_to", state) {
@@ -296,10 +296,10 @@ impl TeleportConsumeWorld for Teleporter<'_, '_> {
&mut self.0.world.random
}
fn min_y(&mut self, level: Dim) -> Result<i32, Infallible> {
Ok(level.min_y())
Ok(self.0.level_shapes.get(level.id()).min_y)
}
fn logical_height(&mut self, level: Dim) -> Result<i32, Infallible> {
Ok(level.logical_height())
Ok(self.0.level_shapes.get(level.id()).logical_height)
}
fn is_passenger(&mut self) -> Result<bool, Infallible> {
Ok(self.0.riding.is_some())
+1 -1
View File
@@ -342,7 +342,7 @@ impl Game<'_> {
let (lx, lz, ly) = ((i & 15) as i32, ((i >> 4) & 15) as i32, (i >> 8) as i32);
let pos = (
key.0 * 16 + lx,
horizon_client::chunk::MIN_Y + si as i32 * 16 + ly,
self.world.bottom_y() + si as i32 * 16 + ly,
key.1 * 16 + lz,
);
self.world
+500 -43
View File
@@ -21,7 +21,7 @@ use horizon_client::{
text::Text,
};
use horizon_core::{
geometry::{Aabb, Direction, Vec3, traverse_blocks},
geometry::{Aabb, Direction, Vec3, traverse_blocks, trig::Mth},
position::BlockPos,
random::RandomSource,
};
@@ -57,8 +57,9 @@ pub(super) struct Creatures {
/// (game/lan_pvp.rs).
pub sweeps: Vec<super::lan_pvp::Sweep>,
/// Explosion packets from a guest's turn, waiting for it to end
/// (centre, small, radius, blocks hit; game/lan_particles.rs).
pub lan_explosions: Vec<([f64; 3], bool, f32, usize)>,
/// (centre, small, radius, blocks hit, the players it pushed;
/// game/lan_particles.rs).
pub lan_explosions: Vec<([f64; 3], bool, f32, usize, Vec<(u32, [f64; 3])>)>,
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
pub elder_apparition: i32,
/// The wandering trader spawner's clock.
@@ -107,12 +108,10 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
hit.direction.normal(),
))
}
fn in_water(&self, at: Vec3) -> bool {
self.w.fluid(
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
) == Fluid::Water
/// Entity.updateFluidInteraction over the projectile's box, not the
/// block at its feet (#3072).
fn in_water(&self, bounds: Aabb) -> bool {
self.w.fluids(bounds, bounds.max_position().y).in_water
}
fn targets(&self) -> &[(Who, Aabb)] {
&self.targets
@@ -130,21 +129,35 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
/// `AbstractWindCharge` is pickable, so a thrown pearl's
/// `ProjectileUtil.getEntityHitResult` can strike it (`ThrownEnderpearl.onHit`).
const WIND_CHARGE_HIT: u32 = super::dragon::SPECIAL - 1;
/// `WIND_CHARGE_IDS + j` is `projectiles[j]` during `tick_projectiles`, below
/// the dragon's reserved ids.
const WIND_CHARGE_IDS: u32 = super::dragon::SPECIAL - 0x10000;
fn wind_charge_hitboxes(projectiles: &[Projectile], skip: usize) -> Vec<(Who, Aabb)> {
/// The index of the wind charge a `wind_charge_hitboxes` id stands for.
fn wind_charge_hit(id: u32) -> Option<usize> {
(WIND_CHARGE_IDS..super::dragon::SPECIAL)
.contains(&id)
.then(|| (id - WIND_CHARGE_IDS) as usize)
}
fn wind_charge_hitboxes(
projectiles: &[Projectile],
alive: &[bool],
skip: usize,
) -> Vec<(Who, Aabb)> {
projectiles
.iter()
.enumerate()
.filter(|(j, q)| {
*j != skip
&& alive[*j]
&& !q.stuck
&& matches!(
q.kind,
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
)
})
.map(|(_, q)| (Who::Mob(WIND_CHARGE_HIT), q.aabb()))
.map(|(j, q)| (Who::Mob(WIND_CHARGE_IDS + j as u32), q.aabb()))
.collect()
}
@@ -354,6 +367,41 @@ impl Game<'_> {
}
Some(id)
}
/// `Mob.convertTo(type, ConversionParams.single(...))` for a mob already
/// removed: a new mob of `kind`, made without spawn finalization, takes
/// its place with what `conversion::single` carries over, and the old
/// mob's riders move onto it. A zombie that comes out of it rolls
/// `Zombie.handleAttributes(special, CONVERSION)` (the
/// `postConversionStep` of its `ConversionTracker`).
pub(super) fn convert_to(
&mut self,
from: &Mob,
kind: &str,
params: horizon_client::mobs::conversion::Params,
) -> Option<u32> {
let t = self.res.mob_types().get(kind).cloned()?;
self.next_entity += 1;
let mut mob = Mob::new(self.next_entity, t, from.position, from.yaw);
horizon_client::mobs::conversion::single(from, &mut mob, params);
if matches!(from.species(), Species::Zombie | Species::Husk) {
let at = [from.position.x, from.position.y, from.position.z];
let special = self.local_difficulty(at).special();
horizon_client::mobs::finalize::zombie_attributes(
&mut mob,
special,
horizon_client::mobs::finalize::Reason::Conversion,
);
}
let id = mob.id;
// ConversionType.SINGLE: the passengers get off and board the new mob.
for rider in self.mobs.iter_mut() {
if rider.extra.mount == Some(from.id) {
rider.extra.mount = Some(id);
}
}
self.mobs.push(mob);
Some(id)
}
/// The `summon` command: spawn a mob in front of the player or at a position.
pub(super) fn summon(&mut self, kind: &str, at: Option<[f64; 3]>, baby: bool) -> Result<u32> {
let at = match at {
@@ -454,13 +502,13 @@ impl Game<'_> {
/// Highest motion-blocking (no leaves) block + 1 (Heightmap MOTION_BLOCKING_NO_LEAVES).
pub(super) fn motion_top(&self, x: i32, z: i32) -> i32 {
let top = if self.dim == Dim::Nether { 127 } else { 319 };
for y in (horizon_client::chunk::MIN_Y..=top).rev() {
for y in (self.world.bottom_y()..=top).rev() {
let f = self.res.facts().get(self.world.block(x, y, z));
if (f.full_collision || f.fluid != Fluid::None) && !f.name.ends_with("leaves") {
return y + 1;
}
}
horizon_client::chunk::MIN_Y
self.world.bottom_y()
}
pub(super) fn mob_world_base<'s>(&'s self, others: &'s [Seen]) -> MobWorld<'s> {
let (rain, thunder) = self.world.weather.levels(1.0);
@@ -589,8 +637,10 @@ impl Game<'_> {
self.died.insert(m.id);
self.mob_died(&m);
}
// Their victims have been looted: no charged creeper is waiting any more.
// Their victims have been looted and mourned: no charged creeper is
// waiting any more.
self.combat.charged_exploders.clear();
self.combat.exploded.clear();
let uuid = self.level.player_uuid;
let mut snapshot: Vec<Seen> = self.mobs.iter().map(|m| seen(m, uuid)).collect();
let mut events = Vec::new();
@@ -833,6 +883,8 @@ impl Game<'_> {
self.set_bed_occupied(bed, false);
}
let killer = self.last_hurt_by_player.remove(&m.id);
// TamableAnimal.die, before the rest of LivingEntity.die.
self.tell_owner_pet_died(m, killer);
self.love_cause.remove(&m.id);
let by_player = killer.is_some() || m.ai.last_hurt_by_player > 0;
let mut dying = m.clone();
@@ -883,8 +935,12 @@ impl Game<'_> {
}
}
self.score_entity_removed(m.id);
self.raider_died(m, by_player);
self.villager_murdered(m, by_player, killer.flatten());
// Raider.die's hero and Villager.tellWitnessesThatIWasMurdered go to
// the causing entity, not the credited player: a tame wolf's kill is
// neither its owner's heroism nor a murder by them.
let slain_by_player = by_player && !killed_by_mob(m);
self.raider_died(m, slain_by_player);
self.villager_murdered(m, slain_by_player, killer.flatten());
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
self.drop_death_loot(&mut dying, by_player, consumed, pos);
self.create_wither_rose(m);
@@ -919,7 +975,12 @@ impl Game<'_> {
/// The killer's side of a mob's death: `ServerPlayer.awardKillScore`'s
/// statistics and scores, and the `player_killed_entity` trigger.
fn credit_kill(&mut self, m: &Mob) {
self.stats.add("killed", &m.kind.id, 1);
// Player.killedEntity: the killed statistic goes to the causing
// entity, so a kill by a mob (a tame wolf credited to its owner)
// leaves it out; ServerPlayer.awardKillScore counts the rest.
if !killed_by_mob(m) {
self.stats.add("killed", &m.kind.id, 1);
}
self.stats.custom("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(m.id));
let view = self.mob_entity(m);
@@ -1056,6 +1117,20 @@ impl Game<'_> {
// the orbs' merge ids and block ticks read next).
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
}
drops::Drop::CarriedBlock(state) => {
// Enderman.dropCustomDeathLoot: spawnAtLocation for each
// of the carried block's silk-touch drops.
let stacks = horizon_client::mobs::enderman::carried_block_drops(
world_items!(self),
self.pack.assets.registry,
state,
&mut self.world.random,
Some((&mut self.rand_sequences, self.level.seed)),
);
for item in stacks {
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
}
}
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
drops::Drop::Orb(_) => {}
}
@@ -1088,6 +1163,9 @@ impl Game<'_> {
}
}
}
if hurt {
self.resolve_player_responsible(target, source);
}
if infested {
self.mob_infested(target);
}
@@ -1110,6 +1188,49 @@ impl Game<'_> {
}
hurt
}
/// `LivingEntity.resolvePlayerResponsibleForDamage` after `source` hurt a
/// mob: a tame wolf's hit is its owner's (`setLastHurtByPlayer(uuid, 100)`),
/// so the kill drops experience and player-only loot and the owner, when
/// in the world, gets the credit (`getKillCredit`); a tame wolf without an
/// owner clears it. A player's own hits are recorded where they land.
pub(super) fn resolve_player_responsible(&mut self, victim: u32, source: Damage) {
let Some(Who::Mob(attacker)) = source.attacker else {
return;
};
let Some(owner) = self
.mobs
.iter()
.find(|m| m.id == attacker && m.species() == Species::Wolf && m.extra.tame)
.map(|m| m.extra.owner)
else {
return;
};
let player = owner.and_then(|uuid| self.player_with_uuid(uuid));
let Some(m) = self.mobs.iter_mut().find(|m| m.id == victim) else {
return;
};
m.ai.last_hurt_by_player = if owner.is_some() { 100 } else { 0 };
match player {
Some(who) => self.last_hurt_by_player.insert(victim, who),
None => self.last_hurt_by_player.remove(&victim),
};
}
/// The player in the world with `uuid` (`EntityReference.getPlayer`), as
/// `last_hurt_by_player` keeps them: None for the player in place, else the
/// guest's connection.
fn player_with_uuid(&self, uuid: [i32; 4]) -> Option<Option<u32>> {
if self.player.is_some()
&& horizon_client::mobs::tame::owned_by(Some(uuid), self.acting_uuid())
{
return Some(self.lan_acting);
}
let uuid = horizon_client::lan::playerdata::ints_uuid(uuid);
self.lan
.iter()
.flat_map(|h| h.hub.guests.iter())
.find(|g| g.placed && g.uuid == uuid)
.map(|g| Some(g.conn))
}
/// `InfestedMobEffect.onMobHurt` for a mob: silverfish from half its height up.
pub(super) fn mob_infested(&mut self, mob: u32) {
if let Some(m) = self.mobs.iter().find(|m| m.id == mob) {
@@ -1205,7 +1326,7 @@ 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);
self.lan_level_particles(&options, at, spread, speed, count, None);
}
}
MobEvent::Sound(name, at, volume, pitch)
@@ -1319,6 +1440,7 @@ impl Game<'_> {
self.combat.explosion_source = Some(mob);
// The creeper causes the blast's damage, but it is out of
// the mob list already: its name comes with the event.
self.combat.exploded.push((mob, name.clone()));
self.note_named_attacker("minecraft:explosion", mob, name);
if charged {
self.combat.charged_exploders.push(mob);
@@ -1361,6 +1483,31 @@ impl Game<'_> {
} => {
self.mob_hits_mob(target, amount, source);
}
MobEvent::CubeHurtMob {
cube,
target,
amount,
source,
} => {
// AbstractCubeMob.dealDamage: the attack sound (and its
// two pitch draws, made even for a silent cube, whose
// Entity.playSound is then skipped) only follows a hit
// that landed.
if self.mob_hits_mob(target, amount, source)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == cube)
{
let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 1.0;
let at = m.center();
if !m.silent {
self.handle_mob_events(vec![MobEvent::Sound(
"entity.slime.attack",
at,
1.0,
pitch,
)])?;
}
}
}
MobEvent::SonicBoom {
target,
attacker,
@@ -1503,6 +1650,9 @@ impl Game<'_> {
self.apply_setup(id, &setup);
}
}
MobEvent::Convert { from, kind, params } => {
self.convert_to(&from, kind, params);
}
MobEvent::DropItem { item, count, at } => self.spawn_item(
ItemStack {
item,
@@ -1850,6 +2000,11 @@ impl Game<'_> {
}
}
MobEvent::PandaSneezed { mob } => self.panda_sneezed(mob),
MobEvent::FallToPassengers {
mob,
distance,
modifier,
} => self.fall_to_passengers(mob, distance, modifier),
MobEvent::ShedScute { mob, at, pitch } => {
// The mob already checked `shouldDropLoot` (a baby, `mob_drops`).
let table = "minecraft:gameplay/armadillo_shed";
@@ -2083,7 +2238,9 @@ impl Game<'_> {
}
return;
}
let coin = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
// (Mooshroom draws its own coin in getOffspringVariant.)
let coin = p.species() != Species::Mooshroom
&& self.world.random.next_int_bound(2).unwrap_or(0) == 0;
// Axolotl.getBreedOffspring: useRareVariant (one in 1200) makes it blue,
// else either parent's variant. The draws come from the mother's random.
let axolotl = if p.species() == Species::Axolotl {
@@ -2102,6 +2259,24 @@ impl Game<'_> {
} else {
None
};
// MushroomCow.getOffspringVariant, from the mother's random: the other
// colour 1 time in 1024 when both parents share one, otherwise either
// parent's colour by a coin flip.
let mooshroom = if p.species() == Species::Mooshroom {
let mate = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant);
let own = p.extra.variant;
self.mobs.iter_mut().find(|m| m.id == parent).map(|m| {
if own == mate && m.random.next_int_bound(1024).unwrap_or(1) == 0 {
if own == 1 { 0 } else { 1 }
} else if m.random.next_boolean() {
own
} else {
mate
}
})
} else {
None
};
// A horse and a donkey have a mule.
let kind = match (p.species(), q.as_ref().map(|q| q.species())) {
(Species::Horse, Some(Species::Donkey)) | (Species::Donkey, Some(Species::Horse)) => {
@@ -2113,6 +2288,8 @@ impl Game<'_> {
&& let Some(child) = self.mobs.iter_mut().find(|m| m.id == id)
{
child.extra.age = -24000;
// No Mob.finalizeSpawn either: no follow range bonus, right-handed.
spawning::bred_offspring(child);
// getBreedOffspring creates the baby without finalizeSpawn: it keeps
// the default (`classic`) sound variant.
child.extra.sound_variant = None;
@@ -2247,6 +2424,9 @@ impl Game<'_> {
{
child.extra.variant = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant);
}
if let Some(variant) = mooshroom {
child.extra.variant = variant;
}
// Sheep.getBreedOffspring: the lamb's wool mixes both parents' dyes.
if p.species() == Species::Sheep {
let other = q.as_ref().unwrap_or(&p);
@@ -2645,7 +2825,7 @@ impl Game<'_> {
let mut keep_flags = vec![true; projectiles.len()];
for i in 0..projectiles.len() {
let winds = if matches!(projectiles[i].kind, ProjectileKind::EnderPearl) {
wind_charge_hitboxes(&projectiles, i)
wind_charge_hitboxes(&projectiles, &keep_flags, i)
} else {
Vec::new()
};
@@ -2754,6 +2934,16 @@ impl Game<'_> {
hits.push((p.clone(), hit));
}
keep_flags[i] = keep;
// Projectile.onHit: the wind charge it hit turns before its own tick.
if let Hit::Entity {
who: Who::Mob(id), ..
} = hit
&& let Some(j) = wind_charge_hit(id)
{
let (owner, guest) = (p.owner, p.guest);
let look = self.owner_look(owner, guest);
super::deflect::aim_deflect(&mut projectiles[j], look, owner, guest);
}
}
let mut idx = 0;
projectiles.retain(|_| {
@@ -2863,11 +3053,29 @@ impl Game<'_> {
}
}
}
/// `onProjectileHit` of chorus flowers and decorated pots: an impact
/// projectile (`#impact_projectiles`) breaks the block when
/// `Projectile.mayInteract` and `Projectile.mayBreak` allow it, which takes
/// the `projectiles_can_break_blocks` rule and a shooter that may break
/// blocks (`canBreakBlockInAdventureMode`: never a mob, never an
/// `AmethystBlock.onProjectileHit`: a projectile that strikes an amethyst
/// block rings `block.amethyst_block.chime` there, pitched by the level random.
fn amethyst_projectile_hit(&mut self, pos: [i32; 3]) {
let state = self.world.block(pos[0], pos[1], pos[2]);
if &*self.res.facts().get(state).name != "minecraft:amethyst_block" {
return;
}
let pitch = 0.5 + self.world.random.next_float() * 1.2;
self.level_events
.push(horizon_world::level::LevelEvent::Sound {
pos: BlockPos::new(pos[0], pos[1], pos[2]),
sound: "minecraft:block.amethyst_block.chime".into(),
volume: 1.0,
pitch,
});
let _ = self.process_level_events();
}
/// `onProjectileHit` of chorus flowers, decorated pots and speleothems
/// (pointed dripstone, sulfur spikes): an impact projectile
/// (`#impact_projectiles`) breaks the block when `Projectile.mayInteract`
/// and `Projectile.mayBreak` allow it, which takes the
/// `projectiles_can_break_blocks` rule and a shooter that may break blocks
/// (`canBreakBlockInAdventureMode`: never a mob, never an
/// adventure player). A pot is cracked first (flags 260), so it shatters.
fn projectile_breaks_block(&mut self, p: &Projectile, pos: [i32; 3]) {
use horizon_client::command::args::GameMode;
@@ -2895,6 +3103,11 @@ impl Game<'_> {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
match &*self.res.facts().get(state).name {
"minecraft:chorus_flower" => {}
// SpeleothemBlock.onProjectileHit (pointed dripstone and sulfur spikes): a
// thrown trident faster than 0.6 breaks it.
"minecraft:pointed_dripstone" | "minecraft:sulfur_spike"
if matches!(p.kind, ProjectileKind::Trident)
&& p.arrow.as_ref().is_some_and(|a| a.hit_speed > 0.6) => {}
"minecraft:decorated_pot" => {
let registry = &self.pack.assets.registry;
let Some(mut pot) = registry.state(state).cloned() else {
@@ -2929,6 +3142,11 @@ impl Game<'_> {
[position.x, position.y, position.z],
[velocity.x, velocity.y, velocity.z],
),
// Arrow.tick: `broadcastEntityEvent(this, 0)`. The window and the
// guests burst the particles themselves (`remote_arrow_puff`).
ArrowEvent::PotionPuff { id, .. } => {
self.lan_entity_event(id as i32, 0, [at.x, at.y, at.z]);
}
ArrowEvent::Sound {
sound,
volume,
@@ -3074,6 +3292,7 @@ impl Game<'_> {
&& face != [0; 3]
{
self.bell_projectile_hit(p, pos, at, face);
self.amethyst_projectile_hit(pos);
self.projectile_breaks_block(p, pos);
}
self.combat.projectile_owner = match p.owner {
@@ -3229,7 +3448,7 @@ impl Game<'_> {
}
// AbstractArrow.onHitBlock plays its sound itself (ArrowEvent).
(ProjectileKind::Trident, Hit::Block { .. }) => {}
(ProjectileKind::Arrow { effect, .. }, Hit::Entity { who, .. }) => {
(ProjectileKind::Arrow { .. }, Hit::Entity { who, .. }) => {
let mut arrow = p.clone();
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
let player_fire = self.player.as_ref().map(|pl| pl.fire_ticks);
@@ -3242,17 +3461,11 @@ impl Game<'_> {
}])?;
}
let hurt = match who {
Who::Player => {
let hurt = self.mob_hits_player(
amount,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:arrow",
);
if hurt && let Some((e, t)) = effect {
self.give_player_effect(e, *t, 0);
}
hurt
}
Who::Player => self.mob_hits_player(
amount,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:arrow",
),
Who::Mob(id) => {
let hurt = self
.mobs
@@ -3371,6 +3584,9 @@ impl Game<'_> {
velocity: [p.velocity.x * k, 0.1, p.velocity.z * k],
}])?;
}
if hurt {
self.arrow_post_hurt_effects(p, who);
}
match (hurt, who) {
(true, _) => self.finish_arrow_hit(arrow, EntityHit::Hurt),
// What the player took no damage from (a raised shield)
@@ -4696,8 +4912,14 @@ impl Game<'_> {
f64::from(p[1]),
f64::from(p[2]) + 0.5,
];
if self.spawn_mob_spawned(&kind, at).is_none() {
let Some(mob_id) = self.spawn_mob_spawned(&kind, at) else {
return Ok(false);
};
// Entity.applyImplicitComponents: a name from an anvil names the mob.
if let Some(name) = stack.custom_name()
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob_id)
{
m.custom_name = Some(name.to_owned());
}
self.swing_arm();
if !self.creative
@@ -4801,6 +5023,11 @@ impl Game<'_> {
if mob.invisible && outline.is_none() {
continue;
}
// LevelRenderer: the entity the view looks out of is not drawn
// in first person.
if self.hides_as_camera(mob.id) {
continue;
}
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
// mean edge, scaled by the view scale. RemotePlayer's box counts
@@ -4972,10 +5199,28 @@ impl Game<'_> {
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z(a.pitch.to_radians());
// ArrowRenderer: `state.shake = shakeTime - partialTicks`; ArrowModel.setupAnim
// rolls the root while the shake timer runs.
let shake = a
.arrow
.as_ref()
.map_or(0.0, |s| s.shake as f32 - alpha as f32);
let roll = if shake > 0.0 {
-(shake * 3.0).msin() * shake * (std::f32::consts::PI / 180.0)
} else {
0.0
};
let pose_for = |part: &horizon_client::mobs::Part| {
let mut pose = part.pose;
if std::ptr::eq(part, model) {
pose[5] += roll;
}
pose
};
horizon_client::mobs::render_model(
model,
m,
&|part| part.pose,
&pose_for,
[r.u0, r.v0, r.u1, r.v1],
packed,
0,
@@ -5123,12 +5368,12 @@ impl Game<'_> {
let Some(r) = atlas.sprites.get(name).copied() else {
continue;
};
let s = 0.25 * a.sprite_scale();
for (k, uv) in [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
let lift = is_item_billboard(&a.kind);
let quad = thrown_item_quad(c, right, up, a.sprite_scale(), lift);
for (v, uv) in quad
.iter()
.zip([[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]])
{
let v = c + right * k.0 * s + up * k.1 * s;
out.push(Vertex {
normal: [0; 4],
pos: v.to_array(),
@@ -5158,6 +5403,36 @@ fn thrown_item_axes(
let orientation = super::camera_rotation(camera_type, yaw, pitch);
(orientation * glam::Vec3::X, orientation * glam::Vec3::Y)
}
/// The four corners of the item billboard, in the order its UVs are laid out.
/// `ThrownItemRenderer` only scales and billboards it; the lift is
/// `item/generated`'s ground display transform, `translation [0, 2, 0]` of 16
/// units = 0.125 blocks, which is `0.125 * scale` == `0.5 * s`.
fn thrown_item_quad(
at: glam::Vec3,
right: glam::Vec3,
up: glam::Vec3,
scale: f32,
lift: bool,
) -> [glam::Vec3; 4] {
let s = 0.25 * scale;
let rise = if lift { 0.5 * s } else { 0.0 };
let centre = at + up * rise;
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
.map(|k| centre + right * k.0 * s + up * k.1 * s)
}
/// Whether vanilla draws the kind as `ThrownItemRenderer`'s item billboard, the
/// only ones that take that lift. Llama spit shares this quad but
/// `LlamaSpitRenderer` is a model, with its own offset.
fn is_item_billboard(kind: &ProjectileKind) -> bool {
matches!(
kind,
ProjectileKind::Snowball
| ProjectileKind::Egg
| ProjectileKind::EnderPearl
| ProjectileKind::SmallFireball
| ProjectileKind::LargeFireball { .. }
)
}
/// `Bucketable.getBucketItemStack` and `getPickupSound` of a mob that a water
/// bucket picks up.
fn bucketable(species: Species) -> Option<(&'static str, &'static str)> {
@@ -5221,6 +5496,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::Spit => "minecraft:spit",
K::Stalagmite => "minecraft:stalagmite",
K::Starve => "minecraft:starve",
K::HotFloor => "minecraft:hot_floor",
K::SulfurCubeHot => "minecraft:sulfur_cube_hot",
K::WindCharge => "minecraft:wind_charge",
K::Wither => "minecraft:wither",
@@ -5319,6 +5595,55 @@ impl Game<'_> {
mod tests {
use super::super::perspective::CameraType;
use super::thrown_item_axes;
use super::thrown_item_quad;
use super::{ProjectileKind, is_item_billboard};
/// The component-wise lowest and highest corner of the quad.
fn bounds(corners: &[glam::Vec3; 4]) -> (glam::Vec3, glam::Vec3) {
(
corners.iter().copied().reduce(glam::Vec3::min).unwrap(),
corners.iter().copied().reduce(glam::Vec3::max).unwrap(),
)
}
#[test]
fn thrown_item_billboard_lifts_by_the_generated_ground_transform() {
// The kinds vanilla draws with `ThrownItemRenderer`: the only lifted ones.
assert!(is_item_billboard(&ProjectileKind::Snowball));
assert!(is_item_billboard(&ProjectileKind::Egg));
assert!(is_item_billboard(&ProjectileKind::EnderPearl));
assert!(is_item_billboard(&ProjectileKind::SmallFireball));
let large = ProjectileKind::LargeFireball { power: 1 };
assert!(is_item_billboard(&large));
// A projectile at y = 100 with right = +X, up = +Y: the sprite sits
// 0.125 blocks above the entity (2 of `item/generated`'s 16 units) and
// stays 0.5 blocks wide, so the quad's y runs 99.875 .. 100.375.
let at = glam::Vec3::Y * 100.0;
let (right, up) = (glam::Vec3::X, glam::Vec3::Y);
let lifted = is_item_billboard(&ProjectileKind::Snowball);
let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 1.0, lifted));
assert_eq!((lo.y, hi.y), (99.875, 100.375));
assert_eq!((lo.x, hi.x), (-0.25, 0.25));
// A small fireball's 0.75 scale: the same quad scaled down, lifted by
// 0.09375, half extent 0.1875.
let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 0.75, lifted));
assert_eq!((lo.y, hi.y), (99.90625, 100.28125));
// A large fireball's 3.0: lift 0.375, half extent 0.75.
let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 3.0, lifted));
assert_eq!((lo.y, hi.y), (99.625, 101.125));
// Llama spit shares the quad here but is a model in vanilla
// (`LlamaSpitRenderer`): no lift at all.
let lifted = is_item_billboard(&ProjectileKind::LlamaSpit);
assert!(!lifted);
let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 1.0, lifted));
assert_eq!((lo.y, hi.y), (99.75, 100.25));
// The rise follows the billboard's own up axis, not world up.
let (right, up) = (glam::Vec3::Z, glam::Vec3::X);
let lifted = is_item_billboard(&ProjectileKind::Egg);
let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 1.0, lifted));
assert_eq!((lo.x, hi.x), (-0.125, 0.375));
assert_eq!((lo.y, hi.y), (100.0, 100.0));
}
#[test]
fn thrown_item_billboard_does_not_flip_at_straight_up_or_down() {
@@ -5349,6 +5674,13 @@ mod tests {
}
}
/// Whether the killing blow's causing entity (`DamageSource.getEntity`) was a
/// mob, such as a tame wolf whose owner gets the kill credit.
fn killed_by_mob(m: &Mob) -> bool {
m.last_damage
.is_some_and(|(d, _)| matches!(d.attacker, Some(Who::Mob(_))))
}
/// `Animal.finalizeSpawnChildFromBreeding`'s player: the one who fed the
/// parent, or else the one who fed the partner (`getLoveCause`). `Some(None)`
/// is the host, `Some(Some(conn))` a guest. Both animals are spent.
@@ -5379,3 +5711,128 @@ mod breeding_tests {
assert_eq!(breeding_credit(&mut love, 1, 2), None);
}
}
#[cfg(test)]
mod pearl_tests {
use super::*;
/// A pearl's water test is `Entity.isInWater`: `wasTouchingWater` from the
/// last `updateFluidInteraction`, water whose surface reaches its 0.25
/// box (#3072), not the block at its feet.
#[test]
fn a_pearl_feels_the_water_its_box_touches() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("entity-types.json").exists()
|| !root.join("movement-properties.nbt").exists()
{
return;
}
let assets = horizon_client::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let registry = assets.registry;
let facts = horizon_client::blocks::BlockFacts::load(&root, registry).unwrap();
let motion = horizon_client::motion::MotionResources::load(&root, &assets).unwrap();
let data = horizon_client::mobs::MobData::load(&root, &facts);
let biomes = horizon_client::biome::Biomes::load(&root).unwrap();
let items = horizon_client::items::Items::load(
&root,
registry,
&facts,
&horizon_world::loot::PackOverlay::NONE,
)
.unwrap();
let collision = horizon_client::collisions::load(&root, registry).unwrap();
let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap();
// A pool of still water one block deep at y = 4, from x = 20 on (clear of the
// scene's pyramid): its
// surface is at 4 + 8/9.
let water = registry.default_state("minecraft:water").unwrap();
for x in 20..=40 {
for z in 0..=2 {
world.set(x, 4, z, water).unwrap();
}
}
let lid_openers = std::collections::HashMap::new();
let w = MobWorld {
teams: Default::default(),
world: &world,
registry,
facts: &facts,
fluid_tables: &motion.fluids,
movement: &motion.blocks,
collision: &collision,
data: &data,
player: None,
nearest_player: None,
others: &[],
others_grid: None,
others_index: None,
daylight: true,
monsters_burn: false,
game_time: 1000,
day_time: 1000,
raining: false,
thundering: false,
nether: false,
block_tags: &items.block_tags,
biomes: &biomes,
mob_griefing: true,
mob_drops: true,
max_entity_cramming: 24,
difficulty: spawning::LocalDifficulty::new(2, 1000, 0, 0.0),
end: false,
world_seed: 0,
sea_level: 63,
items: Some(&items),
lid_openers: &lid_openers,
};
let pw = ProjWorld {
w: &w,
targets: Vec::new(),
};
let pearl = |x: f64, y: f64, velocity: Vec3| {
let mut p = Projectile::new(ProjectileKind::EnderPearl, Vec3::new(x, y, 1.5), None);
p.velocity = velocity;
p
};
// Skimming the pool: its feet are in the water block, its box above
// the surface. ThrowableProjectile.applyInertia keeps the air drag.
let mut p = pearl(25.5, 4.95, Vec3::new(0.3, 0.0, 0.0));
p.tick(&pw);
assert!(p.position.y > 4.9, "the pearl dipped: {p:?}");
p.tick(&pw);
assert!(
(p.velocity.x - 0.3 * 0.99 * 0.99).abs() < 1e-9,
"a pearl above the surface slowed as in water: {p:?}"
);
// At the pool's edge: its feet over the dry block at x = 19, a side
// of its box in the water at x = 20. The water drag after the first
// tick, which still reads `wasTouchingWater` as false.
let mut p = pearl(19.95, 4.3, Vec3::ZERO);
p.tick(&pw);
assert!(
(p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9,
"the first tick has the air drag: {p:?}"
);
p.tick(&pw);
assert!(
(p.velocity.y - (-0.03 * 0.99 - 0.03) * 0.8).abs() < 1e-9,
"a pearl whose box is in the water kept the air drag: {p:?}"
);
// Thrown under water, a pearl moves with the air drag on its first
// tick and the water drag after it.
let mut p = pearl(30.5, 4.2, Vec3::ZERO);
p.tick(&pw);
assert!(
(p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9,
"the first tick under water has the air drag: {p:?}"
);
p.tick(&pw);
assert!(
(p.velocity.y - (-0.03 * 0.99 - 0.03) * 0.8).abs() < 1e-9,
"the second tick under water has the water drag: {p:?}"
);
}
}
+198 -30
View File
@@ -14,6 +14,8 @@ use horizon_client::{
text::Text,
};
use horizon_core::geometry::{Axis, Vec3};
use horizon_protocol::packets::play::{BlockEntityTagQuery, EntityTagQuery, Serverbound};
use horizon_world::nbt::Tag;
use winit::keyboard::KeyCode;
/// `ChatFormatting.YELLOW` and `RED`.
@@ -217,7 +219,7 @@ impl Game<'_> {
self.debug_charts.log_packets([sent, received]);
}
if shown {
use horizon_protocol::packets::{play::Serverbound, status::Ping};
use horizon_protocol::packets::status::Ping;
let time = self.debug_millis();
self.send(Serverbound::PingRequest(Ping { time }));
}
@@ -682,7 +684,7 @@ impl Game<'_> {
/// `GameModeCommand.setGameMode` with its feedback.
pub(super) fn change_game_mode(&mut self, mode: GameMode) {
if self.remote.is_some() {
use horizon_protocol::packets::play::{ChangeGameMode, Serverbound};
use horizon_protocol::packets::play::ChangeGameMode;
let mode = match mode {
GameMode::Survival => 0,
GameMode::Creative => 1,
@@ -698,10 +700,11 @@ impl Game<'_> {
}
}
/// `KeyboardHandler.copyRecreateCommand`: the targeted block or mob as a
/// `/setblock` or `/summon` command. In a single-player world the
/// server's data (no Shift) is the game's own.
/// `/setblock` or `/summon` command. The server's data (no Shift) comes
/// from the server the game is connected to, the integrated one included
/// (`DebugQueryHandler`); without one, the game's own data is the server's.
fn copy_recreate_command(&mut self, add_nbt: bool, from_server: bool) {
use horizon_client::java_text::format_fixed2 as f2;
let query = add_nbt && from_server && self.remote.is_some();
let side = if add_nbt && from_server {
"server"
} else {
@@ -710,45 +713,135 @@ impl Game<'_> {
if self.entity_targeted()
&& let Some((index, _)) = self.mob_hit()
{
let (id, pos, mob) = {
let (kind, position, id) = {
let m = &self.mobs[index];
(m.kind.id.clone(), m.position, m.id)
};
let mut command = format!("/summon {id} {} {} {}", f2(pos.x), f2(pos.y), f2(pos.z));
if add_nbt && let Some(mut tag) = self.entity_nbt(super::commands::Target::Mob(mob)) {
if let horizon_world::nbt::Tag::Compound(c) = &mut tag {
for key in ["id", "UUID", "Pos"] {
c.remove(&key.into());
}
if query {
// DebugQueryHandler.queryEntityTag: the answer copies it.
let reply = QueryReply::Entity { id, kind, position };
if let Some(transaction) = self.remote.as_mut().map(|r| r.debug_query.start(reply))
{
self.send(Serverbound::EntityTagQuery(EntityTagQuery {
transaction,
entity: id as i32,
}));
}
command.push(' ');
command.push_str(&horizon_client::snbt::pretty(&tag).plain());
return;
}
self.set_clipboard(&command);
// Entity.saveWithoutId.
let tag = if add_nbt {
self.entity_nbt(super::commands::Target::Mob(id))
.map(|mut tag| {
if let Tag::Compound(c) = &mut tag {
c.remove(&"id".into());
}
tag
})
} else {
None
};
self.copy_create_entity_command(&kind, position, tag);
self.debug_translated(&format!("debug.inspect.{side}.entity"));
return;
}
let Some(hit) = self.pick() else {
return;
};
let p = hit.pos;
let mut description = self.state_string(self.world.block(p[0], p[1], p[2]));
if add_nbt && let Some(e) = self.world.block_entities.get(&(p[0], p[1], p[2])) {
// BlockEntity.saveWithoutMetadata: no id or position.
let name = self.data_block_name(p);
let mut tag = horizon_client::save::encode_block_entity((p[0], p[1], p[2]), e, &name);
if let horizon_world::nbt::Tag::Compound(c) = &mut tag {
for key in ["id", "x", "y", "z", "keepPacked"] {
c.remove(&key.into());
}
let pos = hit.pos;
let state = self.state_string(self.world.block(pos[0], pos[1], pos[2]));
if query {
// DebugQueryHandler.queryBlockEntityTag: the answer copies it.
let reply = QueryReply::Block { pos, state };
if let Some(transaction) = self.remote.as_mut().map(|r| r.debug_query.start(reply)) {
self.send(Serverbound::BlockEntityTagQuery(BlockEntityTagQuery {
transaction,
pos,
}));
}
description.push_str(&horizon_client::snbt::to_snbt(&tag));
return;
}
let tag = if add_nbt {
self.block_entity_tag(pos)
} else {
None
};
self.copy_create_block_command(pos, &state, tag.as_ref());
self.debug_translated(&format!("debug.inspect.{side}.block"));
}
/// `BlockEntity.saveWithoutMetadata` of the block entity at `pos`, if
/// there is one: its data without the id or the position.
pub(super) fn block_entity_tag(&self, pos: [i32; 3]) -> Option<Tag> {
let e = self.world.block_entities.get(&(pos[0], pos[1], pos[2]))?;
let name = self.data_block_name(pos);
let mut tag = horizon_client::save::encode_block_entity((pos[0], pos[1], pos[2]), e, &name);
if let Tag::Compound(c) = &mut tag {
for key in ["id", "x", "y", "z", "keepPacked"] {
c.remove(&key.into());
}
}
Some(tag)
}
/// `ClientPacketListener.handleTagQueryPacket`: the server's answer to
/// the last debug query (`DebugQueryHandler.handleResponse`) completes
/// it; an answer to an older query is ignored.
pub(super) fn answer_tag_query(&mut self, transaction: i32, tag: Tag) {
let Some(reply) = self
.remote
.as_mut()
.and_then(|r| r.debug_query.answer(transaction))
else {
return;
};
// FriendlyByteBuf.readNbt: an End tag is no compound.
let tag = match tag {
Tag::End => None,
tag => Some(tag),
};
match reply {
QueryReply::Block { pos, state } => {
self.copy_create_block_command(pos, &state, tag.as_ref());
self.debug_translated("debug.inspect.server.block");
}
QueryReply::Entity { id, kind, position } => {
// Entity.position() when the answer comes: where it is now,
// else where it was last.
let position = self
.mobs
.iter()
.find(|m| m.id == id)
.map_or(position, |m| m.position);
self.copy_create_entity_command(&kind, position, tag);
self.debug_translated("debug.inspect.server.entity");
}
}
}
/// `KeyboardHandler.copyCreateBlockCommand`.
fn copy_create_block_command(&mut self, pos: [i32; 3], state: &str, tag: Option<&Tag>) {
let mut description = state.to_owned();
if let Some(tag) = tag {
description.push_str(&horizon_client::snbt::to_snbt(tag));
}
self.set_clipboard(&format!(
"/setblock {} {} {} {description}",
p[0], p[1], p[2]
pos[0], pos[1], pos[2]
));
self.debug_translated(&format!("debug.inspect.{side}.block"));
}
/// `KeyboardHandler.copyCreateEntityCommand`: the data without its UUID
/// or position.
fn copy_create_entity_command(&mut self, kind: &str, pos: Vec3, tag: Option<Tag>) {
use horizon_client::java_text::format_fixed2 as f2;
let mut command = format!("/summon {kind} {} {} {}", f2(pos.x), f2(pos.y), f2(pos.z));
if let Some(mut tag) = tag {
if let Tag::Compound(c) = &mut tag {
for key in ["UUID", "Pos"] {
c.remove(&key.into());
}
}
command.push(' ');
command.push_str(&horizon_client::snbt::pretty(&tag).plain());
}
self.set_clipboard(&command);
}
/// `KeyboardHandler.setClipboard`.
fn set_clipboard(&mut self, text: &str) {
@@ -766,8 +859,8 @@ impl Game<'_> {
p.position.y.floor() as i32,
p.position.z.floor() as i32,
];
let min_y = horizon_client::chunk::MIN_Y;
g.chunk_borders(block, min_y, min_y + horizon_client::chunk::HEIGHT - 1);
let min_y = self.world.bottom_y();
g.chunk_borders(block, min_y, min_y + self.world.layout().height() - 1);
}
if !self.show_hitboxes {
return g;
@@ -1010,3 +1103,78 @@ fn step_view(step: [i32; 3]) -> (f32, f32) {
(-x.atan2(z)).to_degrees() as f32,
)
}
/// `DebugQueryHandler`: the last debug NBT query (F3+I) sent to the server
/// and what its answer completes. Only the newest query is answered.
#[derive(Debug)]
pub(super) struct DebugQuery {
transaction: i32,
pending: Option<QueryReply>,
}
impl Default for DebugQuery {
fn default() -> Self {
Self {
transaction: -1,
pending: None,
}
}
}
/// What a debug query's answer is copied into: the block command
/// (`copyCreateBlockCommand`) or the entity's (`copyCreateEntityCommand`).
#[derive(Clone, Debug, PartialEq)]
pub(super) enum QueryReply {
Block {
pos: [i32; 3],
state: String,
},
Entity {
id: u32,
kind: String,
position: Vec3,
},
}
impl DebugQuery {
/// `DebugQueryHandler.startTransaction`: the next transaction id, which
/// replaces any query still waiting.
fn start(&mut self, reply: QueryReply) -> i32 {
self.pending = Some(reply);
self.transaction = self.transaction.wrapping_add(1);
self.transaction
}
/// `DebugQueryHandler.handleResponse`: what the answer to `transaction`
/// completes, once, when it answers the newest query.
fn answer(&mut self, transaction: i32) -> Option<QueryReply> {
if transaction == self.transaction {
self.pending.take()
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn block(x: i32) -> QueryReply {
QueryReply::Block {
pos: [x, 64, 0],
state: "minecraft:chest[facing=north,type=single,waterlogged=false]".into(),
}
}
#[test]
fn a_debug_query_is_answered_once_and_only_the_newest() {
let mut q = DebugQuery::default();
// The first transaction is 0 (the handler starts at -1).
assert_eq!(q.start(block(1)), 0);
assert_eq!(q.answer(0), Some(block(1)));
assert_eq!(q.answer(0), None);
// A second press before the first answer: the older answer is ignored
// and the newer one copies the newer target.
assert_eq!(q.start(block(2)), 1);
assert_eq!(q.start(block(3)), 2);
assert_eq!(q.answer(1), None);
assert_eq!(q.answer(2), Some(block(3)));
}
}
+8 -3
View File
@@ -733,6 +733,7 @@ impl Game<'_> {
resource_pack: props.resource_pack.clone(),
// DedicatedServer.serverLinks: `bug-report-link` as the only link
// (ServerLinks.KnownLinkType.BUG_REPORT is id 0).
levels: self.level_shapes,
server_links: if props.bug_report_link.is_empty() {
Vec::new()
} else if super::server_links::valid_untrusted_uri(&props.bug_report_link) {
@@ -756,6 +757,7 @@ impl Game<'_> {
view_distance: props.view_distance.clamp(2, 32),
simulation_distance: props.simulation_distance.clamp(2, 32),
dimension: self.dim.id().to_owned(),
levels: self.level_shapes,
seed: self.level.seed,
max_players: props.max_players.max(0) as usize,
dedicated: true,
@@ -1501,10 +1503,13 @@ impl Game<'_> {
self.native_lifecycle.begin_close();
log("INFO", "Stopping server");
log("INFO", "Saving players");
if let Some(h) = self.lan.as_mut() {
for g in &h.hub.guests {
horizon_client::lan::playerdata::save(g, &self.world_root);
if let Some(host) = self.lan.take() {
for g in &host.hub.guests {
// The vehicle the guest rides, for the file that names it.
let vehicle = self.root_vehicle_uuid(g.vehicle);
horizon_client::lan::playerdata::save(g, &self.world_root, vehicle);
}
self.lan = Some(host);
}
if let Some(h) = self.lan.as_ref() {
for g in &h.hub.guests {
+73
View File
@@ -25,6 +25,26 @@ pub(super) fn redirectable(p: &Projectile) -> bool {
)
}
/// `Projectile.onRedirectProjectile`: another projectile hit `p`, which turns
/// with `deflect(AIM_DEFLECT, hitter's owner, hitter's owner, true, 1.0)`.
/// It flies along the owner's look (`Entity.getLookAngle`; unchanged when the
/// owner is gone), is that owner's now, and its acceleration power is back
/// to 0.1 (`AbstractHurtingProjectile.onDeflection` by an attack).
pub(super) fn aim_deflect(
p: &mut Projectile,
look: Option<Vec3>,
owner: Option<Who>,
guest: Option<u32>,
) {
if let Some(look) = look {
p.velocity = look;
p.needs_sync = true;
}
p.owner = owner;
p.guest = guest;
p.acceleration = 0.1;
}
/// Its box for the crosshair: the entity's (a fireball 1 x 1, a wind charge
/// 0.3125, from its feet up) grown by the pick radius.
fn pick_box(p: &Projectile) -> Aabb {
@@ -106,6 +126,27 @@ impl Game<'_> {
d2 < range * range
}
/// `Entity.getLookAngle` of a projectile's owner: the host, the guest who
/// threw it, or a mob; none when the owner is not here.
pub(super) fn owner_look(&self, owner: Option<Who>, guest: Option<u32>) -> Option<Vec3> {
let (pitch, yaw) = match (owner?, guest) {
(Who::Player, Some(conn)) => {
let lan = self.lan.as_ref()?;
let g = lan.hub.guests.iter().find(|g| g.conn == conn)?;
(g.pitch, g.yaw)
}
(Who::Player, None) => {
self.player.as_ref()?;
(self.pitch, self.yaw)
}
(Who::Mob(id), _) => {
let m = self.mobs.iter().find(|m| m.id == id)?;
(m.head_pitch, m.yaw)
}
};
Some(horizon_world::view_direction::view_vector(pitch, yaw))
}
/// `Projectile.deflect(AIM_DEFLECT, player, player, true)`.
pub(super) fn deflect(&mut self, i: usize) {
let dir = view_vector(self.yaw, self.pitch);
@@ -156,4 +197,36 @@ mod tests {
assert_eq!(b.min_position().x, -1.5);
assert_eq!(b.max_position().y, 66.0);
}
fn wind_charge() -> Projectile {
let mut p = Projectile::new(
ProjectileKind::WindCharge,
Vec3::new(0.0, 70.0, 0.0),
Some(Who::Mob(7)),
);
p.velocity = Vec3::new(0.0, 1.5, 0.0);
p
}
#[test]
fn a_redirected_wind_charge_flies_along_the_new_owners_look() {
let mut p = wind_charge();
let look = Vec3::new(0.0, -1.0, 0.0);
aim_deflect(&mut p, Some(look), Some(Who::Player), Some(3));
assert_eq!(p.velocity, look);
assert_eq!(p.owner, Some(Who::Player));
assert_eq!(p.guest, Some(3));
assert_eq!(p.acceleration, 0.1);
assert!(p.needs_sync);
}
#[test]
fn with_no_owner_here_it_keeps_its_movement_but_changes_hands() {
let mut p = wind_charge();
aim_deflect(&mut p, None, None, None);
assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0));
assert_eq!(p.owner, None);
assert_eq!(p.acceleration, 0.1);
assert!(!p.needs_sync);
}
}
@@ -186,6 +186,8 @@ impl Game<'_> {
Some("end") => self.dim = super::Dim::End,
_ => {}
}
// The level's build range follows its dimension, as `enter_dimension` sets it.
self.world.shape = self.dim.shape();
// The oracle level's clock stands at day time 6000.
self.world.day_time = 6000;
if let Some(rules) = case["rules"].as_object() {
+17 -5
View File
@@ -908,6 +908,12 @@ impl Game<'_> {
let Some(id) = self.spawn_mob_at(&kind, front, facing != Direction::Up) else {
return Ok((Some(stack), CLICK));
};
// Entity.applyImplicitComponents: a name from an anvil names the mob.
if let Some(name) = stack.custom_name()
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
m.custom_name = Some(name.to_owned());
}
self.mob_ambient(id)?;
// SpawnEggItemBehavior: the entity was placed by the dispenser.
self.post_block_game_event("minecraft:entity_place", [pos.0, pos.1, pos.2], None, None);
@@ -1051,8 +1057,12 @@ impl Game<'_> {
// EntityType.create rolls a yaw from the level random before the stack config turns it.
let _ = self.world.random.next_float();
self.next_entity += 1;
let stand =
let mut stand =
horizon_client::entities::ArmorStand::new(self.next_entity, foot, y_rot(facing));
// Entity.applyImplicitComponents: a name from an anvil names the stand.
if let Some(name) = stack.custom_name() {
stand.custom_name = Some(horizon_client::text::Text::literal(name));
}
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
self.armor_stands.push(stand);
(shrink(stack), CLICK)
@@ -1176,8 +1186,9 @@ impl Game<'_> {
let (content, sound, mob) =
horizon_client::items::bucket_contents(&item).expect("filled bucket dispatcher");
let target_pos = Self::pos_of(target);
let emptied = self
.with_level(|b, level, ticks| b.bucket_empty(level, ticks, target_pos, content, sound));
let emptied = self.with_level(|b, level, ticks| {
b.bucket_empty(level, ticks, target_pos, content, sound, false, false)
});
self.process_level_events()?;
if !emptied {
return Ok((self.dispense_default(pos, facing, stack), CLICK));
@@ -1235,7 +1246,8 @@ impl Game<'_> {
stack: ItemStack,
) -> Result<Dispensed> {
let target = Self::pos_of(offset(pos, facing));
let picked = self.with_level(|b, level, ticks| b.bucket_pickup(level, ticks, target));
let picked =
self.with_level(|b, level, ticks| b.bucket_pickup(level, ticks, target, false));
self.process_level_events()?;
match picked {
Some(filled) => {
@@ -1412,7 +1424,7 @@ impl Game<'_> {
) -> Result<Dispensed> {
let target = offset(pos, facing);
let free = self.res.facts().is_air(self.block_at(target))
&& target.1 >= horizon_client::chunk::MIN_Y + 2
&& target.1 >= self.world.bottom_y() + 2
&& self.ui.difficulty != 0
&& self.can_build_wither(target);
if free {
+1
View File
@@ -2887,6 +2887,7 @@ impl Game<'_> {
precise: false,
needs_sync: false,
leash: None,
spectator: false,
})
}
}
@@ -25,6 +25,12 @@ enum MendSlot {
Armor(usize),
}
/// `Player.getDestroySpeed` multiplies by `BLOCK_BREAK_SPEED`; a player
/// without the attribute keeps the vanilla default of 1.0.
fn block_break_speed_factor(value: Option<f64>) -> f32 {
value.map_or(1.0, |value| value as f32)
}
impl Game<'_> {
/// `Player.getDestroySpeed` after the tool speed: mining efficiency, haste
/// and mining fatigue.
@@ -39,6 +45,15 @@ impl Game<'_> {
}
speed * self.potions.effects.mining_speed_factor()
}
/// `Player.getDestroySpeed`'s final `Attributes.BLOCK_BREAK_SPEED` factor:
/// 1.0 by default, so a missing player or attribute changes nothing.
pub(super) fn block_break_speed(&mut self) -> f32 {
block_break_speed_factor(
self.player
.as_mut()
.and_then(|p| p.attribute("minecraft:block_break_speed").ok()),
)
}
/// `Attributes.SUBMERGED_MINING_SPEED` (0.2, raised by aqua affinity).
pub(super) fn submerged_mining_speed(&self) -> f32 {
let mut total = 0.0;
@@ -1012,3 +1027,19 @@ fn location_requirements(req: &Value, on_ground: bool, riding: bool) -> bool {
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::block_break_speed_factor;
/// #4094: `BLOCK_BREAK_SPEED` is a multiplier on the destroy speed.
#[test]
fn block_break_speed_is_a_destroy_speed_multiplier() {
assert_eq!(block_break_speed_factor(None), 1.0);
assert_eq!(block_break_speed_factor(Some(1.0)), 1.0);
assert_eq!(block_break_speed_factor(Some(0.5)), 0.5);
assert_eq!(block_break_speed_factor(Some(2.0)), 2.0);
assert_eq!(8.0 * block_break_speed_factor(Some(0.5)), 4.0);
assert_eq!(8.0 * block_break_speed_factor(Some(2.0)), 16.0);
}
}
+157 -50
View File
@@ -10,8 +10,10 @@ use horizon_client::{
environment::Environment,
items::ItemStack,
mesher::Vertex,
mobs::Who,
motion::PlayerMotion,
particles::{ParticleOptions, ParticleType},
projectiles::{Projectile, ProjectileKind},
vanillagen::VanillaTerrain,
world_spawn::PlayerSpawnFinder,
};
@@ -29,6 +31,9 @@ fn doubles(values: [f64; 3]) -> Tag {
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
}
/// `TicketType.PORTAL`'s timeout, in ticks.
const PORTAL_TICKET: u32 = 300;
/// ServerLevel.END_SPAWN_POINT.
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
/// EnderDragonFight gateway ring radius and height.
@@ -47,18 +52,27 @@ pub(super) enum GatewayJob {
Known { exit: [i32; 3], exact: bool },
}
/// An entity travelling through an End gateway: its saved compound and the
/// job that finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`).
/// An entity travelling through an End gateway: the entity and the job that
/// finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`).
#[derive(Clone, Debug)]
struct GatewayArrival {
/// The entity's compound, as it waits for the exit's chunks.
tag: Tag,
/// The entity, as it waits for the exit's chunks.
traveller: Arriving,
/// The gateway's exit, or the search for one.
job: GatewayJob,
/// The spot to load before the job is resolved.
target: Vec3,
}
/// What waits at a gateway's exit.
#[derive(Clone, Debug)]
enum Arriving {
/// An item or a mob, as its saved compound.
Saved(Tag),
/// A thrown ender pearl (projectiles are not saved).
Pearl(Box<Projectile>),
}
impl GatewayJob {
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
fn for_gateway(
@@ -120,6 +134,10 @@ pub(super) struct EndState {
arrivals: Vec<GatewayArrival>,
/// The chunks kept loaded for those arrivals.
areas: Vec<(i32, i32)>,
/// `Entity.placePortalTicket` of the pearls that came out of a gateway:
/// the chunk each landed in, kept loaded for the ticks left of its
/// `TicketType.PORTAL`, so that it can come down and take its owner there.
tickets: Vec<((i32, i32), u32)>,
/// The Overworld generator, for stronghold positions (ServerLevel.findNearestMapStructure).
locator: std::sync::Mutex<Option<Arc<VanillaTerrain>>>,
/// Thrown eyes of ender in flight.
@@ -1462,13 +1480,17 @@ impl Game<'_> {
// ---- End gateway travel for the entities ----
/// `EndGatewayBlock.entityInside` for the items and mobs in the End: the
/// gateway blocks their box swept through. A gateway that is cooling down
/// (`TheEndGatewayBlockEntity.triggerCooldown`) takes nobody in.
/// `EndGatewayBlock.entityInside` for the items, mobs and thrown ender
/// pearls in the End: the gateway blocks their box swept through. A
/// gateway that is cooling down (`TheEndGatewayBlockEntity.triggerCooldown`)
/// takes nobody in.
pub(super) fn find_end_gateway_portals(&mut self) {
if self.remote.is_some() || self.dim != Dim::End {
return;
}
let is_gateway = |game: &Self, p: BlockPos| {
&*game.res.facts().get(game.world.block(p.x, p.y, p.z)).name == "minecraft:end_gateway"
};
let items: Vec<(u32, Vec3, f32, f32)> = self
.dropped
.iter()
@@ -1484,13 +1506,28 @@ impl Game<'_> {
for (id, to, width, height) in items.into_iter().chain(mobs) {
let from = self.portals.before.get(&id).copied().unwrap_or(to);
let found = horizon_client::end_gateway::swept_gateway(from, to, width, height, |p| {
&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name
== "minecraft:end_gateway"
is_gateway(self, p)
});
if let Some(pos) = found {
inside.push((id, pos));
}
}
// A pearl's move this tick, from where it started it.
let pearls: Vec<(u32, Option<Who>, Option<u32>, BlockPos)> = self
.creatures
.projectiles
.iter()
.filter_map(|p| {
let pos = horizon_client::end_gateway::pearl_gateway(p, |b| is_gateway(self, b))?;
Some((p.id, p.owner, p.guest, pos))
})
.collect();
for (id, owner, guest, pos) in pearls {
if owner == Some(Who::Player) {
self.pearl_owner_in_gateway(guest, pos);
}
inside.push((id, pos));
}
for (id, pos) in inside {
// One entity at a time: the gateway cools down for 40 ticks.
if !self.gateway_enter([pos.x, pos.y, pos.z]) {
@@ -1504,6 +1541,22 @@ impl Game<'_> {
}
}
/// `ThrownEnderpearl.onInsideBlock` in an End gateway: its owner's
/// `ServerPlayer.onInsideBlock`, the enter_block trigger, as if they had
/// walked in. A guest's pearl triggers theirs; one whose guest has left
/// has no owner.
fn pearl_owner_in_gateway(&mut self, guest: Option<u32>, pos: BlockPos) {
let state = self.world.block(pos.x, pos.y, pos.z);
let Some(conn) = guest else {
self.trigger_enter_block(state);
return;
};
if let Some(mut host) = self.lan.take() {
self.as_guest(&mut host, conn, |g| g.trigger_enter_block(state));
self.lan = Some(host);
}
}
/// `Entity.handlePortal` for the entities in an End gateway: they go
/// through to the gateway's exit, where they wait for its chunks.
pub(super) fn handle_end_gateway_portals(&mut self) {
@@ -1515,8 +1568,20 @@ impl Game<'_> {
.iter()
.map(|e| (e.id, true))
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
.chain(
self.creatures
.projectiles
.iter()
.filter(|p| matches!(p.kind, ProjectileKind::EnderPearl))
.map(|p| (p.id, true)),
)
.collect();
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
let mut present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
// A pearl waiting for its exit keeps its portal cooldown.
present.extend(self.end.arrivals.iter().filter_map(|a| match &a.traveller {
Arriving::Pearl(p) => Some(p.id),
Arriving::Saved(_) => None,
}));
self.end.gateways.retain(|id, _| present.contains(id));
let mut leaving: Vec<(u32, BlockPos)> = Vec::new();
for (id, allowed) in ids {
@@ -1573,11 +1638,28 @@ impl Game<'_> {
self.forget_entity(g);
}
tag
} else if let Some(i) = self.creatures.projectiles.iter().position(|p| p.id == id) {
let pearl = Box::new(self.creatures.projectiles.remove(i));
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
let arrival = GatewayArrival {
traveller: Arriving::Pearl(pearl),
job,
target,
};
// It is at the exit before it moves this tick, once that has loaded.
if let Some(waiting) = self.arrive_through_gateway(arrival) {
self.end.arrivals.push(waiting);
}
return;
} else {
return;
};
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
self.end.arrivals.push(GatewayArrival { tag, job, target });
self.end.arrivals.push(GatewayArrival {
traveller: Arriving::Saved(tag),
job,
target,
});
}
/// The entities that reached a gateway's exit: they land where the
@@ -1586,47 +1668,60 @@ impl Game<'_> {
if self.remote.is_some() || self.dim != Dim::End {
return Ok(());
}
let mut i = 0;
while i < self.end.arrivals.len() {
let (cx, cz) = (
(self.end.arrivals[i].target.x.floor() as i32) >> 4,
(self.end.arrivals[i].target.z.floor() as i32) >> 4,
);
if !self.world.chunks.contains_key(&(cx, cz)) {
i += 1;
continue;
}
let (job, target) = (self.end.arrivals[i].job, self.end.arrivals[i].target);
let at = match self.resolve_gateway(job, target) {
Ok(at) => at,
Err(e) => {
// The traveller waits for the exit: the failure is retried.
log::warn!("End gateway: {e}");
i += 1;
continue;
}
};
let mut tag = self.end.arrivals.remove(i).tag;
if let Tag::Compound(c) = &mut tag {
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
c.insert("Motion".into(), doubles([0.0, 0.0, 0.0]));
// setPortalCooldown before the teleport.
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
}
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
match self
.world
.loaded_entities
.iter_mut()
.find(|(k, _)| *k == key)
{
Some((_, tags)) => tags.push(tag),
None => self.world.loaded_entities.push((key, vec![tag])),
for arrival in std::mem::take(&mut self.end.arrivals) {
if let Some(waiting) = self.arrive_through_gateway(arrival) {
self.end.arrivals.push(waiting);
}
}
Ok(())
}
/// `Entity.teleport` of a gateway traveller once its exit's chunk has
/// loaded: an item or a mob joins the chunk it lands in, a pearl flies
/// on from there. The traveller comes back while it still waits.
fn arrive_through_gateway(&mut self, arrival: GatewayArrival) -> Option<GatewayArrival> {
let chunk = |at: Vec3| ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
if !self.world.chunks.contains_key(&chunk(arrival.target)) {
return Some(arrival);
}
let at = match self.resolve_gateway(arrival.job, arrival.target) {
Ok(at) => at,
Err(e) => {
// The traveller waits for the exit: the failure is retried.
log::warn!("End gateway: {e}");
return Some(arrival);
}
};
let key = chunk(at);
match arrival.traveller {
Arriving::Saved(mut tag) => {
if let Tag::Compound(c) = &mut tag {
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
c.insert("Motion".into(), doubles([0.0, 0.0, 0.0]));
// setPortalCooldown before the teleport.
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
}
match self
.world
.loaded_entities
.iter_mut()
.find(|(k, _)| *k == key)
{
Some((_, tags)) => tags.push(tag),
None => self.world.loaded_entities.push((key, vec![tag])),
}
}
Arriving::Pearl(mut pearl) => {
horizon_client::end_gateway::place_pearl(&mut pearl, at);
// ThrownEnderpearl.teleport: placePortalTicket where it came out.
self.end.tickets.retain(|(c, _)| *c != key);
self.end.tickets.push((key, PORTAL_TICKET));
self.add_projectile(*pearl);
}
}
None
}
/// The chunks the entities waiting to arrive need, kept loaded like the
/// areas a server keeps around its players.
pub(super) fn keep_gateway_destinations(&mut self) {
@@ -1634,12 +1729,24 @@ impl Game<'_> {
self.streaming
.extra
.retain(|&(c, r)| !(r == 1 && areas.contains(&c)));
// The pearls' portal tickets hold End chunks, and run out there
// (TicketType.PORTAL's timeout).
let in_end = self.dim == Dim::End;
if in_end {
self.end.tickets.retain_mut(|(_, ticks)| {
*ticks = ticks.saturating_sub(1);
*ticks > 0
});
}
let mut areas = Vec::new();
for a in &self.end.arrivals {
let c = (
let arrivals = self.end.arrivals.iter().map(|a| {
(
(a.target.x.floor() as i32) >> 4,
(a.target.z.floor() as i32) >> 4,
);
)
});
let tickets = self.end.tickets.iter().filter(|_| in_end);
for c in arrivals.chain(tickets.map(|&(c, _)| c)) {
if !areas.contains(&c) {
areas.push(c);
self.streaming.extra.push((c, 1));
@@ -1,6 +1,5 @@
//! Entity occlusion affects mesh submission, never entity ticking or interaction.
use super::{Game, Vertex};
use horizon_client::chunk::MIN_Y;
use horizon_client::{
blocks::{RenderShape, StateFacts},
entity_culling::Culler,
@@ -41,7 +40,7 @@ impl Game<'_> {
if !opaque(self.res.facts().get(state)) {
return false;
}
let Some(y) = pos[1].checked_sub(MIN_Y) else {
let Some(y) = pos[1].checked_sub(self.world.bottom_y()) else {
return false;
};
let section = (pos[0] >> 4, y.div_euclid(16), pos[2] >> 4);
@@ -219,7 +219,7 @@ fn armor_equipment_loads_without_an_entity_model_document() {
assert!(assets.layers.is_empty());
assert_eq!(
assets.equipment["minecraft:leather"]["humanoid"],
("entity/equipment/humanoid/leather".into(), Some(0x123456))
vec![("entity/equipment/humanoid/leather".into(), Some(0x123456))]
);
assert!(!assets.equipment.contains_key("minecraft:invalid"));
}
+146 -19
View File
@@ -89,6 +89,29 @@ pub(super) enum BlockInteraction {
DestroyWithDecay,
}
/// `ServerExplosion.hurtEntities`: an entity is hurt when its position is
/// within the blast's diameter of the centre.
pub(super) fn blast_reaches(center: [f64; 3], diameter: f64, at: [f64; 3]) -> bool {
let d =
((at[0] - center[0]).powi(2) + (at[1] - center[1]).powi(2) + (at[2] - center[2]).powi(2))
.sqrt();
d / diameter <= 1.0
}
/// Whether a blast leaves item frames, paintings, cushions and armour stands
/// alone. Without `mobGriefing`, a blast that keeps blocks is ignored
/// (`ServerExplosion.shouldAffectBlocklikeEntities` through their
/// `ignoreExplosion`; no wind charge goes through `server_explode`), and one a
/// mob causes does no harm (`BlockAttachedEntity.hurtServer` and
/// `ArmorStand.hurtServer`: `source.getEntity() instanceof Mob`).
pub(super) fn blast_spares_decorations(
mob_griefing: bool,
interaction: BlockInteraction,
by_mob: bool,
) -> bool {
!mob_griefing && (interaction == BlockInteraction::Keep || by_mob)
}
thread_local! {
/// Client-side randomness (explosion sound pitch), separate from the level random.
static CLIENT_RANDOM: std::cell::RefCell<LegacyRandom> = std::cell::RefCell::new(LegacyRandom::new(0x5eed));
@@ -224,20 +247,24 @@ impl Game<'_> {
}
set.entries().map(|(k, _)| k.0).collect()
}
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT, minecarts);
/// the player takes `damage_type`.
/// `ServerExplosion.hurtEntities` for the player: damage scaled by
/// distance and exposure, through a raised shield (`hurtServer` blocks
/// explosions like any attack from where the blast is), then the push
/// (less with explosion knockback resistance; none for spectators or a
/// creative player flying).
///
/// Returns the push their client adds from the explosion packet
/// (`hitPlayers`), unless a hurt marked their velocity: that goes to them
/// whole, this push in it, once their server copy has travelled
/// (Entity.markHurt, then ServerEntity.sendChanges), and replaces what the
/// packet would add.
pub(super) fn explosion_hurt_player(
&mut self,
center: [f64; 3],
radius: f32,
damage_type: &str,
exploding: Option<u32>,
) {
) -> Option<[f64; 3]> {
let diameter = f64::from(radius * 2.0);
// Blast protection: `EXPLOSION_KNOCKBACK_RESISTANCE` (0.15 per level and
// piece, so two Blast Protection IV pieces cancel it).
@@ -246,9 +273,7 @@ impl Game<'_> {
.as_mut()
.and_then(|p| p.attribute("minecraft:explosion_knockback_resistance").ok())
.unwrap_or(0.0);
let Some(p) = self.player.as_ref() else {
return;
};
let p = self.player.as_ref()?;
let feet = [p.position.x, p.position.y, p.position.z];
let dist = ((feet[0] - center[0]).powi(2)
+ (feet[1] - center[1]).powi(2)
@@ -256,7 +281,7 @@ impl Game<'_> {
.sqrt()
/ diameter;
if dist > 1.0 || p.health.dead_or_dying() {
return;
return None;
}
// ServerPlayer.onExplosionHit: the blast that hit the player is theirs.
self.adv.marks.blast = Some((feet, false));
@@ -291,6 +316,7 @@ impl Game<'_> {
let (creative, spectator) = (self.creative, self.spectator());
let flying = self.player.as_ref().is_some_and(|p| p.flying);
let mut outcome = horizon_client::health::BlockOutcome::default();
let mut pushed = None;
if let Some(p) = self.player.as_mut()
&& let Some(id) = self.motion.health.damage.id(damage_type)
{
@@ -317,27 +343,33 @@ impl Game<'_> {
}
}
if !spectator && !(creative && flying) {
let push = [dir[0] * power, dir[1] * power, dir[2] * power];
p.velocity = Vec3::new(
p.velocity.x + dir[0] * power,
p.velocity.y + dir[1] * power,
p.velocity.z + dir[2] * power,
p.velocity.x + push[0],
p.velocity.y + push[1],
p.velocity.z + push[2],
);
pushed = (!p.health.sync_velocity).then_some(push);
}
}
if raised {
self.after_block(outcome, amount, None, false);
}
pushed
}
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT, minecarts);
/// the player takes `damage_type`. Returns the players whose clients add a
/// push from the explosion packet, and how much (`explosion_hurt_player`).
fn explosion_hurt_entities(
&mut self,
center: [f64; 3],
radius: f32,
damage_type: &str,
interaction: BlockInteraction,
) {
) -> Vec<(u32, [f64; 3])> {
if radius < 1.0e-5 {
return;
return Vec::new();
}
self.explosion_hits_special(center, radius);
let diameter = f64::from(radius * 2.0);
@@ -371,8 +403,10 @@ impl Game<'_> {
})
};
// The player, then every other player in the level (LAN guests),
// each swapped in as the player.
self.explosion_hurt_player(center, radius, damage_type, exploding);
// each swapped in as the player. In a guest's turn (their hit on an
// end crystal) the player is that guest.
let own = self.explosion_hurt_player(center, radius, damage_type, exploding);
let mut pushed: Vec<_> = self.lan_acting.zip(own).into_iter().collect();
if let Some(mut host) = self.lan.take() {
let guests: Vec<u32> = host
.hub
@@ -382,9 +416,12 @@ impl Game<'_> {
.map(|g| g.conn)
.collect();
for conn in guests {
self.guest_hit_by(&mut host, conn, |g| {
g.explosion_hurt_player(center, radius, damage_type, exploding);
let push = self.as_guest(&mut host, conn, |g| {
g.explosion_hurt_player(center, radius, damage_type, exploding)
});
if let Some(Some(push)) = push {
pushed.push((conn, push));
}
}
self.lan = Some(host);
}
@@ -446,7 +483,6 @@ impl Game<'_> {
);
}
self.mobs = mobs;
self.explosion_breaks_frames(center, diameter, interaction);
// Dropped items: 5 health, destroyed by the explosion damage (nether stars resist).
let mut items = std::mem::take(&mut self.dropped);
items.retain_mut(|item| {
@@ -525,6 +561,74 @@ impl Game<'_> {
c.velocity.z + dir[2] * power,
);
}
// Frames, paintings, cushions and armour stands last: vanilla lists
// the entities in reach before hurting any, so the items they drop
// here are neither destroyed nor pushed by the same blast.
self.explosion_breaks_frames(center, diameter, interaction);
self.explosion_breaks_decorations(center, diameter, interaction);
pushed
}
/// `blast_spares_decorations` for the blast going off now: the mob that
/// owns its projectile or explodes itself is the source's entity.
pub(super) fn blast_spares_decorations(&self, interaction: BlockInteraction) -> bool {
let by_mob = self
.combat
.explosion_owner
.or(self.combat.explosion_source)
.is_some();
blast_spares_decorations(self.ui.rules.bool("mob_griefing"), interaction, by_mob)
}
/// `ServerExplosion.hurtEntities` for paintings, cushions and armour
/// stands (item frames: `explosion_breaks_frames`). A painting or cushion
/// in reach is killed and dropped (`BlockAttachedEntity.hurtServer`,
/// `Painting.dropItem`, `Cushion.dropItem`). An armour stand that is
/// neither invisible nor a marker breaks (`ArmorStand.hurtServer` with
/// `IS_EXPLOSION`: `brokenByAnything`, then `kill`): its equipment drops,
/// the stand itself does not.
/// Not ported: `BlockAttachedEntity.ignoreExplosion` also spares paintings
/// and cushions when the exploding entity is in water.
fn explosion_breaks_decorations(
&mut self,
center: [f64; 3],
diameter: f64,
interaction: BlockInteraction,
) {
if self.blast_spares_decorations(interaction) {
return;
}
let mut i = 0;
while i < self.paintings.len() {
// HangingEntity.recalculateBoundingBox: a painting stands at the
// centre of its box.
let (lo, hi) = self.paintings[i].bounds();
let at = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0);
if blast_reaches(center, diameter, at) {
self.break_painting(i, false);
} else {
i += 1;
}
}
let mut i = 0;
while i < self.cushions.len() {
let p = self.cushions[i].position;
if blast_reaches(center, diameter, [p.x, p.y, p.z]) {
self.break_cushion(i, false);
} else {
i += 1;
}
}
let mut i = 0;
while i < self.armor_stands.len() {
let a = &self.armor_stands[i];
let p = a.position;
if !super::armor_stands::spared_by_blast(a)
&& blast_reaches(center, diameter, [p.x, p.y, p.z])
{
self.break_armor_stand_by_anything(i);
} else {
i += 1;
}
}
}
/// `ServerLevel.getDestroyType`: a drop decay rule picks between decaying
/// and plain destruction.
@@ -586,7 +690,7 @@ impl Game<'_> {
let ctx = horizon_client::sculk::Context::default();
self.post_game_event("minecraft:explode", center, ctx);
let mut targets = self.exploded_positions(center, radius, resists);
self.explosion_hurt_entities(center, radius, damage_type, interaction);
let pushed = self.explosion_hurt_entities(center, radius, damage_type, interaction);
if interaction != BlockInteraction::Keep {
// Util.shuffle(list, level.random)
for i in (2..=targets.len()).rev() {
@@ -635,7 +739,7 @@ impl Game<'_> {
.explosion(center, small, block_count, radius, &|b| {
facts.is_air(world.block(b[0], b[1], b[2]))
});
self.lan_explode(center, small, radius, block_count);
self.lan_explode(center, small, radius, block_count, pushed);
Ok(())
}
/// `BlockBehaviour.onExplosionHit` (TNT: `wasExploded` primes a short fuse).
@@ -758,6 +862,29 @@ fn add_or_append_stack(
mod tests {
use super::*;
#[test]
fn blasts_reach_entities_within_their_diameter() {
// A TNT blast: radius 4, so the entity pass reaches 8 blocks out.
let center = [0.5, 64.0, 0.5];
assert!(blast_reaches(center, 8.0, [8.5, 64.0, 0.5]));
assert!(blast_reaches(center, 8.0, [-3.5, 68.0, 4.5]));
assert!(!blast_reaches(center, 8.0, [8.6, 64.0, 0.5]));
}
#[test]
fn mob_griefing_off_spares_decorations_from_kept_and_mob_blasts() {
use BlockInteraction::{Destroy, DestroyWithDecay, Keep};
// mobGriefing on: every blast breaks them, a creeper's included.
assert!(!blast_spares_decorations(true, DestroyWithDecay, true));
assert!(!blast_spares_decorations(true, Keep, true));
// Off: TNT still breaks them (ExplosionInteraction.TNT destroys blocks).
assert!(!blast_spares_decorations(false, Destroy, false));
assert!(!blast_spares_decorations(false, DestroyWithDecay, false));
// Off: a blast that keeps blocks, or one a mob causes, does not.
assert!(blast_spares_decorations(false, Keep, false));
assert!(blast_spares_decorations(false, Destroy, true));
}
#[test]
fn block_pos_hash_matches_vec3i() {
// Vec3i.hashCode: (y + z * 31) * 31 + x
+27 -25
View File
@@ -21,6 +21,12 @@ use horizon_core::{
pub(super) const FIREWORK_EXPLODE_EVENT: i8 = 17;
/// What `ProjectileUtil.getHitResultOnMoveVector` found along a rocket's move.
enum RocketHit {
Entity(Who),
Block,
}
pub(super) struct Rocket {
/// The entity id a server tracks it by (0 until it is tracked).
pub id: u32,
@@ -43,8 +49,7 @@ pub(super) struct Rocket {
/// `DATA_ATTACHED_TO_TARGET`: the server entity the rocket follows;
/// boosting happens when it is the local player.
pub(super) attached_to: Option<u32>,
/// `DATA_SHOT_AT_ANGLE` (dispensers): constant velocity, exploding on
/// the first block it hits.
/// `DATA_SHOT_AT_ANGLE` (dispensers, crossbows): constant velocity.
pub(super) angled: bool,
/// `DATA_ID_FIREWORKS_ITEM`: the stack it was launched from.
pub(super) item: ItemStack,
@@ -118,9 +123,8 @@ impl Rocket {
}
}
/// `FireworkRocketEntity.tick` for a rocket attached to no entity: speed
/// up (an angled one keeps its motion) and rise, then move. True when a
/// block stopped it.
fn free_flight(&mut self, scene: &mut SceneCollisions) -> bool {
/// up (an angled one keeps its motion) and rise, then move.
fn free_flight(&mut self, scene: &mut SceneCollisions) {
let h = 0.125;
let bounds = Aabb::new(
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
@@ -132,11 +136,8 @@ impl Rocket {
);
let wanted = self.next_motion();
let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) else {
return false;
return;
};
let blocked = (moved.x - wanted.x).abs() > 1e-7
|| (moved.y - wanted.y).abs() > 1e-7
|| (moved.z - wanted.z).abs() > 1e-7;
let horizontal = (moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7;
self.position = Vec3::new(
self.position.x + moved.x,
@@ -148,7 +149,6 @@ impl Rocket {
} else {
wanted
};
blocked
}
/// Position and motion (the dispenser parity fixture).
pub(super) fn motion(&self) -> (Vec3, Vec3) {
@@ -334,15 +334,14 @@ impl Game<'_> {
binding: self.collision,
subject: None,
};
if r.free_flight(&mut scene) && r.angled {
// hitTargetOrDeflectSelf: explode on the block.
r.life = r.lifetime;
}
r.free_flight(&mut scene);
}
// hitTargetOrDeflectSelf on an entity: a breeze turns it back
// (ProjectileDeflection.REVERSE), anything else sets it off.
// onHitBlock: only a rocket with explosions (hasExplosion) goes
// off on a block; a plain one flies on to its lifetime.
match hit {
Some(Who::Mob(id))
Some(RocketHit::Entity(Who::Mob(id)))
if self
.mobs
.iter()
@@ -350,8 +349,9 @@ impl Game<'_> {
{
r.velocity = r.velocity.scale(-0.5);
}
Some(_) => r.life = r.lifetime,
None => {}
Some(RocketHit::Entity(_)) => r.life = r.lifetime,
Some(RocketHit::Block) if !r.explosions.is_empty() => r.life = r.lifetime,
Some(RocketHit::Block) | None => {}
}
let at = [r.position.x, r.position.y, r.position.z];
if r.life == 0 {
@@ -510,25 +510,26 @@ impl Game<'_> {
targets.extend(self.lan_projectile_targets());
targets
}
/// `ProjectileUtil.getHitResultOnMoveVector`'s entity half: the nearest
/// target on the move, short of the block it clips, each box grown by
/// `computeMargin` (`tickCount` is the rocket's life plus one).
/// `ProjectileUtil.getHitResultOnMoveVector`: the nearest target on the
/// move, short of the block it clips (`ClipContext.Block.COLLIDER`), each
/// box grown by `computeMargin` (`tickCount` is the rocket's life plus
/// one); else that block.
fn rocket_struck(
&self,
targets: &[(Who, Aabb)],
r: &Rocket,
motion: Vec3,
skip: Option<Who>,
) -> Option<Who> {
) -> Option<RocketHit> {
let from = r.position;
let mut to = from.add_vector(motion);
let w = self.mob_world_base(&[]);
if let Some((at, ..)) = (super::creatures::ProjWorld {
let block = (super::creatures::ProjWorld {
w: &w,
targets: Vec::new(),
})
.clip(from, to)
{
.clip(from, to);
if let Some((at, ..)) = block {
to = at;
}
let margin = (f64::from(r.life - 1) / 20.0).clamp(0.0, 0.3);
@@ -542,7 +543,8 @@ impl Game<'_> {
Some((*who, from.distance_squared(at)))
})
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(who, _)| who)
.map(|(who, _)| RocketHit::Entity(who))
.or(block.map(|_| RocketHit::Block))
}
/// FireworkRocketEntity.handleEntityEvent: use the synchronized item data.
pub(super) fn remote_firework_explosion(&mut self, id: i32) {
+8
View File
@@ -79,6 +79,14 @@ impl Game<'_> {
.fov_player()
.map_or(1.0, |p| field_of_view_modifier(&p, first_person, effect));
let target = self.zoomed_fov_target(target);
// A server's spectator camera on a mob: its eye height. Its modifier
// stays 1 (another player's own modifier is not modelled here); the
// zoom extra still narrows it.
if let Some(m) = self.camera_mob() {
let eye = m.eye_height() as f32;
self.view.tick(Some(eye), self.zoomed_fov_target(1.0));
return;
}
let eye = self.player.as_ref().map(|p| {
if self.sleeping.is_some() {
0.2
+3 -7
View File
@@ -298,9 +298,8 @@ impl Game<'_> {
}
/// `ServerExplosion.hurtEntities` for item frames: a frame within the blast
/// is killed and dropped with its item (`BlockAttachedEntity.hurtServer`).
/// A fixed frame is not hurt by an explosion. Without `mobGriefing` a blast
/// that keeps blocks (`ServerExplosion.shouldAffectBlocklikeEntities` through
/// `BlockAttachedEntity.ignoreExplosion`) and a mob's blast spare frames.
/// A fixed frame is not hurt by an explosion, and `blast_spares_decorations`
/// keeps every frame without `mobGriefing`.
/// Not ported: `ignoreExplosion` also spares frames when the exploding
/// entity is in water.
pub(super) fn explosion_breaks_frames(
@@ -309,10 +308,7 @@ impl Game<'_> {
diameter: f64,
interaction: super::explosion::BlockInteraction,
) {
if !self.ui.rules.bool("mob_griefing")
&& (interaction == super::explosion::BlockInteraction::Keep
|| self.combat.explosion_source.is_some())
{
if self.blast_spares_decorations(interaction) {
return;
}
let mut i = 0;
@@ -73,6 +73,105 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// `ItemStack.canBreakBlockInAdventureMode` / `canPlaceOnBlockInAdventureMode`
/// for the main-hand stack: some predicate of its `can_break` or
/// `can_place_on` component (`AdventureModePredicate`: one block predicate
/// or a list) matches the block at `pos`. Predicates with `nbt` or
/// `components` need the block entity's data and never match.
fn adventure_predicate(&self, component: &str, pos: [i32; 3]) -> bool {
use horizon_world::nbt::Tag;
let Some(tag) = self
.held()
.and_then(|s| s.data())
.and_then(|d| d.other(component))
else {
return false;
};
let state = self.world.block(pos[0], pos[1], pos[2]);
let name = &self.res.facts().get(state).name;
let properties = self
.pack
.assets
.registry
.state(state)
.map(|s| &s.properties);
let matches = |p: &Tag| {
if p.get("nbt").is_some() || p.get("components").is_some() {
return false;
}
let block_ok = match p.get("blocks") {
None => true,
Some(Tag::String(id)) => self.block_in_holder_set(&id.to_string_lossy(), name),
Some(Tag::List(ids)) => ids.iter().any(|id| match id {
Tag::String(id) => self.block_in_holder_set(&id.to_string_lossy(), name),
_ => false,
}),
Some(_) => false,
};
// StatePropertiesPredicate: every property exact, or in a min/max range.
let state_ok = match p.get("state") {
None => true,
Some(Tag::Compound(want)) => want.iter().all(|(key, matcher)| {
let Some(have) = properties.and_then(|p| p.get(&key.to_string_lossy())) else {
return false;
};
match matcher {
Tag::String(value) => *have == value.to_string_lossy(),
Tag::Compound(_) => {
let bound = |k: &str| match matcher.get(k) {
Some(Tag::String(v)) => Some(v.to_string_lossy()),
_ => None,
};
let in_range = |have: &str, v: Option<String>, above: bool| {
v.is_none_or(|v| match (have.parse::<i64>(), v.parse::<i64>()) {
(Ok(h), Ok(b)) if above => h >= b,
(Ok(h), Ok(b)) => h <= b,
_ => false,
})
};
in_range(have, bound("min"), true)
&& in_range(have, bound("max"), false)
}
_ => false,
}
}),
Some(_) => false,
};
block_ok && state_ok
};
match tag {
Tag::List(list) => list.iter().any(matches),
single => matches(single),
}
}
/// A `HolderSet<Block>` entry: a block id or a `#tag`.
fn block_in_holder_set(&self, entry: &str, block: &str) -> bool {
let namespaced = |id: &str| {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
};
match entry.strip_prefix('#') {
Some(tag) => self.res.items().block_in_tag(&namespaced(tag), block),
None => namespaced(entry) == block,
}
}
/// `Player.blockActionRestricted` for the block at `pos`: spectators never
/// act on blocks, adventure players only with a main-hand item whose
/// `can_break` matches it.
pub(super) fn block_action_restricted(&self, pos: [i32; 3]) -> bool {
if self.spectator() {
return true;
}
!self.may_build() && !self.adventure_predicate("minecraft:can_break", pos)
}
/// `ItemStack.useOn` in adventure mode: the used item's `can_place_on`
/// matches the clicked block (`Player.mayUseItemAt`).
pub(super) fn can_place_on_in_adventure(&self, clicked: [i32; 3]) -> bool {
self.adventure_predicate("minecraft:can_place_on", clicked)
}
/// `ServerPlayer.setGameMode(SPECTATOR)`: `stopRiding`.
fn dismount_for_spectator(&mut self) {
let Some(id) = self.riding else {
+118 -16
View File
@@ -58,7 +58,53 @@ fn attack_range_contains(range: &serde_json::Value, distance: f64, creative: boo
distance >= f64::from(min - margin) && distance <= f64::from(max + margin)
}
/// `FirstPersonHandsAndItems.shouldInstantlyReplaceVisibleItem`'s
/// `ItemStack.matchesIgnoringComponents(visible, held,
/// DataComponentType::ignoreSwapAnimation)` for one hand (every item plays
/// the swap animation, `ItemModelResolver.shouldPlaySwapAnimation`): the same
/// stack object, or an equal one, whose damage (the one component built
/// with `ignoreSwapAnimation`) may differ.
///
/// A stack that stays in its slot with the same item is the same object,
/// whatever its count or damage (vanilla shrinks, grows and damages it in
/// place), unless the slot was `replaced` by one the hand doesn't show yet:
/// swapping the hands (`setItemInHand`, and the server's slot updates that
/// answer it) puts another object in both (#2866).
fn shows_held_stack(
visible: &(usize, Option<ItemStack>),
held: &(usize, Option<ItemStack>),
replaced: bool,
) -> bool {
let same_object = visible.0 == held.0 && !replaced;
match (&visible.1, &held.1) {
(Some(a), Some(b)) if same_object => a.item == b.item,
(a, b) => matches_for_swap(a.as_ref(), b.as_ref()),
}
}
/// `ItemStack.matchesIgnoringComponents(a, b,
/// DataComponentType::ignoreSwapAnimation)`: the same count, item and
/// components but damage.
pub(super) fn matches_for_swap(a: Option<&ItemStack>, b: Option<&ItemStack>) -> bool {
match (a, b) {
(None, None) => true,
(Some(a), Some(b)) => a.count == b.count && a.item == b.item && a.extra == b.extra,
_ => false,
}
}
impl Game<'_> {
/// A slot of the inventory (`OFFHAND_SLOT` for the off hand) was set to
/// a new stack: the hand holding it compares it as another `ItemStack`
/// (`shows_held_stack`) until the hand shows the held stack again.
pub(super) fn hand_stack_replaced(&mut self, slot: usize) {
if slot == self.selected {
self.hands_replaced[0] = true;
}
if slot == super::shield::OFFHAND_SLOT {
self.hands_replaced[1] = true;
}
}
/// Opening a screen stops breaking before the next game tick (which may pause).
pub(super) fn stop_mining(&mut self) {
if self.clicks.destroying.active {
@@ -155,7 +201,8 @@ impl Game<'_> {
Ok(())
}
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed`.
pub(super) fn destroy_progress(&self, pos: [i32; 3]) -> f32 {
pub(super) fn destroy_progress(&mut self, pos: [i32; 3]) -> f32 {
let block_break_speed = self.block_break_speed();
let state = self.world.block(pos[0], pos[1], pos[2]);
let facts = self.res.facts().get(state);
if facts.hardness == -1.0 {
@@ -164,6 +211,7 @@ impl Game<'_> {
let held = self.held();
let mut speed = self.items().mining_speed(held, &facts.name);
speed = self.modify_destroy_speed(speed);
speed *= block_break_speed;
if let Some(eye) = self.eye_position()
&& self
.res
@@ -266,22 +314,13 @@ impl Game<'_> {
}
self.attack_item = main.as_ref().map(|s| s.item.clone());
let next = [(self.selected, main), (OFFHAND, self.offhand.clone())];
// shouldInstantlyReplaceVisibleItem: the same stack, or an equal one
// (matchesIgnoringComponents; every item plays the swap animation).
// A stack that stays in its slot with the same item is the same
// stack, whatever its count (vanilla shrinks and grows it in place).
let mut changed = [false; 2];
for h in 0..2 {
let same = match (&self.visible_hands[h], &next[h]) {
((_, None), (_, None)) => true,
((slot, Some(a)), (next_slot, Some(b))) => {
a.item == b.item
&& (slot == next_slot || (a.count == b.count && a.extra == b.extra))
}
_ => false,
};
let replaced = self.hands_replaced[h];
let same = shows_held_stack(&self.visible_hands[h], &next[h], replaced);
if same {
self.visible_hands[h] = next[h].clone();
self.hands_replaced[h] = false;
}
changed[h] = !same;
}
@@ -294,6 +333,7 @@ impl Game<'_> {
for h in 0..2 {
if replace[h] {
self.visible_hands[h] = next[h].clone();
self.hands_replaced[h] = false;
}
}
}
@@ -418,7 +458,21 @@ impl Game<'_> {
/// Haste and Mining Fatigue modifiers (`ADD_MULTIPLIED_TOTAL`), clamped to
/// the attribute's range.
pub(super) fn main_hand_attack_speed(&self) -> f64 {
let value = horizon_client::attack_strength::BASE_ATTACK_SPEED
// The base comes from the player's attribute map so `/attribute ... base set` applies.
let base = self
.player
.as_ref()
.and_then(|player| {
let key = player.attributes.key("minecraft:attack_speed")?;
let handle = player.attributes.lookup(Some(&key))?;
player
.attributes
.instance(&handle)
.ok()
.map(|value| value.base_value())
})
.unwrap_or(horizon_client::attack_strength::BASE_ATTACK_SPEED);
let value = base
+ self
.attack_item_info()
.map_or(0.0, |i| f64::from(i.attack_speed));
@@ -562,13 +616,15 @@ impl HandWorld for GameHands<'_, '_> {
fn attack_entity(&mut self) -> bool {
self.game.attack()
}
fn block_action_restricted(&mut self, _: [i32; 3]) -> bool {
!self.game.may_build()
fn block_action_restricted(&mut self, pos: [i32; 3]) -> bool {
self.game.block_action_restricted(pos)
}
fn within_border(&mut self, _: [i32; 3]) -> bool {
true
}
fn attack_block(&mut self, pos: [i32; 3]) {
// RedStoneOreBlock.interact: the dust comes whether or not the ore was lit.
self.game.redstone_ore_particles(pos);
let result = self.game.attack_block(pos);
self.keep(result);
}
@@ -661,6 +717,52 @@ mod tests {
assert_eq!(reach_pick(&boxes, eye, look, Vec3::ZERO, reach, wall), None);
}
fn stack(item: &str, count: u32, damage: u32) -> Option<ItemStack> {
Some(ItemStack {
item: item.into(),
count,
damage,
extra: None,
})
}
#[test]
fn a_swapped_in_stack_of_the_same_item_re_equips() {
// #2866: F swaps 64 torches into the off hand that held 32. The off
// hand's slot stays, but the server's slot update puts another stack
// object there; a different count fails matchesIgnoringComponents,
// so the hand plays the swap animation.
let visible = (OFFHAND, stack("minecraft:torch", 32, 0));
let held = (OFFHAND, stack("minecraft:torch", 64, 0));
assert!(!shows_held_stack(&visible, &held, true));
// The same stack shrunk in place (a torch placed): no animation.
assert!(shows_held_stack(&visible, &held, false));
// An equal stack replaced it: shown at once.
assert!(shows_held_stack(&visible, &visible.clone(), true));
// DataComponents.DAMAGE ignores the swap animation: a sword with
// other damage swapped in shows at once.
let sword = (OFFHAND, stack("minecraft:iron_sword", 1, 3));
let worn = (OFFHAND, stack("minecraft:iron_sword", 1, 40));
assert!(shows_held_stack(&sword, &worn, true));
// Another component (an anvil's repair cost) differs.
let mut named = worn.clone();
named.1.as_mut().unwrap().extra = Some(Box::new(horizon_client::item_data::StackData {
repair_cost: 1,
..Default::default()
}));
assert!(!shows_held_stack(&sword, &named, true));
// The main hand changing hotbar slot to an equal stack: instant; to
// a different count: animation.
let (a, b) = (
(0, stack("minecraft:torch", 32, 0)),
(1, stack("minecraft:torch", 32, 0)),
);
assert!(shows_held_stack(&a, &b, false));
let c = (1, stack("minecraft:torch", 5, 0));
assert!(!shows_held_stack(&a, &c, false));
assert!(shows_held_stack(&(0, None), &(OFFHAND, None), true));
}
#[test]
fn attack_range_uses_inclusive_float_bounds() {
let range = serde_json::json!({
@@ -6,7 +6,6 @@ use super::{Game, dispenser::entity_random};
use horizon_client::{
Result,
assets::Properties,
chunk::{HEIGHT, MIN_Y},
collisions::{SceneCollisions, no_collision_or_liquid},
world::World,
world_spawn::{
@@ -32,13 +31,13 @@ struct Columns<'a, 'r> {
}
impl SpawnLevel for Columns<'_, '_> {
fn min_y(&self) -> i32 {
MIN_Y
self.world.bottom_y()
}
fn height(&self, kind: HeightmapType, x: i32, z: i32) -> i32 {
(MIN_Y..MIN_Y + HEIGHT)
(self.world.bottom_y()..self.world.top_y())
.rev()
.find(|&y| kind.is_opaque(self.properties.heightmap_state(self.world.block(x, y, z))))
.map_or(MIN_Y, |y| y + 1)
.map_or(self.world.bottom_y(), |y| y + 1)
}
fn has_fluid(&self, x: i32, y: i32, z: i32) -> bool {
use horizon_world::heightmap::HeightmapState;
@@ -85,6 +84,7 @@ impl Game<'_> {
&s,
self.pack.assets.registry,
self.res.biomes(),
self.level_shapes.overworld,
)
})
{
@@ -346,11 +346,11 @@ impl Game<'_> {
/// down onto the ground.
fn fixup_spawn_height(&self, pos: [i32; 3]) -> Vec3 {
let [x, mut y, z] = pos;
while !self.player_fits([x, y, z]) && y < MIN_Y + HEIGHT - 1 {
while !self.player_fits([x, y, z]) && y < self.world.top_y() - 1 {
y += 1;
}
y -= 1;
while self.player_fits([x, y, z]) && y > MIN_Y {
while self.player_fits([x, y, z]) && y > self.world.bottom_y() {
y -= 1;
}
bottom_center([x, y + 1, z])
@@ -469,6 +469,7 @@ impl Game<'_> {
view_distance,
simulation_distance: self.settings.simulation_distance.max(2),
dimension: self.dim.id().to_owned(),
levels: self.level_shapes,
seed: self.level.seed,
max_players: MAX_PLAYERS,
// No player of its own: the hub runs without a host.
@@ -486,6 +487,7 @@ impl Game<'_> {
(uuid != owner && published.published.load(Ordering::Relaxed) == 0)
.then(|| translatable("multiplayer.disconnect.not_whitelisted", &[]))
})),
levels: self.level_shapes,
// IntegratedServer.initServer: the MOTD names the owner and the world.
..NetConfig::lan(
0,
+19 -23
View File
@@ -309,32 +309,28 @@ impl Game<'_> {
batch,
x,
y,
self.started.elapsed().as_secs_f32(),
self.settings.file.double("glintSpeed") as f32,
self.settings.file.double("glintStrength") as f32,
self.glint_frame.millis / 1000.0,
self.glint_frame.speed,
self.glint_frame.alpha,
);
}
/// The `GLINT_TEXTURING` inputs the atlas and options provide.
pub(super) fn glint(&self) -> Option<item_render::Glint> {
self.gui
.atlas
.sprites
.get("misc/enchanted_glint_item")
.copied()
.map(|_| item_render::Glint {
speed: self.settings.file.double("glintSpeed") as f32,
alpha: self.settings.file.double("glintStrength") as f32,
millis: self.started.elapsed().as_secs_f32() * 1000.0,
scale: 0.5,
})
/// The glint clock, options and sprite for this frame: read once per
/// frame, not per item (`GameRenderer.extractOptions`).
pub(super) fn capture_glint_frame(&self) -> item_render::GlintFrame {
item_render::GlintFrame {
millis: self.started.elapsed().as_secs_f32() * 1000.0,
speed: self.settings.file.double("glintSpeed") as f32,
alpha: self.settings.file.double("glintStrength") as f32,
sprite: self
.gui
.atlas
.sprites
.contains_key("misc/enchanted_glint_item"),
}
}
/// The glint scroll phase shared by world items and armour
/// (`item_render::Glint::scroll_ticks`).
pub(super) fn glint_ticks(&self) -> f32 {
item_render::Glint::scroll_ticks(
self.started.elapsed().as_secs_f32() * 1000.0,
self.settings.file.double("glintSpeed") as f32,
)
/// The `ENTITY_GLINT_TEXTURING` inputs of this frame.
pub(super) fn glint(&self) -> Option<item_render::Glint> {
self.glint_frame.glint(0.5)
}
/// Submit a resolved item under `pose` (camera-relative) at `light`
/// (packed lightmap coordinates).
@@ -3,9 +3,13 @@
use super::Game;
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
item_render::stack_view,
items::ItemStack,
particles::{self, ParticleOptions, ParticleType},
};
use horizon_core::random::RandomSource;
use horizon_world::player_inventory::EquipmentSlot;
use serde_json::Value;
impl Game<'_> {
/// `ItemParticleOption(ITEM, stack)`: the layer particle sprites of the
@@ -36,6 +40,75 @@ impl Game<'_> {
self.particles.add(&options, p, v);
}
}
/// `LivingEntity.handleEntityEvent` 47 to 52, 65 and 68 from a server:
/// the entity's item in `slot` broke, so `breakItem` the stack this
/// client still has there (the slot is emptied by a later update).
pub(super) fn remote_equipment_break(&mut self, id: i32, slot: EquipmentSlot) {
if self.remote.as_ref().is_some_and(|r| r.entity_id == id) {
let stack = match slot {
EquipmentSlot::MainHand => self.held().cloned(),
EquipmentSlot::OffHand => self.offhand.clone(),
EquipmentSlot::Head => self.armor[0].clone(),
EquipmentSlot::Chest => self.armor[1].clone(),
EquipmentSlot::Legs => self.armor[2].clone(),
EquipmentSlot::Feet => self.armor[3].clone(),
EquipmentSlot::Body | EquipmentSlot::Saddle => None,
};
let Some(stack) = stack else {
return;
};
if let Some(p) = self.player.as_ref().map(|p| p.position) {
self.break_sound_at(&stack, [p.x, p.y, p.z]);
}
self.eat_particles(&stack, 5);
return;
}
let Some(m) = self.mobs.iter().find(|m| m.id == id as u32) else {
return;
};
let Some(stack) = m.item_by_slot(slot) else {
return;
};
let at = [m.position.x, m.position.y, m.position.z];
let (eye, pitch, yaw, silent) = (m.eye_height(), m.head_pitch, m.yaw, m.silent);
if !silent {
self.break_sound_at(&stack, at);
}
let options = self.item_particle(&stack);
let mut spawned = Vec::new();
particles::item_use_particles(
&mut self.world.random,
pitch,
yaw,
at,
at[1] + eye,
5,
|p, v| spawned.push((p, v)),
);
for (p, v) in spawned {
self.particles.add(&options, p, v);
}
}
/// `LivingEntity.breakItem`'s sound: the stack's `break_sound` (none when
/// the component is removed), heard by this client only
/// (`playLocalSound`) at the entity's feet, at 0.8 volume and a pitch of
/// 0.8 to 1.2.
fn break_sound_at(&mut self, stack: &ItemStack, at: [f64; 3]) {
let models = &self.pack.item_renderer.models;
let sound = match models
.components(&stack_view(stack))
.get("minecraft:break_sound")
{
Some(Value::String(id)) => id.clone(),
Some(Value::Object(inline)) => match inline.get("sound_id") {
Some(Value::String(id)) => id.clone(),
_ => return,
},
_ => return,
};
let pitch = 0.8 + self.world.random.next_float() * 0.4;
self.audio.play_unshared(&sound, Some(at), 0.8, pitch);
}
/// The slime ball, snowball and cobweb models behind `item_slime`,
/// `item_snowball` and `item_cobweb`, resolved on first use.
pub(super) fn ensure_item_particle_sprites(&mut self) {
+100 -8
View File
@@ -183,7 +183,8 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
| "minecraft:axolotl"
| "minecraft:dolphin"
| "minecraft:squid"
| "minecraft:glow_squid" => Some(16),
| "minecraft:glow_squid"
| "minecraft:sulfur_cube" => Some(16),
_ => None,
}
}
@@ -627,10 +628,11 @@ fn mob_meta(
}
}
// AbstractCubeMob.ID_SIZE.
"minecraft:slime" | "minecraft:magma_cube" => {
"minecraft:slime" | "minecraft:magma_cube" | "minecraft:sulfur_cube" => {
if e.size > 1 {
meta.push((18, serializer::INT, Value::Int(e.size)));
}
defaults.push((18, serializer::INT, Value::Int(1)));
}
// Creeper.DATA_SWELL_DIR, DATA_IS_POWERED and DATA_IS_IGNITED: the
// guest swells the creeper from them. The direction always goes in,
@@ -845,6 +847,14 @@ fn mob_meta(
}
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
}
// Bogged.DATA_SHEARED (the first entry after Mob's): the mushrooms
// on its head are gone.
"minecraft:bogged" => {
if e.sheared {
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
}
// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two).
"minecraft:wandering_trader" => unhappy_counter(m, meta, defaults),
// Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and
@@ -1145,12 +1155,14 @@ impl Game<'_> {
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)
};
@@ -1281,15 +1293,53 @@ impl Game<'_> {
/// Save a guest when they leave or the world closes: an integrated
/// server's owner with the world (`save_game`), anyone else in their file.
pub(super) fn save_guest(&mut self, host: &LanHost, guest: &horizon_client::lan::guest::Guest) {
// The vehicle the guest rides, for the file that names it.
let vehicle = self.root_vehicle_uuid(guest.vehicle);
if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
if guest.placed {
playerdata::store(guest, &mut self.level);
playerdata::store(guest, &mut self.level, vehicle);
}
} else {
playerdata::save(guest, &self.world_root);
playerdata::save(guest, &self.world_root, vehicle);
}
self.save_guest_records(host, guest);
}
/// `ServerPlayer.loadAndSpawnParentVehicle`: a guest whose file names the
/// vehicle it rode rides it again, once the vehicle is in the world.
fn restore_guest_vehicles(&mut self, host: &mut LanHost) {
let waiting: Vec<(usize, [i32; 4])> = host
.hub
.guests
.iter()
.enumerate()
.filter_map(|(i, g)| Some((i, g.saved_vehicle?)))
.filter(|(i, _)| {
let g = &host.hub.guests[*i];
let chunk = (
(g.position[0].floor() as i32).div_euclid(16),
(g.position[2].floor() as i32).div_euclid(16),
);
g.placed
&& g.vehicle.is_none()
&& g.dimension == self.dim.id()
&& g.sent_chunks.contains(&chunk)
})
.collect();
for (i, uuid) in waiting {
let raw = host.hub.guests[i].saved.clone();
let Some(id) = self.restore_saved_vehicle(uuid, raw.as_ref()) else {
continue;
};
let g = &mut host.hub.guests[i];
if let Some(horizon_world::nbt::Tag::Compound(p)) = &mut g.saved {
p.remove(&"RootVehicle".into());
}
g.vehicle = Some(id);
g.saved_vehicle = None;
// Pair the stationary restored entities before their passenger packet.
g.moved = true;
}
}
/// The file name of a player's advancements and statistics: an
/// integrated server's owner keeps single player's.
fn records_uuid(&self, host: &LanHost, uuid: u128) -> Option<[i32; 4]> {
@@ -1360,14 +1410,28 @@ impl Game<'_> {
}
/// The integrated server's owner into the world's player, for `Level::write`.
pub(super) fn store_owner(&mut self) {
let Some(uuid) = self
.lan
.as_ref()
.and_then(|h| h.owner.as_ref())
.map(|o| o.uuid)
else {
return;
};
let riding = self.lan.as_ref().and_then(|h| {
h.hub
.guests
.iter()
.find(|g| g.uuid == uuid && g.placed)
.and_then(|g| g.vehicle)
});
// The vehicle the owner rides, by the UUID it is saved with.
let vehicle = self.root_vehicle_uuid(riding);
let Some(host) = &self.lan else {
return;
};
let Some(uuid) = host.owner.as_ref().map(|o| o.uuid) else {
return;
};
if let Some(g) = host.hub.guests.iter().find(|g| g.uuid == uuid && g.placed) {
playerdata::store(g, &mut self.level);
playerdata::store(g, &mut self.level, vehicle);
self.save_guest_records(host, g);
}
}
@@ -1395,6 +1459,25 @@ impl Game<'_> {
host.settings.force_game_mode = force;
host.hub.config.guest_commands = effective;
host.hub.config.force_game_mode = force;
// MinecraftServer.enforceGameTypeForPlayers: the guests online
// take the default mode (`setGameMode` does nothing when it is
// already theirs). A world open to LAN is an IntegratedServer,
// whose `updateGameModeBasedOnPermission` leaves out the players
// who have game master permission; a dedicated server does not.
if force {
let mode = host.hub.config.default_game_mode;
let spares_operators = !host.hub.config.dedicated || host.hub.config.integrated;
let stale: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| g.game_mode != mode && !(spares_operators && g.permission >= 2))
.map(|g| g.conn)
.collect();
for conn in stale {
host.hub.set_game_mode(conn, mode);
}
}
host.hub.resend_commands();
}
}
@@ -1551,6 +1634,8 @@ impl Game<'_> {
m.rider = None;
}
}
// A guest whose file names the vehicle it rode rides it again.
self.restore_guest_vehicles(&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();
@@ -1613,6 +1698,7 @@ impl Game<'_> {
tick_steps: self.clock.step_state(),
collision: Some(self.collision),
border: &self.border,
streaming: Some(&self.streaming),
};
host.hub.movement_checks = (
self.ui.rules.bool("player_movement_check"),
@@ -1869,6 +1955,7 @@ impl Game<'_> {
tick_steps: self.clock.step_state(),
collision: Some(self.collision),
border: &self.border,
streaming: Some(&self.streaming),
};
host.hub.place_new_player(conn, &mut ctx);
if elsewhere && let Err(e) = self.enter_level(Some(host), back) {
@@ -1941,6 +2028,11 @@ impl Game<'_> {
if event == 21 && self.options.execution_role.is_client() {
self.start_guardian_sound(id as u32);
}
// Event 53: a mob sliding down a honey block sheds honey particles
// (HoneyBlock.showSlideParticles), drawn by the client that runs the world.
if event == 53 && self.options.execution_role.is_client() {
self.honey_slide_particles(at);
}
// Event 2: a mob's spear hit felt; the same window hears its own mob's hit.
if event == 2 && self.options.execution_role.is_client() {
self.remote_kinetic_hit(id);
+96 -7
View File
@@ -1138,7 +1138,7 @@ impl Game<'_> {
let _ = g.break_block(pos);
return true;
}
if g.may_build() {
if !g.block_action_restricted(pos) {
let _ = g.attack_block(pos);
g.mining = Some(Mining {
pos,
@@ -1319,14 +1319,25 @@ impl Game<'_> {
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
let g = &mut host.hub.guests[gi];
(g.yaw, g.pitch) = (p.yaw, p.pitch);
let swung = self.as_guest(host, conn, |g| {
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
let used = self.as_guest(host, conn, |g| {
// A horn's sound, inline instruments included.
let horn = g.with_hand(hand, |g| {
let held = g.held()?;
g.res.items().instrument(held).map(|i| i.sound)
});
g.guest_use = Some(GuestUse::Item(hand));
let swings = g.swings_started;
let _ = g.use_item();
g.guest_use = None;
g.swings_started != swings
(g.swings_started != swings, horn)
});
let (swung, horn) = match used {
Some((swung, horn)) => (Some(swung), horn),
None => (None, None),
};
self.lan_swing_back(host, conn, swung);
self.guest_instrument_sound(host, conn, heard, horn);
}
S::Attack(p) => self.guest_attack(host, conn, p.id),
S::Interact(p) => {
@@ -1583,6 +1594,33 @@ impl Game<'_> {
self.send_admin_feedback(host, Some(conn));
}
}
// handleEntityTagQuery: a game master's F3+I on an entity of their
// level gets its Entity.saveWithoutId.
S::EntityTagQuery(p) if host.hub.guests[gi].permission >= 2 => {
if let Some(tag) = self.level_entity_tag(host, p.entity) {
host.hub.send_to(
conn,
&C::TagQuery(play::TagQuery {
transaction: p.transaction,
tag,
}),
);
}
}
// handleBlockEntityTagQuery: BlockEntity.saveWithoutMetadata, or
// null where there is no block entity.
S::BlockEntityTagQuery(p) if host.hub.guests[gi].permission >= 2 => {
let tag = self
.block_entity_tag(p.pos)
.unwrap_or(horizon_world::nbt::Tag::End);
host.hub.send_to(
conn,
&C::TagQuery(play::TagQuery {
transaction: p.transaction,
tag,
}),
);
}
S::Unknown(id, payload)
if id == horizon_protocol::ids::play::serverbound::COMMAND_SUGGESTION =>
{
@@ -1638,6 +1676,31 @@ impl Game<'_> {
}
}
/// `ServerLevel.getEntity(id)`'s `Entity.saveWithoutId`: the player or
/// other entity of the level in place with that network id.
fn level_entity_tag(&mut self, host: &mut LanHost, id: i32) -> Option<horizon_world::nbt::Tag> {
use super::commands::Target;
let player = host
.hub
.guests
.iter()
.find(|g| g.entity_id == id && host.hub.here(g))
.map(|g| g.conn);
let mut tag = match player {
Some(player) => self
.as_guest(host, player, |g| g.entity_nbt(Target::Player))
.flatten()?,
None => {
let target = self.level_entity(id)?;
self.entity_nbt(target)?
}
};
if let horizon_world::nbt::Tag::Compound(c) = &mut tag {
c.remove(&"id".into());
}
Some(tag)
}
/// `ServerGamePacketListenerImpl.tryPickItem` for the guest: a matching stack
/// is selected, else a creative player gets the item; the held slot is sent
/// back and the changed slots follow with the tick's inventory changes.
@@ -2232,6 +2295,7 @@ impl Game<'_> {
tick_steps: self.clock.step_state(),
collision: Some(self.collision),
border: &self.border,
streaming: Some(&self.streaming),
};
host.hub.respawn(i, spawn, &ctx);
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
@@ -2450,7 +2514,7 @@ impl Game<'_> {
let me = self.player_text(&name, uuid);
let text = match cause {
Some(key) => horizon_client::text::Text::translate(&key, &[me]),
None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => {
None if at[1] < f64::from(self.world.bottom_y() - 64) => {
horizon_client::text::Text::translate("death.attack.outOfWorld", &[me])
}
None => self.guest_death_message(conn, me),
@@ -2719,9 +2783,10 @@ impl Game<'_> {
for (moved, on_ground) in moves {
let movement = Vec3::new(moved[0], moved[1], moved[2]);
if w.player.ground.on_ground && !on_ground && moved[1] > 0.0 {
let sprinting = w.player.sprinting;
w.player
.cause_food_exhaustion(if sprinting { 0.2 } else { 0.05 });
// ServerGamePacketListenerImpl.handlePlayerPositionChange:
// the server's copy jumps with the player, so the velocity
// it sends them keeps the jump.
let _ = w.jump_from_ground();
}
let landed = (|| -> horizon_client::Result<()> {
w.set_on_ground_with_movement(on_ground, false, movement)?;
@@ -3444,6 +3509,30 @@ impl Game<'_> {
}
}
/// `InstrumentItem.play`'s `level.playSound(player, ...)`: the others hear
/// a guest's horn now, the guest's own client played it as it used it.
fn guest_instrument_sound(
&mut self,
host: &mut LanHost,
conn: u32,
heard: usize,
horn: Option<String>,
) {
let (Some(horn), Some(capture)) = (horn, self.audio.capture.as_mut()) else {
return;
};
let mut i = heard.min(capture.len());
while i < capture.len() {
if capture[i].0 == horn {
let (name, at, volume, pitch) = capture.remove(i);
let source = horizon_client::settings::sound_source(&name) as i32;
host.hub.sound(&name, at, volume, pitch, source, Some(conn));
} else {
i += 1;
}
}
}
/// Positional sounds and level events of the tick, for the guests.
pub(super) fn lan_broadcast_effects(&mut self, host: &mut LanHost) {
let sounds = self.audio.capture.take().unwrap_or_default();
@@ -8,7 +8,7 @@ use horizon_client::{
effects,
lan::attributes::{add_modifier, generic_syncable, instance, operation_named},
mobs::{
Mob,
Mob, Species,
finalize::{self, Attr, Op},
},
};
@@ -147,6 +147,25 @@ impl Game<'_> {
}
}
}
// AbstractHorse.randomizeAttributes rolls a horse's jump strength as
// its base (kept in `vector[0]`), so the instance is synced: its
// rider's client jumps it (`getCustomJump`).
let rolled = matches!(
m.species(),
Species::Horse | Species::SkeletonHorse | Species::ZombieHorse
);
let jump_base = m
.extra
.attributes
.as_deref()
.is_some_and(|s| s.bases.iter().any(|(a, _)| *a == Attr::JumpStrength));
if rolled
&& !jump_base
&& m.extra.vector[0] > 0.0
&& let Some(a) = self.synced_attribute(kind, "minecraft:jump_strength")
{
instance(out, a, m.extra.vector[0]).base = m.extra.vector[0];
}
if let (Some(a), Some((amount, Op::AddMultipliedBase))) = (
self.synced_attribute(kind, "minecraft:movement_speed"),
finalize::transient(m, Attr::MovementSpeed),
+30 -2
View File
@@ -467,17 +467,44 @@ impl Game<'_> {
else {
return false;
};
// handleContainerClick: broadcastFullState only for a click on an old
// state, else only what the client got wrong. The whole menu after every
// click would undo the clicks it has predicted since.
let mut agreed = false;
// A table that is no longer valid takes no clicks (handleContainerClick).
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
&& !host.hub.guests[gi].spectator()
&& self.lan_table_valid(&host.hub.guests[gi], pos)
{
self.guest_menu_op(host, gi, &mut menu, |menu, env| {
let changed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
let before: Vec<_> = (0..menu.slot_count())
.map(|s| menu.slot(s).cloned())
.collect();
let _ = menu.click(env, i32::from(p.slot), i32::from(p.button), input(p.input));
(0..menu.slot_count())
.filter(|&s| menu.slot(s) != before[s].as_ref())
.collect::<Vec<_>>()
});
agreed = p.state == state
&& host.hub.click_agreed(
gi,
p,
menu.slot_count(),
&|s| menu.slot(s).cloned(),
&changed,
);
if agreed {
// The inventory menu's sync isn't to send what the click settled.
let g = &mut host.hub.guests[gi];
for &s in changed.iter().filter(|&&s| s >= 10) {
g.menu_sent[s - 1] = menu.slot(s).cloned();
}
}
}
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu, pos));
self.lan_send_table_content(host, conn);
if !agreed {
self.lan_send_table_content(host, conn);
}
true
}
@@ -549,6 +576,7 @@ impl Game<'_> {
carried,
}),
);
host.hub.guests[gi].inventory_told();
}
/// `AbstractContainerMenu.removed` of a crafting menu: the closed window
@@ -31,6 +31,7 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
precise: false,
needs_sync: false,
leash: None,
spectator: false,
}
}
@@ -83,7 +83,8 @@ impl Game<'_> {
}
/// `ServerLevel.sendParticles` of a mob's particles: a packet to each
/// player within 32 blocks. The window draws its own copy.
/// player within 32 blocks. The window draws its own copy. `state` is the
/// block of a block particle.
pub(super) fn lan_level_particles(
&self,
options: &ParticleOptions,
@@ -91,8 +92,10 @@ impl Game<'_> {
spread: [f64; 3],
speed: f64,
count: u32,
state: Option<BlockStateId>,
) {
let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, None)) else {
let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, state))
else {
return;
};
let packet = C::LevelParticles(play::LevelParticles {
@@ -418,15 +421,18 @@ impl Game<'_> {
}
}
/// `ServerLevel.explode`'s packet to each player within 64 blocks. The
/// sound and the push reach them on their own (the level's sounds, the
/// motion a hurt player is sent), so the packet carries neither.
/// `ServerLevel.explode`'s packet to each player within 64 blocks, with
/// the push `pushed` holds for them (`ServerExplosion.hitPlayers`: a
/// creative player, whom nothing hurt). The sound reaches them on its own
/// (the level's sounds), and so does a hurt player's push (the motion
/// they are sent).
pub(super) fn lan_explode(
&mut self,
center: [f64; 3],
small: bool,
radius: f32,
block_count: usize,
pushed: Vec<(u32, [f64; 3])>,
) {
// In a guest's turn (their hit on an end crystal or a minecart) the
// host holds the players (`as_guest`): the packet waits for the turn
@@ -434,7 +440,7 @@ impl Game<'_> {
if self.lan_acting.is_some() && self.lan.is_none() {
self.creatures
.lan_explosions
.push((center, small, radius, block_count));
.push((center, small, radius, block_count, pushed));
return;
}
let emitter = if small {
@@ -446,16 +452,16 @@ impl Game<'_> {
if let Some(packet) =
self.explosion_packet(center, radius, block_count, emitter, sound, true)
{
self.send_explosion(packet, &[]);
self.send_explosion(packet, &pushed);
}
}
/// The explosions guests' turns put off.
pub(super) fn flush_lan_explosions(&mut self) {
for (center, small, radius, block_count) in
for (center, small, radius, block_count, pushed) in
std::mem::take(&mut self.creatures.lan_explosions)
{
self.lan_explode(center, small, radius, block_count);
self.lan_explode(center, small, radius, block_count, pushed);
}
}
@@ -438,6 +438,7 @@ impl Game<'_> {
}
}
host.hub.send_to(conn, &packet);
host.hub.guests[gi].inventory_told();
for packet in data {
host.hub.send_to(conn, &packet);
}
+63 -19
View File
@@ -11,6 +11,7 @@
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
use horizon_client::collisions::SceneCollisions;
use horizon_client::mobs::equine;
use horizon_core::geometry::{Aabb, Vec3};
use horizon_protocol::{
packets::play::{self, Clientbound as C},
@@ -225,9 +226,10 @@ impl Game<'_> {
}
}
/// `ServerGamePacketListenerImpl.handleMoveVehicle`: the boat its rider
/// drives goes where the client says, unless it moved too quickly or
/// wrongly; then it stays, and the client is told where it is.
/// `ServerGamePacketListenerImpl.handleMoveVehicle`: the boat or the
/// saddled horse its rider drives goes where the client says, unless it
/// moved too quickly or wrongly; then it stays, and the client is told
/// where it is.
pub(super) fn guest_move_vehicle(
&mut self,
host: &mut LanHost,
@@ -264,9 +266,41 @@ impl Game<'_> {
};
host.hub.send_to(conn, &C::MoveVehicle(packet));
};
// Only a boat is driven by its rider (getControllingPassenger); a
// minecart that is elsewhere on the client is put back.
let Some(i) = vehicle.and_then(|v| self.boats.iter().position(|b| b.id == v)) else {
// Only a boat, and a horse its rider's client moves, are driven by
// their rider (getControllingPassenger); a minecart that is elsewhere
// on the client is put back.
let boat = vehicle.and_then(|v| self.boats.iter().position(|b| b.id == v));
let mount = vehicle.and_then(|v| {
self.mobs
.iter()
.position(|m| m.id == v && equine::moved_by_client(m))
});
// The vehicle's id, position, rotation, motion, box, step height
// and ground.
let (id, old, yaw, velocity, bounds, step, on_ground) = if let Some(i) = boat {
let b = &self.boats[i];
(
b.id,
b.position,
b.yaw,
b.velocity,
b.bounds(),
0.0,
b.on_ground,
)
} else if let Some(i) = mount {
let m = &self.mobs[i];
let step = m.kind.step_height as f32;
(
m.id,
m.position,
m.yaw,
m.velocity,
m.bounds(),
step,
m.on_ground,
)
} else {
if let Some(c) = vehicle.and_then(|v| self.carts.iter().find(|c| c.id == v))
&& c.position.distance_squared(Vec3::new(x, y, z)) > 1.0
{
@@ -274,8 +308,6 @@ impl Game<'_> {
}
return;
};
let b = &self.boats[i];
let old = b.position;
let good = good.map_or(old, |g| Vec3::new(g[0], g[1], g[2]));
let target = Vec3::new(
x.clamp(-3.0E7, 3.0E7),
@@ -283,16 +315,15 @@ impl Game<'_> {
z.clamp(-3.0E7, 3.0E7),
);
// Moved too quickly: more than 10 blocks past its own motion.
let moved = target.distance_squared(old) - b.velocity.length_squared();
let moved = target.distance_squared(old) - velocity.length_squared();
if moved > 100.0 && !owner {
log::warn!("boat {} moved too quickly! {moved}", b.id);
back(host, old, b.yaw);
log::warn!("vehicle {id} moved too quickly! {moved}");
back(host, old, yaw);
return;
}
// vehicle.move(PLAYER, target - lastGood) with the boat's collisions:
// where it can't follow the client (a quarter block off, the height
// aside), it moved wrongly.
let bounds = b.bounds();
// vehicle.move(PLAYER, target - lastGood) with the vehicle's
// collisions: where it can't follow the client (a quarter block off,
// the height aside), it moved wrongly.
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
@@ -300,7 +331,7 @@ impl Game<'_> {
};
let wanted = target.subtract(good);
let resolved = scene
.resolve(bounds, wanted, 0.0, b.on_ground)
.resolve(bounds, wanted, step, on_ground)
.unwrap_or(wanted);
let reached = old.add_vector(resolved);
let wrongly = (target.x - reached.x).powi(2) + (target.z - reached.z).powi(2) > 0.0625;
@@ -311,10 +342,24 @@ impl Game<'_> {
.unobstructed(moved_box.deflate(Vec3::new(1.0E-5, 1.0E-5, 1.0E-5)))
.unwrap_or(true);
if free_before && (wrongly || !free_after) {
log::warn!("boat {} moved wrongly", b.id);
back(host, old, b.yaw);
log::warn!("vehicle {id} moved wrongly");
back(host, old, yaw);
return;
}
host.hub.guests[gi].vehicle_good = Some([target.x, target.y, target.z]);
if let Some(i) = mount {
// absSnapTo: where and how its rider's client turned it; the
// rider sits on it from its own ride tick.
let m = &mut self.mobs[i];
m.position = target;
m.yaw = p.yaw;
m.head_pitch = p.pitch;
m.on_ground = p.on_ground;
return;
}
let Some(i) = boat else {
return;
};
let b = &mut self.boats[i];
b.previous = old;
b.velocity = target.subtract(old);
@@ -324,7 +369,6 @@ impl Game<'_> {
b.on_ground = p.on_ground;
let seat = self.player_boat_seat(i);
let g = &mut host.hub.guests[gi];
g.vehicle_good = Some([target.x, target.y, target.z]);
g.previous_position = g.position;
g.position = [seat.x, seat.y, seat.z];
}
Loaded 100 of 339 files, more files were not shown because too many files have changed in this diff. Show more