Merge main into #6491

minecarts.rs: both branches added in_water; keeps this PR's
update_entity_fluids (stream push + water state) after the move, main's
doc comment, and drops both helper functions for c.in_water.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 10:00:36 +01:00
commit 1bdee6af8d
179 files changed
+10660 -656

No files matched your search

+20
View File
@@ -15,6 +15,8 @@ name: launcher
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
# unset (forks), they are signed ad hoc.
# Windows ships the launcher's own binary twice: in the ZIP (what a launcher updates itself from) and as
# horizon-launcher-setup.exe, which opens the per-user installer instead of Home (launcher/src/installer.rs).
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
# gh workflow run launcher --ref main -f dry_run=false -f force=true
@@ -241,6 +243,18 @@ jobs:
# The client starts: --help prints before any window or GPU is needed.
help() { "$client" --help > "$RUNNER_TEMP/help.txt" 2>&1 && head -3 "$RUNNER_TEMP/help.txt" && grep -q -- '--resources' "$RUNNER_TEMP/help.txt"; }
soft help
# The per-user Windows setup: the same binary under the setup's name installs into a scratch
# folder, and the copy it installs uninstalls itself. HKCU of the runner only.
if [[ "$RUNNER_OS" == "Windows" ]]; then
setup="$RUNNER_TEMP/setup-smoke"
cp "$launcher" "$RUNNER_TEMP/horizon-launcher-setup.exe"
"$RUNNER_TEMP/horizon-launcher-setup.exe" setup --quiet --dir "$setup"
installed="$setup/Horizon Launcher/horizon-launcher.exe"
[[ -f "$installed" ]] || { echo "::error::the setup copied no launcher"; exit 1; }
"$installed" uninstall --quiet
[[ ! -f "$installed" ]] || { echo "::error::uninstall left the launcher"; exit 1; }
rm -rf "$setup"
fi
rm -rf "$HORIZON_LAUNCHER_HOME"
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
@@ -276,6 +290,12 @@ jobs:
fi
fi
ls -l out
- name: Setup exe (Windows)
# The launcher's binary under another name: started with no arguments it shows the per-user
# installer instead of Home (launcher/src/installer.rs). Its name is what makes it the setup,
# so the ZIP's copy stays the launcher. Both go into the release's signed SHA256SUMS.
if: runner.os == 'Windows'
run: cp "$BIN/${{ matrix.binary }}" out/horizon-launcher-setup.exe
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset }}
+1 -10
View File
@@ -634,16 +634,6 @@ A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https:/
</details>
#### HDR monitors
Output for HDR monitors: brighter highlights and richer colour where your screen supports it.
<details><summary>Technical</summary>
Issue [#1441](https://github.com/horizon-rust/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
</details>
#### MSAA, anisotropic filtering, exclusive fullscreen
More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. Today Horizon has FXAA and borderless fullscreen.
@@ -904,6 +894,7 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
- **Discord Rich Presence** (v0.2.58): Your Discord profile can show that you are playing Horizon, with a switch to turn it off. [#1787](https://github.com/horizon-rust/horizon/issues/1787) · [#1788](https://github.com/horizon-rust/horizon/issues/1788)
- **Server dialogs and login screens** (v0.2.67): Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon. [#1720](https://github.com/horizon-rust/horizon/issues/1720) · [#1734](https://github.com/horizon-rust/horizon/issues/1734) · [#1735](https://github.com/horizon-rust/horizon/issues/1735)
- **Microsoft sign-in** (launcher@0.2.17): Sign in with your Microsoft account in the launcher to play on online-mode servers. [docs/microsoft-account.md](docs/microsoft-account.md) · [#2879](https://github.com/horizon-rust/horizon/issues/2879)
- **HDR monitors** (v0.2.74): Output for HDR monitors: brighter highlights and richer colour where your screen supports it. [#1441](https://github.com/horizon-rust/horizon/issues/1441) · [#2021](https://github.com/horizon-rust/horizon/issues/2021) · [#2292](https://github.com/horizon-rust/horizon/issues/2292)
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/horizon-rust/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
+65 -1
View File
@@ -215,7 +215,7 @@ fn untrusted_http_url(url: &str) -> bool {
})
}
/// ClickEvent.CODEC plus the legacy `value` field TextComponentHoverAndClickEventFix
/// `ClickEvent.CODEC` plus the legacy `value` field TextComponentHoverAndClickEventFix
/// renames (`url` / `command` / `page`).
fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::ClickEvent> {
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
@@ -249,6 +249,13 @@ fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::
Some("show_dialog") => e.get("dialog").map(|dialog| {
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
}),
Some("custom") => Some(crate::text::ClickEvent::Custom {
id: crate::identifier::parse_identifier(&text("id")?)?,
payload: e
.get("payload")
.filter(|value| !value.is_null())
.map(crate::scoreboard::json_to_tag),
}),
_ => None,
}
}
@@ -585,6 +592,63 @@ mod tests {
assert_eq!(text.spans[0].style.underlined, Some(true));
}
#[test]
fn custom_click_events_keep_payloads_and_inherit_to_inline_objects() {
use crate::text::ClickEvent;
use horizon_world::nbt::Tag;
for (payload, expected) in [
(None, None),
(Some(serde_json::Value::Null), None),
(Some(serde_json::json!(42)), Some(Tag::Int(42))),
(
Some(serde_json::json!({"shop": "potions", "amount": 3})),
Some(Tag::Compound(
[
("amount".into(), Tag::Int(3)),
("shop".into(), Tag::String("potions".into())),
]
.into_iter()
.collect(),
)),
),
] {
let mut event = serde_json::json!({"action": "custom", "id": "test:shop"});
if let Some(payload) = payload {
event["payload"] = payload;
}
let component = serde_json::json!({
"text": "", "click_event": event,
"extra": [{"object": "atlas", "sprite": "minecraft:item/potion"}, "Shop"]
});
let text = to_text(&component, &mut |_| Text::empty());
for (_, style) in text.chars() {
assert_eq!(
style.click,
Some(ClickEvent::Custom {
id: "test:shop".into(),
payload: expected.clone(),
})
);
}
let encoded = text.component();
let decoded = to_text(&encoded, &mut |_| Text::empty());
assert_eq!(decoded, text);
if expected.is_none() {
assert!(!encoded.to_string().contains("\"payload\""));
}
}
for id in ["Bad ID", "Uppercase:shop"] {
let text = to_text(
&serde_json::json!({
"text": "Shop", "click_event": {"action": "custom", "id": id}
}),
&mut |_| Text::empty(),
);
assert_eq!(text.spans[0].style.click, None);
}
}
#[test]
fn the_font_is_inherited_and_kept() {
let (v, _) =
+14 -2
View File
@@ -39,7 +39,7 @@ pub fn named_color(name: &str) -> Option<u32> {
COLORS.iter().find(|c| c.1 == name).map(|c| c.2)
}
/// Click actions carried by locally constructed text components.
/// Click actions carried by text components.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ClickEvent {
/// `ClickEvent.OpenUrl`: an http or https link.
@@ -53,6 +53,11 @@ pub enum ClickEvent {
/// `ClickEvent.ShowDialog`: a `minecraft:dialog` registry id, or the
/// dialog itself as the component carried it.
ShowDialog(horizon_world::nbt::Tag),
/// `ClickEvent.Custom`: a server-defined action with an optional NBT payload.
Custom {
id: String,
payload: Option<horizon_world::nbt::Tag>,
},
/// A local file to open with the system handler.
/// Only allow this in locally constructed messages, never server/LAN text.
OpenFile(String),
@@ -539,7 +544,7 @@ const STYLE_KEYS: [&str; 11] = [
"insertion",
"font",
];
/// Style.Serializer: the fields a style sets.
/// `Style.Serializer`: the fields a style sets.
fn style_json(style: &Style) -> Vec<(String, Value)> {
let mut out = Vec::new();
if let Some(c) = style.color {
@@ -581,6 +586,13 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
"action": "show_dialog",
"dialog": crate::scoreboard::tag_to_json(dialog),
}),
ClickEvent::Custom { id, payload } => {
let mut event = json!({ "action": "custom", "id": id });
if let Some(payload) = payload {
event["payload"] = crate::scoreboard::tag_to_json(payload);
}
event
}
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
};
out.push(("click_event".to_owned(), event));
+9 -1
View File
@@ -105,6 +105,8 @@ pub struct RenderAssets {
pub pbr_size: u32,
/// OptiFine connected textures (empty without `optifine/ctm` files).
pub connected: crate::connected::ConnectedTextures,
/// Fusion connected textures (empty without Fusion's connecting textures and models).
pub fusion: crate::fusion::FusionTextures,
}
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
@@ -224,7 +226,7 @@ impl RenderAssets {
} else {
crate::model_library::ParsedModels::default()
};
let models = if draws {
let mut models = if draws {
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
} else {
crate::models::PreparedModels::default()
@@ -244,6 +246,11 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
let fusion = if draws {
crate::fusion::FusionTextures::load(root, registry, &mut sprites, &mut models.catalog)
} else {
Default::default()
};
let mut emissive = Vec::new();
let connected = if draws {
crate::native_emissive::load(root, &mut sprites);
@@ -304,6 +311,7 @@ impl RenderAssets {
pbr_specular: sprites.specular,
pbr_size: sprites.pbr_size,
connected,
fusion,
})
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -118,6 +118,7 @@ mod tests {
layer: 0,
opaque: true,
translucent: false,
fusion: None,
quad: ModelQuad {
positions: [
[0.0, 0.5, 0.0],
@@ -39,6 +39,8 @@ pub enum Pick {
/// `ctm_compact`: a tile per quarter of the sprite (top-left, top-right, bottom-left,
/// bottom-right).
Quarters([TileLayer; 4]),
/// Nothing but its overlays: a Fusion face whose tile is empty, or of the overlay layout.
Nothing,
}
/// A tile drawn over a face, after its own (`overlay*` methods).
@@ -375,6 +375,14 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(in.scene));
}
// HDR lighting on clouds, drawn again after them with only alpha written: they're lit by the
// sky, so they take its value, which their blending would otherwise cover.
@fragment fn fs_hdr_cloud_mark(in: Out) -> @location(0) vec4<f32> {
let a = in.color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y));
if a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(globals.hdr_light.y));
}
fn celestial_color(in: Out) -> vec4<f32> {
// RenderPipelines.DRAGON_RAYS / rendertype_lightning.fsh: untextured
// vertex colour, with fog fading both RGB and alpha before additive blending.
+663
View File
@@ -0,0 +1,663 @@
//! Fusion connected textures at mesh time. A pack's connecting textures (a `fusion` section in
//! their `.mcmeta`) are cut into a sprite per tile at load, and every baked face of a
//! `fusion:connecting` model that shows one is given the rule it joins by: the sheet and its
//! predicates, with their blocks resolved to state ids. Meshing such a face evaluates them in
//! the face's eight directions and draws the layout's tiles through [`crate::connected`]'s
//! [`Pick`] and [`Overlay`]. Without Fusion files nothing is loaded and nothing changes.
//!
//! Directions follow a cube's default UVs ([`Axes::cube`]): the top and bottom of a model
//! turned about x, or turned with `uvlock`, join a quarter turn off.
//!
//! The format is `horizon_optifine::fusion`'s.
use crate::connected::{Overlay, Pick, TileLayer};
use crate::models::StateModels;
use crate::sprites::Sprites;
use horizon_optifine::cem::json::Json;
use horizon_optifine::ctm::connect::{self, Axes, Face};
use horizon_optifine::fusion::{
Connection, Layout, Tiles,
model::{Connections, ModelType},
predicate::Query,
texture::{Kind, Meta},
};
use horizon_world::block_state_properties::BlockProperties;
use horizon_world::registry::{BlockRegistry, BlockStateId};
use std::{collections::HashMap, fs, num::NonZeroU16, path::Path};
/// A connecting texture as drawn: its layout and its tiles, `None` for an empty one.
struct Sheet {
layout: Layout,
tiles: Vec<Option<TileLayer>>,
}
/// A sheet and what it joins by, blocks as sorted state ids.
struct Rule {
sheet: usize,
connection: Connection<Vec<u32>>,
}
#[derive(Default)]
pub struct FusionTextures {
sheets: Vec<Sheet>,
/// By [`crate::models::BakedFace::fusion`].
rules: Vec<Rule>,
/// Each state's block, for `is_same_block` (`u32::MAX` for an id the registry skips).
blocks: Vec<u32>,
}
/// A model's reference to a texture or another model (`block/glass`), namespaced.
fn namespaced(reference: &str) -> String {
horizon_optifine::matchers::namespaced(reference)
}
fn read_model(root: &Path, name: &str) -> Option<Json> {
let (ns, path) = name.split_once(':')?;
let text = fs::read_to_string(root.join(format!("assets/{ns}/models/{path}.json"))).ok()?;
Json::parse(&text).ok()
}
/// What a model's face draws and joins by: its sprite, its side in the model, and its
/// predicates (`None`: the texture's own).
type ModelFace = (String, Face, Option<Connection>);
/// Of a `fusion:connecting` model: each face's [`ModelFace`]. Empty for any other model.
fn connecting_model(root: &Path, name: &str) -> Vec<ModelFace> {
let Some(model) = read_model(root, name) else {
return Vec::new();
};
if ModelType::of(&model) != Some(ModelType::Connecting) {
return Vec::new();
}
// Its parents, up to one without a file (`builtin/generated`); deeper than vanilla's few
// levels is a loop.
let mut chain = vec![model];
while chain.len() < 16
&& let Some(parent) = chain.last().and_then(|m| m.get("parent")?.as_str())
&& let Some(parent) = read_model(root, &namespaced(parent))
{
chain.push(parent);
}
// Parents first, so a child's keys replace theirs.
let mut connections = Connections::Textures;
let mut textures: HashMap<&str, &str> = HashMap::new();
for model in chain.iter().rev() {
match Connections::of(model) {
Ok(own) => connections = own.inherit(&connections),
Err(e) => log::warn!("Horizon: Fusion model {name}: {e}"),
}
for (key, value) in model
.get("textures")
.and_then(Json::as_object)
.unwrap_or(&[])
{
// A sprite, or an object naming one (`{"sprite": ..., "force_translucent": true}`).
let value = value.as_str().or_else(|| value.get("sprite")?.as_str());
if let Some(value) = value {
textures.insert(key.as_str(), value);
}
}
}
// A `#key` as vanilla resolves it.
let resolve = |reference: &str| {
let mut value = reference;
for _ in 0..16 {
match value.strip_prefix('#') {
Some(key) => value = textures.get(key)?,
None => return Some(namespaced(value)),
}
}
None
};
// The elements are the nearest model's that has some.
let elements = chain
.iter()
.find_map(|m| m.get("elements")?.as_array())
.unwrap_or(&[]);
let mut out: Vec<ModelFace> = Vec::new();
let faces = elements
.iter()
.filter_map(|e| e.get("faces")?.as_object())
.flatten();
for (side, face) in faces {
let side = match side.as_str() {
"down" => Face::Down,
"up" => Face::Up,
"north" => Face::North,
"south" => Face::South,
"west" => Face::West,
"east" => Face::East,
_ => continue,
};
let Some(texture) = face.get("texture").and_then(Json::as_str) else {
continue;
};
let Some(sprite) = resolve(texture) else {
continue;
};
if !out.iter().any(|(s, f, _)| *s == sprite && *f == side) {
let key = face.get("connections").and_then(Json::as_str);
out.push((sprite, side, connections.for_face(key, texture).cloned()));
}
}
out
}
/// Where a model's `face` points once a blockstate turns it `x`, then `y` quarter turns.
fn turned(face: Face, [x, y, _]: [u8; 3]) -> Face {
let mut n = face.normal();
for _ in 0..x % 4 {
// x=90 takes up to north.
n = [n[0], n[2], -n[1]];
}
for _ in 0..y % 4 {
// y=90 takes north to east.
n = [-n[2], n[1], n[0]];
}
Face::ALL
.into_iter()
.find(|f| f.normal() == n)
.expect("a quarter turn maps faces onto faces")
}
/// The connecting textures under `root`, by sprite name.
fn connecting_textures(root: &Path) -> HashMap<String, Meta> {
let mut out = HashMap::new();
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
return out;
};
for ns in namespaces.flatten() {
let ns_name = ns.file_name().to_string_lossy().into_owned();
let textures = ns.path().join("textures");
let mut dirs = vec![textures.clone()];
while let Some(dir) = dirs.pop() {
for entry in fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
// Linked folders too: mods' content links into the packs' tree.
let dir = match entry.file_type() {
Ok(t) if t.is_symlink() => path.is_dir(),
Ok(t) => t.is_dir(),
Err(_) => path.is_dir(),
};
if dir {
dirs.push(path);
continue;
}
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
continue;
};
let Ok(text) = fs::read_to_string(&path) else {
continue;
};
if !text.contains("fusion") {
continue;
}
let Some(rel) = Path::new(png)
.strip_prefix(&textures)
.ok()
.and_then(|p| p.to_str())
else {
continue;
};
let name = format!("{ns_name}:{}", rel.replace('\\', "/"));
match Meta::from_mcmeta(&text) {
Ok(Some(meta)) => {
out.insert(name, meta);
}
Ok(None) => {}
Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"),
}
}
}
}
out
}
/// `sprites`' tiles of the connecting texture `name`, and the predicates it joins by itself.
fn sheet(
root: &Path,
sprites: &mut Sprites,
name: &str,
meta: Meta,
) -> Option<(Sheet, Connection)> {
let Kind::Connecting {
layout,
connections,
per_tile_animation,
..
} = meta.kind
else {
return None;
};
let tiles = sprites
.insert_tiles(root, name, layout.grid(), per_tile_animation)
.map_err(|e| log::warn!("Horizon: Fusion texture {name}: {e}"))
.ok()?;
// A layer past the mesher's `u16` leaves the sheet to vanilla.
let tiles = tiles
.into_iter()
.map(|t| match t {
None => Some(None),
Some(l) => Some(Some(TileLayer::Layer {
layer: u16::try_from(l).ok()?,
translucent: sprites.translucent[l as usize],
opaque: sprites.opaque[l as usize],
})),
})
.collect::<Option<Vec<_>>>()?;
Some((Sheet { layout, tiles }, connections))
}
impl FusionTextures {
/// The connecting textures under `root`: the tiles of those `fusion:connecting` models
/// draw are inserted into `sprites` (before it finishes), and each face in `catalog` that
/// shows one gets its rule.
pub fn load(
root: &Path,
registry: &BlockRegistry,
sprites: &mut Sprites,
catalog: &mut StateModels,
) -> Self {
let mut textures = connecting_textures(root);
if textures.is_empty() {
return Self::default();
}
// The layers models show the sheets as: only their models are read.
let shown: std::collections::HashSet<u32> = textures
.keys()
.filter_map(|name| sprites.layers.get(name).copied())
.collect();
let mut models: HashMap<String, Vec<ModelFace>> = HashMap::new();
let mut sheets: Vec<(Sheet, Connection)> = Vec::new();
let mut by_name: HashMap<String, Option<usize>> = HashMap::new();
let mut rules: Vec<(usize, Connection)> = Vec::new();
for (variant, model) in catalog.models_mut() {
if !model.faces.iter().any(|f| shown.contains(&f.layer)) {
continue;
}
let found = models
.entry(namespaced(&variant.model))
.or_insert_with_key(|name| connecting_model(root, name));
for face in &mut model.faces {
let sprite = &face.quad.material.sprite;
// The model's face on the side this one turned to, else any with its sprite.
let side = Face::ALL[face.quad.facing as usize];
let mut drawing = found.iter().filter(|(s, _, _)| s == sprite);
let Some((_, _, connection)) = drawing
.clone()
.find(|(_, f, _)| turned(*f, variant.rotations) == side)
.or_else(|| drawing.next())
else {
continue;
};
// The sheet, cut into tiles on first use.
let sheet = *by_name.entry(sprite.clone()).or_insert_with(|| {
let meta = textures.remove(sprite)?;
sheets.push(sheet(root, sprites, sprite, meta)?);
Some(sheets.len() - 1)
});
let Some(sheet) = sheet else {
continue;
};
let connection = connection
.clone()
.unwrap_or_else(|| sheets[sheet].1.clone());
let rule = match rules.iter().position(|r| r.0 == sheet && r.1 == connection) {
Some(rule) => rule,
None => {
rules.push((sheet, connection));
rules.len() - 1
}
};
face.fusion = u16::try_from(rule + 1).ok().and_then(NonZeroU16::new);
}
}
let states = (0..registry.len() as u32).map(|id| {
registry
.state(BlockStateId(id))
.map(|s| (s.name.as_str(), &s.properties))
});
let sheets = sheets.into_iter().map(|(sheet, _)| sheet).collect();
Self::build(sheets, rules, states)
}
/// The lookups for `rules` over every block state (`None` for an id the registry skips).
fn build<'a>(
sheets: Vec<Sheet>,
rules: Vec<(usize, Connection)>,
states: impl Iterator<Item = Option<(&'a str, &'a BlockProperties)>>,
) -> Self {
let states: Vec<_> = states.collect();
let mut block_states: HashMap<&str, Vec<u32>> = HashMap::new();
let mut names = HashMap::new();
let blocks = (0..states.len() as u32)
.map(|id| match states[id as usize] {
Some((name, _)) => {
block_states.entry(name).or_default().push(id);
let next = names.len() as u32;
*names.entry(name).or_insert(next)
}
None => u32::MAX,
})
.collect();
// Block lists to the states they match.
let mut resolve = |list: Vec<horizon_optifine::BlockMatch>| {
let mut ids: Vec<u32> = list
.iter()
.flat_map(|m| {
let candidates = block_states
.get(m.id.as_str())
.map_or(&[][..], Vec::as_slice);
candidates.iter().copied().filter(|&id| {
states[id as usize].is_some_and(|(name, props)| {
m.matches(name, |k| props.get(k).map(String::as_str))
})
})
})
.collect();
ids.sort_unstable();
ids.dedup();
ids
};
let rules = rules
.into_iter()
.map(|(sheet, connection)| Rule {
sheet,
connection: connection.map_blocks(&mut resolve),
})
.collect();
Self {
sheets,
rules,
blocks,
}
}
/// What the `face` of the block `state` draws for a quad whose rule is `rule` and whose
/// tint index is `tint`. `neighbour` answers the state at an offset from the block,
/// `covers` whether a state hides the face of a block beside it; the overlay layout's
/// pieces, tinted as the quad, go to `overlay`.
#[allow(clippy::too_many_arguments)]
pub fn pick(
&self,
rule: NonZeroU16,
state: u32,
face: Face,
tint: i32,
neighbour: impl Fn([i32; 3]) -> u32,
covers: impl Fn(u32) -> bool,
mut overlay: impl FnMut(Overlay),
) -> Option<Pick> {
let rule = self.rules.get(usize::from(rule.get() - 1))?;
let normal = face.normal();
let offsets = connect::offsets(Axes::cube(face));
let block = self.blocks.get(state as usize).filter(|&&b| b != u32::MAX);
let mask = rule.connection.mask(|bit, query| {
let &(_, [x, y, z]) = offsets.iter().find(|o| o.0 == bit).expect("a mask bit");
let in_front = || neighbour([x + normal[0], y + normal[1], z + normal[2]]);
match query {
Query::SameBlock => {
block.is_some() && self.blocks.get(neighbour([x, y, z]) as usize) == block
}
Query::SameState => neighbour([x, y, z]) == state,
Query::Neighbour(ids) => ids.binary_search(&neighbour([x, y, z])).is_ok(),
Query::InFront(ids) => ids.binary_search(&in_front()).is_ok(),
Query::FaceVisible => !covers(in_front()),
}
});
let sheet = &self.sheets[rule.sheet];
let tile = |i: u8| sheet.tiles.get(usize::from(i)).copied().flatten();
// An empty tile draws nothing: a pack leaves empty only the tiles its layout's uses
// never need.
Some(match sheet.layout.tiles(mask) {
Tiles::One(t) => tile(t).map_or(Pick::Nothing, Pick::Tile),
Tiles::Quarters(q) => match q.map(tile) {
[Some(a), Some(b), Some(c), Some(d)] => Pick::Quarters([a, b, c, d]),
_ => Pick::Nothing,
},
Tiles::Overlay(mask) => {
for t in Layout::overlay_tiles(mask) {
if let Some(TileLayer::Layer {
layer, translucent, ..
}) = tile(t)
{
overlay(Overlay {
layer,
translucent,
tint: (tint >= 0).then_some((tint, None)),
});
}
}
Pick::Nothing
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Tile `i` of a sheet is layer `base + i`; `empty` tiles have none.
fn sheet(layout: Layout, base: u16, empty: &[u16]) -> Sheet {
let (w, h) = layout.grid();
Sheet {
layout,
tiles: (0..(w * h) as u16)
.map(|i| (!empty.contains(&i)).then_some(layer(base + i)))
.collect(),
}
}
fn predicate(json: &str) -> Connection {
Connection::parse(&Json::parse(json).unwrap()).unwrap()
}
fn layer(layer: u16) -> TileLayer {
TileLayer::Layer {
layer,
translucent: false,
opaque: false,
}
}
/// The lookups over blocks named `names` (a state each).
fn world(
names: &[&str],
sheets: Vec<Sheet>,
rules: Vec<(usize, Connection)>,
) -> FusionTextures {
let none = BlockProperties::default();
let states = names.iter().map(|n| Some((*n, &none)));
FusionTextures::build(sheets, rules, states)
}
/// `pick` for the block at the origin among `blocks` (offset, state), the opaque ones in
/// `opaque`; with the overlays it hands out.
fn pick_in(
fusion: &FusionTextures,
rule: u16,
state: u32,
face: Face,
blocks: &[([i32; 3], u32)],
opaque: &[u32],
) -> (Option<Pick>, Vec<u16>) {
// A face's rule counts from 1.
let rule = NonZeroU16::new(rule + 1).unwrap();
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
let mut overlays = Vec::new();
let pick = fusion.pick(
rule,
state,
face,
-1,
at,
|s| opaque.contains(&s),
|o| overlays.push(o.layer),
);
(pick, overlays)
}
#[test]
fn glass_pieces_join_the_same_block() {
// Glass twice (two states of one block), then stone.
let fusion = world(
&[
"minecraft:air",
"minecraft:glass",
"minecraft:glass",
"minecraft:stone",
],
vec![sheet(Layout::Pieced, 100, &[])],
vec![(0, Connection::SameBlock)],
);
let at = |blocks: &[([i32; 3], u32)]| pick_in(&fusion, 0, 1, Face::North, blocks, &[]).0;
// Alone: every quarter the lone tile.
assert_eq!(at(&[]), Some(Pick::Quarters([layer(100); 4])));
// A north face's right is west: glass there (in its other state) joins the right
// quarters, the horizontal piece. Stone doesn't.
let right = Pick::Quarters([layer(100), layer(103), layer(100), layer(103)]);
assert_eq!(at(&[([-1, 0, 0], 2)]), Some(right));
assert_eq!(
at(&[([-1, 0, 0], 3)]),
Some(Pick::Quarters([layer(100); 4]))
);
// A rule that doesn't exist draws as vanilla.
assert_eq!(pick_in(&fusion, 1, 1, Face::North, &[], &[]).0, None);
}
#[test]
fn overlays_reach_toward_visible_sand() {
let sand = predicate(
r#"{"type": "and", "predicates": [{"type": "match_block", "block": "sand"}, {"type": "is_face_visible"}]}"#,
);
let fusion = world(
&["minecraft:air", "minecraft:stone", "minecraft:sand"],
vec![sheet(Layout::Overlay, 200, &[7])],
vec![(0, sand)],
);
// Sand to the north of stone's top face, its own top open: the top edge piece, (1, 2),
// over a face that draws nothing itself.
let north = [([0, 0, -1], 2)];
assert_eq!(
pick_in(&fusion, 0, 1, Face::Up, &north, &[]),
(Some(Pick::Nothing), vec![213])
);
// Stone over that sand hides its top: no edge.
let covered = [([0, 0, -1], 2), ([0, 1, -1], 1)];
assert_eq!(
pick_in(&fusion, 0, 1, Face::Up, &covered, &[1]),
(Some(Pick::Nothing), vec![])
);
// Sand all round: the ring, (4, 1).
let ring: Vec<_> = [-1, 0, 1]
.into_iter()
.flat_map(|x| [-1, 0, 1].map(|z| ([x, 0, z], 2)))
.filter(|(o, _)| *o != [0, 0, 0])
.collect();
assert_eq!(pick_in(&fusion, 0, 1, Face::Up, &ring, &[]).1, [210]);
// Its pieces take the quad's tint index.
let mut tints = Vec::new();
let at = |o: [i32; 3]| if o == [0, 0, -1] { 2 } else { 0 };
let rule = NonZeroU16::new(1).unwrap();
fusion.pick(rule, 1, Face::Up, 0, at, |_| false, |o| tints.push(o.tint));
assert_eq!(tints, [Some((0, None))]);
}
#[test]
fn in_front_and_empty_tiles() {
// Dirt's top joins where sand sits on the block beside it; its full sheet leaves the
// lone tile empty.
let front = predicate(r#"{"type": "match_block_in_front", "block": "sand"}"#);
let fusion = world(
&["minecraft:air", "minecraft:dirt", "minecraft:sand"],
vec![sheet(Layout::Full, 300, &[0])],
vec![(0, front)],
);
// Sand on the block east of the top face: joined right, the full template's (1, 0).
assert_eq!(
pick_in(&fusion, 0, 1, Face::Up, &[([1, 1, 0], 2)], &[]).0,
Some(Pick::Tile(layer(301)))
);
// Sand beside it, not in front of anything: alone, and the lone tile is empty.
assert_eq!(
pick_in(&fusion, 0, 1, Face::Up, &[([1, 0, 0], 2)], &[]).0,
Some(Pick::Nothing)
);
}
#[test]
fn models_name_the_predicates_their_faces_draw() {
let root =
std::env::temp_dir().join(format!("horizon-fusion-models-{}", std::process::id()));
let models = root.join("assets/demo/models/block");
fs::create_dir_all(&models).unwrap();
// A pane arm over a template whose `particle` names the pane sheet but draws no face
// with it (as Fusion Connected Glass's posts do): only the faces count.
fs::write(
models.join("template.json"),
r##"{"textures": {"particle": "#pane"}, "elements": [{"from": [7, 0, 0], "to": [9, 16, 7],
"faces": {"up": {"texture": "#edge"}, "west": {"texture": "#pane"}}}]}"##,
)
.unwrap();
fs::write(
models.join("arm.json"),
r#"{"loader": "fusion:model", "type": "fusion:connecting", "parent": "demo:block/template",
"connections": {"edge": {"type": "true"}},
"textures": {"pane": "demo:block/glass", "edge": {"sprite": "demo:block/edge"}}}"#,
)
.unwrap();
fs::write(
models.join("plain.json"),
r#"{"parent": "demo:block/template", "textures": {"pane": "demo:block/glass"}}"#,
)
.unwrap();
let arm = connecting_model(&root, "demo:block/arm");
let plain = connecting_model(&root, "demo:block/plain");
fs::remove_dir_all(&root).unwrap();
// `edge` has the model's predicate; `pane` none, so the texture's own.
assert_eq!(
arm,
[
(
"demo:block/edge".to_owned(),
Face::Up,
Some(Connection::Const(true))
),
("demo:block/glass".to_owned(), Face::West, None),
]
);
// Not a Fusion model: vanilla.
assert!(plain.is_empty());
}
#[test]
fn faces_of_one_sprite_join_by_their_own_keys() {
let root = std::env::temp_dir().join(format!("horizon-fusion-keys-{}", std::process::id()));
let models = root.join("assets/demo/models/block");
fs::create_dir_all(&models).unwrap();
// The wiki's example: four keys on one texture, two of them joining by `blue`.
fs::write(
models.join("tiles.json"),
r##"{"loader": "fusion:model", "type": "connecting",
"textures": {"ice": "block/oak_tiles", "dirt": "block/oak_tiles", "stone": "block/oak_tiles"},
"connections": {"blue": {"type": "match_block", "block": "ice"},
"dirt": {"type": "is_same_block"}, "default": {"type": "is_same_state"}},
"elements": [{"from": [0, 0, 0], "to": [16, 16, 16], "faces": {
"up": {"texture": "#ice", "connections": "#blue"},
"north": {"texture": "#dirt"}, "south": {"texture": "#stone"}}}]}"##,
)
.unwrap();
let faces = connecting_model(&root, "demo:block/tiles");
fs::remove_dir_all(&root).unwrap();
let joins = |side| faces.iter().find(|f| f.1 == side).and_then(|f| f.2.clone());
assert!(matches!(joins(Face::Up), Some(Connection::Neighbour(_))));
assert_eq!(joins(Face::North), Some(Connection::SameBlock));
assert_eq!(joins(Face::South), Some(Connection::SameState));
// A blockstate's turns move them: x=90 takes the top to the north, y=90 the north to
// the east.
assert_eq!(turned(Face::Up, [1, 0, 0]), Face::North);
assert_eq!(turned(Face::North, [0, 1, 0]), Face::East);
assert_eq!(turned(Face::Up, [1, 1, 0]), Face::East);
}
}
+415 -5
View File
@@ -3,7 +3,9 @@
//! world coordinates keep full precision.
use crate::{
Result,
block_entity_render::{StaticCacheFrameStats, StaticSectionRevision, StaticSectionSnapshot},
environment::Sky,
fast_hash::FxHashMap,
graphics::GraphicsApi,
hud::{HudBatch, HudRenderer},
mesher::{Part as Quads, SectionMesh, Sides, TerrainVertex, Vertex},
@@ -33,6 +35,12 @@ fn entity_shader() -> String {
crate::overlay::wgsl_constants() + include_str!("entity.wgsl")
}
/// The static block-entity vertex entry point shares entity shading but lives
/// in its own source file and pipeline.
fn static_entity_shader() -> String {
entity_shader() + include_str!("static_entity.wgsl")
}
#[cfg(test)]
mod crumbling_tests;
mod entity_batch;
@@ -332,6 +340,20 @@ struct Globals {
hdr_windows: [[f32; 4]; 2],
hdr_light: [f32; 4],
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct StaticEntityUniform {
camera_block: [i32; 4],
camera_fraction: [f32; 4],
}
struct GpuStaticEntitySection {
buffer: wgpu::Buffer,
origin_buffer: wgpu::Buffer,
origin: [i32; 3],
capacity: u64,
revision: u64,
vertices: u32,
}
pub type SectionKey = (i32, i32, i32);
/// The water mirror's targets: the world as a camera mirrored about the water plane sees
/// it, at half the world's resolution (the material stage's `reflection`).
@@ -623,6 +645,12 @@ pub struct Frame<'a> {
pub nether_lighting: bool,
/// Camera-relative entity geometry textured from the shared atlas.
pub entities: &'a [Vertex],
/// Per-section cacheable block-entity models. Positions are section-local.
pub static_block_entities: &'a [StaticSectionSnapshot],
/// Sections currently intersecting the coarse camera frustum.
pub visible_static_block_entity_sections: &'a [[i32; 3]],
/// Cache work for this frame, reported in bench summaries.
pub static_block_entity_stats: StaticCacheFrameStats,
/// Which entity `entities`' vertices belong to: (first vertex, entity id), each run up
/// to the next; id 0 for none. Frame generation follows these entities' own motion.
pub entity_motion: &'a [(u32, u64)],
@@ -815,6 +843,12 @@ pub struct Renderer {
mirror_group: wgpu::BindGroup,
mirror: Option<MirrorTarget>,
entity_pipeline: wgpu::RenderPipeline,
static_entity_pipeline: wgpu::RenderPipeline,
static_entity_group: wgpu::BindGroup,
static_entity_transform: wgpu::Buffer,
static_entity_camera: Option<DVec3>,
static_entity_catalog_revision: u64,
static_entity_frame_stats: StaticCacheFrameStats,
hdr_mark_pipeline: wgpu::RenderPipeline,
hdr_hand_unmark_pipeline: wgpu::RenderPipeline,
hdr_mark_lightning_pipeline: wgpu::RenderPipeline,
@@ -825,6 +859,7 @@ pub struct Renderer {
/// `RenderPipelines.WATER_MASK`: depth writes only, no colour.
water_mask_pipeline: wgpu::RenderPipeline,
cloud_pipeline: wgpu::RenderPipeline,
hdr_cloud_mark_pipeline: wgpu::RenderPipeline,
cloud_mesh: crate::clouds::Mesh,
cloud_buffer: wgpu::Buffer,
cloud_capacity: u64,
@@ -863,6 +898,9 @@ pub struct Renderer {
hand_globals: wgpu::Buffer,
entity_buffer: wgpu::Buffer,
entity_capacity: u64,
static_entity_sections: HashMap<[i32; 3], GpuStaticEntitySection>,
static_entity_indices: wgpu::Buffer,
static_entity_index_quads: u32,
entity_batch: EntityBatch,
/// Ordinary entity models posed on the GPU; `None` without compute shaders.
entity_skin: Option<entity_skin::EntitySkin>,
@@ -924,7 +962,7 @@ pub struct Renderer {
lod_leaving: Vec<(SectionKey, std::time::Instant)>,
/// Finished tile meshes `upload_lods` has yet to upload.
lod_pending: Option<lod::Pending>,
detail_columns: crate::fast_hash::FxHashMap<(i32, i32), SectionMask>,
detail_columns: FxHashMap<(i32, i32), SectionMask>,
/// Bumped whenever a column becomes or stops being complete.
detail_revision: u64,
arena: arena::Arena,
@@ -973,10 +1011,17 @@ pub struct Renderer {
/// The shadow camera's draws by page, in bands of distance from the light.
shadow_runs: page_runs::PageRuns,
/// Block-entity blocks per meshed section (world position, state id).
block_entities: crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
block_entities: FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
/// Changes on every mesh upload for that section; block-entity static
/// geometry also depends on the section's packed light.
block_entity_section_revisions: FxHashMap<SectionKey, u64>,
/// Changes only when the block-entity positions/states in a section
/// change. Empty-section tombstones remain until that section unloads so
/// the static cache can remove deleted models without a global flush.
block_entity_membership_revisions: FxHashMap<SectionKey, u64>,
/// When each section was first uploaded (`RenderSection.uploadedTime`): kept across meshing
/// it again, gone with its column. Sections that fade in over far terrain are not in it.
section_uploaded: crate::fast_hash::FxHashMap<SectionKey, std::time::Instant>,
section_uploaded: FxHashMap<SectionKey, std::time::Instant>,
/// When `section_uploaded` last gained a section: once the fade time has passed since,
/// every section is whole and frames skip the per-section lookups.
newest_upload: Option<std::time::Instant>,
@@ -986,7 +1031,7 @@ pub struct Renderer {
/// Changes whenever `block_entities` does (unique across renderers).
block_entity_revision: u64,
/// The light sources of each section and their revision, for `block_light`.
light_blocks: crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
light_blocks: FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
light_revision: u64,
/// VisGraph face connectivity per meshed section (cave culling).
occlusion: occlusion::Occlusion,
@@ -2715,6 +2760,10 @@ impl Renderer {
label: Some("Horizon entities"),
source: wgpu::ShaderSource::Wgsl(entity_shader().into()),
});
let static_entity_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon static block entities"),
source: wgpu::ShaderSource::Wgsl(static_entity_shader().into()),
});
let entity_bindings = kept.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Entity bindings"),
entries: &[
@@ -2780,6 +2829,19 @@ impl Renderer {
),
],
});
let static_entity_bindings =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Static block-entity transform"),
entries: &[entry(
0,
wgpu::ShaderStages::VERTEX,
wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
)],
});
let weather = weather::Weather::new(
&device,
config.format.remove_srgb_suffix(),
@@ -2828,6 +2890,23 @@ impl Renderer {
},
],
});
let static_entity_transform =
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Static block-entity camera position"),
contents: bytemuck::bytes_of(&StaticEntityUniform {
camera_block: [0; 4],
camera_fraction: [0.0; 4],
}),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let static_entity_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Static block-entity transform"),
layout: &static_entity_bindings,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: static_entity_transform.as_entire_binding(),
}],
});
let hand_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("First-person hand resources"),
layout: &entity_bindings,
@@ -2871,6 +2950,11 @@ impl Renderer {
bind_group_layouts: &[Some(&entity_bindings)],
..Default::default()
});
let static_entity_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Static block-entity layout"),
bind_group_layouts: &[Some(&entity_bindings), Some(&static_entity_bindings)],
..Default::default()
});
let cloud_bindings = kept.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Cloud bindings"),
entries: &[entry(
@@ -2914,6 +2998,19 @@ impl Renderer {
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &entity_attributes,
})];
let static_entity_origin_attributes = wgpu::vertex_attr_array![7=>Sint32x3];
let static_entity_vertex = [
Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &entity_attributes,
}),
Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<[i32; 3]>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &static_entity_origin_attributes,
}),
];
#[allow(clippy::too_many_arguments)]
let entity_pipeline_with = |label,
cull_mode,
@@ -3048,6 +3145,17 @@ impl Renderer {
alpha_blend,
true,
);
let hdr_cloud_mark_pipeline = entity_pipeline_with(
"HDR cloud mark",
Some(wgpu::Face::Back),
false,
wgpu::CompareFunction::GreaterEqual,
Default::default(),
wgpu::BlendState::REPLACE,
true,
"fs_hdr_cloud_mark",
wgpu::ColorWrites::ALPHA,
);
// RenderPipelines.TEXT_SEE_THROUGH: translucent, no depth test or
// writes, culled like every world text pipeline (TEXT_SNIPPET keeps
// RenderPipeline.Builder's default): a name tag or text display hides
@@ -3086,6 +3194,41 @@ impl Renderer {
alpha_blend,
false,
);
let static_entity_pipeline =
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Static block entities"),
layout: Some(&static_entity_layout),
vertex: wgpu::VertexState {
module: &static_entity_shader,
entry_point: Some("vs_static_entity"),
compilation_options: Default::default(),
buffers: &static_entity_vertex,
},
primitive: wgpu::PrimitiveState::default(),
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &static_entity_shader,
entry_point: Some("fs_entity"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: config.format.remove_srgb_suffix(),
// fs_entity's discard handles binary cutout alpha. Any
// sprite with fractional alpha stays on the blended
// dynamic entity path.
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
// RenderPipelines.ENERGY_SWIRL: BlendFunction.ADDITIVE (one, one), no culling; no depth
// writes either: vanilla sorts the quads far to near, so every face passes the test.
let one_one = wgpu::BlendComponent {
@@ -3550,6 +3693,8 @@ impl Renderer {
usage: entity_skin::EntitySkin::usage(entity_skin.is_some()),
mapped_at_creation: false,
});
let static_entity_index_quads = 1 << 14;
let static_entity_indices = Self::quad_indices(&device, static_entity_index_quads);
let flat_wanted = flat::wanted(&adapter, &device);
// Metal draws solid terrain in two passes (`split_solid`): meshes put all
// their opaque quads first, so each pass draws its part in a run or two.
@@ -3733,6 +3878,7 @@ impl Renderer {
mirror_group,
mirror: None,
entity_pipeline,
static_entity_pipeline,
hdr_mark_pipeline,
hdr_hand_unmark_pipeline,
hdr_mark_lightning_pipeline,
@@ -3742,6 +3888,7 @@ impl Renderer {
translucent_item_pipeline,
water_mask_pipeline,
cloud_pipeline,
hdr_cloud_mark_pipeline,
cloud_mesh: Default::default(),
cloud_buffer,
cloud_capacity: 4,
@@ -3769,10 +3916,18 @@ impl Renderer {
screen_capacity,
entity_group,
entity_bindings,
static_entity_group,
static_entity_transform,
static_entity_camera: None,
static_entity_catalog_revision: 0,
static_entity_frame_stats: Default::default(),
hand_group,
hand_globals,
entity_buffer,
entity_capacity,
static_entity_sections: HashMap::default(),
static_entity_indices,
static_entity_index_quads,
entity_batch: EntityBatch::default(),
entity_skin,
group,
@@ -3838,6 +3993,8 @@ impl Renderer {
phase2_runs: page_runs::PageRuns::default(),
shadow_runs: page_runs::PageRuns::default(),
block_entities: Default::default(),
block_entity_section_revisions: Default::default(),
block_entity_membership_revisions: Default::default(),
section_uploaded: Default::default(),
newest_upload: None,
newest_lod_fade: None,
@@ -3901,6 +4058,19 @@ impl Renderer {
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
}
}
/// HDR lighting on the clouds just drawn: the sky's value (`fs_hdr_cloud_mark`).
fn mark_clouds(&self, pass: &mut wgpu::RenderPass<'_>, quads: u32) {
if self.hdr.as_ref().is_none_or(|h| !h.lighting) {
return;
}
pass.set_pipeline(&self.hdr_cloud_mark_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_bind_group(1, &self.cloud_group, &[]);
pass.set_vertex_buffer(0, self.cloud_buffer.slice(..));
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..quads * 6, 0, 0..1);
pass.set_bind_group(0, &self.group, &[]);
}
/// HDR lighting on translucent terrain, over the draws that just blended it.
fn mark_translucent_terrain(
&self,
@@ -4139,6 +4309,17 @@ impl Renderer {
pub fn section_count(&self) -> usize {
self.sections.len()
}
/// Whether the running native pack gives any block a `[[lights]]` colour.
pub fn has_light_colors(&self) -> bool {
self.native
.as_ref()
.is_some_and(|native| !native.light_colors.is_empty())
}
/// The running native pack's `[[lights]]` colour of the block `name`.
pub fn light_color(&self, name: &str) -> Option<[f32; 3]> {
let native = self.native.as_ref()?;
horizon_shader_api::manifest::light_color(&native.light_colors, name)
}
/// The running native pack's `[[particle_lights]]`.
pub fn particle_lights(&self) -> Option<&[horizon_shader_api::manifest::ParticleLight]> {
self.native.as_ref().map(|n| n.particle_lights.as_slice())
@@ -4179,6 +4360,17 @@ impl Renderer {
};
if changed {
self.block_entity_revision = next_block_entity_revision();
self.block_entity_membership_revisions
.insert(key, self.block_entity_revision);
}
if !mesh.block_entities.is_empty()
|| self.block_entity_membership_revisions.contains_key(&key)
{
// Packed light can change with an unchanged BE list. Keep a
// tombstone for formerly occupied sections until unload so a
// deleted last entity invalidates only its own cached section.
self.block_entity_section_revisions
.insert(key, next_block_entity_revision());
}
let lights_changed = if mesh.lights.is_empty() {
self.light_blocks.remove(&key).is_some()
@@ -4573,6 +4765,55 @@ impl Renderer {
.newest_lod_fade
.is_some_and(|t| now.saturating_duration_since(t) < lod::FADE)
}
/// Block entities in sections which can intersect the camera frustum.
/// The section bounds include the same one-block margin as the per-block
/// check in the game. Sections wholly inside the frustum skip that finer
/// check; sections outside it are kept only when a renderer needs to draw
/// geometry extending beyond its block (beacon beams, gateways, outlines).
pub fn visible_block_entities_in_frustum<'a>(
&'a self,
fade_seconds: f32,
camera: DVec3,
frustum: Option<Frustum>,
keep_section: impl Fn(u32) -> bool + 'a,
keep_long_range: impl Fn(u32) -> bool + 'a,
) -> impl Iterator<Item = (&'a ([i32; 3], u32), bool)> + 'a {
let fade = fade_millis(fade_seconds);
let now = Instant::now();
let min_y = self.layout.min_y();
self.block_entities
.iter()
.filter(move |(key, _)| {
chunk_visibility(self.section_elapsed_ms(**key, now), fade)
>= CHUNK_VISIBILITY_THRESHOLD
})
.filter_map(move |(key, blocks)| {
let origin = DVec3::new(
f64::from(key.0 * 16 - 1),
f64::from(min_y + key.1 * 16 - 1),
f64::from(key.2 * 16 - 1),
) - camera;
let min = origin.as_vec3();
// The last block's expanded bound ends at section_origin + 17.
let max = min + Vec3::splat(18.0);
let distance_squared = Vec3::ZERO.max(min).min(max).length_squared();
let in_range = distance_squared <= 64.0 * 64.0
|| (distance_squared <= 256.0 * 256.0
&& blocks.iter().any(|(_, state)| keep_long_range(*state)));
if !in_range {
return None;
}
let (intersects, fully_inside) = frustum.map_or((true, true), |view| {
let fully_inside = view.contains(min, max);
(fully_inside || view.intersects(min, max), fully_inside)
});
(intersects || blocks.iter().any(|(_, state)| keep_section(*state)))
.then_some((blocks, fully_inside))
})
.flat_map(|(blocks, fully_inside)| {
blocks.iter().map(move |block| (block, fully_inside))
})
}
/// Milliseconds since `key` was uploaded; a section not in `section_uploaded` is old.
fn section_elapsed_ms(&self, key: SectionKey, now: std::time::Instant) -> u64 {
self.section_uploaded.get(&key).map_or(u64::MAX, |t| {
@@ -4583,9 +4824,27 @@ impl Renderer {
pub fn block_entity_revision(&self) -> u64 {
self.block_entity_revision
}
/// Both mesh/light and block-entity membership revisions for a section
/// key (`x`, section-y relative to the dimension's minimum, `z`).
pub fn block_entity_section_revision(
&self,
section: [i32; 3],
) -> Option<StaticSectionRevision> {
let key = (section[0], section[1], section[2]);
Some(StaticSectionRevision {
mesh: *self.block_entity_section_revisions.get(&key)?,
block_entities: *self.block_entity_membership_revisions.get(&key)?,
})
}
/// Block-entity cache work and upload volume from the last rendered frame.
pub fn static_block_entity_frame_stats(&self) -> StaticCacheFrameStats {
self.static_entity_frame_stats
}
/// Drops every section, for a level whose chunks hold `layout`.
pub fn clear_sections(&mut self, layout: crate::chunk::ColumnLayout) {
self.block_entities.clear();
self.block_entity_section_revisions.clear();
self.block_entity_membership_revisions.clear();
self.section_uploaded.clear();
self.newest_upload = None;
self.newest_lod_fade = None;
@@ -4759,6 +5018,8 @@ impl Renderer {
for sy in 0..self.layout.sections() as i32 {
let key = (x, sy, z);
block_entities |= self.block_entities.remove(&key).is_some();
self.block_entity_section_revisions.remove(&key);
self.block_entity_membership_revisions.remove(&key);
if self.light_blocks.remove(&key).is_some() {
self.light_revision += 1;
}
@@ -6723,6 +6984,125 @@ impl Renderer {
0,
);
}
let static_sections = frame.static_block_entities;
let mut static_bytes_uploaded = 0;
let catalog_revision = frame.static_block_entity_stats.snapshot_revision;
if catalog_revision != self.static_entity_catalog_revision {
self.static_entity_sections.retain(|section, _| {
static_sections
.binary_search_by_key(section, |snapshot| snapshot.section)
.is_ok()
});
for snapshot in static_sections {
let vertices = snapshot.vertices.len() as u64;
let gpu = self
.static_entity_sections
.entry(snapshot.section)
.or_insert_with(|| {
let capacity = vertices.next_power_of_two().max(4);
let origin_buffer =
self.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Static block-entity section origin"),
contents: bytemuck::bytes_of(&snapshot.origin),
usage: wgpu::BufferUsages::VERTEX,
});
static_bytes_uploaded += std::mem::size_of_val(&snapshot.origin) as u64;
GpuStaticEntitySection {
buffer: self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Static block-entity section vertices"),
size: capacity * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}),
origin_buffer,
origin: snapshot.origin,
capacity,
revision: 0,
vertices: 0,
}
});
if gpu.origin != snapshot.origin {
gpu.origin_buffer =
self.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Static block-entity section origin"),
contents: bytemuck::bytes_of(&snapshot.origin),
usage: wgpu::BufferUsages::VERTEX,
});
gpu.origin = snapshot.origin;
static_bytes_uploaded += std::mem::size_of_val(&snapshot.origin) as u64;
}
if gpu.revision == snapshot.revision {
continue;
}
if vertices > gpu.capacity {
gpu.capacity = vertices.next_power_of_two();
gpu.buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Static block-entity section vertices"),
size: gpu.capacity * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
}
self.uploads.buffer(
&mut encoder,
&gpu.buffer,
0,
bytemuck::cast_slice(&snapshot.vertices),
);
static_bytes_uploaded += vertices * std::mem::size_of::<Vertex>() as u64;
gpu.revision = snapshot.revision;
gpu.vertices = vertices as u32;
}
self.static_entity_catalog_revision = catalog_revision;
}
let static_entity_quads = frame
.visible_static_block_entity_sections
.iter()
.filter_map(|section| self.static_entity_sections.get(section))
.map(|section| section.vertices / 4)
.sum::<u32>();
let max_static_section_quads = frame
.visible_static_block_entity_sections
.iter()
.filter_map(|section| self.static_entity_sections.get(section))
.map(|section| section.vertices / 4)
.max()
.unwrap_or(0);
if max_static_section_quads > self.static_entity_index_quads {
self.static_entity_index_quads = max_static_section_quads.next_power_of_two();
self.static_entity_indices =
Self::quad_indices(&self.device, self.static_entity_index_quads);
}
if !frame.visible_static_block_entity_sections.is_empty()
&& self.static_entity_camera != Some(frame.camera)
{
let camera_floor = [
frame.camera.x.floor() as i32,
frame.camera.y.floor() as i32,
frame.camera.z.floor() as i32,
];
let transform = StaticEntityUniform {
camera_block: [camera_floor[0], camera_floor[1], camera_floor[2], 0],
camera_fraction: [
(frame.camera.x - f64::from(camera_floor[0])) as f32,
(frame.camera.y - f64::from(camera_floor[1])) as f32,
(frame.camera.z - f64::from(camera_floor[2])) as f32,
0.0,
],
};
self.uploads.buffer(
&mut encoder,
&self.static_entity_transform,
0,
bytemuck::bytes_of(&transform),
);
static_bytes_uploaded += std::mem::size_of::<StaticEntityUniform>() as u64;
self.static_entity_camera = Some(frame.camera);
}
self.static_entity_frame_stats = frame.static_block_entity_stats;
self.static_entity_frame_stats.static_bytes_uploaded = static_bytes_uploaded;
self.entity_outline.prepare(
&self.device,
&mut self.uploads,
@@ -7618,8 +7998,30 @@ impl Renderer {
if surface.is_some() {
drop(pass);
pass = plain_pass(&mut encoder);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
if static_entity_quads > 0 {
pass.set_pipeline(&self.static_entity_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_bind_group(1, &self.static_entity_group, &[]);
pass.set_index_buffer(
self.static_entity_indices.slice(..),
wgpu::IndexFormat::Uint32,
);
for section in frame.visible_static_block_entity_sections {
let Some(gpu) = self.static_entity_sections.get(section) else {
continue;
};
let quads = gpu.vertices / 4;
if quads == 0 {
continue;
}
pass.set_vertex_buffer(0, gpu.buffer.slice(..));
pass.set_vertex_buffer(1, gpu.origin_buffer.slice(..));
pass.draw_indexed(0..quads * 6, 0, 0..1);
}
pass.set_bind_group(0, &self.group, &[]);
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
if entity_quads > 0 {
pass.set_pipeline(&self.entity_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
@@ -7960,6 +8362,9 @@ impl Renderer {
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
if cloud_quads > 0 && !pack_draws_clouds {
self.mark_clouds(&mut pass, cloud_quads);
}
// HDR output's mark on what shines in the stages, as the classic
// pass marks it after drawing it: eyes, the dragon's eyes and rays,
// firework sparks and lightning.
@@ -8063,6 +8468,7 @@ impl Renderer {
pass.set_bind_group(1, &self.cloud_group, &[]);
pass.set_vertex_buffer(0, self.cloud_buffer.slice(..));
pass.draw_indexed(0..cloud_quads * 6, 0, 0..1);
self.mark_clouds(&mut pass, cloud_quads);
}
// LevelRenderer.executeClassicTransparency: weather after translucent
// terrain and clouds, so the water behind the rain is drawn first.
@@ -9217,6 +9623,8 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
#[cfg(test)]
mod tests {
use crate::gfx::static_entity_shader;
fn adapter(device_type: wgpu::DeviceType) -> wgpu::AdapterInfo {
wgpu::AdapterInfo {
name: "test adapter".into(),
@@ -9624,6 +10032,8 @@ mod tests {
} else if name == "oit_features.wgsl" {
let base = super::entity_shader() + include_str!("oit.wgsl");
sources.push((path, base + &text));
} else if name == "static_entity.wgsl" {
sources.push((path, static_entity_shader()));
} else if name == "oit_border.wgsl" {
let base = include_str!("border.wgsl").to_owned() + include_str!("oit.wgsl");
sources.push((path, base + &text));
@@ -645,6 +645,7 @@ mod tests {
include_str!("../hdr.wgsl"),
include_str!("../terrain.wgsl"),
include_str!("../hdr_stats.wgsl"),
include_str!("stars.wgsl"),
&entity,
] {
let module = naga::front::wgsl::parse_str(source).unwrap();
@@ -23,6 +23,8 @@ pub struct NativePack {
pub shadow: Option<NativeShadow>,
/// `[[particle_lights]]`.
pub particle_lights: Vec<horizon_shader_api::manifest::ParticleLight>,
/// `[[lights]]`, for the colour of held and dropped items' light.
pub light_colors: Vec<horizon_shader_api::manifest::LightColor>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<PassTiming>,
}
@@ -136,6 +138,7 @@ impl NativePack {
}
let pack = Self {
particle_lights: report.manifest.particle_lights.clone(),
light_colors: report.manifest.lights.clone(),
frame,
sky,
material,
@@ -478,6 +478,7 @@ struct ShadowCacheKey {
}
pub struct PackPost {
setup_pending: bool,
pub notes: Vec<String>,
passes: Vec<Pass>,
/// Shadow cache for temporal reprojection.
@@ -2286,6 +2287,7 @@ impl PackPost {
let flat_normal = [128u8, 128, 255, 255];
Self {
notes,
setup_pending: true,
passes,
terrain,
water,
@@ -3338,6 +3340,7 @@ impl PackPost {
if self.targets.as_ref().is_some_and(|t| t.size == size) {
return;
}
self.setup_pending = true;
let sizes: [[u32; 2]; BUFFERS] =
std::array::from_fn(|i| self.sizes[i].map_or(size, |s| s.resolve(size)));
let full_chain = |size: [u32; 2]| 32 - size[0].max(size[1]).max(1).leading_zeros();
@@ -3771,7 +3774,6 @@ impl PackPost {
self.gpu.clear,
&mut self.zeros,
);
self.run_shadow(device, queue, encoder, terrain, frame);
let Some(targets) = &self.targets else { return };
let main = |m: u16, i: usize| usize::from(m & 1 << i != 0);
let view = |m: u16, i: usize, alt: bool| {
@@ -3901,8 +3903,17 @@ impl PackPost {
.any(|(p, late)| p.is_some() && late)
});
let mut water_drawn = self.water.is_none() && self.weather.is_none() && !late_entities;
let mut shadow_drawn = false;
for pass in &mut passes {
if !terrain_drawn && pass.group != Group::Prepare {
if pass.group == Group::Setup && !self.setup_pending {
continue;
}
let early = matches!(pass.group, Group::Setup | Group::Begin);
if !early && !shadow_drawn {
self.run_shadow(device, queue, encoder, terrain, frame);
shadow_drawn = true;
}
if !terrain_drawn && !early && pass.group != Group::Prepare {
terrain_drawn = true;
self.draw_opaque(device, queue, encoder, size, frame, terrain);
}
@@ -3981,6 +3992,10 @@ impl PackPost {
self.mips_valid &= !pass.written;
}
self.passes = passes;
self.setup_pending = false;
if !shadow_drawn {
self.run_shadow(device, queue, encoder, terrain, frame);
}
if !terrain_drawn {
self.draw_opaque(device, queue, encoder, size, frame, terrain);
}
@@ -86,6 +86,8 @@ pub(super) fn upload(
fn stage(group: Group) -> &'static str {
match group {
Group::Gbuffers | Group::Translucent | Group::Shadow => "gbuffers",
Group::Setup => "setup",
Group::Begin => "begin",
Group::Prepare => "prepare",
Group::Deferred => "deferred",
Group::Composite | Group::Final => "composite",
@@ -964,6 +964,9 @@ mod initial_history_tests {
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
colored_lights: &[],
emissive_layers: &[],
@@ -132,14 +132,15 @@ impl Stars {
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
// RenderPipelines.STARS: the celestial blend (src alpha, one).
// RenderPipelines.STARS: the celestial blend (src alpha, one), with the
// colour premultiplied in fs_stars, so its alpha can carry HDR's mark.
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent::OVER,
alpha: wgpu::BlendComponent::REPLACE,
}),
write_mask: wgpu::ColorWrites::ALL,
})],
@@ -11,6 +11,10 @@ struct Stars {
return stars.clip_from_sky * vec4(position, 1.0);
}
// The colour premultiplied, as the celestial blend (src alpha, one) would: the pipeline adds it.
// The alpha is HDR output's mark (hdr.wgsl): stars are points of light, at the bright tier,
// fully lifted wherever they show at all. Nothing else reads the frame's alpha.
@fragment fn fs_stars() -> @location(0) vec4<f32> {
return stars.color;
let b = stars.color.a;
return vec4(stars.color.rgb * b, 0.175);
}
+60 -1
View File
@@ -551,6 +551,20 @@ pub struct Graphics {
/// vanilla's fancy leaves. Ignored with a resource pack that may replace
/// the vanilla leaf models ([`Graphics::leaf_culling_effective`]).
pub leaf_culling: LeafCullingMode,
/// Cache settled shulker-box models.
pub cache_shulker_boxes: bool,
/// Cache sign-text geometry.
pub cache_signs: bool,
/// Cache decorated pots outside their wobble animation.
pub cache_decorated_pots: bool,
/// Cache bells while they are not ringing.
pub cache_bells: bool,
/// Cache closed chests, including copper variants.
pub cache_chests: bool,
/// Cache banner poles while leaving their flags animated.
pub cache_banners: bool,
/// Cache copper-golem statue models.
pub cache_copper_golem_statues: bool,
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
pub chunk_updates: u32,
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
@@ -630,6 +644,14 @@ impl Default for Graphics {
cave_culling: true,
entity_culling: EntityCullingMode::Off,
leaf_culling: LeafCullingMode::Off,
cache_shulker_boxes: false,
// Sign text has used its renderer cache independently of the new model caches.
cache_signs: true,
cache_decorated_pots: false,
cache_bells: false,
cache_chests: false,
cache_banners: false,
cache_copper_golem_statues: false,
chunk_updates: 0,
upload_budget_kib: 0,
texture_budget_mib: 0,
@@ -701,6 +723,29 @@ impl Graphics {
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
"blockEntityCache" => {
let enabled = flag(false);
g.cache_shulker_boxes = enabled;
g.cache_signs = enabled;
g.cache_decorated_pots = enabled;
g.cache_bells = enabled;
g.cache_chests = enabled;
g.cache_banners = enabled;
g.cache_copper_golem_statues = enabled;
}
"blockEntityCacheShulkerBoxes" => {
g.cache_shulker_boxes = flag(g.cache_shulker_boxes)
}
"blockEntityCacheSigns" => g.cache_signs = flag(g.cache_signs),
"blockEntityCacheDecoratedPots" => {
g.cache_decorated_pots = flag(g.cache_decorated_pots)
}
"blockEntityCacheBells" => g.cache_bells = flag(g.cache_bells),
"blockEntityCacheChests" => g.cache_chests = flag(g.cache_chests),
"blockEntityCacheBanners" => g.cache_banners = flag(g.cache_banners),
"blockEntityCacheCopperGolemStatues" => {
g.cache_copper_golem_statues = flag(g.cache_copper_golem_statues)
}
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
"textureBudgetMiB" => {
@@ -767,13 +812,20 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
bool_text(self.cave_culling),
self.entity_culling.name(),
self.leaf_culling.name(),
bool_text(self.cache_shulker_boxes),
bool_text(self.cache_signs),
bool_text(self.cache_decorated_pots),
bool_text(self.cache_bells),
bool_text(self.cache_chests),
bool_text(self.cache_banners),
bool_text(self.cache_copper_golem_statues),
self.chunk_updates,
self.upload_budget_kib,
self.texture_budget_mib,
@@ -940,6 +992,13 @@ mod tests {
cave_culling: false,
entity_culling: EntityCullingMode::Synchronous,
leaf_culling: LeafCullingMode::SolidAggressive,
cache_shulker_boxes: true,
cache_signs: true,
cache_decorated_pots: true,
cache_bells: true,
cache_chests: true,
cache_banners: true,
cache_copper_golem_statues: true,
chunk_updates: 12,
upload_budget_kib: 4096,
texture_budget_mib: 1536,
@@ -31,6 +31,13 @@ pub enum H {
CaveCulling,
EntityCulling,
LeafCulling,
CacheShulkerBoxes,
CacheSigns,
CacheDecoratedPots,
CacheBells,
CacheChests,
CacheBanners,
CacheCopperGolemStatues,
ChunkUpdates,
UploadBudget,
Sky,
@@ -207,13 +214,49 @@ pub fn rows(level: Level) -> Vec<Row> {
h(
H::EntityCulling,
None,
"Hides models behind opaque blocks. Async checks off-thread; entities tick.",
"Hides entities behind opaque blocks.",
),
h(
H::LeafCulling,
Saves,
"Draws or drops leaf faces inside canopies. Off keeps vanilla's fancy leaves.",
),
Row::Header("Block Entities"),
h(
H::CacheShulkerBoxes,
Saves,
"Reuses settled boxes; lid movement stays animated.",
),
h(
H::CacheSigns,
Saves,
"Reuses sign text until text, light, or font changes.",
),
h(
H::CacheDecoratedPots,
Saves,
"Reuses still pots; break wobble and sherd changes redraw.",
),
h(
H::CacheBells,
Saves,
"Reuses still bells; ringing stays animated.",
),
h(
H::CacheChests,
Saves,
"Reuses closed chests; moving lids stay animated.",
),
h(
H::CacheBanners,
Saves,
"Reuses poles; flags and pattern layers still update.",
),
h(
H::CacheCopperGolemStatues,
Saves,
"Reuses statue geometry until its block state changes.",
),
Row::Header("Quality"),
v(
Opt::SmoothLighting,
@@ -470,6 +513,11 @@ fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
"Set up for your display. Leave on Auto unless it looks wrong."
}
(Item::Horizon(H::EntityCulling), _) => match s.horizon.entity_culling {
EntityCullingMode::Off => "No entity occlusion culling.",
EntityCullingMode::Synchronous => "CPU occlusion test runs this frame.",
EntityCullingMode::Asynchronous => "Occlusion checks run on a worker thread.",
},
(Item::Horizon(H::LeafCulling), _) => match s.horizon.leaf_culling {
LeafCullingMode::Off => "Every leaf face. Vanilla's fancy leaves.",
LeafCullingMode::Solid => {
@@ -551,6 +599,15 @@ impl H {
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
H::CacheShulkerBoxes => labelled("Shulker Boxes", on_off(g.cache_shulker_boxes)),
H::CacheSigns => labelled("Signs", on_off(g.cache_signs)),
H::CacheDecoratedPots => labelled("Decorated Pots", on_off(g.cache_decorated_pots)),
H::CacheBells => labelled("Bells", on_off(g.cache_bells)),
H::CacheChests => labelled("Chests", on_off(g.cache_chests)),
H::CacheBanners => labelled("Banners", on_off(g.cache_banners)),
H::CacheCopperGolemStatues => {
labelled("Copper Golem Statues", on_off(g.cache_copper_golem_statues))
}
H::ChunkUpdates => match g.chunk_updates {
0 => labelled("Chunk Updates", "Unlimited"),
n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])),
@@ -693,6 +750,15 @@ impl H {
H::LeafCulling => {
g.leaf_culling = step(&LeafCullingMode::ALL, g.leaf_culling, back);
}
H::CacheShulkerBoxes => g.cache_shulker_boxes = !g.cache_shulker_boxes,
H::CacheSigns => g.cache_signs = !g.cache_signs,
H::CacheDecoratedPots => g.cache_decorated_pots = !g.cache_decorated_pots,
H::CacheBells => g.cache_bells = !g.cache_bells,
H::CacheChests => g.cache_chests = !g.cache_chests,
H::CacheBanners => g.cache_banners = !g.cache_banners,
H::CacheCopperGolemStatues => {
g.cache_copper_golem_statues = !g.cache_copper_golem_statues
}
H::Sky => g.sky = !g.sky,
H::Fog => g.fog = !g.fog,
H::Weather => g.weather = !g.weather,
@@ -790,6 +856,13 @@ impl Item {
let g = &s.horizon;
Some(match self {
Item::Horizon(H::CaveCulling) => g.cave_culling,
Item::Horizon(H::CacheShulkerBoxes) => g.cache_shulker_boxes,
Item::Horizon(H::CacheSigns) => g.cache_signs,
Item::Horizon(H::CacheDecoratedPots) => g.cache_decorated_pots,
Item::Horizon(H::CacheBells) => g.cache_bells,
Item::Horizon(H::CacheChests) => g.cache_chests,
Item::Horizon(H::CacheBanners) => g.cache_banners,
Item::Horizon(H::CacheCopperGolemStatues) => g.cache_copper_golem_statues,
Item::Horizon(H::Sky) => g.sky,
Item::Horizon(H::Fog) => g.fog,
Item::Horizon(H::Weather) => g.weather,
@@ -1907,8 +1980,8 @@ mod tests {
item.cycle(&mut s, true, [854, 480]);
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Hollow);
// The three culling settings share one group, below Rendering's chunk
// updates and above Quality.
// The culling group is followed by the separate block-entity cache
// group, then Quality.
let rows = rows(Level::Advanced);
let culling = rows
.iter()
@@ -1928,6 +2001,27 @@ mod tests {
));
assert!(matches!(
rows.get(culling + 4),
Some(Row::Header("Block Entities"))
));
for (offset, cache) in [
H::CacheShulkerBoxes,
H::CacheSigns,
H::CacheDecoratedPots,
H::CacheBells,
H::CacheChests,
H::CacheBanners,
H::CacheCopperGolemStatues,
]
.into_iter()
.enumerate()
{
assert!(matches!(
rows.get(culling + 5 + offset),
Some(Row::Setting(setting)) if setting.item == Item::Horizon(cache)
));
}
assert!(matches!(
rows.get(culling + 12),
Some(Row::Header("Quality"))
));
+9
View File
@@ -59,6 +59,9 @@ impl TextureFlags {
}
}
pub struct HudAtlas {
/// Identity of this packed atlas, used to invalidate cached model UV data
/// even when a resource reload places sprites at the same coordinates.
pub revision: u64,
/// RGBA8, `width` by `height`, as built: empty once `pack_pixels` has
/// compressed them (read them through `pixels()`).
pub pixels: Vec<u8>,
@@ -526,6 +529,10 @@ fn galactic_widths(
widths
}
impl HudAtlas {
fn next_revision() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
/// The atlas's pixels: as built, or uncompressed again after
/// `pack_pixels` (a renderer being rebuilt).
pub fn pixels(&self) -> std::borrow::Cow<'_, [u8]> {
@@ -700,6 +707,7 @@ impl HudAtlas {
);
let white = sprites["horizon/white"];
Self {
revision: Self::next_revision(),
font: crate::font::Font::load(
Path::new("horizon-boot"),
&sprites,
@@ -1143,6 +1151,7 @@ impl HudAtlas {
white,
);
Ok(Self {
revision: Self::next_revision(),
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
pixels,
packed: None,
+1
View File
@@ -38,6 +38,7 @@ pub mod extras;
pub mod fast_hash;
pub mod fog;
pub mod font;
pub mod fusion;
pub mod gfx;
pub mod graphics;
pub mod graphics_style;
+34 -11
View File
@@ -1607,12 +1607,38 @@ impl Mesher<'_> {
([lighting::gray(directional); 4], [coords; 4], [1.0f32; 4])
};
let tint = Self::tint(state, q.tint_index, tints, pos);
// OptiFine connected textures: another sprite, or one per quarter, and the overlays
// drawn over it (a few per face at most; more are dropped).
let connected = &self.render.connected;
// Fusion's and then OptiFine's connected textures: another sprite, or one per quarter,
// and the overlays drawn over it (a few per face at most; more are dropped).
let (fusion, connected) = (&self.render.fusion, &self.render.connected);
let mut overlays = [None; 8];
let mut count = 0;
let pick = if connected.is_empty() {
let mut push = |overlay| {
if let Some(slot) = overlays.get_mut(count) {
*slot = Some(overlay);
count += 1;
}
};
let neighbour =
|[dx, dy, dz]: [i32; 3]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)];
let fused = face.fusion.and_then(|rule| {
fusion.pick(
rule,
id,
CtmFace::ALL[q.facing as usize],
q.tint_index,
neighbour,
|s| {
self.tables
.info
.get(s as usize)
.is_some_and(|i| i.opaque_cube)
},
&mut push,
)
});
let pick = if let Some(pick) = fused {
pick
} else if connected.is_empty() {
Pick::Keep
} else {
let tints = &*tints;
@@ -1621,7 +1647,7 @@ impl Mesher<'_> {
face.layer as u16,
CtmFace::ALL[q.facing as usize],
[world.x, world.y, world.z],
|[dx, dy, dz]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)],
neighbour,
|| Some(tints.biome(pos[0], pos[1], pos[2])),
// A pane's tiles are drawn with the cube's UVs (below), so that is its texture.
|| {
@@ -1631,12 +1657,7 @@ impl Mesher<'_> {
CtmAxes::texture(&q.positions, &q.uv)
}
},
|overlay| {
if let Some(slot) = overlays.get_mut(count) {
*slot = Some(overlay);
count += 1;
}
},
&mut push,
)
};
let vertices = |tint: Option<u32>, uv: [[f32; 2]; 4]| {
@@ -1712,6 +1733,7 @@ impl Mesher<'_> {
}
}
}
Pick::Nothing => {}
}
// Overlays: the face again, after it (equal depth passes), tinted as their rule says.
// `layer=cutout` is an alpha cutout whatever the tile's alpha, but over a translucent
@@ -1724,6 +1746,7 @@ impl Mesher<'_> {
Pick::Keep => self.translucent(own),
Pick::Tile(t) => self.translucent(tile(t)),
Pick::Quarters(tiles) => tiles.into_iter().any(|t| self.translucent(tile(t))),
Pick::Nothing => false,
};
// Overlay tiles face the standard way too (Continuity: "overlay textures always have the
// standard orientation"), whatever turns the model's own UVs (dirt's and sand's random
@@ -522,6 +522,7 @@ mod tests {
let facts = BlockFacts {
states: Vec::new(),
air: horizon_world::registry::BlockStateId(0),
woods: Default::default(),
};
let mut world = world_with(
(-2..=2)
@@ -609,6 +610,7 @@ mod tests {
let facts = BlockFacts {
states: Vec::new(),
air: horizon_world::registry::BlockStateId(0),
woods: Default::default(),
};
// Grass everywhere, one stone block 10 blocks north of the player.
let mut world = world_with(
@@ -33,6 +33,9 @@ pub struct BakedFace {
pub opaque: bool,
/// Fractional alpha: drawn in the sorted translucent pass.
pub translucent: bool,
/// The Fusion connecting texture rule its sprite joins by ([`crate::fusion`]), plus one:
/// two bytes, within the padding every face already has.
pub fusion: Option<std::num::NonZeroU16>,
}
pub struct BakedModel {
pub ambient_occlusion: bool,
@@ -90,6 +93,7 @@ fn bake_resolved(
opaque: sprites.region_opaque(layer, &quad.uv),
quad,
layer,
fusion: None,
})
})
.collect::<Result<_>>()?;
@@ -343,6 +347,12 @@ impl StateModels {
}
Ok(())
}
/// Every baked model with the variant it was baked from, to change its faces.
pub fn models_mut(&mut self) -> impl Iterator<Item = (&Variant, &mut BakedModel)> {
self.baked
.iter_mut()
.map(|(variant, model)| (&*variant, model))
}
/// A model baked by [`Self::bake_standalone`], by id (`mymod:block/shaft_half`).
pub fn standalone(&self, name: &str) -> Option<&BakedModel> {
let &i = self.standalone.get(name)?;
@@ -119,6 +119,7 @@ mod tests {
layer: 7,
opaque: true,
translucent: false,
fusion: None,
})
.collect();
let Some(styles) = cube_styles(&faces) else {
@@ -153,6 +154,7 @@ mod tests {
layer: 0,
opaque: true,
translucent: false,
fusion: None,
})
.collect();
faces[0].opaque = false;
+12 -4
View File
@@ -3423,6 +3423,9 @@ impl OptionsUi {
self.narrate_list(&widgets, list_key, (top, bottom), w, cursor);
}
let mut tooltip = None;
// The widget a keyboard-focused tooltip belongs to: `MenuTooltipPositioner`
// keeps it against that widget, not at the pointer.
let mut tooltip_rect: Option<[f32; 4]> = None;
for (wd_idx, wd) in widgets.iter().enumerate() {
if !visible(wd) {
continue;
@@ -3432,9 +3435,9 @@ impl OptionsUi {
.as_ref()
.is_some_and(|fid| matches_focus(fid, wd_idx, wd));
let start = batch.vertices.len();
let hover = (ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
&& (!wd.list || cursor.1 >= top && cursor.1 < bottom))
|| is_focused;
let pointer_over = ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
&& (!wd.list || cursor.1 >= top && cursor.1 < bottom);
let hover = pointer_over || is_focused;
// A pack's option: its label within the widget (the name shortened), and on hover
// the whole label when it was, then what the pack says of it.
let shader = s.shader_options.as_ref();
@@ -3478,6 +3481,7 @@ impl OptionsUi {
.collect();
if !lines.is_empty() {
tooltip = Some(lines);
tooltip_rect = (!pointer_over).then_some([wd.x, wd.y, wd.w, wd.h]);
}
}
if hover && let Some(tip) = tooltip_text(s, wd) {
@@ -3492,6 +3496,7 @@ impl OptionsUi {
.collect::<Vec<_>>(),
_ => atlas.font.split(&tip, 170.0),
});
tooltip_rect = (!pointer_over).then_some([wd.x, wd.y, wd.w, wd.h]);
}
match &wd.kind {
WKind::Button { label, active, .. } => {
@@ -3663,7 +3668,10 @@ impl OptionsUi {
self.settings_search = search;
}
if let Some(lines) = tooltip {
ui::tooltip(batch, atlas, &lines, cursor);
match tooltip_rect {
Some(rect) => ui::tooltip_menu(batch, atlas, &lines, cursor, rect),
None => ui::tooltip(batch, atlas, &lines, cursor),
}
}
}
/// Mouse press/drag. Returns events for the game.
+29 -10
View File
@@ -222,10 +222,14 @@ fn component_text(tag: &Tag) -> String {
_ => String::new(),
}
}
/// Whether a block name is a sign (standing, wall, hanging or wall hanging).
/// Whether a vanilla block's name is a sign (standing, wall, hanging or wall
/// hanging). A mod's sign goes by its template's class, whatever its name; a
/// mod's `road_sign` of stone is no sign.
pub fn sign_kind(name: &str) -> Option<bool> {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
if name == "hanging_sign" || name.ends_with("_hanging_sign") {
if name.contains(':') {
None
} else if name == "hanging_sign" || name.ends_with("_hanging_sign") {
Some(true)
} else if name == "sign" || name.ends_with("_sign") {
Some(false)
@@ -233,15 +237,17 @@ pub fn sign_kind(name: &str) -> Option<bool> {
None
}
}
/// Wood type of a sign block (`SignBlock.getWoodType`), for the editor texture.
pub fn wood_type(name: &str) -> &str {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
for suffix in ["_wall_hanging_sign", "_hanging_sign", "_wall_sign", "_sign"] {
if let Some(wood) = name.strip_suffix(suffix) {
return wood;
/// The sprite of a wood type's editor background, `folder` being `signs` or
/// `hanging_signs`: `textures/gui/<folder>/<wood>.png` (`SignEditScreen`), in
/// a mod's namespace when the wood names one.
pub fn editor_sprite(folder: &str, wood: &str) -> String {
match wood.split_once(':') {
Some((namespace, path)) if namespace != "minecraft" => {
format!("{namespace}:gui/{folder}/{path}")
}
Some((_, path)) => format!("gui/{folder}/{path}"),
None => format!("gui/{folder}/{wood}"),
}
"oak"
}
#[cfg(test)]
@@ -262,12 +268,25 @@ mod tests {
assert_eq!(decoded, data);
}
#[test]
fn a_mods_sign_like_name_does_not_make_it_a_sign() {
assert_eq!(sign_kind("ruby:road_sign"), None);
assert!(crate::containers::BlockEntity::for_block("ruby:road_sign").is_none());
}
#[test]
fn names_and_colors() {
assert_eq!(sign_kind("minecraft:oak_wall_hanging_sign"), Some(true));
assert_eq!(sign_kind("minecraft:sign"), Some(false));
assert_eq!(sign_kind("minecraft:spruce_wall_sign"), Some(false));
assert_eq!(sign_kind("minecraft:stone"), None);
assert_eq!(wood_type("minecraft:dark_oak_wall_sign"), "dark_oak");
assert_eq!(editor_sprite("signs", "dark_oak"), "gui/signs/dark_oak");
assert_eq!(
editor_sprite("signs", "minecraft:cherry"),
"gui/signs/cherry"
);
assert_eq!(
editor_sprite("hanging_signs", "ruby:elm"),
"ruby:gui/hanging_signs/elm"
);
assert_eq!(SignText::default().dark_color(), 0);
let red = SignText {
color: 14,
+24
View File
@@ -255,6 +255,30 @@ pub mod startup {
}
}
/// Whether a world's view is still filling (between [`arm`] and the
/// `world.view_full` mark).
#[inline]
pub fn filling() -> bool {
on() && FILLING.load(Ordering::Relaxed)
}
/// Values at this moment, as a counter track named `name` (Perfetto draws
/// each value as a line): what the view waits on while it fills.
pub fn counters(name: &'static str, values: &[(&'static str, usize)]) {
if !on() {
return;
}
let args: Vec<String> = values
.iter()
.map(|(k, v)| format!(r#""{k}":{v}"#))
.collect();
write(format_args!(
r#""ph":"C","name":"{name}","ts":{},"args":{{{}}}"#,
micros(Instant::now()),
args.join(",")
));
}
/// Called each frame in a world: marks `world.view_full` the first frame
/// after [`arm`] where nothing waits to load, generate, light, mesh or
/// upload and the resident columns cover perf_bench's share of the view
@@ -0,0 +1,21 @@
// Static block-entity meshes use section-local positions and the shared
// entity vertex/fragment helpers. The shader subtracts integer block origins
// before converting to f32; CPU supplies the camera's fractional block offset.
struct StaticEntityUniform {
camera_block: vec4<i32>,
camera_fraction: vec4<f32>,
}
@group(1) @binding(0) var<uniform> static_entity: StaticEntityUniform;
@vertex fn vs_static_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>,
@location(7) section_origin: vec3<i32>) -> Out {
let block_delta = section_origin - static_entity.camera_block.xyz;
let camera_relative = vec3<f32>(block_delta) + position - static_entity.camera_fraction.xyz;
var out = entity_vertex(camera_relative,
uv, overlay & 0xffffu, color, light, normal);
out.glint_uv = glint_uv;
out.tier = (overlay >> 16u) & 0xffu;
return out;
}
+114 -9
View File
@@ -868,7 +868,77 @@ pub fn tooltip(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], cursor: (
if lines.is_empty() {
return;
}
crate::widgets::frame_tooltip(lines);
let (w, h) = tooltip_size(atlas, lines);
let mut x = cursor.0 + 12.0;
let mut y = cursor.1 - 12.0;
if x + w > batch.width {
x = (cursor.0 - 24.0 - w).max(4.0);
}
if y + h + 3.0 > batch.height {
y = batch.height - h - 3.0;
}
tooltip_box(batch, atlas, lines, x, y, w, h);
}
/// `WidgetTooltipHolder.createTooltipPositioner`'s focused branch
/// (`MenuTooltipPositioner`): a widget with the keyboard focus keeps the
/// pointer where it is, and its tooltip stays against the widget at `rect`.
pub fn tooltip_menu(
batch: &mut HudBatch,
atlas: &HudAtlas,
lines: &[Text],
cursor: (f32, f32),
rect: [f32; 4],
) {
if lines.is_empty() {
return;
}
let (w, h) = tooltip_size(atlas, lines);
let (x, y) = menu_tooltip_position(
(batch.width as i32, batch.height as i32),
(cursor.0 as i32, cursor.1 as i32),
rect.map(|v| v as i32),
(w.ceil() as i32, h.ceil() as i32),
);
tooltip_box(batch, atlas, lines, x as f32, y as f32, w, h);
}
/// `MenuTooltipPositioner.positionTooltip`: the tooltip's top left with the
/// pointer at `cursor`, the widget it belongs to at `rect`, and the screen.
/// `MARGIN` 5 and `MOUSE_OFFSET_X` 12 on the horizontal, and the
/// `MAX_OVERLAP_WITH_WIDGET` 3 and `MAX_DISTANCE_TO_WIDGET` 5 of the vertical
/// offset from the widget's top or bottom edge.
pub fn menu_tooltip_position(
screen: (i32, i32),
cursor: (i32, i32),
rect: [i32; 4],
size: (i32, i32),
) -> (i32, i32) {
let [_, y, _, height] = rect;
let height = height.max(1);
let mut pos = (cursor.0 + 12, cursor.1);
if pos.0 + size.0 > screen.0 - 5 {
pos.0 = (cursor.0 - 12 - size.0).max(9);
}
pos.1 += 3;
// `bottom() + 3 + getOffset(0, 0, height)` is the widget's edge, `height`
// further down; below that the tooltip goes under it, else over it.
let below = size.1 + 6;
if y + 2 * height + below <= screen.1 - 5 {
pos.1 += widget_offset(pos.1, y, height);
} else {
pos.1 -= below + widget_offset(pos.1, y + height, height);
}
pos
}
/// `MenuTooltipPositioner.getOffset`: how far the tooltip keeps from the
/// widget's top (or bottom) edge, from `MAX_DISTANCE_TO_WIDGET` 5 to
/// `MAX_OVERLAP_WITH_WIDGET` 3 as the pointer nears the edge.
/// The distance over the height is an int division in vanilla, so the lerp
/// is `height - 3` until the pointer is a whole height away, then 5.
fn widget_offset(at: i32, edge: i32, height: i32) -> i32 {
let d = ((at - edge).unsigned_abs().min(height as u32) / height as u32) as f32;
((height as f32 - 3.0) + d * (5.0 - (height as f32 - 3.0))).round() as i32
}
fn tooltip_size(atlas: &HudAtlas, lines: &[Text]) -> (f32, f32) {
let w = lines
.iter()
.map(|l| atlas.font.width_text(l))
@@ -878,14 +948,18 @@ pub fn tooltip(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], cursor: (
} else {
10.0 * lines.len() as f32
};
let mut x = cursor.0 + 12.0;
let mut y = cursor.1 - 12.0;
if x + w > batch.width {
x = (cursor.0 - 24.0 - w).max(4.0);
}
if y + h + 3.0 > batch.height {
y = batch.height - h - 3.0;
}
(w, h)
}
fn tooltip_box(
batch: &mut HudBatch,
atlas: &HudAtlas,
lines: &[Text],
x: f32,
y: f32,
w: f32,
h: f32,
) {
crate::widgets::frame_tooltip(lines);
nine_slice(
batch,
atlas,
@@ -2086,4 +2160,35 @@ mod tests {
assert!(!delete_words(&mut v, 1));
assert_eq!(v, "stone");
}
/// `MenuTooltipPositioner.positionTooltip`: the pointer's 12 px offset,
/// the 5 px screen margin, and the 3 to 5 px the tooltip keeps from the
/// widget's top or bottom edge.
#[test]
fn a_menu_tooltip_stays_by_its_widget() {
let screen = (427, 240);
let widget = [100, 100, 150, 20];
let size = (80, 10);
// Pointer at the widget's top edge: the tooltip drops 3 px, then
// `getOffset`'s int division gives `height - 3` = 17 more.
assert_eq!(
menu_tooltip_position(screen, (100, 100), widget, size),
(112, 120)
);
// Near the right edge its left side mirrors to the pointer's left.
assert_eq!(
menu_tooltip_position(screen, (400, 100), widget, size),
(308, 120)
);
// A whole widget height from the edge, the offset is 5.
assert_eq!(
menu_tooltip_position(screen, (100, 117), widget, size),
(112, 125)
);
// A widget too close to the bottom: the tooltip goes above it,
// 203 - (10 + 6) - 17.
assert_eq!(
menu_tooltip_position(screen, (100, 200), [100, 200, 150, 20], size),
(112, 170)
);
}
}
@@ -166,6 +166,10 @@
"How far mobs, crops and machines keep working. CPU time.": "Wie weit Kreaturen, Pflanzen und Maschinen weiterarbeiten. Kostet CPU-Zeit.",
"How far away mobs and items are drawn, as a share of normal.": "Zeichenweite für Kreaturen und Gegenstände, anteilig zur Norm.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Überspringt Gelände hinter geschlossenen Abschnitten. Aus kostet viel.",
"Hides entities behind opaque blocks.": "Versteckt Entitäten hinter undurchsichtigen Blöcken.",
"No entity occlusion culling.": "Keine Verdeckungserkennung für Entitäten.",
"CPU occlusion test runs this frame.": "Die CPU prüft die Verdeckung in diesem Frame.",
"Occlusion checks run on a worker thread.": "Verdeckungsprüfungen laufen in einem Worker-Thread.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Mesh-Abschnitte pro Bild. Weniger: flüssigeres Laden.",
"Soft shadows in corners. Costs chunk building time only.": "Weiche Schatten in Ecken. Kostet nur Zeit beim Chunk-Aufbau.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Kleinere Textur-Kopien für fernes Gelände: weniger Flimmern.",
@@ -170,6 +170,10 @@
"How far mobs, crops and machines keep working. CPU time.": "How far mobs, crops and machines keep working. CPU time.",
"How far away mobs and items are drawn, as a share of normal.": "How far away mobs and items are drawn, as a share of normal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Skips terrain hidden behind closed sections. Off costs a lot.",
"Hides entities behind opaque blocks.": "Hides entities behind opaque blocks.",
"No entity occlusion culling.": "No entity occlusion culling.",
"CPU occlusion test runs this frame.": "CPU occlusion test runs this frame.",
"Occlusion checks run on a worker thread.": "Occlusion checks run on a worker thread.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Meshed sections uploaded per frame. Lower is smoother while loading.",
"Soft shadows in corners. Costs chunk building time only.": "Soft shadows in corners. Costs chunk building time only.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Smaller copies of block textures for distant terrain: less shimmer.",
@@ -357,6 +361,21 @@
"horizon.splitscreen.setup.help.unchanged": "Nothing changed yet.",
"Culling": "Culling",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
"Block Entities": "Block Entities",
"Shulker Boxes": "Shulker Boxes",
"Reuses settled boxes; lid movement stays animated.": "Reuses settled boxes; lid movement stays animated.",
"Signs": "Signs",
"Reuses sign text until text, light, or font changes.": "Reuses sign text until text, light, or font changes.",
"Decorated Pots": "Decorated Pots",
"Reuses still pots; break wobble and sherd changes redraw.": "Reuses still pots; break wobble and sherd changes redraw.",
"Bells": "Bells",
"Reuses still bells; ringing stays animated.": "Reuses still bells; ringing stays animated.",
"Chests": "Chests",
"Reuses closed chests; moving lids stay animated.": "Reuses closed chests; moving lids stay animated.",
"Banners": "Banners",
"Reuses poles; flags and pattern layers still update.": "Reuses poles; flags and pattern layers still update.",
"Copper Golem Statues": "Copper Golem Statues",
"Reuses statue geometry until its block state changes.": "Reuses statue geometry until its block state changes.",
"Leaf Culling": "Leaf Culling",
"Every leaf face. Vanilla's fancy leaves.": "Every leaf face. Vanilla's fancy leaves.",
"Distant Plant Dither": "Distant Plant Dither",
@@ -158,6 +158,10 @@
"How far mobs, crops and machines keep working. CPU time.": "Hasta dónde siguen funcionando mobs, cultivos y máquinas. Tiempo de CPU.",
"How far away mobs and items are drawn, as a share of normal.": "A qué distancia se dibujan mobs y objetos, como proporción de lo normal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Omite el terreno oculto tras secciones cerradas. Desactivarlo cuesta mucho.",
"Hides entities behind opaque blocks.": "Oculta entidades tras bloques opacos.",
"No entity occlusion culling.": "Sin oclusión de entidades.",
"CPU occlusion test runs this frame.": "La CPU comprueba la oclusión en este fotograma.",
"Occlusion checks run on a worker thread.": "Las comprobaciones de oclusión se ejecutan en un hilo de trabajo.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Secciones malladas subidas por fotograma. Menos es más fluido durante la carga.",
"Soft shadows in corners. Costs chunk building time only.": "Sombras suaves en las esquinas. Solo cuesta tiempo de construcción de chunks.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Copias más pequeñas de las texturas de bloques para el terreno lejano: menos parpadeo.",
@@ -166,6 +166,10 @@
"How far mobs, crops and machines keep working. CPU time.": "Portée d'activité des créatures, cultures, machines. Coût : CPU.",
"How far away mobs and items are drawn, as a share of normal.": "Distance des créatures et objets, en part de la normale.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Ignore le terrain caché. Désactiver coûte cher.",
"Hides entities behind opaque blocks.": "Masque les entités derrière les blocs opaques.",
"No entity occlusion culling.": "Aucun masquage des entités occultées.",
"CPU occlusion test runs this frame.": "Le CPU teste l’occlusion sur cette image.",
"Occlusion checks run on a worker thread.": "Les tests d’occlusion s’exécutent sur un thread de travail.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Sections maillées par image. Moins : chargement plus fluide.",
"Soft shadows in corners. Costs chunk building time only.": "Ombres douces dans les coins. Coût : construction des chunks.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Textures réduites au loin : moins de scintillement.",
@@ -166,6 +166,10 @@
"How far mobs, crops and machines keep working. CPU time.": "Jak daleko stworzenia, uprawy i maszyny nadal działają. Czas procesora.",
"How far away mobs and items are drawn, as a share of normal.": "Odległość rysowania stworzeń i przedmiotów, jako ułamek normy.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Pomija teren ukryty za zamkniętymi sekcjami. Wyłączone dużo kosztuje.",
"Hides entities behind opaque blocks.": "Ukrywa byty za nieprzezroczystymi blokami.",
"No entity occlusion culling.": "Bez odrzucania zasłoniętych bytów.",
"CPU occlusion test runs this frame.": "Test zasłonięcia wykonuje CPU w tej klatce.",
"Occlusion checks run on a worker thread.": "Testy zasłonięcia działają w wątku roboczym.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Liczba sekcji siatki wysyłanych na klatkę. Mniej to płynniej podczas ładowania.",
"Soft shadows in corners. Costs chunk building time only.": "Miękkie cienie w narożnikach. Kosztuje tylko czas budowania chunków.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Mniejsze kopie tekstur bloków dla odległego terenu: mniej migotania.",
@@ -157,6 +157,10 @@
"How far mobs, crops and machines keep working. CPU time.": "Как далеко мобы, посевы и механизмы продолжают работать. Нагрузка на процессор.",
"How far away mobs and items are drawn, as a share of normal.": "С какой дистанции рисуются мобы и предметы, в долях от обычной.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускает рельеф, скрытый за закрытыми секциями. Выключено — дорого.",
"Hides entities behind opaque blocks.": "Скрывает сущности за непрозрачными блоками.",
"No entity occlusion culling.": "Без отсечения сущностей по перекрытию.",
"CPU occlusion test runs this frame.": "В этом кадре проверку перекрытия выполняет CPU.",
"Occlusion checks run on a worker thread.": "Проверки перекрытия выполняются в рабочем потоке.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Секций меша за кадр. Меньше — плавнее во время загрузки.",
"Soft shadows in corners. Costs chunk building time only.": "Мягкие тени по углам. Замедляет только построение чанков.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Уменьшенные копии текстур блоков для дали: меньше мерцания.",
@@ -158,6 +158,10 @@
"How far mobs, crops and machines keep working. CPU time.": "Yaratıkların, ekinlerin ve makinelerin ne kadar uzaktan çalışmaya devam edeceğini belirler. İşlemci gücü kullanır.",
"How far away mobs and items are drawn, as a share of normal.": "Yaratıkların ve eşyaların ne kadar uzaktan görüntüleneceğini, normal mesafeye oranla belirler.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Kapalı alanların arkasında kalan arazilerin çizilmesini önler. Kapalı olması ciddi performans kaybına neden olur.",
"Hides entities behind opaque blocks.": "Opak blokların arkasındaki varlıkları gizler.",
"No entity occlusion culling.": "Varlık oklüzyon ayıklaması kapalı.",
"CPU occlusion test runs this frame.": "Oklüzyon denetimini bu karede CPU yapar.",
"Occlusion checks run on a worker thread.": "Oklüzyon denetimleri bir iş parçacığında çalışır.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Her karede GPU'ya yüklenen oluşturulmuş bölüm sayısı. Daha düşük değerler yükleme sırasında daha akıcı bir deneyim sağlar.",
"Soft shadows in corners. Costs chunk building time only.": "Köşelerde yumuşak gölgeler oluşturur. Yalnızca parça oluşturma süresini etkiler.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Uzak arazilerde blok dokularının daha küçük kopyalarını kullanır. Titreşimli görüntüyü azaltır.",
@@ -166,6 +166,10 @@
"How far mobs, crops and machines keep working. CPU time.": "Як далеко працюють моби, посіви й механізми. Витрачає CPU.",
"How far away mobs and items are drawn, as a share of normal.": "Відстань малювання мобів і предметів, у частці від норми.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускає приховану місцевість. Вимкнення коштує дорого.",
"Hides entities behind opaque blocks.": "Ховає сутності за непрозорими блоками.",
"No entity occlusion culling.": "Відсікання закритих сутностей вимкнено.",
"CPU occlusion test runs this frame.": "Цього кадру перекриття перевіряє CPU.",
"Occlusion checks run on a worker thread.": "Перевірки перекриття виконуються в робочому потоці.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Секцій сітки за кадр. Менше — плавніше під час завантаження.",
"Soft shadows in corners. Costs chunk building time only.": "М’які тіні в кутах. Витрачає лише час на побудову чанків.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Менші копії текстур блоків для віддаленої місцевості: менше мерехтіння.",
@@ -160,6 +160,10 @@
"How far mobs, crops and machines keep working. CPU time.": "生物、农作物和机器持续工作的距离。占用 CPU 时间。",
"How far away mobs and items are drawn, as a share of normal.": "生物和物品的绘制距离,按正常值的比例。",
"Skips terrain hidden behind closed sections. Off costs a lot.": "跳过被封闭区块遮挡的地形。关闭的开销很大。",
"Hides entities behind opaque blocks.": "隐藏不透明方块后的实体。",
"No entity occlusion culling.": "不进行实体遮挡剔除。",
"CPU occlusion test runs this frame.": "本帧由 CPU 执行遮挡检测。",
"Occlusion checks run on a worker thread.": "遮挡检测在工作线程上运行。",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "每帧上传的网格化区块数。越低加载时越流畅。",
"Soft shadows in corners. Costs chunk building time only.": "角落里的柔和阴影。只占用区块构建时间。",
"Smaller copies of block textures for distant terrain: less shimmer.": "为远处地形生成更小的方块纹理副本:减少闪烁。",
@@ -195,7 +195,7 @@ pub fn main() -> Result<()> {
));
}
}
ArrowEvent::Block { pos, reaction } => match reaction {
ArrowEvent::Block { pos, reaction, .. } => match reaction {
BlockReaction::PrimeTnt => {
// TntBlock.prime: PrimedTnt draws its push from the
// level random, then removeBlock(pos, false).
@@ -21,7 +21,7 @@ use horizon_world::registry::{BlockRegistry, BlockStateId};
use serde_json::{Value, json};
use std::{
cell::RefCell,
collections::HashMap,
collections::{HashMap, HashSet},
fs,
io::{BufWriter, Write},
path::{Path, PathBuf},
@@ -364,6 +364,8 @@ pub fn main() -> Result<()> {
world: &world,
sprites: &sprites,
emissive: &Default::default(),
translucent_sprites: &HashSet::new(),
atlas_revision: 1,
nether: false,
lid: &|_| open,
sign: &|_| sign.as_ref(),
+7 -1
View File
@@ -117,6 +117,8 @@ pub enum ArrowEvent {
Block {
pos: BlockPos,
reaction: BlockReaction,
/// The arrow's owner (`Projectile.getOwner`), who owns a TNT it primes.
owner: Option<Who>,
},
/// The SPLASH and PROJECTILE_SHOOT game events.
SplashEvent,
@@ -982,7 +984,11 @@ impl Ticker<'_, '_, '_, '_> {
if self.st.on_fire()
&& let Some(reaction) = self.block_reaction(state)
{
self.st.events.push(ArrowEvent::Block { pos, reaction });
self.st.events.push(ArrowEvent::Block {
pos,
reaction,
owner: self.p.owner,
});
}
let v = self.p.velocity;
let offset = f64::from(0.05f32);
+50
View File
@@ -9,6 +9,7 @@
//! path ends in `.json`, the game listens on a random localhost TCP port and
//! writes `{"transport":"tcp","port":N,"token":"..."}` to the path, readable
//! only by its user; a client's first line must then be `auth TOKEN`.
use crate::block_entity_render::StaticCacheFrameStats;
use std::{
io::{self, BufRead, BufReader, Read, Write},
net::{Ipv4Addr, TcpListener, TcpStream},
@@ -411,6 +412,30 @@ mod tests {
assert_eq!(random_token().len(), 32);
assert!(same_token("abc", "abc") && !same_token("abc", "abd") && !same_token("a", "ab"));
}
#[test]
fn frame_summary_reports_block_entity_cache_work() {
let mut stats = FrameStats::default();
stats.record(10.0);
stats.record_cpu(5.0);
stats.record_block_entities(StaticCacheFrameStats {
snapshot_rebuilds: 2,
snapshot_bytes_built: 128,
static_bytes_uploaded: 96,
static_entities_drawn: 4,
dynamic_entities_drawn: 3,
..Default::default()
});
let summary = stats.summary();
assert_eq!(summary["static_snapshot_rebuilds"], 2);
assert_eq!(summary["static_snapshot_bytes_built"], 128);
assert_eq!(summary["static_bytes_uploaded"], 96);
assert_eq!(summary["static_entities_drawn"], 4);
assert_eq!(summary["dynamic_entities_drawn"], 3);
assert_eq!(summary["static_entities_drawn_avg_per_frame"], 4.0);
assert_eq!(summary["dynamic_entities_drawn_avg_per_frame"], 3.0);
}
}
/// Frame durations since the last reset, summarized like the Java recorder,
@@ -422,6 +447,11 @@ pub struct FrameStats {
gpu: Vec<f32>,
/// Milliseconds per frame phase (`PHASES`), one entry per rendered frame.
phases: Vec<[f32; PHASES.len()]>,
static_snapshot_rebuilds: u64,
static_snapshot_bytes_built: u64,
static_bytes_uploaded: u64,
static_entities_drawn: u64,
dynamic_entities_drawn: u64,
}
/// The parts of a frame's CPU time: world streaming and uploads, game ticks,
@@ -490,6 +520,14 @@ impl FrameStats {
self.gpu.extend_from_slice(millis);
}
}
/// Block-entity cache counters for the frame just rendered.
pub fn record_block_entities(&mut self, stats: StaticCacheFrameStats) {
self.static_snapshot_rebuilds += stats.snapshot_rebuilds;
self.static_snapshot_bytes_built += stats.snapshot_bytes_built;
self.static_bytes_uploaded += stats.static_bytes_uploaded;
self.static_entities_drawn += stats.static_entities_drawn;
self.dynamic_entities_drawn += stats.dynamic_entities_drawn;
}
/// Every recorded frame time, in milliseconds, in order.
pub fn frame_millis(&self) -> &[f32] {
&self.millis
@@ -507,6 +545,11 @@ impl FrameStats {
self.cpu.clear();
self.gpu.clear();
self.phases.clear();
self.static_snapshot_rebuilds = 0;
self.static_snapshot_bytes_built = 0;
self.static_bytes_uploaded = 0;
self.static_entities_drawn = 0;
self.dynamic_entities_drawn = 0;
}
pub fn summary(&self) -> serde_json::Value {
let mut sorted = self.millis.clone();
@@ -550,6 +593,13 @@ impl FrameStats {
"gpu": percentiles(&self.gpu),
"phases_avg_ms": self.phase_average(0.0),
"slow_phases_avg_ms": slow_phases,
"static_snapshot_rebuilds": self.static_snapshot_rebuilds,
"static_snapshot_bytes_built": self.static_snapshot_bytes_built,
"static_bytes_uploaded": self.static_bytes_uploaded,
"static_entities_drawn": self.static_entities_drawn,
"dynamic_entities_drawn": self.dynamic_entities_drawn,
"static_entities_drawn_avg_per_frame": self.static_entities_drawn as f64 / n as f64,
"dynamic_entities_drawn_avg_per_frame": self.dynamic_entities_drawn as f64 / n as f64,
})
}
}
+71 -1
View File
@@ -5,9 +5,33 @@
use crate::{
dialog::{Body, Control, Dialog, Kind},
font::Font,
text::Text,
text::{Style, Text},
};
/// `ActiveTextCollector.ClickableStyleFinder.accept`: the style under a drawn
/// message glyph, using the same visual order and advances as rendering.
pub fn line_style_at(
font: &Font,
text: &Text,
x: f32,
y: f32,
cursor: (f32, f32),
) -> Option<Style> {
let mut left = cursor.0 - x;
if left < 0.0 || !(0.0..9.0).contains(&(cursor.1 - y)) {
return None;
}
let text = text.resolve_legacy();
let visual = crate::bidi::visual(&text);
for (c, style) in visual.chars() {
left -= font.advance(c, style);
if left < 0.0 {
return Some(style.clone());
}
}
None
}
/// `HeaderAndFooterLayout.DEFAULT_HEADER_AND_FOOTER_HEIGHT`.
const BAR: f32 = 33.0;
/// `Checkbox.getBoxSize`.
@@ -433,6 +457,52 @@ pub fn waiting_button(ticks: u32) -> (Text, bool, bool) {
mod tests {
use super::*;
#[test]
fn message_hit_testing_respects_sprite_advances_and_glyph_bounds() {
use crate::{component, hud::Rect, text::ClickEvent};
let font = Font::load(
std::path::Path::new("/nonexistent-horizon-dialog-test-resources"),
&std::collections::HashMap::new(),
&[],
(1, 1),
(0, 0),
Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
},
);
let text = component::to_text(
&serde_json::json!([
{"object": "atlas", "sprite": "minecraft:item/potion"},
{"object": "atlas", "sprite": "minecraft:item/potion", "click_event": {
"action": "custom", "id": "test:shop"
}},
{"object": "atlas", "sprite": "minecraft:item/potion"},
]),
&mut |_| Text::empty(),
);
let style = |x, y| line_style_at(&font, &text, 10.0, 20.0, (x, y));
assert!(style(9.9, 20.0).is_none());
assert_eq!(style(17.9, 20.0).unwrap().click, None);
for x in [18.0, 21.0, 25.9] {
assert_eq!(
style(x, 24.0).unwrap().click,
Some(ClickEvent::Custom {
id: "test:shop".into(),
payload: None,
})
);
}
assert_eq!(style(26.0, 20.0).unwrap().click, None);
assert!(style(34.0, 20.0).is_none());
assert!(style(21.0, 19.9).is_none());
assert!(style(21.0, 29.0).is_none());
}
#[test]
fn grids_pack_full_rows_and_centre_the_rest() {
// AuthMe's two buttons: one row, 2 apart.
+57
View File
@@ -91,6 +91,12 @@ pub struct ItemEntity {
pub stuck: Vec3,
/// `Entity.noGravity` (`NoGravity` in its save): `applyGravity` does nothing.
pub no_gravity: bool,
/// `Entity.verticalCollision`: the last move was stopped vertically.
/// `ServerEntity.isFullPrecisionEncodingRequired` reads it for items, whose
/// resting position is exact while a 1/4096 delta is not.
pub vertical_collision: bool,
/// `Entity.horizontalCollision`: the last move was stopped on x or z.
pub horizontal_collision: bool,
}
/// Environment results needed by the entity owner, including lethal lava damage.
#[derive(Debug)]
@@ -280,6 +286,13 @@ impl ItemEntity {
if !no_physics {
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
}
// Entity.move: what the move was stopped on. They outlive the
// tick, as vanilla's fields do, for the tracker that sends the
// position at the type's update interval.
self.vertical_collision = moved.y != wanted.y;
// `Mth.equal` on x and z (1e-5 as a float), exact on y.
let differs = |a: f64, b: f64| (b - a).abs() >= 9.999_999_747_378_752e-6;
self.horizontal_collision = differs(moved.x, wanted.x) || differs(moved.z, wanted.z);
if moved.x != wanted.x {
self.velocity.x = 0.0;
}
@@ -1182,6 +1195,8 @@ pub struct ArmorStand {
pub enchantment_fall_distance: f64,
/// Last equipment seen by `collectEquipmentChanges`; not saved to NBT.
pub enchantment_equipment: [Option<ItemStack>; 6],
/// `Entity.mainSupportingBlockPos` after the last move.
pub support: horizon_world::ground_support::GroundState,
}
impl ArmorStand {
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
@@ -1256,6 +1271,22 @@ impl ArmorStand {
enchantment_block: None,
enchantment_fall_distance: 0.0,
enchantment_equipment: Default::default(),
support: Default::default(),
}
}
/// `Entity.getOnPos(0.2)` (`getOnPosLegacy`): the supporting block's
/// column 0.2 below the feet, the supporting block itself when `tall`
/// says it is a fence, wall or fence gate, else the cell 0.2 below.
pub fn on_pos_legacy(&self, tall: impl Fn(BlockPos) -> bool) -> BlockPos {
let y = (self.position.y - f64::from(0.2_f32)).floor() as i32;
match self.support.supporting_block {
Some(p) if tall(p) => p,
Some(p) => BlockPos::new(p.x, y, p.z),
None => BlockPos::new(
self.position.x.floor() as i32,
y,
self.position.z.floor() as i32,
),
}
}
/// `EquipmentSlot.getFilterFlag` of an `equipment` index (main hand, off
@@ -1394,6 +1425,24 @@ impl ArmorStand {
self.position.z + moved.z,
);
self.on_ground = wanted.y < 0.0 && moved.y > wanted.y;
// Entity.setOnGroundWithMovement: the block under the feet.
let context = horizon_world::collision_context::CollisionContext::for_entity(
horizon_world::collision_context::CollisionEntity {
identity: horizon_world::entity_obstruction::EntityIdentity(u64::from(self.id)),
y: self.position.y,
descending: false,
main_hand: Some(horizon_world::collision_context::CollisionItem::EMPTY),
},
false,
);
let position = self.position;
let _ =
self.support
.check_support(self.on_ground, self.bounds(), Some(wanted), |area| {
horizon_world::ground_support::find_supporting_block(
scene, context, position, area, 1_000_000,
)
});
if (wanted.x - moved.x).abs() >= f64::from(1.0e-5_f32) {
velocity.x = 0.0;
}
@@ -1504,6 +1553,8 @@ mod fire_environment_tests {
previous: at,
velocity: Vec3::ZERO,
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
@@ -1651,6 +1702,8 @@ mod merge_tests {
previous: at,
velocity: Vec3::ZERO,
on_ground: true,
vertical_collision: false,
horizontal_collision: false,
age,
pickup_delay: 0,
bob_offset: 0.0,
@@ -1990,6 +2043,8 @@ mod honey_slide_tests {
previous: at,
velocity: Vec3::new(vx, vy, vz),
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
age: 10,
pickup_delay: 0,
bob_offset: 0.0,
@@ -2120,6 +2175,8 @@ mod slime_bounce_tests {
previous: at,
velocity: Vec3::ZERO,
on_ground: true,
vertical_collision: false,
horizontal_collision: false,
age: 10,
pickup_delay: 0,
bob_offset: 0.0,
@@ -245,6 +245,8 @@ mod tests {
previous: at,
velocity: Vec3::ZERO,
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
+48 -1
View File
@@ -2053,12 +2053,23 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
}
tag
}
/// `PrimedTnt.addAdditionalSaveData`: its `fuse`.
/// `PrimedTnt.readAdditionalSaveData`: `explosion_power`, 4 by default and
/// clamped to 0..128.
pub fn tnt_power(tag: &Tag) -> f32 {
match tag.get("explosion_power") {
Some(Tag::Float(bits)) => f32::from_bits(*bits).clamp(0.0, 128.0),
_ => 4.0,
}
}
/// `PrimedTnt.addAdditionalSaveData`: its `fuse` and, when it is not the
/// default, its `explosion_power`.
pub fn tnt_to_nbt(
position: Vec3,
motion: Vec3,
on_ground: bool,
fuse: i32,
power: f32,
uuid: [i32; 4],
raw: Option<&Tag>,
) -> Tag {
@@ -2073,6 +2084,9 @@ pub fn tnt_to_nbt(
}
.tag(raw);
put(&mut tag, "fuse", Tag::Short(fuse as i16));
if power != 4.0 {
put(&mut tag, "explosion_power", Tag::Float(power.to_bits()));
}
// PrimedTnt writes its block state (a summoned `block_state` is kept).
if tag.get("block_state").is_none() {
put(&mut tag, "block_state", string("minecraft:tnt"));
@@ -2094,6 +2108,8 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
previous: position,
velocity: motion(tag),
on_ground: flag(tag, "OnGround"),
vertical_collision: false,
horizontal_collision: false,
// Age -32768 marks items that never despawn; Horizon counts upwards.
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
pickup_delay: number(tag.get("PickupDelay")).unwrap_or(0).clamp(0, 32767) as u32,
@@ -3224,6 +3240,35 @@ fn read_villager(m: &mut Mob, tag: &Tag) {
}));
}
#[cfg(test)]
mod tnt_power_tests {
use super::*;
/// `PrimedTnt.readAdditionalSaveData`: `explosion_power` defaults to 4 and
/// is clamped to 0..128 both ways, so a summoned `explosion_power:200` is
/// 128 and one that is absent is ordinary TNT.
#[test]
fn explosion_power_is_read_clamped_and_defaulted() {
let with = |power: Option<f32>| {
let mut map = std::collections::BTreeMap::new();
if let Some(p) = power {
map.insert("explosion_power".into(), Tag::Float(p.to_bits()));
}
Tag::Compound(map)
};
assert_eq!(tnt_power(&with(None)), 4.0);
assert_eq!(tnt_power(&with(Some(10.0))), 10.0);
assert_eq!(tnt_power(&with(Some(200.0))), 128.0);
assert_eq!(tnt_power(&with(Some(-3.0))), 0.0);
// And it round-trips through the save, which writes it only when it is
// not the default.
let saved = tnt_to_nbt(Vec3::ZERO, Vec3::ZERO, false, 20, 10.0, [1, 2, 3, 4], None);
assert_eq!(tnt_power(&saved), 10.0);
let plain = tnt_to_nbt(Vec3::ZERO, Vec3::ZERO, false, 20, 4.0, [1, 2, 3, 4], None);
assert!(plain.get("explosion_power").is_none());
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -3391,6 +3436,8 @@ mod tests {
previous: Vec3::ZERO,
velocity: Vec3::new(0.0, -0.04, 0.0),
on_ground: true,
vertical_collision: false,
horizontal_collision: false,
age: 120,
pickup_delay: 10,
ticks: 0,
+168 -43
View File
@@ -219,6 +219,7 @@ mod native_net;
mod native_panel;
mod native_panel_recipes;
mod native_projectiles;
mod native_quick_move;
mod native_recipes;
mod native_server;
mod native_shaders;
@@ -341,6 +342,10 @@ use horizon_client::{
assets::{Assets, RenderAssets},
audio::Audio,
biome::Biomes,
block_entity_render::{
self, BlockEntityRenderer, CacheOptions, Scene as BlockEntityScene, StaticCacheFrameStats,
StaticSectionSnapshot,
},
blocks::{BlockFacts, Fluid},
clock::FixedStep,
collisions,
@@ -365,6 +370,7 @@ use horizon_client::{
particles::Particle,
save::{Level, Persistent, SaveDir},
settings::Settings,
spans,
sprint::Keys,
streaming::{Streaming, WorkerContext},
testgen::TestTerrain,
@@ -908,6 +914,7 @@ struct InteractTarget {
/// The client world as seen by the vanilla block-behaviour library.
struct GameLevel<'w> {
world: &'w mut World,
redstone_experiments: bool,
facts: &'w BlockFacts,
collision: &'w BlockCollisionRegistry<'w>,
events: &'w mut Vec<LevelEvent>,
@@ -1183,6 +1190,9 @@ impl GameLevel<'_> {
}
}
impl LevelAccess for GameLevel<'_> {
fn redstone_experiments(&self) -> bool {
self.redstone_experiments
}
fn allow_hopper_pickup_item(&self, pos: BlockPos, item: &str, count: u32) -> bool {
let mut cancel = false;
if self.native_authoritative {
@@ -1935,7 +1945,7 @@ struct WorldRest {
mob_types: HashMap<String, Arc<MobType>>,
mob_data: horizon_client::mobs::MobData,
providers: Providers,
block_entity_render: horizon_client::block_entity_render::BlockEntityRenderer,
block_entity_render: BlockEntityRenderer,
animate: horizon_client::animate_tick::AnimateTables,
fog: horizon_client::fog::FogTables,
music: horizon_client::music::MusicTables,
@@ -2002,7 +2012,7 @@ impl Resources {
fn providers(&self) -> &Providers {
&self.rest().providers
}
fn block_entity_render(&self) -> &horizon_client::block_entity_render::BlockEntityRenderer {
fn block_entity_render(&self) -> &BlockEntityRenderer {
&self.rest().block_entity_render
}
fn animate(&self) -> &horizon_client::animate_tick::AnimateTables {
@@ -3891,6 +3901,8 @@ impl Game<'_> {
previous: p,
velocity: Vec3::new(velocity[0], velocity[1], velocity[2]),
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
age: 0,
pickup_delay,
bob_offset: bob,
@@ -4394,6 +4406,11 @@ impl Game<'_> {
};
let native_authoritative = self.native_authoritative();
let mut level = GameLevel {
redstone_experiments: self
.level
.enabled_features
.iter()
.any(|feature| feature == "minecraft:redstone_experiments"),
cart_boxes,
item_frames: &self.item_frames,
carts: &self.carts,
@@ -5485,7 +5502,8 @@ impl Game<'_> {
return;
}
// SignBlock.useWithoutItem: the text editor, not a container.
if horizon_client::signs::sign_kind(&name).is_some() {
let block = self.world.block(pos[0], pos[1], pos[2]);
if self.res.facts().get(block).sign_form().is_some() {
self.use_sign_empty_hand(pos);
return;
}
@@ -6250,17 +6268,16 @@ impl Game<'_> {
// opening the editor, unless the side has click commands to run
// (`shouldTryToChainAnotherHangingSign`).
let chains_sign = hit.face == [0, -1, 0]
&& held
.as_ref()
.is_some_and(|s| s.item.ends_with("_hanging_sign"))
&& {
let name = &self
.res
.facts()
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
.name;
name.ends_with("_hanging_sign") && !name.contains("_wall_")
}
// `instanceof HangingSignItem`, independent of the item's name.
&& held.as_ref().is_some_and(|s| {
self.res.behaviours().item_binding(&s.item)
.is_some_and(|i| i.kind == horizon_world::level::ItemKind::HangingSign)
})
&& self
.res
.facts()
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
.has_class("CeilingHangingSignBlock")
&& matches!(
self.world
.block_entities
@@ -7770,7 +7787,7 @@ impl Game<'_> {
if running {
horizon_client::spans::time("tick_paintings", || self.tick_paintings());
horizon_client::spans::time("tick_item_frames", || self.tick_item_frames());
horizon_client::spans::time("tick_armor_stands", || self.tick_armor_stands());
horizon_client::spans::time("tick_armor_stands", || self.tick_armor_stands())?;
horizon_client::spans::time("tick_cushions", || self.tick_cushions());
horizon_client::spans::time("tick_boats", || self.tick_boats());
horizon_client::spans::time("tick_carts", || self.tick_carts());
@@ -10853,15 +10870,24 @@ impl Game<'_> {
camera: glam::DVec3,
alpha: f64,
mut out: Vec<Vertex>,
) -> Vec<Vertex> {
) -> (
Vec<Vertex>,
Arc<[StaticSectionSnapshot]>,
Vec<[i32; 3]>,
StaticCacheFrameStats,
) {
// Its steps as spans (HORIZON_SPIKES): each lap names the step that just ended.
let mut laps = horizon_client::spans::laps();
let mut static_block_entities = std::sync::Arc::from([]);
let mut visible_static_block_entity_sections = Vec::new();
let mut static_block_entity_stats = StaticCacheFrameStats::default();
self.entity_spans.borrow_mut().clear();
self.entity_motion.borrow_mut().clear();
// LevelExtractor.extract: the transient blocks queued since the last frame.
self.drain_transient_block_queue();
self.prepare_entity_culling(camera);
self.prepare_trims();
self.begin_skinning();
self.mark_entity(out.len(), "minecraft:entity_flame");
// One scan for both passes that draw them (block entities below and the
// overlay near the end of this function).
@@ -11077,7 +11103,7 @@ impl Game<'_> {
if let Some(r) = &self.renderer {
let lid = |p: [i32; 3]| self.chest_lid(p, alpha as f32);
let anim = |p: [i32; 3]| self.block_anim(p, alpha as f32);
let scene = horizon_client::block_entity_render::Scene {
let scene = BlockEntityScene {
camera,
orientation: self.camera_orientation(),
anim: &anim,
@@ -11088,6 +11114,8 @@ impl Game<'_> {
world: &self.world,
sprites: &self.gui.atlas.sprites,
emissive: &self.gui.atlas.emissive,
translucent_sprites: &self.gui.atlas.translucent,
atlas_revision: self.gui.atlas.revision,
nether: self.dim == Dim::Nether,
lid: &lid,
sign: &|p| match self.world.block_entities.get(&(p[0], p[1], p[2])) {
@@ -11114,6 +11142,9 @@ impl Game<'_> {
| "minecraft:test_instance_block"
)
};
let long_range = |state: u32| {
&*self.res.facts().get(BlockStateId(state)).name == "minecraft:end_gateway"
};
let in_view = |p: &[i32; 3]| {
view.is_none_or(|v| {
let o = (glam::DVec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]))
@@ -11122,32 +11153,90 @@ impl Game<'_> {
v.intersects(o - glam::Vec3::ONE, o + glam::Vec3::splat(2.0))
})
};
let visible =
r.visible_block_entities(self.settings.chunk_fade)
.filter(|(p, state)| {
self.res.block_entity_render().renders(*state)
&& (off_screen(*state) || in_view(p))
});
if self.entity_culler.is_some()
|| self.async_entity_culler.is_some()
|| self.pack_ids.is_some()
let visible = r
.visible_block_entities_in_frustum(
self.settings.chunk_fade,
camera,
view,
&off_screen,
long_range,
)
.filter(|(block, section_inside)| {
let (pos, state) = **block;
self.res.block_entity_render().renders(state)
&& (off_screen(state) || *section_inside || in_view(&pos))
})
.map(|(block, _)| block);
let block_entity_cache = CacheOptions {
shulker_boxes: self.settings.horizon.cache_shulker_boxes,
signs: self.settings.horizon.cache_signs,
decorated_pots: self.settings.horizon.cache_decorated_pots,
bells: self.settings.horizon.cache_bells,
chests: self.settings.horizon.cache_chests,
banners: self.settings.horizon.cache_banners,
copper_golem_statues: self.settings.horizon.cache_copper_golem_statues,
};
// Entity occlusion is for ordinary entity meshes. Block entities
// already have section and per-block frustum culling; routing them
// through the per-vertex occlusion callback both repeats that work
// and disables their per-section GPU cache. Shader packs still need
// per-entity span marks, so they keep the callback path.
if self.pack_ids.is_some() {
let section_revision = |pos| r.block_entity_section_revision(pos);
static_block_entity_stats = self
.res
.block_entity_render()
.render_with_cache_options_and_callbacks(
&scene,
visible,
block_entity_cache,
r.block_entity_revision(),
Some(&section_revision),
&mut out,
|_, state, start| self.mark_block_entity(start, state),
|_, _, _, _| {},
);
} else if (self.entity_culler.is_some() || self.async_entity_culler.is_some())
&& !block_entity_cache.any_model_cache()
{
// Entity Culling on and no model cache: a block entity hidden
// behind a wall stays culled, as it was before the caches.
for block in visible {
let start = out.len();
self.mark_block_entity(start, block.1);
self.res
.block_entity_render()
.render(&scene, std::iter::once(block), &mut out);
if !off_screen(block.1)
&& (self.entity_culler.is_some() || self.async_entity_culler.is_some())
{
if !off_screen(block.1) {
self.cull_entity_vertices(&mut out, start);
}
}
} else {
self.res
let cache_candidates = r
.visible_block_entities_in_frustum(
self.settings.chunk_fade,
camera,
None,
|_| false,
|_| false,
)
.filter(|(block, _)| self.res.block_entity_render().renders(block.1))
.map(|(block, _)| block);
(
static_block_entities,
visible_static_block_entity_sections,
static_block_entity_stats,
) = self
.res
.block_entity_render()
.render(&scene, visible, &mut out);
.render_with_gpu_cache_options(
&scene,
visible,
cache_candidates,
block_entity_cache,
r.block_entity_revision(),
|pos| r.block_entity_section_revision(pos),
&mut out,
);
}
// Mod block entities: their mods draw them (`native_block_entity_submit`),
// culled by the bounds they give.
@@ -11350,7 +11439,12 @@ impl Game<'_> {
laps.lap("eg_cracks");
self.finish_async_entity_culling();
laps.lap("eg_culling_finish");
out
(
out,
static_block_entities,
visible_static_block_entity_sections,
static_block_entity_stats,
)
}
/// `LevelRenderer.destroyBlockProgress`: a player's crack on a block (0..=9), or none
@@ -14175,7 +14269,7 @@ impl Game<'_> {
self.submit_item_world(
&state,
m,
horizon_client::block_entity_render::vertex_light(light),
block_entity_render::vertex_light(light),
&mut out,
);
}
@@ -14707,7 +14801,14 @@ impl Game<'_> {
if self.enough_slept() {
if self.ui.rules.bool("advance_time") {
let t = self.world.day_time;
let target = t + 24000 - t.rem_euclid(24000);
let mut target = t + 24000 - t.rem_euclid(24000);
// SleepFinishedTimeEvent: the mods may move the wake-up time,
// never before now (`setTimeAddition` refuses a lower one).
let mut asked = target;
self.native_sleep_finished_time(self.dim.id(), t, &mut asked);
if asked >= t {
target = asked;
}
let mut cancel = false;
self.native_time_skip(self.dim.id(), t, target, "sleep", &mut cancel);
if !cancel {
@@ -15650,7 +15751,7 @@ impl Game<'_> {
lines.extend(g.lines);
let on_top = g.on_top;
let colored_lights = if self.title.is_none() {
self.native_frame_visuals(camera, &mut lines)
self.native_frame_visuals(camera, alpha, &mut lines)
} else {
Vec::new()
};
@@ -15790,13 +15891,21 @@ impl Game<'_> {
// Keep the buffer local while builders need mutable access to the game.
let mut entities = std::mem::take(&mut self.entity_vertices);
entities.clear();
let mut static_block_entities = std::sync::Arc::from([]);
let mut visible_static_block_entity_sections = Vec::new();
let mut static_block_entity_stats = StaticCacheFrameStats::default();
self.see_through_vertices.clear();
self.glowing_vertices.get_mut().clear();
self.shadow_only_vertices.get_mut().clear();
if !title {
entities = horizon_client::spans::time("entity_geometry", || {
self.entity_geometry(camera, alpha, entities)
});
let (built, static_sections, visible_static_sections, static_stats) =
spans::time("entity_geometry", || {
self.entity_geometry(camera, alpha, entities)
});
entities = built;
static_block_entities = static_sections;
visible_static_block_entity_sections = visible_static_sections;
static_block_entity_stats = static_stats;
}
let bolts = if title {
Vec::new()
@@ -15959,6 +16068,9 @@ impl Game<'_> {
color: sky.cloud_color,
}),
entities: &entities,
static_block_entities: &static_block_entities,
visible_static_block_entity_sections: &visible_static_block_entity_sections,
static_block_entity_stats,
entity_motion: &entity_motion,
entity_spans: &entity_spans,
skin: skin.pending().then_some(&skin),
@@ -18260,6 +18372,8 @@ impl Game<'_> {
ms(ticked.elapsed().saturating_sub(r.last_wait + render)),
ms(render),
]);
self.frame_stats
.record_block_entities(r.static_block_entity_frame_stats());
}
self.frame_stats.record_gpu(&r.gpu_times);
r.gpu_times.clear();
@@ -18321,6 +18435,14 @@ impl Game<'_> {
self.perf.title_shown();
} else if self.screen == Screen::Playing && self.player.is_some() {
self.perf.frame(elapsed.as_secs_f32() * 1000.0);
if startup::filling() {
let mut fill: Vec<(&'static str, usize)> =
self.streaming.stages().to_vec();
fill.push(("resident", self.world.chunks.len()));
fill.push(("uploads", self.pending_uploads.len()));
fill.push(("backlog", self.remote_backlog()));
startup::counters("world.fill", &fill);
}
if startup::on() {
startup::view_filled(
self.world.chunks.len(),
@@ -20049,14 +20171,14 @@ fn load_world_rest(
Behaviours::load_with_gameplay(registry, root, behaviour_json, gameplay_projection)
})
};
let load_block_entity_render = || {
let load_block_entity_render = || -> Result<_> {
let _step = startup::step("rest.block_entity_render");
if !draws {
return Ok(Default::default());
}
horizon_client::block_entity_render::BlockEntityRenderer::load_with_visibility(
root, registry, visibility,
)
let mut renderer = BlockEntityRenderer::load_with_visibility(root, registry, visibility)?;
renderer.add_mod_signs(facts, registry);
Ok(renderer)
};
let load_animate = || -> Result<_> {
let _step = startup::step("rest.animate_fog");
@@ -20362,6 +20484,9 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: Default::default(),
entity_motion: &[],
entity_spans: &[],
skin: None,
+98 -21
View File
@@ -4,6 +4,7 @@
//! the default pose, the hit wobble and humanoid armor layers.
use super::{Game, InteractTarget};
use horizon_client::{
Result,
cit::{Hand, Kind, Rule},
cit_armor,
entities::ArmorStand,
@@ -35,10 +36,11 @@ fn layer_color(undyed: Option<u32>, stack: Option<&ItemStack>) -> u32 {
/// Armor stand models and the humanoid layers of every equipment asset.
pub(super) struct ArmorStandAssets {
pub main: Option<Part>,
pub small: Option<Part>,
/// Shared (`Arc`) so the GPU path (`mobs::skin`) keeps one stored copy of each.
pub main: Option<Arc<Part>>,
pub small: Option<Arc<Part>>,
/// (slot model suffix, small) -> model.
pub layers: HashMap<(&'static str, bool), Part>,
pub layers: HashMap<(&'static str, bool), Arc<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) -> each layer's (texture, undyed colour), in order.
@@ -63,7 +65,7 @@ impl ArmorStandAssets {
}
for (small, model) in [(false, "armor_stand"), (true, "armor_stand_small")] {
if let Some(part) = load(&format!("minecraft:{model}#{slot}")) {
layers.insert((slot, small), part);
layers.insert((slot, small), Arc::new(part));
}
}
}
@@ -127,8 +129,8 @@ impl ArmorStandAssets {
}
}
Self {
main: load("minecraft:armor_stand#main"),
small: load("minecraft:armor_stand_small#main"),
main: load("minecraft:armor_stand#main").map(Arc::new),
small: load("minecraft:armor_stand_small#main").map(Arc::new),
layers,
player,
equipment,
@@ -778,7 +780,7 @@ impl Game<'_> {
.insert(((at[0].floor() as i32) >> 4, (at[2].floor() as i32) >> 4));
true
}
pub(super) fn tick_armor_stands(&mut self) {
pub(super) fn tick_armor_stands(&mut self) -> Result<()> {
let mut dead = Vec::new();
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
@@ -819,16 +821,60 @@ impl Game<'_> {
if a.on_ground {
a.enchantment_fall_distance = 0.0;
}
if !a.environment_tick(&self.world, self.res.facts(), self.pack.assets.registry) {
dead.push(i);
}
// LivingEntity.checkFallDamage: onChangedBlock before the block's
// fallOn, then aiStep's applyEffectsFromBlocks (stepOn, then the
// blocks it is inside).
if landed {
self.armor_stand_location_effects(i, true);
}
self.armor_stand_turtle_eggs(i, landed)?;
let a = &mut self.armor_stands[i];
if !a.environment_tick(&self.world, self.res.facts(), self.pack.assets.registry) {
dead.push(i);
}
}
for i in dead.into_iter().rev() {
self.break_armor_stand_by_anything(i);
}
Ok(())
}
/// An armor stand is a `LivingEntity`, so the egg under it
/// (`getOnPosLegacy`) cracks like under a mob: `TurtleEggBlock.fallOn` one
/// landing in 3 (`Entity.move`'s `checkFallDamage`), then `stepOn` one tick
/// in 100 while it is on the ground (`LivingEntity.aiStep`'s
/// `applyEffectsFromBlocks`, after `travel`, so a marker that has landed
/// still steps). `canDestroyEgg` wants mob griefing before the level's roll.
fn armor_stand_turtle_eggs(&mut self, i: usize, landed: bool) -> Result<()> {
let stand = &self.armor_stands[i];
if !stand.on_ground || self.remote.is_some() || self.options.execution_role.is_client() {
return Ok(());
}
let p = stand.on_pos_legacy(|p| {
let name = &self.res.facts().get(self.world.block(p.x, p.y, p.z)).name;
let tags = &self.res.items().block_tags;
[
"minecraft:fences",
"minecraft:walls",
"minecraft:fence_gates",
]
.iter()
.any(|t| tags.get(*t).is_some_and(|s| s.contains(&**name)))
});
for chance in [landed.then_some(3), Some(100)].into_iter().flatten() {
let egg = self
.pack
.assets
.registry
.state(self.world.block(p.x, p.y, p.z))
.is_some_and(|s| s.name == "minecraft:turtle_egg");
if egg
&& self.ui.rules.bool("mob_griefing")
&& self.world.random.next_int_bound(chance).unwrap_or(1) == 0
{
self.crack_turtle_egg([p.x, p.y, p.z])?;
}
}
Ok(())
}
/// `ArmorStand.brokenByAnything`, then `kill`, as burning out or a blast
/// breaks a stand: its equipment pops above its block, the stand item does
@@ -851,6 +897,8 @@ impl Game<'_> {
alpha: f64,
out: &mut Vec<Vertex>,
) {
// The stands' models posed on the GPU path (`mobs::skin`), when it runs this frame.
let skinning = self.skinning();
for a in &self.armor_stands {
let entity_start = out.len();
// ArmorStand.shouldRenderAtSqrDistance: 256 times the box's mean
@@ -904,20 +952,37 @@ impl Game<'_> {
at.z.floor() as i32,
);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
self.armor_stand_vertices(a, base, packed, out);
self.cull_entity_vertices(out, entity_start);
let skinned = if skinning {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mark = frame.mark();
let mut skin = horizon_client::mobs::skin::Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
self.armor_stand_vertices(a, base, packed, Some(&mut skin), out);
Some((mark, skin.bounds))
} else {
self.armor_stand_vertices(a, base, packed, None, out);
None
};
self.cull_skinned(out, entity_start, skinned);
}
}
/// `ArmorStandRenderer.submit` for stand `a` under `base` (its
/// `setupRotations`): the body, the hand and head items, the armour and
/// the wings, at packed light `packed`.
/// the wings, at packed light `packed`. With `skin`, the stand's own models
/// (body and armour) are posed on the GPU; items and wings stay here.
fn armor_stand_vertices(
&self,
a: &ArmorStand,
base: glam::Mat4,
packed: u32,
mut skin: Option<&mut horizon_client::mobs::skin::Skin>,
out: &mut Vec<Vertex>,
) {
use horizon_client::mobs::render::put;
let assets = &self.armor_stand_assets;
let sprites = &self.gui.atlas.sprites;
let uv_of = |name: &str| sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
@@ -961,11 +1026,13 @@ impl Game<'_> {
// LivingEntityRenderer.submit: an invisible stand has no body render type, but its
// layers (held items, custom head, armour) are submitted all the same.
if !a.invisible {
horizon_client::mobs::render::draw(
put(
&mut skin,
model,
base,
&poses,
wood,
0.0,
0xFFFF_FFFF,
packed,
0,
@@ -973,11 +1040,13 @@ impl Game<'_> {
);
if let Some(glow) = glow_of("entity/armorstand/armorstand") {
let full = horizon_client::mobs::render::FULL_BRIGHT;
horizon_client::mobs::render::draw(
put(
&mut skin,
model,
base,
&poses,
glow,
0.0,
0xFFFF_FFFF,
full,
0,
@@ -986,7 +1055,9 @@ impl Game<'_> {
}
}
self.armor_stand_items(a, model, base, &posed, packed, out);
// HumanoidArmorLayer: head, chest, legs, feet.
// HumanoidArmorLayer: head, chest, legs, feet. One pose map for every layer
// (`reset` keeps its capacity).
let mut layer_poses = Poses::default();
for (slot, layer) in [
(5usize, "helmet"),
(4, "chestplate"),
@@ -1030,7 +1101,7 @@ impl Game<'_> {
0
};
// ArmorStandArmorModel.setupAnim: the same pose as the stand.
let mut layer_poses = Poses::new(part);
layer_poses.reset(part);
apply_pose(&mut layer_poses, &a.pose);
// EquipmentLayerRenderer: every layer of the asset, the leather overlay
// too; the shader glint goes on the first layer only (renderShaderGlint).
@@ -1040,11 +1111,13 @@ impl Game<'_> {
continue;
};
let color = layer_color(*undyed, Some(stack));
horizon_client::mobs::render::draw(
put(
&mut skin,
part,
base,
&layer_poses,
uv,
0.0,
color,
packed,
if i == 0 { overlay } else { 0 },
@@ -1052,11 +1125,13 @@ impl Game<'_> {
);
// The layer's OptiFine emissive overlay, full bright without the glint.
if let Some(glow) = glow_of(texture) {
horizon_client::mobs::render::draw(
put(
&mut skin,
part,
base,
&layer_poses,
glow,
0.0,
color,
horizon_client::mobs::render::FULL_BRIGHT,
0,
@@ -1069,11 +1144,13 @@ impl Game<'_> {
.trim_texture(&asset, kind, stack)
.and_then(|t| uv_of(&t.sprite()))
{
horizon_client::mobs::render::draw(
put(
&mut skin,
part,
base,
&layer_poses,
trim,
0.0,
0xFFFF_FFFF,
packed,
overlay,
@@ -1123,7 +1200,7 @@ impl Game<'_> {
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0));
let mut verts = Vec::new();
self.armor_stand_vertices(&stand, base, 240 | 240 << 8, &mut verts);
self.armor_stand_vertices(&stand, base, 240 | 240 << 8, None, &mut verts);
// ARMOR_STAND_ANGLE: rotationXYZ(0.43633232, 0, PI).
let gui =
glam::Mat4::from_translation(glam::Vec3::new((x0 + x1) / 2.0, (y0 + y1) / 2.0, 0.0))
@@ -146,9 +146,7 @@ impl Game<'_> {
{
let p = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| {
let block = b.block(state);
b.update_neighbors_at(level, ticks, p, block);
b.update_neighbors_at(level, ticks, p.offset(0, -1, 0), block);
b.trapped_chest_update_neighbors(level, ticks, p, state);
});
}
(before == 0) != (count == 0)
+7 -2
View File
@@ -170,6 +170,12 @@ impl Game<'_> {
}
// defaultHandleGameClickEvent: the dialog opens over the book.
ClickEvent::ShowDialog(dialog) => self.show_dialog_ref(&dialog),
// Screen.defaultHandleGameClickEvent: custom actions retain the book.
ClickEvent::Custom { id, payload } => {
if let Some(remote) = &self.remote {
remote.session.send_custom_click(&id, payload);
}
}
// SuggestCommand's insertText does nothing on a book; a server's
// text never opens a file.
ClickEvent::SuggestCommand(_) | ClickEvent::OpenFile(_) => {}
@@ -773,8 +779,7 @@ impl Game<'_> {
let p = BlockPos::new(key.0, key.1, key.2);
let state = self.world.block(key.0, key.1, key.2);
self.with_level(|b, level, ticks| {
let block = b.block(state);
b.update_neighbors_at(level, ticks, BlockPos::new(p.x, p.y - 1, p.z), block);
b.lectern_update_below(level, ticks, p, state);
});
}
fn set_lectern_property(&mut self, key: (i32, i32, i32), name: &str, value: bool) {
+6
View File
@@ -987,6 +987,12 @@ impl Game<'_> {
// Screen.defaultHandleGameClickEvent: the dialog opens over the
// screen, and closes back to it.
Some(ClickEvent::ShowDialog(dialog)) => self.show_dialog_ref(&dialog),
// Screen.defaultHandleGameClickEvent: custom actions retain chat.
Some(ClickEvent::Custom { id, payload }) => {
if let Some(remote) = &self.remote {
remote.session.send_custom_click(&id, payload);
}
}
// Screen.defaultHandleClickEvent: a page link means nothing in chat.
Some(ClickEvent::ChangePage(_)) | None => {}
}
+24 -1
View File
@@ -101,9 +101,14 @@ fn java_float(v: f64) -> String {
format!("{v}")
}
}
/// String.format(Locale.ROOT, "%.<decimals>f"): Java rounds the shortest
/// decimal text half-up, which `{:.N}` (exact value, half-even) does not.
pub(super) fn percent_f(v: f64, decimals: usize) -> String {
horizon_client::java_text::format_fixed(v, decimals)
}
/// String.format(Locale.ROOT, "%f").
fn format_double(v: f64) -> String {
format!("{v:.6}")
percent_f(v, 6)
}
/// `EntitySelectorParser.createRotationPredicate`: the wrapped angle lies in
/// the wrapped range, which runs through 180 when its minimum is above its
@@ -3906,3 +3911,21 @@ mod look_at_tests {
assert_eq!(non_player_entity_id(Target::Mob(u32::MAX - 4096)), None);
}
}
#[cfg(test)]
mod percent_f_tests {
use super::percent_f;
#[test]
fn rounds_the_shortest_decimal_half_up_like_java() {
// WorldBorderCommand: "%.1f" distance, "%.2f" buffer, "%.0f" size.
assert_eq!(percent_f(10.35, 1), "10.4");
assert_eq!(percent_f(5.125, 2), "5.13");
assert_eq!(percent_f(10.5, 0), "11");
assert_eq!(percent_f(2.5, 0), "3");
// TeleportCommand.formatDouble: "%f".
assert_eq!(percent_f(0.1234565, 6), "0.123457");
assert_eq!(percent_f(-1.005, 2), "-1.01");
assert_eq!(percent_f(3.0, 2), "3.00");
}
}
@@ -195,6 +195,7 @@ impl Game<'_> {
t.velocity,
t.on_ground,
t.fuse,
t.power,
uuid,
self.persist.raw.get(&id),
)
@@ -160,7 +160,7 @@ fn tr_error(key: &str, args: &[Text]) -> CommandError {
}
/// `String.format(Locale.ROOT, "%.2f", v)`.
fn two_places(v: f64) -> String {
format!("{v:.2}")
super::percent_f(v, 2)
}
/// `Util.getFilenameFormattedDateTime` (`yyyy-MM-dd_HH.mm.ss`, local time).
fn filename_date_time() -> String {
@@ -23,7 +23,7 @@ fn holder(text: Text) -> Holder {
}
}
fn ticks_to_seconds(ticks: i64) -> String {
format!("{:.2}", ticks as f64 / 20.0)
super::percent_f(ticks as f64 / 20.0, 2)
}
impl Game<'_> {
@@ -312,7 +312,7 @@ impl Game<'_> {
}
}
pub(super) fn cmd_worldborder(&mut self, id: &str, ctx: &Context) -> R<i32> {
let float2 = |v: f64| format!("{v:.2}");
let float2 = |v: f64| super::percent_f(v, 2);
let double = match ctx.get("distance") {
Some(Arg::Double(d)) => *d,
_ => 0.0,
@@ -326,7 +326,7 @@ impl Game<'_> {
let size = self.border.size();
self.feedback(Text::translate(
"commands.worldborder.get",
&[Text::literal(format!("{size:.0}"))],
&[Text::literal(super::percent_f(size, 0))],
));
Ok((size + 0.5).floor() as i32)
}
@@ -346,7 +346,7 @@ impl Game<'_> {
if distance > MAX_SIZE {
return Err(err("commands.worldborder.set.failed.big", &[&"5.999997E7"]));
}
let shown = Text::literal(format!("{distance:.1}"));
let shown = Text::literal(super::percent_f(distance, 1));
if time > 0 {
self.border.lerp_to(distance, time);
let key = if distance > current {
@@ -376,23 +376,8 @@ impl Game<'_> {
let Some(p) = self.target_position(t) else {
continue;
};
let d = [pos[0] - p[0], pos[1] - p[1], pos[2] - p[2]];
let dist2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
let (at, v) = if dist2 > f64::from(range) * f64::from(range) {
if min_volume <= 0.0 {
continue;
}
let dist = dist2.sqrt();
(
[
p[0] + d[0] / dist * 2.0,
p[1] + d[1] / dist * 2.0,
p[2] + d[2] / dist * 2.0,
],
min_volume,
)
} else {
(pos, volume)
let Some((at, v)) = playsound_placement(pos, p, range, volume, min_volume) else {
continue;
};
// The connection is sent the sound; the window's own player
// plays it.
@@ -1499,7 +1484,7 @@ impl Game<'_> {
num(points.len()),
float(centre_f[0]),
float(centre_f[1]),
Text::literal(format!("{min_distance:.2}")),
Text::literal(super::percent_f(min_distance, 2)),
],
));
}
@@ -1553,7 +1538,7 @@ impl Game<'_> {
num(points.len()),
float(centre_f[0]),
float(centre_f[1]),
Text::literal(format!("{average:.2}")),
Text::literal(super::percent_f(average, 2)),
],
));
Ok(points.len() as i32)
@@ -1612,6 +1597,40 @@ fn text_error(key: &str, args: &[Text]) -> horizon_client::command::CommandError
horizon_client::command::CommandError::new(Text::translate(key, args))
}
/// `PlaySoundCommand.playSound`: the point and the volume one target hears the
/// sound at. A target past `range` hears nothing unless `minVolume` is above
/// zero, and then the point moves two blocks from the target towards the sound
/// and the volume is `minVolume`; a target exactly at `range` still hears it
/// (the command's test is `>`, unlike `PlayerList.broadcast`'s `<`).
fn playsound_placement(
pos: [f64; 3],
target: [f64; 3],
range: f32,
volume: f32,
min_volume: f32,
) -> Option<([f64; 3], f32)> {
let d = [pos[0] - target[0], pos[1] - target[1], pos[2] - target[2]];
let dist2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
// PlaySoundCommand compares against Mth.square(float range): the square
// rounds in float and only then widens, so a point between that square and
// the double square is out of range in vanilla.
if dist2 <= f64::from(range * range) {
return Some((pos, volume));
}
if min_volume <= 0.0 {
return None;
}
let dist = dist2.sqrt();
Some((
[
target[0] + d[0] / dist * 2.0,
target[1] + d[1] / dist * 2.0,
target[2] + d[2] / dist * 2.0,
],
min_volume,
))
}
/// `Entity.lookAt`: yaw and pitch from `from` towards `to`, the yaw wrapped
/// to -180..180 as `Mth.wrapDegrees` does.
pub(super) fn look_at(from: [f64; 3], to: [f64; 3]) -> [f32; 2] {
@@ -1626,7 +1645,7 @@ pub(super) fn look_at(from: [f64; 3], to: [f64; 3]) -> [f32; 2] {
#[cfg(test)]
mod tests {
use super::look_at;
use super::{look_at, playsound_placement};
fn assert_angle(got: f32, want: f32) {
assert!((got - want).abs() < 1.0e-4, "{got} is not {want}");
@@ -1646,4 +1665,56 @@ mod tests {
// The pitch is the elevation, unchanged; its own wrap is a no-op.
assert_angle(look_at(at, [0.0, 65.0, 1.0])[1], -45.0);
}
/// `PlaySoundCommand.playSound`: inside the range the point and volume are
/// the command's own, exactly on it still is, past it there is nothing
/// without a minimum volume, and with one the point is two blocks from the
/// target towards the sound and the volume is that minimum.
#[test]
fn a_target_past_the_range_hears_it_two_blocks_away() {
let pos = [0.0, 64.0, 0.0];
let near = [0.0, 64.0, 15.0];
assert_eq!(
playsound_placement(pos, near, 16.0, 3.0, 0.0),
Some((pos, 3.0))
);
let on_the_edge = [16.0, 64.0, 0.0];
assert_eq!(
playsound_placement(pos, on_the_edge, 16.0, 3.0, 0.0),
Some((pos, 3.0))
);
let far = [0.0, 64.0, 32.0];
assert_eq!(playsound_placement(pos, far, 16.0, 3.0, 0.0), None);
assert_eq!(
playsound_placement(pos, far, 16.0, 3.0, 0.5),
Some(([0.0, 64.0, 30.0], 0.5))
);
}
/// The range is squared as a float, as `Mth.square(getRange(volume))` is:
/// a point between that square and the double one is past the range in
/// vanilla, so it gets the `minVolume` placement rather than full volume.
#[test]
fn the_range_square_rounds_as_a_float() {
let volume = 1.1f32;
let range = 16.0 * volume;
let float_square = f64::from(range * range);
let double_square = f64::from(range) * f64::from(range);
assert!(float_square < double_square);
let dist2 = (float_square + double_square) / 2.0;
let pos = [0.0, 64.0, 0.0];
let target = [dist2.sqrt(), 64.0, 0.0];
assert_eq!(
playsound_placement(pos, target, range, volume, 0.0),
None,
"past the float range"
);
let moved = playsound_placement(pos, target, range, volume, 0.7);
let Some((point, heard)) = moved else {
panic!("minVolume should place it");
};
assert_eq!(heard, 0.7);
assert!((point[0] - (target[0] - 2.0)).abs() < 1e-9, "{point:?}");
assert_eq!(point[1..], [64.0, 0.0]);
}
}
+83 -50
View File
@@ -2297,29 +2297,7 @@ impl Game<'_> {
self.level_event_at(2001, pos, old.0 as i32);
}
}
MobEvent::CrackTurtleEgg { pos } => {
let state = self.world.block(pos[0], pos[1], pos[2]);
let eggs = self
.pack
.assets
.registry
.state(state)
.filter(|s| s.name == "minecraft:turtle_egg")
.and_then(|s| s.properties.get("eggs"))
.and_then(|e| e.parse::<i32>().ok());
if let Some(eggs) = eggs {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
// decreaseEggs: the break sound and the remaining
// eggs (a stack of one is destroyed after it).
self.with_level(|b, level, ticks| {
b.turtle_egg_destroyed_by_player(level, ticks, at, state);
if eggs <= 1 {
b.destroy_block(level, ticks, at, false);
}
});
self.process_level_events()?;
}
}
MobEvent::CrackTurtleEgg { pos } => self.crack_turtle_egg(pos)?,
MobEvent::BedOccupied { pos, occupied } => self.set_bed_occupied(pos, occupied),
MobEvent::SetDoorOpen { pos, open } => {
let pos = BlockPos::new(pos[0], pos[1], pos[2]);
@@ -2724,6 +2702,31 @@ impl Game<'_> {
}
Ok(())
}
/// `TurtleEggBlock.decreaseEggs` after an entity's step or landing won its
/// roll (`destroyEgg`): the break sound and the remaining eggs (a stack of
/// one is destroyed after it).
pub(super) fn crack_turtle_egg(&mut self, pos: [i32; 3]) -> Result<()> {
let state = self.world.block(pos[0], pos[1], pos[2]);
let eggs = self
.pack
.assets
.registry
.state(state)
.filter(|s| s.name == "minecraft:turtle_egg")
.and_then(|s| s.properties.get("eggs"))
.and_then(|e| e.parse::<i32>().ok());
if let Some(eggs) = eggs {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| {
b.turtle_egg_destroyed_by_player(level, ticks, at, state);
if eggs <= 1 {
b.destroy_block(level, ticks, at, false);
}
});
self.process_level_events()?;
}
Ok(())
}
/// `TurtleEggBlock.randomTick` at the last hatch stage: one baby turtle
/// per egg, a day from grown, with the egg as its home (the sound, the
/// crumbs and the block's removal came from the level).
@@ -3937,10 +3940,15 @@ impl Game<'_> {
.play(name, Some([at.x, at.y, at.z]), volume, pitch);
}
ArrowEvent::BubbleColumnParticles { pos } => self.bubble_column_particles(pos),
ArrowEvent::Block { pos, reaction } => match reaction {
ArrowEvent::Block {
pos,
reaction,
owner,
} => match reaction {
BlockReaction::PrimeTnt => {
// `TntBlock.onProjectileHit`: the arrow's owner lights it.
if self.tnt_prime_allowed(pos) {
self.spawn_primed_tnt(pos, false, None, None);
self.spawn_primed_tnt(pos, false, owner, None);
self.with_level(|b, level, ticks| b.remove_block(level, ticks, pos, false));
let _ = self.process_level_events();
}
@@ -6151,24 +6159,64 @@ impl Game<'_> {
}
}
/// Mob models and projectiles for the entity pass.
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = self.view_scale();
self.rendered_mobs.set(0);
// Mob models posed on the GPU (mobs::skin), when the renderer takes them this frame.
let skinning = self
/// The renderer's models for the GPU path (`mobs::skin`) this frame, if it
/// poses entities: a new frame of draws, the store restarted when the
/// renderer's no longer matches. Once per frame, before any entity draws.
pub(super) fn begin_skinning(&self) {
let stored = self
.renderer
.as_ref()
.filter(|r| r.skins_entities())
.and_then(|r| r.skinned_models());
match skinning {
match stored {
Some(stored) => self
.skin_models
.borrow_mut()
.begin(&mut self.skin_frame.borrow_mut(), stored),
None => self.skin_frame.borrow_mut().clear_draws(),
}
}
/// Whether this frame's models can be posed on the GPU (`begin_skinning`).
pub(super) fn skinning(&self) -> bool {
self.renderer
.as_ref()
.is_some_and(|r| r.skins_entities() && r.skinned_models().is_some())
}
/// Culls one entity's vertices from `entity_start`, counting what it drew
/// on the GPU path by the corners of `skinned`'s bounds; a culled entity's
/// posed draws since `skinned`'s mark are taken back.
pub(super) fn cull_skinned(
&self,
out: &mut Vec<Vertex>,
entity_start: usize,
skinned: Option<((usize, usize, u32), Option<(glam::Vec3, glam::Vec3)>)>,
) {
let corners = skinned.and_then(|(_, bounds)| bounds);
if let Some((lo, hi)) = corners {
out.extend([lo, hi].map(|pos| Vertex {
pos: pos.to_array(),
..bytemuck::Zeroable::zeroed()
}));
}
let len = out.len();
self.cull_entity_vertices(out, entity_start);
if out.len() < len {
if let Some((mark, _)) = skinned {
self.skin_frame.borrow_mut().truncate(mark);
}
} else if corners.is_some() {
out.truncate(len - 2);
}
}
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = self.view_scale();
self.rendered_mobs.set(0);
let skinning = self.skinning();
// What custom entity model animations read of the frame.
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
time: self.world.game_time as f64 + alpha,
@@ -6313,7 +6361,7 @@ impl Game<'_> {
let body = out.len();
// The GPU path: an ordinary mob, no outline, no layers the CPU path changes after
// drawing them (a player's, the HDR tiers below, the sulfur cube's shell).
let posable = skinning.is_some()
let posable = skinning
&& !is_player
&& !layers_only
&& outline.is_none()
@@ -6408,22 +6456,7 @@ impl Game<'_> {
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
{
// What the GPU draws of the mob counts by its bounds' corners.
let corners = skinned.and_then(|(_, bounds)| bounds);
if let Some((lo, hi)) = corners {
out.extend([lo, hi].map(|pos| Vertex {
pos: pos.to_array(),
..bytemuck::Zeroable::zeroed()
}));
}
let len = out.len();
self.cull_entity_vertices(out, entity_start);
if out.len() < len {
if let Some((mark, _)) = skinned {
self.skin_frame.borrow_mut().truncate(mark);
}
} else if corners.is_some() {
out.truncate(len - 2);
}
self.cull_skinned(out, entity_start, skinned);
}
// GuardianRenderer: the laser to the locked target.
if let Some((time, scale)) = horizon_client::mobs::guardian::beam(mob, alpha as f32)
+26 -14
View File
@@ -516,6 +516,22 @@ impl Game<'_> {
match part {
Part::Line(text, x, y) => {
batch.text_styled(atlas, &text, x, y, u32::MAX, true);
// AbstractStringWidget: only the styled glyph under the
// pointer requests a hand, not the whole message widget.
if (!clipped || in_area(cursor.1))
&& let Some(style) = horizon_client::dialog_screen::line_style_at(
&atlas.font,
&text,
x,
y,
cursor,
)
{
widgets::widget_cursor(style.click.is_some(), true);
if let Some(hover) = style.hover {
tooltip = Some(*hover);
}
}
}
Part::Field { input, rect } => {
let focused = open.focus == Some(input);
@@ -824,20 +840,15 @@ impl Game<'_> {
}
// AbstractStringWidget.setComponentClickHandler: the click event
// of the styled text under a centred message line.
Part::Line(line, x, y)
if text_click.is_none()
&& cursor.0 >= x
&& (0.0..9.0).contains(&(cursor.1 - y)) =>
{
let content = line.resolve_legacy();
let mut left = cursor.0 - x;
for (c, style) in content.chars() {
left -= self.gui.atlas.font.advance(c, style);
if left < 0.0 {
text_click = style.click.clone();
break;
}
}
Part::Line(line, x, y) if text_click.is_none() => {
text_click = horizon_client::dialog_screen::line_style_at(
&self.gui.atlas.font,
&line,
x,
y,
cursor,
)
.and_then(|style| style.click);
}
_ => {}
}
@@ -851,6 +862,7 @@ impl Game<'_> {
Some(ClickEvent::RunCommand(command)) => Click::RunCommand(command),
Some(ClickEvent::CopyToClipboard(value)) => Click::CopyToClipboard(value),
Some(ClickEvent::ShowDialog(dialog)) => Click::ShowDialog(dialog),
Some(ClickEvent::Custom { id, payload }) => Click::Custom { id, payload },
Some(ClickEvent::OpenFile(_)) | None => return Ok(()),
};
let after = open.dialog.after_action;
+2
View File
@@ -3210,6 +3210,8 @@ impl Game<'_> {
equipment: Vec::new(),
attributes: Vec::new(),
precise: false,
vertical_collision: false,
horizontal_collision: false,
needs_sync: false,
leash: None,
spectator: false,
@@ -12,57 +12,95 @@ impl Game<'_> {
self.luminance
.get_or_init(|| Luminance::new(self.res.facts(), self.pack.assets.registry))
}
pub(super) fn gather_dynamic_lights(&mut self, camera: DVec3, alpha: f64) {
let mut out = std::mem::take(&mut self.dynamic_lights);
out.clear();
/// The light sources this frame while the extra is on: the player's and mobs' held items and
/// fire, and glowing dropped items, each with the item that gives the light (none for fire).
pub(super) fn dynamic_light_sources(&self, alpha: f64) -> Vec<(Source, Option<String>)> {
let mut sources = Vec::new();
if !self.settings.extras.dynamic_lights {
self.dynamic_lights = out;
return;
return sources;
}
let lum = self.luminance();
let held = |item: Option<&str>| item.map_or(0, |i| lum.of(i));
let mut sources = Vec::new();
// The brighter of two hands with what it holds, or fire if it outshines both.
let brighter = |items: [Option<&str>; 2], on_fire: bool| {
let (level, item) = items
.into_iter()
.flatten()
.map(|i| (lum.of(i), Some(i)))
.max_by_key(|(level, _)| *level)
.unwrap_or((0, None));
if on_fire && BURNING > level {
(BURNING, None)
} else {
// Only a lit source names its item: nothing is allocated for a dark one.
(level, item.filter(|_| level > 0).map(str::to_owned))
}
};
if let Some(p) = &self.player
&& !p.spectator
{
let feet = self
.previous_position
.lerp(p.position, self.clock.camera_fraction());
let level = held(self.stack_in(self.selected).map(|s| &*s.item))
.max(held(self.stack_in(shield::OFFHAND_SLOT).map(|s| &*s.item)))
.max(if p.fire_ticks > 0 { BURNING } else { 0 });
let (level, item) = brighter(
[
self.stack_in(self.selected).map(|s| &*s.item),
self.stack_in(shield::OFFHAND_SLOT).map(|s| &*s.item),
],
p.fire_ticks > 0,
);
// About where the hands are.
sources.push(Source {
at: DVec3::new(feet.x, feet.y + 1.2, feet.z),
level,
});
sources.push((
Source {
at: DVec3::new(feet.x, feet.y + 1.2, feet.z),
level,
},
item,
));
}
for m in self.mobs.iter().filter(|m| !m.removed) {
let level = held(m.extra.main_hand.as_deref())
.max(held(m.extra.off_hand.as_deref()))
.max(if m.fire_ticks > 0 { BURNING } else { 0 });
let (level, item) = brighter(
[m.extra.main_hand.as_deref(), m.extra.off_hand.as_deref()],
m.fire_ticks > 0,
);
if level == 0 {
continue;
}
let at = m.previous.lerp(m.position, alpha);
sources.push(Source {
at: DVec3::new(at.x, at.y + f64::from(m.height()) * 0.6, at.z),
level,
});
sources.push((
Source {
at: DVec3::new(at.x, at.y + f64::from(m.height()) * 0.6, at.z),
level,
},
item,
));
}
for item in &self.dropped {
let level = lum
.of(&item.stack.item)
.max(if item.fire_ticks > 0 { BURNING } else { 0 });
for dropped in &self.dropped {
let (level, item) =
brighter([Some(&*dropped.stack.item), None], dropped.fire_ticks > 0);
if level == 0 {
continue;
}
let at = item.previous.lerp(item.position, alpha);
sources.push(Source {
at: DVec3::new(at.x, at.y + 0.25, at.z),
level,
});
let at = dropped.previous.lerp(dropped.position, alpha);
sources.push((
Source {
at: DVec3::new(at.x, at.y + 0.25, at.z),
level,
},
item,
));
}
sources
}
/// Fills `dynamic_lights` for the frame from [`Self::dynamic_light_sources`], the nearest
/// [`dynamic_light::MAX`] of them.
pub(super) fn gather_dynamic_lights(&mut self, camera: DVec3, alpha: f64) {
let mut out = std::mem::take(&mut self.dynamic_lights);
out.clear();
let sources: Vec<Source> = self
.dynamic_light_sources(alpha)
.into_iter()
.map(|(s, _)| s)
.collect();
dynamic_light::select(&sources, camera, &mut out);
self.dynamic_lights = out;
}
@@ -143,7 +143,10 @@ fn optimized(root: &Path) -> BTreeMap<String, Part> {
} else {
"armor_stand"
};
models.insert(format!("{model}/{slot}"), part);
models.insert(
format!("{model}/{slot}"),
std::sync::Arc::unwrap_or_clone(part),
);
}
for (slot, part) in armor.player {
models.insert(format!("player/{slot}"), part);
@@ -153,7 +156,10 @@ fn optimized(root: &Path) -> BTreeMap<String, Part> {
("armor_stand_small", armor.small),
] {
if let Some(part) = part {
models.insert(format!("{model}/main"), part);
models.insert(
format!("{model}/main"),
std::sync::Arc::unwrap_or_clone(part),
);
}
}
models
+47
View File
@@ -186,6 +186,19 @@ impl Game<'_> {
self.swing_arm();
true
}
/// The boat the hook's owner rides (`Entity.isPassengerOfSameVehicle`).
fn own_vehicle(&self) -> Option<u32> {
match self.lan_acting {
None => self.riding,
Some(conn) => self.lan.as_ref().and_then(|h| {
h.hub
.guests
.iter()
.find(|g| g.conn == conn)
.and_then(|g| g.vehicle)
}),
}
}
/// `FishingHook.retrieve`: the rod damage it costs.
fn retrieve_hook(&mut self, rod: &ItemStack) -> u32 {
let Some(hook) = self.hook.take() else {
@@ -212,6 +225,10 @@ impl Game<'_> {
hooked_item = true;
} else if let Some(t) = self.tnts.iter_mut().find(|t| t.id != 0 && t.id == id) {
t.velocity = pulled(t.velocity);
} else if let Some(a) = self.armor_stands.iter_mut().find(|a| a.id == id) {
a.velocity = pulled(a.velocity);
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
b.velocity = pulled(b.velocity);
} else if self.creatures.hook_targets.iter().any(|t| t.id == id) {
// A player: the hub moves them when the tick ends.
self.creatures
@@ -604,6 +621,24 @@ impl Game<'_> {
.find(|t| t.conn != owner && touches(t.bounds))
.map(|t| t.id)
})
.or_else(|| {
// Entity.canBeHitByProjectile: `isAlive() && isPickable()`;
// ArmorStand.isPickable is false for a marker.
self.armor_stands
.iter()
.find(|a| a.health > 0.0 && !a.marker && touches(a.bounds()))
.map(|a| a.id)
})
.or_else(|| {
// AbstractBoat.isPickable; Projectile.canHitEntity skips the
// boat the owner rides (approximating `leftOwner`, as the
// players above skip their owner).
let own = self.own_vehicle();
self.boats
.iter()
.find(|b| Some(b.id) != own && touches(b.bounds()))
.map(|b| b.id)
})
.or_else(|| {
self.dropped
.iter()
@@ -678,6 +713,18 @@ impl Game<'_> {
)
})
})
.or_else(|| {
self.armor_stands.iter().find(|a| a.id == id).map(|a| {
let b = a.bounds();
(a.position, b.max_position().y - b.min_position().y)
})
})
.or_else(|| {
self.boats
.iter()
.find(|b| b.id == id)
.map(|b| (b.position, super::boats::HEIGHT))
})
.or_else(|| {
self.dropped
.iter()
+2 -2
View File
@@ -715,12 +715,12 @@ impl Game<'_> {
self.finish_open_world(opening.world?.finish()?)?;
drop(open_step);
if let Some(sync) = opening.sync {
sync.adopt(&self.pack_files());
startup::time("server.sync_adopt", || sync.adopt(&self.pack_files()));
}
let saved = Box::new(self.level.clone());
// IntegratedServer runs the server mods; the window the client mods.
self.mods.dev = self.options.dev_mods.clone();
self.start_mods();
startup::time("server.start_mods", || self.start_mods());
// Math.max(2, options.renderDistance): past vanilla's 32 when the
// window's render distance is (Horizon's options go to 64).
let view_distance = self.settings.render_distance.max(2);
+10 -6
View File
@@ -1302,12 +1302,14 @@ impl Game<'_> {
.get("minecraft:worldgen/biome")
.map(|v| v.iter().map(|(n, _)| n.clone()).collect())
.unwrap_or_default();
let mut encoder = ChunkEncoder::new(
self.res
.facts()
.states
.iter()
.map(|s| (s.name.clone(), s.fluid_level > 0 || s.waterlogged)),
let mut encoder = ChunkEncoder::with_signs(
self.res.facts().states.iter().map(|s| {
(
s.name.clone(),
s.fluid_level > 0 || s.waterlogged,
s.sign_form(),
)
}),
&sync.builtin,
&biome_names,
&registry,
@@ -1821,6 +1823,8 @@ impl Game<'_> {
e.velocity = [d.velocity.x, d.velocity.y, d.velocity.z];
e.precise = d.needs_sync;
e.needs_sync = d.needs_sync;
e.vertical_collision = d.vertical_collision;
e.horizontal_collision = d.horizontal_collision;
// Entity.DATA_SHARED_FLAGS_ID: glowing 0x40 (the Glowing tag).
if self.entity_glowing(d.id) {
e.meta.push((0, serializer::BYTE, Value::Byte(0x40)));
@@ -2205,6 +2205,8 @@ impl Game<'_> {
});
}
}
// AbstractContainerMenu.quickMoveStack of a mod container: the mods first.
S::ContainerClick(p) if self.lan_mod_quick_move(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_merchant_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_inventory_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_crafting_click(host, conn, &p) => {}
@@ -25,7 +25,7 @@ pub(super) struct GuestCrafting {
/// The open furnace's or brewing stand's window and menu (`AbstractFurnaceMenu`,
/// `BrewingStandMenu`); its slots live in the block entity and the hub's
/// container menu keeps the client in sync.
machine: Option<(i32, Box<Menu>)>,
pub(super) machine: Option<(i32, Box<Menu>)>,
}
/// ServerboundContainerClickPacket's input.
@@ -46,6 +46,8 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
equipment: Vec::new(),
attributes: Vec::new(),
precise: false,
vertical_collision: false,
horizontal_collision: false,
needs_sync: false,
leash: None,
spectator: false,
@@ -400,6 +400,16 @@ impl Game<'_> {
if let Some(powered) = registry.id(&s) {
// The rod's onPlace schedules the 8-tick unpowering.
let _ = self.level_set(p, powered);
self.with_level(|b, level, ticks| {
if level.redstone_experiments {
b.lightning_rod_update_neighbors(
level,
ticks,
BlockPos::new(p[0], p[1], p[2]),
state,
);
}
});
}
// Level event 3002 with the rod's axis: sparks along it.
let axis = match s.properties.get("facing").map(String::as_str) {
@@ -1438,6 +1438,8 @@ mod item_column_tests {
previous: at,
velocity: Vec3::ZERO,
on_ground: true,
vertical_collision: false,
horizontal_collision: false,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
+27 -20
View File
@@ -131,8 +131,10 @@ pub(super) struct Minecart {
pub loot: Option<horizon_client::save::RolledLoot>,
/// `Entity.firstTick`: not ticked yet (saved as `HasTicked`).
pub first_tick: bool,
/// `Entity.isInWater`: set by the end of the last tick's fluid update, so a
/// cart's first tick still falls at the dry rate.
/// `Entity.isInWater` (`wasTouchingWater`): `AbstractMinecart.tick`
/// updates it after the move (`updateFluidInteraction`), so the move
/// goes by where the cart was a tick before, and a new cart's first
/// move is a dry one.
pub in_water: bool,
/// Command block minecarts: the cart's `BaseCommandBlock` (command,
/// success count, last output, output tracking). The block-only flags of
@@ -154,8 +156,9 @@ pub(super) struct Minecart {
pub synced_command: String,
pub synced_output: String,
}
pub(super) const WIDTH: f64 = 0.98;
pub(super) const HEIGHT: f64 = 0.7;
/// `EntityType.MINECART`'s `sized(0.98F, 0.7F)`: float dimensions, widened.
pub(super) const WIDTH: f64 = 0.98_f32 as f64;
pub(super) const HEIGHT: f64 = 0.7_f32 as f64;
/// AbstractMinecart.getDefaultDisplayOffset, in sixteenths of a block.
fn display_offset(kind: CartKind) -> f64 {
if kind == CartKind::Hopper { 1.0 } else { 6.0 }
@@ -388,9 +391,6 @@ impl Game<'_> {
.get(self.world.block(x, y, z))
.full_collision
}
fn cart_in_water(&self, c: &Minecart) -> bool {
c.in_water
}
/// `OldMinecartBehavior.getPos`: the point on the rail under (x, y, z).
fn rail_pos(&self, x: f64, y: f64, z: f64) -> Option<[f64; 3]> {
let [xt, yt, zt] = self.rail_block(Vec3::new(x, y, z));
@@ -522,10 +522,11 @@ impl Game<'_> {
if (moved.y - delta.y).abs() > 1.0e-7 {
c.velocity.y = 0.0;
}
// Entity.checkFallDamage: the move's drop counts (not in water),
// Entity.checkFallDamage: the move's drop counts (not in water, as of
// the tick before: `isInWater` is `wasTouchingWater`),
// and a landing hands the whole fall to causeFallDamage, which
// sets a TNT minecart off at (fall / 10) squared.
if moved.y < 0.0 && !self.cart_in_water(c) {
if moved.y < 0.0 && !c.in_water {
c.fall_distance -= f64::from(moved.y as f32);
}
if c.on_ground {
@@ -543,9 +544,9 @@ impl Game<'_> {
self.cart_block_effects(c);
}
fn max_speed(&self, c: &Minecart) -> f64 {
let base = if self.cart_in_water(c) { 0.2 } else { 0.4 };
let base = if c.in_water { 0.2 } else { 0.4 };
if c.kind == CartKind::Furnace {
base * if self.cart_in_water(c) { 0.75 } else { 0.5 }
base * if c.in_water { 0.75 } else { 0.5 }
} else {
base
}
@@ -554,7 +555,7 @@ impl Game<'_> {
/// `getSlowdownFactor`: 0.997 riding, the empty cart's own by behavior.
fn natural_slowdown(&self, c: &mut Minecart, ridden: bool, factor: f64) -> Vec3 {
let mut v = c.velocity;
let water = self.cart_in_water(c);
let water = c.in_water;
if c.kind == CartKind::Furnace {
if c.push.length_squared() > 1.0e-7 {
c.push = push_along(c.push, v);
@@ -569,7 +570,9 @@ impl Game<'_> {
let f = if ridden { 0.997 } else { factor };
let mut out = Vec3::new(v.x * f, 0.0, v.z * f);
if water {
out = Vec3::new(out.x * 0.95, 0.0, out.z * 0.95);
// scale(0.95F): the float widened.
let drag = f64::from(0.95_f32);
out = Vec3::new(out.x * drag, 0.0, out.z * drag);
}
out
}
@@ -596,7 +599,7 @@ impl Game<'_> {
power = rail.powered;
halt = !power;
}
let slide = if self.cart_in_water(c) {
let slide = if c.in_water {
0.0078125 * 0.2
} else {
0.0078125
@@ -718,7 +721,7 @@ impl Game<'_> {
/// `NewMinecartBehavior.getMaxSpeed`: the game rule over water's half,
/// every cart alike (the old behavior's furnace bonus is its own).
fn max_speed_new(&self, c: &Minecart) -> f64 {
if self.cart_in_water(c) { 0.2 } else { 0.4 }
if c.in_water { 0.2 } else { 0.4 }
}
/// `AbstractMinecart.getRedstoneDirection` (over the old port's
/// conductor proxy): which way a powered rail pushes a resting cart.
@@ -774,7 +777,7 @@ impl Game<'_> {
// calculateSlopeSpeed (once per tick): the slide grows with the
// speed, so a descending cart keeps its pace.
if !slope_done {
let base: f64 = if self.cart_in_water(c) {
let base: f64 = if c.in_water {
0.0078125 * 0.2
} else {
0.0078125
@@ -948,10 +951,12 @@ impl Game<'_> {
// unlike the rail paths, which re-apply the track's velocity.
c.collision_speed = 0.0;
if !c.on_ground {
// scale(getAirDrag()): 0.95F, widened.
let drag = f64::from(0.95_f32);
c.velocity = Vec3::new(
c.velocity.x * 0.95,
c.velocity.y * 0.95,
c.velocity.z * 0.95,
c.velocity.x * drag,
c.velocity.y * drag,
c.velocity.z * drag,
);
}
}
@@ -992,7 +997,7 @@ impl Game<'_> {
c.damage = (c.damage - 1.0).max(0.0);
// applyGravity
if !c.no_gravity {
c.velocity.y -= if self.cart_in_water(c) { 0.005 } else { 0.04 };
c.velocity.y -= if c.in_water { 0.005 } else { 0.04 };
}
let pos = self.rail_block(c.position);
if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) {
@@ -2260,6 +2265,8 @@ impl Game<'_> {
world: &self.world,
sprites: &self.gui.atlas.sprites,
emissive: &self.gui.atlas.emissive,
translucent_sprites: &self.gui.atlas.translucent,
atlas_revision: self.gui.atlas.revision,
nether: self.dim == super::Dim::Nether,
lid: &lid,
sign: &|_| None,
@@ -296,7 +296,8 @@ impl Game<'_> {
/// drawn by no vanilla renderer) whose block entity is a mod's, closer than
/// `BlockEntityRenderer.getViewDistance` to the camera (`shouldRender`)
/// and with its bounds (`native_block_entity_render_bounding_box`) in the
/// frustum.
/// frustum. Entity occlusion culling is for ordinary entities; block
/// entities use these renderer-provided bounds instead.
pub(super) fn mod_block_entity_geometry<'a>(
&self,
blocks: impl Iterator<Item = &'a ([i32; 3], u32)>,
@@ -343,7 +344,6 @@ impl Game<'_> {
pos: (Vec3::from_array(v.pos) + origin).to_array(),
..*v
}));
self.cull_entity_vertices(&mut draw.vertices, start);
}
continue;
}
@@ -379,7 +379,6 @@ impl Game<'_> {
for v in &mut draw.vertices[start..] {
v.pos = (Vec3::from_array(v.pos) + origin).to_array();
}
self.cull_entity_vertices(&mut draw.vertices, start);
}
*out = draw.vertices;
}
+200 -1
View File
@@ -487,6 +487,7 @@ use horizon_client::{
items::ItemStack,
keybinds,
lan::menu::Slots,
merchant::MerchantOffer,
mobs::{Damage, Who},
mod_list::{self, Installed, NativePage},
music::{Music, MusicLayer},
@@ -2880,6 +2881,50 @@ impl Game<'_> {
) {
mod_api::dispatch_menu_button_v2(self, mod_api::mods(), pos, entity, id, carried);
}
/// A player shift-clicked slot `slot` of the menu of the mod block
/// entity at `pos` (`AbstractContainerMenu.quickMoveStack`, the method a
/// Java menu overrides), on the server, before the game's own routing:
/// move the stack where your block wants it. `slot` is the menu's index:
/// below `container_slots` it is the block's own slot (`entity.items`),
/// from there on the player's inventory (27 main slots, then the hotbar).
/// `stack` is a copy of what the slot holds; take what you move out of
/// it (put it in `entity.items`, or in another block with
/// `native_insert_item`) and set `handled`: the slot then holds what is
/// left of `stack` (whatever you wrote to that slot of `entity.items`),
/// the game's routing is skipped, and the player's client gets the menu
/// again. Left unhandled, `entity.items` goes back to what it held and
/// the game moves the stack as for any container; a later mod sees what
/// an earlier one did to `stack`.
///
/// For a mod container on a vanilla menu type and on a mod menu type,
/// the left or right button, a slot that holds something and that the
/// mod's screen shows; not for a spectator or a dead player, a menu no
/// longer valid, nor during a drag.
/// Asked once per click, where vanilla asks `quickMoveStack` again while
/// it keeps moving the same item. The player's state is swapped in, so
/// `native_acting_player` names them; `entity` is out of the world
/// during the call, as in `native_menu_button`.
/// API @API:menu_quick_move@.
pub(super) fn native_menu_quick_move(
&mut self,
pos: [i32; 3],
entity: &mut ModBlockEntity,
container_slots: usize,
slot: usize,
stack: &mut ItemStack,
handled: &mut bool,
) {
mod_api::dispatch_menu_quick_move(
self,
mod_api::mods(),
pos,
entity,
container_slots,
slot,
stack,
handled,
);
}
/// The commands native mods add to the server's command tree: push
/// yours. Asked once when a world opens to players (a dedicated
/// server, a LAN host, single player's server thread); every player's
@@ -3962,6 +4007,24 @@ impl Game<'_> {
pub(super) fn native_player_trade(&mut self, player: u128, name: &str, entity_id: u32) {
mod_api::dispatch_player_trade(self, mod_api::mods(), player, name, entity_id);
}
/// A server player traded with a villager or a wandering trader
/// (NeoForge's `TradeWithVillagerEvent`, at vanilla's
/// `MerchantResultSlot.onTake` once the offer is taken): `merchant` is
/// the trader's entity id and `kind` its type (`minecraft:villager`,
/// `minecraft:wandering_trader`), `offer` the trade as it is after this
/// one (its `uses` counted, `result` what the player got). Once per
/// trade, so a shift-click that trades several times is heard for each,
/// after the trade's statistic and `villager_trade` trigger. Listen
/// only: the trade is made. The player's state is swapped in
/// (`native_acting_player`). API @API:trade_with_villager@.
pub(super) fn native_trade_with_villager(
&mut self,
merchant: u32,
kind: &str,
offer: &MerchantOffer,
) {
mod_api::dispatch_trade_with_villager(self, mod_api::mods(), merchant, kind, offer);
}
/// A server player's shield was disabled by a blocked melee hit. The
/// shield cooldown is set immediately after this call. Helpers looking up
/// the guest through `self.lan` may find nothing during packet handling.
@@ -4408,6 +4471,29 @@ impl Game<'_> {
mod_api::dispatch_lightning_strike(self, mod_api::mods(), player, pos, cause, cancel);
}
/// Enough players have slept through the night in level `dimension`
/// and the clock is about to move to the morning (NeoForge's
/// `SleepFinishedTimeEvent`, vanilla's `ServerLevel.tick` when it moves
/// the clock to `wake_up_from_sleep`): `new_time` comes in as the next
/// morning's day time and may be changed to wake everyone at another
/// time. A time before `current` (the day time now) is refused, as
/// `setTimeAddition` refuses one, and the morning stays. Unlike
/// NeoForge, which refuses a bad value as it is set, the check runs once
/// after every mod, so a later mod sees the value an earlier one left.
/// On the server, only when the `advance_time` rule is on;
/// `native_time_skip` then hears the jump to the time chosen here and
/// may still cancel it. The players wake and the weather resets as
/// usual either way.
/// API @API:sleep_finished_time@.
pub(super) fn native_sleep_finished_time(
&mut self,
dimension: &str,
current: i64,
new_time: &mut i64,
) {
mod_api::dispatch_sleep_finished_time(self, mod_api::mods(), dimension, current, new_time);
}
/// The overworld clock is about to jump, from sleep or `/time`. Set
/// `cancel` to keep `from`. @API:native_time_skip@.
pub(super) fn native_time_skip(
@@ -7417,6 +7503,16 @@ impl Game<'_> {
/// `EntityBlock.newBlockEntity` of block `name`: Horizon's own, else a
/// native mod's.
pub(super) fn new_block_entity(&mut self, name: &str) -> Option<BlockEntity> {
// `SignBlock.newBlockEntity`: a mod's sign is one by its template's class.
if !name.starts_with("minecraft:")
&& let Some(state) = self.pack.assets.registry.default_state(name)
&& let Some(form) = self.res.facts().get(state).sign_form()
{
return Some(BlockEntity::Sign(horizon_client::signs::SignData {
hanging: form.hanging,
..Default::default()
}));
}
if let Some(entity) = BlockEntity::for_block(name) {
return Some(entity);
}
@@ -7813,7 +7909,7 @@ impl Game<'_> {
}
/// Puts back a mod block entity taken out for a call, unless the call
/// removed or replaced it, with what its `setChanged` does.
fn put_back_mod_block_entity(
pub(super) fn put_back_mod_block_entity(
&mut self,
pos: Pos,
mut taken: ModBlockEntity,
@@ -8197,6 +8293,109 @@ fn mod_screen_opens(
#[cfg(test)]
mod tests {
/// Exercise the gameplay constructor, where name-based entities compete
/// with a mod block's sign template.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn mod_sign_constructor_prioritizes_templates_over_suffixes() {
use super::super::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::mod_content::{ContentMod, build};
use horizon_mods::content::{Block, Content};
let base = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let root = std::env::temp_dir().join(format!(
"horizon-sign-constructor-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let cases = [
("ruby:notice_banner", "minecraft:oak_sign", false),
("ruby:board_shelf", "minecraft:oak_wall_sign", false),
("ruby:lantern_head", "minecraft:oak_hanging_sign", true),
(
"ruby:bracket_skull",
"minecraft:oak_wall_hanging_sign",
true,
),
];
let mut content = Content::declared("ruby");
let blocks: Vec<_> = cases
.iter()
.map(|&(id, template, _)| (id, template))
.chain([("ruby:road_sign", "minecraft:stone")])
.map(|(id, template)| Block {
id: id.into(),
template: template.into(),
item: false,
..Default::default()
})
.collect();
content.declare(&blocks, &[]).unwrap();
build(
&base,
&[ContentMod {
id: "ruby".into(),
content,
}],
&root,
)
.unwrap();
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!("no menu handoff"),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = super::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
for (name, hanging) in cases
.map(|(name, _, hanging)| (name, hanging))
.into_iter()
.chain([
("minecraft:oak_sign", false),
("minecraft:oak_hanging_sign", true),
])
{
match game.new_block_entity(name) {
Some(super::BlockEntity::Sign(sign)) => {
assert_eq!(sign.hanging, hanging, "{name}")
}
entity => panic!("{name}: expected a sign, got {entity:?}"),
}
}
assert!(game.new_block_entity("ruby:road_sign").is_none());
assert!(matches!(
game.new_block_entity("minecraft:white_banner"),
Some(super::BlockEntity::Banner(_))
));
let _ = std::fs::remove_dir_all(&root);
}
/// A mod's own screen opens only over the world, never over another
/// screen, an overlay (the title screen, a book, loading, no window) or
/// another mod's; opening it again is a no-op.
@@ -0,0 +1,353 @@
//! A shift-click in a mod container's menu, routed by the mods
//! (`native_menu_quick_move`, `AbstractContainerMenu.quickMoveStack`) before
//! the game's own routing.
use super::{Game, lan::LanHost};
use horizon_client::{
block_entities::Pos, containers::BlockEntity, items::ItemStack, lan::menu::Slots,
};
use horizon_protocol::packets::play;
/// `ClickType.QUICK_MOVE`'s id in `ServerboundContainerClickPacket`.
const QUICK_MOVE: i32 = 1;
/// Where a menu slot of a block container's menu lives: the container's own
/// slot `k`, or the player's inventory index (hotbar 0 to 8, then the 27
/// main slots), the menu listing the main slots before the hotbar.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum MenuSlot {
Container(usize),
Inventory(usize),
}
/// The slot a quick move of `slot` on a menu with `size` container slots
/// asks a mod about, or None when the click is not one: another click type,
/// a button other than left or right (`doClick` takes 0 and 1), no slot,
/// a slot past the menu, or a container slot the mod's screen hides (the
/// menu has no such slot, so the click goes nowhere).
pub(super) fn quick_move_slot(
input: i32,
button: i32,
slot: i32,
size: usize,
hidden: &[usize],
) -> Option<MenuSlot> {
if input != QUICK_MOVE || !(0..=1).contains(&button) {
return None;
}
let slot = usize::try_from(slot).ok()?;
if slot < size {
return (!hidden.contains(&slot)).then_some(MenuSlot::Container(slot));
}
let n = slot - size;
match n {
0..27 => Some(MenuSlot::Inventory(9 + n)),
27..36 => Some(MenuSlot::Inventory(n - 27)),
_ => None,
}
}
/// What a quick move the mods were asked about leaves: when one took it
/// (`handled`), the clicked slot holds what is left of `stack`, whatever the
/// mods wrote there, and their other changes to the block's items stay;
/// otherwise the block's items are as they were before the call (`before`),
/// so nothing a mod moved without taking the click is kept twice.
fn settle(
at: MenuSlot,
handled: bool,
stack: ItemStack,
before: &[Option<ItemStack>],
items: &mut Vec<Option<ItemStack>>,
inventory: &mut [Option<ItemStack>],
) {
if !handled {
before.clone_into(items);
return;
}
let left = (stack.count > 0).then_some(stack);
match at {
MenuSlot::Container(k) => {
if items.len() <= k {
items.resize(k + 1, None);
}
items[k] = left;
}
MenuSlot::Inventory(i) => {
if let Some(s) = inventory.get_mut(i) {
*s = left;
}
}
}
}
impl Game<'_> {
/// `ServerGamePacketListenerImpl.handleContainerClick` for a quick move
/// in a guest's open mod container menu (on a vanilla menu type or the
/// mod's own): the mods' `native_menu_quick_move` first, as the clicking
/// player. True when a mod took it: the slot keeps what the mod left and
/// the guest's client gets the whole menu again. False for every other
/// click, and for one no mod took, so the game's own routing runs.
pub(super) fn lan_mod_quick_move(
&mut self,
host: &mut LanHost,
conn: u32,
p: &play::ContainerClick,
) -> bool {
let Some(g) = host.hub.guest(conn) else {
return false;
};
let Some(menu) = g
.menu
.as_ref()
.filter(|m| p.container != 0 && m.window == p.container)
else {
return false;
};
let Slots::Block(pos) = menu.slots else {
return false;
};
let Some(at) = quick_move_slot(
p.input,
i32::from(p.button),
i32::from(p.slot),
menu.size,
&menu.hidden,
) else {
return false;
};
// A spectator's or a dead player's click and a menu no longer valid
// change nothing, and a shift-click during a drag only ends the drag
// (`doClick`'s resetQuickCraft): the game's own path answers them.
if g.spectator()
|| g.dead
|| menu.drag.is_some()
|| !menu.slots.still_valid(&self.world, g, menu.pos)
{
return false;
}
let size = menu.size;
let window = menu.window;
if !matches!(
self.world.block_entities.get(&pos),
Some(BlockEntity::Mod(m)) if !m.id.is_empty()
) {
return false;
}
let handled = self
.as_guest(host, conn, |game| {
game.mod_menu_quick_move(pos, size, p.slot as usize, at)
})
.unwrap_or(false);
if !handled {
return false;
}
// The mod menu type's engine starts over from the block entity next click.
if let Some(play) = host.play.get_mut(&conn)
&& play
.crafting
.machine
.as_ref()
.is_some_and(|(w, _)| *w == window)
{
play.crafting.machine = None;
}
// sendAllDataToRemote: the client predicted the game's routing. A mod
// that removed its own block leaves no menu to send: it closes, and
// the inventory goes out on its own.
if matches!(
self.world.block_entities.get(&pos),
Some(BlockEntity::Mod(_))
) {
host.hub.resend_menu(conn, window, &self.world);
} else if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) {
host.hub.close_container(conn);
host.hub.send_inventory(gi);
}
true
}
/// The mods' `native_menu_quick_move` on menu slot `slot` (living at
/// `at`) of the mod container at `pos` (`size` container slots), for the
/// player in place: true when
/// one took it, the slot then holding what is left of its stack. The
/// block entity is out of the world during the call.
fn mod_menu_quick_move(&mut self, pos: Pos, size: usize, slot: usize, at: MenuSlot) -> bool {
let Some(BlockEntity::Mod(m)) = self.world.block_entities.get_mut(&pos) else {
return false;
};
let held = match at {
MenuSlot::Container(k) => m.items.get(k).cloned().flatten(),
MenuSlot::Inventory(i) => self.inventory.get(i).cloned().flatten(),
};
let Some(mut stack) = held.filter(|s| s.count > 0) else {
return false;
};
let mut taken = std::mem::take(&mut **m);
let items = taken.items.clone();
let mut handled = false;
self.native_menu_quick_move(
[pos.0, pos.1, pos.2],
&mut taken,
size,
slot,
&mut stack,
&mut handled,
);
settle(
at,
handled,
stack,
&items,
&mut taken.items,
&mut self.inventory,
);
self.put_back_mod_block_entity(pos, taken, &items);
handled
}
}
#[cfg(test)]
mod tests {
use super::{MenuSlot, quick_move_slot, settle};
use horizon_client::items::ItemStack;
fn stack(item: &str, count: u32) -> ItemStack {
ItemStack {
item: item.into(),
count,
damage: 0,
extra: None,
}
}
/// A quick move asks about the clicked slot only: a `generic_9x1` menu
/// has the container's 9, then the main inventory, then the hotbar.
#[test]
fn a_quick_move_names_the_container_or_inventory_slot() {
assert_eq!(
quick_move_slot(1, 0, 0, 9, &[]),
Some(MenuSlot::Container(0))
);
assert_eq!(
quick_move_slot(1, 1, 8, 9, &[]),
Some(MenuSlot::Container(8))
);
// The first main slot is inventory index 9, the last 35.
assert_eq!(
quick_move_slot(1, 0, 9, 9, &[]),
Some(MenuSlot::Inventory(9))
);
assert_eq!(
quick_move_slot(1, 0, 35, 9, &[]),
Some(MenuSlot::Inventory(35))
);
// The hotbar comes last in the menu and first in the inventory.
assert_eq!(
quick_move_slot(1, 0, 36, 9, &[]),
Some(MenuSlot::Inventory(0))
);
assert_eq!(
quick_move_slot(1, 0, 44, 9, &[]),
Some(MenuSlot::Inventory(8))
);
}
/// Only a left or right shift-click on a slot the menu has is a quick
/// move: no other click type, button, outside click or hidden slot.
#[test]
fn other_clicks_are_not_quick_moves() {
// PICKUP, SWAP, CLONE, THROW, QUICK_CRAFT, PICKUP_ALL.
for input in [0, 2, 3, 4, 5, 6] {
assert_eq!(quick_move_slot(input, 0, 0, 9, &[]), None);
}
assert_eq!(quick_move_slot(1, 2, 0, 9, &[]), None);
assert_eq!(quick_move_slot(1, -1, 0, 9, &[]), None);
assert_eq!(quick_move_slot(1, 0, -999, 9, &[]), None);
assert_eq!(quick_move_slot(1, 0, 45, 9, &[]), None);
// A slot the mod's screen hides is not in the menu.
assert_eq!(quick_move_slot(1, 0, 3, 9, &[3]), None);
assert_eq!(
quick_move_slot(1, 0, 4, 9, &[3]),
Some(MenuSlot::Container(4))
);
// The inventory's slots are never hidden.
assert_eq!(
quick_move_slot(1, 0, 9, 9, &[0, 1, 2, 3, 4, 5, 6, 7, 8]),
Some(MenuSlot::Inventory(9))
);
}
/// A taken shift-click from the inventory: the clicked slot keeps what the
/// mod left, and what it put in its block stays there.
#[test]
fn a_taken_click_leaves_the_remainder_in_the_clicked_slot() {
let before = vec![None; 9];
let mut items = before.clone();
items[8] = Some(stack("minecraft:cobblestone", 32));
let mut inventory = vec![None; 36];
inventory[0] = Some(stack("minecraft:cobblestone", 64));
let left = stack("minecraft:cobblestone", 32);
settle(
MenuSlot::Inventory(0),
true,
left.clone(),
&before,
&mut items,
&mut inventory,
);
assert_eq!(inventory[0], Some(left));
assert_eq!(items[8], Some(stack("minecraft:cobblestone", 32)));
// All of it taken: the slot empties.
settle(
MenuSlot::Inventory(0),
true,
stack("minecraft:cobblestone", 0),
&before,
&mut items,
&mut inventory,
);
assert_eq!(inventory[0], None);
}
/// A taken click on the block's own slot: the remainder wins over what the
/// mod wrote to that slot, so a moved stack is never also left behind.
#[test]
fn the_clicked_container_slot_gets_the_remainder() {
let before = vec![Some(stack("minecraft:dirt", 10))];
// The mod moved the dirt to a slot past the menu's and left the clicked one as it was.
let mut items = vec![
Some(stack("minecraft:dirt", 10)),
None,
Some(stack("minecraft:dirt", 10)),
];
let mut inventory = vec![None; 36];
settle(
MenuSlot::Container(0),
true,
stack("minecraft:dirt", 0),
&before,
&mut items,
&mut inventory,
);
assert_eq!(items, [None, None, Some(stack("minecraft:dirt", 10))]);
}
/// A click no mod took leaves the block's items as they were before the
/// call, so a mod that moved items without taking it duplicates nothing.
#[test]
fn an_untaken_click_keeps_the_items_as_they_were() {
let before = vec![Some(stack("minecraft:dirt", 10)), None];
let mut items = vec![None, Some(stack("minecraft:dirt", 10))];
let mut inventory = vec![None; 36];
inventory[3] = Some(stack("minecraft:dirt", 5));
settle(
MenuSlot::Inventory(3),
false,
stack("minecraft:dirt", 0),
&before,
&mut items,
&mut inventory,
);
assert_eq!(items, before);
assert_eq!(inventory[3], Some(stack("minecraft:dirt", 5)));
}
}
@@ -14,9 +14,10 @@ impl Game<'_> {
pub(super) fn native_frame_visuals(
&self,
camera: glam::DVec3,
alpha: f64,
lines: &mut Vec<gizmos::Line>,
) -> Vec<dynamic_light::Colored> {
let particle_lights = self
let mut particle_lights = self
.renderer
.as_ref()
.and_then(|r| r.particle_lights())
@@ -24,6 +25,26 @@ impl Game<'_> {
self.particles
.lights(entries, self.clock.camera_fraction() as f32)
});
// Held and dropped items whose block the pack's `[[lights]]` colour shine in that colour,
// as far as their dynamic light reaches.
if let Some(renderer) = self.renderer.as_ref().filter(|r| r.has_light_colors()) {
for (source, item) in self.dynamic_light_sources(alpha) {
let Some(color) = item.and_then(|i| renderer.light_color(&i)) else {
continue;
};
let top = color[0].max(color[1]).max(color[2]);
if source.level == 0 || top <= 0.0 {
continue;
}
let [r, g, b] = color.map(|c| (c / top * 255.0).round() as u32);
particle_lights.push((
source.at.to_array(),
r << 16 | g << 8 | b,
f32::from(source.level),
1.0,
));
}
}
let Some(player) = self.native_player() else {
return dynamic_light::select_colored(particle_lights.into_iter(), camera);
};
@@ -1093,6 +1093,7 @@ impl Game<'_> {
t.velocity,
t.on_ground,
t.fuse,
t.power,
uuid,
self.persist_raw(id),
);
@@ -534,6 +534,9 @@ impl Game<'_> {
on_ground: matches!(tag.get("OnGround"), Some(Tag::Byte(1))),
owner: None,
guest: None,
// PrimedTnt.readAdditionalSaveData: 4 by default,
// kept to 0..128.
power: entity_nbt::tnt_power(&tag),
});
// `Entity.load`: the `PortalCooldown` of one that just came through.
self.remember(id, uuid, tag);
@@ -1268,6 +1271,7 @@ impl Game<'_> {
t.velocity,
t.on_ground,
t.fuse,
t.power,
uuid,
self.persist.raw.get(&id),
);
+2
View File
@@ -5231,6 +5231,8 @@ impl Game<'_> {
previous: at,
velocity,
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
age: 0,
pickup_delay: 0,
bob_offset,
@@ -325,6 +325,7 @@ impl Game<'_> {
on_ground: false,
owner: None,
guest: None,
power: 4.0,
}),
// FallingBlockEntity.recreateFromPacket: the block state is the data.
"minecraft:falling_block" => self.falling.push(FallingBlock {
@@ -100,7 +100,8 @@ impl Game<'_> {
.get(self.world.block(key.0, key.1, key.2))
.name
.clone();
let entity = horizon_client::containers::BlockEntity::for_block(&name)
let entity = self
.new_block_entity(&name)
.filter(|e| matches!(e, horizon_client::containers::BlockEntity::Sign(_)))
.ok_or("no sign there")?;
self.world.block_entities.get_or_insert_with(key, || entity);
@@ -490,7 +490,7 @@ impl Game<'_> {
let f = self.res.facts().get(state);
let name = &*f.name;
name.ends_with("_banner")
|| name.ends_with("_sign")
|| f.sign_form().is_some()
|| name.ends_with("_pressure_plate")
|| !(f.legacy_solid || matches!(name, "minecraft:water" | "minecraft:lava"))
}
+12 -15
View File
@@ -10,7 +10,7 @@ use horizon_client::{
containers::BlockEntity,
hud::{HudBatch, rgba},
items::ItemStack,
signs::{ClickAction, DYE_TEXT, click_action, wood_type},
signs::{ClickAction, DYE_TEXT, click_action, editor_sprite},
ui::EditBox,
};
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
@@ -58,14 +58,15 @@ impl Game<'_> {
let Some(player) = self.player.as_ref() else {
return true;
};
let state = self.world.block(pos.0, pos.1, pos.2);
let Some(state) = self.pack.assets.registry.state(state) else {
let id = self.world.block(pos.0, pos.1, pos.2);
let Some(state) = self.pack.assets.registry.state(id) else {
return true;
};
let form = self.res.facts().get(id).sign_form();
let prop = |key: &str| state.properties.get(key).map(String::as_str);
// getYRotationDegrees: a wall sign's `Direction.toYRot`, a standing or
// ceiling sign's rotation segment.
let y_rot = if state.name.contains("_wall_") {
let y_rot = if form.is_some_and(|f| f.wall) {
match prop("facing") {
Some("west") => 90.0,
Some("north") => 180.0,
@@ -80,7 +81,7 @@ impl Game<'_> {
};
// getSignHitboxCenterPosition: only a wall sign's board is off-centre,
// against the wall it hangs on.
let centre = if state.name.ends_with("_wall_sign") {
let centre = if form.is_some_and(|f| f.wall && !f.hanging) {
match y_rot as i32 {
90 => (0.9375, 0.5),
180 => (0.5, 0.9375),
@@ -759,20 +760,16 @@ impl Game<'_> {
});
let tw = HudBatch::text_width(atlas, &title);
batch.text(atlas, &title, (w - tw) / 2.0, 40.0, u32::MAX, true);
let name = self
.res
.facts()
.get(self.world.block(edit.pos.0, edit.pos.1, edit.pos.2))
.name
.clone();
let wood = wood_type(&name);
let facts = self.res.facts();
let block = facts.get(self.world.block(edit.pos.0, edit.pos.1, edit.pos.2));
let wood = facts.wood_type(&block.name).unwrap_or("oak");
let cx = w / 2.0;
let y = if sign.hanging {
// HangingSignEditScreen: 16x16 texture scaled 4.5 at (0, -13).
let s = 4.5;
batch.sprite_scaled(
atlas,
&format!("gui/hanging_signs/{wood}"),
&editor_sprite("hanging_signs", wood),
cx - 8.0 * s,
125.0 - 13.0 - 8.0 * s,
16.0 * s,
@@ -783,9 +780,9 @@ impl Game<'_> {
} else {
// SignEditScreen: 24x26 texture scaled 3.9 at (0, 27); wall signs show 12 rows.
let s = 3.9;
let wall = name.contains("_wall_");
let wall = block.sign_form().is_some_and(|f| f.wall);
let rows = if wall { 12.0 } else { 26.0 };
if let Some(mut r) = atlas.sprites.get(&format!("gui/signs/{wood}")).copied() {
if let Some(mut r) = atlas.sprites.get(&editor_sprite("signs", wood)).copied() {
r.v1 = r.v0 + (r.v1 - r.v0) * rows / 26.0;
batch.rect_uv(
cx - 12.0 * s,
+47 -10
View File
@@ -3,7 +3,7 @@
//! (flint and steel / fire charge).
use super::Game;
use horizon_client::{
Result, collisions::SceneCollisions, items::ItemStack, mesher::Vertex, mobs::Who,
Result, collisions::SceneCollisions, items::ItemStack, mesher::Vertex, mobs::Who, text::Text,
};
use horizon_core::{
geometry::{Aabb, Vec3},
@@ -25,6 +25,9 @@ pub(super) struct PrimedTnt {
pub guest: Option<u32>,
/// `Entity.noGravity` (`NoGravity` in its save).
pub no_gravity: bool,
/// `PrimedTnt.explosionPower`: 4 by default, saved as `explosion_power`
/// and clamped to 0..128 when read back.
pub power: f32,
}
impl PrimedTnt {
pub(super) const SIZE: f64 = 0.98;
@@ -211,6 +214,8 @@ impl Game<'_> {
on_ground: false,
owner,
guest,
// PrimedTnt's constructor: 4 when nothing sets it.
power: 4.0,
});
}
/// `PrimedTnt.tick` for every primed TNT, in order; an expired fuse
@@ -244,7 +249,7 @@ impl Game<'_> {
t.position.z,
];
let bounds = t.bounds();
let (owner, guest) = (t.owner, t.guest);
let (owner, guest, power) = (t.owner, t.guest, t.power);
let in_water = self.bounds_in_water(bounds);
self.tnts.remove(i);
count -= 1;
@@ -254,15 +259,17 @@ impl Game<'_> {
// `Explosion.getIndirectSourceEntity`: a player-lit TNT names
// its igniter, which `blast_attacker` and the damage type read.
self.combat.blast_guest = guest;
self.combat.blast_owner = match owner {
Some(Who::Player) => self
.lan
.as_ref()
.and_then(|host| self.player_attacker(host, guest)),
Some(Who::Mob(_)) | None => None,
};
self.combat.blast_owner = tnt_blast_owner(
owner,
|| {
self.lan
.as_ref()
.and_then(|host| self.player_attacker(host, guest))
},
|id| self.mob_display_name(id),
);
let result = self.with_hive_emptying_blast(|g| {
g.server_explode(center, 4.0, interaction, false)
g.server_explode(center, power, interaction, false)
});
self.combat.blast_owner = None;
self.combat.blast_guest = None;
@@ -603,6 +610,19 @@ impl Game<'_> {
}
}
/// `PrimedTnt.getOwner` as the blast's causing entity: the player, or a mob
/// that is still in the level (null once it is gone).
fn tnt_blast_owner(
owner: Option<Who>,
player: impl FnOnce() -> Option<(u32, Text)>,
mob_name: impl Fn(u32) -> Option<Text>,
) -> Option<(u32, Text)> {
match owner? {
Who::Player => player(),
Who::Mob(id) => mob_name(id).map(|name| (id, name)),
}
}
fn tnt_seat(tnt: Vec3, rider_offset: f64) -> Vec3 {
Vec3::new(tnt.x, tnt.y + PrimedTnt::SIZE - rider_offset, tnt.z)
}
@@ -611,6 +631,19 @@ fn tnt_seat(tnt: Vec3, rider_offset: f64) -> Vec3 {
mod tests {
use super::*;
#[test]
fn a_tnt_lit_by_a_skeletons_arrow_names_the_skeleton_as_its_cause() {
let name = || Text::translate("entity.minecraft.skeleton", &[]);
let by_mob = tnt_blast_owner(Some(Who::Mob(7)), || None, |id| (id == 7).then(name));
assert_eq!(by_mob, Some((7, name())));
// The skeleton died before the blast: `getOwner` is null.
assert_eq!(tnt_blast_owner(Some(Who::Mob(7)), || None, |_| None), None);
let player = Text::literal("Steve");
let by_player = tnt_blast_owner(Some(Who::Player), || Some((1, player.clone())), |_| None);
assert_eq!(by_player, Some((1, player)));
assert_eq!(tnt_blast_owner(None, || None, |_| None), None);
}
#[test]
fn a_rider_sits_on_top_of_the_tnt_less_its_own_attachment() {
// 64 + 0.98 (the box's top) - 0.14 (a rider's vehicle attachment).
@@ -633,6 +666,7 @@ mod tests {
on_ground: true,
owner: None,
guest: None,
power: 4.0,
};
tnt.velocity = bounce_velocity(Direction::Up, tnt.velocity);
tnt.on_ground = false;
@@ -676,6 +710,7 @@ mod tests {
on_ground: false,
owner: None,
guest: None,
power: 4.0,
};
tnt.tick(&world, &collision, &assets, &facts).unwrap();
assert_eq!(
@@ -771,6 +806,7 @@ mod tests {
on_ground: false,
owner: None,
guest: None,
power: 4.0,
};
for _ in 0..10 {
tnt.tick(&world, &collision, &assets, &facts).unwrap();
@@ -845,6 +881,7 @@ mod tests {
on_ground: false,
owner: None,
guest: None,
power: 4.0,
};
let mut tops = Vec::new();
let mut tnt = primed();
+19 -14
View File
@@ -414,15 +414,16 @@ impl Game<'_> {
}
}
/// What a menu click's session did, applied to the world and its mob;
/// with the result of each trade made, for `merchant_traded`.
/// with the offer of each trade made (as it is after the trade), for
/// `merchant_traded`.
pub(super) fn merchant_apply(
&mut self,
session: Box<merchant::Session>,
mob: Option<u32>,
) -> (Box<merchant::Session>, Vec<ItemStack>) {
) -> (Box<merchant::Session>, Vec<MerchantOffer>) {
let mut session = session;
let mut traded = Vec::new();
let events = std::mem::take(&mut session.events);
let mut traded = Vec::new();
let (villager, origin) = (session.villager, session.origin);
if let Some(m) = mob.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id)) {
m.random = session.random.clone();
@@ -450,9 +451,7 @@ impl Game<'_> {
};
self.audio.play(sound, Some(origin), 1.0, pitch);
}
merchant::Event::Traded(index) => {
traded.extend(session.offers.get(index).map(|o| o.result.clone()));
}
merchant::Event::Traded(_, offer) => traded.push(*offer),
merchant::Event::TradeSound => {
let sound = if villager {
"minecraft:entity.villager.yes"
@@ -465,16 +464,22 @@ impl Game<'_> {
}
(session, traded)
}
/// `MerchantResultSlot.checkTakeAchievements` and `AbstractVillager.notifyTrade`
/// for each trade made: the statistic and the `villager_trade` trigger.
pub(super) fn merchant_traded(&mut self, mob: Option<u32>, results: &[ItemStack]) {
for result in results {
/// `MerchantResultSlot.onTake` for each trade made (`offers`, as each
/// was right after its trade): the statistic, `AbstractVillager.notifyTrade`'s
/// `villager_trade` trigger, then the mods' `native_trade_with_villager`
/// for a server's player (when the trader is still in the level).
pub(super) fn merchant_traded(&mut self, mob: Option<u32>, offers: &[MerchantOffer]) {
for offer in offers {
self.stats.custom("traded_with_villager", 1);
let villager = mob
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|m| self.mob_entity(m));
let merchant = mob.and_then(|id| self.mobs.iter().find(|m| m.id == id));
let kind = merchant.map(super::native_hooks::mob_kind);
let villager = merchant.map(|m| self.mob_entity(m));
if let Some(villager) = villager {
self.trigger_trade(&villager, result);
self.trigger_trade(&villager, &offer.result);
}
// TradeWithVillagerEvent: on the server, in the player's turn.
if let (Some(id), Some(kind), Some(_)) = (mob, kind, self.lan_acting) {
self.native_trade_with_villager(id, &kind, offer);
}
}
}
@@ -49,6 +49,7 @@ impl WorldRequest {
settings: &Settings,
upgrade: &horizon_world::datafix::progress::UpgradeProgress,
) -> Result<PreparedWorld> {
let _prepare_step = startup::step("world.prepare");
// Before any chunk is read: a loaded container with a loot table is rolled as its chunk
// is decoded (`save::roll_loaded_loot`), and the spawn chunks are read while the world
// opens. Every world opens here: in-frame, on the server thread, on a dedicated server.
@@ -229,6 +230,7 @@ impl WorldRequest {
let dim = Dim::from_id(&level.dimension);
let shapes = level_shapes(&save_root, res.biomes());
let shape = shapes.get(dim.id());
let streaming_step = startup::step("world.make_streaming");
let (mut streaming, save, locator) = make_streaming(
res,
pack,
@@ -240,6 +242,7 @@ impl WorldRequest {
level.seed,
&level.enabled_features,
)?;
drop(streaming_step);
streaming.radius = settings.render_distance;
let mut world = World::new(res.facts().air, level.seed);
world.attachments = save.load_attachments();
@@ -746,7 +749,10 @@ pub(super) fn make_streaming(
&settings,
true,
) {
Ok(terrain) => Some(Arc::new(terrain)),
Ok(mut terrain) => {
terrain.add_mod_signs(res.facts());
Some(Arc::new(terrain))
}
Err(e) => {
log::warn!("Horizon: the superflat structures are unavailable: {e}");
None
@@ -817,13 +823,15 @@ pub(super) fn make_streaming(
let preset = horizon_client::save::WorldGen::preset(world_root);
let overrides =
world_worldgen_overrides(world_root, &options.resources, &biomes, enabled_features);
let inner = Arc::new(VanillaTerrain::for_world_with(&options.resources.join("worldgen"), &overrides, pack.assets.registry, &biomes, seed, preset, dim.id(), structures).map_err(|e| {
let mut inner = VanillaTerrain::for_world_with(&options.resources.join("worldgen"), &overrides, pack.assets.registry, &biomes, seed, preset, dim.id(), structures).map_err(|e| {
if overrides.is_empty() {
format!("{e}\nPrepare the worldgen bundle with the launcher, or: horizon-prepare --prepare")
} else {
format!("{e}\nThe world's data packs change its world generation: one of them may not be made for this version")
}
})?);
})?;
inner.add_mod_signs(res.facts());
let inner = Arc::new(inner);
if dim == Dim::Overworld && structures {
inner.search_rings_behind();
}
+16 -7
View File
@@ -307,15 +307,24 @@ fn submit_quads(
FoilType::Special => Foil::Special,
};
let _ = display;
// Quads of one layer mostly share a sprite: look its translucency up once.
// Quads of one layer mostly share a sprite: look its rect and its
// translucency up once.
let mut last: Option<(&str, bool)> = None;
let mut last_rect: Option<(&str, Option<Rect>)> = None;
for q in &baked.quads {
let Some(r) = sprites
.get(sprite_name(&q.material.sprite))
.or_else(|| sprites.get("misc/missingno"))
.or_else(|| sprites.get("horizon/missingno"))
.copied()
else {
let rect = match last_rect {
Some((sprite, rect)) if sprite == q.material.sprite => rect,
_ => {
let rect = sprites
.get(sprite_name(&q.material.sprite))
.or_else(|| sprites.get("misc/missingno"))
.or_else(|| sprites.get("horizon/missingno"))
.copied();
last_rect = Some((&q.material.sprite, rect));
rect
}
};
let Some(r) = rect else {
continue;
};
// `getLayerColorSafe`: an untinted quad, or a tint index past the
+115 -4
View File
@@ -415,6 +415,22 @@ impl ChunkEncoder {
builtin: &Builtin,
biome_names: &[String],
biome_registry: &[String],
) -> Self {
Self::with_signs(
states.into_iter().map(|(name, fluid)| (name, fluid, None)),
builtin,
biome_names,
biome_registry,
)
}
/// The encoder with each state's sign form from its template's class: mod
/// signs reuse vanilla's sign block entity types.
pub fn with_signs(
states: impl IntoIterator<Item = (Arc<str>, bool, Option<crate::blocks::SignForm>)>,
builtin: &Builtin,
biome_names: &[String],
biome_registry: &[String],
) -> Self {
let types = builtin.get("minecraft:block_entity_type");
let blocks = builtin.get("minecraft:block");
@@ -422,7 +438,7 @@ impl ChunkEncoder {
let mut entity_types = Vec::new();
let mut mod_entities = Vec::new();
let mut block_ids = Vec::new();
for (name, has_fluid) in states {
for (name, has_fluid, sign) in states {
let air = matches!(
&*name,
"minecraft:air" | "minecraft:cave_air" | "minecraft:void_air"
@@ -435,12 +451,13 @@ impl ChunkEncoder {
}
}
// A mod block's block entity type is named after the block
// (`mod_content`: registries.json).
let modded = !name.starts_with("minecraft:");
// (`mod_content`: registries.json), unless it is one of vanilla's.
let vanilla = sign.map(crate::blocks::SignForm::block_entity_type);
let modded = !name.starts_with("minecraft:") && vanilla.is_none();
let t = if modded {
Some(&*name)
} else {
block_entity_type(&name)
vanilla.or_else(|| block_entity_type(&name))
}
.and_then(|t| types.and_then(|ids| ids.id(t)))
.unwrap_or(-1);
@@ -1467,6 +1484,100 @@ mod tests {
assert_eq!(infos[0].tag, Tag::End);
}
/// A mod's sign is of vanilla's block entity type, as its block is on
/// `BlockEntityType.SIGN`'s list, and goes out with its text. Another mod
/// block's type stays its own, and a sign block entity on it stays out.
#[test]
fn a_mods_sign_goes_out_with_vanillas_type_and_its_text() {
use crate::blocks::SignForm;
let builtin = Builtin::parse(
r#"{"minecraft:block_entity_type": {"entries": {
"minecraft:sign": {"protocol_id": 7},
"minecraft:hanging_sign": {"protocol_id": 8},
"test:notice": {"protocol_id": 49},
"test:lantern": {"protocol_id": 50},
"test:casing": {"protocol_id": 51}}}}"#,
)
.unwrap();
let states = [
"minecraft:air",
"test:notice",
"test:lantern",
"test:casing",
]
.map(|n| (Arc::<str>::from(n), false));
let states = states.map(|(name, fluid)| {
let form = match &*name {
"test:notice" => Some(SignForm {
hanging: false,
wall: false,
}),
"test:lantern" => Some(SignForm {
hanging: true,
wall: true,
}),
_ => None,
};
(name, fluid, form)
});
let encoder = ChunkEncoder::with_signs(states, &builtin, &[], &[]);
let (notice, lantern, casing) = ((1, MIN_Y + 2, 3), (5, MIN_Y + 2, 5), (9, MIN_Y + 2, 9));
let mut chunk = Chunk::empty(0, 0);
chunk.set(notice.0, notice.1, notice.2, 1);
chunk.set(lantern.0, lantern.1, lantern.2, 2);
chunk.set(casing.0, casing.1, casing.2, 3);
let mut sign = crate::signs::SignData::default();
sign.front.lines[0] = "Hello".into();
let hanging = crate::signs::SignData {
hanging: true,
..Default::default()
};
let block_entities = HashMap::from([
(notice, BlockEntity::Sign(sign.clone())),
(lantern, BlockEntity::Sign(hanging.clone())),
(casing, BlockEntity::Sign(sign.clone())),
]);
let infos = encoder.encode(&chunk, &block_entities).chunk.block_entities;
let sent = |kind: i32| infos.iter().find(|i| i.kind == kind).map(|i| &i.tag);
assert_eq!(sent(7), Some(&Tag::compound(sign.encode())));
assert_eq!(sent(8), Some(&Tag::compound(hanging.encode())));
assert_eq!(infos.len(), 2, "the casing is no sign: {infos:?}");
// The same encoder supplies the type and fresh tag for incremental
// BlockEntityData packets, even after the chunk's section was cached.
sign.back.lines[0] = "Back".into();
sign.front.color = 9;
sign.back.glowing = true;
sign.waxed = true;
let entity = BlockEntity::Sign(sign);
assert_eq!(encoder.entity_type(1), Some(7));
assert_eq!(encoder.entity_type(2), Some(8));
let tag = encoder.update_tag(notice, &entity).unwrap();
let mut nbt = match tag {
Tag::Compound(fields) => fields,
_ => panic!("sign update is not a compound"),
};
nbt.insert("id".into(), Tag::String("minecraft:sign".into()));
for (axis, value) in ["x", "y", "z"]
.into_iter()
.zip([notice.0, notice.1, notice.2])
{
nbt.insert(axis.into(), Tag::Int(value));
}
let (_, loaded) = crate::save::decode_block_entity(&Tag::Compound(nbt)).unwrap();
assert!(
matches!((&loaded, &entity), (BlockEntity::Sign(a), BlockEntity::Sign(b)) if a == b)
);
let changed = HashMap::from([(notice, entity)]);
for encoder in [encoder.detached(), encoder] {
let sent = encoder.encode(&chunk, &changed).chunk.block_entities;
assert_eq!(
sent.iter().find(|i| i.kind == 7).unwrap().tag,
encoder.update_tag(notice, &changed[&notice]).unwrap()
);
}
}
fn encoder() -> ChunkEncoder {
let states = ["minecraft:air", "minecraft:stone", "minecraft:water"]
.map(|n| (Arc::<str>::from(n), n.ends_with("water")));
+28 -7
View File
@@ -208,10 +208,13 @@ impl SyncData {
resources: &Path,
overlay: &horizon_world::loot::PackOverlay,
) -> Result<Self, String> {
use crate::spans::startup;
// The recipe book, the recipe sync and the advancements read the
// recipe folder: held here, they share one read of it.
let _recipe_files =
horizon_world::json_folder::read(&resources.join("data/minecraft/recipe"));
let _recipe_files = startup::time("sync.recipe_folder", || {
horizon_world::json_folder::read(&resources.join("data/minecraft/recipe"))
});
let registries_step = startup::step("sync.registries");
let builtin = Builtin::find(resources)
.and_then(|p| Builtin::load(&p).ok())
.ok_or("registries.json not found next to the resources")?;
@@ -252,12 +255,20 @@ impl SyncData {
// The world's packs' dialogs joined the registry above
// (`pack_entries`); their `minecraft:dialog` tags join the root's here,
// for the Quick Actions key and `show_dialog`.
drop(registries_step);
let tags_step = startup::step("sync.tags");
let mut tags = Self::load_tags(&root, &builtin, &dynamic);
Self::merge_dialog_tags(&mut tags, &root, overlay, &builtin, &dynamic);
drop(tags_step);
let content_identity = if resources.join(crate::content_identity::MARKER).exists()
|| horizon_protocol::item::custom::entries().next().is_some()
{
Some(crate::content_identity::identity(resources).map_err(|error| error.to_string())?)
Some(
startup::time("sync.content_identity", || {
crate::content_identity::identity(resources)
})
.map_err(|error| error.to_string())?,
)
} else {
None
};
@@ -268,14 +279,24 @@ impl SyncData {
registries,
tags,
features: vec!["minecraft:vanilla".to_owned()],
commands: super::commands::CommandTree::load(resources),
commands: startup::time("sync.commands", || {
super::commands::CommandTree::load(resources)
}),
recipes: Vec::new(),
advancements: {
let (recipes, groups) = super::recipes::network_recipes(resources, overlay);
super::advancements::ServerAdvancements::load(resources, overlay, recipes, groups)
let (recipes, groups) = startup::time("sync.network_recipes", || {
super::recipes::network_recipes(resources, overlay)
});
startup::time("sync.advancements", || {
super::advancements::ServerAdvancements::load(
resources, overlay, recipes, groups,
)
})
},
};
sync.recipes = super::recipes::update_recipes(&sync.advancements.recipes, &sync);
sync.recipes = startup::time("sync.update_recipes", || {
super::recipes::update_recipes(&sync.advancements.recipes, &sync)
});
Ok(sync)
}
+83 -1
View File
@@ -64,6 +64,12 @@ pub struct EntityState {
/// `Entity.getRequiresPrecisePosition` (primed TNT and falling blocks
/// touching a block): moves are sent as full position syncs.
pub precise: bool,
/// `Entity.verticalCollision` and `Entity.horizontalCollision`: the last
/// move's stops. An item that stopped on a block gets a full position
/// sync rather than a 1/4096 delta whose decoded point is not the one it
/// came to rest at (`ServerEntity.isFullPrecisionEncodingRequired`).
pub vertical_collision: bool,
pub horizontal_collision: bool,
/// `Entity.needsSync`: send its changes this tick, due or not.
pub needs_sync: bool,
/// `Leashable.getLeashHolder`: the entity holding its lead.
@@ -95,6 +101,8 @@ impl EntityState {
self.equipment.clear();
self.attributes.clear();
self.precise = false;
self.vertical_collision = false;
self.horizontal_collision = false;
self.needs_sync = false;
self.leash = None;
self.spectator = false;
@@ -263,6 +271,23 @@ fn encode(v: f64) -> i64 {
(v * 4096.0).round() as i64
}
/// `ServerEntity.isFullPrecisionEncodingRequired`: an item whose last move was
/// stopped by a block rests where the collision solver left it, which is not on
/// the 1/4096 grid a delta decodes to; vanilla sends its exact position instead.
/// The axis pairs are vanilla's own: a vertical stop looks at x and z, a
/// horizontal one at z and x.
fn full_precision_required(e: &EntityState, delta: [i64; 3]) -> bool {
if e.kind != "minecraft:item" {
return false;
}
let loss = |v: f64| encode(v) as f64 / 4096.0 - v;
let vertical =
delta[0] != 0 && loss(e.position[0]) != 0.0 || delta[2] != 0 && loss(e.position[2]) != 0.0;
let horizontal =
delta[0] != 0 && loss(e.position[2]) != 0.0 || delta[2] != 0 && loss(e.position[0]) != 0.0;
e.vertical_collision && vertical || e.horizontal_collision && horizontal
}
impl ServerEntity {
/// `new ServerEntity`: the entity as it is when it joins the level.
fn new(e: &EntityState, kind: Option<i32>, riding: &Riding) -> Self {
@@ -425,7 +450,9 @@ impl ServerEntity {
Some(play::Clientbound::EntityPositionSync(sync))
} else if send_position {
let delta = [0, 1, 2].map(|k| encode(e.position[k]) - encode(self.base[k]));
if delta.iter().any(|v| i16::try_from(*v).is_err()) {
if delta.iter().any(|v| i16::try_from(*v).is_err())
|| full_precision_required(e, delta)
{
Some(play::Clientbound::EntityPositionSync(sync))
} else {
let m = MoveEntity {
@@ -1056,6 +1083,8 @@ mod tests {
equipment: Vec::new(),
attributes: Vec::new(),
precise: false,
vertical_collision: false,
horizontal_collision: false,
needs_sync: false,
leash: None,
spectator: false,
@@ -1206,6 +1235,59 @@ mod tests {
));
}
/// `ServerEntity.isFullPrecisionEncodingRequired`: an item that came to
/// rest against a block gets its exact position, because a delta decodes
/// to the 1/4096 grid. Vanilla's axis pairs cross over, and another kind
/// of entity that collided is not an item.
#[test]
fn an_item_stopped_by_a_block_needs_full_precision() {
let mut item = state(0.0);
item.kind = "minecraft:item".into();
item.position = [0.3, 64.0, 0.3];
assert!(!full_precision_required(&item, [1, 0, 0]));
item.vertical_collision = true;
assert!(full_precision_required(&item, [1, 0, 0]));
item.vertical_collision = false;
item.horizontal_collision = true;
// A horizontal stop pairs an x delta with z, exactly as vanilla does.
assert!(full_precision_required(&item, [1, 0, 0]));
item.position[2] = 0.5;
assert!(!full_precision_required(&item, [1, 0, 0]));
// A drop that only moved in y says nothing about x and z.
item.position[2] = 0.3;
assert!(!full_precision_required(&item, [0, 9, 0]));
item.kind = "minecraft:zombie".into();
assert!(!full_precision_required(&item, [1, 0, 0]));
}
/// The same through `send_changes`: a slow item stopped by a wall would
/// have its resting point rounded to the delta grid, so at its next update
/// (an item's is every 20 ticks) it goes out as a position sync carrying
/// the exact point; one that hit nothing keeps the quantised delta.
#[test]
fn a_slow_item_stopped_by_a_wall_syncs_its_exact_position() {
let stop = |collision: bool| {
let mut e = state(0.0);
e.kind = "minecraft:item".into();
let mut s = added(&e);
changes(&mut s, &e);
e.position = [0.3, 64.0, 0.3];
e.horizontal_collision = collision;
for _ in 0..19 {
assert!(changes(&mut s, &e).is_empty());
}
changes(&mut s, &e)
};
let out = stop(true);
assert!(
matches!(&out[..], [play::Clientbound::EntityPositionSync(p)]
if p.path.end() == [0.3, 64.0, 0.3]),
"{out:?}"
);
let out = stop(false);
assert!(matches!(&out[..], [play::Clientbound::MoveEntityPos(_)]));
}
#[test]
fn precise_entities_sync_their_position() {
let mut e = state(0.0);
+2
View File
@@ -2323,6 +2323,8 @@ impl Hub {
equipment,
attributes,
precise: false,
vertical_collision: false,
horizontal_collision: false,
needs_sync: false,
leash: None,
spectator: false,
+27 -3
View File
@@ -278,8 +278,9 @@ pub enum Event {
TradeUpdated(bool, f32),
/// `MerchantMenu.playTradeSound` (a shift-clicked trade).
TradeSound,
/// A trade of this offer was made (`notifyTrade`).
Traded(usize),
/// A trade of this offer was made (`notifyTrade`), with the offer as
/// it was right after that trade's use (before a level-up's prices).
Traded(usize, Box<MerchantOffer>),
}
/// What the world lends a trading session for offers made while it is open.
pub struct TradeEnv<'a> {
@@ -373,7 +374,8 @@ impl Session {
offer.uses += 1;
let offer = offer.clone();
self.ambient_sound_time = -80;
self.events.push(Event::Traded(index));
self.events
.push(Event::Traded(index, Box::new(offer.clone())));
let mut prices = false;
if self.villager {
let mut pop = 3 + self.random.next_int_bound(4).unwrap_or(0);
@@ -1016,6 +1018,28 @@ mod tests {
}
}
/// A shift-click trades several times in one session: each `Traded`
/// event keeps the offer as it was right after its own trade, which is
/// what the mods hear (`TradeWithVillagerEvent` per `onTake`).
#[test]
fn each_trade_event_keeps_its_own_offer() {
let items = items();
let mut s = Session::new(vec![offer(4, None), offer(2, None)], false);
s.notify_trade(1, None, &items);
s.notify_trade(1, None, &items);
s.notify_trade(0, None, &items);
let seen: Vec<_> = s
.events
.iter()
.filter_map(|e| match e {
Event::Traded(i, o) => Some((*i, o.uses, o.base_cost_a.stack.count)),
_ => None,
})
.collect();
assert_eq!(seen, [(1, 1, 2), (1, 2, 2), (0, 1, 4)]);
assert_eq!((s.offers[0].uses, s.offers[1].uses), (1, 2));
}
#[test]
fn demand_raises_prices() {
let items = items();
+3 -2
View File
@@ -1328,9 +1328,10 @@ pub(super) fn posed(base: glam::Mat4, p: [f32; 9]) -> glam::Mat4 {
))
* glam::Mat4::from_scale(glam::Vec3::new(p[6], p[7], p[8]))
}
/// `draw_scrolled` through the GPU path when the mob takes it (`skin`).
/// `draw_scrolled` through the GPU path when the mob takes it (`skin`), on
/// the CPU otherwise.
#[allow(clippy::too_many_arguments)]
fn put(
pub fn put(
skin: &mut Option<&mut super::skin::Skin>,
part: &std::sync::Arc<Part>,
base: glam::Mat4,
+95
View File
@@ -419,4 +419,99 @@ mod tests {
eprintln!("posed on the GPU: {posed} mob draws; kept on the CPU: {kept}");
assert!(posed > 3 * 40, "only {posed} draws took the GPU path");
}
/// The armor stand's models (`ArmorStandRenderer`: the stand, the small
/// one, their armour layers) posed like a stand: turned arms and head, a
/// hidden part (scale 0, as a stand without arms or base plate), the
/// armour glint on one layer. The GPU path draws what the CPU path draws,
/// to the bit. Needs the prepared resources.
#[test]
fn armor_stand_models_pose_on_the_gpu_as_on_the_cpu() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("entity-models.json").exists() {
eprintln!("no prepared resources: skipped");
return;
}
let document = crate::mobs::EntityModelDocument::load(&root).unwrap();
let base = glam::Mat4::from_translation(glam::Vec3::new(2.5, -1.0, 6.25))
* glam::Mat4::from_rotation_y(2.1)
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0));
let (mut models, mut frame) = (SkinModels::default(), SkinFrame::default());
let mut store = Vec::new();
let mut drawn = 0;
for model in ["armor_stand", "armor_stand_small"] {
for slot in ["main", "helmet", "chestplate", "leggings", "boots"] {
let key = format!("minecraft:{model}#{slot}");
let part = Arc::new(document.model(&key).unwrap_or_else(|| panic!("no {key}")));
let mut poses = Poses::new(&part);
for (name, turn) in [("head", [0.3, -0.7, 0.1]), ("left_arm", [-1.2, 0.4, 0.25])] {
if poses.has(name) {
poses.get(name)[3..6].copy_from_slice(&turn);
}
}
for hidden in ["base_plate", "right_arm"] {
if poses.has(hidden) {
poses.get(hidden)[6..9].copy_from_slice(&[0.0; 3]);
}
}
let overlay = if slot == "chestplate" {
render::ARMOR_GLINT
} else {
0
};
let uv = [0.125, 0.25, 0.1875, 0.28125];
let mut cpu = Vec::new();
render::draw(
&part,
base,
&poses,
uv,
0xff80_c0ff,
0x70a0,
overlay,
&mut cpu,
);
models.begin(&mut frame, store.len() as u32);
let mut gpu = Vec::new();
let mut skin = Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
let paint = (uv, 0.0, 0xff80_c0ff, 0x70a0, overlay);
render::put(
&mut Some(&mut skin),
&part,
base,
&poses,
paint.0,
paint.1,
paint.2,
paint.3,
paint.4,
&mut gpu,
);
if frame.reset {
store.clear();
}
store.extend_from_slice(&frame.new_model);
frame.handed();
assert!(gpu.is_empty(), "{key} stayed on the CPU");
let expanded = expand(&store, &frame);
assert_eq!(expanded.len(), cpu.len(), "{key}: vertex count");
assert!(!cpu.is_empty(), "{key} drew nothing");
for (i, (a, b)) in expanded.iter().zip(&cpu).enumerate() {
assert_eq!(
bytemuck::bytes_of(a),
bytemuck::bytes_of(b),
"{key} vertex {i}: {a:?} != {b:?}"
);
}
drawn += 1;
}
}
assert_eq!(drawn, 10);
}
}
+1
View File
@@ -27,6 +27,7 @@ pub use crate::gfx::native_render::{
pub use crate::hud::HudBatch;
pub use crate::hud_layout::{GuiDraw, HealthInputs};
pub use crate::items::ItemStack;
pub use crate::merchant::MerchantOffer;
pub use crate::music::{Music, MusicLayer};
pub use crate::text::Text;
pub use horizon_assets::item_model::DisplayContext;
+145
View File
@@ -2999,6 +2999,36 @@ pub trait Hooks: Send + Sync {
let _ = event;
Flow::Continue
}
/// A player shift-clicked slot `slot` of the menu of the mod block
/// entity at `pos` (`AbstractContainerMenu.quickMoveStack`, the method a
/// Java menu overrides), on the server, before the game's own routing:
/// move the stack where your block wants it. `slot` is the menu's index:
/// below `container_slots` it is the block's own slot (`entity.items`),
/// from there on the player's inventory (27 main slots, then the hotbar).
/// `stack` is a copy of what the slot holds; take what you move out of
/// it (put it in `entity.items`, or in another block with
/// `native_insert_item`) and set `handled`: the slot then holds what is
/// left of `stack` (whatever you wrote to that slot of `entity.items`),
/// the game's routing is skipped, and the player's client gets the menu
/// again. Left unhandled, `entity.items` goes back to what it held and
/// the game moves the stack as for any container; a later mod sees what
/// an earlier one did to `stack`.
/// For a mod container on a vanilla menu type and on a mod menu type,
/// the left or right button, a slot that holds something and that the
/// mod's screen shows; not for a spectator or a dead player, a menu no
/// longer valid, nor during a drag.
/// Asked once per click, where vanilla asks `quickMoveStack` again while
/// it keeps moving the same item. The player's state is swapped in, so
/// `native_acting_player` names them; `entity` is out of the world
/// during the call, as in `native_menu_button`.
/// API @API:menu_quick_move@.
///
/// Return `Flow::Cancel` to take it: this sets `handled`, and later mods are skipped.
fn menu_quick_move(&self, host: &mut dyn Host, event: &mut MenuQuickMoveEvent<'_>) -> Flow {
let _ = host;
let _ = event;
Flow::Continue
}
/// Farmland at `pos` is about to change moisture from `from` to `to`.
/// Set `cancel` to keep its old moisture. Server-side.
/// @API:native_moisture_change@.
@@ -3955,6 +3985,24 @@ pub trait Hooks: Send + Sync {
let _ = host;
let _ = event;
}
/// Enough players have slept through the night in level `dimension`
/// and the clock is about to move to the morning (NeoForge's
/// `SleepFinishedTimeEvent`, vanilla's `ServerLevel.tick` when it moves
/// the clock to `wake_up_from_sleep`): `new_time` comes in as the next
/// morning's day time and may be changed to wake everyone at another
/// time. A time before `current` (the day time now) is refused, as
/// `setTimeAddition` refuses one, and the morning stays. Unlike
/// NeoForge, which refuses a bad value as it is set, the check runs once
/// after every mod, so a later mod sees the value an earlier one left.
/// On the server, only when the `advance_time` rule is on;
/// `native_time_skip` then hears the jump to the time chosen here and
/// may still cancel it. The players wake and the weather resets as
/// usual either way.
/// API @API:sleep_finished_time@.
fn sleep_finished_time(&self, host: &mut dyn Host, event: &mut SleepFinishedTimeEvent<'_>) {
let _ = host;
let _ = event;
}
/// A sponge at `pos` absorbed water and became wet. Server-side.
/// @API:native_sponge_absorb@.
fn sponge_absorb(&self, level: &dyn LevelHost, event: &mut SpongeAbsorbEvent) {
@@ -3999,6 +4047,20 @@ pub trait Hooks: Send + Sync {
let _ = host;
let _ = event;
}
/// A server player traded with a villager or a wandering trader
/// (NeoForge's `TradeWithVillagerEvent`, at vanilla's
/// `MerchantResultSlot.onTake` once the offer is taken): `merchant` is
/// the trader's entity id and `kind` its type (`minecraft:villager`,
/// `minecraft:wandering_trader`), `offer` the trade as it is after this
/// one (its `uses` counted, `result` what the player got). Once per
/// trade, so a shift-click that trades several times is heard for each,
/// after the trade's statistic and `villager_trade` trigger. Listen
/// only: the trade is made. The player's state is swapped in
/// (`native_acting_player`). API @API:trade_with_villager@.
fn trade_with_villager(&self, host: &mut dyn Host, event: &mut TradeWithVillagerEvent<'_>) {
let _ = host;
let _ = event;
}
/// A vault at `pos` changed to `state`. Its block state is updated.
/// Server-side. @API:native_vault_change_state@.
fn vault_change_state(&self, host: &mut dyn Host, event: &mut VaultChangeStateEvent<'_>) {
@@ -9892,6 +9954,42 @@ pub fn dispatch_lightning_strike(
}
}
}
/// What [`Hooks::menu_quick_move`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct MenuQuickMoveEvent<'a> {
pub pos: [i32; 3],
pub entity: &'a mut ModBlockEntity,
pub container_slots: usize,
pub slot: usize,
pub stack: &'a mut ItemStack,
}
pub fn dispatch_menu_quick_move(
host: &mut dyn Host,
mods: &[Box<dyn Hooks>],
pos: [i32; 3],
entity: &mut ModBlockEntity,
container_slots: usize,
slot: usize,
stack: &mut ItemStack,
handled: &mut bool,
) {
if *handled {
return;
}
let mut event = MenuQuickMoveEvent {
pos,
entity,
container_slots,
slot,
stack,
};
for mod_hook in mods {
if matches!(mod_hook.menu_quick_move(host, &mut event), Flow::Cancel) {
*handled = true;
break;
}
}
}
/// What [`Hooks::moisture_change`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct MoistureChangeEvent {
@@ -11939,6 +12037,30 @@ pub fn dispatch_sculk_bloom(
mod_hook.sculk_bloom(host, &mut event);
}
}
/// What [`Hooks::sleep_finished_time`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct SleepFinishedTimeEvent<'a> {
pub dimension: &'a str,
pub current: i64,
pub new_time: i64,
}
pub fn dispatch_sleep_finished_time(
host: &mut dyn Host,
mods: &[Box<dyn Hooks>],
dimension: &str,
current: i64,
new_time: &mut i64,
) {
let mut event = SleepFinishedTimeEvent {
dimension,
current,
new_time: *new_time,
};
for mod_hook in mods {
mod_hook.sleep_finished_time(host, &mut event);
}
*new_time = event.new_time;
}
/// What [`Hooks::sponge_absorb`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct SpongeAbsorbEvent {
@@ -12094,6 +12216,29 @@ pub fn dispatch_trade_select(
mod_hook.trade_select(host, &mut event);
}
}
/// What [`Hooks::trade_with_villager`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct TradeWithVillagerEvent<'a> {
pub merchant: u32,
pub kind: &'a str,
pub offer: &'a MerchantOffer,
}
pub fn dispatch_trade_with_villager(
host: &mut dyn Host,
mods: &[Box<dyn Hooks>],
merchant: u32,
kind: &str,
offer: &MerchantOffer,
) {
let mut event = TradeWithVillagerEvent {
merchant,
kind,
offer,
};
for mod_hook in mods {
mod_hook.trade_with_villager(host, &mut event);
}
}
/// What [`Hooks::vault_change_state`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct VaultChangeStateEvent<'a> {
+37 -5
View File
@@ -583,6 +583,9 @@ struct NewBlock<'a> {
/// Each state's template state offset and combination index, in registry order
/// (`state_order`).
order: Vec<(u32, u32)>,
/// The sign whose item places this block on a wall, which makes that item
/// this block's too (`StandingAndWallBlockItem.registerBlocks`).
wall_of: Option<String>,
}
impl NewBlock<'_> {
/// Template states and declared combinations in registry order.
@@ -600,6 +603,8 @@ struct NewItem {
rarity: Option<Rarity>,
components: Vec<(String, J)>,
crafting_remainder: Option<String>,
/// `StandingAndWallBlockItem.wallBlock`, when its block declares it.
wall_block: Option<String>,
}
struct Plan<'a> {
@@ -1054,9 +1059,20 @@ impl<'a> Plan<'a> {
default,
combos,
order,
wall_of: None,
});
}
}
// A sign's wall form, which `Content` checked against its mod's blocks.
let pairs: Vec<_> = blocks
.iter()
.filter_map(|b| Some((b.decl.wall_block.clone()?, b.decl.id.clone())))
.collect();
for (wall, sign) in pairs {
if let Some(block) = blocks.iter_mut().find(|b| b.decl.id == wall) {
block.wall_of = Some(sign);
}
}
if next > MAX_STATES {
let mut by_mod: BTreeMap<&str, u32> = BTreeMap::new();
for b in &blocks {
@@ -1101,6 +1117,7 @@ impl<'a> Plan<'a> {
rarity: None,
components: component_patch(&b.decl.id, &b.decl.components)?,
crafting_remainder: None,
wall_block: b.decl.wall_block.clone(),
});
}
}
@@ -1112,6 +1129,7 @@ impl<'a> Plan<'a> {
rarity: i.rarity,
components: component_patch(&i.id, &i.components)?,
crafting_remainder: i.crafting_remainder.clone(),
wall_block: None,
});
}
}
@@ -1148,13 +1166,15 @@ impl<'a> Plan<'a> {
/// What moves in a new block's copies of its template's rows: the
/// block, and the template's item when it has another name. A block
/// without an item gets `minecraft:air` where rows name one.
/// without an item gets `minecraft:air` where rows name one, except a
/// sign's wall form, whose item is the paired sign's.
fn renames(&self, b: &NewBlock) -> Vec<Rename> {
let mut out = vec![b.rename.clone()];
let own = b.decl.item.then_some(b.decl.id.as_str());
if let Some(item) = b.item.as_deref().filter(|i| *i != b.decl.template)
&& b.decl.item
&& let Some(to) = own.or(b.wall_of.as_deref())
{
out.push(Rename::new(item, &b.decl.id));
out.push(Rename::new(item, to));
}
out
}
@@ -1180,7 +1200,7 @@ impl<'a> Plan<'a> {
}
if gameplay {
row.set("id", J::Num(next.into()));
if !b.decl.item {
if !b.decl.item && b.wall_of.is_none() {
row.set("item", J::Str("minecraft:air".into()));
}
self.override_gameplay(b, combo, &mut row);
@@ -1276,6 +1296,11 @@ impl<'a> Plan<'a> {
};
let mut v = v.clone();
v.rename(&i.rename, false);
if let Some(wall) = &i.wall_block
&& let Some(fields) = v.get_mut("fields")
{
fields.set("wallBlock", J::Str(wall.clone()));
}
if components {
if let Some(n) = i.max_stack_size {
v.set("minecraft:max_stack_size", J::Num(n.into()));
@@ -1343,9 +1368,16 @@ impl<'a> Plan<'a> {
for r in &self.renames(b) {
v.rename(r, false);
}
if !b.decl.item {
if !b.decl.item && b.wall_of.is_none() {
v.set("item", J::Str("minecraft:air".into()));
}
// `SignBlock.getWoodType`: the wood's name is what
// the editor reads of it.
if let Some(wood) = &b.decl.wood_type
&& let Some(name) = v.at("fields/type/name")
{
*name = J::Str(wood.clone());
}
// A connecting block: the chorus plant's PipeBlock
// without its rules, joining the declared tag.
if let Some(tag) = &b.decl.connects_to {
@@ -3588,3 +3588,185 @@ fn a_file_item_whose_remainder_was_refused_takes_only_its_mod_out() {
// A remainder that is the mod's own block item is kept too (`jar`).
assert_eq!(left, ["jar", "good", "vanilla"]);
}
/// A sign's four forms come from its template, whatever the blocks are named,
/// its wood from its declaration, and its item places the wall form it names
/// and is that form's item too. A `road_sign` of stone is no sign.
#[test]
#[ignore = "full content root: CI and pr_ready run it"]
fn signs_take_their_form_wood_and_wall_block_from_their_declaration() {
use horizon_world::level::{Behaviours, ItemKind};
let base = prepared().expect("this ignored test needs prepared 26.3 resources");
let scratch = std::env::temp_dir().join(format!("horizon-mod-signs-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
let m = folder_mod(
&scratch.join("signs"),
&[
("horizon-mod.toml", &manifest("signs", "")),
(
"data/signs/horizon/block/notice.json",
r#"{"template": "minecraft:oak_sign", "wood_type": "signs:elm", "wall_block": "signs:board"}"#,
),
(
"data/signs/horizon/block/board.json",
r#"{"template": "minecraft:oak_wall_sign", "wood_type": "signs:elm", "item": false}"#,
),
(
"data/signs/horizon/block/lantern.json",
r#"{"template": "minecraft:spruce_hanging_sign", "wall_block": "signs:bracket"}"#,
),
(
"data/signs/horizon/block/bracket.json",
r#"{"template": "minecraft:spruce_wall_hanging_sign", "wood_type": "cherry", "item": false}"#,
),
(
"data/signs/horizon/block/road_sign.json",
r#"{"template": "minecraft:stone"}"#,
),
],
);
let root = resource_root(&base, std::slice::from_ref(&m), &scratch.join("cache")).unwrap();
let assets = Assets::load(&root).unwrap();
let registry = assets.registry;
let facts = BlockFacts::load(&root, registry).unwrap();
let behaviours = Behaviours::load(registry, &root).unwrap();
let block = |name: &str| facts.get(registry.default_state(name).unwrap());
let form = |hanging, wall| Some(crate::blocks::SignForm { hanging, wall });
assert_eq!(block("signs:notice").sign_form(), form(false, false));
assert_eq!(block("signs:board").sign_form(), form(false, true));
assert_eq!(block("signs:lantern").sign_form(), form(true, false));
assert_eq!(block("signs:bracket").sign_form(), form(true, true));
assert_eq!(block("signs:road_sign").sign_form(), None);
assert_eq!(
block("minecraft:oak_wall_sign").sign_form(),
form(false, true)
);
// The declared wood, or the template's.
assert_eq!(facts.wood_type("signs:notice"), Some("signs:elm"));
assert_eq!(facts.wood_type("signs:bracket"), Some("cherry"));
assert_eq!(facts.wood_type("signs:lantern"), Some("spruce"));
assert_eq!(
facts.wood_type("minecraft:dark_oak_wall_sign"),
Some("dark_oak")
);
assert_eq!(facts.wood_type("signs:road_sign"), None);
// Each item places its sign and the wall form it names, which it is the item of.
let id = |name: &str| behaviours.table.block_by_name(name);
let notice = behaviours.item_binding("signs:notice").unwrap();
assert_eq!(notice.kind, ItemKind::StandingAndWall);
assert_eq!(
(Some(notice.block), notice.wall_block),
(id("signs:notice"), id("signs:board"))
);
let lantern = behaviours.item_binding("signs:lantern").unwrap();
assert_eq!(lantern.kind, ItemKind::HangingSign);
assert_eq!(
(Some(lantern.block), lantern.wall_block),
(id("signs:lantern"), id("signs:bracket"))
);
assert_eq!(&*block("signs:board").item, "signs:notice");
assert_eq!(&*block("signs:bracket").item, "signs:lantern");
let mut renderer =
crate::block_entity_render::BlockEntityRenderer::load(&root, registry).unwrap();
renderer.add_mod_signs(&facts, registry);
for name in [
"signs:notice",
"signs:board",
"signs:lantern",
"signs:bracket",
] {
assert!(
renderer.renders(registry.default_state(name).unwrap().0),
"{name}"
);
}
assert!(!renderer.renders(registry.default_state("signs:road_sign").unwrap().0));
// Vanilla's pair stays as it was.
let oak = behaviours.item_binding("minecraft:oak_sign").unwrap();
assert_eq!(oak.wall_block, id("minecraft:oak_wall_sign"));
let _ = fs::remove_dir_all(&scratch);
}
/// `WorldGenRegion.setBlock`: a feature that places a mod's sign leaves it its
/// block entity, which goes by the template's class and not the block's name.
#[test]
#[ignore = "full content root: CI and pr_ready run it"]
fn a_feature_gives_a_mods_sign_its_block_entity() {
use crate::containers::BlockEntity;
use crate::streaming::{ChunkGenerator, Proto};
let base = prepared().expect("this ignored test needs prepared 26.3 resources");
let scratch =
std::env::temp_dir().join(format!("horizon-mod-sign-feature-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
let m = folder_mod(
&scratch.join("post"),
&[
("horizon-mod.toml", &manifest("post", "")),
(
"data/post/horizon/block/notice.json",
r#"{"template": "minecraft:oak_sign"}"#,
),
(
"data/post/worldgen/configured_feature/notice.json",
r#"{"type": "minecraft:ore", "size": 9, "discard_chance_on_air_exposure": 0.0,
"targets": [{"target": {"predicate_type": "minecraft:tag_match",
"tag": "minecraft:stone_ore_replaceables"},
"state": "post:notice"}]}"#,
),
(
"data/post/worldgen/placed_feature/notice.json",
r#"{"feature": "post:notice", "placement": [
{"type": "minecraft:count", "count": 40}, {"type": "minecraft:in_square"},
{"type": "minecraft:height_range", "height": {"type": "minecraft:uniform",
"min_inclusive": {"absolute": -32}, "max_inclusive": {"absolute": 48}}},
{"type": "minecraft:biome"}]}"#,
),
(
"data/post/neoforge/biome_modifier/add_notice.json",
r##"{"type": "neoforge:add_features", "biomes": "#minecraft:is_overworld",
"features": "post:notice", "step": "underground_ores"}"##,
),
],
);
let root = resource_root(&base, &[m], &scratch.join("cache")).unwrap();
let assets = Assets::load(&root).unwrap();
let registry = assets.registry;
let facts = BlockFacts::load(&root, registry).unwrap();
let biomes = crate::biome::Biomes::load(&root).unwrap();
let notice = registry.default_state("post:notice").unwrap().0;
let generate = |signs: bool| -> (usize, usize) {
let mut terrain =
crate::vanillagen::VanillaTerrain::new(&root.join("worldgen"), registry, &biomes, 1234)
.unwrap();
if signs {
terrain.add_mod_signs(&facts);
}
let mut worker = terrain.worker();
let mut region: [Proto; 9] =
std::array::from_fn(|i| terrain.proto(&mut worker, i as i32 % 3 - 1, i as i32 / 3 - 1));
terrain.decorate(&mut worker, &mut region);
let [_, _, _, _, center, ..] = region;
let chunk = terrain.finish(center);
let mut placed = 0;
for y in chunk.min_y()..chunk.max_y() {
for z in 0..16 {
for x in 0..16 {
placed += usize::from(chunk.get(x, y, z) == notice);
}
}
}
let entities = chunk
.block_entities
.iter()
.filter(|(_, e)| matches!(e, BlockEntity::Sign(s) if !s.hanging))
.count();
(placed, entities)
};
let (placed, entities) = generate(false);
assert!(placed > 0, "the feature places the sign");
assert_eq!(entities, 0, "by its name alone it has none");
assert_eq!(generate(true), (placed, placed));
let _ = fs::remove_dir_all(&scratch);
}
+39 -1
View File
@@ -250,7 +250,17 @@ fn text(nbt: &Compound, key: &str) -> Option<String> {
/// hands the template's block entity NBT to `loadWithComponents`, which is
/// what a chunk load does with a saved one.
fn generated_block_entity(block: &str, nbt: &Compound) -> Option<BlockEntity> {
let fallback = BlockEntity::for_block(block)?;
// A mod sign has no vanilla block name; its saved entity id is still vanilla's.
let fallback = BlockEntity::for_block(block).or_else(|| {
let id = text(nbt, "id")?;
if !block.starts_with("minecraft:")
&& matches!(id.as_str(), "minecraft:sign" | "minecraft:hanging_sign")
{
BlockEntity::for_block(&id)
} else {
None
}
})?;
let mut tag = nbt.clone();
// RandomizableContainer.tryLoadLootTable: with a table the items are not read.
if text(&tag, "LootTable").is_some() {
@@ -308,6 +318,34 @@ mod tests {
use super::*;
use horizon_world::loot::{ExplorationMap, LootStack};
#[test]
fn a_saved_mod_sign_uses_its_vanilla_entity_type() {
for hanging in [false, true] {
let mut sign = crate::signs::SignData {
hanging,
..Default::default()
};
sign.front.lines[0] = "Front".into();
sign.back.lines[0] = "Back".into();
sign.front.color = 9;
sign.back.glowing = true;
sign.waxed = true;
let mut nbt: Compound = sign
.encode()
.into_iter()
.map(|(k, v)| (k.into(), v))
.collect();
nbt.insert("id".into(), Tag::String(sign.id().into()));
let Some(BlockEntity::Sign(loaded)) = generated_block_entity("test:notice", &nbt)
else {
panic!("saved sign missing");
};
assert_eq!(loaded, sign);
assert!(generated_block_entity("minecraft:stone", &nbt).is_none());
}
assert!(generated_block_entity("test:road_sign", &Compound::new()).is_none());
}
#[test]
fn generated_end_gateway_keeps_its_exit() {
let Tag::Compound(nbt) = Tag::compound([
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff. Show more