mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 02:21:33 -10:00
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:
commit
1bdee6af8d
179 files changed
+10660
-656
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
@@ -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, _) =
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"))
|
||||
));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(§ion_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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
®istry,
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[¬ice]).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn encoder() -> ChunkEncoder {
|
||||
let states = ["minecraft:air", "minecraft:stone", "minecraft:water"]
|
||||
.map(|n| (Arc::<str>::from(n), n.ends_with("water")));
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2323,6 +2323,8 @@ impl Hub {
|
||||
equipment,
|
||||
attributes,
|
||||
precise: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
needs_sync: false,
|
||||
leash: None,
|
||||
spectator: false,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user