mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge current main through server and LAN joins
Co-Authored-By: OpenAI Codex <[email protected]>
This commit is contained in:
commit
78ca40d8a5
126 files changed
+10484
-995
No files matched your search
@@ -0,0 +1,61 @@
|
||||
name: pack load bench
|
||||
# How long resource packs take to load: crates/horizon-client-render/examples/pack_load_bench.rs on generated packs
|
||||
# (no game files), on the branch and then on `base` with the branch's copy of the bench, on the same runner, so a pull
|
||||
# request can show its numbers next to main's. Started by hand, or by a push to a `pack-bench/*` branch (a fork can't
|
||||
# start a workflow its main lacks), against horizon-rust/horizon's main.
|
||||
on:
|
||||
push:
|
||||
branches: ['pack-bench/**']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
base:
|
||||
description: Ref to compare against
|
||||
default: main
|
||||
scale:
|
||||
description: How many times the default pack sizes
|
||||
default: '1'
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
CARGO_TERM_COLOR: never
|
||||
|
||||
jobs:
|
||||
bench:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Rust and the ALSA and udev headers
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: pack-bench
|
||||
- name: Bench the branch
|
||||
env:
|
||||
SCALE: ${{ inputs.scale || '1' }}
|
||||
run: |
|
||||
cargo run --profile fast -p horizon-client-render --example pack_load_bench -- --scale "$SCALE" > head.md
|
||||
- name: Bench the base, with the branch's bench
|
||||
env:
|
||||
BASE: ${{ inputs.base || 'main' }}
|
||||
SCALE: ${{ inputs.scale || '1' }}
|
||||
run: |
|
||||
git fetch https://github.com/horizon-rust/horizon.git "$BASE"
|
||||
git worktree add ../base FETCH_HEAD
|
||||
cp crates/horizon-client-render/examples/pack_load_bench.rs ../base/crates/horizon-client-render/examples/
|
||||
# A bench row that times a function the base lacks keeps it from building there: then
|
||||
# the branch's own rows compare the two ways.
|
||||
(cd ../base && CARGO_TARGET_DIR="$GITHUB_WORKSPACE/target" cargo run --profile fast -p horizon-client-render \
|
||||
--example pack_load_bench -- --scale "$SCALE") > base.md \
|
||||
|| echo "The bench does not build on the base." > base.md
|
||||
- name: Summary
|
||||
run: |
|
||||
{
|
||||
echo "### Branch ($(git rev-parse --short HEAD))"; cat head.md
|
||||
echo; echo "### Base (${{ inputs.base || 'main' }})"; cat base.md
|
||||
} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
@@ -159,7 +159,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::{
|
||||
json::Result,
|
||||
math::{Mat4, quaternion_xyz},
|
||||
model::{Direction, DisplayTransform, GuiLight, ItemTransform, Material},
|
||||
resolve::ModelLibrary,
|
||||
resolve::{ModelLibrary, ResolvedCuboidModel},
|
||||
};
|
||||
use std::{collections::BTreeSet, f32::consts::PI};
|
||||
|
||||
@@ -110,6 +110,15 @@ pub fn bake_item_model(
|
||||
let model = library
|
||||
.resolve(name)
|
||||
.or_else(|_| library.resolve(crate::resolve::MISSING_MODEL))?;
|
||||
bake_resolved(&model, sprites)
|
||||
}
|
||||
|
||||
/// Bake an already resolved model, e.g. one from
|
||||
/// [`ModelLibrary::resolve_with_textures`].
|
||||
pub fn bake_resolved(
|
||||
model: &ResolvedCuboidModel,
|
||||
sprites: &dyn SpriteLookup,
|
||||
) -> Result<BakedItemModel> {
|
||||
let known = |sprite: &str| sprite == MISSING_SPRITE || sprites.mask(sprite).is_some();
|
||||
let material = |m: &Material| {
|
||||
if known(&m.sprite) {
|
||||
@@ -143,7 +152,7 @@ pub fn bake_item_model(
|
||||
quads
|
||||
} else {
|
||||
let mut quads =
|
||||
bake_block_model(&model, crate::bake::BlockModelTransform::new([0; 3], false))?;
|
||||
bake_block_model(model, crate::bake::BlockModelTransform::new([0; 3], false))?;
|
||||
for q in &mut quads {
|
||||
q.material = material(&q.material);
|
||||
}
|
||||
@@ -298,3 +307,87 @@ fn extrude_layer(mask: &SpriteMask, material: &Material, layer: i32) -> Result<V
|
||||
}
|
||||
Ok(quads)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{CuboidModel, TextureSlot};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The stick: 2x2 with one transparent pixel. `item/cit`: one opaque pixel, so a bake
|
||||
/// shows whose mask it extruded.
|
||||
struct Sprites;
|
||||
impl SpriteLookup for Sprites {
|
||||
fn mask(&self, sprite: &str) -> Option<SpriteMask> {
|
||||
match sprite {
|
||||
"minecraft:item/stick" => Some(SpriteMask {
|
||||
width: 2,
|
||||
height: 2,
|
||||
frames: vec![vec![true, false, false, false]],
|
||||
}),
|
||||
"horizon_cit:item/cit" => Some(SpriteMask {
|
||||
width: 1,
|
||||
height: 1,
|
||||
frames: vec![vec![false]],
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn library() -> ModelLibrary {
|
||||
ModelLibrary::with_item_generator(BTreeMap::from(
|
||||
[
|
||||
("item/generated", r#"{"parent":"builtin/generated","display":{"gui":{"scale":[2,2,2]}}}"#),
|
||||
("item/stick", r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#),
|
||||
("horizon:loop", r#"{"parent":"horizon:loop2"}"#),
|
||||
("horizon:loop2", r#"{"parent":"horizon:loop"}"#),
|
||||
("block/slab", r##"{"textures":{"particle":"#side","side":"block/absent"},"elements":[{"from":[0,0,0],"to":[16,8,16],"faces":{"up":{"texture":"#side"},"down":{"texture":"#side","cullface":"down"}}}]}"##),
|
||||
]
|
||||
.map(|(name, json)| (name.to_owned(), CuboidModel::from_json(json).unwrap())),
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn baking_by_name_falls_back_to_the_missing_model() {
|
||||
let library = library();
|
||||
// Front, back, and the 8 unit edges around the L of 3 opaque pixels.
|
||||
let stick = bake_item_model(&library, &Sprites, "item/stick").unwrap();
|
||||
assert_eq!(stick.quads.len(), 10);
|
||||
// An unknown sprite bakes as the missing sprite.
|
||||
let slab = bake_item_model(&library, &Sprites, "block/slab").unwrap();
|
||||
assert_eq!(slab.quads.len(), 2);
|
||||
assert_eq!(slab.particle.sprite, MISSING_SPRITE);
|
||||
// A cyclic chain, an error to `resolve`, bakes as the missing cube.
|
||||
let cyclic = bake_item_model(&library, &Sprites, "horizon:loop").unwrap();
|
||||
assert_eq!(cyclic.quads.len(), 6);
|
||||
assert!(
|
||||
cyclic
|
||||
.quads
|
||||
.iter()
|
||||
.all(|q| q.material.sprite == MISSING_SPRITE)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn replaced_layer_bakes_with_its_own_sprite() {
|
||||
let library = library();
|
||||
let child = BTreeMap::from([(
|
||||
"layer0".to_owned(),
|
||||
TextureSlot::Material(Material {
|
||||
sprite: "horizon_cit:item/cit".to_owned(),
|
||||
force_translucent: false,
|
||||
}),
|
||||
)]);
|
||||
let model = library.resolve_with_textures("item/stick", &child).unwrap();
|
||||
let baked = bake_resolved(&model, &Sprites).unwrap();
|
||||
// The CIT sprite's own mask, one opaque pixel: front, back and 4 edges.
|
||||
assert_eq!(baked.quads.len(), 6);
|
||||
assert!(
|
||||
baked
|
||||
.quads
|
||||
.iter()
|
||||
.all(|q| q.material.sprite == "horizon_cit:item/cit")
|
||||
);
|
||||
assert_eq!(baked.particle.sprite, "horizon_cit:item/cit");
|
||||
assert_eq!(baked.transforms[5].as_ref().unwrap().scale, [2.0; 3]);
|
||||
}
|
||||
}
|
||||
@@ -102,10 +102,54 @@ impl ModelLibrary {
|
||||
missing,
|
||||
})
|
||||
}
|
||||
/// This library plus `extra` models; an `extra` model replaces a
|
||||
/// same-named one, but not the built-in missing and generated models.
|
||||
/// Every existing model is shared, not copied.
|
||||
pub fn overlaid(&self, extra: BTreeMap<String, CuboidModel>) -> Result<Self> {
|
||||
let mut models = self.models.clone();
|
||||
let mut added = BTreeSet::new();
|
||||
for (name, model) in extra {
|
||||
let name = identifier(&name)?;
|
||||
if !added.insert(name.clone()) {
|
||||
return Err(error("duplicate normalized model identifier"));
|
||||
}
|
||||
// The built-ins win, as `from_models` keeps `missing` and
|
||||
// `with_item_generator` keeps `generated`.
|
||||
if name != MISSING_MODEL && name != GENERATED_MODEL {
|
||||
models.insert(name, Arc::new(model));
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
models,
|
||||
missing: Arc::clone(&self.missing),
|
||||
})
|
||||
}
|
||||
/// Follow the complete parent chain. A missing resource uses the built-in
|
||||
/// model; a cyclic chain is rejected, as native discovery excludes it.
|
||||
/// The returned model can outlive this library without copying its elements.
|
||||
pub fn resolve(&self, name: &str) -> Result<ResolvedCuboidModel> {
|
||||
self.resolve_chain(name, None)
|
||||
}
|
||||
/// `name`'s resolved model with a virtual child in front of it that has
|
||||
/// only `textures`: the child's slots win, and `#references` anywhere in
|
||||
/// the chain re-bind through them (so `builtin/generated`'s
|
||||
/// `particle: #layer0` follows a replaced `layer0`). The child's slots are
|
||||
/// taken as given: sprites namespaced, references without their `#`, as
|
||||
/// the JSON decoder leaves them. A cyclic chain is an
|
||||
/// error, as for `resolve`: a caller wanting the missing model falls
|
||||
/// back to it.
|
||||
pub fn resolve_with_textures(
|
||||
&self,
|
||||
name: &str,
|
||||
child: &BTreeMap<String, TextureSlot>,
|
||||
) -> Result<ResolvedCuboidModel> {
|
||||
self.resolve_chain(name, Some(child))
|
||||
}
|
||||
fn resolve_chain(
|
||||
&self,
|
||||
name: &str,
|
||||
child: Option<&BTreeMap<String, TextureSlot>>,
|
||||
) -> Result<ResolvedCuboidModel> {
|
||||
let mut name = identifier(name)?;
|
||||
let mut visited = BTreeSet::new();
|
||||
let mut chain = Vec::new();
|
||||
@@ -156,6 +200,9 @@ impl ModelLibrary {
|
||||
for model in chain.into_iter().rev() {
|
||||
slots.extend(model.textures.iter().map(|(k, v)| (k.clone(), v)));
|
||||
}
|
||||
if let Some(child) = child {
|
||||
slots.extend(child.iter().map(|(k, v)| (k.clone(), v)));
|
||||
}
|
||||
let mut textures = BTreeMap::<String, Material>::new();
|
||||
let mut unresolved_textures = BTreeSet::<String>::new();
|
||||
for start in slots.keys() {
|
||||
@@ -274,6 +321,195 @@ mod tests {
|
||||
}
|
||||
);
|
||||
}
|
||||
fn library(models: &[(&str, &str)]) -> ModelLibrary {
|
||||
ModelLibrary::with_item_generator(
|
||||
models
|
||||
.iter()
|
||||
.map(|(name, json)| (name.to_string(), CuboidModel::from_json(json).unwrap()))
|
||||
.collect(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
const GENERATED: &str = r#"{"parent":"builtin/generated","display":{"ground":{"translation":[0,2,0],"scale":[0.5,0.5,0.5]}}}"#;
|
||||
const CIT: &str = "horizon_cit:minecraft/optifine/cit/x";
|
||||
#[test]
|
||||
fn child_textures_replace_slots_and_rebind_the_generated_particle() {
|
||||
let library = library(&[
|
||||
("item/generated", GENERATED),
|
||||
(
|
||||
"item/stick",
|
||||
r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#,
|
||||
),
|
||||
]);
|
||||
let child = BTreeMap::from([(
|
||||
"layer0".to_owned(),
|
||||
TextureSlot::Material(Material {
|
||||
sprite: CIT.to_owned(),
|
||||
force_translucent: false,
|
||||
}),
|
||||
)]);
|
||||
let model = library.resolve_with_textures("item/stick", &child).unwrap();
|
||||
assert!(model.uses_item_generator());
|
||||
assert_eq!(model.material("layer0").unwrap().unwrap().sprite, CIT);
|
||||
assert_eq!(model.material("particle").unwrap().unwrap().sprite, CIT);
|
||||
assert_eq!(
|
||||
model.display.contexts[6],
|
||||
DisplayTransform::Explicit(Some(ItemTransform {
|
||||
rotation: [0.0; 3],
|
||||
translation: [0.0, 0.125, 0.0],
|
||||
scale: [0.5; 3],
|
||||
}))
|
||||
);
|
||||
// The library itself is untouched.
|
||||
let plain = library.resolve("item/stick").unwrap();
|
||||
assert_eq!(
|
||||
plain.material("particle").unwrap().unwrap().sprite,
|
||||
"minecraft:item/stick"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn overlaid_models_inherit_from_the_library_and_share_the_rest() {
|
||||
let base = library(&[
|
||||
("item/generated", GENERATED),
|
||||
(
|
||||
"item/handheld",
|
||||
r#"{"parent":"item/generated","display":{"thirdperson_righthand":{"rotation":[0,-90,55]}}}"#,
|
||||
),
|
||||
(
|
||||
"item/stick",
|
||||
r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#,
|
||||
),
|
||||
(
|
||||
"item/apple",
|
||||
r#"{"parent":"item/generated","textures":{"layer0":"item/apple"}}"#,
|
||||
),
|
||||
]);
|
||||
let cit = format!(r#"{{"parent":"item/handheld","textures":{{"layer0":"{CIT}"}}}}"#);
|
||||
let over = base
|
||||
.overlaid(BTreeMap::from([
|
||||
(CIT.to_owned(), CuboidModel::from_json(&cit).unwrap()),
|
||||
(
|
||||
"item/stick".to_owned(),
|
||||
CuboidModel::from_json(
|
||||
r#"{"parent":"item/generated","textures":{"layer0":"item/bone"}}"#,
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
]))
|
||||
.unwrap();
|
||||
let model = over.resolve(CIT).unwrap();
|
||||
assert!(model.uses_item_generator());
|
||||
assert_eq!(model.material("particle").unwrap().unwrap().sprite, CIT);
|
||||
let DisplayTransform::Explicit(Some(right)) = &model.display.contexts[1] else {
|
||||
panic!("handheld's right-hand transform is inherited");
|
||||
};
|
||||
assert_eq!(right.rotation, [0.0, -90.0, 55.0]);
|
||||
let sprite = |l: &ModelLibrary| {
|
||||
l.resolve("item/stick").unwrap().textures["layer0"]
|
||||
.sprite
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(sprite(&over), "minecraft:item/bone");
|
||||
assert_eq!(sprite(&base), "minecraft:item/stick");
|
||||
assert!(!base.resolve(CIT).unwrap().uses_item_generator());
|
||||
assert!(Arc::ptr_eq(
|
||||
&base.models["minecraft:item/apple"],
|
||||
&over.models["minecraft:item/apple"]
|
||||
));
|
||||
}
|
||||
fn material(sprite: &str) -> TextureSlot {
|
||||
TextureSlot::Material(Material {
|
||||
sprite: sprite.to_owned(),
|
||||
force_translucent: false,
|
||||
})
|
||||
}
|
||||
#[test]
|
||||
fn child_slots_take_part_in_reference_chains() {
|
||||
let library = library(&[
|
||||
(
|
||||
"item/two",
|
||||
r##"{"textures":{"layer0":"#tex","layer1":"item/b","tex":"item/a"}}"##,
|
||||
),
|
||||
("horizon:loop", r#"{"parent":"horizon:loop2"}"#),
|
||||
("horizon:loop2", r#"{"parent":"horizon:loop"}"#),
|
||||
]);
|
||||
let child = BTreeMap::from([
|
||||
// A base alias binds through a slot only the child sets...
|
||||
("tex".to_owned(), material(CIT)),
|
||||
// ...and a child alias through the base's slots.
|
||||
(
|
||||
"particle".to_owned(),
|
||||
TextureSlot::Reference("layer1".to_owned()),
|
||||
),
|
||||
// A child alias to itself resolves to nothing, without looping.
|
||||
("self".to_owned(), TextureSlot::Reference("self".to_owned())),
|
||||
]);
|
||||
let model = library.resolve_with_textures("item/two", &child).unwrap();
|
||||
assert_eq!(model.material("layer0").unwrap().unwrap().sprite, CIT);
|
||||
assert_eq!(
|
||||
model.material("particle").unwrap().unwrap().sprite,
|
||||
"minecraft:item/b"
|
||||
);
|
||||
assert_eq!(model.material("self").unwrap(), None);
|
||||
assert!(model.unresolved_textures.contains("self"));
|
||||
// An absent base is the missing cube under the child; a cyclic one an error.
|
||||
let absent = library
|
||||
.resolve_with_textures("item/absent", &child)
|
||||
.unwrap();
|
||||
assert_eq!(absent.elements().len(), 1);
|
||||
assert!(
|
||||
library
|
||||
.resolve_with_textures("horizon:loop", &child)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn an_overlay_replaces_parents_but_not_the_built_ins_and_refuses_twins() {
|
||||
let base = library(&[
|
||||
("item/generated", GENERATED),
|
||||
("item/handheld", r#"{"parent":"item/generated"}"#),
|
||||
(
|
||||
"item/sword",
|
||||
r#"{"parent":"item/handheld","textures":{"layer0":"item/sword"}}"#,
|
||||
),
|
||||
]);
|
||||
let model = |json: &str| CuboidModel::from_json(json).unwrap();
|
||||
let over = base
|
||||
.overlaid(BTreeMap::from([
|
||||
(
|
||||
"item/handheld".to_owned(),
|
||||
model(
|
||||
r#"{"parent":"item/generated","display":{"head":{"rotation":[0,45,0]}}}"#,
|
||||
),
|
||||
),
|
||||
// Neither built-in can be replaced.
|
||||
("builtin/missing".to_owned(), model(r#"{"elements":[]}"#)),
|
||||
("builtin/generated".to_owned(), model("{}")),
|
||||
]))
|
||||
.unwrap();
|
||||
// The base's sword sees the overlay's parent; the base library does not.
|
||||
let head = |l: &ModelLibrary| l.resolve("item/sword").unwrap().display.contexts[4].clone();
|
||||
let DisplayTransform::Explicit(Some(rotated)) = head(&over) else {
|
||||
panic!("the replaced parent's head transform");
|
||||
};
|
||||
assert_eq!(rotated.rotation, [0.0, 45.0, 0.0]);
|
||||
assert_ne!(head(&base), head(&over));
|
||||
// The generator kept: its `particle: #layer0`, which `{}` would drop.
|
||||
let sword = over.resolve("item/sword").unwrap();
|
||||
assert_eq!(
|
||||
sword.material("particle").unwrap().unwrap().sprite,
|
||||
"minecraft:item/sword"
|
||||
);
|
||||
// The missing cube kept, looked up by its own name, not as a fallback.
|
||||
let missing = over.resolve("builtin/missing").unwrap();
|
||||
assert_eq!(missing.elements().len(), 1);
|
||||
// Two spellings of one id.
|
||||
let twins = BTreeMap::from([
|
||||
("item/x".to_owned(), model("{}")),
|
||||
("minecraft:item/x".to_owned(), model("{}")),
|
||||
]);
|
||||
assert!(base.overlaid(twins).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn absent_parents_fall_back_but_cycles_invalidate_descendants() {
|
||||
let a = CuboidModel::from_json(r#"{"parent":"horizon:b"}"#).unwrap();
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
//! How long a big resource pack takes to load and reload, on generated files only (no game
|
||||
//! resources): a stand-in vanilla root, a server-style zip pack of many namespaces, and a
|
||||
//! high-resolution pack over vanilla's own textures. Each stage runs a few times; the median
|
||||
//! is printed as a Markdown table.
|
||||
//! `cargo run --profile fast -p horizon-client-render --example pack_load_bench [-- --scale 1]`
|
||||
//! (`.github/workflows/pack-bench.yml` runs it on a branch and on its base, side by side).
|
||||
use horizon_client_render::{
|
||||
hud::HudAtlas,
|
||||
model_library::ParsedModels,
|
||||
packs::{self, Pack},
|
||||
sprites::Sprites,
|
||||
};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
/// A small deterministic generator: the same files on every run and every branch.
|
||||
struct Noise(u64);
|
||||
impl Noise {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A `side`x`side` RGBA PNG (`frames` tall when animated) of blotchy pixels, which compress
|
||||
/// about as well as real textures do.
|
||||
fn png(noise: &mut Noise, side: u32, frames: u32, alpha: bool) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut out, side, side * frames);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
let mut pixels = Vec::with_capacity((side * side * frames * 4) as usize);
|
||||
let mut colour = [0u8; 4];
|
||||
for i in 0..side * side * frames {
|
||||
if i % 3 == 0 {
|
||||
let n = noise.next();
|
||||
colour = [n as u8, (n >> 8) as u8, (n >> 16) as u8, 255];
|
||||
if alpha && n >> 40 & 3 == 0 {
|
||||
colour[3] = 0;
|
||||
}
|
||||
}
|
||||
pixels.extend_from_slice(&colour);
|
||||
}
|
||||
writer.write_image_data(&pixels).unwrap();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn model(noise: &mut Noise, namespace: &str, texture: &str) -> Vec<u8> {
|
||||
let n = noise.next() % 4;
|
||||
format!(
|
||||
r##"{{"parent":"minecraft:block/cube_all","textures":{{"all":"{namespace}:{texture}"}},"elements":[{{"from":[0,0,0],"to":[16,{h},16],"faces":{{"north":{{"texture":"#all","uv":[0,0,16,16]}},"up":{{"texture":"#all"}}}}}}]}}"##,
|
||||
h = 4 + n * 4
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn write(root: &Path, name: &str, bytes: &[u8]) {
|
||||
let path = root.join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
/// A deflated zip written by hand (the reader skips CRCs, so they stay zero), at most 65535
|
||||
/// entries.
|
||||
#[derive(Default)]
|
||||
struct ZipOut {
|
||||
out: Vec<u8>,
|
||||
central: Vec<u8>,
|
||||
count: u16,
|
||||
}
|
||||
impl ZipOut {
|
||||
fn put(&mut self, name: &str, body: &[u8]) {
|
||||
let data = miniz_oxide::deflate::compress_to_vec(body, 6);
|
||||
let offset = u32::try_from(self.out.len()).unwrap();
|
||||
let sizes = [data.len() as u32, body.len() as u32];
|
||||
self.out.extend(0x0403_4b50u32.to_le_bytes());
|
||||
self.out.extend([20, 0, 0, 0, 8, 0]);
|
||||
self.out.extend([0; 8]);
|
||||
sizes.iter().for_each(|s| self.out.extend(s.to_le_bytes()));
|
||||
self.out.extend((name.len() as u16).to_le_bytes());
|
||||
self.out.extend([0; 2]);
|
||||
self.out.extend(name.as_bytes());
|
||||
self.out.extend(&data);
|
||||
self.central.extend(0x0201_4b50u32.to_le_bytes());
|
||||
self.central.extend([20, 0, 20, 0, 0, 0, 8, 0]);
|
||||
self.central.extend([0; 8]);
|
||||
sizes
|
||||
.iter()
|
||||
.for_each(|s| self.central.extend(s.to_le_bytes()));
|
||||
self.central.extend((name.len() as u16).to_le_bytes());
|
||||
self.central.extend([0; 12]);
|
||||
self.central.extend(offset.to_le_bytes());
|
||||
self.central.extend(name.as_bytes());
|
||||
self.count = self.count.checked_add(1).expect("too many zip entries");
|
||||
}
|
||||
fn finish(mut self, path: &Path) {
|
||||
let start = self.out.len() as u32;
|
||||
self.out.extend(&self.central);
|
||||
self.out.extend(0x0605_4b50u32.to_le_bytes());
|
||||
self.out.extend([0; 4]);
|
||||
self.out.extend(self.count.to_le_bytes());
|
||||
self.out.extend(self.count.to_le_bytes());
|
||||
self.out.extend((self.central.len() as u32).to_le_bytes());
|
||||
self.out.extend(start.to_le_bytes());
|
||||
self.out.extend([0; 2]);
|
||||
fs::write(path, self.out).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
const META: &[u8] = br#"{"pack":{"description":"bench","min_format":[69,0],"max_format":[999,0]}}"#;
|
||||
|
||||
/// The stand-in game resources: vanilla-sized texture folders, models and the stacked JSON
|
||||
/// files (lang, sounds, fonts, atlases) every reload merges.
|
||||
fn vanilla(root: &Path, scale: usize, noise: &mut Noise) -> Vec<String> {
|
||||
let mut blocks = Vec::new();
|
||||
for i in 0..1100 * scale {
|
||||
let name = format!("block/b{i}");
|
||||
let frames = if i % 50 == 0 { 8 } else { 1 };
|
||||
write(
|
||||
root,
|
||||
&format!("assets/minecraft/textures/{name}.png"),
|
||||
&png(noise, 16, frames, i % 7 == 0),
|
||||
);
|
||||
if frames > 1 {
|
||||
write(
|
||||
root,
|
||||
&format!("assets/minecraft/textures/{name}.png.mcmeta"),
|
||||
br#"{"animation":{"frametime":2}}"#,
|
||||
);
|
||||
}
|
||||
write(
|
||||
root,
|
||||
&format!("assets/minecraft/models/block/b{i}.json"),
|
||||
&model(noise, "minecraft", &name),
|
||||
);
|
||||
blocks.push(format!("minecraft:{name}"));
|
||||
}
|
||||
for (folder, count, side) in [("item", 1500, 16), ("entity", 900, 64), ("gui", 800, 16)] {
|
||||
for i in 0..count * scale {
|
||||
let sub = if folder == "gui" { "sprites/" } else { "" };
|
||||
write(
|
||||
root,
|
||||
&format!(
|
||||
"assets/minecraft/textures/{folder}/{sub}{}/t{i}.png",
|
||||
i % 20
|
||||
),
|
||||
&png(noise, side, 1, true),
|
||||
);
|
||||
}
|
||||
}
|
||||
for i in 0..1500 * scale {
|
||||
write(
|
||||
root,
|
||||
&format!("assets/minecraft/models/item/i{i}.json"),
|
||||
&model(noise, "minecraft", &format!("item/{}/t{i}", i % 20)),
|
||||
);
|
||||
}
|
||||
let lang: String = (0..4000)
|
||||
.map(|i| format!("\"key.{i}\":\"Value {i}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
write(
|
||||
root,
|
||||
"assets/minecraft/lang/en_us.json",
|
||||
format!("{{{lang}}}").as_bytes(),
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"assets/minecraft/sounds.json",
|
||||
br#"{"a":{"sounds":["a"]}}"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"assets/minecraft/atlases/blocks.json",
|
||||
br#"{"sources":[{"type":"directory","source":"block","prefix":"block/"}]}"#,
|
||||
);
|
||||
write(root, "pack.mcmeta", META);
|
||||
blocks
|
||||
}
|
||||
|
||||
/// A server pack the way ItemsAdder-style plugins make them: one zip of many namespaces, each
|
||||
/// with its own items' textures and models, its sounds and its lang files.
|
||||
fn server_zip(path: &Path, scale: usize, noise: &mut Noise) {
|
||||
let mut zip = ZipOut::default();
|
||||
zip.put("pack.mcmeta", META);
|
||||
for n in 0..190 {
|
||||
let ns = format!("plugin{n}");
|
||||
for i in 0..50 * scale {
|
||||
let texture = format!("item/{}/t{i}", i % 5);
|
||||
zip.put(
|
||||
&format!("assets/{ns}/textures/{texture}.png"),
|
||||
&png(noise, 32, 1, true),
|
||||
);
|
||||
zip.put(
|
||||
&format!("assets/{ns}/models/item/{}/m{i}.json", i % 5),
|
||||
&model(noise, &ns, &texture),
|
||||
);
|
||||
if i % 10 == 0 {
|
||||
zip.put(&format!("assets/{ns}/sounds/s{i}.ogg"), &[0; 2048]);
|
||||
}
|
||||
}
|
||||
zip.put(
|
||||
&format!("assets/{ns}/lang/en_us.json"),
|
||||
br#"{"item.custom":"Custom"}"#,
|
||||
);
|
||||
}
|
||||
let lang: String = (0..2000)
|
||||
.map(|i| format!("\"server.{i}\":\"Server {i}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
zip.put(
|
||||
"assets/minecraft/lang/en_us.json",
|
||||
format!("{{{lang}}}").as_bytes(),
|
||||
);
|
||||
zip.finish(path);
|
||||
}
|
||||
|
||||
/// A 64x pack over vanilla's own block, item and entity textures, with OptiFine CTM-style
|
||||
/// tiles beside them: what the big OptiFine packs look like on disk.
|
||||
fn hd_zip(path: &Path, scale: usize, noise: &mut Noise) {
|
||||
let mut zip = ZipOut::default();
|
||||
zip.put("pack.mcmeta", META);
|
||||
for i in 0..1100 * scale {
|
||||
zip.put(
|
||||
&format!("assets/minecraft/textures/block/b{i}.png"),
|
||||
&png(noise, 64, 1, i % 7 == 0),
|
||||
);
|
||||
if i % 4 == 0 {
|
||||
for tile in 0..12 {
|
||||
zip.put(
|
||||
&format!("assets/minecraft/optifine/ctm/b{i}/{tile}.png"),
|
||||
&png(noise, 64, 1, false),
|
||||
);
|
||||
}
|
||||
zip.put(
|
||||
&format!("assets/minecraft/optifine/ctm/b{i}/b{i}.properties"),
|
||||
format!("matchTiles=b{i}\nmethod=ctm\ntiles=0-11\n").as_bytes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
for i in 0..750 * scale {
|
||||
zip.put(
|
||||
&format!("assets/minecraft/textures/item/{}/t{i}.png", i % 20),
|
||||
&png(noise, 64, 1, true),
|
||||
);
|
||||
}
|
||||
zip.finish(path);
|
||||
}
|
||||
|
||||
fn median(mut ms: Vec<f64>) -> f64 {
|
||||
ms.sort_by(f64::total_cmp);
|
||||
ms[ms.len() / 2]
|
||||
}
|
||||
|
||||
/// `f`'s median time over `runs`, with `before` (not timed) ahead of each run.
|
||||
fn time(runs: usize, mut before: impl FnMut(), mut f: impl FnMut()) -> f64 {
|
||||
median(
|
||||
(0..runs)
|
||||
.map(|_| {
|
||||
before();
|
||||
let started = Instant::now();
|
||||
f();
|
||||
started.elapsed().as_secs_f64() * 1000.0
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn zip_pack(id: &str, path: PathBuf) -> Pack {
|
||||
// Read the way `packs::folder` reads a `resourcepacks/` zip.
|
||||
let dir = path.parent().unwrap().join(format!("{id}-dir"));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let at = dir.join(path.file_name().unwrap());
|
||||
fs::rename(&path, &at).unwrap();
|
||||
packs::folder(&dir, packs::RESOURCE_FORMAT)
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_else(|| panic!("{id} is not a pack"))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let scale = args
|
||||
.iter()
|
||||
.position(|a| a == "--scale")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map_or(1, |s| s.parse().unwrap());
|
||||
let runs = 3;
|
||||
let scratch = std::env::temp_dir().join(format!("horizon-pack-bench-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&scratch);
|
||||
fs::create_dir_all(&scratch).unwrap();
|
||||
let mut noise = Noise(0x9e37_79b9_7f4a_7c15);
|
||||
let vanilla_root = scratch.join("vanilla");
|
||||
let blocks = vanilla(&vanilla_root, scale, &mut noise);
|
||||
server_zip(&scratch.join("server.zip"), scale, &mut noise);
|
||||
hd_zip(&scratch.join("hd.zip"), scale, &mut noise);
|
||||
let server = zip_pack("server", scratch.join("server.zip"));
|
||||
let hd = zip_pack("hd", scratch.join("hd.zip"));
|
||||
let cache = scratch.join("cache");
|
||||
let both = [hd.clone(), server.clone()];
|
||||
|
||||
let mut rows: Vec<(String, f64)> = Vec::new();
|
||||
let mut row = |name: &str, ms: f64| {
|
||||
eprintln!("{name}: {ms:.1} ms");
|
||||
rows.push((name.to_owned(), ms));
|
||||
};
|
||||
let fresh = || {
|
||||
let _ = fs::remove_dir_all(&cache);
|
||||
};
|
||||
row(
|
||||
"overlay: build, both packs (cold)",
|
||||
time(runs, fresh, || {
|
||||
packs::resource_root(&vanilla_root, &both, &cache).unwrap();
|
||||
}),
|
||||
);
|
||||
let root = packs::resource_root(&vanilla_root, &both, &cache).unwrap();
|
||||
row(
|
||||
"overlay: reuse, both packs (cache hit)",
|
||||
time(
|
||||
runs,
|
||||
|| {},
|
||||
|| {
|
||||
packs::resource_root(&vanilla_root, &both, &cache).unwrap();
|
||||
},
|
||||
),
|
||||
);
|
||||
// The pack screen: one pack turned off, then on again.
|
||||
row(
|
||||
"overlay: toggle the server pack off (new selection)",
|
||||
time(
|
||||
runs,
|
||||
|| {
|
||||
let only =
|
||||
packs::resource_root(&vanilla_root, std::slice::from_ref(&hd), &cache).unwrap();
|
||||
fs::remove_dir_all(&only).unwrap();
|
||||
},
|
||||
|| {
|
||||
packs::resource_root(&vanilla_root, std::slice::from_ref(&hd), &cache).unwrap();
|
||||
},
|
||||
),
|
||||
);
|
||||
row(
|
||||
"models: parse every model",
|
||||
time(
|
||||
runs,
|
||||
|| {},
|
||||
|| {
|
||||
std::hint::black_box(ParsedModels::load(&root));
|
||||
},
|
||||
),
|
||||
);
|
||||
let sprite_cache = root.join("horizon-sprites.cache");
|
||||
// OptiFine connected-texture tiles, inserted by their path as `ConnectedTextures` does.
|
||||
let tiles: Vec<String> = (0..1100 * scale)
|
||||
.step_by(4)
|
||||
.flat_map(|i| (0..12).map(move |t| format!("assets/minecraft/optifine/ctm/b{i}/{t}.png")))
|
||||
.collect();
|
||||
let sprites = |cold: bool, prefetch: bool, names: &[String], files: bool| {
|
||||
time(
|
||||
runs,
|
||||
|| {
|
||||
if cold {
|
||||
let _ = fs::remove_file(&sprite_cache);
|
||||
}
|
||||
},
|
||||
|| {
|
||||
let mut sprites = Sprites::with_disk_cache(&root);
|
||||
if prefetch {
|
||||
sprites.prefetch(&root, names.iter().map(String::as_str));
|
||||
}
|
||||
for name in names {
|
||||
if files {
|
||||
sprites.insert_file(&root, name).unwrap();
|
||||
} else {
|
||||
sprites.insert(&root, name).unwrap();
|
||||
}
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
sprites.finish();
|
||||
std::hint::black_box(&sprites.pixels);
|
||||
},
|
||||
)
|
||||
};
|
||||
row(
|
||||
"block sprites: one by one (no cache)",
|
||||
sprites(true, false, &blocks, false),
|
||||
);
|
||||
row(
|
||||
"block sprites: prefetched (no cache)",
|
||||
sprites(true, true, &blocks, false),
|
||||
);
|
||||
row(
|
||||
"block sprites: from the disk cache",
|
||||
sprites(false, false, &blocks, false),
|
||||
);
|
||||
row("CTM tiles: one by one", sprites(false, false, &tiles, true));
|
||||
row("CTM tiles: prefetched", sprites(false, true, &tiles, true));
|
||||
row(
|
||||
"GUI atlas: decode and stitch, both packs",
|
||||
time(
|
||||
runs,
|
||||
|| {},
|
||||
|| {
|
||||
if let Err(e) = HudAtlas::build_over(&root, &vanilla_root) {
|
||||
eprintln!("GUI atlas failed: {e}");
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
row(
|
||||
"GUI atlas: vanilla alone",
|
||||
time(
|
||||
runs,
|
||||
|| {},
|
||||
|| {
|
||||
let _ = HudAtlas::build(&vanilla_root);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
let cores = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||||
println!("| Stage (scale {scale}, {cores} threads, median of {runs}) | ms |");
|
||||
println!("|---|---:|");
|
||||
for (name, ms) in &rows {
|
||||
println!("| {name} | {ms:.0} |");
|
||||
}
|
||||
let _ = fs::remove_dir_all(&scratch);
|
||||
}
|
||||
@@ -208,10 +208,26 @@ pub fn list_textures(root: &Path) -> Vec<String> {
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
/// The file of a texture identifier.
|
||||
/// The file of a texture identifier. A Custom Item Textures id (`cit_assets`) names its
|
||||
/// resource path, wherever the folder: `directory` is not used for it. An id whose path would
|
||||
/// leave the root (a pack's model may write any) names a file that is never there.
|
||||
pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
|
||||
// No file can have a NUL in its name, so this one is never there.
|
||||
const NOWHERE: &str = "assets/minecraft/textures/horizon/\0refused.png";
|
||||
if texture.starts_with("horizon_cit:") {
|
||||
return root.join(
|
||||
crate::cit_assets::resource(texture, "png")
|
||||
.as_deref()
|
||||
.unwrap_or(NOWHERE),
|
||||
);
|
||||
}
|
||||
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
|
||||
root.join(format!("assets/{namespace}/{directory}/{path}.png"))
|
||||
let rel = format!("assets/{namespace}/{directory}/{path}.png");
|
||||
root.join(if horizon_optifine::paths::valid(&rel) {
|
||||
&rel
|
||||
} else {
|
||||
NOWHERE
|
||||
})
|
||||
}
|
||||
|
||||
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
//! Which states tick is a table by state id, built by the game from the block
|
||||
//! registry; whether a slot is made is decided when the slot is promoted at
|
||||
//! the start of the next tick, from the block state then.
|
||||
use crate::chunk::block_entity::{BlockEntity, ModContainer, ModFluidTanks};
|
||||
use crate::chunk::block_entity::{BlockEntity, ModContainer, ModEnergyBuffer, ModFluidTanks};
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -76,6 +76,9 @@ pub struct BlockEntities {
|
||||
/// The fluid tanks of mod blocks' block entities, by block state id;
|
||||
/// built with `states`, like the containers.
|
||||
mod_fluids: FxHashMap<u32, Arc<ModFluidTanks>>,
|
||||
/// The energy buffers of mod blocks' block entities, by block state id;
|
||||
/// built with `states`, like the containers.
|
||||
mod_energy: FxHashMap<u32, Arc<ModEnergyBuffer>>,
|
||||
/// The positions of the mod block entities (`BlockEntity::Mod`) by chunk,
|
||||
/// for the renderer: the mesher lists block entities by their template,
|
||||
/// which leaves out a mod block whose template has none. One taken out
|
||||
@@ -192,6 +195,13 @@ impl BlockEntities {
|
||||
pub fn mod_fluids(&self, state: u32) -> Option<&Arc<ModFluidTanks>> {
|
||||
self.mod_fluids.get(&state)
|
||||
}
|
||||
pub fn set_mod_energy(&mut self, energy: FxHashMap<u32, Arc<ModEnergyBuffer>>) {
|
||||
self.mod_energy = energy;
|
||||
}
|
||||
/// The energy buffer of a mod block's block entity in block state `state`.
|
||||
pub fn mod_energy(&self, state: u32) -> Option<&Arc<ModEnergyBuffer>> {
|
||||
self.mod_energy.get(&state)
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u32 {
|
||||
let n = self.next_slot;
|
||||
|
||||
@@ -8,7 +8,13 @@ use horizon_world::{
|
||||
level::sculk::SculkSpreader,
|
||||
nbt::{Compound, Tag},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
/// An entity's UUID as its four ints (`UUIDUtil.CODEC`).
|
||||
pub type Uuid = [i32; 4];
|
||||
@@ -94,18 +100,98 @@ pub enum BlockEntity {
|
||||
/// `TestInstanceBlockEntity` (`test_instance_block.rs`).
|
||||
TestInstanceBlock(Box<crate::test_instance_block::TestInstanceBlock>),
|
||||
}
|
||||
/// A mod's block entity: its type id (`mymod:machine`), its saved
|
||||
/// A mod's block entity, read and changed through its fields
|
||||
/// (`ModBlockEntityFields`) as a struct's: `entity.nbt`. Each change goes
|
||||
/// through `DerefMut` and gives it a new `revision`, so a client tells
|
||||
/// whether what it kept of it (`BlockEntityDraw::keep`) is still right
|
||||
/// without comparing the whole entity, and no write can skip that.
|
||||
pub struct ModBlockEntity {
|
||||
fields: ModBlockEntityFields,
|
||||
revision: ModRevision,
|
||||
}
|
||||
/// What a `ModBlockEntity` holds: its type id (`mymod:machine`), its saved
|
||||
/// compound without `id`, `x`, `y`, `z` and `Items`, its item slots
|
||||
/// (`Items`) when its block has a container (`ModContainer`), and the data
|
||||
/// slots its menu shows (`ContainerData`: a furnace menu's flame and arrow),
|
||||
/// which the mod sets and nothing saves.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ModBlockEntity {
|
||||
pub struct ModBlockEntityFields {
|
||||
pub id: String,
|
||||
pub nbt: Compound,
|
||||
pub items: Vec<Option<ItemStack>>,
|
||||
pub data: Vec<i32>,
|
||||
}
|
||||
/// Which contents a `ModBlockEntity` has had: one per entity made (a clone
|
||||
/// is another), counted on by each change. Equal revisions, equal contents.
|
||||
/// Neither saved nor compared.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ModRevision {
|
||||
born: u64,
|
||||
changes: u64,
|
||||
}
|
||||
static MOD_BLOCK_ENTITIES_MADE: AtomicU64 = AtomicU64::new(0);
|
||||
impl ModBlockEntity {
|
||||
pub fn new(fields: ModBlockEntityFields) -> Self {
|
||||
Self {
|
||||
fields,
|
||||
revision: ModRevision {
|
||||
born: MOD_BLOCK_ENTITIES_MADE.fetch_add(1, Ordering::Relaxed),
|
||||
changes: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
pub fn revision(&self) -> ModRevision {
|
||||
self.revision
|
||||
}
|
||||
/// A client's copy loads the tag the server sent (`loadWithComponents`
|
||||
/// in `ClientPacketListener.handleBlockEntityData`): `tag`'s fields
|
||||
/// replace its own of the same names. One it already has as sent is
|
||||
/// left alone, so a tag sent again unchanged keeps its revision.
|
||||
pub fn merge_update_tag(&mut self, tag: &Compound) {
|
||||
for (k, v) in tag {
|
||||
if self.nbt.get(k) != Some(v) {
|
||||
self.nbt.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl From<ModBlockEntityFields> for ModBlockEntity {
|
||||
fn from(fields: ModBlockEntityFields) -> Self {
|
||||
Self::new(fields)
|
||||
}
|
||||
}
|
||||
impl Default for ModBlockEntity {
|
||||
fn default() -> Self {
|
||||
Self::new(ModBlockEntityFields::default())
|
||||
}
|
||||
}
|
||||
impl Clone for ModBlockEntity {
|
||||
fn clone(&self) -> Self {
|
||||
Self::new(self.fields.clone())
|
||||
}
|
||||
}
|
||||
impl PartialEq for ModBlockEntity {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.fields == other.fields
|
||||
}
|
||||
}
|
||||
impl std::fmt::Debug for ModBlockEntity {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.fields.fmt(f)
|
||||
}
|
||||
}
|
||||
impl std::ops::Deref for ModBlockEntity {
|
||||
type Target = ModBlockEntityFields;
|
||||
fn deref(&self) -> &ModBlockEntityFields {
|
||||
&self.fields
|
||||
}
|
||||
}
|
||||
impl std::ops::DerefMut for ModBlockEntity {
|
||||
fn deref_mut(&mut self) -> &mut ModBlockEntityFields {
|
||||
self.revision.changes += 1;
|
||||
&mut self.fields
|
||||
}
|
||||
}
|
||||
/// The container of a mod block's block entity, as a `WorldlyContainer`
|
||||
/// says it: its slot count, the slots each face reaches
|
||||
/// (`getSlotsForFace`, by `Direction`: down, up, north, south, west, east),
|
||||
@@ -135,6 +221,23 @@ pub struct ModFluidTanks {
|
||||
/// The tanks a drain may take from (`canTakeItemThroughFace`).
|
||||
pub outputs: Vec<usize>,
|
||||
}
|
||||
/// The energy buffer of a mod block's block entity (NeoForge's
|
||||
/// `SimpleEnergyHandler`): its capacity, the most one insert and one extract
|
||||
/// moves, and the faces a fill may come through and a drain may leave by.
|
||||
/// The amount is not here: it lives in the entity's saved compound, under
|
||||
/// `energy`, so it survives restarts and the block's own tick reads it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ModEnergyBuffer {
|
||||
pub capacity: i32,
|
||||
/// The most one insert moves; 0 takes none.
|
||||
pub max_insert: i32,
|
||||
/// The most one extract moves; 0 gives none.
|
||||
pub max_extract: i32,
|
||||
/// The faces, by `Direction` number, a fill may come through.
|
||||
pub insert_faces: [bool; 6],
|
||||
/// The faces a drain may leave by.
|
||||
pub extract_faces: [bool; 6],
|
||||
}
|
||||
/// The vanilla menus a mod container can open, with the slot count each
|
||||
/// shows: slot grids, and the furnaces' (input, fuel, result), whose four
|
||||
/// data slots draw the flame (`data[0]` of `data[1]`) and the arrow
|
||||
@@ -588,3 +691,64 @@ pub struct CommandBlock {
|
||||
pub auto: bool,
|
||||
pub condition_met: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_mod_block_entitys_revision_moves_with_each_change_and_only_then() {
|
||||
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
|
||||
id: "mymod:machine".into(),
|
||||
data: vec![0],
|
||||
..Default::default()
|
||||
});
|
||||
let made = machine.revision();
|
||||
// Reads leave it.
|
||||
assert_eq!(machine.data[0] + machine.nbt.len() as i32, 0);
|
||||
assert_eq!(machine.revision(), made);
|
||||
// Every way to change a field moves it.
|
||||
let mut seen = vec![made];
|
||||
let changes: [fn(&mut ModBlockEntity); 4] = [
|
||||
|m| m.data[0] = 1,
|
||||
|m| _ = m.nbt.insert("progress".into(), Tag::Int(3)),
|
||||
|m| m.items.push(None),
|
||||
|m| m.id.push('s'),
|
||||
];
|
||||
for change in changes {
|
||||
change(&mut machine);
|
||||
assert!(!seen.contains(&machine.revision()));
|
||||
seen.push(machine.revision());
|
||||
}
|
||||
// Another entity, or a clone, never shares one, even with equal
|
||||
// contents; equality is the contents' alone.
|
||||
let copy = machine.clone();
|
||||
assert_eq!(copy, machine);
|
||||
assert!(!seen.contains(©.revision()));
|
||||
let fresh = ModBlockEntity::default();
|
||||
assert_ne!(fresh.revision(), ModBlockEntity::default().revision());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_update_tag_sent_again_unchanged_keeps_the_revision() {
|
||||
let mut machine = ModBlockEntity::default();
|
||||
let tag = Compound::from([
|
||||
("progress".into(), Tag::Int(3)),
|
||||
("fuel".into(), Tag::Int(9)),
|
||||
]);
|
||||
machine.merge_update_tag(&tag);
|
||||
let merged = machine.revision();
|
||||
machine.merge_update_tag(&tag);
|
||||
assert_eq!(machine.revision(), merged);
|
||||
// One field changed: replaced, the other kept, the revision moved.
|
||||
machine.merge_update_tag(&Compound::from([("progress".into(), Tag::Int(4))]));
|
||||
assert_ne!(machine.revision(), merged);
|
||||
assert_eq!(
|
||||
machine.nbt,
|
||||
Compound::from([
|
||||
("progress".into(), Tag::Int(4)),
|
||||
("fuel".into(), Tag::Int(9)),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! The ids Custom Item Textures' files take in Horizon's atlas and model library. A CIT rule
|
||||
//! names its textures and models by resource path, mostly under `optifine/cit/` where vanilla
|
||||
//! never looks: those take the reserved namespace [`NAMESPACE`], their path the resource path's
|
||||
//! (`assets/minecraft/optifine/cit/swords/flame.png` is
|
||||
//! `horizon_cit:minecraft/optifine/cit/swords/flame`). A file already under `textures/` (or
|
||||
//! `models/`) keeps its vanilla id, so it is not packed twice.
|
||||
|
||||
use horizon_optifine::cit::{Kind, Rule};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// The namespace of CIT files outside `textures/` and `models/`.
|
||||
pub const NAMESPACE: &str = "horizon_cit";
|
||||
|
||||
/// `<namespace>:<path>`, if both keep to the identifier rules (lower-case letters, digits,
|
||||
/// `_.-`, and `/` in the path). CIT Resewn refuses the other paths too.
|
||||
fn identifier(namespace: &str, path: &str) -> Option<String> {
|
||||
if namespace.is_empty() || path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
horizon_client_core::identifier::parse_identifier(&format!("{namespace}:{path}"))
|
||||
}
|
||||
|
||||
/// The id of a resource path ending in `.<ext>`: `assets/<ns>/<folder>/<p>` is `<ns>:<p>`, any
|
||||
/// other `assets/<rest>` is `horizon_cit:<rest>`.
|
||||
fn id(resource: &str, folder: &str, ext: &str) -> Option<String> {
|
||||
let rest = resource
|
||||
.strip_prefix("assets/")?
|
||||
.strip_suffix(ext)?
|
||||
.strip_suffix('.')?;
|
||||
if let Some((namespace, path)) = rest.split_once('/')
|
||||
&& let Some(path) = path.strip_prefix(folder).and_then(|p| p.strip_prefix('/'))
|
||||
{
|
||||
// A pack's own `assets/horizon_cit/` would name another file than its id maps back to.
|
||||
return (namespace != NAMESPACE)
|
||||
.then(|| identifier(namespace, path))
|
||||
.flatten();
|
||||
}
|
||||
identifier(NAMESPACE, rest)
|
||||
}
|
||||
|
||||
/// The atlas sprite id of a `.png` resource path.
|
||||
pub fn sprite_id(resource: &str) -> Option<String> {
|
||||
id(resource, "textures", "png")
|
||||
}
|
||||
|
||||
/// The model id of a `.json` resource path.
|
||||
pub fn model_id(resource: &str) -> Option<String> {
|
||||
id(resource, "models", "json")
|
||||
}
|
||||
|
||||
/// The resource path of a `horizon_cit` id, with the extension `ext`; `None` for other ids,
|
||||
/// and for one that would leave the resource root (a pack model may write any id).
|
||||
pub fn resource(id: &str, ext: &str) -> Option<String> {
|
||||
let rest = id.strip_prefix(NAMESPACE)?.strip_prefix(':')?;
|
||||
horizon_optifine::paths::valid(rest).then(|| format!("assets/{rest}.{ext}"))
|
||||
}
|
||||
|
||||
/// Sprite ids the item rules draw: `Kind::Item` rules' `texture` and `texture.<name>` values.
|
||||
/// (Textures named by CIT JSON models come in a later PR.) A path no id can name is skipped with
|
||||
/// a warning.
|
||||
pub fn item_sprites(rules: &[Rule]) -> BTreeSet<String> {
|
||||
let mut out = BTreeSet::new();
|
||||
for rule in rules.iter().filter(|r| r.kind == Kind::Item) {
|
||||
let named = rule.textures.iter().map(|(_, path)| path);
|
||||
for path in rule.texture.iter().chain(named) {
|
||||
match sprite_id(path) {
|
||||
Some(id) => {
|
||||
out.insert(id);
|
||||
}
|
||||
None => log::warn!(
|
||||
"custom item textures: {} names {path}, which is not a valid texture id",
|
||||
rule.path
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::atlas_sources::texture_path;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn cit_paths_take_the_reserved_namespace_and_come_back() {
|
||||
let png = "assets/minecraft/optifine/cit/swords/flame.png";
|
||||
let id = sprite_id(png).unwrap();
|
||||
assert_eq!(id, "horizon_cit:minecraft/optifine/cit/swords/flame");
|
||||
assert_eq!(resource(&id, "png").as_deref(), Some(png));
|
||||
let json = "assets/minecraft/optifine/cit/swords/flame.json";
|
||||
let id = model_id(json).unwrap();
|
||||
assert_eq!(id, "horizon_cit:minecraft/optifine/cit/swords/flame");
|
||||
assert_eq!(resource(&id, "json").as_deref(), Some(json));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_under_textures_and_models_keep_their_vanilla_ids() {
|
||||
let id = sprite_id("assets/minecraft/textures/item/stick.png");
|
||||
assert_eq!(id.as_deref(), Some("minecraft:item/stick"));
|
||||
let id = model_id("assets/gems/models/item/ruby.json");
|
||||
assert_eq!(id.as_deref(), Some("gems:item/ruby"));
|
||||
// A model id is not a resource of this module's own.
|
||||
assert_eq!(resource("minecraft:item/stick", "png"), None);
|
||||
// `textures` as a folder name elsewhere is still a CIT path.
|
||||
let id = sprite_id("assets/minecraft/optifine/cit/textures/a.png");
|
||||
assert_eq!(
|
||||
id.as_deref(),
|
||||
Some("horizon_cit:minecraft/optifine/cit/textures/a")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_no_identifier_can_name_are_refused() {
|
||||
for path in [
|
||||
"assets/minecraft/optifine/cit/Flame.png",
|
||||
"assets/minecraft/optifine/cit/fire sword.png",
|
||||
"assets/minecraft/textures/item/Stick.png",
|
||||
"assets/minecraft/optifine/cit/a.json",
|
||||
"optifine/cit/a.png",
|
||||
] {
|
||||
assert_eq!(sprite_id(path), None, "{path}");
|
||||
}
|
||||
assert_eq!(model_id("assets/minecraft/optifine/cit/A.json"), None);
|
||||
assert_eq!(
|
||||
sprite_id("assets//textures/item/x.png"),
|
||||
None,
|
||||
"no namespace"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reserved_namespace_names_only_files_inside_the_root() {
|
||||
// A pack's own `horizon_cit` namespace would map back to another file.
|
||||
assert_eq!(sprite_id("assets/horizon_cit/textures/item/x.png"), None);
|
||||
// A model may write any id: only ones inside the root name a file.
|
||||
for id in [
|
||||
"horizon_cit:../../etc/x",
|
||||
"horizon_cit:a/../../x",
|
||||
"horizon_cit:a\\..\\x",
|
||||
] {
|
||||
assert_eq!(resource(id, "png"), None, "{id}");
|
||||
// Nor does the texture's file: refused, not read from elsewhere.
|
||||
let file = texture_path(Path::new("/r"), id, "textures");
|
||||
assert!(file.starts_with("/r/assets") && !file.to_string_lossy().contains(".."));
|
||||
}
|
||||
let escape = texture_path(Path::new("/r"), "minecraft:../../../x", "textures");
|
||||
assert!(!escape.to_string_lossy().contains(".."), "{escape:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_id_forms_find_their_file() {
|
||||
let root = Path::new("/r");
|
||||
assert_eq!(
|
||||
texture_path(root, "horizon_cit:minecraft/optifine/cit/a", "textures"),
|
||||
root.join("assets/minecraft/optifine/cit/a.png")
|
||||
);
|
||||
assert_eq!(
|
||||
texture_path(root, "minecraft:item/stick", "textures"),
|
||||
root.join("assets/minecraft/textures/item/stick.png")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,9 +41,35 @@ pub enum Pick {
|
||||
Quarters([TileLayer; 4]),
|
||||
}
|
||||
|
||||
/// A tile drawn over a face, after its own (`overlay*` methods).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Overlay {
|
||||
pub layer: u16,
|
||||
/// `layer=translucent`: drawn in the translucent pass, else with an alpha cutout.
|
||||
pub translucent: bool,
|
||||
/// `tintIndex` and the state whose tint it is (`tintBlock`, else the face's block).
|
||||
pub tint: Option<(i32, Option<u32>)>,
|
||||
}
|
||||
|
||||
struct LoadedRule {
|
||||
rule: Rule,
|
||||
tiles: Vec<TileLayer>,
|
||||
overlay: OverlayJoin,
|
||||
}
|
||||
|
||||
/// What an overlay rule joins: `connectBlocks` as state ids and `connectTiles` as sprite
|
||||
/// layers (sorted), and its tint.
|
||||
#[derive(Default)]
|
||||
struct OverlayJoin {
|
||||
states: Vec<u32>,
|
||||
tiles: Vec<u16>,
|
||||
tint: Option<(i32, Option<u32>)>,
|
||||
}
|
||||
|
||||
impl OverlayJoin {
|
||||
fn any(&self) -> bool {
|
||||
!self.states.is_empty() || !self.tiles.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
@@ -98,14 +124,24 @@ impl ConnectedTextures {
|
||||
mut face_layer: impl FnMut(BlockStateId, Face) -> Option<u32>,
|
||||
) -> Self {
|
||||
let mut rules = ctm::load(root);
|
||||
// Rules no face can use load no tiles: overlays (not drawn yet), and custom names (section
|
||||
// meshes see no block entity).
|
||||
rules.retain(|r| !r.method.is_overlay() && r.name.is_none());
|
||||
// Rules with a custom name load no tiles: section meshes see no block entity.
|
||||
rules.retain(|r| r.name.is_none());
|
||||
// Rule indices are `u16`.
|
||||
rules.truncate(usize::from(u16::MAX));
|
||||
if rules.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
// The tiles decode side by side; the loop below inserts them in rule order, under the
|
||||
// same names.
|
||||
let tiles: Vec<String> = rules
|
||||
.iter()
|
||||
.flat_map(|rule| &rule.tiles)
|
||||
.filter_map(|tile| match tile {
|
||||
Tile::Texture(path) => Some(sprite_name(path)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
sprites.prefetch(root, tiles.iter().map(String::as_str));
|
||||
let loaded = rules
|
||||
.into_iter()
|
||||
.filter_map(|rule| {
|
||||
@@ -130,7 +166,11 @@ impl ConnectedTextures {
|
||||
})
|
||||
.collect::<crate::Result<Vec<_>>>();
|
||||
match tiles {
|
||||
Ok(tiles) => Some(LoadedRule { rule, tiles }),
|
||||
Ok(tiles) => Some(LoadedRule {
|
||||
rule,
|
||||
tiles,
|
||||
overlay: OverlayJoin::default(),
|
||||
}),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: connected textures {}: a tile: {e}", rule.path);
|
||||
None
|
||||
@@ -144,7 +184,11 @@ impl ConnectedTextures {
|
||||
.map(|s| (s.name.as_str(), &s.properties))
|
||||
});
|
||||
let mut out = Self::build(loaded, states, |name| sprites.layers.get(name).copied());
|
||||
if out.rules.iter().any(|r| r.rule.connect == Connect::Tile) {
|
||||
if out
|
||||
.rules
|
||||
.iter()
|
||||
.any(|r| r.rule.connect == Connect::Tile || !r.overlay.tiles.is_empty())
|
||||
{
|
||||
out.face_layers = (0..registry.len() as u32)
|
||||
.map(|id| {
|
||||
Face::ALL
|
||||
@@ -166,6 +210,48 @@ impl ConnectedTextures {
|
||||
rules,
|
||||
..Self::default()
|
||||
};
|
||||
let states: Vec<_> = states.collect();
|
||||
// Each block's states, for `connectBlocks` and `tintBlock`.
|
||||
let mut block_states: HashMap<&str, Vec<u32>> = HashMap::new();
|
||||
if out.rules.iter().any(|r| r.rule.method.is_overlay()) {
|
||||
for (id, state) in states.iter().enumerate() {
|
||||
if let Some((name, _)) = state {
|
||||
block_states.entry(name).or_default().push(id as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
for r in out.rules.iter_mut().filter(|r| r.rule.method.is_overlay()) {
|
||||
let rule = &r.rule;
|
||||
// `connectBlocks` and `connectTiles` are "only for method overlay".
|
||||
if rule.method == Method::Overlay {
|
||||
for m in &rule.connect_blocks {
|
||||
let candidates = block_states
|
||||
.get(m.id.as_str())
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
r.overlay.states.extend(candidates.iter().filter(|&&id| {
|
||||
states[id as usize].is_some_and(|(name, props)| {
|
||||
m.matches(name, |k| props.get(k).map(String::as_str))
|
||||
})
|
||||
}));
|
||||
}
|
||||
r.overlay.states.sort_unstable();
|
||||
r.overlay.states.dedup();
|
||||
r.overlay.tiles = rule
|
||||
.connect_tiles
|
||||
.iter()
|
||||
.filter_map(|t| layer(&sprite_name(t)).map(|l| l as u16))
|
||||
.collect();
|
||||
r.overlay.tiles.sort_unstable();
|
||||
}
|
||||
// A `tintBlock` the game doesn't have tints nothing.
|
||||
let tint = match rule.tint_block.as_deref() {
|
||||
None => Some(None),
|
||||
Some(block) => block_states.get(block).map(|ids| Some(ids[0])),
|
||||
};
|
||||
r.overlay.tint = tint
|
||||
.filter(|_| rule.tint_index >= 0)
|
||||
.map(|b| (rule.tint_index, b));
|
||||
}
|
||||
let mut blocks = HashMap::new();
|
||||
let mut by_layer: Vec<Vec<u16>> = Vec::new();
|
||||
// Block rules by the block each entry names, so a state tests only its own.
|
||||
@@ -249,6 +335,7 @@ impl ConnectedTextures {
|
||||
pos: [i32; 3],
|
||||
neighbour: impl Fn([i32; 3]) -> u32,
|
||||
biome: impl Fn() -> Option<&'b str>,
|
||||
mut overlay: impl FnMut(Overlay),
|
||||
) -> Pick {
|
||||
let (mut by_state, mut by_layer) = (
|
||||
self.list(&self.by_state, state as usize),
|
||||
@@ -279,14 +366,16 @@ impl ConnectedTextures {
|
||||
let r = &self.rules[usize::from(i)];
|
||||
if (block || r.rule.match_blocks.is_empty())
|
||||
&& (tile || r.rule.match_tiles.is_empty())
|
||||
&& let Some(pick) = self.apply(r, state, layer, face, pos, &neighbour, &biome)
|
||||
&& let Some(pick) =
|
||||
self.apply(r, state, layer, face, pos, &neighbour, &biome, &mut overlay)
|
||||
{
|
||||
return pick;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One rule on a face: `None` when it doesn't apply or picks `<skip>`.
|
||||
/// One rule on a face: `None` when it doesn't apply, picks `<skip>` or draws an overlay
|
||||
/// (handed to `overlay`: the rules after it still choose the face's own sprite).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn apply<'b>(
|
||||
&self,
|
||||
@@ -297,6 +386,7 @@ impl ConnectedTextures {
|
||||
pos: [i32; 3],
|
||||
neighbour: &impl Fn([i32; 3]) -> u32,
|
||||
biome: &impl Fn() -> Option<&'b str>,
|
||||
overlay: &mut impl FnMut(Overlay),
|
||||
) -> Option<Pick> {
|
||||
let rule = &r.rule;
|
||||
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
|
||||
@@ -312,8 +402,17 @@ impl ConnectedTextures {
|
||||
return None;
|
||||
}
|
||||
let info = self.states.get(state as usize).copied().unwrap_or_default();
|
||||
let join = &r.overlay;
|
||||
let joins = |offset: [i32; 3]| {
|
||||
let other = neighbour(offset);
|
||||
if join.any() {
|
||||
// An overlay joins the blocks it is drawn beside.
|
||||
return join.states.binary_search(&other).is_ok()
|
||||
|| self
|
||||
.face_layers
|
||||
.get(other as usize)
|
||||
.is_some_and(|f| join.tiles.binary_search(&f[face as usize]).is_ok());
|
||||
}
|
||||
match rule.connect {
|
||||
Connect::Block => {
|
||||
self.states.get(other as usize).map(|s| s.block) == Some(info.block)
|
||||
@@ -332,7 +431,36 @@ impl ConnectedTextures {
|
||||
};
|
||||
let mask = || geometry::neighbours(axes(face), face.normal(), rule.inner_seams, joins);
|
||||
let tile = |i: usize| r.tiles.get(i).copied().unwrap_or(TileLayer::Default);
|
||||
let index = match rule.method {
|
||||
let mut draw = |t: TileLayer| {
|
||||
if let TileLayer::Layer { layer, .. } = t {
|
||||
overlay(Overlay {
|
||||
layer,
|
||||
translucent: rule.layer == ctm::Layer::Translucent,
|
||||
tint: join.tint,
|
||||
});
|
||||
}
|
||||
};
|
||||
// An overlay picks its tile as the method it is the overlay of.
|
||||
let method = match rule.method {
|
||||
Method::Overlay => {
|
||||
// The 17 transition tiles, toward the blocks it names (none: it draws nothing).
|
||||
if join.any() {
|
||||
let mask = geometry::neighbours(axes(face), face.normal(), false, joins);
|
||||
geometry::overlay_tiles(mask).for_each(|i| draw(tile(i)));
|
||||
}
|
||||
return None;
|
||||
}
|
||||
Method::OverlayCtm => Method::Ctm,
|
||||
Method::OverlayRandom => Method::Random,
|
||||
Method::OverlayRepeat => Method::Repeat,
|
||||
Method::OverlayFixed => Method::Fixed,
|
||||
Method::OverlayHorizontal => Method::Horizontal,
|
||||
Method::OverlayVertical => Method::Vertical,
|
||||
Method::OverlayHorizontalVertical => Method::HorizontalVertical,
|
||||
Method::OverlayVerticalHorizontal => Method::VerticalHorizontal,
|
||||
m => m,
|
||||
};
|
||||
let index = match method {
|
||||
Method::Ctm => geometry::ctm_index(mask()),
|
||||
Method::CtmCompact => {
|
||||
let mask = mask();
|
||||
@@ -377,9 +505,13 @@ impl ConnectedTextures {
|
||||
let face = geometry::symmetric_face(face, rule.symmetry);
|
||||
geometry::repeat_index(pos, axes(face), rule.width, rule.height)
|
||||
}
|
||||
// Overlays are not loaded yet.
|
||||
// Every overlay was mapped to its method above.
|
||||
_ => return None,
|
||||
};
|
||||
if rule.method.is_overlay() {
|
||||
draw(tile(index));
|
||||
return None;
|
||||
}
|
||||
match tile(index) {
|
||||
TileLayer::Skip => None,
|
||||
TileLayer::Default => Some(Pick::Keep),
|
||||
@@ -412,6 +544,7 @@ mod tests {
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(n, rule)| LoadedRule {
|
||||
overlay: OverlayJoin::default(),
|
||||
tiles: rule
|
||||
.tiles
|
||||
.iter()
|
||||
@@ -432,7 +565,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// State 0 air, 1 glass, 2 stone, 3 and 4 oak logs (axis y, x), 5 and 6 a door (lower,
|
||||
/// upper); the stone sprite is layer 7.
|
||||
/// upper); the stone sprite is layer 7, glass 3.
|
||||
fn world(files: &[(&str, &str)]) -> ConnectedTextures {
|
||||
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
|
||||
pairs
|
||||
@@ -452,7 +585,11 @@ mod tests {
|
||||
ConnectedTextures::build(
|
||||
rules(files),
|
||||
states.iter().map(|(n, p)| Some((*n, p))),
|
||||
|name| (name == "minecraft:block/stone").then_some(7),
|
||||
|name| match name {
|
||||
"minecraft:block/stone" => Some(7),
|
||||
"minecraft:block/glass" => Some(3),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -473,7 +610,7 @@ mod tests {
|
||||
blocks: &[([i32; 3], u32)],
|
||||
) -> Pick {
|
||||
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
|
||||
ctm.pick(state, layer, face, [0, 64, 0], at, || None)
|
||||
ctm.pick(state, layer, face, [0, 64, 0], at, || None, |_| {})
|
||||
}
|
||||
|
||||
const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46";
|
||||
@@ -596,8 +733,8 @@ mod tests {
|
||||
)]);
|
||||
let mut tiles = std::collections::HashSet::new();
|
||||
for x in 0..20 {
|
||||
let lower = ctm.pick(5, 3, Face::North, [x, 64, 0], |_| 0, || None);
|
||||
let upper = ctm.pick(6, 3, Face::North, [x, 65, 0], |_| 0, || None);
|
||||
let lower = ctm.pick(5, 3, Face::North, [x, 64, 0], |_| 0, || None, |_| {});
|
||||
let upper = ctm.pick(6, 3, Face::North, [x, 65, 0], |_| 0, || None, |_| {});
|
||||
assert_eq!(lower, upper, "{x}");
|
||||
tiles.insert(format!("{lower:?}"));
|
||||
}
|
||||
@@ -610,8 +747,9 @@ mod tests {
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=fixed\ntiles=0\nheights=0-63\nbiomes=desert",
|
||||
)]);
|
||||
let at =
|
||||
|y, biome: &'static str| ctm.pick(1, 3, Face::Up, [0, y, 0], |_| 0, || Some(biome));
|
||||
let at = |y, biome: &'static str| {
|
||||
ctm.pick(1, 3, Face::Up, [0, y, 0], |_| 0, || Some(biome), |_| {})
|
||||
};
|
||||
assert_eq!(at(10, "minecraft:desert"), tile(1000));
|
||||
assert_eq!(at(64, "minecraft:desert"), Pick::Keep);
|
||||
assert_eq!(at(10, "minecraft:plains"), Pick::Keep);
|
||||
@@ -645,7 +783,7 @@ mod tests {
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=repeat\nwidth=2\nheight=2\ntiles=0-3",
|
||||
)]);
|
||||
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None);
|
||||
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None, |_| {});
|
||||
assert_eq!(at([0, 64, 0]), tile(1000));
|
||||
// The north face's right is west; rows run down.
|
||||
assert_eq!(at([-1, 64, 0]), tile(1001));
|
||||
@@ -710,6 +848,136 @@ mod tests {
|
||||
assert_eq!(pick_in(&top, 1, 3, Face::Up, &[]), tile(2000));
|
||||
}
|
||||
|
||||
/// The overlays `pick` hands out, with what it picks for the face itself.
|
||||
fn overlays_in(
|
||||
ctm: &ConnectedTextures,
|
||||
state: u32,
|
||||
face: Face,
|
||||
blocks: &[([i32; 3], u32)],
|
||||
) -> (Vec<u16>, Pick) {
|
||||
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
|
||||
let mut out = Vec::new();
|
||||
let pick = ctm.pick(
|
||||
state,
|
||||
3,
|
||||
face,
|
||||
[0, 64, 0],
|
||||
at,
|
||||
|| None,
|
||||
|o| out.push(o.layer),
|
||||
);
|
||||
(out, pick)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlays_draw_toward_the_blocks_they_name() {
|
||||
// Stone gets glass's edge where glass is beside it; then a fixed tile for the stone.
|
||||
let ctm = world(&[
|
||||
(
|
||||
"a_overlay.properties",
|
||||
"matchBlocks=stone\nmethod=overlay\nconnectBlocks=glass\ntiles=0-16\ntintIndex=0\ntintBlock=oak_log",
|
||||
),
|
||||
("b.properties", "matchBlocks=stone\nmethod=fixed\ntiles=0"),
|
||||
]);
|
||||
// Nothing beside: no overlay, and b draws.
|
||||
assert_eq!(overlays_in(&ctm, 2, Face::Up, &[]), (vec![], tile(2000)));
|
||||
// Glass to the north of a top face (its texture's up): tile 15. Glass east and west
|
||||
// too: one tile for the three sides, 13.
|
||||
assert_eq!(overlays_in(&ctm, 2, Face::Up, &[([0, 0, -1], 1)]).0, [1015]);
|
||||
let three = [([0, 0, -1], 1), ([1, 0, 0], 1), ([-1, 0, 0], 1)];
|
||||
assert_eq!(overlays_in(&ctm, 2, Face::Up, &three).0, [1013]);
|
||||
// Glass only at the north-east corner: the corner tile, 14.
|
||||
assert_eq!(overlays_in(&ctm, 2, Face::Up, &[([1, 0, -1], 1)]).0, [1014]);
|
||||
// Stone beside stone draws nothing.
|
||||
assert_eq!(
|
||||
overlays_in(&ctm, 2, Face::Up, &[([0, 0, -1], 2)]).0,
|
||||
[0u16; 0]
|
||||
);
|
||||
let mut tint = None;
|
||||
ctm.pick(
|
||||
2,
|
||||
3,
|
||||
Face::Up,
|
||||
[0, 64, 0],
|
||||
|o| if o == [0, 0, -1] { 1 } else { 0 },
|
||||
|| None,
|
||||
|o| {
|
||||
tint = Some((o.tint, o.translucent));
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
tint,
|
||||
Some((Some((0, Some(3))), false)),
|
||||
"oak_log's first state is 3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_joins_layers_and_tints() {
|
||||
let mut ctm = world(&[
|
||||
(
|
||||
"a.properties",
|
||||
"matchBlocks=stone\nmethod=overlay\nconnectTiles=glass\ntiles=0-16\nlayer=translucent\ntintIndex=0\ntintBlock=no_such_block",
|
||||
),
|
||||
(
|
||||
"b.properties",
|
||||
"matchBlocks=glass\nmethod=overlay_ctm\nconnectBlocks=stone\ntiles=0-46",
|
||||
),
|
||||
]);
|
||||
// connectTiles joins neighbours showing a named sprite on the same side: the door to the
|
||||
// north shows glass (sprite 3) on top.
|
||||
let mut faces = vec![[9u16; 6]; 7];
|
||||
faces[5][Face::Up as usize] = 3;
|
||||
ctm.face_layers = faces;
|
||||
let mut got = Vec::new();
|
||||
let door_north = |o: [i32; 3]| if o == [0, 0, -1] { 5 } else { 0 };
|
||||
ctm.pick(
|
||||
2,
|
||||
7,
|
||||
Face::Up,
|
||||
[0, 64, 0],
|
||||
door_north,
|
||||
|| None,
|
||||
|o| got.push(o),
|
||||
);
|
||||
assert_eq!(
|
||||
got,
|
||||
[Overlay {
|
||||
layer: 1015,
|
||||
translucent: true,
|
||||
// A tintBlock the game lacks tints nothing.
|
||||
tint: None,
|
||||
}]
|
||||
);
|
||||
// connectBlocks is only for `overlay`: overlay_ctm joins glass by `connect`, not stone.
|
||||
let (layers, _) = overlays_in(&ctm, 1, Face::North, &[([-1, 0, 0], 2)]);
|
||||
assert_eq!(layers, [2000]);
|
||||
let (layers, _) = overlays_in(&ctm, 1, Face::North, &[([-1, 0, 0], 1)]);
|
||||
assert_eq!(layers, [2001]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_methods_pick_like_their_own() {
|
||||
let ctm = world(&[
|
||||
(
|
||||
"a.properties",
|
||||
"matchBlocks=glass\nmethod=overlay_ctm\ntiles=0-46\nlayer=translucent",
|
||||
),
|
||||
(
|
||||
"b.properties",
|
||||
"matchBlocks=glass\nmethod=overlay_fixed\ntiles=<skip>",
|
||||
),
|
||||
(
|
||||
"c.properties",
|
||||
"matchBlocks=glass\nmethod=overlay_fixed\ntiles=0",
|
||||
),
|
||||
]);
|
||||
// overlay_ctm joins glass (connect=block): the left end of a row, tile 1; b's <skip>
|
||||
// draws nothing; c draws; the face keeps its own sprite.
|
||||
let (layers, pick) = overlays_in(&ctm, 1, Face::North, &[([-1, 0, 0], 1)]);
|
||||
assert_eq!((layers, pick), (vec![1001, 3000], Pick::Keep));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_names() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! from the data-driven `context_int_provider`/`context_float_provider` files.
|
||||
pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer,
|
||||
ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity,
|
||||
ModBlockEntityFields, ModContainer, ModEnergyBuffer, ModFluidTanks, ModRevision, PotentSulfur,
|
||||
Shelf, Skull, SkullSkin,
|
||||
};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -575,7 +575,7 @@ pub struct DataStore {
|
||||
/// `item_modifier` files, for `/item modify`.
|
||||
pub item_modifiers: BTreeMap<String, serde_json::Value>,
|
||||
/// The content tables' raw files from the packs after the built-in one:
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `data_maps/` and `tags/…/` files, by
|
||||
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
|
||||
/// merge them over what they read from the resource root.
|
||||
pub files: BTreeMap<String, Vec<Vec<u8>>>,
|
||||
@@ -584,7 +584,7 @@ pub struct DataStore {
|
||||
}
|
||||
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
|
||||
/// `villager_trade/`, `dialog/` or `tags/<sub>/`, where the id keeps the sub
|
||||
/// `villager_trade/`, `dialog/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
|
||||
/// path so a nested tag kind stays addressable.
|
||||
fn content_id(file: &str) -> Option<String> {
|
||||
let rest = file.strip_prefix("data/")?;
|
||||
@@ -592,7 +592,7 @@ fn content_id(file: &str) -> Option<String> {
|
||||
let (kind, rest) = rest.split_once('/')?;
|
||||
let kind = match kind {
|
||||
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
|
||||
| "villager_trade" | "dialog" => kind,
|
||||
| "villager_trade" | "dialog" | "data_maps" => kind,
|
||||
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
|
||||
// test:sub/mytag`), the whole path after the namespace, so nested
|
||||
// registries keep their directory (`TagLoader`'s id shape).
|
||||
@@ -623,6 +623,7 @@ impl DataStore {
|
||||
let builtin = pack.id == "vanilla";
|
||||
for f in pack.list_kinds(&[
|
||||
"advancement",
|
||||
"data_maps",
|
||||
"dialog",
|
||||
"function",
|
||||
"item_modifier",
|
||||
@@ -882,6 +883,15 @@ pub fn worldgen_overrides(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn data_map_files_are_content_files_keyed_by_map_namespace_and_registry() {
|
||||
assert_eq!(
|
||||
content_id("data/mymod/data_maps/item/fuel.json").as_deref(),
|
||||
Some("data_maps/mymod:item/fuel")
|
||||
);
|
||||
assert_eq!(content_id("data/mymod/data_maps/item/fuel.txt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_packs_override_worldgen_in_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
|
||||
|
||||
@@ -111,6 +111,7 @@ mod end_sky;
|
||||
mod flat;
|
||||
mod frame_gen;
|
||||
mod hdr;
|
||||
mod hdr_icc;
|
||||
mod hdr_stats;
|
||||
pub use hdr::Calibration as HdrCalibration;
|
||||
pub mod block_light;
|
||||
@@ -126,6 +127,7 @@ mod occlusion;
|
||||
mod oit;
|
||||
mod oit_features;
|
||||
mod pack_atlas;
|
||||
mod pack_center_depth;
|
||||
pub mod pack_draw;
|
||||
mod pack_entities;
|
||||
mod pack_hand;
|
||||
@@ -7793,6 +7795,9 @@ impl Renderer {
|
||||
if let Some(timer) = &mut self.own_timing {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if let Some((_, Some(post))) = &mut self.pack_post {
|
||||
post.submitted();
|
||||
}
|
||||
let failed = checks.and_then(GpuChecks::pop);
|
||||
if let Some((request, buffer)) = readback {
|
||||
// Out of memory on the way: the PNG would be black; taken again at half the size.
|
||||
@@ -8250,19 +8255,19 @@ impl Renderer {
|
||||
Ok([width, height])
|
||||
}
|
||||
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
|
||||
/// third edition), with the peak and paper white as its content light levels (cLLI).
|
||||
/// third edition), with the peak and paper white as its content light levels (cLLI), and
|
||||
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
|
||||
fn write_hdr_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
|
||||
let Some(hdr) = &self.hdr else {
|
||||
return Ok(());
|
||||
};
|
||||
let pixels = hdr10_to_rgb16(&self.read_frame(buffer)?);
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(path)?),
|
||||
self.config.width,
|
||||
self.config.height,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder.set_depth(png::BitDepth::Sixteen);
|
||||
let mut info = png::Info::with_size(self.config.width, self.config.height);
|
||||
info.color_type = png::ColorType::Rgb;
|
||||
info.bit_depth = png::BitDepth::Sixteen;
|
||||
info.icc_profile = Some(hdr_icc::profile().into());
|
||||
let encoder =
|
||||
png::Encoder::with_info(std::io::BufWriter::new(std::fs::File::create(path)?), info)?;
|
||||
let mut writer = encoder.write_header()?;
|
||||
// BT.2020 primaries, PQ, RGB, full range.
|
||||
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! The ICC profile of HDR screenshots, for programs that don't read PNG's `cICP`: BT.2020
|
||||
//! primaries and the PQ curve, with HDR reference white (203 nits) as the profile's white.
|
||||
//! An ICC profile has no values above white, so those programs show a correct SDR picture
|
||||
//! with its highlights clipped, where without it they took PQ's values for sRGB's and showed
|
||||
//! them washed out. Programs that read `cICP` ignore this (PNG's third edition).
|
||||
|
||||
/// HDR reference white in nits (ITU-R BT.2408): where the curve reaches the profile's white.
|
||||
const WHITE: f64 = 203.0;
|
||||
/// PQ's values from 0 to 1, in the curve's steps.
|
||||
const STEPS: usize = 4096;
|
||||
/// BT.2020's primaries, adapted to the profile connection space's D50 (Bradford).
|
||||
const RED: [f64; 3] = [0.67348, 0.27904, -0.00193];
|
||||
const GREEN: [f64; 3] = [0.16567, 0.67534, 0.02998];
|
||||
const BLUE: [f64; 3] = [0.12505, 0.04561, 0.79685];
|
||||
const D50: [f64; 3] = [0.9642, 1.0, 0.8249];
|
||||
|
||||
/// PQ's EOTF (SMPTE ST 2084): a signal from 0 to 1 in nits.
|
||||
fn pq_nits(signal: f64) -> f64 {
|
||||
let (m1, m2) = (2610.0 / 16384.0, 2523.0 / 32.0);
|
||||
let (c1, c2, c3) = (3424.0 / 4096.0, 2413.0 / 128.0, 2392.0 / 128.0);
|
||||
let p = signal.powf(1.0 / m2);
|
||||
10_000.0 * ((p - c1).max(0.0) / (c2 - c3 * p)).powf(1.0 / m1)
|
||||
}
|
||||
|
||||
fn s15_fixed16(v: f64) -> [u8; 4] {
|
||||
((v * 65536.0).round() as i32).to_be_bytes()
|
||||
}
|
||||
|
||||
fn xyz(v: [f64; 3]) -> Vec<u8> {
|
||||
let mut tag = b"XYZ \0\0\0\0".to_vec();
|
||||
tag.extend(v.iter().flat_map(|&c| s15_fixed16(c)));
|
||||
tag
|
||||
}
|
||||
|
||||
fn curve() -> Vec<u8> {
|
||||
let mut tag = b"curv\0\0\0\0".to_vec();
|
||||
tag.extend((STEPS as u32).to_be_bytes());
|
||||
for i in 0..STEPS {
|
||||
let y = (pq_nits(i as f64 / (STEPS - 1) as f64) / WHITE).min(1.0);
|
||||
tag.extend(((y * 65535.0).round() as u16).to_be_bytes());
|
||||
}
|
||||
tag
|
||||
}
|
||||
|
||||
/// `textDescriptionType` (ICC v2), with its empty Unicode and ScriptCode parts.
|
||||
fn description(text: &str) -> Vec<u8> {
|
||||
let mut tag = b"desc\0\0\0\0".to_vec();
|
||||
tag.extend((text.len() as u32 + 1).to_be_bytes());
|
||||
tag.extend(text.as_bytes());
|
||||
tag.extend([0; 1 + 4 + 4 + 2 + 1 + 67]);
|
||||
tag
|
||||
}
|
||||
|
||||
/// An ICC v2 matrix-shaper display profile.
|
||||
pub(super) fn profile() -> Vec<u8> {
|
||||
let trc = curve();
|
||||
let copyright = b"text\0\0\0\0Public domain\0".to_vec();
|
||||
let tags: [(&[u8; 4], &[u8]); 9] = [
|
||||
(b"desc", &description("BT.2020 PQ, 203 nits = white")),
|
||||
(b"cprt", ©right),
|
||||
(b"wtpt", &xyz(D50)),
|
||||
(b"rXYZ", &xyz(RED)),
|
||||
(b"gXYZ", &xyz(GREEN)),
|
||||
(b"bXYZ", &xyz(BLUE)),
|
||||
(b"rTRC", &trc),
|
||||
(b"gTRC", &trc),
|
||||
(b"bTRC", &trc),
|
||||
];
|
||||
let data_start = 128 + 4 + 12 * tags.len();
|
||||
let mut table = (tags.len() as u32).to_be_bytes().to_vec();
|
||||
let mut data = Vec::new();
|
||||
let mut trc_at = None;
|
||||
for (name, tag) in tags {
|
||||
// The three channels share one curve.
|
||||
let is_trc = tag == &trc[..];
|
||||
let at = match trc_at.filter(|_| is_trc) {
|
||||
Some(at) => at,
|
||||
None => {
|
||||
let at = data_start + data.len();
|
||||
data.extend(tag);
|
||||
data.resize(data.len().next_multiple_of(4), 0);
|
||||
at
|
||||
}
|
||||
};
|
||||
if is_trc {
|
||||
trc_at = Some(at);
|
||||
}
|
||||
table.extend(name);
|
||||
table.extend((at as u32).to_be_bytes());
|
||||
table.extend((tag.len() as u32).to_be_bytes());
|
||||
}
|
||||
let mut out = vec![0; 128];
|
||||
let size = data_start + data.len();
|
||||
out[0..4].copy_from_slice(&(size as u32).to_be_bytes());
|
||||
out[8..12].copy_from_slice(&[2, 0x40, 0, 0]);
|
||||
out[12..16].copy_from_slice(b"mntr");
|
||||
out[16..20].copy_from_slice(b"RGB ");
|
||||
out[20..24].copy_from_slice(b"XYZ ");
|
||||
out[36..40].copy_from_slice(b"acsp");
|
||||
// Relative colorimetric, and the connection space's illuminant.
|
||||
out[64..68].copy_from_slice(&1u32.to_be_bytes());
|
||||
for (i, &c) in D50.iter().enumerate() {
|
||||
out[68 + 4 * i..72 + 4 * i].copy_from_slice(&s15_fixed16(c));
|
||||
}
|
||||
out.extend(table);
|
||||
out.extend(data);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tag<'a>(profile: &'a [u8], name: &[u8; 4]) -> &'a [u8] {
|
||||
let u32_at = |i: usize| u32::from_be_bytes(profile[i..i + 4].try_into().unwrap()) as usize;
|
||||
let at = (0..u32_at(128))
|
||||
.map(|i| 132 + 12 * i)
|
||||
.find(|&e| &profile[e..e + 4] == name)
|
||||
.unwrap();
|
||||
&profile[u32_at(at + 4)..u32_at(at + 4) + u32_at(at + 8)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_profile_is_whole() {
|
||||
let p = profile();
|
||||
assert_eq!(
|
||||
u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize,
|
||||
p.len()
|
||||
);
|
||||
assert_eq!(&p[36..40], b"acsp");
|
||||
assert_eq!(p.len() % 4, 0);
|
||||
assert_eq!(&tag(&p, b"desc")[0..4], b"desc");
|
||||
// One curve, shared: the profile stays small.
|
||||
assert_eq!(tag(&p, b"rTRC").as_ptr(), tag(&p, b"bTRC").as_ptr());
|
||||
assert!(p.len() < 10_000, "{}", p.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_primaries_add_up_to_white() {
|
||||
for i in 0..3 {
|
||||
assert!((RED[i] + GREEN[i] + BLUE[i] - D50[i]).abs() < 0.0001);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_reaches_white_at_reference_white_and_stays() {
|
||||
let p = profile();
|
||||
let trc = tag(&p, b"rTRC");
|
||||
let value = |signal: f64| {
|
||||
let i = 12 + 2 * (signal * (STEPS - 1) as f64).round() as usize;
|
||||
u16::from_be_bytes([trc[i], trc[i + 1]])
|
||||
};
|
||||
assert_eq!(value(0.0), 0);
|
||||
// 203 nits in PQ is a signal of 0.5807.
|
||||
assert!(value(0.57) < 65535);
|
||||
assert_eq!(value(0.59), 65535);
|
||||
assert_eq!(value(1.0), 65535);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! `centerDepthSmooth`'s input: `depthtex0` at the centre of the screen. A depth texture can
|
||||
//! only be copied whole, so each frame a one-pixel pass loads that texel into a 1x1 `R32Float`
|
||||
//! target, copied to one of a few small buffers and read back frames later without waiting for
|
||||
//! the GPU (as `timing.rs` reads its timestamps); `FrameUniforms` smooths the latest value.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
const SLOTS: usize = 3;
|
||||
const FREE: u8 = 0;
|
||||
/// Copied this frame: mapped once the frame is submitted.
|
||||
const COPIED: u8 = 1;
|
||||
const PENDING: u8 = 2;
|
||||
const MAPPED: u8 = 3;
|
||||
/// One row of a texture copy, the alignment wgpu asks of `bytes_per_row`.
|
||||
const ROW: u32 = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||
|
||||
const SHADER: &str = "
|
||||
@group(0) @binding(0) var depth: texture_depth_2d;
|
||||
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
@fragment fn fs() -> @location(0) vec4<f32> {
|
||||
let centre = vec2<i32>(textureDimensions(depth) / 2u);
|
||||
return vec4<f32>(textureLoad(depth, centre, 0), 0.0, 0.0, 1.0);
|
||||
}
|
||||
";
|
||||
|
||||
struct Slot {
|
||||
buffer: wgpu::Buffer,
|
||||
state: Arc<AtomicU8>,
|
||||
}
|
||||
|
||||
pub struct CenterDepth {
|
||||
slots: Vec<Slot>,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
/// The 1x1 target the centre texel is loaded into.
|
||||
texel: wgpu::Texture,
|
||||
texel_view: wgpu::TextureView,
|
||||
/// The bind group of the depth texture it reads, made again when that texture changes.
|
||||
group: Option<(wgpu::Texture, wgpu::BindGroup)>,
|
||||
/// The depth last read back: GL window depth, 0..1.
|
||||
latest: Option<f32>,
|
||||
}
|
||||
|
||||
impl CenterDepth {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
let slots = (0..SLOTS)
|
||||
.map(|_| Slot {
|
||||
buffer: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
size: u64::from(ROW),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
state: Arc::new(AtomicU8::new(FREE)),
|
||||
})
|
||||
.collect();
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
source: wgpu::ShaderSource::Wgsl(SHADER.into()),
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let format = wgpu::TextureFormat::R32Float;
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(format.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let texel = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let texel_view = texel.create_view(&Default::default());
|
||||
Self {
|
||||
slots,
|
||||
pipeline,
|
||||
layout,
|
||||
texel,
|
||||
texel_view,
|
||||
group: None,
|
||||
latest: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the centre texel of `depth` (a `Depth32Float` texture that can be bound) and
|
||||
/// copies it into a free slot; with none free (the GPU is behind), this frame is skipped.
|
||||
pub fn copy(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
depth: &wgpu::Texture,
|
||||
) {
|
||||
let Some(slot) = self
|
||||
.slots
|
||||
.iter()
|
||||
.find(|s| s.state.load(Ordering::Acquire) == FREE)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.group.as_ref().is_none_or(|(t, _)| t != depth) {
|
||||
let view = depth.create_view(&Default::default());
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
layout: &self.layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
}],
|
||||
});
|
||||
self.group = Some((depth.clone(), group));
|
||||
}
|
||||
let Some((_, group)) = &self.group else {
|
||||
return;
|
||||
};
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &self.texel_view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
self.texel.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &slot.buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(ROW),
|
||||
rows_per_image: Some(1),
|
||||
},
|
||||
},
|
||||
self.texel.size(),
|
||||
);
|
||||
slot.state.store(COPIED, Ordering::Release);
|
||||
}
|
||||
|
||||
/// After the frame's submission: start reading back what it copied.
|
||||
pub fn submitted(&mut self) {
|
||||
for slot in &self.slots {
|
||||
if slot.state.load(Ordering::Acquire) != COPIED {
|
||||
continue;
|
||||
}
|
||||
slot.state.store(PENDING, Ordering::Release);
|
||||
let state = slot.state.clone();
|
||||
slot.buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
let next = if result.is_ok() { MAPPED } else { FREE };
|
||||
state.store(next, Ordering::Release);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The newest depth read back so far, `None` before the first arrives.
|
||||
pub fn collect(&mut self, device: &wgpu::Device) -> Option<f32> {
|
||||
let _ = device.poll(wgpu::PollType::Poll);
|
||||
for slot in &self.slots {
|
||||
if slot.state.load(Ordering::Acquire) != MAPPED {
|
||||
continue;
|
||||
}
|
||||
let depth = slot
|
||||
.buffer
|
||||
.slice(..4)
|
||||
.get_mapped_range()
|
||||
.ok()
|
||||
.map(|bytes| bytemuck::pod_read_unaligned::<f32>(&bytes[..4]));
|
||||
slot.buffer.unmap();
|
||||
slot.state.store(FREE, Ordering::Release);
|
||||
if let Some(depth) = depth.filter(|d| d.is_finite()) {
|
||||
self.latest = Some(depth.clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
self.latest
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance =
|
||||
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
/// A depth texture cleared to a known depth comes back as that depth, a frame or more
|
||||
/// later, without the frame waiting for it.
|
||||
#[test]
|
||||
fn the_centre_depth_comes_back() {
|
||||
let Some((device, queue)) = test_device() else {
|
||||
return;
|
||||
};
|
||||
let depth = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 9,
|
||||
height: 7,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = depth.create_view(&Default::default());
|
||||
let mut center = CenterDepth::new(&device);
|
||||
assert_eq!(center.collect(&device), None);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.375),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
center.copy(&device, &mut encoder, &depth);
|
||||
queue.submit([encoder.finish()]);
|
||||
center.submitted();
|
||||
let _ = device.poll(wgpu::PollType::wait_indefinitely());
|
||||
assert_eq!(center.collect(&device), Some(0.375));
|
||||
// Its slot is free again: every slot can be used once more.
|
||||
for _ in 0..SLOTS {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
center.copy(&device, &mut encoder, &depth);
|
||||
queue.submit([encoder.finish()]);
|
||||
center.submitted();
|
||||
}
|
||||
let _ = device.poll(wgpu::PollType::wait_indefinitely());
|
||||
assert_eq!(center.collect(&device), Some(0.375));
|
||||
}
|
||||
}
|
||||
@@ -512,6 +512,8 @@ pub struct PackPost {
|
||||
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
pub timing: Option<super::pack_timing::PassTiming>,
|
||||
/// `depthtex0` at the screen's centre, read back for `centerDepthSmooth`.
|
||||
center_depth: Option<super::pack_center_depth::CenterDepth>,
|
||||
/// `shaders.properties`' custom uniforms, after the frame's in `uniforms.values`.
|
||||
custom: horizon_shaders::Custom,
|
||||
atlas: Option<wgpu::TextureView>,
|
||||
@@ -2242,6 +2244,7 @@ impl PackPost {
|
||||
block_ids,
|
||||
page_groups: HashMap::new(),
|
||||
timing: None,
|
||||
center_depth: None,
|
||||
custom,
|
||||
atlas: world.atlas.as_ref().map(|a| a.0.clone()),
|
||||
lightmap: world.lightmap,
|
||||
@@ -2903,6 +2906,13 @@ impl PackPost {
|
||||
}
|
||||
}
|
||||
|
||||
/// After the frame's submission: the centre depth it copied starts its way back.
|
||||
pub fn submitted(&mut self) {
|
||||
if let Some(center) = &mut self.center_depth {
|
||||
center.submitted();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pack draws entities or block entities itself.
|
||||
pub fn has_entities(&self) -> bool {
|
||||
self.entities.is_some()
|
||||
@@ -3620,6 +3630,10 @@ impl PackPost {
|
||||
if let Some(t) = &mut self.timing {
|
||||
t.begin(device, queue);
|
||||
}
|
||||
self.uniforms.center_depth_raw = self
|
||||
.center_depth
|
||||
.get_or_insert_with(|| super::pack_center_depth::CenterDepth::new(device))
|
||||
.collect(device);
|
||||
self.uniforms.update(frame, inputs, size);
|
||||
// Until the camera's biome is known, `smooth` waits for the world's values.
|
||||
if !inputs.biome_known {
|
||||
@@ -3858,6 +3872,10 @@ impl PackPost {
|
||||
self.draw_translucent(device, queue, encoder, size, terrain);
|
||||
}
|
||||
let targets = self.targets.as_ref().expect("resized");
|
||||
// depthtex0 as the frame leaves it, for next frames' `centerDepthSmooth`.
|
||||
if let Some(center) = &mut self.center_depth {
|
||||
center.copy(device, encoder, &targets.depth_texture);
|
||||
}
|
||||
let view = |m: u16, i: usize, alt: bool| {
|
||||
targets.color[i]
|
||||
.as_ref()
|
||||
|
||||
@@ -252,6 +252,7 @@ pub const NAMES: &[&str] = &[
|
||||
"is_sprinting",
|
||||
"endFlashIntensity",
|
||||
"endFlashPosition",
|
||||
"centerDepthSmooth",
|
||||
"isRightHanded",
|
||||
"is_on_ground",
|
||||
"bedrockLevel",
|
||||
@@ -274,6 +275,9 @@ pub struct FrameUniforms {
|
||||
previous: Option<(glam::DVec3, Mat4, Mat4)>,
|
||||
wetness: f32,
|
||||
eye_smooth: [f32; 2],
|
||||
/// `depthtex0` at the screen's centre as last read back, and `centerDepthSmooth`.
|
||||
pub center_depth_raw: Option<f32>,
|
||||
center_depth: Option<f32>,
|
||||
/// The pack's shadow settings (7.1).
|
||||
pub shadow: Shadow,
|
||||
}
|
||||
@@ -289,6 +293,8 @@ impl Default for FrameUniforms {
|
||||
previous: None,
|
||||
wetness: 0.0,
|
||||
eye_smooth: [0.0; 2],
|
||||
center_depth_raw: None,
|
||||
center_depth: None,
|
||||
shadow: Shadow::default(),
|
||||
}
|
||||
}
|
||||
@@ -433,6 +439,15 @@ impl FrameUniforms {
|
||||
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
|
||||
*smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt);
|
||||
}
|
||||
// `centerDepthHalflife`'s default, 1 tick (OptiFine's shaders.txt); the first value
|
||||
// read back is taken as it is.
|
||||
if let Some(raw) = self.center_depth_raw {
|
||||
self.center_depth = Some(match self.center_depth {
|
||||
Some(v) => smooth(v, raw, 1.0 / 20.0, dt),
|
||||
None => raw,
|
||||
});
|
||||
}
|
||||
let center_depth = self.center_depth.unwrap_or(1.0);
|
||||
// Vanilla fades by the stronger of two linear fogs; a pack gets one: the pair (start
|
||||
// and end together) of the one that is complete nearer (OQ-36). Mixing the nearer start
|
||||
// of one with the nearer end of the other made fog begin at the camera on clear days.
|
||||
@@ -479,6 +494,7 @@ impl FrameUniforms {
|
||||
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
|
||||
"sunPosition" => v3(sun),
|
||||
"endFlashPosition" => v3(to_view(inputs.end_flash_direction)),
|
||||
"centerDepthSmooth" => Value::f(&[center_depth]),
|
||||
"moonPosition" => v3(moon),
|
||||
"shadowLightPosition" => v3(if day { sun } else { moon }),
|
||||
"sunAngle" => Value::f(&[sun_angle]),
|
||||
|
||||
@@ -137,6 +137,22 @@ pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
|
||||
}
|
||||
Ok((info.width, info.height, rgba))
|
||||
}
|
||||
/// The most texels of OptiFine random entity variants the entity atlas packs: 2048 rows of
|
||||
/// its 4096, which keeps the default atlas (4096x5120) under the 8192 rows many GPUs take.
|
||||
const RANDOM_TEXELS: u64 = 4096 * 2048;
|
||||
/// The `files` (in order) whose sizes add up to at most `budget` texels.
|
||||
fn within_budget(files: &[&PathBuf], budget: u64) -> HashSet<PathBuf> {
|
||||
let mut texels = 0u64;
|
||||
files
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
let (w, h) = png_size(p).unwrap_or((0, 0));
|
||||
texels += u64::from(w) * u64::from(h);
|
||||
texels <= budget
|
||||
})
|
||||
.map(|p| (*p).clone())
|
||||
.collect()
|
||||
}
|
||||
/// A PNG's width and height, from its header alone.
|
||||
fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
use std::io::Read;
|
||||
@@ -215,6 +231,119 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
|
||||
}
|
||||
(name.clone(), bw, bh, out)
|
||||
}
|
||||
/// The textures the enabled packs' Custom Item Textures item rules draw, under their
|
||||
/// `cit_assets` ids, beyond those already packed; with their flags and whether they animate.
|
||||
/// They are item textures, so an animated strip keeps its first frame. None without
|
||||
/// `optifine/cit` rules.
|
||||
fn cit_sprites(
|
||||
root: &Path,
|
||||
packed: &[PackedImage],
|
||||
) -> Vec<(PackedImage, Option<TextureFlags>, bool)> {
|
||||
let have: HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
|
||||
let ids: Vec<String> = crate::cit_assets::item_sprites(&crate::cit::load_rules(root))
|
||||
.into_iter()
|
||||
// Vanilla's sprites are packed without their namespace, as `sprite_name` looks them up;
|
||||
// Horizon's own (`horizon/white`, the run-time slots) are added after, and stay its own.
|
||||
.filter(|id| {
|
||||
let name = id.strip_prefix("minecraft:").unwrap_or(id);
|
||||
!have.contains(name) && !name.starts_with("horizon/")
|
||||
})
|
||||
.collect();
|
||||
// No more than the atlas could ever hold, by their headers, so a pack naming thousands of
|
||||
// big textures is cut short before they are decoded, not after. Each is charged what it
|
||||
// can be packed as (a strip's frame, or a still image whole), 1024x512 at most since
|
||||
// `cit_sprite` refuses more, with a gutter;
|
||||
// the glyph rows and the shelves' waste (a tenth) are kept aside.
|
||||
let side = u64::from(MAX_ATLAS_SIZE);
|
||||
let used: u64 = packed
|
||||
.iter()
|
||||
.map(|i| u64::from(i.1 + 1) * u64::from(i.2 + 1))
|
||||
.sum();
|
||||
let mut left = (side * side * 9 / 10).saturating_sub(side * u64::from(GLYPH_ROWS) + used);
|
||||
let before = ids.len();
|
||||
let ids: Vec<String> = ids
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
let path = crate::atlas_sources::texture_path(root, id, "textures");
|
||||
let Some((w, h)) = png_size(&path) else {
|
||||
return true;
|
||||
};
|
||||
let (w, h) = (u64::from(w), u64::from(h));
|
||||
if w * h > 1024 * 512 * 64 {
|
||||
// Refused by `cit_sprite` without decoding: costs nothing.
|
||||
return true;
|
||||
}
|
||||
// A strip's frame is at most its width squared; a still image is all of it.
|
||||
let frame = (w * h).min(w * w).min(1024 * 512) + w + h + 1;
|
||||
match left.checked_sub(frame) {
|
||||
Some(rest) => {
|
||||
left = rest;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if ids.len() < before {
|
||||
log::warn!(
|
||||
"left out {} custom item textures: more than the atlas holds",
|
||||
before - ids.len()
|
||||
);
|
||||
}
|
||||
// Decoded on the threads `pack` decodes the rest on: a big pack names thousands.
|
||||
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||||
let chunk = ids.len().div_ceil(threads).max(1);
|
||||
std::thread::scope(|scope| {
|
||||
let workers: Vec<_> = ids
|
||||
.chunks(chunk)
|
||||
.map(|ids| scope.spawn(|| ids.iter().filter_map(|id| cit_sprite(root, id)).collect()))
|
||||
.collect();
|
||||
workers
|
||||
.into_iter()
|
||||
.flat_map(|w| {
|
||||
w.join().unwrap_or_else(|_| {
|
||||
log::warn!("a custom item texture decoder panicked; its textures are left out");
|
||||
Vec::new()
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
/// One of `cit_sprites`, or `None` (with a warning) when it does not load or is too big.
|
||||
fn cit_sprite(root: &Path, id: &str) -> Option<(PackedImage, Option<TextureFlags>, bool)> {
|
||||
let name = id.strip_prefix("minecraft:").unwrap_or(id);
|
||||
let path = crate::atlas_sources::texture_path(root, id, "textures");
|
||||
// The limit `pack` keeps every other texture to, for a frame; and from the header first,
|
||||
// so an image bigger than 64 such frames is never decoded.
|
||||
let fits = |w: u32, h: u32| w <= 1024 && h <= 512;
|
||||
if let Some((w, h)) = png_size(&path)
|
||||
&& u64::from(w) * u64::from(h) > 1024 * 512 * 64
|
||||
{
|
||||
log::warn!("skipped custom item texture {}: {w}x{h}", path.display());
|
||||
return None;
|
||||
}
|
||||
let image = match decode(&path) {
|
||||
Ok(image) => image,
|
||||
Err(e) => {
|
||||
log::warn!("skipped custom item texture {}: {e}", path.display());
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
// Only an animation is cut to its first frame: a still CIT texture is drawn whole, and
|
||||
// armour layers are twice as wide as they are tall.
|
||||
let (w, h, pixels) = match &animation {
|
||||
Some(animation) => first_frame(image, Some(animation)),
|
||||
None => image,
|
||||
};
|
||||
if !fits(w, h) {
|
||||
log::warn!("skipped custom item texture {}: {w}x{h}", path.display());
|
||||
return None;
|
||||
}
|
||||
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
|
||||
Some(((name.to_owned(), w, h, pixels), flags, animation.is_some()))
|
||||
}
|
||||
/// Folders of `assets/minecraft/textures/` that [`HudAtlas::pack`] walks for
|
||||
/// the full atlas. A pack may add others through `atlases/blocks.json`.
|
||||
const WALKED_TEXTURE_FOLDERS: &[&str] = &[
|
||||
@@ -492,6 +621,25 @@ impl HudAtlas {
|
||||
random_textures: Default::default(),
|
||||
}
|
||||
}
|
||||
/// `layout`, but when `extras` (Custom Item Textures) make the images more than `max`
|
||||
/// holds, without them: they are left out, not the packs.
|
||||
fn layout_leaving_out(
|
||||
images: &mut Vec<PackedImage>,
|
||||
animated: &mut Vec<String>,
|
||||
extras: &HashSet<String>,
|
||||
max: u32,
|
||||
) -> Result<(u32, Vec<(u32, u32)>, u32)> {
|
||||
match Self::layout(images, max) {
|
||||
Err(e) if !extras.is_empty() => {
|
||||
images.retain(|i| !extras.contains(&i.0));
|
||||
animated.retain(|n| !extras.contains(n));
|
||||
let layout = Self::layout(images, max)?;
|
||||
log::warn!("left out the custom item textures: {e}");
|
||||
Ok(layout)
|
||||
}
|
||||
layout => layout,
|
||||
}
|
||||
}
|
||||
/// Shelf placements of `images` (tallest first) in the narrowest atlas
|
||||
/// width from 4096 up that keeps the atlas, glyph rows included, within
|
||||
/// `max` texels a side; and the first row free for glyphs. High-resolution
|
||||
@@ -590,10 +738,23 @@ impl HudAtlas {
|
||||
named.insert(root.join(file), name.to_owned());
|
||||
}
|
||||
if !named.is_empty() {
|
||||
// A pack's variants can't grow the shared atlas past what a GPU takes: every variant
|
||||
// (those beside their texture too) counts, in name order, and those past the budget
|
||||
// stay out (their mobs wear the texture itself), not the whole pack.
|
||||
let mut variants: Vec<&PathBuf> = named.keys().collect();
|
||||
variants.sort();
|
||||
let kept = within_budget(&variants, RANDOM_TEXELS);
|
||||
if kept.len() < variants.len() {
|
||||
log::warn!(
|
||||
"{} random entity textures left out of the atlas: past its budget",
|
||||
variants.len() - kept.len()
|
||||
);
|
||||
}
|
||||
files.retain(|p| !named.contains_key(p) || kept.contains(p));
|
||||
// Those beside their texture are walked already.
|
||||
let walked: HashSet<&PathBuf> = files.iter().collect();
|
||||
let mut extra: Vec<PathBuf> = named
|
||||
.keys()
|
||||
let mut extra: Vec<PathBuf> = kept
|
||||
.iter()
|
||||
.filter(|p| !walked.contains(p))
|
||||
.cloned()
|
||||
.collect();
|
||||
@@ -682,10 +843,21 @@ impl HudAtlas {
|
||||
}
|
||||
images.push(image);
|
||||
}
|
||||
let mut cit_names = HashSet::new();
|
||||
if full {
|
||||
let (more, more_animated) = atlas_sprites(root, &images);
|
||||
images.extend(more);
|
||||
animated.extend(more_animated);
|
||||
for (image, image_flags, is_animated) in cit_sprites(root, &images) {
|
||||
cit_names.insert(image.0.clone());
|
||||
if let Some(f) = image_flags {
|
||||
flags.insert(image.0.clone(), f);
|
||||
}
|
||||
if is_animated {
|
||||
animated.push(image.0.clone());
|
||||
}
|
||||
images.push(image);
|
||||
}
|
||||
}
|
||||
// The title screen's "JAVA EDITION" banner reads "RUST EDITION".
|
||||
if let Some(edition) = images
|
||||
@@ -720,7 +892,8 @@ impl HudAtlas {
|
||||
}
|
||||
}
|
||||
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
|
||||
let (width, placements, glyph_region) = Self::layout(&images, MAX_ATLAS_SIZE)?;
|
||||
let (width, placements, glyph_region) =
|
||||
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)?;
|
||||
// A multiple of 1024 rather than a power of two: the default resources
|
||||
// need 4096x5120 (8192 rows before), Faithful 32x 4096x12288 (16384).
|
||||
let height = (glyph_region + GLYPH_ROWS).next_multiple_of(1024);
|
||||
@@ -2320,6 +2493,49 @@ mod tests {
|
||||
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
/// OptiFine random entity variants outside `textures/` pack under the names
|
||||
/// `RandomTextures` gives them; one beside its texture packs once, under its own.
|
||||
#[test]
|
||||
fn random_entity_variants_pack_under_their_scan_names() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-random-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let random = root.join("assets/minecraft/optifine/random/entity/pig");
|
||||
fs::create_dir_all(&random).unwrap();
|
||||
write_strip(&random.join("pig2.png"), 64, 64, &[[0, 200, 0, 255]], None);
|
||||
let beside = root.join("assets/minecraft/textures/entity/cat");
|
||||
fs::create_dir_all(&beside).unwrap();
|
||||
fs::write(beside.join("cat.properties"), "textures.1=2").unwrap();
|
||||
write_strip(&beside.join("cat.png"), 64, 32, &[[1, 1, 1, 255]], None);
|
||||
write_strip(&beside.join("cat2.png"), 64, 32, &[[0, 0, 200, 255]], None);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let rect = atlas.sprites["minecraft:optifine/random/entity/pig/pig2"];
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 200, 0, 255]);
|
||||
let rect = atlas.sprites["entity/cat/cat2"];
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 0, 200, 255]);
|
||||
assert!(atlas.random_textures.get("entity/pig/pig").is_some());
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
/// Variants past the budget, in name order, stay out of the atlas.
|
||||
#[test]
|
||||
fn random_variants_stop_at_their_budget() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-atlas-budget-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let files: Vec<PathBuf> = ["a2.png", "a3.png", "a4.png"]
|
||||
.iter()
|
||||
.map(|n| dir.join(n))
|
||||
.collect();
|
||||
for f in &files {
|
||||
write_strip(f, 16, 16, &[[1, 2, 3, 255]], None);
|
||||
}
|
||||
let refs: Vec<&PathBuf> = files.iter().collect();
|
||||
// 256 texels each: 600 holds two.
|
||||
let kept = within_budget(&refs, 600);
|
||||
assert_eq!(kept, files[..2].iter().cloned().collect());
|
||||
assert_eq!(within_budget(&refs, 768).len(), 3);
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn content_mod_effect_icons_pack_under_their_namespace() {
|
||||
let root =
|
||||
@@ -2464,6 +2680,209 @@ mod tests {
|
||||
.expect("the namespaced sprite animates");
|
||||
assert_eq!(animation.frame_count(), 2);
|
||||
}
|
||||
/// A resource root with one plain item texture and these CIT files (relative path, contents).
|
||||
fn cit_root(name: &str, files: &[(&str, &str)]) -> PathBuf {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-cit-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let item = root.join("assets/minecraft/textures/item");
|
||||
fs::create_dir_all(&item).unwrap();
|
||||
write_strip(&item.join("stick.png"), 16, 16, &[[90, 60, 30, 255]], None);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
fs::create_dir_all(&cit).unwrap();
|
||||
for (rel, text) in files {
|
||||
fs::write(cit.join(rel), text).unwrap();
|
||||
}
|
||||
root
|
||||
}
|
||||
#[test]
|
||||
fn a_cit_item_rules_texture_is_packed_under_its_id() {
|
||||
let root = cit_root("plain", &[("a.properties", "items=stick\ntexture=a")]);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
write_strip(&cit.join("a.png"), 2, 2, &[[10, 200, 30, 255]], None);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/a"];
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (2, 2));
|
||||
assert_eq!(texel_at(&atlas, rect, 1, 1), [10, 200, 30, 255]);
|
||||
}
|
||||
#[test]
|
||||
fn a_still_cit_texture_is_packed_whole() {
|
||||
// Not cut to a square as an unmarked strip would be: armour layers are 2:1.
|
||||
let root = cit_root("still", &[("a.properties", "items=stick\ntexture=a")]);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
write_strip(&cit.join("a.png"), 4, 2, &[[7, 8, 9, 255]], None);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/a"];
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (4, 2));
|
||||
}
|
||||
#[test]
|
||||
fn an_animated_cit_texture_packs_one_frame_and_animates() {
|
||||
let root = cit_root("anim", &[("a.properties", "items=stick\ntexture=a")]);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
write_strip(
|
||||
&cit.join("a.png"),
|
||||
16,
|
||||
48,
|
||||
&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255]],
|
||||
Some(r#"{"animation": {"frametime": 2}}"#),
|
||||
);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/a"];
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [255, 0, 0, 255]);
|
||||
let animations: Vec<_> = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
.filter(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0)
|
||||
.collect();
|
||||
assert_eq!(animations.len(), 1);
|
||||
assert_eq!(animations[0].1.frame_count(), 3);
|
||||
}
|
||||
#[test]
|
||||
fn a_texture_only_an_armor_rule_names_is_not_packed() {
|
||||
let root = cit_root(
|
||||
"armor",
|
||||
&[(
|
||||
"a.properties",
|
||||
"type=armor\nitems=iron_chestplate\ntexture.iron_layer_1=a",
|
||||
)],
|
||||
);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
write_strip(&cit.join("a.png"), 64, 32, &[[1, 2, 3, 255]], None);
|
||||
// The rule loads; only its kind keeps the texture out.
|
||||
assert_eq!(crate::cit::load_rules(&root).len(), 1);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
assert!(!atlas.sprites.keys().any(|k| k.starts_with("horizon_cit:")));
|
||||
}
|
||||
#[test]
|
||||
fn named_cit_textures_pack_once_and_bad_ones_are_skipped() {
|
||||
let root = cit_root(
|
||||
"mixed",
|
||||
&[
|
||||
// Vanilla's own texture: already packed, under its usual name.
|
||||
(
|
||||
"v.properties",
|
||||
"items=stick
|
||||
texture=item/stick",
|
||||
),
|
||||
(
|
||||
"sub.properties",
|
||||
"items=bow
|
||||
texture.bow_pulling_0=b",
|
||||
),
|
||||
(
|
||||
"broken.properties",
|
||||
"items=apple
|
||||
texture=broken",
|
||||
),
|
||||
(
|
||||
"wide.properties",
|
||||
"items=egg
|
||||
texture=wide",
|
||||
),
|
||||
],
|
||||
);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
write_strip(&cit.join("b.png"), 2, 2, &[[5, 6, 7, 255]], None);
|
||||
fs::write(cit.join("broken.png"), b"not a png").unwrap();
|
||||
// A still image past the 1024x512 limit.
|
||||
write_strip(&cit.join("wide.png"), 1040, 1040, &[[1, 1, 1, 255]], None);
|
||||
// Vanilla's stick is already packed when the CIT pass runs.
|
||||
let stick = ("item/stick".to_owned(), 16, 16, vec![0; 16 * 16 * 4]);
|
||||
let added = cit_sprites(&root, &[stick]);
|
||||
let names: Vec<_> = added.iter().map(|(image, _, _)| image.0.as_str()).collect();
|
||||
assert_eq!(names, ["horizon_cit:minecraft/optifine/cit/b"]);
|
||||
// And the atlas still builds around the broken and the too-wide texture.
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
assert!(
|
||||
atlas
|
||||
.sprites
|
||||
.contains_key("horizon_cit:minecraft/optifine/cit/b")
|
||||
);
|
||||
assert!(atlas.sprites.contains_key("item/stick"));
|
||||
}
|
||||
#[test]
|
||||
fn one_oversized_texture_leaves_the_others_in() {
|
||||
// `a` sorts first and claims more than any atlas; `b` is small and must still pack.
|
||||
let root = cit_root(
|
||||
"oversized",
|
||||
&[
|
||||
("a.properties", "items=stick\ntexture=a"),
|
||||
("b.properties", "items=apple\ntexture=b"),
|
||||
],
|
||||
);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
// Only the header is read for `a`: a PNG signature and an IHDR of 20000x20000.
|
||||
let mut header = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
|
||||
header.extend(20000u32.to_be_bytes());
|
||||
header.extend(20000u32.to_be_bytes());
|
||||
fs::write(cit.join("a.png"), header).unwrap();
|
||||
write_strip(&cit.join("b.png"), 2, 2, &[[4, 5, 6, 255]], None);
|
||||
let added = cit_sprites(&root, &[]);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let names: Vec<_> = added.iter().map(|(i, _, _)| i.0.as_str()).collect();
|
||||
assert_eq!(names, ["horizon_cit:minecraft/optifine/cit/b"]);
|
||||
}
|
||||
#[test]
|
||||
fn a_wide_grid_animation_packs_its_frame() {
|
||||
// 1104 wide is past the limit for a still texture, not for a row of 16x16 frames.
|
||||
let root = cit_root("grid", &[("a.properties", "items=stick\ntexture=a")]);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
write_strip(&cit.join("a.png"), 1104, 16, &[[9, 9, 9, 255]], None);
|
||||
let mcmeta = r#"{"animation":{"width":16,"height":16}}"#;
|
||||
fs::write(cit.join("a.png.mcmeta"), mcmeta).unwrap();
|
||||
let added = cit_sprites(&root, &[]);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let sizes: Vec<_> = added.iter().map(|(i, _, _)| (i.1, i.2)).collect();
|
||||
assert_eq!(sizes, [(16, 16)]);
|
||||
}
|
||||
#[test]
|
||||
fn too_many_custom_item_textures_leave_them_out() {
|
||||
// A 1024 atlas holds the stick and the glyph rows, not an image 1100 wide.
|
||||
let image = |name: &str, side: u32| {
|
||||
let pixels = vec![0; (side * side * 4) as usize];
|
||||
(name.to_owned(), side, side, pixels)
|
||||
};
|
||||
let mut images = vec![image("item/stick", 16), image("horizon_cit:big", 1100)];
|
||||
let mut animated = vec!["horizon_cit:big".to_owned()];
|
||||
let extras = HashSet::from(["horizon_cit:big".to_owned()]);
|
||||
assert!(HudAtlas::layout(&images, 1024).is_err());
|
||||
HudAtlas::layout_leaving_out(&mut images, &mut animated, &extras, 1024).unwrap();
|
||||
let names: Vec<_> = images.iter().map(|i| i.0.as_str()).collect();
|
||||
assert_eq!(names, ["item/stick"]);
|
||||
assert!(animated.is_empty());
|
||||
// Without extras to leave out, it fails as before.
|
||||
let mut alone = vec![image("item/big", 1100)];
|
||||
let none = HashSet::new();
|
||||
assert!(HudAtlas::layout_leaving_out(&mut alone, &mut animated, &none, 1024).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn a_root_without_cit_rules_packs_the_same_sprites() {
|
||||
let root = cit_root("none", &[("a.properties", "items=stick\ntexture=a")]);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
write_strip(&cit.join("a.png"), 2, 2, &[[10, 200, 30, 255]], None);
|
||||
let with: HashSet<String> = HudAtlas::build(&root)
|
||||
.unwrap()
|
||||
.sprites
|
||||
.into_keys()
|
||||
.collect();
|
||||
fs::remove_dir_all(root.join("assets/minecraft/optifine")).unwrap();
|
||||
let without: HashSet<String> = HudAtlas::build(&root)
|
||||
.unwrap()
|
||||
.sprites
|
||||
.into_keys()
|
||||
.collect();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
assert!(without.is_subset(&with));
|
||||
let added: Vec<_> = with.difference(&without).collect();
|
||||
assert_eq!(added, ["horizon_cit:minecraft/optifine/cit/a"]);
|
||||
assert!(without.contains("item/stick"));
|
||||
}
|
||||
/// `count` images of `w x h`; layout reads only their sizes.
|
||||
fn sized(count: usize, w: u32, h: u32) -> Vec<PackedImage> {
|
||||
(0..count)
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod chat;
|
||||
pub mod chunk;
|
||||
pub mod chunk_map;
|
||||
pub mod cit;
|
||||
pub mod cit_assets;
|
||||
pub mod clouds;
|
||||
pub mod collisions;
|
||||
pub mod command_block;
|
||||
|
||||
@@ -1333,8 +1333,11 @@ impl Mesher<'_> {
|
||||
([lighting::gray(directional); 4], [coords; 4])
|
||||
};
|
||||
let tint = Self::tint(state, q.tint_index, tints, pos);
|
||||
// OptiFine connected textures: another sprite, or one per quarter.
|
||||
// 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;
|
||||
let mut overlays = [None; 8];
|
||||
let mut count = 0;
|
||||
let pick = if connected.is_empty() {
|
||||
Pick::Keep
|
||||
} else {
|
||||
@@ -1346,27 +1349,35 @@ impl Mesher<'_> {
|
||||
[world.x, world.y, world.z],
|
||||
|[dx, dy, dz]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)],
|
||||
|| Some(tints.biome(pos[0], pos[1], pos[2])),
|
||||
|overlay| {
|
||||
if let Some(slot) = overlays.get_mut(count) {
|
||||
*slot = Some(overlay);
|
||||
count += 1;
|
||||
}
|
||||
},
|
||||
)
|
||||
};
|
||||
let mut corners = [(TerrainVertex::new([0.0; 3], [0.0; 2], 0, 0, 0), [0.0; 2]); 4];
|
||||
for (i, corner) in corners.iter_mut().enumerate() {
|
||||
let g = gray[i] as u32;
|
||||
let rgb = match tint {
|
||||
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
|
||||
None => [g; 3],
|
||||
};
|
||||
let p = q.positions[i];
|
||||
*corner = (
|
||||
TerrainVertex::new(
|
||||
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
|
||||
let vertices = |tint: Option<u32>| {
|
||||
std::array::from_fn::<_, 4, _>(|i| {
|
||||
let g = gray[i] as u32;
|
||||
let rgb = match tint {
|
||||
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
|
||||
None => [g; 3],
|
||||
};
|
||||
let p = q.positions[i];
|
||||
(
|
||||
TerrainVertex::new(
|
||||
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
|
||||
q.uv[i],
|
||||
0,
|
||||
emitting(pack_rgb(rgb), tier),
|
||||
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
|
||||
),
|
||||
q.uv[i],
|
||||
0,
|
||||
emitting(pack_rgb(rgb), tier),
|
||||
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
|
||||
),
|
||||
q.uv[i],
|
||||
);
|
||||
}
|
||||
)
|
||||
})
|
||||
};
|
||||
let corners = vertices(tint);
|
||||
// Opaque leaves (forceOpaque) and leaves a leaf-culling mode proved internal draw in the
|
||||
// solid layer whatever their sprite.
|
||||
let solid_leaves = !self.cutout_leaves && state.leaves || interior_leaf;
|
||||
@@ -1403,15 +1414,69 @@ impl Mesher<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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
|
||||
// face it must be in the translucent pass too, or the face would draw over it.
|
||||
// Most faces have none: nothing below runs for them.
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
let face_translucent = match pick {
|
||||
Pick::Keep => self.translucent(own),
|
||||
Pick::Tile(t) => self.translucent(tile(t)),
|
||||
Pick::Quarters(tiles) => tiles.into_iter().any(|t| self.translucent(tile(t))),
|
||||
};
|
||||
for overlay in overlays[..count].iter().flatten().copied() {
|
||||
let tint = overlay.tint.and_then(|(index, block)| {
|
||||
let info = block
|
||||
.and_then(|b| self.tables.info.get(b as usize))
|
||||
.unwrap_or(state);
|
||||
Self::tint(info, index, tints, pos)
|
||||
});
|
||||
let tile = TileLayer::Layer {
|
||||
layer: overlay.layer,
|
||||
translucent: overlay.translucent || face_translucent,
|
||||
opaque: false,
|
||||
};
|
||||
self.quad_as(out, false, tile, vertices(tint).map(|c| c.0), false);
|
||||
}
|
||||
}
|
||||
/// One terrain quad with sprite `tile`, in the pass its alpha needs (`solid_leaves`: no
|
||||
/// alpha cutout whatever the sprite).
|
||||
fn quad(
|
||||
&self,
|
||||
out: &mut SectionMesh,
|
||||
solid_leaves: bool,
|
||||
tile: TileLayer,
|
||||
v: [TerrainVertex; 4],
|
||||
) {
|
||||
self.quad_as(out, solid_leaves, tile, v, true);
|
||||
}
|
||||
/// Whether [`Mesher::quad`] draws `tile` in the translucent pass.
|
||||
fn translucent(&self, tile: TileLayer) -> bool {
|
||||
match tile {
|
||||
TileLayer::Layer {
|
||||
layer, translucent, ..
|
||||
} => {
|
||||
translucent
|
||||
|| self
|
||||
.translucent_layers
|
||||
.get(usize::from(layer))
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
}
|
||||
TileLayer::Skip | TileLayer::Default => false,
|
||||
}
|
||||
}
|
||||
/// [`Mesher::quad`], with the sprite's own translucency (`translucent_layers`) counted only
|
||||
/// when `by_sprite`: an overlay's pass is its rule's.
|
||||
fn quad_as(
|
||||
&self,
|
||||
out: &mut SectionMesh,
|
||||
solid_leaves: bool,
|
||||
tile: TileLayer,
|
||||
mut v: [TerrainVertex; 4],
|
||||
by_sprite: bool,
|
||||
) {
|
||||
let TileLayer::Layer {
|
||||
layer,
|
||||
@@ -1424,12 +1489,11 @@ impl Mesher<'_> {
|
||||
if self.tables.is_animated(layer) {
|
||||
out.note_sprite_layer(layer);
|
||||
}
|
||||
let translucent = translucent
|
||||
|| self
|
||||
.translucent_layers
|
||||
.get(usize::from(layer))
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
let translucent = if by_sprite {
|
||||
self.translucent(tile)
|
||||
} else {
|
||||
translucent
|
||||
};
|
||||
// Quads over opaque texels draw in the solid layer: no alpha cutout (a dirt path's side
|
||||
// skips its clear top row). All four vertices share the layer.
|
||||
let layer = if opaque && !translucent || solid_leaves {
|
||||
|
||||
@@ -10,6 +10,29 @@ use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
/// The standalone models native mods asked for (`native_standalone_models`).
|
||||
static STANDALONE: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
|
||||
|
||||
/// Sets the models no blockstate names that every resource load bakes
|
||||
/// beside the block models ([`StateModels::standalone`]): once, at start,
|
||||
/// as vanilla's `ModelEvent.RegisterStandalone` is posted once per model
|
||||
/// discovery. Names are sorted and deduplicated; one without a namespace
|
||||
/// (`mymod:block/shaft_half`) is dropped. False when already set.
|
||||
pub fn register_standalone(mut names: Vec<String>) -> bool {
|
||||
names.retain(|n| {
|
||||
n.split_once(':')
|
||||
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty())
|
||||
});
|
||||
names.sort();
|
||||
names.dedup();
|
||||
STANDALONE.set(names).is_ok()
|
||||
}
|
||||
|
||||
/// What [`register_standalone`] set; none before.
|
||||
pub fn standalone() -> &'static [String] {
|
||||
STANDALONE.get().map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
type Placement = (&'static str, &'static [(&'static str, &'static str)]);
|
||||
const GALLERY: [Placement; 16] = [
|
||||
(
|
||||
@@ -76,8 +99,9 @@ pub(crate) fn prepare_with_models(
|
||||
.filter(|name| registry.default_state(name).is_some())
|
||||
.map(|name| name.strip_prefix("minecraft:").unwrap_or(name))
|
||||
.collect();
|
||||
let catalog =
|
||||
let mut catalog =
|
||||
StateModels::from_facts_with_models(root, registry, &blocks, sprites, &facts, models)?;
|
||||
catalog.bake_standalone(root, sprites, models, standalone())?;
|
||||
let mut placements = Vec::new();
|
||||
let mut states = Vec::new();
|
||||
for (index, (name, properties)) in GALLERY.iter().enumerate() {
|
||||
@@ -149,3 +173,23 @@ pub fn load_visibility(
|
||||
.collect::<std::result::Result<_, &str>>()?;
|
||||
Ok(FaceVisibility::new(&occlusion, &definitions)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn standalone_names_are_namespaced_sorted_and_set_once() {
|
||||
assert!(super::standalone().is_empty());
|
||||
let names = [
|
||||
"lanef:block/b",
|
||||
"nonamespace",
|
||||
":x",
|
||||
"lanef:",
|
||||
"lanef:block/a",
|
||||
"lanef:block/b",
|
||||
];
|
||||
assert!(super::register_standalone(names.map(str::to_owned).into()));
|
||||
assert_eq!(super::standalone(), ["lanef:block/a", "lanef:block/b"]);
|
||||
assert!(!super::register_standalone(vec!["other:block/c".into()]));
|
||||
assert_eq!(super::standalone(), ["lanef:block/a", "lanef:block/b"]);
|
||||
}
|
||||
}
|
||||
@@ -52,10 +52,55 @@ pub struct StateModels {
|
||||
states: Vec<Option<(usize, SelectedModels)>>,
|
||||
/// Every transformed model once, with the variant it was baked from.
|
||||
baked: Vec<(Variant, BakedModel)>,
|
||||
/// Standalone models by id: their index in `baked`.
|
||||
standalone: BTreeMap<String, u32>,
|
||||
}
|
||||
fn key(variant: &Variant) -> VariantKey {
|
||||
(variant.model.clone(), variant.rotations, variant.uv_lock)
|
||||
}
|
||||
/// Bakes model `name` of `library`, turned and UV-locked as a variant says.
|
||||
fn bake_variant(
|
||||
root: &Path,
|
||||
sprites: &mut Sprites,
|
||||
library: &horizon_assets::resolve::ModelLibrary,
|
||||
name: &str,
|
||||
turns: [u8; 3],
|
||||
lock: bool,
|
||||
) -> Result<BakedModel> {
|
||||
bake_resolved(root, sprites, &library.resolve(name)?, turns, lock)
|
||||
}
|
||||
/// Bakes a resolved `model`, turned and UV-locked as a variant says.
|
||||
fn bake_resolved(
|
||||
root: &Path,
|
||||
sprites: &mut Sprites,
|
||||
model: &horizon_assets::resolve::ResolvedCuboidModel,
|
||||
turns: [u8; 3],
|
||||
lock: bool,
|
||||
) -> Result<BakedModel> {
|
||||
let particle = model
|
||||
.material("particle")?
|
||||
.map(|material| sprites.insert(root, &material.sprite))
|
||||
.transpose()?;
|
||||
let faces: Vec<BakedFace> = bake_block_model(model, BlockModelTransform::new(turns, lock))?
|
||||
.into_iter()
|
||||
.map(|quad| {
|
||||
let layer = sprites.insert(root, &quad.material.sprite)?;
|
||||
Ok(BakedFace {
|
||||
translucent: quad.material.force_translucent || sprites.translucent[layer as usize],
|
||||
opaque: sprites.region_opaque(layer, &quad.uv),
|
||||
quad,
|
||||
layer,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let cube = super::cube::cube_styles(&faces);
|
||||
Ok(BakedModel {
|
||||
ambient_occlusion: model.ambient_occlusion,
|
||||
particle,
|
||||
faces,
|
||||
cube,
|
||||
})
|
||||
}
|
||||
impl StateModels {
|
||||
pub fn load(
|
||||
root: &Path,
|
||||
@@ -219,41 +264,30 @@ impl StateModels {
|
||||
models.block_library_or_missing(root, names)?
|
||||
}
|
||||
};
|
||||
let resolved: Vec<_> = variants
|
||||
.iter()
|
||||
.map(|(name, ..)| library.resolve(name))
|
||||
.collect();
|
||||
// Every texture the models name, decoded side by side before the bake adds them one
|
||||
// by one in its own order.
|
||||
sprites.prefetch(
|
||||
root,
|
||||
resolved
|
||||
.iter()
|
||||
.flatten()
|
||||
.flat_map(|model| model.textures.values())
|
||||
.map(|material| material.sprite.as_str()),
|
||||
);
|
||||
let mut baked = Vec::with_capacity(variants.len());
|
||||
let mut index = BTreeMap::new();
|
||||
for (name, turns, lock) in variants {
|
||||
let model = library.resolve(&name)?;
|
||||
let particle = model
|
||||
.material("particle")?
|
||||
.map(|material| sprites.insert(root, &material.sprite))
|
||||
.transpose()?;
|
||||
let faces: Vec<BakedFace> =
|
||||
bake_block_model(&model, BlockModelTransform::new(turns, lock))?
|
||||
.into_iter()
|
||||
.map(|quad| {
|
||||
let layer = sprites.insert(root, &quad.material.sprite)?;
|
||||
Ok(BakedFace {
|
||||
translucent: quad.material.force_translucent
|
||||
|| sprites.translucent[layer as usize],
|
||||
opaque: sprites.region_opaque(layer, &quad.uv),
|
||||
quad,
|
||||
layer,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let cube = super::cube::cube_styles(&faces);
|
||||
for ((name, turns, lock), model) in variants.into_iter().zip(resolved) {
|
||||
let model = bake_resolved(root, sprites, &model?, turns, lock)?;
|
||||
index.insert((name.clone(), turns, lock), baked.len() as u32);
|
||||
let variant = Variant {
|
||||
model: name,
|
||||
rotations: turns,
|
||||
uv_lock: lock,
|
||||
};
|
||||
let model = BakedModel {
|
||||
ambient_occlusion: model.ambient_occlusion,
|
||||
particle,
|
||||
faces,
|
||||
cube,
|
||||
};
|
||||
baked.push((variant, model));
|
||||
}
|
||||
// Every variant of every definition was baked above.
|
||||
@@ -262,8 +296,58 @@ impl StateModels {
|
||||
choices,
|
||||
states,
|
||||
baked,
|
||||
standalone: BTreeMap::new(),
|
||||
})
|
||||
}
|
||||
/// Bakes `names`, models no blockstate needs to name (vanilla's
|
||||
/// standalone models, NeoForge's `ModelEvent.RegisterStandalone`), as
|
||||
/// they are, unturned, for [`Self::standalone`]. One a block state
|
||||
/// already bakes unturned is shared. A model that doesn't read (missing,
|
||||
/// or a parent missing) is logged and left out, as a missing blockstate
|
||||
/// is; the others still bake.
|
||||
pub(crate) fn bake_standalone(
|
||||
&mut self,
|
||||
root: &Path,
|
||||
sprites: &mut Sprites,
|
||||
models: &crate::model_library::ParsedModels,
|
||||
names: &[String],
|
||||
) -> Result<()> {
|
||||
for name in names {
|
||||
if self.standalone.contains_key(name) {
|
||||
continue;
|
||||
}
|
||||
if let Some(i) = self
|
||||
.baked
|
||||
.iter()
|
||||
.position(|(v, _)| v.model == *name && v.rotations == [0; 3] && !v.uv_lock)
|
||||
{
|
||||
self.standalone.insert(name.clone(), i as u32);
|
||||
continue;
|
||||
}
|
||||
let model = models
|
||||
.block_library(root, vec![name.clone()])
|
||||
.and_then(|library| bake_variant(root, sprites, &library, name, [0; 3], false));
|
||||
match model {
|
||||
Ok(model) => {
|
||||
self.standalone
|
||||
.insert(name.clone(), self.baked.len() as u32);
|
||||
let variant = Variant {
|
||||
model: name.clone(),
|
||||
rotations: [0; 3],
|
||||
uv_lock: false,
|
||||
};
|
||||
self.baked.push((variant, model));
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: standalone model {name} not loaded: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// 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)?;
|
||||
Some(&self.baked[i as usize].1)
|
||||
}
|
||||
pub fn collect(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
@@ -328,3 +412,103 @@ pub fn descriptor(name: &str, overrides: &[(&str, &str)]) -> BlockState {
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model_library::ParsedModels;
|
||||
|
||||
struct Fixture(std::path::PathBuf);
|
||||
impl Fixture {
|
||||
fn write(&self, name: &str, json: &str) {
|
||||
let (namespace, path) = name.split_once(':').unwrap();
|
||||
let path = self
|
||||
.0
|
||||
.join(format!("assets/{namespace}/models/{path}.json"));
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, json).unwrap();
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_models_bake_once_and_skip_what_does_not_read() -> Result<()> {
|
||||
let fixture = Fixture(
|
||||
std::env::temp_dir().join(format!("horizon-standalone-{}", std::process::id())),
|
||||
);
|
||||
// A half shaft: one element, two faces; a child with its parent's elements.
|
||||
fixture.write(
|
||||
"lanef:block/shaft_half",
|
||||
r##"{"textures":{"side":"lanef:block/shaft"},"elements":[{"from":[6,6,0],"to":[10,10,8],"faces":{"north":{"texture":"#side"},"up":{"texture":"#side"}}}]}"##,
|
||||
);
|
||||
fixture.write(
|
||||
"lanef:block/cog",
|
||||
r#"{"parent":"lanef:block/shaft_half","textures":{"side":"lanef:block/cog"}}"#,
|
||||
);
|
||||
fixture.write("lanef:block/orphan", r#"{"parent":"lanef:block/absent"}"#);
|
||||
let mut models = StateModels::default();
|
||||
let mut sprites = Sprites::default();
|
||||
let names: Vec<String> = [
|
||||
"lanef:block/shaft_half",
|
||||
"lanef:block/cog",
|
||||
"lanef:block/orphan",
|
||||
"lanef:block/absent",
|
||||
"lanef:block/shaft_half",
|
||||
]
|
||||
.map(str::to_owned)
|
||||
.into();
|
||||
let parsed = ParsedModels::load(&fixture.0);
|
||||
models.bake_standalone(&fixture.0, &mut sprites, &parsed, &names)?;
|
||||
let shaft = models.standalone("lanef:block/shaft_half").expect("baked");
|
||||
assert_eq!(shaft.faces.len(), 2);
|
||||
let cog = models.standalone("lanef:block/cog").expect("baked");
|
||||
assert_eq!(cog.faces.len(), 2);
|
||||
assert_ne!(cog.faces[0].layer, shaft.faces[0].layer, "its own texture");
|
||||
assert!(models.standalone("lanef:block/orphan").is_none());
|
||||
assert!(models.standalone("lanef:block/absent").is_none());
|
||||
assert!(models.standalone("lanef:block/unnamed").is_none());
|
||||
// Named twice, baked once.
|
||||
assert_eq!(models.baked.len(), 2);
|
||||
// Without a parsed library, the files are read directly.
|
||||
let mut direct = StateModels::default();
|
||||
direct.bake_standalone(&fixture.0, &mut sprites, &ParsedModels::default(), &names)?;
|
||||
assert_eq!(
|
||||
direct.standalone("lanef:block/cog").map(|m| m.faces.len()),
|
||||
Some(2)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_standalone_model_a_state_bakes_unturned_is_shared() -> Result<()> {
|
||||
let mut models = StateModels::default();
|
||||
let variant = |rotations| Variant {
|
||||
model: "lanef:block/shared".into(),
|
||||
rotations,
|
||||
uv_lock: false,
|
||||
};
|
||||
let empty = || BakedModel {
|
||||
ambient_occlusion: true,
|
||||
particle: None,
|
||||
faces: Vec::new(),
|
||||
cube: None,
|
||||
};
|
||||
models.baked.push((variant([0, 90, 0]), empty()));
|
||||
models.baked.push((variant([0; 3]), empty()));
|
||||
let names = vec!["lanef:block/shared".to_owned()];
|
||||
let root = std::env::temp_dir().join("horizon-standalone-none");
|
||||
models.bake_standalone(
|
||||
&root,
|
||||
&mut Sprites::default(),
|
||||
&ParsedModels::default(),
|
||||
&names,
|
||||
)?;
|
||||
assert_eq!(models.baked.len(), 2);
|
||||
assert_eq!(models.standalone.get("lanef:block/shared"), Some(&1));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -695,18 +695,20 @@ fn build_overlay_uncached(
|
||||
merged.insert(name.clone(), bytes);
|
||||
}
|
||||
}
|
||||
/// A zip entry to write: its pack, its name in the zip and where it goes.
|
||||
type Extract<'a> = (usize, &'a str, PathBuf);
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mirror(
|
||||
fn mirror<'a>(
|
||||
rel: &str,
|
||||
vanilla: &Path,
|
||||
out: &Path,
|
||||
touched: &BTreeSet<String>,
|
||||
hidden: &BTreeSet<String>,
|
||||
files: &BTreeMap<String, (usize, Provided)>,
|
||||
files: &'a BTreeMap<String, (usize, Provided)>,
|
||||
merged: &BTreeMap<String, Vec<u8>>,
|
||||
filters: &[&Filter],
|
||||
zips: &[Option<Zip>],
|
||||
dependencies: &mut cache::Dependencies,
|
||||
extract: &mut Vec<Extract<'a>>,
|
||||
) -> io::Result<()> {
|
||||
let src = if rel.is_empty() {
|
||||
vanilla.to_path_buf()
|
||||
@@ -733,7 +735,13 @@ fn build_overlay_uncached(
|
||||
} else {
|
||||
format!("{rel}/")
|
||||
};
|
||||
for f in files.keys().filter_map(|f| f.strip_prefix(&prefix)) {
|
||||
// The files under this directory are one run of the sorted map: walking only that run
|
||||
// keeps a pack of tens of thousands of files in thousands of folders from being
|
||||
// scanned once per folder.
|
||||
for f in files
|
||||
.range(prefix.clone()..)
|
||||
.map_while(|(f, _)| f.strip_prefix(&prefix))
|
||||
{
|
||||
names.insert(f.split('/').next().unwrap_or("").to_owned());
|
||||
}
|
||||
for name in names {
|
||||
@@ -747,19 +755,8 @@ fn build_overlay_uncached(
|
||||
let target = out.join(&child);
|
||||
match path {
|
||||
Some(p) => link(p, &target)?,
|
||||
None => {
|
||||
if let Some(Some(z)) = zips.get(*owner) {
|
||||
// One unreadable entry costs that file, not the
|
||||
// pack: an error here failed the build and the
|
||||
// reload dropped every server pack in the stack.
|
||||
match z.read(inner) {
|
||||
Ok(bytes) => fs::write(&target, bytes)?,
|
||||
Err(e) => log::warn!(
|
||||
"Horizon: skipping unreadable pack entry {inner}: {e}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Inflated and written after the walk, on every thread.
|
||||
None => extract.push((*owner, inner.as_str(), target)),
|
||||
}
|
||||
} else if filters.is_empty()
|
||||
&& !touched.contains(&child)
|
||||
@@ -783,8 +780,8 @@ fn build_overlay_uncached(
|
||||
files,
|
||||
merged,
|
||||
filters,
|
||||
zips,
|
||||
dependencies,
|
||||
extract,
|
||||
)?;
|
||||
} else if filters.iter().any(|f| f.blocks(&child)) {
|
||||
// Hidden by a pack's filter.
|
||||
@@ -798,6 +795,7 @@ fn build_overlay_uncached(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut extract = Vec::new();
|
||||
mirror(
|
||||
"",
|
||||
vanilla,
|
||||
@@ -807,9 +805,27 @@ fn build_overlay_uncached(
|
||||
&files,
|
||||
&merged,
|
||||
&filters,
|
||||
&zips,
|
||||
dependencies,
|
||||
)
|
||||
&mut extract,
|
||||
)?;
|
||||
// Inflating and writing a big pack's files one by one was most of a cold build; its
|
||||
// folders all exist by now.
|
||||
crate::par::par_map(&extract, |(owner, inner, target)| {
|
||||
let Some(Some(z)) = zips.get(*owner) else {
|
||||
return Ok(());
|
||||
};
|
||||
// One unreadable entry costs that file, not the pack: an error here
|
||||
// failed the build and the reload dropped every server pack in the stack.
|
||||
match z.read(inner) {
|
||||
Ok(bytes) => fs::write(target, bytes),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: skipping unreadable pack entry {inner}: {e}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
})
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
/// Files vanilla loads from every pack in turn (`getResourceStack`) rather
|
||||
/// than from the top pack only.
|
||||
@@ -1489,6 +1505,72 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn every_file_of_a_big_stack_comes_from_its_top_pack() {
|
||||
use std::io::Write as _;
|
||||
let base = std::env::temp_dir().join(format!("horizon-pack-stack-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let vanilla = base.join("vanilla");
|
||||
// Folders whose names start alike (`item`, `item_extra`, `item2`) and files beside
|
||||
// folders of almost their name: what a prefix lookup could mix up.
|
||||
let names: Vec<String> = (0..400)
|
||||
.map(|i| {
|
||||
let folder = ["item", "item_extra", "item2", "item/sub", "block"][i % 5];
|
||||
format!("assets/ns{}/textures/{folder}/f{i}.png", i % 3)
|
||||
})
|
||||
.chain(["assets/ns0/textures/item.png".to_owned()])
|
||||
.collect();
|
||||
let mut expected: BTreeMap<String, Vec<u8>> = BTreeMap::new();
|
||||
for name in names.iter().step_by(4) {
|
||||
let path = vanilla.join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(&path, format!("vanilla {name}")).unwrap();
|
||||
expected.insert(name.clone(), format!("vanilla {name}").into_bytes());
|
||||
}
|
||||
let meta = br#"{"pack":{"description":"d","pack_format":97}}"#;
|
||||
// Packs a and b are zips, c a folder; each provides a different part of the names.
|
||||
fs::create_dir_all(base.join("packs/c")).unwrap();
|
||||
fs::write(base.join("packs/c/pack.mcmeta"), meta).unwrap();
|
||||
for (pack, every) in [("a", 2), ("b", 3)] {
|
||||
let file = fs::File::create(base.join(format!("packs/{pack}.zip"))).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(meta).unwrap();
|
||||
for name in names.iter().step_by(every) {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(format!("{pack} {name}").as_bytes()).unwrap();
|
||||
expected.insert(name.clone(), format!("{pack} {name}").into_bytes());
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
for name in names.iter().skip(1).step_by(5) {
|
||||
let path = base.join("packs/c").join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(&path, format!("c {name}")).unwrap();
|
||||
expected.insert(name.clone(), format!("c {name}").into_bytes());
|
||||
}
|
||||
let all = folder(&base.join("packs"), RESOURCE_FORMAT);
|
||||
let ids = ["file/a.zip", "file/b.zip", "file/c"].map(String::from);
|
||||
let out = base.join("overlay");
|
||||
build_overlay(&vanilla, &enabled(&all, &ids), &out).unwrap();
|
||||
fn walk(dir: &Path, root: &Path, out: &mut BTreeMap<String, Vec<u8>>) {
|
||||
for entry in fs::read_dir(dir).unwrap().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
walk(&path, root, out);
|
||||
} else {
|
||||
let name = path.strip_prefix(root).unwrap().to_string_lossy();
|
||||
out.insert(name.replace('\\', "/"), fs::read(&path).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut built = BTreeMap::new();
|
||||
walk(&out.join("assets"), &out, &mut built);
|
||||
assert_eq!(built, expected);
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn zip_entries_never_leave_the_overlay() {
|
||||
use std::io::Write as _;
|
||||
let base = std::env::temp_dir().join(format!("horizon-pack-escape-{}", std::process::id()));
|
||||
|
||||
@@ -99,6 +99,9 @@ pub struct Sprites {
|
||||
still: std::collections::HashMap<(Vec<u8>, u64, u32, [u32; 2], [bool; 4]), u32>,
|
||||
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
|
||||
disk: Option<crate::sprite_cache::SpriteCache>,
|
||||
/// Textures [`Sprites::prefetch`] decoded ahead of their insert, by sprite name or
|
||||
/// resource path.
|
||||
ready: std::collections::HashMap<String, crate::sprite_cache::Decoded>,
|
||||
}
|
||||
/// Edge length of vanilla's block textures: the smallest array layer.
|
||||
pub const SPRITE_SIZE: u32 = 16;
|
||||
@@ -242,6 +245,8 @@ impl Sprites {
|
||||
self.lay_out(false);
|
||||
}
|
||||
fn lay_out(&mut self, pixels: bool) {
|
||||
// Prefetched textures nothing inserted.
|
||||
self.ready = Default::default();
|
||||
let layers = self.native.len().max(1) as u64;
|
||||
let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE);
|
||||
self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE);
|
||||
@@ -335,13 +340,50 @@ impl Sprites {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Decode on every thread the textures about to be inserted: sprite names, or resource
|
||||
/// paths outside `textures/` (as [`Sprites::insert_file`] takes them). A loader that adds
|
||||
/// many (the block models, an OptiFine pack's tiles) calls this first and then inserts
|
||||
/// them in its own order, so the layers are the same as without it. What is already a
|
||||
/// layer or in the disk cache is skipped; what fails to decode is left for the insert,
|
||||
/// which reports it.
|
||||
pub fn prefetch<'a>(&mut self, root: &Path, names: impl IntoIterator<Item = &'a str>) {
|
||||
let names: Vec<&str> = names
|
||||
.into_iter()
|
||||
.filter(|name| {
|
||||
!self.layers.contains_key(*name)
|
||||
&& !self.ready.contains_key(*name)
|
||||
&& self.disk.as_ref().is_none_or(|d| d.get(name).is_none())
|
||||
})
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let decoded = crate::par::par_map(&names, |name| {
|
||||
if name.contains(':') {
|
||||
Self::decode(root, name).ok()
|
||||
} else {
|
||||
Self::decode_file(&root.join(name), name).ok()
|
||||
}
|
||||
});
|
||||
for (name, decoded) in names.into_iter().zip(decoded) {
|
||||
if let Some(decoded) = decoded {
|
||||
self.ready.insert(name.to_owned(), decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn load(&mut self, root: &Path, name: &str) -> Result<u32> {
|
||||
let cached = self.disk.as_ref().and_then(|d| d.get(name)).cloned();
|
||||
// Custom Item Textures' files are outside `textures/`, which the disk cache's
|
||||
// fingerprint covers: like `insert_file`'s, they skip it.
|
||||
let cit = name.starts_with("horizon_cit:");
|
||||
let disk = self.disk.as_mut().filter(|_| !cit);
|
||||
let cached = disk.as_ref().and_then(|d| d.get(name)).cloned();
|
||||
let decoded = match cached {
|
||||
Some(decoded) => decoded,
|
||||
None => {
|
||||
let decoded = Self::decode(root, name)?;
|
||||
if let Some(disk) = &mut self.disk {
|
||||
let decoded = match self.ready.remove(name) {
|
||||
Some(decoded) => decoded,
|
||||
None => Self::decode(root, name)?,
|
||||
};
|
||||
if let Some(disk) = disk {
|
||||
disk.insert(name, decoded.clone());
|
||||
}
|
||||
decoded
|
||||
@@ -357,16 +399,20 @@ impl Sprites {
|
||||
if let Some(layer) = self.layers.get(resource) {
|
||||
return Ok(*layer);
|
||||
}
|
||||
let decoded = Self::decode_file(&root.join(resource), resource)?;
|
||||
let decoded = match self.ready.remove(resource) {
|
||||
Some(decoded) => decoded,
|
||||
None => Self::decode_file(&root.join(resource), resource)?,
|
||||
};
|
||||
self.register(root, resource, decoded)
|
||||
}
|
||||
/// Read and decode one texture and its `.mcmeta`.
|
||||
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
|
||||
let (namespace, path) = name.split_once(':').ok_or("invalid sprite identifier")?;
|
||||
Self::decode_file(
|
||||
&root.join(format!("assets/{namespace}/textures/{path}.png")),
|
||||
name,
|
||||
)
|
||||
if !name.contains(':') {
|
||||
return Err("invalid sprite identifier".into());
|
||||
}
|
||||
// `texture_path`, so Custom Item Textures' `horizon_cit:` sprites find their file.
|
||||
let path = crate::atlas_sources::texture_path(root, name, "textures");
|
||||
Self::decode_file(&path, name)
|
||||
}
|
||||
fn decode_file(path: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
|
||||
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
|
||||
@@ -462,8 +508,8 @@ impl Sprites {
|
||||
let count = frames.len() as u32;
|
||||
let layer = u32::try_from(self.native.len())?;
|
||||
let pbr = match name.split_once(':') {
|
||||
Some((namespace, path)) if labpbr_maps() => {
|
||||
let path = root.join(format!("assets/{namespace}/textures/{path}.png"));
|
||||
Some(_) if labpbr_maps() => {
|
||||
let path = crate::atlas_sources::texture_path(root, name, "textures");
|
||||
["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count))
|
||||
}
|
||||
_ => [None, None],
|
||||
@@ -473,9 +519,11 @@ impl Sprites {
|
||||
strategy,
|
||||
alpha_cutoff_bias,
|
||||
has_transparent,
|
||||
item: name
|
||||
.split_once(':')
|
||||
.is_some_and(|(_, p)| p.starts_with("item/")),
|
||||
// Custom Item Textures' ids are all item textures.
|
||||
item: name.starts_with("horizon_cit:")
|
||||
|| name
|
||||
.split_once(':')
|
||||
.is_some_and(|(_, p)| p.starts_with("item/")),
|
||||
dither_cutouts: crate::mipmap::dither_cutouts(),
|
||||
};
|
||||
let still = (animation.is_none() && frames.len() == 1).then(|| {
|
||||
@@ -706,6 +754,172 @@ mod tests {
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn custom_item_texture_ids_load_with_their_animation() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-sprite-cit-{}", std::process::id()));
|
||||
let file = root.join("assets/minecraft/optifine/cit/a.png");
|
||||
fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
let mut encoder = png::Encoder::new(File::create(&file).unwrap(), 16, 48);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let pixels: Vec<u8> = (0..16 * 48).flat_map(|_| [200, 10, 10, 255]).collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
fs::write(file.with_extension("png.mcmeta"), r#"{"animation":{}}"#).unwrap();
|
||||
let mut sprites = Sprites::default();
|
||||
sprites
|
||||
.insert(&root, "horizon_cit:minecraft/optifine/cit/a")
|
||||
.unwrap();
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
// The missing texture would not animate: this one is the file, in three frames.
|
||||
assert_eq!(sprites.animations.len(), 1);
|
||||
assert_eq!(sprites.animations[0].frame_count(), 3);
|
||||
}
|
||||
#[test]
|
||||
fn custom_item_textures_skip_the_disk_cache() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-cit-disk-{}", std::process::id()));
|
||||
// A realistic root: the cache's fingerprint walks the `textures` trees.
|
||||
let vanilla = root.join("assets/minecraft/textures/item/stick.png");
|
||||
let file = root.join("assets/minecraft/optifine/cit/a.png");
|
||||
let write = |path: &Path, red: u8| {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut encoder = png::Encoder::new(File::create(path).unwrap(), 16, 16);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let pixels: Vec<u8> = (0..256).flat_map(|_| [red, 0, 0, 255]).collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
};
|
||||
write(&vanilla, 1);
|
||||
write(&file, 10);
|
||||
let id = "horizon_cit:minecraft/optifine/cit/a";
|
||||
let red = |sprites: &Sprites, layer: u32| sprites.native[layer as usize].1[0][0];
|
||||
let mut first = Sprites::with_disk_cache(&root);
|
||||
let layer = first.insert(&root, id).unwrap();
|
||||
assert_eq!(red(&first, layer), 10);
|
||||
first.save_disk_cache();
|
||||
// The pack's file changes; `textures/` does not, so a cached copy would still match.
|
||||
write(&file, 20);
|
||||
let mut second = Sprites::with_disk_cache(&root);
|
||||
let layer = second.insert(&root, id).unwrap();
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
assert_eq!(red(&second, layer), 20);
|
||||
}
|
||||
#[test]
|
||||
fn custom_item_textures_mip_as_items() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-cit-mip-{}", std::process::id()));
|
||||
let write = |rel: &str| {
|
||||
let path = root.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut encoder = png::Encoder::new(File::create(path).unwrap(), 16, 16);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
// Any pixels: whether a texture mips as an item is part of the shared-layer key.
|
||||
let pixels: Vec<u8> = (0..256)
|
||||
.flat_map(|i| [9, 9, 9, if i % 2 == 0 { 255 } else { 0 }])
|
||||
.collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
};
|
||||
write("assets/a/textures/block/x.png");
|
||||
write("assets/a/textures/item/x.png");
|
||||
write("assets/minecraft/optifine/cit/x.png");
|
||||
let mut sprites = Sprites::default();
|
||||
let block = sprites.insert(&root, "a:block/x").unwrap();
|
||||
let item = sprites.insert(&root, "a:item/x").unwrap();
|
||||
let cit = sprites
|
||||
.insert(&root, "horizon_cit:minecraft/optifine/cit/x")
|
||||
.unwrap();
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
// The same pixels share a layer only with the same mipmap settings: the CIT texture's
|
||||
// are the item's.
|
||||
assert_ne!(block, item);
|
||||
assert_eq!(cit, item);
|
||||
}
|
||||
#[test]
|
||||
fn prefetched_textures_take_the_layers_inserting_alone_gives() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-prefetch-{}", std::process::id()));
|
||||
let png = |path: &str, side: u32, seed: u8| {
|
||||
let file = root.join(path);
|
||||
fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
let mut encoder = png::Encoder::new(File::create(file).unwrap(), side, side);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let pixels: Vec<u8> = (0..side * side)
|
||||
.flat_map(|i| {
|
||||
[
|
||||
seed,
|
||||
i as u8,
|
||||
(i >> 8) as u8,
|
||||
if i % 3 == 0 { 0 } else { 255 },
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
};
|
||||
for i in 0..40u8 {
|
||||
png(
|
||||
&format!("assets/minecraft/textures/block/b{i}.png"),
|
||||
16 << (i % 3),
|
||||
i,
|
||||
);
|
||||
}
|
||||
// Two textures alike share a layer; a tile outside `textures/`, a broken file and a
|
||||
// missing one take the paths they take without prefetching.
|
||||
png("assets/minecraft/textures/block/same.png", 16, 3);
|
||||
png("assets/minecraft/optifine/ctm/glass/0.png", 32, 77);
|
||||
fs::write(
|
||||
root.join("assets/minecraft/textures/block/broken.png"),
|
||||
b"no png",
|
||||
)
|
||||
.unwrap();
|
||||
let names: Vec<String> = (0..40)
|
||||
.map(|i| format!("minecraft:block/b{}", (i * 7) % 40))
|
||||
.chain(["minecraft:block/same", "minecraft:block/broken"].map(String::from))
|
||||
.chain(["minecraft:block/missing", "minecraft:block/b3"].map(String::from))
|
||||
.collect();
|
||||
let tile = "assets/minecraft/optifine/ctm/glass/0.png";
|
||||
let load = |prefetch: bool| {
|
||||
let mut sprites = Sprites::default();
|
||||
if prefetch {
|
||||
sprites.prefetch(&root, names.iter().map(String::as_str).chain([tile]));
|
||||
}
|
||||
let mut layers: Vec<u32> = names
|
||||
.iter()
|
||||
.map(|name| sprites.insert(&root, name).unwrap())
|
||||
.collect();
|
||||
layers.push(sprites.insert_file(&root, tile).unwrap());
|
||||
sprites.finish();
|
||||
(
|
||||
layers,
|
||||
sprites.layers,
|
||||
sprites.pixels,
|
||||
sprites.translucent,
|
||||
sprites.size,
|
||||
)
|
||||
};
|
||||
let alone = load(false);
|
||||
assert_eq!(load(true), alone);
|
||||
// The textures alike and the broken one's missing texture: fewer layers than names.
|
||||
assert!(alone.1.values().max().unwrap() + 1 < names.len() as u32);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn identical_still_textures_share_a_layer() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-share-{}", std::process::id()));
|
||||
|
||||
@@ -19,6 +19,8 @@ horizon-client-render = { path = "../horizon-client-render" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# OptiFine-format pack files: custom entity models, random entity textures.
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
@@ -40,7 +42,6 @@ horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
# Their runtime is the `mods` feature.
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
|
||||
@@ -505,6 +505,24 @@ fn signum(v: f64) -> f64 {
|
||||
/// entity stops there and is not kept: the game hands it to
|
||||
/// `finish_entity_hit` once it has hurt the entity.
|
||||
pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) -> (bool, Hit) {
|
||||
tick_with(p, scene, targets, true)
|
||||
}
|
||||
/// One arrow tick whose hit is canceled (`ProjectileImpactEvent`): the arrow
|
||||
/// moves on to where it would have hit, as `AbstractArrow.stepMoveAndHit` leaves
|
||||
/// it when the event is canceled, and flies on: not stuck, nothing hurt, no hit.
|
||||
pub fn tick_through(
|
||||
p: &mut Projectile,
|
||||
scene: &ArrowScene,
|
||||
targets: &[(Who, Aabb)],
|
||||
) -> (bool, Hit) {
|
||||
tick_with(p, scene, targets, false)
|
||||
}
|
||||
fn tick_with(
|
||||
p: &mut Projectile,
|
||||
scene: &ArrowScene,
|
||||
targets: &[(Who, Aabb)],
|
||||
impact: bool,
|
||||
) -> (bool, Hit) {
|
||||
let Some(mut st) = p.arrow.take() else {
|
||||
return (false, Hit::None);
|
||||
};
|
||||
@@ -517,7 +535,7 @@ pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) ->
|
||||
st: &mut st,
|
||||
scene,
|
||||
}
|
||||
.tick(targets);
|
||||
.tick(targets, impact);
|
||||
p.stuck = st.in_ground;
|
||||
let keep = !st.removed && !matches!(hit, Hit::Entity { .. });
|
||||
p.arrow = Some(st);
|
||||
@@ -600,7 +618,7 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
fn trident(&self) -> bool {
|
||||
matches!(self.p.kind, ProjectileKind::Trident)
|
||||
}
|
||||
fn tick(&mut self, targets: &[(Who, Aabb)]) -> Hit {
|
||||
fn tick(&mut self, targets: &[(Who, Aabb)], impact: bool) -> Hit {
|
||||
// ThrownTrident.tick.
|
||||
if self.trident() && self.st.in_ground_time > 4 {
|
||||
self.st.dealt_damage = true;
|
||||
@@ -711,7 +729,7 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
original.z + movement.z,
|
||||
);
|
||||
if physics {
|
||||
hit = self.step_move_and_hit(original, end, targets);
|
||||
hit = self.step_move_and_hit(original, end, targets, impact);
|
||||
if matches!(hit, Hit::Entity { .. }) {
|
||||
return hit;
|
||||
}
|
||||
@@ -795,7 +813,13 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
/// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the
|
||||
/// block. A piercing arrow that hits an entity here goes on from it next
|
||||
/// tick (`finish_entity_hit`); vanilla's loop goes on within the same tick.
|
||||
fn step_move_and_hit(&mut self, from: Vec3, to: Vec3, targets: &[(Who, Aabb)]) -> Hit {
|
||||
fn step_move_and_hit(
|
||||
&mut self,
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
targets: &[(Who, Aabb)],
|
||||
impact: bool,
|
||||
) -> Hit {
|
||||
let block = self.scene.clip(from, to);
|
||||
let end = block.map_or(to, |b| b.0);
|
||||
// findHitEntities: ProjectileUtil.computeMargin and the search box.
|
||||
@@ -828,6 +852,10 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
self.p.position = next;
|
||||
self.apply_effects_from_blocks(from, next);
|
||||
if let Some((who, at, _)) = best {
|
||||
// A canceled impact ends the search, the arrow where it would have hit.
|
||||
if !impact {
|
||||
return Hit::None;
|
||||
}
|
||||
// AbstractArrow.onHitEntity: past pierce level + 1 entities the
|
||||
// arrow is discarded before it hurts the next one.
|
||||
if self.st.pierce > 0 {
|
||||
@@ -849,6 +877,9 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
if self.scene.is_air(state) {
|
||||
return Hit::None;
|
||||
}
|
||||
if !impact {
|
||||
return Hit::None;
|
||||
}
|
||||
self.on_hit_block(pos, state);
|
||||
Hit::Block {
|
||||
pos: [pos.x, pos.y, pos.z],
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Breathing for native mods: `LivingEntity.baseTick`'s air supply, the part
|
||||
//! that raises or lowers it. A player (`motion/environment.rs`) and a mob
|
||||
//! (`mobs/ai.rs`) work out what vanilla does this tick, `breathe` lets the
|
||||
//! mods change it (`native_living_breathe`, NeoForge's `LivingBreatheEvent`),
|
||||
//! and the caller applies the answer. Servers only: the callers keep it off a
|
||||
//! client's prediction.
|
||||
|
||||
/// One tick of breathing: whether the entity can breathe, how much air it
|
||||
/// loses when it cannot and how much it gains when it can.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Breath {
|
||||
pub can_breathe: bool,
|
||||
/// `decreaseAirSupply`: 1, or 0 by the oxygen bonus's chance, for an
|
||||
/// entity that drowns; 1 for one that would not.
|
||||
pub consume: i32,
|
||||
/// `increaseAirSupply`: 4 for one that can breathe, 0 for one that drowns
|
||||
/// or is held under by the nautilus' breath.
|
||||
pub refill: i32,
|
||||
}
|
||||
|
||||
/// An entity's breathing this tick (`LivingBreatheEvent`, posted from
|
||||
/// `LivingEntity.baseTick` every tick for a living entity with the generic
|
||||
/// air logic): `entity` is its ID and `kind` its type (`minecraft:player`,
|
||||
/// `minecraft:zombie`). `can_breathe`, `consume` and `refill` come in as
|
||||
/// Horizon does it today (a mod-free game changes nothing); set them to
|
||||
/// change this tick. With `can_breathe` the air rises by `refill`, up to its
|
||||
/// maximum (300); without, it falls by `consume`, and at -20 it is set to 0
|
||||
/// and the entity takes 2 drowning damage. Negative amounts count as 0.
|
||||
/// Asked on servers for players and for mobs, except those with a
|
||||
/// `WaterAnimal`'s or an axolotl's own air logic. API @API:living_breathe@.
|
||||
#[inline(never)]
|
||||
pub fn native_living_breathe(
|
||||
entity: u32,
|
||||
kind: &str,
|
||||
can_breathe: &mut bool,
|
||||
consume: &mut i32,
|
||||
refill: &mut i32,
|
||||
) {
|
||||
let _ = (entity, kind, can_breathe, consume, refill);
|
||||
}
|
||||
|
||||
/// `vanilla`'s breathing after the mods have had their say.
|
||||
pub fn breathe(entity: u32, kind: &str, vanilla: Breath) -> Breath {
|
||||
let mut now = vanilla;
|
||||
native_living_breathe(
|
||||
entity,
|
||||
kind,
|
||||
&mut now.can_breathe,
|
||||
&mut now.consume,
|
||||
&mut now.refill,
|
||||
);
|
||||
Breath {
|
||||
can_breathe: now.can_breathe,
|
||||
consume: now.consume.max(0),
|
||||
refill: now.refill.max(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The air supply after `breath`: raised up to `max` when it can breathe,
|
||||
/// lowered otherwise. The second is whether the entity now drowns
|
||||
/// (`LivingEntity.shouldTakeDrowningDamage`: -20 or less), and its air is
|
||||
/// then 0.
|
||||
pub fn apply(air: i32, max: i32, breath: Breath) -> (i32, bool) {
|
||||
if breath.can_breathe {
|
||||
return (air.max((air + breath.refill).min(max)), false);
|
||||
}
|
||||
let air = air - breath.consume;
|
||||
if air <= -20 { (0, true) } else { (air, false) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const VANILLA: Breath = Breath {
|
||||
can_breathe: true,
|
||||
consume: 1,
|
||||
refill: 4,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn air_refills_up_to_the_maximum() {
|
||||
assert_eq!(apply(100, 300, VANILLA), (104, false));
|
||||
assert_eq!(apply(299, 300, VANILLA), (300, false));
|
||||
assert_eq!(apply(300, 300, VANILLA), (300, false));
|
||||
// A refill of 0 leaves it, as does one that would pass the maximum.
|
||||
let held = Breath {
|
||||
refill: 0,
|
||||
..VANILLA
|
||||
};
|
||||
assert_eq!(apply(120, 300, held), (120, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn air_runs_down_and_drowns_at_minus_twenty() {
|
||||
let drowning = Breath {
|
||||
can_breathe: false,
|
||||
consume: 1,
|
||||
refill: 0,
|
||||
};
|
||||
assert_eq!(apply(300, 300, drowning), (299, false));
|
||||
assert_eq!(apply(-18, 300, drowning), (-19, false));
|
||||
assert_eq!(apply(-19, 300, drowning), (0, true));
|
||||
// The oxygen bonus can spare a tick, and a mod can take more.
|
||||
let spared = Breath {
|
||||
consume: 0,
|
||||
..drowning
|
||||
};
|
||||
assert_eq!(apply(-19, 300, spared), (-19, false));
|
||||
let gas = Breath {
|
||||
consume: 30,
|
||||
..drowning
|
||||
};
|
||||
assert_eq!(apply(20, 300, gas), (-10, false));
|
||||
assert_eq!(apply(10, 300, gas), (0, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_changes_without_a_mod() {
|
||||
assert_eq!(breathe(1, "minecraft:zombie", VANILLA), VANILLA);
|
||||
let negative = Breath {
|
||||
can_breathe: false,
|
||||
consume: -3,
|
||||
refill: -2,
|
||||
};
|
||||
// Mods only ever answer with their own values, so what comes back is clamped.
|
||||
assert_eq!(breathe(1, "minecraft:player", negative).consume, 0);
|
||||
assert_eq!(breathe(1, "minecraft:player", negative).refill, 0);
|
||||
}
|
||||
}
|
||||
@@ -10,17 +10,17 @@ pub mod block_contact;
|
||||
pub use horizon_client_render::{
|
||||
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities,
|
||||
block_entity_anim, block_entity_render, border_render, breaking, brewing, camera, chat, chunk,
|
||||
chunk_map, cit, clouds, collisions, command_block, containers, crash, datapack, dynamic_light,
|
||||
effects, environment, extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui,
|
||||
hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block,
|
||||
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
|
||||
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
|
||||
options_ui, overlay, pack_screen, packs, par, particles, poi_sections, post_chain,
|
||||
potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk, section_mask, settings,
|
||||
shader_options, shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview,
|
||||
spans, split_layout, split_map, split_profiles, sprites, streaming, structure_block, tab_list,
|
||||
telemetry, test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather,
|
||||
widgets, world, worldborder, zip,
|
||||
chunk_map, cit, cit_assets, clouds, collisions, command_block, containers, crash, datapack,
|
||||
dynamic_light, effects, environment, extras, fast_hash, fog, font, gfx, graphics,
|
||||
graphics_style, graphics_ui, hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects,
|
||||
java_text, jigsaw_block, level_loading, light, lod, logging, menu_blur, mesher, minimap,
|
||||
mipmap, mod_list, model_library, models, multiline_edit, native_emissive, options_file,
|
||||
options_fixes, options_ui, overlay, pack_screen, packs, par, particles, poi_sections,
|
||||
post_chain, potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk,
|
||||
section_mask, settings, shader_options, shader_pack_list, signs, skins, sound_engine,
|
||||
sound_events, sound_preview, spans, split_layout, split_map, split_profiles, sprites,
|
||||
streaming, structure_block, tab_list, telemetry, test_block, test_instance_block, testgen,
|
||||
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
@@ -32,6 +32,7 @@ pub mod block_entity_item;
|
||||
pub mod block_pattern;
|
||||
pub mod block_transform;
|
||||
pub mod boss_overlay;
|
||||
pub mod breathing;
|
||||
pub mod cauldron;
|
||||
pub mod chunk_io;
|
||||
pub mod clock;
|
||||
|
||||
@@ -2322,6 +2322,12 @@ mod armor_stand_motion_tests {
|
||||
let start = Vec3::new(20.5, 20.0, 20.5);
|
||||
let mut stand = ArmorStand::new(1, start, 0.0);
|
||||
stand.on_ground = false;
|
||||
// A summoned stand has no motion yet: the first tick only builds it
|
||||
// (vanilla moves by the velocity, then applies gravity), so it falls
|
||||
// from the second tick on (measured on the real server: drop 0, 0, 0.0784 blocks).
|
||||
tick_stand(&mut stand, &world, &facts, &collision);
|
||||
assert_eq!(stand.position, start);
|
||||
assert!((stand.velocity.y + 0.0784).abs() < 1e-6);
|
||||
tick_stand(&mut stand, &world, &facts, &collision);
|
||||
assert_eq!(stand.previous, start);
|
||||
assert!(
|
||||
|
||||
@@ -196,22 +196,27 @@ mod native_block_entity_draw;
|
||||
mod native_claimed_items;
|
||||
mod native_components;
|
||||
mod native_criteria;
|
||||
mod native_data_maps;
|
||||
mod native_entities;
|
||||
mod native_explosions;
|
||||
mod native_falling;
|
||||
mod native_hooks;
|
||||
mod native_inventories;
|
||||
mod native_item_fluids;
|
||||
mod native_lifecycle;
|
||||
mod native_menus;
|
||||
mod native_mob_draw;
|
||||
mod native_mobs;
|
||||
mod native_motion;
|
||||
mod native_mounts;
|
||||
mod native_net;
|
||||
mod native_panel;
|
||||
mod native_panel_recipes;
|
||||
mod native_projectiles;
|
||||
mod native_recipes;
|
||||
mod native_server;
|
||||
mod native_shaders;
|
||||
mod native_ticks;
|
||||
mod native_visuals;
|
||||
mod native_world_columns;
|
||||
mod native_world_edit;
|
||||
@@ -1790,6 +1795,9 @@ struct WorldTables {
|
||||
items: Items,
|
||||
recipes: Recipes,
|
||||
stations: horizon_client::stations::StationData,
|
||||
/// The values of the data map types native mods registered
|
||||
/// (`native_data_map_types`).
|
||||
data_maps: native_data_maps::DataMaps,
|
||||
}
|
||||
|
||||
impl Resources {
|
||||
@@ -2417,6 +2425,10 @@ struct Game<'a> {
|
||||
use_duration: i32,
|
||||
/// A mod's `Start` or `Tick` set that use's ticks left (`game/item_use.rs`).
|
||||
use_ticks_set: bool,
|
||||
/// The crossbow charge's start and middle loading sounds played so far
|
||||
/// (`game/crossbow.rs`): each charge plays each of them once, however
|
||||
/// far a mod's tick moved the charge at a time.
|
||||
crossbow_sounds: [bool; 2],
|
||||
/// Camera.oldFovModifier / fovModifier.
|
||||
/// `Camera` (eye height and field-of-view easing, view placement).
|
||||
view: horizon_client::camera::Camera,
|
||||
@@ -3760,6 +3772,29 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
fn tick_items(&mut self) {
|
||||
// EntityTickEvent.Pre for the items about to tick, and the Post after them.
|
||||
let mut round = self.native_tick_round();
|
||||
if round.listening() {
|
||||
let ticking = self.entity_ticking_area();
|
||||
let merging = self.remote.is_none();
|
||||
let mut i = 0;
|
||||
while let Some(item) = self.dropped.get(i) {
|
||||
let (id, x, z) = (
|
||||
item.id,
|
||||
item.position.x.floor() as i32,
|
||||
item.position.z.floor() as i32,
|
||||
);
|
||||
if !(merging && id > self.ticked_entities_through) && ticking(x, z) {
|
||||
let kind = native_ticks::TickKind::Fixed("minecraft:item");
|
||||
self.native_tick_pre_batch(&mut round, id, kind);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
self.tick_dropped_items(&mut round);
|
||||
self.native_tick_post_batch(round);
|
||||
}
|
||||
fn tick_dropped_items(&mut self, round: &mut native_ticks::TickRound) {
|
||||
let mut items = std::mem::take(&mut self.dropped);
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
let ticking = self.entity_ticking_area();
|
||||
@@ -3774,6 +3809,8 @@ impl Game<'_> {
|
||||
let mut nearby = Vec::new();
|
||||
for i in 0..items.len() {
|
||||
if removed[i] {
|
||||
// Merged into an earlier item before its own tick: it never ticked.
|
||||
round.not_ticked(items[i].id);
|
||||
continue;
|
||||
}
|
||||
let item = &mut items[i];
|
||||
@@ -3786,7 +3823,8 @@ impl Game<'_> {
|
||||
if !ticking(
|
||||
item.position.x.floor() as i32,
|
||||
item.position.z.floor() as i32,
|
||||
) {
|
||||
) || round.canceled(item.id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// ItemEntity.tick's noPhysics (servers only): out of a block
|
||||
@@ -8131,42 +8169,50 @@ impl Game<'_> {
|
||||
hud_layout::full_attack_indicator(a, delay, self.crosshair_living())
|
||||
});
|
||||
let draws = hud_layout::crosshair(wi, hi, attack, full);
|
||||
hud_layout::paint(&mut batch, &self.gui.atlas, &draws);
|
||||
self.gui_layer("minecraft:crosshair", &mut batch, |g, batch| {
|
||||
hud_layout::paint(batch, &g.gui.atlas, &draws);
|
||||
});
|
||||
}
|
||||
if spectator {
|
||||
self.draw_spectator_hotbar(&mut batch);
|
||||
} else {
|
||||
let partial = self.clock.fraction() as f32;
|
||||
let inputs = hud_layout::HotbarInputs {
|
||||
selected: self.selected as i32,
|
||||
right_handed: !self.settings.main_hand_left,
|
||||
offhand: self.offhand.is_some(),
|
||||
slots: std::array::from_fn(|i| {
|
||||
self.inventory[i]
|
||||
.as_ref()
|
||||
.map(|_| (f32::from(self.hotbar_pop[i]) - partial).max(0.0))
|
||||
}),
|
||||
offhand_pop: (f32::from(self.hotbar_pop[9]) - partial).max(0.0),
|
||||
attack: (indicator == 2).then_some(strength),
|
||||
};
|
||||
let draws = hud_layout::hotbar(wi, hi, &inputs);
|
||||
hud_layout::paint(&mut batch, &self.gui.atlas, &draws);
|
||||
for d in &draws {
|
||||
if let hud_layout::GuiDraw::Item { slot, x, y, .. } = *d {
|
||||
let stack = if slot == 40 {
|
||||
self.offhand.as_ref()
|
||||
} else {
|
||||
self.inventory[slot as usize].as_ref()
|
||||
};
|
||||
if let Some(stack) = stack {
|
||||
self.draw_item(&mut batch, stack, x as f32, y as f32);
|
||||
self.gui_layer("minecraft:hotbar", &mut batch, |g, batch| {
|
||||
if spectator {
|
||||
g.draw_spectator_hotbar(batch);
|
||||
} else {
|
||||
let partial = g.clock.fraction() as f32;
|
||||
let inputs = hud_layout::HotbarInputs {
|
||||
selected: g.selected as i32,
|
||||
right_handed: !g.settings.main_hand_left,
|
||||
offhand: g.offhand.is_some(),
|
||||
slots: std::array::from_fn(|i| {
|
||||
g.inventory[i]
|
||||
.as_ref()
|
||||
.map(|_| (f32::from(g.hotbar_pop[i]) - partial).max(0.0))
|
||||
}),
|
||||
offhand_pop: (f32::from(g.hotbar_pop[9]) - partial).max(0.0),
|
||||
attack: (indicator == 2).then_some(strength),
|
||||
};
|
||||
let draws = hud_layout::hotbar(wi, hi, &inputs);
|
||||
hud_layout::paint(batch, &g.gui.atlas, &draws);
|
||||
for d in &draws {
|
||||
if let hud_layout::GuiDraw::Item { slot, x, y, .. } = *d {
|
||||
let stack = if slot == 40 {
|
||||
g.offhand.as_ref()
|
||||
} else {
|
||||
g.inventory[slot as usize].as_ref()
|
||||
};
|
||||
if let Some(stack) = stack {
|
||||
g.draw_item(batch, stack, x as f32, y as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.draw_effect_icons(&mut batch);
|
||||
});
|
||||
self.gui_layer("minecraft:effects", &mut batch, |g, batch| {
|
||||
g.draw_effect_icons(batch);
|
||||
});
|
||||
// Hud: the action bar and titles draw over the hotbar (nextStratum).
|
||||
self.draw_boss_bars(&mut batch);
|
||||
self.gui_layer("minecraft:boss_overlay", &mut batch, |g, batch| {
|
||||
g.draw_boss_bars(batch);
|
||||
});
|
||||
self.draw_extras(&mut batch);
|
||||
}
|
||||
if show_hud {
|
||||
@@ -8181,47 +8227,56 @@ impl Game<'_> {
|
||||
self.experience_recent(),
|
||||
);
|
||||
use horizon_client::locator_bar::ContextualBar;
|
||||
// The mount's hearts (and the jump bar).
|
||||
// The mount's hearts.
|
||||
let vehicle_hearts = if spectator {
|
||||
0
|
||||
} else {
|
||||
self.draw_mount_hud(
|
||||
&mut batch,
|
||||
wi,
|
||||
hi,
|
||||
contextual == ContextualBar::JumpableVehicle,
|
||||
)
|
||||
self.draw_mount_hud(&mut batch, wi, hi)
|
||||
};
|
||||
// Without the survival bars only the mount's hearts raise it.
|
||||
bar_height = 39 + 10 * ((vehicle_hearts + 9) / 10);
|
||||
if contextual == ContextualBar::Locator {
|
||||
let (sprite, [x, y, w, h]) = horizon_client::locator_bar::background(wi, hi);
|
||||
horizon_client::ui::nine_slice(
|
||||
&mut batch,
|
||||
&self.gui.atlas,
|
||||
sprite,
|
||||
x as f32,
|
||||
y as f32,
|
||||
w as f32,
|
||||
h as f32,
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
// The one bar above the hotbar: the mount's jump bar, the locator
|
||||
// bar (with its background) or the experience bar, with the level
|
||||
// number that a survival player always has.
|
||||
self.gui_layer("minecraft:contextual_info_bar", &mut batch, |g, batch| {
|
||||
if contextual == ContextualBar::JumpableVehicle {
|
||||
g.draw_jump_bar(batch, wi, hi);
|
||||
}
|
||||
if contextual == ContextualBar::Locator {
|
||||
let (sprite, [x, y, w, h]) = horizon_client::locator_bar::background(wi, hi);
|
||||
horizon_client::ui::nine_slice(
|
||||
batch,
|
||||
&g.gui.atlas,
|
||||
sprite,
|
||||
x as f32,
|
||||
y as f32,
|
||||
w as f32,
|
||||
h as f32,
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
if has_experience && g.player.is_some() {
|
||||
let atlas = &g.gui.atlas;
|
||||
let width = |s: &str| HudBatch::text_width(atlas, s) as i32;
|
||||
let draws = hud_layout::experience(
|
||||
wi,
|
||||
hi,
|
||||
g.experience.progress,
|
||||
g.experience.level,
|
||||
contextual == ContextualBar::Experience,
|
||||
&width,
|
||||
);
|
||||
hud_layout::paint(batch, atlas, &draws);
|
||||
}
|
||||
if contextual == ContextualBar::Locator {
|
||||
let draws = g.locator_bar_draws(wi, hi);
|
||||
hud_layout::paint(batch, &g.gui.atlas, &draws);
|
||||
}
|
||||
});
|
||||
if !self.creative
|
||||
&& !spectator
|
||||
&& let Some(p) = &mut self.player
|
||||
{
|
||||
let atlas = &self.gui.atlas;
|
||||
let width = |s: &str| HudBatch::text_width(atlas, s) as i32;
|
||||
let draws = hud_layout::experience(
|
||||
wi,
|
||||
hi,
|
||||
self.experience.progress,
|
||||
self.experience.level,
|
||||
contextual == ContextualBar::Experience,
|
||||
&width,
|
||||
);
|
||||
hud_layout::paint(&mut batch, atlas, &draws);
|
||||
let effects = &self.potions.effects;
|
||||
let kind = if effects.get("poison").is_some() {
|
||||
hud_layout::HeartKind::Poisoned
|
||||
@@ -8266,9 +8321,11 @@ impl Game<'_> {
|
||||
Some(draws) => draws,
|
||||
None => self.health_hud.layout(&inputs, wi, hi),
|
||||
};
|
||||
// A fresh borrow: the hook ran with all of `self`.
|
||||
let atlas = &self.gui.atlas;
|
||||
hud_layout::paint(&mut batch, atlas, &draws);
|
||||
// The rows draw in one pass here, so a mod's `player_health`
|
||||
// layer stands for the armor, food and air rows too.
|
||||
self.gui_layer("minecraft:player_health", &mut batch, |g, batch| {
|
||||
hud_layout::paint(batch, &g.gui.atlas, &draws);
|
||||
});
|
||||
bar_height = self
|
||||
.health_hud
|
||||
.left_height
|
||||
@@ -8286,21 +8343,30 @@ impl Game<'_> {
|
||||
self.audio.capture = capture;
|
||||
}
|
||||
}
|
||||
if contextual == ContextualBar::Locator {
|
||||
let draws = self.locator_bar_draws(wi, hi);
|
||||
hud_layout::paint(&mut batch, &self.gui.atlas, &draws);
|
||||
}
|
||||
if !spectator {
|
||||
self.draw_selected_name(&mut batch, bar_height, !self.creative);
|
||||
self.gui_layer("minecraft:selected_item_name", &mut batch, |g, batch| {
|
||||
g.draw_selected_name(batch, bar_height, !g.creative);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Hud.extractSleepOverlay: over all health/food bars, under titles and chat.
|
||||
let draws = hud_overlays::sleep_overlay(wi, hi, self.sleep_timer as i32);
|
||||
hud_layout::paint(&mut batch, &self.gui.atlas, &draws);
|
||||
self.gui_layer("minecraft:sleep_overlay", &mut batch, |g, batch| {
|
||||
hud_layout::paint(batch, &g.gui.atlas, &draws);
|
||||
});
|
||||
if show_hud {
|
||||
self.draw_titles(&mut batch, bar_height);
|
||||
self.draw_scoreboard(&mut batch);
|
||||
self.draw_subtitles(&mut batch);
|
||||
self.gui_layer("minecraft:overlay_message", &mut batch, |g, batch| {
|
||||
g.draw_overlay_message(batch, bar_height);
|
||||
});
|
||||
self.gui_layer("minecraft:title", &mut batch, |g, batch| {
|
||||
g.draw_title(batch);
|
||||
});
|
||||
self.gui_layer("minecraft:scoreboard_sidebar", &mut batch, |g, batch| {
|
||||
g.draw_scoreboard(batch);
|
||||
});
|
||||
self.gui_layer("minecraft:subtitle_overlay", &mut batch, |g, batch| {
|
||||
g.draw_subtitles(batch);
|
||||
});
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
if show_hud
|
||||
@@ -8310,8 +8376,16 @@ impl Game<'_> {
|
||||
let tw = HudBatch::text_width(atlas, text);
|
||||
batch.text(atlas, text, (w - tw) / 2.0, h - 59.0, u32::MAX, true);
|
||||
}
|
||||
self.draw_chat(&mut batch);
|
||||
self.draw_tab_list(&mut batch);
|
||||
// The chat screen draws the focused chat itself; F1 hides the rest.
|
||||
if show_hud && self.screen != Screen::Chat {
|
||||
self.gui_layer("minecraft:chat", &mut batch, |g, batch| g.draw_chat(batch));
|
||||
self.gui_layer("minecraft:tab_list", &mut batch, |g, batch| {
|
||||
g.draw_tab_list(batch);
|
||||
});
|
||||
} else {
|
||||
self.draw_chat(&mut batch);
|
||||
self.draw_tab_list(&mut batch);
|
||||
}
|
||||
// Screen.extractBlurredBackground blurs the world and HUD, while the
|
||||
// screen's background, widgets, toasts and debug overlay stay sharp.
|
||||
batch.blur_at = batch.vertices.len() as u32;
|
||||
@@ -11352,6 +11426,7 @@ impl Game<'_> {
|
||||
}
|
||||
fn tick_mobs(&mut self) -> Result<()> {
|
||||
self.tick_creatures()?;
|
||||
self.native_mobs_equipment();
|
||||
self.position_rider();
|
||||
self.tick_stats();
|
||||
self.tick_scoreboard();
|
||||
@@ -17982,6 +18057,7 @@ impl<'a> Game<'a> {
|
||||
using: None,
|
||||
use_duration: 0,
|
||||
use_ticks_set: false,
|
||||
crossbow_sounds: [false; 2],
|
||||
view: Default::default(),
|
||||
limiter: horizon_client::window::FramerateLimiter::new(0),
|
||||
frame_clock: Default::default(),
|
||||
@@ -18598,11 +18674,20 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let stations = horizon_client::stations::StationData::load(root, &items, &overlay);
|
||||
let mut data_map_types = Vec::new();
|
||||
self.native_data_map_types(&mut data_map_types);
|
||||
let data_maps = native_data_maps::DataMaps::load(
|
||||
&data_map_types,
|
||||
&files,
|
||||
&items.item_tags,
|
||||
&items.block_tags,
|
||||
);
|
||||
Some(WorldTables {
|
||||
files,
|
||||
items,
|
||||
recipes,
|
||||
stations,
|
||||
data_maps,
|
||||
})
|
||||
}
|
||||
/// Reload the content tables with the world's packs, as
|
||||
@@ -20222,6 +20307,7 @@ pub fn run() -> Result<()> {
|
||||
}
|
||||
}
|
||||
mods::register_components(&vanilla_root)?;
|
||||
mods::register_standalone_models();
|
||||
let early_settings = Settings::load(&Game::options_path());
|
||||
// Minecraft's constructor opens the narrator; a server has none.
|
||||
if options.execution_role.is_client() {
|
||||
|
||||
@@ -560,10 +560,13 @@ mod tests {
|
||||
// a mod block's, as `Game::new_block_entity` makes it
|
||||
let made = |n: &str| {
|
||||
(n == "machines:press").then(|| {
|
||||
BlockEntity::Mod(Box::new(horizon_client::containers::ModBlockEntity {
|
||||
id: "machines:press".into(),
|
||||
..Default::default()
|
||||
}))
|
||||
BlockEntity::Mod(Box::new(
|
||||
horizon_client::containers::ModBlockEntityFields {
|
||||
id: "machines:press".into(),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
})
|
||||
};
|
||||
replace_block_entities(&mut w, &out.tracked, names, made);
|
||||
@@ -685,10 +688,13 @@ mod tests {
|
||||
|n| {
|
||||
calls.push(n.to_string());
|
||||
(n == "machines:press").then(|| {
|
||||
BlockEntity::Mod(Box::new(horizon_client::containers::ModBlockEntity {
|
||||
id: format!("press-{}", calls.len()),
|
||||
..Default::default()
|
||||
}))
|
||||
BlockEntity::Mod(Box::new(
|
||||
horizon_client::containers::ModBlockEntityFields {
|
||||
id: format!("press-{}", calls.len()),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
@@ -28,6 +28,8 @@ impl Game<'_> {
|
||||
self.world.block_entities.set_mod_containers(containers);
|
||||
let fluids = self.native_fluid_tables();
|
||||
self.world.block_entities.set_mod_fluids(fluids);
|
||||
let energy = self.native_energy_tables();
|
||||
self.world.block_entities.set_mod_energy(energy);
|
||||
}
|
||||
// Only lingering cooldowns are dropped; a hopper that ticks checks
|
||||
// its own block. Once a second is soon enough for the rest.
|
||||
|
||||
@@ -720,9 +720,14 @@ impl Game<'_> {
|
||||
pub(super) fn tick_boats(&mut self) {
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
let ticking = self.entity_ticking_area();
|
||||
let mut round = self.native_tick_round();
|
||||
// A server player's boat is driven by its client (lan_vehicles.rs).
|
||||
let driven = self.guest_vehicles();
|
||||
for i in 0..self.boats.len() {
|
||||
// A mod's hook may have removed boats.
|
||||
if i >= self.boats.len() {
|
||||
break;
|
||||
}
|
||||
let p = self.boats[i].position;
|
||||
if Some(self.boats[i].id) == self.riding
|
||||
|| driven.contains(&self.boats[i].id)
|
||||
@@ -736,8 +741,25 @@ impl Game<'_> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let id = self.boats[i].id;
|
||||
let kind = if round.listening() {
|
||||
self.boats[i].entity_type()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if !self.native_tick_pre_one(&mut round, id, &kind) {
|
||||
continue;
|
||||
}
|
||||
// The hook may have removed boats.
|
||||
let Some(i) = Some(i)
|
||||
.filter(|&i| self.boats.get(i).is_some_and(|b| b.id == id))
|
||||
.or_else(|| self.boats.iter().position(|b| b.id == id))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let b = self.boats[i].clone();
|
||||
self.boats[i] = self.tick_boat(b, false);
|
||||
self.native_tick_post_one(&round, id, &kind);
|
||||
}
|
||||
// AbstractBoat.tick's applyEffectsFromBlocks, on the server for every
|
||||
// boat, the client-driven ones too.
|
||||
@@ -745,6 +767,7 @@ impl Game<'_> {
|
||||
while i < self.boats.len() {
|
||||
let p = self.boats[i].position;
|
||||
let skip = Some(self.boats[i].id) == self.riding
|
||||
|| round.canceled(self.boats[i].id)
|
||||
|| !ticking(p.x.floor() as i32, p.z.floor() as i32);
|
||||
if skip || !self.boat_lava_tick(i) {
|
||||
i += 1;
|
||||
@@ -755,7 +778,8 @@ impl Game<'_> {
|
||||
// pushes what it touches.
|
||||
for i in 0..self.boats.len() {
|
||||
let p = self.boats[i].position;
|
||||
if !ticking(p.x.floor() as i32, p.z.floor() as i32) {
|
||||
if !ticking(p.x.floor() as i32, p.z.floor() as i32) || round.canceled(self.boats[i].id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// `AbstractBoat.tick` counts submersion on the server even when
|
||||
@@ -1046,7 +1070,18 @@ impl Game<'_> {
|
||||
/// `getDismountLocationForPassenger`: in front of the player if there is
|
||||
/// floor there, else on top of the boat.
|
||||
pub(super) fn dismount(&mut self, i: usize) {
|
||||
let id = self.boats[i].id;
|
||||
if self.riding == Some(id) && !self.native_may_leave(id) {
|
||||
return;
|
||||
}
|
||||
self.riding = None;
|
||||
// `AbstractBoat.tick`'s non-authoritative branch: the copy of a boat
|
||||
// that no player of this side drives drops its cached motion
|
||||
// (`setDeltaMovement(Vec3.ZERO)`, `deltaRotation = 0`) and is not
|
||||
// moved, so a boat is at a standstill when its rider gets out instead
|
||||
// of coasting on the velocity of the ride that just ended.
|
||||
self.boats[i].velocity = Vec3::ZERO;
|
||||
self.boats[i].delta_rotation = 0.0;
|
||||
let target = self.boat_dismount_target(i, 0.6, self.yaw);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = target;
|
||||
@@ -1235,6 +1270,9 @@ impl Game<'_> {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !self.native_may_board(self.boats[i].id) {
|
||||
return true;
|
||||
}
|
||||
self.riding = Some(self.boats[i].id);
|
||||
self.yaw = self.boats[i].yaw;
|
||||
// ServerPlayer.startRiding.
|
||||
|
||||
@@ -162,6 +162,22 @@ impl Game<'_> {
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
// A native mod's own icons (`native_map_decorations`).
|
||||
for d in self.mod_map_decorations(id, true) {
|
||||
let Some(s) = self.gui.atlas.sprites.get(d.sprite.as_str()).copied() else {
|
||||
continue;
|
||||
};
|
||||
batch.quad(
|
||||
pose_corners(
|
||||
super::maps::decoration_pose_at(d.x, d.y, d.rot, 0.0),
|
||||
x,
|
||||
y,
|
||||
scale,
|
||||
),
|
||||
[[s.u0, s.v0], [s.u1, s.v0], [s.u1, s.v1], [s.u0, s.v1]],
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,9 +185,14 @@ impl Game<'_> {
|
||||
/// `scale` GUI pixels per map pixel: (-1, 1), (1, 1), (1, -1), (-1, -1)
|
||||
/// under `MapRenderer.render`'s pose.
|
||||
fn decoration_corners(d: &super::maps::Decoration, x: f32, y: f32, scale: f32) -> [[f32; 2]; 4] {
|
||||
pose_corners(super::maps::decoration_pose(d, 0.0), x, y, scale)
|
||||
}
|
||||
/// The corners of a decoration drawn under `pose` (`decoration_pose`), like
|
||||
/// [`decoration_corners`].
|
||||
fn pose_corners(pose: glam::Mat4, x: f32, y: f32, scale: f32) -> [[f32; 2]; 4] {
|
||||
let m = glam::Mat4::from_translation(glam::Vec3::new(x, y, 0.0))
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(scale, scale, 1.0))
|
||||
* super::maps::decoration_pose(d, 0.0);
|
||||
* pose;
|
||||
[(-1.0, 1.0), (1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)].map(|(cx, cy)| {
|
||||
let p = m.transform_point3(glam::Vec3::new(cx, cy, 0.0));
|
||||
[p.x, p.y]
|
||||
|
||||
@@ -142,6 +142,9 @@ pub(super) struct CombatTracker {
|
||||
/// The mob that owns the projectile exploding now (`getIndirectSourceEntity`):
|
||||
/// the mobs the blast hurts record it as their attacker.
|
||||
pub(super) explosion_owner: Option<u32>,
|
||||
/// `WitherSkull.getBlockExplosionResistance`: a dangerous skull's blast
|
||||
/// caps every destroyable block's resistance at this value.
|
||||
pub(super) blast_resistance_cap: Option<f32>,
|
||||
/// Charged creepers whose blast just went off and that have not dropped
|
||||
/// a head yet (`Creeper.droppedSkulls`): they are gone from the mob list,
|
||||
/// but the mobs they killed still ask for their loot.
|
||||
|
||||
@@ -1234,8 +1234,13 @@ impl Game<'_> {
|
||||
return Err(generic());
|
||||
}
|
||||
if let Some(rider) = rider {
|
||||
self.mount_mob(rider, id);
|
||||
if !self.try_mount_mob(rider, id) {
|
||||
return Err(generic());
|
||||
}
|
||||
} else {
|
||||
if !self.native_may_board(id) {
|
||||
return Err(generic());
|
||||
}
|
||||
self.riding = Some(id);
|
||||
self.yaw = self.boats[i].yaw;
|
||||
// sendEventAndTriggers: START_RIDING_TRIGGER.
|
||||
@@ -1248,7 +1253,10 @@ impl Game<'_> {
|
||||
return Ok(1);
|
||||
}
|
||||
Target::Cart(id) => {
|
||||
if target != Target::Player || self.ridden_by_guest(id) {
|
||||
if target != Target::Player
|
||||
|| self.ridden_by_guest(id)
|
||||
|| !self.native_may_board(id)
|
||||
{
|
||||
return Err(generic());
|
||||
}
|
||||
self.riding = Some(id);
|
||||
@@ -1270,13 +1278,18 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(rider) = rider {
|
||||
// Entity.startRiding.
|
||||
self.mount_mob(rider, mob_id);
|
||||
if !self.try_mount_mob(rider, mob_id) {
|
||||
return Err(generic());
|
||||
}
|
||||
self.feedback(Text::translate(
|
||||
"commands.ride.mount.success",
|
||||
&[name, vehicle_name],
|
||||
));
|
||||
return Ok(1);
|
||||
}
|
||||
if !self.native_may_board(mob_id) {
|
||||
return Err(generic());
|
||||
}
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob_id) {
|
||||
m.extra.flag = false;
|
||||
m.extra.counter = 0;
|
||||
@@ -1314,9 +1327,13 @@ impl Game<'_> {
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == rider) else {
|
||||
return;
|
||||
};
|
||||
let Some(v) = self.mobs[i].extra.mount.take() else {
|
||||
let Some(v) = self.mobs[i].extra.mount else {
|
||||
return;
|
||||
};
|
||||
if !self.native_mob_may_ride(rider, v, false) {
|
||||
return;
|
||||
}
|
||||
self.mobs[i].extra.mount = None;
|
||||
let (width, yaw) = (f64::from(self.mobs[i].width()), self.mobs[i].yaw);
|
||||
let at = if let Some(b) = self.boats.iter().position(|b| b.id == v) {
|
||||
self.boat_dismount_target(b, width, yaw)
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
|
||||
//! breeding, conversions, drops), projectiles, player interactions
|
||||
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
|
||||
use super::{Dim, Game, InteractTarget, dispenser, view_vector};
|
||||
use super::{
|
||||
Dim, Game, InteractTarget, dispenser, native_mob_draw::NativePlayerRender,
|
||||
native_projectiles::Impact, view_vector,
|
||||
};
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
@@ -56,6 +59,9 @@ pub(super) struct NativeSound {
|
||||
#[derive(Default)]
|
||||
pub(super) struct Creatures {
|
||||
pub projectiles: Vec<Projectile>,
|
||||
/// The entities a family tells the mods ticked, kept between ticks
|
||||
/// (game/native_ticks.rs).
|
||||
pub tick_buffer: Vec<(u32, super::native_ticks::TickKind)>,
|
||||
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
|
||||
pub wind_bursts: Vec<super::wind::Burst>,
|
||||
/// The players a fishing hook can catch, taken as the hooks tick, and
|
||||
@@ -147,7 +153,7 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
const WIND_CHARGE_IDS: u32 = super::dragon::SPECIAL - 0x10000;
|
||||
|
||||
/// The index of the wind charge a `wind_charge_hitboxes` id stands for.
|
||||
fn wind_charge_hit(id: u32) -> Option<usize> {
|
||||
pub(super) fn wind_charge_hit(id: u32) -> Option<usize> {
|
||||
(WIND_CHARGE_IDS..super::dragon::SPECIAL)
|
||||
.contains(&id)
|
||||
.then(|| (id - WIND_CHARGE_IDS) as usize)
|
||||
@@ -885,6 +891,20 @@ impl Game<'_> {
|
||||
// waiting any more.
|
||||
self.combat.charged_exploders.clear();
|
||||
self.combat.exploded.clear();
|
||||
// EntityTickEvent.Pre for the mobs about to tick, before the loop holds them.
|
||||
let mut round = self.native_tick_round();
|
||||
if round.listening() {
|
||||
let ticking = self.entity_ticking_area();
|
||||
let mut i = 0;
|
||||
while let Some(m) = self.mobs.get(i) {
|
||||
let (x, z) = (m.position.x.floor() as i32, m.position.z.floor() as i32);
|
||||
if self.world.loaded(x, z) && ticking(x, z) {
|
||||
let (id, kind) = (m.id, super::native_ticks::TickKind::Mob(m.kind.clone()));
|
||||
self.native_tick_pre_batch(&mut round, id, kind);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let uuid = self.level.player_uuid;
|
||||
let mut snapshot: Vec<Seen> = self.mobs.iter().map(|m| seen(m, uuid)).collect();
|
||||
let mut events = Vec::new();
|
||||
@@ -997,6 +1017,11 @@ impl Game<'_> {
|
||||
keep.push(kept);
|
||||
continue;
|
||||
}
|
||||
// A mod canceled its tick: it stays as it is.
|
||||
if round.canceled(m.id) {
|
||||
keep.push(true);
|
||||
continue;
|
||||
}
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
horizon_client::mobs::blocks_changed(m, &path_changes);
|
||||
let from = events.len();
|
||||
@@ -1077,6 +1102,7 @@ impl Game<'_> {
|
||||
self.mobs.append(&mut mobs);
|
||||
}
|
||||
self.died = died;
|
||||
self.native_tick_post_batch(round);
|
||||
// ... and entity event 60 goes out as it is: the poof.
|
||||
for (id, at, width, height) in gone {
|
||||
let at = [at.x, at.y, at.z];
|
||||
@@ -3411,24 +3437,12 @@ impl Game<'_> {
|
||||
let mut deflections: Vec<[f64; 3]> = Vec::new();
|
||||
let mut arrow_events = Vec::new();
|
||||
let mut trails = Vec::new();
|
||||
let mut keep_flags = vec![true; projectiles.len()];
|
||||
// The impacts the mods may cancel (`native_projectile_impact`), on a server.
|
||||
let hear = self.native_hears_impacts();
|
||||
let mut impacts: Vec<Impact> = Vec::new();
|
||||
{
|
||||
let mut targets: Vec<(Who, Aabb)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.map(|m| (Who::Mob(m.id), m.bounds()))
|
||||
.collect();
|
||||
if let Some(p) = self.player_view()
|
||||
&& p.alive
|
||||
{
|
||||
targets.push((Who::Player, p.bounds));
|
||||
}
|
||||
// Dragon parts and end crystals under reserved ids (game/dragon.rs).
|
||||
targets.extend(
|
||||
self.special_targets()
|
||||
.into_iter()
|
||||
.map(|(id, b)| (Who::Mob(id), b)),
|
||||
);
|
||||
let targets = self.projectile_targets();
|
||||
let breezes: Vec<u32> = self
|
||||
.mobs
|
||||
.iter()
|
||||
@@ -3472,7 +3486,6 @@ impl Game<'_> {
|
||||
.map(|g| g.conn)
|
||||
.collect()
|
||||
});
|
||||
let mut keep_flags = vec![true; projectiles.len()];
|
||||
for i in 0..projectiles.len() {
|
||||
// A wind charge is pickable, so a pearl, an arrow or a trident can
|
||||
// strike it (`Projectile.isPickable`, `Projectile.onHit`).
|
||||
@@ -3492,6 +3505,8 @@ impl Game<'_> {
|
||||
keep_flags[i] = false;
|
||||
continue;
|
||||
}
|
||||
// The projectile as it was, to fly on if a mod cancels its impact.
|
||||
let before = (hear && !p.stuck).then(|| p.clone());
|
||||
let mob_shot = !matches!(p.owner, Some(Who::Player));
|
||||
// A player's arrows, snowballs and eggs meet every player; a
|
||||
// guest's flies from that guest (Projectile.getOwner), not
|
||||
@@ -3556,6 +3571,7 @@ impl Game<'_> {
|
||||
&mut p.velocity,
|
||||
);
|
||||
}
|
||||
let events_from = arrow_events.len();
|
||||
if let Some(a) = p.arrow.as_mut() {
|
||||
arrow_events.extend(a.events.drain(..).map(|e| (p.position, e)));
|
||||
}
|
||||
@@ -3594,6 +3610,15 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
if hit != Hit::None {
|
||||
if let Some(before) = before {
|
||||
impacts.push(Impact {
|
||||
index: i,
|
||||
before,
|
||||
hit,
|
||||
hit_slot: hits.len(),
|
||||
events: events_from..arrow_events.len(),
|
||||
});
|
||||
}
|
||||
hits.push((p.clone(), hit));
|
||||
}
|
||||
keep_flags[i] = keep;
|
||||
@@ -3609,12 +3634,22 @@ impl Game<'_> {
|
||||
super::deflect::aim_deflect(wind, look, owner, owner_uuid, guest);
|
||||
}
|
||||
}
|
||||
let mut idx = 0;
|
||||
projectiles.retain(|_| {
|
||||
let k = keep_flags[idx];
|
||||
idx += 1;
|
||||
k
|
||||
});
|
||||
}
|
||||
let listed = keep_flags.clone();
|
||||
let mut idx = 0;
|
||||
projectiles.retain(|_| {
|
||||
let k = keep_flags[idx];
|
||||
idx += 1;
|
||||
k
|
||||
});
|
||||
if !impacts.is_empty() {
|
||||
self.native_settle_impacts(
|
||||
&mut projectiles,
|
||||
&listed,
|
||||
impacts,
|
||||
&mut hits,
|
||||
&mut arrow_events,
|
||||
);
|
||||
}
|
||||
self.smoke_trails(trails);
|
||||
for at in deflections {
|
||||
@@ -3885,11 +3920,32 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `Level.isRainingAt` for arrows.
|
||||
fn arrow_rain(&self) -> impl Fn(BlockPos) -> bool + '_ {
|
||||
/// What a projectile can hit: the mobs, the player, the dragon's parts and
|
||||
/// the End crystals (under reserved ids, game/dragon.rs).
|
||||
pub(super) fn projectile_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
let mut targets: Vec<(Who, Aabb)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.map(|m| (Who::Mob(m.id), m.bounds()))
|
||||
.collect();
|
||||
if let Some(p) = self.player_view()
|
||||
&& p.alive
|
||||
{
|
||||
targets.push((Who::Player, p.bounds));
|
||||
}
|
||||
targets.extend(
|
||||
self.special_targets()
|
||||
.into_iter()
|
||||
.map(|(id, b)| (Who::Mob(id), b)),
|
||||
);
|
||||
targets
|
||||
}
|
||||
pub(super) fn arrow_rain(&self) -> impl Fn(BlockPos) -> bool + '_ {
|
||||
move |p: BlockPos| self.raining_at(p)
|
||||
}
|
||||
/// The level as arrows see it.
|
||||
fn arrow_scene<'s>(
|
||||
pub(super) fn arrow_scene<'s>(
|
||||
&'s self,
|
||||
rain: &'s dyn Fn(BlockPos) -> bool,
|
||||
) -> horizon_client::arrows::ArrowScene<'s, 's> {
|
||||
@@ -6062,6 +6118,10 @@ impl Game<'_> {
|
||||
[p.position.x, p.position.y, p.position.z]
|
||||
}),
|
||||
player_rot: [self.pitch, self.yaw],
|
||||
paused: self.pause,
|
||||
first_person: self.camera_type.first_person(),
|
||||
third_person_reversed: self.camera_type
|
||||
== super::perspective::CameraType::ThirdPersonFront,
|
||||
..Default::default()
|
||||
});
|
||||
for mob in &self.mobs {
|
||||
@@ -6143,10 +6203,20 @@ impl Game<'_> {
|
||||
// A native mod's own model in place of the vanilla one
|
||||
// (`native_mob_submit`), before any of the vanilla model's parts
|
||||
// (a sulfur cube's swallowed block) are drawn.
|
||||
if !is_player
|
||||
&& !layers_only
|
||||
&& self.mod_mob_geometry(mob, alpha as f32, camera, packed, out)
|
||||
{
|
||||
// A player's own hook (`native_player_submit`) is asked for the
|
||||
// other players in view.
|
||||
let who = is_player
|
||||
.then(|| self.remote_player_name(mob.id))
|
||||
.flatten()
|
||||
.map(|name| NativePlayerRender { name, local: false });
|
||||
let modded = if layers_only {
|
||||
false
|
||||
} else if let Some(who) = who {
|
||||
self.mod_player_geometry(mob, who, alpha as f32, camera, packed, out)
|
||||
} else {
|
||||
!is_player && self.mod_mob_geometry(mob, alpha as f32, camera, packed, out)
|
||||
};
|
||||
if modded {
|
||||
if let Some(rgb) = outline {
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
if mob.invisible {
|
||||
@@ -6225,6 +6295,8 @@ impl Game<'_> {
|
||||
}
|
||||
// CustomHeadLayer (a pumpkin, skull or head item) included.
|
||||
self.mob_block_geometry(mob, camera, alpha, packed, out);
|
||||
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
|
||||
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
|
||||
if let Some(rgb) = outline {
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
if mob.invisible {
|
||||
|
||||
@@ -43,6 +43,29 @@ fn projectile_slot(
|
||||
})
|
||||
}
|
||||
|
||||
/// `CrossbowItem.onUseTick`'s loading sounds for a charge now at `charge`
|
||||
/// (of `getChargeDuration`) that has played `played` so far: the start and
|
||||
/// middle sounds each play once the charge reaches their point, and both
|
||||
/// count as unheard again while the charge is under the first one. A tick
|
||||
/// that jumped the charge past a point still plays that point's sound, as
|
||||
/// vanilla's two flags do. Returns the flags for the next tick and the
|
||||
/// sounds to play now.
|
||||
fn charge_sounds(
|
||||
charge: f32,
|
||||
played: [bool; 2],
|
||||
sounds: [&'static str; 3],
|
||||
) -> ([bool; 2], Option<&'static str>, Option<&'static str>) {
|
||||
let mut played = if charge < 0.2 { [false; 2] } else { played };
|
||||
let mut play = [None; 2];
|
||||
for (i, point) in [0.2, 0.5].into_iter().enumerate() {
|
||||
if charge >= point && !played[i] && !sounds[i].is_empty() {
|
||||
played[i] = true;
|
||||
play[i] = Some(sounds[i]);
|
||||
}
|
||||
}
|
||||
(played, play[0], play[1])
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn charge_ticks(stack: &ItemStack) -> u32 {
|
||||
// crossbow_charge_time: 1.25 s, minus 0.25 s per Quick Charge level.
|
||||
@@ -100,17 +123,19 @@ impl Game<'_> {
|
||||
let held = self.ticks_held(stack, ticks);
|
||||
self.using = Some((slot, ticks + 1));
|
||||
let charge = Self::charge_ticks(stack).max(1) as f32;
|
||||
let (before, now) = (held as f32 / charge, (held + 1) as f32 / charge);
|
||||
let now = (held + 1) as f32 / charge;
|
||||
let sounds = Self::charging_sounds(stack);
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
if before < 0.2 && now >= 0.2 && !sounds[0].is_empty() {
|
||||
self.audio.play(sounds[0], at, 0.5, 1.0);
|
||||
let (played, start, middle) = charge_sounds(now, self.crossbow_sounds, sounds);
|
||||
self.crossbow_sounds = played;
|
||||
if let Some(sound) = start {
|
||||
self.audio.play(sound, at, 0.5, 1.0);
|
||||
}
|
||||
if before < 0.5 && now >= 0.5 && !sounds[1].is_empty() {
|
||||
self.audio.play(sounds[1], at, 0.5, 1.0);
|
||||
if let Some(sound) = middle {
|
||||
self.audio.play(sound, at, 0.5, 1.0);
|
||||
}
|
||||
let charged = stack.data().is_some_and(|d| !d.charged.is_empty());
|
||||
if now >= 1.0 && !charged {
|
||||
@@ -254,6 +279,44 @@ impl Game<'_> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A charge that crosses a point between two ticks plays that point's
|
||||
/// sound, and one a mod's tick jumps over (`native_living_entity_use_item_tick`
|
||||
/// cutting the ticks left) still plays every point it passed, as
|
||||
/// `CrossbowItem.onUseTick`'s two flags do.
|
||||
#[test]
|
||||
fn a_jumped_charge_still_plays_the_loading_sounds_it_passed() {
|
||||
let sounds = [
|
||||
"minecraft:item.crossbow.loading_start",
|
||||
"minecraft:item.crossbow.loading_middle",
|
||||
"minecraft:item.crossbow.loading_end",
|
||||
];
|
||||
// Each step: the charge and the sounds it plays, from the flags the
|
||||
// step before left.
|
||||
let mut played = [false; 2];
|
||||
let mut step = |charge: f32| {
|
||||
let (next, start, middle) = charge_sounds(charge, played, sounds);
|
||||
played = next;
|
||||
[start, middle]
|
||||
};
|
||||
assert_eq!(step(0.1), [None, None]);
|
||||
assert_eq!(step(0.25), [Some(sounds[0]), None]);
|
||||
assert_eq!(step(0.25), [None, None]);
|
||||
assert_eq!(step(0.55), [None, Some(sounds[1])]);
|
||||
assert_eq!(step(1.0), [None, None]);
|
||||
// A charge below the first point is a new charge: both sound again.
|
||||
assert_eq!(step(0.1), [None, None]);
|
||||
assert_eq!(step(0.6), [Some(sounds[0]), Some(sounds[1])]);
|
||||
// One tick from the start past every point, as a mod's tick makes it.
|
||||
assert_eq!(step(0.1), [None, None]);
|
||||
assert_eq!(step(0.9), [Some(sounds[0]), Some(sounds[1])]);
|
||||
// Quick Charge 1 and up have no middle sound.
|
||||
let quick = [sounds[0], "", sounds[2]];
|
||||
assert_eq!(
|
||||
charge_sounds(0.9, [false; 2], quick),
|
||||
([true, false], Some(sounds[0]), None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_projectiles_come_before_the_inventory() {
|
||||
let stack = |item: &str| Some(ItemStack::new(item, 1));
|
||||
|
||||
@@ -338,6 +338,9 @@ impl Game<'_> {
|
||||
}
|
||||
// `Entity.startRiding`: a passenger first gets off what it rides
|
||||
// (`stopRiding`), so a seated player moves to this cushion.
|
||||
if !self.native_may_board(id) {
|
||||
return true;
|
||||
}
|
||||
self.stop_riding();
|
||||
self.riding = Some(id);
|
||||
let p = self.cushions[i].position;
|
||||
@@ -454,6 +457,9 @@ impl Game<'_> {
|
||||
/// and `Cushion.removePassenger`'s get-up sound for a rider that left
|
||||
/// rather than one whose cushion was killed.
|
||||
pub(super) fn get_up_from_cushion(&mut self, c: &Cushion) {
|
||||
if self.riding == Some(c.id) && !self.native_may_leave(c.id) {
|
||||
return;
|
||||
}
|
||||
let at = c.dismount();
|
||||
self.riding = None;
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
|
||||
@@ -27,7 +27,7 @@ use horizon_world::nbt::Tag;
|
||||
/// Mob ids from here on are dragon parts (`SPECIAL + part`) and crystals
|
||||
/// (`SPECIAL + 16 + crystal`), so projectiles can target them.
|
||||
pub(super) const SPECIAL: u32 = u32::MAX - 4096;
|
||||
const CRYSTAL_IDS: u32 = SPECIAL + 16;
|
||||
pub(super) const CRYSTAL_IDS: u32 = SPECIAL + 16;
|
||||
const FIGHT_FILE: &str = "data/minecraft/ender_dragon_fight.dat";
|
||||
const MAX_HEALTH: f32 = 200.0;
|
||||
/// `Mob.getAmbientSoundInterval`.
|
||||
|
||||
@@ -812,7 +812,30 @@ impl Game<'_> {
|
||||
if !moved && !landed && !changed.iter().any(|c| *c) {
|
||||
return;
|
||||
}
|
||||
let id = stand.id;
|
||||
let told: Vec<_> = (0..6)
|
||||
.filter(|&i| changed[i])
|
||||
.map(|i| {
|
||||
let slot = horizon_client::entities::ArmorStand::SLOTS[i];
|
||||
(
|
||||
slot,
|
||||
stand.enchantment_equipment[i].clone(),
|
||||
stand.equipment[i].clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
stand.enchantment_equipment.clone_from(&stand.equipment);
|
||||
if self.remote.is_none() && !self.options.execution_role.is_client() {
|
||||
for (slot, from, to) in told {
|
||||
self.native_living_equipment_change(
|
||||
id,
|
||||
"minecraft:armor_stand",
|
||||
slot,
|
||||
from.as_ref(),
|
||||
to.as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let stand = &self.armor_stands[index];
|
||||
let at = [stand.position.x, stand.position.y, stand.position.z];
|
||||
let who = Who::Mob(stand.id);
|
||||
|
||||
@@ -100,6 +100,15 @@ pub(super) fn blast_reaches(center: [f64; 3], diameter: f64, at: [f64; 3]) -> bo
|
||||
d / diameter <= 1.0
|
||||
}
|
||||
|
||||
/// `ItemEntity.ignoreExplosion` through
|
||||
/// `ServerExplosion.shouldAffectBlocklikeEntities`: an explosion that does not
|
||||
/// affect blocklike entities is ignored entirely by a dropped item. With
|
||||
/// `mobGriefing` off that is every blast that keeps blocks; with it on, none
|
||||
/// (no wind charge goes through `server_explode`).
|
||||
fn items_ignore_explosion(mob_griefing: bool, interaction: BlockInteraction) -> bool {
|
||||
!mob_griefing && interaction == BlockInteraction::Keep
|
||||
}
|
||||
|
||||
/// Whether a blast leaves item frames, paintings, cushions and armour stands
|
||||
/// alone. Without `mobGriefing`, a blast that keeps blocks is ignored
|
||||
/// (`ServerExplosion.shouldAffectBlocklikeEntities` through their
|
||||
@@ -259,6 +268,16 @@ impl Game<'_> {
|
||||
use horizon_world::level::LevelAccess;
|
||||
(level.min_y(), level.max_y())
|
||||
});
|
||||
// WitherSkull.getBlockExplosionResistance: a dangerous skull's blast
|
||||
// caps a block the wither may destroy at 0.8.
|
||||
let cap = self.combat.blast_resistance_cap;
|
||||
let wither_immune = cap.and_then(|_| {
|
||||
self.res
|
||||
.items()
|
||||
.block_tags
|
||||
.get("minecraft:wither_immune")
|
||||
.cloned()
|
||||
});
|
||||
let mut set: JavaHashMap<Key, ()> = JavaHashMap::default();
|
||||
for xx in 0..16 {
|
||||
for yy in 0..16 {
|
||||
@@ -301,12 +320,14 @@ impl Game<'_> {
|
||||
power -= (resistance + 0.3) * 0.3;
|
||||
}
|
||||
_ if !self.res.facts().is_air(state) || f.fluid != Fluid::None => {
|
||||
let resistance = if spared {
|
||||
let base = if spared {
|
||||
0.0
|
||||
} else {
|
||||
f.explosion_resistance.max(fluid)
|
||||
};
|
||||
power -= (resistance + 0.3) * 0.3;
|
||||
let immune =
|
||||
wither_immune.as_ref().is_some_and(|t| t.contains(&*f.name));
|
||||
power -= (blast_resistance(base, cap, immune) + 0.3) * 0.3;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -593,9 +614,15 @@ impl Game<'_> {
|
||||
}
|
||||
self.mobs = mobs;
|
||||
// Dropped items: 5 health, destroyed by the explosion damage (nether stars resist).
|
||||
// `ItemEntity.ignoreExplosion` asks `shouldAffectBlocklikeEntities`
|
||||
// first, so a blast that keeps blocks leaves every item untouched.
|
||||
let items_ignore = items_ignore_explosion(self.ui.rules.bool("mob_griefing"), interaction);
|
||||
let before = self.dropped_ids();
|
||||
let mut items = std::mem::take(&mut self.dropped);
|
||||
items.retain_mut(|item| {
|
||||
if items_ignore {
|
||||
return true;
|
||||
}
|
||||
let feet = [item.position.x, item.position.y, item.position.z];
|
||||
let dist = distance(feet) / diameter;
|
||||
if dist > 1.0 {
|
||||
@@ -1164,10 +1191,45 @@ fn add_or_append_stack(
|
||||
stacks.push((pos, stack));
|
||||
}
|
||||
|
||||
/// `WitherSkull.getBlockExplosionResistance`: `isDangerous() &&
|
||||
/// WitherBoss.canDestroy(state) ? min(0.8, resistance) : resistance`. Air has
|
||||
/// no resistance to begin with, and `#wither_immune` blocks keep theirs.
|
||||
fn blast_resistance(base: f32, cap: Option<f32>, wither_immune: bool) -> f32 {
|
||||
match cap {
|
||||
Some(cap) if !wither_immune => base.min(cap),
|
||||
_ => base,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A dangerous wither skull's blast treats a block as at most 0.8 harder to
|
||||
/// break, but a `#wither_immune` block keeps its own resistance and any
|
||||
/// other blast is unchanged.
|
||||
#[test]
|
||||
fn a_dangerous_wither_skull_caps_block_resistance() {
|
||||
assert_eq!(blast_resistance(4.0, Some(0.8), false), 0.8);
|
||||
assert_eq!(blast_resistance(0.3, Some(0.8), false), 0.3, "already low");
|
||||
assert_eq!(blast_resistance(4.0, Some(0.8), true), 4.0, "wither immune");
|
||||
assert_eq!(blast_resistance(4.0, None, false), 4.0, "another blast");
|
||||
}
|
||||
|
||||
/// A dropped item survives a mob's blast untouched while `mobGriefing` is
|
||||
/// off, and is thrown and hurt as usual by one that destroys blocks or by
|
||||
/// any blast with the rule back on.
|
||||
#[test]
|
||||
fn items_ignore_a_blast_that_keeps_blocks_with_griefing_off() {
|
||||
assert!(items_ignore_explosion(false, BlockInteraction::Keep));
|
||||
assert!(!items_ignore_explosion(false, BlockInteraction::Destroy));
|
||||
assert!(!items_ignore_explosion(
|
||||
false,
|
||||
BlockInteraction::DestroyWithDecay
|
||||
));
|
||||
assert!(!items_ignore_explosion(true, BlockInteraction::Keep));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blasts_reach_entities_within_their_diameter() {
|
||||
// A TNT blast: radius 4, so the entity pass reaches 8 blocks out.
|
||||
|
||||
@@ -90,6 +90,9 @@ impl Game<'_> {
|
||||
if harnessed && !sneaking {
|
||||
// doPlayerRide.
|
||||
let id = self.mobs[index].id;
|
||||
if !self.native_may_board(id) {
|
||||
return true;
|
||||
}
|
||||
self.break_leash(id, true, false);
|
||||
let was_empty = self.mobs[index].rider.is_none();
|
||||
// addPassenger: the rider is not standing on top, so no still
|
||||
|
||||
@@ -32,6 +32,9 @@ impl Game<'_> {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
self.use_duration = duration;
|
||||
self.use_ticks_set = ticks_set;
|
||||
// A new charge hears the crossbow's loading sounds again
|
||||
// (`game/crossbow.rs`).
|
||||
self.crossbow_sounds = [false; 2];
|
||||
true
|
||||
}
|
||||
/// `ItemStack.getUseDuration`: a consumable's time, 72000 for what is
|
||||
|
||||
@@ -1113,54 +1113,11 @@ fn mob_equipment(
|
||||
hub: &mut Hub,
|
||||
out: &mut Vec<(u8, Option<horizon_protocol::item::Stack>)>,
|
||||
) {
|
||||
let e = &m.extra;
|
||||
// A horse's armour, a llama's carpet, a harness: Horizon keeps the body
|
||||
// slot's item (6) in `head`.
|
||||
let body = horizon_client::mobs::drops::head_is_body(m.species());
|
||||
let slots = [
|
||||
(0u8, &e.main_hand, e.main_hand_stack()),
|
||||
(1, &e.off_hand, None),
|
||||
(2, &e.feet, e.armor[3].as_ref()),
|
||||
(3, &e.legs, e.armor[2].as_ref()),
|
||||
(4, &e.chest, e.armor[1].as_ref()),
|
||||
// The body slot's item is no worn armour.
|
||||
(
|
||||
if body { 6 } else { 5 },
|
||||
&e.head,
|
||||
e.armor[0].as_ref().filter(|_| !body),
|
||||
),
|
||||
];
|
||||
for (slot, item, worn) in slots {
|
||||
if let Some(item) = item {
|
||||
// The worn armour keeps its stack: a dyed leather piece keeps
|
||||
// its colour.
|
||||
let stack = worn.cloned().unwrap_or_else(|| ItemStack {
|
||||
item: item.clone(),
|
||||
count: 1,
|
||||
// A wolf's armor shows its cracks.
|
||||
damage: if slot == 6 && m.species() == horizon_client::mobs::Species::Wolf {
|
||||
e.body_damage.max(0) as u32
|
||||
} else {
|
||||
0
|
||||
},
|
||||
extra: if slot == 6 && m.species() == horizon_client::mobs::Species::Wolf {
|
||||
e.body_extra.clone()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
});
|
||||
out.push((slot, hub.stack(&Some(stack))));
|
||||
for (slot, stack) in m.equipment_stacks().into_iter().enumerate() {
|
||||
if stack.is_some() {
|
||||
out.push((slot as u8, hub.stack(&stack)));
|
||||
}
|
||||
}
|
||||
if e.saddled {
|
||||
let stack = e.saddle_stack.clone().unwrap_or(ItemStack {
|
||||
item: Arc::from("minecraft:saddle"),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
});
|
||||
out.push((7, hub.stack(&Some(stack))));
|
||||
}
|
||||
}
|
||||
|
||||
pub const HELP: &[&str] = &[
|
||||
@@ -2334,6 +2291,15 @@ impl Game<'_> {
|
||||
.take()
|
||||
.expect("the hub is back after the menu events");
|
||||
}
|
||||
host.hub.collect_tracking();
|
||||
if !host.hub.native_tracking.is_empty() {
|
||||
self.lan = Some(host);
|
||||
self.run_native_tracking();
|
||||
host = self
|
||||
.lan
|
||||
.take()
|
||||
.expect("the hub is back after the tracking events");
|
||||
}
|
||||
if !paused {
|
||||
self.tick_guests(&mut host);
|
||||
}
|
||||
|
||||
@@ -93,6 +93,8 @@ pub(super) struct GuestPlay {
|
||||
pub(super) mount_window: Option<super::lan_mount_menu::GuestMount>,
|
||||
/// The lectern the guest reads (game/lan_stations.rs).
|
||||
pub(super) lectern: Option<super::lan_stations::GuestLectern>,
|
||||
/// The menu the mods last heard this guest open (`tick_player_menus`).
|
||||
pub(super) menu_heard: Option<super::native_menus::HeardMenu>,
|
||||
/// What the guest's body was given for its effects and enchantments.
|
||||
potions: super::potions::BodyPotions,
|
||||
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
|
||||
@@ -1305,6 +1307,8 @@ impl Game<'_> {
|
||||
self.lan_station_closed(host, conn, None);
|
||||
self.lan_mount_closed(host, conn, None);
|
||||
self.guest_carried_back(host, gi);
|
||||
// `PlayerContainerEvent.Close`, after the menu's `removed`.
|
||||
self.player_menu_closing(host, conn);
|
||||
}
|
||||
|
||||
/// `ContainerEntity`'s `Container.stopOpen` when a vehicle menu closes
|
||||
@@ -3263,6 +3267,7 @@ impl Game<'_> {
|
||||
self.lan_tables_tick(host);
|
||||
self.lan_containers_tick(host);
|
||||
self.tick_lan_mount_menus(host);
|
||||
self.tick_player_menus(host);
|
||||
self.announce_guest_advancements(host);
|
||||
let conns: Vec<u32> = host
|
||||
.hub
|
||||
|
||||
@@ -83,6 +83,42 @@ fn stand_poses(a: &horizon_client::entities::ArmorStand) -> Vec<(u8, i32, Value)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The entities players started and stopped tracking since the last
|
||||
/// tick, told to the mods in order with the hub back in `self.lan` and
|
||||
/// no player's state swapped in (`ServerEntity.addPairing` and
|
||||
/// `removePairing`).
|
||||
pub(super) fn run_native_tracking(&mut self) {
|
||||
let batches = match self.lan.as_mut() {
|
||||
Some(host) => std::mem::take(&mut host.hub.native_tracking),
|
||||
None => return,
|
||||
};
|
||||
if self.remote.is_some() || self.options.execution_role.is_client() {
|
||||
return;
|
||||
}
|
||||
for batch in batches {
|
||||
for e in batch.events {
|
||||
let (id, kind) = (e.entity as u32, &*e.tracked.kind);
|
||||
if e.start {
|
||||
self.native_player_start_tracking(
|
||||
batch.player,
|
||||
&batch.name,
|
||||
id,
|
||||
e.tracked.uuid,
|
||||
kind,
|
||||
);
|
||||
} else {
|
||||
self.native_player_stop_tracking(
|
||||
batch.player,
|
||||
&batch.name,
|
||||
id,
|
||||
e.tracked.uuid,
|
||||
kind,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Who rides what (`Entity.getPassengers`): mobs on mobs, and the
|
||||
/// host in its boat or minecart.
|
||||
pub(super) fn lan_riding(
|
||||
|
||||
@@ -22,8 +22,8 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
pub(super) struct GuestStation {
|
||||
pub(super) window: i32,
|
||||
state: i32,
|
||||
kind: StationKind,
|
||||
pos: [i32; 3],
|
||||
pub(super) kind: StationKind,
|
||||
pub(super) pos: [i32; 3],
|
||||
menu: Box<Menu>,
|
||||
/// The data slots the client was last sent.
|
||||
sent_data: Vec<i32>,
|
||||
@@ -43,7 +43,7 @@ pub(super) struct GuestLectern {
|
||||
|
||||
/// The `MenuType`, the engine's menu and the input slots
|
||||
/// (`clearContainer`'s container) of a station a guest can use.
|
||||
fn menu_of(kind: StationKind) -> Option<(&'static str, Kind, std::ops::Range<usize>)> {
|
||||
pub(super) fn menu_of(kind: StationKind) -> Option<(&'static str, Kind, std::ops::Range<usize>)> {
|
||||
Some(match kind {
|
||||
StationKind::Loom => ("minecraft:loom", Kind::Loom, 0..3),
|
||||
StationKind::Stonecutter => ("minecraft:stonecutter", Kind::Stonecutter, 0..1),
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
|
||||
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
|
||||
use horizon_client::collisions::SceneCollisions;
|
||||
use horizon_client::lan::hub::no_blocks_in;
|
||||
use horizon_client::mobs::equine;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_core::geometry::{Aabb, Axis, Vec3};
|
||||
use horizon_protocol::{
|
||||
packets::play::{self, Clientbound as C},
|
||||
text::translatable,
|
||||
@@ -367,7 +368,31 @@ impl Game<'_> {
|
||||
back(host, old, yaw);
|
||||
return;
|
||||
}
|
||||
host.hub.guests[gi].vehicle_good = Some([target.x, target.y, target.z]);
|
||||
// clientVehicleIsFloating: the move didn't go down a step, the vehicle hadn't come down onto
|
||||
// something on its last move (`verticalCollisionBelow` before this one), and nothing but air is
|
||||
// around its box grown by 0.0625 and stretched 0.55 down (water holds a boat up). A flying or
|
||||
// no-gravity mount, and a server that allows flight, never float.
|
||||
let (gravity, no_gravity) = match mount {
|
||||
Some(i) => (0.08, self.mobs[i].no_gravity),
|
||||
None => (0.04, false),
|
||||
};
|
||||
let around = moved_box
|
||||
.inflate(Vec3::new(0.0625, 0.0625, 0.0625))
|
||||
.expand_towards(Vec3::new(0.0, -0.55, 0.0));
|
||||
let corner = |max: bool| {
|
||||
[Axis::X, Axis::Y, Axis::Z].map(|a| if max { around.max(a) } else { around.min(a) })
|
||||
};
|
||||
let no_blocks = no_blocks_in(&self.world, corner(false), corner(true));
|
||||
let allow_flight = host.hub.config.allow_flight;
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.vehicle_good = Some([target.x, target.y, target.z]);
|
||||
let rested = std::mem::replace(
|
||||
&mut g.vehicle_rests,
|
||||
wanted.y < 0.0 && resolved.y != wanted.y,
|
||||
);
|
||||
g.vehicle_floating =
|
||||
wanted.y >= -0.03125 && !rested && !allow_flight && !no_gravity && no_blocks;
|
||||
g.vehicle_gravity = gravity;
|
||||
if let Some(i) = mount {
|
||||
// absSnapTo: where and how its rider's client turned it; the
|
||||
// rider sits on it from its own ride tick.
|
||||
|
||||
@@ -130,6 +130,9 @@ impl Game<'_> {
|
||||
}
|
||||
// doPlayerRide; mounting drops a lead.
|
||||
let id = self.mobs[index].id;
|
||||
if !self.native_may_board(id) {
|
||||
return true;
|
||||
}
|
||||
self.break_leash(id, true, false);
|
||||
let m = &mut self.mobs[index];
|
||||
m.extra.flag = false;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use super::{Game, storage::is_shulker_box};
|
||||
|
||||
mod crafter;
|
||||
pub(in crate::game) mod native_energy;
|
||||
pub(in crate::game) mod native_fluids;
|
||||
pub(in crate::game) mod native_items;
|
||||
use horizon_client::{
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Energy in a block's buffer for native server mods (docs/native-mods.md):
|
||||
//! the buffer a mod block declared through `native_block_entity_energy`, read
|
||||
//! through a side, filled, drained and moved to another block. Vanilla has no
|
||||
//! energy; the contract is NeoForge's `EnergyHandler` (`SimpleEnergyHandler`
|
||||
//! and `EnergyHandlerUtil.move`). Like the fluid helpers, they act where the
|
||||
//! world is simulated, never on a client's copy, and only on the level in
|
||||
//! place, which the readers name by their `dimension`
|
||||
//! (`minecraft:overworld`), as `native_set_block` does.
|
||||
use super::Game;
|
||||
use horizon_client::containers::ModEnergyBuffer;
|
||||
use horizon_core::geometry::Direction;
|
||||
use horizon_world::nbt::{Compound, JavaString, Tag};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A block's energy as a side reaches it (`native_energy`).
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct NativeEnergy {
|
||||
pub(crate) amount: i32,
|
||||
pub(crate) capacity: i32,
|
||||
/// The most one insert moves.
|
||||
pub(crate) max_insert: i32,
|
||||
/// The most one extract moves.
|
||||
pub(crate) max_extract: i32,
|
||||
/// Whether a fill may come through this side (and moves more than 0).
|
||||
pub(crate) can_insert: bool,
|
||||
/// Whether a drain may leave by this side (and moves more than 0).
|
||||
pub(crate) can_extract: bool,
|
||||
}
|
||||
|
||||
/// The amount in `nbt`: the int `energy`, as `SimpleEnergyHandler` saves it;
|
||||
/// anything else, or a negative, reads as empty.
|
||||
fn read_energy(nbt: &Compound) -> i32 {
|
||||
match nbt.get(&JavaString::from("energy")) {
|
||||
Some(Tag::Int(energy)) => (*energy).max(0),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `SimpleEnergyHandler.insert`: the room left (none when the amount is
|
||||
/// over the capacity), the call's limit and what was offered, whichever is
|
||||
/// least.
|
||||
fn insert_amount(buffer: &ModEnergyBuffer, energy: i32, offered: i32) -> i32 {
|
||||
(buffer.capacity.saturating_sub(energy))
|
||||
.min(offered)
|
||||
.min(buffer.max_insert)
|
||||
.max(0)
|
||||
}
|
||||
|
||||
/// `SimpleEnergyHandler.extract`: what is stored, the call's limit and what
|
||||
/// was asked, whichever is least.
|
||||
fn extract_amount(buffer: &ModEnergyBuffer, energy: i32, asked: i32) -> i32 {
|
||||
energy.min(asked).min(buffer.max_extract).max(0)
|
||||
}
|
||||
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// The buffer of the mod block at `pos` in the level in place,
|
||||
/// `dimension`, and the energy it holds (its block entity's, and not the
|
||||
/// placeholder standing in while it ticks).
|
||||
fn native_energy_at(
|
||||
&self,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
) -> Option<(Arc<ModEnergyBuffer>, i32)> {
|
||||
if !self.native_simulates() || dimension != self.dim.id() {
|
||||
return None;
|
||||
}
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let buffer = self.world.block_entities.mod_energy(state.0)?;
|
||||
// The block entity is out of the world while it ticks.
|
||||
match self.world.block_entities.get(&key) {
|
||||
Some(horizon_client::containers::BlockEntity::Mod(m)) if !m.id.is_empty() => {
|
||||
Some((Arc::clone(buffer), read_energy(&m.nbt)))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Writes `energy` into the block entity at `pos`, saved with its chunk.
|
||||
fn native_energy_store(&mut self, pos: [i32; 3], energy: i32) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
if let Some(horizon_client::containers::BlockEntity::Mod(m)) =
|
||||
self.world.block_entities.get_mut(&key)
|
||||
{
|
||||
m.nbt
|
||||
.insert(JavaString::from("energy"), Tag::Int(energy.max(0)));
|
||||
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
|
||||
}
|
||||
}
|
||||
/// What the buffer at `pos` takes (`insert`) or gives through `face` of
|
||||
/// up to `amount`, written unless `simulate`. Nothing where the side
|
||||
/// doesn't reach the buffer for that.
|
||||
fn native_energy_transfer(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
face: Direction,
|
||||
amount: i32,
|
||||
insert: bool,
|
||||
simulate: bool,
|
||||
) -> i32 {
|
||||
if amount <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let Some((buffer, energy)) = self.native_energy_at(dimension, pos) else {
|
||||
return 0;
|
||||
};
|
||||
let moved = if insert {
|
||||
if !buffer.insert_faces[face as usize] {
|
||||
return 0;
|
||||
}
|
||||
insert_amount(&buffer, energy, amount)
|
||||
} else {
|
||||
if !buffer.extract_faces[face as usize] {
|
||||
return 0;
|
||||
}
|
||||
extract_amount(&buffer, energy, amount)
|
||||
};
|
||||
if moved > 0 && !simulate {
|
||||
let after = if insert {
|
||||
energy.saturating_add(moved)
|
||||
} else {
|
||||
energy - moved
|
||||
};
|
||||
self.native_energy_store(pos, after);
|
||||
}
|
||||
moved
|
||||
}
|
||||
/// The energy buffer of the block at `pos` in the level in place,
|
||||
/// `dimension`, as its side `face` reaches it: what it holds, its
|
||||
/// capacity and its limits per call. None where no buffer is: another
|
||||
/// dimension, a chunk that isn't loaded, a block without one, a side
|
||||
/// that reaches neither a fill nor a drain, or the mod block entity
|
||||
/// ticking now (it's out of the world). `Capabilities.Energy.BLOCK`.
|
||||
pub(in crate::game) fn native_energy(
|
||||
&self,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
face: Direction,
|
||||
) -> Option<NativeEnergy> {
|
||||
let (buffer, amount) = self.native_energy_at(dimension, pos)?;
|
||||
let (fill, drain) = (
|
||||
buffer.insert_faces[face as usize],
|
||||
buffer.extract_faces[face as usize],
|
||||
);
|
||||
(fill || drain).then(|| NativeEnergy {
|
||||
amount,
|
||||
capacity: buffer.capacity,
|
||||
max_insert: buffer.max_insert,
|
||||
max_extract: buffer.max_extract,
|
||||
can_insert: fill && buffer.max_insert > 0,
|
||||
can_extract: drain && buffer.max_extract > 0,
|
||||
})
|
||||
}
|
||||
/// `EnergyHandler.insert`: up to `amount` into the buffer of the block at
|
||||
/// `pos` through its side `face`, in the level in place: no more than
|
||||
/// its room and its limit per call. With `simulate` nothing changes and
|
||||
/// the answer says what would. The amount that went in.
|
||||
pub(in crate::game) fn native_energy_insert(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
face: Direction,
|
||||
amount: i32,
|
||||
simulate: bool,
|
||||
) -> i32 {
|
||||
let dimension = self.dim.id();
|
||||
self.native_energy_transfer(dimension, pos, face, amount, true, simulate)
|
||||
}
|
||||
/// `EnergyHandler.extract`: up to `amount` out of the buffer of the block
|
||||
/// at `pos` in the level in place, `dimension`, through its side `face`:
|
||||
/// no more than it holds and its limit per call. With `simulate` nothing
|
||||
/// changes and the answer says what would. The amount that came out.
|
||||
pub(in crate::game) fn native_energy_extract(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
face: Direction,
|
||||
amount: i32,
|
||||
simulate: bool,
|
||||
) -> i32 {
|
||||
self.native_energy_transfer(dimension, pos, face, amount, false, simulate)
|
||||
}
|
||||
/// `EnergyHandlerUtil.move`: up to `max` from the block at `from`,
|
||||
/// through its side `from_face`, into the one at `to`, through
|
||||
/// `to_face`, in the level in place, `dimension`. It asks what the
|
||||
/// source can give, offers that to the target, and takes from the source
|
||||
/// exactly what the target accepted, so none is lost. The amount moved;
|
||||
/// 0 where either has no buffer for it, and within one block.
|
||||
pub(in crate::game) fn native_move_energy(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
from: [i32; 3],
|
||||
from_face: Direction,
|
||||
to: [i32; 3],
|
||||
to_face: Direction,
|
||||
max: i32,
|
||||
) -> i32 {
|
||||
if from == to || max <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let offered = self.native_energy_transfer(dimension, from, from_face, max, false, true);
|
||||
let accepted = self.native_energy_transfer(dimension, to, to_face, offered, true, true);
|
||||
if accepted == 0 {
|
||||
return 0;
|
||||
}
|
||||
let taken = self.native_energy_transfer(dimension, from, from_face, accepted, false, false);
|
||||
let put = self.native_energy_transfer(dimension, to, to_face, taken, true, false);
|
||||
if put < taken {
|
||||
// Both were just simulated and nothing ran between; a buffer
|
||||
// that answers differently is a bug, and its energy goes back.
|
||||
log::warn!("Horizon: an energy move lost {} energy", taken - put);
|
||||
let _ =
|
||||
self.native_energy_transfer(dimension, from, from_face, taken - put, true, false);
|
||||
}
|
||||
put
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn buffer(capacity: i32, max_insert: i32, max_extract: i32) -> ModEnergyBuffer {
|
||||
ModEnergyBuffer {
|
||||
capacity,
|
||||
max_insert,
|
||||
max_extract,
|
||||
insert_faces: [true; 6],
|
||||
extract_faces: [true; 6],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_insert_stops_at_the_room_the_limit_and_the_offer() {
|
||||
let b = buffer(1000, 100, 50);
|
||||
assert_eq!(insert_amount(&b, 0, 40), 40);
|
||||
assert_eq!(insert_amount(&b, 0, 500), 100);
|
||||
assert_eq!(insert_amount(&b, 950, 500), 50);
|
||||
assert_eq!(insert_amount(&b, 1000, 500), 0);
|
||||
// Over the capacity after a mod update shrank it: nothing goes in.
|
||||
assert_eq!(insert_amount(&b, 1500, 500), 0);
|
||||
assert_eq!(insert_amount(&b, 0, i32::MAX), 100);
|
||||
assert_eq!(
|
||||
insert_amount(&buffer(i32::MAX, i32::MAX, 0), 5, i32::MAX),
|
||||
i32::MAX - 5
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extract_stops_at_what_is_stored_the_limit_and_the_ask() {
|
||||
let b = buffer(1000, 100, 50);
|
||||
assert_eq!(extract_amount(&b, 800, 30), 30);
|
||||
assert_eq!(extract_amount(&b, 800, 500), 50);
|
||||
assert_eq!(extract_amount(&b, 20, 500), 20);
|
||||
assert_eq!(extract_amount(&b, 0, 500), 0);
|
||||
assert_eq!(extract_amount(&buffer(1000, 100, 0), 800, 500), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_amount_reads_as_the_saved_int_and_anything_else_as_empty() {
|
||||
let mut nbt = Compound::new();
|
||||
assert_eq!(read_energy(&nbt), 0);
|
||||
nbt.insert(JavaString::from("energy"), Tag::Int(750));
|
||||
assert_eq!(read_energy(&nbt), 750);
|
||||
nbt.insert(JavaString::from("energy"), Tag::Int(-5));
|
||||
assert_eq!(read_energy(&nbt), 0);
|
||||
nbt.insert(
|
||||
JavaString::from("energy"),
|
||||
Tag::String(JavaString::from("lots")),
|
||||
);
|
||||
assert_eq!(read_energy(&nbt), 0);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,42 @@ pub(super) struct Decoration {
|
||||
pub name: Option<String>,
|
||||
pub key: Key,
|
||||
}
|
||||
/// A map as a native mod sees it when `native_map_decorations` is asked.
|
||||
// Native mods read it; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct NativeMapInfo<'a> {
|
||||
/// The map's id (`map_<id>`).
|
||||
pub id: i32,
|
||||
/// The map's centre block `[x, z]`.
|
||||
pub center: [i32; 2],
|
||||
/// `MapItemSavedData.scale`: a map pixel is `1 << scale` blocks.
|
||||
pub scale: u8,
|
||||
/// The dimension the map shows (`minecraft:overworld`).
|
||||
pub dimension: &'a str,
|
||||
pub locked: bool,
|
||||
/// Whether it is drawn in an item frame, not held or on the cartography screen.
|
||||
pub in_frame: bool,
|
||||
}
|
||||
/// An icon a native mod puts on a map: the same place and pose as vanilla's
|
||||
/// decorations (`MapDecoration`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct NativeMapDecoration {
|
||||
/// An atlas sprite: vanilla's `map/decorations/<name>`, or a content
|
||||
/// mod's texture. An unknown sprite is left out.
|
||||
pub sprite: String,
|
||||
/// The position on the map in half map pixels, -128 (left, top) to 127.
|
||||
/// For the world position `(wx, wz)`: `(wx - center_x) / (1 << scale) * 2`,
|
||||
/// and the same for z, floored and clamped.
|
||||
pub x: i8,
|
||||
pub y: i8,
|
||||
/// 0 to 15, in 22.5 degree steps clockwise (only the low four bits count).
|
||||
pub rot: u8,
|
||||
/// Whether it shows in an item frame and on the cartography screen
|
||||
/// (`MapDecorationType.showOnItemFrame`); a held map shows all.
|
||||
pub shown_on_frame: bool,
|
||||
}
|
||||
|
||||
/// `MapBanner`: a banner a map marks, as `banners` saves it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct MapBanner {
|
||||
@@ -675,11 +711,15 @@ const DECORATION_TYPES: [(&str, &str); 40] = [
|
||||
/// `MapRenderer.render`'s pose for decoration `d` in map pixels (0..128,
|
||||
/// y down) at depth `z`: its quad is (-1, -1) to (1, 1) under it.
|
||||
pub(super) fn decoration_pose(d: &Decoration, z: f32) -> glam::Mat4 {
|
||||
decoration_pose_at(d.x, d.y, d.rot, z)
|
||||
}
|
||||
/// [`decoration_pose`] for a decoration at `x`, `y` (half map pixels) turned `rot`.
|
||||
pub(super) fn decoration_pose_at(x: i8, y: i8, rot: u8, z: f32) -> glam::Mat4 {
|
||||
glam::Mat4::from_translation(glam::Vec3::new(
|
||||
f32::from(d.x) / 2.0 + 64.0,
|
||||
f32::from(d.y) / 2.0 + 64.0,
|
||||
f32::from(x) / 2.0 + 64.0,
|
||||
f32::from(y) / 2.0 + 64.0,
|
||||
z,
|
||||
)) * glam::Mat4::from_rotation_z((f32::from(d.rot) * 360.0 / 16.0).to_radians())
|
||||
)) * glam::Mat4::from_rotation_z((f32::from(rot) * 360.0 / 16.0).to_radians())
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(4.0))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(-0.125, 0.125, 0.0))
|
||||
}
|
||||
@@ -1736,6 +1776,54 @@ impl Game<'_> {
|
||||
);
|
||||
count += 1;
|
||||
}
|
||||
// A native mod's own icons, over the vanilla ones.
|
||||
for d in self.mod_map_decorations(id, show_only_frame) {
|
||||
let Some(s) = self.gui.atlas.sprites.get(d.sprite.as_str()).copied() else {
|
||||
continue;
|
||||
};
|
||||
let dm = decoration_pose_at(d.x, d.y, d.rot, -0.02 - count as f32 * 0.001);
|
||||
let p = |x: f32, y: f32| dm.transform_point3(glam::Vec3::new(x, y, 0.0)).to_array();
|
||||
let corners = [p(-1.0, 1.0), p(1.0, 1.0), p(1.0, -1.0), p(-1.0, -1.0)];
|
||||
let uvs = [[s.u0, s.v0], [s.u1, s.v0], [s.u1, s.v1], [s.u0, s.v1]];
|
||||
for (c, uv) in corners.into_iter().zip(uvs) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: m.transform_point3(glam::Vec3::from_array(c)).to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
/// The icons `native_map_decorations` adds to map `id`, as it is drawn
|
||||
/// (`frame`: in an item frame or on a screen, which show only what
|
||||
/// `shown_on_frame` allows).
|
||||
pub(super) fn mod_map_decorations(&self, id: i32, frame: bool) -> Vec<NativeMapDecoration> {
|
||||
let Some(map) = self.maps.data.get(&id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let info = NativeMapInfo {
|
||||
id,
|
||||
center: map.center,
|
||||
scale: map.scale,
|
||||
dimension: &map.dimension,
|
||||
locked: map.locked,
|
||||
in_frame: frame,
|
||||
};
|
||||
let mut decorations = Vec::new();
|
||||
self.native_map_decorations(&info, &mut decorations);
|
||||
decorations
|
||||
.into_iter()
|
||||
.filter(|d| !frame || d.shown_on_frame)
|
||||
.map(|d| NativeMapDecoration {
|
||||
rot: d.rot & 15,
|
||||
..d
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `renderTwoHandedMap`: map `id` held in both hands, in view space (the
|
||||
/// arms under it: `first_person::map_arm_poses`).
|
||||
@@ -1803,6 +1891,28 @@ impl Game<'_> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_mod_icon_sits_where_a_vanilla_decoration_would() {
|
||||
// Half pixels from the map's centre: x 20 is 10 map pixels right of it.
|
||||
let vanilla = Decoration {
|
||||
sprite: "map/decorations/frame",
|
||||
x: 20,
|
||||
y: -30,
|
||||
rot: 4,
|
||||
name: None,
|
||||
key: Key::Item,
|
||||
};
|
||||
assert_eq!(
|
||||
decoration_pose(&vanilla, -0.02),
|
||||
decoration_pose_at(20, -30, 4, -0.02)
|
||||
);
|
||||
let at = decoration_pose_at(20, -30, 0, 0.0).transform_point3(glam::Vec3::ZERO);
|
||||
assert!(
|
||||
(at.x - 74.0).abs() < 0.6 && (at.y - 49.0).abs() < 0.6,
|
||||
"{at:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_target_decoration_type_has_its_sprite() {
|
||||
// MapDecorationTypes: (name, asset).
|
||||
|
||||
@@ -1292,6 +1292,7 @@ impl Game<'_> {
|
||||
self.push_carts();
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
let ticking = self.entity_ticking_area();
|
||||
let mut round = self.native_tick_round();
|
||||
// A server player's minecart moves with its rider (lan_vehicles.rs).
|
||||
let ridden = self.guest_vehicles();
|
||||
// By id: an exploding cart can destroy or prime others mid-loop.
|
||||
@@ -1307,12 +1308,20 @@ impl Game<'_> {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let kind = self.carts[i].kind.id();
|
||||
if !self.native_tick_pre_one(&mut round, id, kind) {
|
||||
continue;
|
||||
}
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let mut c = self.carts[i].clone();
|
||||
let alive = self.tick_cart(&mut c, [0.0; 2]);
|
||||
if c.kind == CartKind::Hopper && c.enabled {
|
||||
self.hopper_cart_suck(&mut c);
|
||||
}
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
self.native_tick_post_one(&round, id, kind);
|
||||
continue;
|
||||
};
|
||||
if alive {
|
||||
@@ -1327,9 +1336,14 @@ impl Game<'_> {
|
||||
self.release_vehicle_container(id);
|
||||
self.carts.remove(i);
|
||||
}
|
||||
// After the ticked cart is stored: a mod reads and changes the cart as it is now.
|
||||
self.native_tick_post_one(&round, id, kind);
|
||||
}
|
||||
// On the server for every cart, the ridden ones too.
|
||||
for id in ids {
|
||||
if round.canceled(id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
let p = self.carts[i].position;
|
||||
if ticking(p.x.floor() as i32, p.z.floor() as i32) {
|
||||
@@ -1444,6 +1458,10 @@ impl Game<'_> {
|
||||
}
|
||||
/// `getDismountLocationForPassenger`: beside the cart (across its facing), else on top.
|
||||
pub(super) fn dismount_cart(&mut self, i: usize) {
|
||||
let id = self.carts[i].id;
|
||||
if self.riding == Some(id) && !self.native_may_leave(id) {
|
||||
return;
|
||||
}
|
||||
self.riding = None;
|
||||
let c = &self.carts[i];
|
||||
let (bx, by, bz) = (
|
||||
@@ -1603,7 +1621,10 @@ impl Game<'_> {
|
||||
if sneaking || self.riding.is_some() || self.ridden_by_guest(id) || has_mob {
|
||||
return false;
|
||||
}
|
||||
self.riding = Some(self.carts[i].id);
|
||||
if !self.native_may_board(id) {
|
||||
return true;
|
||||
}
|
||||
self.riding = Some(id);
|
||||
}
|
||||
// AbstractMinecartContainer.interact: no sneak check.
|
||||
CartKind::Chest | CartKind::Hopper => self.open_cart_container(i),
|
||||
@@ -1847,7 +1868,21 @@ impl Game<'_> {
|
||||
let Ok(models) = self.pack.render.model_catalog.collect_seeded(state, seed) else {
|
||||
return;
|
||||
};
|
||||
for (_, model) in &models {
|
||||
let models = models.into_iter().map(|(_, model)| model);
|
||||
self.baked_model_vertices(models, m, light, diffuse, tint, out);
|
||||
}
|
||||
/// `tinted_block_model_vertices` for baked models given as they are (a
|
||||
/// state's, or a standalone model).
|
||||
pub(super) fn baked_model_vertices<'m>(
|
||||
&self,
|
||||
models: impl IntoIterator<Item = &'m horizon_client::models::catalog::BakedModel>,
|
||||
m: glam::Mat4,
|
||||
light: impl Fn(&horizon_assets::bake::ModelQuad) -> u32,
|
||||
diffuse: bool,
|
||||
tint: impl Fn(i32) -> Option<u32>,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
for model in models {
|
||||
for face in &model.faces {
|
||||
let Some(s) = self
|
||||
.pack
|
||||
|
||||
@@ -37,6 +37,32 @@ pub(super) fn register_components(root: &std::path::Path) -> horizon_client::Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The blocks and items native mods declare in code (`native_blocks`,
|
||||
/// `native_items`): asked once, so every content root of this run, a pack
|
||||
/// reload's included, has the same ones.
|
||||
fn native_declared() -> &'static (
|
||||
Vec<horizon_mods::content::Block>,
|
||||
Vec<horizon_mods::content::Item>,
|
||||
) {
|
||||
static DECLARED: std::sync::OnceLock<(
|
||||
Vec<horizon_mods::content::Block>,
|
||||
Vec<horizon_mods::content::Item>,
|
||||
)> = std::sync::OnceLock::new();
|
||||
DECLARED.get_or_init(|| {
|
||||
let (mut blocks, mut items) = (Vec::new(), Vec::new());
|
||||
Game::native_blocks(&mut blocks);
|
||||
Game::native_items(&mut items);
|
||||
(blocks, items)
|
||||
})
|
||||
}
|
||||
/// The native mods' standalone models (`native_standalone_models`), set once
|
||||
/// before the resources first load.
|
||||
pub(super) fn register_standalone_models() {
|
||||
let mut names = Vec::new();
|
||||
Game::native_standalone_models(&mut names);
|
||||
horizon_client::models::register_standalone(names);
|
||||
}
|
||||
|
||||
/// `root` with the content mods that are on layered over it
|
||||
/// (docs/modpacks.md): their blocks, items, data and assets. Read once at
|
||||
/// start, before the resources load; a mod whose content is refused is left
|
||||
@@ -92,6 +118,20 @@ pub(super) fn content_root(
|
||||
log::warn!("Horizon: mod {} content not loaded: {e}", m.id);
|
||||
}
|
||||
}
|
||||
let (blocks, items) = native_declared();
|
||||
if !blocks.is_empty() || !items.is_empty() {
|
||||
if known.is_none() {
|
||||
match mod_content::known_components(root) {
|
||||
Ok(types) => known = Some(types),
|
||||
Err(e) => log::warn!("Horizon: native mods' blocks and items not loaded: {e}"),
|
||||
}
|
||||
}
|
||||
if let Some(known) = &known {
|
||||
for e in mod_content::accept_declared(&mut mods, blocks, items, known) {
|
||||
log::warn!("Horizon: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
match mod_content::resource_root(root, &mods, &here.join(".horizon-cache")) {
|
||||
Ok(out) => {
|
||||
if out != root {
|
||||
|
||||
@@ -541,16 +541,19 @@ impl Game<'_> {
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let mut verts = Vec::new();
|
||||
horizon_client::mobs::render::render_with(
|
||||
&m,
|
||||
1.0,
|
||||
camera,
|
||||
&lookup,
|
||||
240 | 240 << 8,
|
||||
self.res.mob_data(),
|
||||
&[],
|
||||
&mut verts,
|
||||
);
|
||||
// A screen preview: custom entity model animations see is_in_gui = 1.
|
||||
horizon_client::mobs::cem_anim::in_gui(|| {
|
||||
horizon_client::mobs::render::render_with(
|
||||
&m,
|
||||
1.0,
|
||||
camera,
|
||||
&lookup,
|
||||
240 | 240 << 8,
|
||||
self.res.mob_data(),
|
||||
&[],
|
||||
&mut verts,
|
||||
);
|
||||
});
|
||||
let height = m.kind.height as f32;
|
||||
let gui = glam::Mat4::from_translation(glam::Vec3::new(cx, cy, 0.0))
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(size, size, -size))
|
||||
|
||||
@@ -86,7 +86,11 @@ impl Game<'_> {
|
||||
if !m.extra.saddled || food || self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft) {
|
||||
return false;
|
||||
}
|
||||
self.riding = Some(m.id);
|
||||
let id = m.id;
|
||||
if !self.native_may_board(id) {
|
||||
return true;
|
||||
}
|
||||
self.riding = Some(id);
|
||||
self.trigger_player("minecraft:started_riding");
|
||||
true
|
||||
}
|
||||
@@ -243,6 +247,10 @@ impl Game<'_> {
|
||||
return true;
|
||||
}
|
||||
// doPlayerRide
|
||||
let id = self.mobs[index].id;
|
||||
if !self.native_may_board(id) {
|
||||
return true;
|
||||
}
|
||||
let m = &mut self.mobs[index];
|
||||
m.extra.flag = false;
|
||||
m.extra.counter = 0;
|
||||
@@ -480,9 +488,13 @@ impl Game<'_> {
|
||||
}
|
||||
/// `removePassenger` + `getDismountLocationForPassenger`: beside the mount.
|
||||
pub(super) fn dismount_mount(&mut self) {
|
||||
let Some(id) = self.riding.take() else {
|
||||
let Some(id) = self.riding else {
|
||||
return;
|
||||
};
|
||||
if !self.native_may_leave(id) {
|
||||
return;
|
||||
}
|
||||
self.riding = None;
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
@@ -640,38 +652,37 @@ impl Game<'_> {
|
||||
pub(super) fn mount_jumping(&self) -> bool {
|
||||
self.mount.jump_scale > 0.0 || self.mount.jump_ticks < 0
|
||||
}
|
||||
/// `Hud.extractVehicleHealth`, and the jump bar when it is the
|
||||
/// contextual bar (`jump_bar`). Returns the mount's heart count
|
||||
/// `Hud.extractVehicleHealth` (a native mod's `minecraft:vehicle_health`
|
||||
/// layer, only while riding a mount). Returns the mount's heart count
|
||||
/// (`getVehicleMaxHearts`).
|
||||
pub(super) fn draw_mount_hud(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
w: i32,
|
||||
h: i32,
|
||||
jump_bar: bool,
|
||||
) -> i32 {
|
||||
pub(super) fn draw_mount_hud(&mut self, batch: &mut HudBatch, w: i32, h: i32) -> i32 {
|
||||
let Some(i) = self.mount_index() else {
|
||||
return 0;
|
||||
};
|
||||
let m = &self.mobs[i];
|
||||
let atlas = &self.gui.atlas;
|
||||
let hearts = horizon_client::hud_layout::vehicle_max_hearts(m.attrs.max_health);
|
||||
let draws = horizon_client::hud_layout::vehicle_health(w, h, hearts, m.health);
|
||||
horizon_client::hud_layout::paint(batch, atlas, &draws);
|
||||
if jump_bar {
|
||||
// JumpableVehicleBar: `getJumpCooldown`, which only the camel's and
|
||||
// the nautilus' dash have; a horse always shows the progress.
|
||||
let cooldown = i32::from(
|
||||
self.mount.jump_ticks < 0
|
||||
&& (matches!(m.species(), Species::Camel | Species::CamelHusk)
|
||||
|| horizon_client::mobs::nautilus::is_nautilus(m.species())),
|
||||
);
|
||||
let draws =
|
||||
horizon_client::hud_overlays::jump_bar(w, h, cooldown, self.mount.jump_scale);
|
||||
horizon_client::hud_layout::paint(batch, atlas, &draws);
|
||||
}
|
||||
self.gui_layer("minecraft:vehicle_health", batch, |g, batch| {
|
||||
horizon_client::hud_layout::paint(batch, &g.gui.atlas, &draws);
|
||||
});
|
||||
hearts
|
||||
}
|
||||
/// The jump bar when it is the contextual bar.
|
||||
pub(super) fn draw_jump_bar(&self, batch: &mut HudBatch, w: i32, h: i32) {
|
||||
let Some(i) = self.mount_index() else {
|
||||
return;
|
||||
};
|
||||
let m = &self.mobs[i];
|
||||
// JumpableVehicleBar: `getJumpCooldown`, which only the camel's and
|
||||
// the nautilus' dash have; a horse always shows the progress.
|
||||
let cooldown = i32::from(
|
||||
self.mount.jump_ticks < 0
|
||||
&& (matches!(m.species(), Species::Camel | Species::CamelHusk)
|
||||
|| horizon_client::mobs::nautilus::is_nautilus(m.species())),
|
||||
);
|
||||
let draws = horizon_client::hud_overlays::jump_bar(w, h, cooldown, self.mount.jump_scale);
|
||||
horizon_client::hud_layout::paint(batch, &self.gui.atlas, &draws);
|
||||
}
|
||||
}
|
||||
|
||||
/// The mount a rider gets off: its kind, where it stands, its box,
|
||||
|
||||
@@ -12,7 +12,7 @@ use horizon_assets::{
|
||||
};
|
||||
use horizon_client::{
|
||||
block_entity_render::{diffuse, diffuse_lights, vertex_light},
|
||||
containers::{BlockEntity, ModBlockEntity},
|
||||
containers::{BlockEntity, ModRevision},
|
||||
fast_hash::FxHashMap,
|
||||
gfx::Cull,
|
||||
hud::Rect,
|
||||
@@ -201,7 +201,8 @@ struct KeptKey {
|
||||
|
||||
struct KeptEntry {
|
||||
key: KeptKey,
|
||||
entity: ModBlockEntity,
|
||||
/// Of the block entity it was drawn from: equal, nothing changed it.
|
||||
revision: ModRevision,
|
||||
bounds: [f32; 6],
|
||||
/// Game time it was last drawn.
|
||||
used: i64,
|
||||
@@ -228,19 +229,19 @@ impl KeptGeometry {
|
||||
.retain(|_, e| (now - KEPT_FOR..=now).contains(&e.used));
|
||||
}
|
||||
}
|
||||
/// What is kept at `pos` when it was drawn with `key` and `entity`,
|
||||
/// marked drawn at `now`.
|
||||
/// What is kept at `pos` when it was drawn with `key` and its block
|
||||
/// entity at `revision`, marked drawn at `now`.
|
||||
fn get(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
key: KeptKey,
|
||||
entity: &ModBlockEntity,
|
||||
revision: ModRevision,
|
||||
now: i64,
|
||||
) -> Option<&KeptEntry> {
|
||||
let entry = self
|
||||
.entries
|
||||
.get_mut(&pos)
|
||||
.filter(|e| e.key == key && e.entity == *entity)?;
|
||||
.filter(|e| e.key == key && e.revision == revision)?;
|
||||
entry.used = now;
|
||||
Some(entry)
|
||||
}
|
||||
@@ -248,7 +249,7 @@ impl KeptGeometry {
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
key: KeptKey,
|
||||
entity: &ModBlockEntity,
|
||||
revision: ModRevision,
|
||||
bounds: [f32; 6],
|
||||
vertices: &[Vertex],
|
||||
now: i64,
|
||||
@@ -263,7 +264,7 @@ impl KeptGeometry {
|
||||
pos,
|
||||
KeptEntry {
|
||||
key,
|
||||
entity: entity.clone(),
|
||||
revision,
|
||||
bounds,
|
||||
used: now,
|
||||
vertices: kept,
|
||||
@@ -336,7 +337,7 @@ impl Game<'_> {
|
||||
state,
|
||||
light,
|
||||
};
|
||||
if let Some(entry) = kept.get(pos, key, entity, now) {
|
||||
if let Some(entry) = kept.get(pos, key, entity.revision(), now) {
|
||||
if in_frustum(self.view_frustum.as_ref(), origin, entry.bounds) {
|
||||
let start = draw.vertices.len();
|
||||
self.mark_block_entity(start, state);
|
||||
@@ -366,7 +367,14 @@ impl Game<'_> {
|
||||
draw.kept = false;
|
||||
self.native_block_entity_submit(pos, block, id, entity, &mut draw);
|
||||
if draw.kept {
|
||||
kept.keep(pos, key, entity, bounds, &draw.vertices[start..], now);
|
||||
kept.keep(
|
||||
pos,
|
||||
key,
|
||||
entity.revision(),
|
||||
bounds,
|
||||
&draw.vertices[start..],
|
||||
now,
|
||||
);
|
||||
} else {
|
||||
kept.forget(pos);
|
||||
}
|
||||
@@ -447,8 +455,8 @@ impl Game<'_> {
|
||||
/// the state with `properties` (`[("axis", "x")]`, the rest default)
|
||||
/// under `draw`'s pose, as vanilla's `submitBlockModel` draws a block in
|
||||
/// an entity's place: a block's baked model, with its default tints. A
|
||||
/// moving part is often a block of the mod's own, never placed. False for
|
||||
/// an unknown block or property. API 71.
|
||||
/// moving part that is no block is a standalone model
|
||||
/// (`native_draw_model`). False for an unknown block or property. API 71.
|
||||
pub(super) fn native_draw_block(
|
||||
&self,
|
||||
draw: &mut BlockEntityDraw,
|
||||
@@ -477,6 +485,30 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
|
||||
/// Draws standalone model `model` (`mymod:block/shaft_half`, one a mod
|
||||
/// named in `native_standalone_models`) under `draw`'s pose, as
|
||||
/// `native_draw_block` draws a block's: unturned, from the block's lower
|
||||
/// north-west corner, lit by `draw.light`. Its faces with a tint index
|
||||
/// are multiplied by the 0xRRGGBB `tint` (0xFFFFFF for none), the
|
||||
/// others drawn as they are. False when the model isn't baked: not
|
||||
/// named, missing from the resources, or on a side that draws nothing.
|
||||
/// API @API:standalone_models@.
|
||||
pub(super) fn native_draw_model(
|
||||
&self,
|
||||
draw: &mut BlockEntityDraw,
|
||||
model: &str,
|
||||
tint: u32,
|
||||
) -> bool {
|
||||
let Some(baked) = self.pack.render.model_catalog.standalone(model) else {
|
||||
return false;
|
||||
};
|
||||
let pose = draw.pose();
|
||||
let light = draw.light;
|
||||
let tint = |index: i32| (index >= 0).then_some(tint & 0xFF_FFFF);
|
||||
self.baked_model_vertices([baked], pose, |_| light, true, tint, &mut draw.vertices);
|
||||
true
|
||||
}
|
||||
|
||||
/// Draws `stack` under `draw`'s pose as an item is drawn in the world
|
||||
/// (`ItemModelResolver.updateForTopItem` and `ItemStackRenderState.submit`), centred on the
|
||||
/// pose's origin, in its model's `display` transform: `Fixed` as a
|
||||
@@ -532,6 +564,7 @@ impl Game<'_> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::containers::{ModBlockEntity, ModBlockEntityFields};
|
||||
|
||||
fn draw() -> BlockEntityDraw {
|
||||
BlockEntityDraw {
|
||||
@@ -628,12 +661,12 @@ mod tests {
|
||||
assert_eq!(shaded(0x10_20_30, 1.0), 0xFF_30_20_10);
|
||||
}
|
||||
|
||||
fn machine(progress: i32) -> ModBlockEntity {
|
||||
ModBlockEntity {
|
||||
fn machine() -> ModRevision {
|
||||
ModBlockEntity::new(ModBlockEntityFields {
|
||||
id: "mymod:machine".into(),
|
||||
data: vec![progress],
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.revision()
|
||||
}
|
||||
|
||||
fn vertex(x: f32) -> Vertex {
|
||||
@@ -653,14 +686,21 @@ mod tests {
|
||||
fn kept_geometry_is_drawn_again_until_what_it_was_drawn_with_changes() {
|
||||
let mut kept = KeptGeometry::default();
|
||||
let pos = [1, 2, 3];
|
||||
kept.keep(pos, KEY, &machine(0), DEFAULT_BOUNDS, &[vertex(0.5)], 0);
|
||||
let entry = kept.get(pos, KEY, &machine(0), 1).expect("kept");
|
||||
let mut entity = ModBlockEntity::new(ModBlockEntityFields {
|
||||
id: "mymod:machine".into(),
|
||||
data: vec![0],
|
||||
..Default::default()
|
||||
});
|
||||
let drawn = entity.revision();
|
||||
kept.keep(pos, KEY, drawn, DEFAULT_BOUNDS, &[vertex(0.5)], 0);
|
||||
let entry = kept.get(pos, KEY, entity.revision(), 1).expect("kept");
|
||||
assert_eq!(entry.vertices.len(), 1);
|
||||
assert_eq!(entry.vertices[0].pos, [0.5, 0.0, 0.0]);
|
||||
assert_eq!(entry.bounds, DEFAULT_BOUNDS);
|
||||
// New data from the server, another state, light or dimension, or
|
||||
// another position: none of them is the kept geometry.
|
||||
assert!(kept.get(pos, KEY, &machine(1), 1).is_none());
|
||||
entity.data[0] = 1;
|
||||
assert!(kept.get(pos, KEY, entity.revision(), 1).is_none());
|
||||
for changed in [
|
||||
KeptKey { state: 8, ..KEY },
|
||||
KeptKey {
|
||||
@@ -672,36 +712,53 @@ mod tests {
|
||||
..KEY
|
||||
},
|
||||
] {
|
||||
assert!(kept.get(pos, changed, &machine(0), 1).is_none());
|
||||
assert!(kept.get(pos, changed, drawn, 1).is_none());
|
||||
}
|
||||
assert!(kept.get([1, 2, 4], KEY, &machine(0), 1).is_none());
|
||||
assert!(kept.get([1, 2, 4], KEY, drawn, 1).is_none());
|
||||
// Another block entity in its place, as the old one was: not it.
|
||||
let replaced = ModBlockEntity::new(ModBlockEntityFields {
|
||||
id: "mymod:machine".into(),
|
||||
data: vec![0],
|
||||
..Default::default()
|
||||
});
|
||||
assert!(kept.get(pos, KEY, replaced.revision(), 1).is_none());
|
||||
// Drawn again and kept with the new data, it replaces the old.
|
||||
kept.keep(pos, KEY, &machine(1), DEFAULT_BOUNDS, &[vertex(0.25)], 2);
|
||||
assert!(kept.get(pos, KEY, &machine(0), 2).is_none());
|
||||
let entry = kept.get(pos, KEY, &machine(1), 2).expect("kept again");
|
||||
kept.keep(
|
||||
pos,
|
||||
KEY,
|
||||
entity.revision(),
|
||||
DEFAULT_BOUNDS,
|
||||
&[vertex(0.25)],
|
||||
2,
|
||||
);
|
||||
assert!(kept.get(pos, KEY, drawn, 2).is_none());
|
||||
let entry = kept
|
||||
.get(pos, KEY, entity.revision(), 2)
|
||||
.expect("kept again");
|
||||
assert_eq!(entry.vertices[0].pos, [0.25, 0.0, 0.0]);
|
||||
// Drawn without keeping: forgotten.
|
||||
kept.forget(pos);
|
||||
assert!(kept.get(pos, KEY, &machine(1), 3).is_none());
|
||||
assert!(kept.get(pos, KEY, entity.revision(), 3).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_geometry_out_of_view_for_its_ticks_is_swept() {
|
||||
let mut kept = KeptGeometry::default();
|
||||
let (seen, gone) = ([0, 0, 0], [5, 0, 0]);
|
||||
kept.keep(seen, KEY, &machine(0), DEFAULT_BOUNDS, &[vertex(0.0)], 0);
|
||||
kept.keep(gone, KEY, &machine(0), DEFAULT_BOUNDS, &[vertex(0.0)], 0);
|
||||
kept.get(gone, KEY, &machine(0), 50);
|
||||
let m = machine();
|
||||
kept.keep(seen, KEY, m, DEFAULT_BOUNDS, &[vertex(0.0)], 0);
|
||||
kept.keep(gone, KEY, m, DEFAULT_BOUNDS, &[vertex(0.0)], 0);
|
||||
kept.get(gone, KEY, m, 50);
|
||||
// A sweep at 100 keeps both: each was drawn in the last 100 ticks.
|
||||
kept.sweep(100);
|
||||
assert!(kept.entries.contains_key(&gone));
|
||||
assert!(kept.entries.contains_key(&seen));
|
||||
kept.get(seen, KEY, &machine(0), 150);
|
||||
kept.get(seen, KEY, m, 150);
|
||||
// At 199 no sweep is due yet; at 200 the one last drawn at 50 goes.
|
||||
kept.sweep(199);
|
||||
assert!(kept.entries.contains_key(&gone));
|
||||
kept.sweep(200);
|
||||
assert!(!kept.entries.contains_key(&gone));
|
||||
assert!(kept.get(seen, KEY, &machine(0), 200).is_some());
|
||||
assert!(kept.get(seen, KEY, m, 200).is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
//! Data maps for native server mods (docs/native-mods.md): values a data pack
|
||||
//! attaches to the entries of a registry, under the types a mod registered
|
||||
//! (`native_data_map_types`). Vanilla has no data maps; the contract is
|
||||
//! NeoForge's `DataMapType`: files at
|
||||
//! `data/<map namespace>/data_maps/<registry path>/<map path>.json` hold
|
||||
//! `replace`, `values` (entry ids and `#tags` to values) and `remove`, and a
|
||||
//! newer value overrides an older one for the same entry. The files are the
|
||||
//! world's data packs' and what mods contribute through
|
||||
//! `native_data_pack_files` (key `data_maps/<ns>:<registry path>/<map path>`),
|
||||
//! merged each time the server's tables are built: a world's first open and
|
||||
//! every `/reload`.
|
||||
use super::Game;
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
/// A data map type a mod registers (`RegisterDataMapTypesEvent`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct NativeDataMapType {
|
||||
/// `mod_id:name`: not in the `minecraft` namespace. Its files are
|
||||
/// `data/<mod_id>/data_maps/<registry path>/<name>.json`.
|
||||
pub(crate) id: String,
|
||||
/// The registry it is for: `minecraft:item` or `minecraft:block`.
|
||||
pub(crate) registry: String,
|
||||
}
|
||||
|
||||
/// The registries data maps can be attached to so far, with their folder
|
||||
/// (the registry's path: `minecraft` registries have no namespace folder).
|
||||
const REGISTRIES: [(&str, &str); 2] = [("minecraft:item", "item"), ("minecraft:block", "block")];
|
||||
|
||||
/// The merged values of every registered type: `(registry, type id)` to the
|
||||
/// value of each entry that has one.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct DataMaps {
|
||||
maps: HashMap<(String, String), BTreeMap<String, Value>>,
|
||||
}
|
||||
|
||||
/// `minecraft:` for an id written without a namespace.
|
||||
fn full_id(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
|
||||
/// A file's value for an entry: the data itself, or the wrapper
|
||||
/// `{"value": ..., "replace": false}` (`DataMapEntry`'s codec). `replace` only
|
||||
/// matters to mergers, which types don't have yet: a newer value overrides.
|
||||
fn entry_value(raw: &Value) -> Option<Value> {
|
||||
if let Value::Object(map) = raw
|
||||
&& let Some(value) = map.get("value")
|
||||
&& map
|
||||
.iter()
|
||||
.all(|(k, v)| k == "value" || (k == "replace" && v.is_boolean()))
|
||||
{
|
||||
return Some(value.clone());
|
||||
}
|
||||
(!raw.is_null()).then(|| raw.clone())
|
||||
}
|
||||
|
||||
/// One file over what earlier files made of the type: `replace` clears it,
|
||||
/// `values` attach (a tag to every entry in it), then `remove` takes entries
|
||||
/// away, as `DataMapFile` is applied. False when the file isn't a data map.
|
||||
fn apply_file(
|
||||
map: &mut BTreeMap<String, Value>,
|
||||
file: &Value,
|
||||
tags: &HashMap<String, HashSet<String>>,
|
||||
) -> bool {
|
||||
let (Some(root), Some(values)) = (file.as_object(), file.get("values")) else {
|
||||
return false;
|
||||
};
|
||||
let Some(values) = values.as_object() else {
|
||||
return false;
|
||||
};
|
||||
if root.get("replace").and_then(Value::as_bool) == Some(true) {
|
||||
map.clear();
|
||||
}
|
||||
// The members of a tag or the entry itself.
|
||||
let members = |key: &str| -> Vec<String> {
|
||||
match key.strip_prefix('#') {
|
||||
Some(tag) => tags
|
||||
.get(&full_id(tag))
|
||||
.map(|set| set.iter().cloned().collect())
|
||||
.unwrap_or_default(),
|
||||
None => vec![full_id(key)],
|
||||
}
|
||||
};
|
||||
for (key, raw) in values {
|
||||
let Some(value) = entry_value(raw) else {
|
||||
continue;
|
||||
};
|
||||
for entry in members(key) {
|
||||
map.insert(entry, value.clone());
|
||||
}
|
||||
}
|
||||
for removal in root
|
||||
.get("remove")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
// An entry or `{"key": entry}`, as `DataMapEntry.Removal` reads it.
|
||||
let key = removal.as_str().or_else(|| removal["key"].as_str());
|
||||
for entry in key.map(members).unwrap_or_default() {
|
||||
map.remove(&entry);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
impl DataMaps {
|
||||
/// The types' values from `files` (the server's tables' pack files, in
|
||||
/// pack order, mods' below the packs'), tags resolved against the
|
||||
/// loaded `item_tags` and `block_tags`. A type with an unsupported
|
||||
/// registry or a bad id is left out with a warning; a file that isn't a
|
||||
/// data map is skipped alone.
|
||||
pub(crate) fn load(
|
||||
types: &[NativeDataMapType],
|
||||
files: &[(String, Vec<Vec<u8>>)],
|
||||
item_tags: &HashMap<String, HashSet<String>>,
|
||||
block_tags: &HashMap<String, HashSet<String>>,
|
||||
) -> Self {
|
||||
let mut maps: HashMap<(String, String), BTreeMap<String, Value>> = HashMap::new();
|
||||
for ty in types {
|
||||
let Some((_, dir)) = REGISTRIES.iter().find(|(r, _)| *r == ty.registry) else {
|
||||
log::warn!(
|
||||
"Horizon: data map {} is for {}, which has no data maps yet: left out",
|
||||
ty.id,
|
||||
ty.registry
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let valid = |s: &str| {
|
||||
!s.is_empty()
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "_-./".contains(c))
|
||||
};
|
||||
let ok = ty
|
||||
.id
|
||||
.split_once(':')
|
||||
.is_some_and(|(ns, path)| ns != "minecraft" && valid(ns) && valid(path));
|
||||
if !ok {
|
||||
log::warn!(
|
||||
"Horizon: data map type {} is not a mod's id: left out",
|
||||
ty.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let key = (ty.registry.clone(), ty.id.clone());
|
||||
if maps.contains_key(&key) {
|
||||
log::warn!(
|
||||
"Horizon: data map {} registered twice: the first is kept",
|
||||
ty.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let tags = if *dir == "item" {
|
||||
item_tags
|
||||
} else {
|
||||
block_tags
|
||||
};
|
||||
let (ns, path) = ty.id.split_once(':').unwrap_or_default();
|
||||
let wanted = format!("data_maps/{ns}:{dir}/{path}");
|
||||
let mut map = BTreeMap::new();
|
||||
if let Some((_, versions)) = files.iter().find(|(k, _)| *k == wanted) {
|
||||
for bytes in versions {
|
||||
let applied = serde_json::from_slice::<Value>(bytes)
|
||||
.ok()
|
||||
.is_some_and(|file| apply_file(&mut map, &file, tags));
|
||||
if !applied {
|
||||
log::warn!(
|
||||
"Horizon: a data map file of {} is not a data map, skipped",
|
||||
ty.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
maps.insert(key, map);
|
||||
}
|
||||
Self { maps }
|
||||
}
|
||||
}
|
||||
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// The merged data map of `type_id` for `registry`, none unless this is a
|
||||
/// server whose tables were built from packs or mods' files.
|
||||
fn native_data_map_of(
|
||||
&self,
|
||||
registry: &str,
|
||||
type_id: &str,
|
||||
) -> Option<&BTreeMap<String, Value>> {
|
||||
if self.serving.is_none() && self.options.dedicated.is_none() {
|
||||
return None;
|
||||
}
|
||||
let key = (registry.to_owned(), type_id.to_owned());
|
||||
self.world_tables.as_ref()?.data_maps.maps.get(&key)
|
||||
}
|
||||
/// `Holder.getData(type)`: the value the data pack and mod files gave
|
||||
/// `entry` (`minecraft:coal`) in data map `type_id` for `registry`
|
||||
/// (`minecraft:item`, `minecraft:block`), as the JSON the file held. None
|
||||
/// when the entry has none, the type isn't registered for that registry
|
||||
/// (`native_data_map_types`), or this isn't a server whose tables hold
|
||||
/// files.
|
||||
pub(in crate::game) fn native_data_map(
|
||||
&self,
|
||||
registry: &str,
|
||||
type_id: &str,
|
||||
entry: &str,
|
||||
) -> Option<&Value> {
|
||||
self.native_data_map_of(registry, type_id)?
|
||||
.get(&full_id(entry))
|
||||
}
|
||||
/// Every entry that has a value in data map `type_id` for `registry`, in
|
||||
/// id order. Empty where `native_data_map` is none for all.
|
||||
pub(in crate::game) fn native_data_map_entries(
|
||||
&self,
|
||||
registry: &str,
|
||||
type_id: &str,
|
||||
) -> Vec<(&str, &Value)> {
|
||||
self.native_data_map_of(registry, type_id)
|
||||
.map(|map| map.iter().map(|(k, v)| (k.as_str(), v)).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn tags(list: &[(&str, &[&str])]) -> HashMap<String, HashSet<String>> {
|
||||
list.iter()
|
||||
.map(|(tag, members)| {
|
||||
(
|
||||
(*tag).to_owned(),
|
||||
members.iter().map(|m| (*m).to_owned()).collect(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ty(id: &str, registry: &str) -> NativeDataMapType {
|
||||
NativeDataMapType {
|
||||
id: id.to_owned(),
|
||||
registry: registry.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn files(list: &[(&str, &[Value])]) -> Vec<(String, Vec<Vec<u8>>)> {
|
||||
list.iter()
|
||||
.map(|(key, versions)| {
|
||||
(
|
||||
(*key).to_owned(),
|
||||
versions
|
||||
.iter()
|
||||
.map(|v| v.to_string().into_bytes())
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn load(files: &[(String, Vec<Vec<u8>>)]) -> DataMaps {
|
||||
let items = tags(&[(
|
||||
"minecraft:logs",
|
||||
&["minecraft:oak_log", "minecraft:birch_log"],
|
||||
)]);
|
||||
DataMaps::load(
|
||||
&[ty("mymod:fuel", "minecraft:item")],
|
||||
files,
|
||||
&items,
|
||||
&HashMap::new(),
|
||||
)
|
||||
}
|
||||
|
||||
fn get<'a>(maps: &'a DataMaps, entry: &str) -> Option<&'a Value> {
|
||||
maps.maps[&("minecraft:item".to_owned(), "mymod:fuel".to_owned())].get(entry)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_attach_to_entries_and_to_every_member_of_a_tag() {
|
||||
let f = files(&[(
|
||||
"data_maps/mymod:item/fuel",
|
||||
&[json!({"values": {
|
||||
"minecraft:coal": {"burn": 1600},
|
||||
"stick": 100,
|
||||
"#minecraft:logs": {"burn": 300}
|
||||
}})],
|
||||
)]);
|
||||
let maps = load(&f);
|
||||
assert_eq!(get(&maps, "minecraft:coal"), Some(&json!({"burn": 1600})));
|
||||
// An id without a namespace is a `minecraft` one.
|
||||
assert_eq!(get(&maps, "minecraft:stick"), Some(&json!(100)));
|
||||
assert_eq!(get(&maps, "minecraft:oak_log"), Some(&json!({"burn": 300})));
|
||||
assert_eq!(
|
||||
get(&maps, "minecraft:birch_log"),
|
||||
Some(&json!({"burn": 300}))
|
||||
);
|
||||
assert_eq!(get(&maps, "minecraft:stone"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_file_overrides_and_replace_clears_what_came_before() {
|
||||
let f = files(&[(
|
||||
"data_maps/mymod:item/fuel",
|
||||
&[
|
||||
json!({"values": {"minecraft:coal": 1, "minecraft:stick": 2}}),
|
||||
json!({"values": {"minecraft:coal": 10}}),
|
||||
],
|
||||
)]);
|
||||
let maps = load(&f);
|
||||
assert_eq!(get(&maps, "minecraft:coal"), Some(&json!(10)));
|
||||
assert_eq!(get(&maps, "minecraft:stick"), Some(&json!(2)));
|
||||
let f = files(&[(
|
||||
"data_maps/mymod:item/fuel",
|
||||
&[
|
||||
json!({"values": {"minecraft:coal": 1, "minecraft:stick": 2}}),
|
||||
json!({"replace": true, "values": {"minecraft:coal": 10}}),
|
||||
],
|
||||
)]);
|
||||
let maps = load(&f);
|
||||
assert_eq!(get(&maps, "minecraft:coal"), Some(&json!(10)));
|
||||
assert_eq!(get(&maps, "minecraft:stick"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_takes_entries_and_tags_away_after_the_files_own_values() {
|
||||
let f = files(&[(
|
||||
"data_maps/mymod:item/fuel",
|
||||
&[
|
||||
json!({"values": {"#minecraft:logs": 5, "minecraft:coal": 1}}),
|
||||
// The file's own value for coal is attached, then removed.
|
||||
json!({"values": {"minecraft:coal": 9}, "remove": ["minecraft:coal", "#minecraft:logs"]}),
|
||||
],
|
||||
)]);
|
||||
let maps = load(&f);
|
||||
assert_eq!(get(&maps, "minecraft:coal"), None);
|
||||
assert_eq!(get(&maps, "minecraft:oak_log"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wrapper_form_gives_its_value_and_a_plain_object_is_a_value() {
|
||||
let f = files(&[(
|
||||
"data_maps/mymod:item/fuel",
|
||||
&[json!({"values": {
|
||||
"minecraft:coal": {"value": {"burn": 3}, "replace": true},
|
||||
"minecraft:stick": {"value": 1, "other": 2}
|
||||
}})],
|
||||
)]);
|
||||
let maps = load(&f);
|
||||
assert_eq!(get(&maps, "minecraft:coal"), Some(&json!({"burn": 3})));
|
||||
// More than `value` and `replace`: the object is the data.
|
||||
assert_eq!(
|
||||
get(&maps, "minecraft:stick"),
|
||||
Some(&json!({"value": 1, "other": 2}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_file_is_skipped_alone_and_bad_types_are_left_out() {
|
||||
let mut f = files(&[(
|
||||
"data_maps/mymod:item/fuel",
|
||||
&[json!({"values": {"minecraft:coal": 1}})],
|
||||
)]);
|
||||
f[0].1.insert(0, b"{nope".to_vec());
|
||||
f[0].1
|
||||
.push(json!({"nothing": true}).to_string().into_bytes());
|
||||
let maps = load(&f);
|
||||
assert_eq!(get(&maps, "minecraft:coal"), Some(&json!(1)));
|
||||
let maps = DataMaps::load(
|
||||
&[
|
||||
ty("minecraft:fuel", "minecraft:item"),
|
||||
ty("mymod:x", "minecraft:biome"),
|
||||
ty("nonamespace", "minecraft:item"),
|
||||
ty("mymod:a", "minecraft:block"),
|
||||
ty("mymod:a", "minecraft:block"),
|
||||
],
|
||||
&[],
|
||||
&HashMap::new(),
|
||||
&HashMap::new(),
|
||||
);
|
||||
// Only the one good type, registered once, empty.
|
||||
assert_eq!(maps.maps.len(), 1);
|
||||
assert!(
|
||||
maps.maps
|
||||
.contains_key(&("minecraft:block".to_owned(), "mymod:a".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_maps_read_the_block_tags() {
|
||||
let blocks = tags(&[("minecraft:logs", &["minecraft:oak_log"])]);
|
||||
let f = files(&[(
|
||||
"data_maps/mymod:block/hardness",
|
||||
&[json!({"values": {"#minecraft:logs": 2}})],
|
||||
)]);
|
||||
let maps = DataMaps::load(
|
||||
&[ty("mymod:hardness", "minecraft:block")],
|
||||
&f,
|
||||
&HashMap::new(),
|
||||
&blocks,
|
||||
);
|
||||
let map = &maps.maps[&("minecraft:block".to_owned(), "mymod:hardness".to_owned())];
|
||||
assert_eq!(map.get("minecraft:oak_log"), Some(&json!(2)));
|
||||
}
|
||||
}
|
||||
@@ -272,12 +272,14 @@
|
||||
//! that would break such a bundle is not made.
|
||||
//! Bodies have no `cfg` and no macros: horizon-mixins refuses a target with
|
||||
//! either (`tests::hook_points_stay_hookable`).
|
||||
use super::native_data_maps::NativeDataMapType;
|
||||
use super::native_lifecycle::{NativeLifecycleContext, ServerTickPhase, WorldPhase};
|
||||
use super::{
|
||||
Game, GameLevel, LevelDimension, Screen,
|
||||
execution_role::ExecutionRole,
|
||||
maps::{NativeMapDecoration, NativeMapInfo},
|
||||
native_block_entity_draw::BlockEntityDraw,
|
||||
native_mob_draw::NativeMobRender,
|
||||
native_mob_draw::{NativeMobRender, NativePlayerRender},
|
||||
native_panel_recipes::RecipePage,
|
||||
native_server::{NativeCommand, NativeServerPlayer},
|
||||
};
|
||||
@@ -286,7 +288,10 @@ use horizon_client::{
|
||||
biome::BiomeColors,
|
||||
block_entities::Pos,
|
||||
chat::ChatSource,
|
||||
containers::{BlockEntity, MOD_MENUS, ModBlockEntity, ModContainer, ModFluidTanks},
|
||||
containers::{
|
||||
BlockEntity, MOD_MENUS, ModBlockEntity, ModBlockEntityFields, ModContainer,
|
||||
ModEnergyBuffer, ModFluidTanks,
|
||||
},
|
||||
crafting::{CustomRecipe, Ingredient, recipe_result},
|
||||
extras::Corner,
|
||||
extras_hud::Corners,
|
||||
@@ -1264,6 +1269,22 @@ impl Game<'_> {
|
||||
pub(super) fn native_data_pack_files(&self, files: &mut Vec<(String, Vec<u8>)>) {
|
||||
let _ = files;
|
||||
}
|
||||
/// The server is assembling its data tables, as for
|
||||
/// `native_data_pack_files` (a world's first open and every `/reload`):
|
||||
/// push the data map types your mod registers (NeoForge's
|
||||
/// `RegisterDataMapTypesEvent`). A type is an `id` (`mod_id:name`, not in
|
||||
/// `minecraft`) for a `registry`, `minecraft:item` or `minecraft:block`
|
||||
/// so far. Its values come from the world's data packs'
|
||||
/// `data/<mod_id>/data_maps/<registry path>/<name>.json`, and from files
|
||||
/// you push through `native_data_pack_files` under the key
|
||||
/// `data_maps/<mod_id>:<registry path>/<name>`: `replace`, `values`
|
||||
/// (entry ids and `#tags` to JSON values) and `remove`, a newer value
|
||||
/// overriding an older. Read them with `native_data_map` and
|
||||
/// `native_data_map_entries`, in `native_data_maps.rs`. Server-side, not
|
||||
/// synced to clients. API @API:data_map_types@.
|
||||
pub(super) fn native_data_map_types(&self, types: &mut Vec<NativeDataMapType>) {
|
||||
let _ = types;
|
||||
}
|
||||
/// The server is about to open its worlds: nothing of the world is
|
||||
/// loaded yet (NeoForge's `ServerAboutToStartEvent`, "called before the
|
||||
/// server begins loading anything"). Vanilla's `MinecraftServer.loadLevel`
|
||||
@@ -1312,6 +1333,37 @@ impl Game<'_> {
|
||||
pub(super) fn native_hud(&mut self, batch: &mut HudBatch, corners: &mut Corners) {
|
||||
let _ = (batch, corners);
|
||||
}
|
||||
/// Before one of vanilla's HUD layers is drawn (`RenderGuiLayerEvent.Pre`):
|
||||
/// draw here to put something below the layer, or set `canceled` to hide
|
||||
/// it. `layer` is the layer's id, in the order Horizon draws them:
|
||||
/// `minecraft:crosshair`, `hotbar`, `effects`, `boss_overlay`,
|
||||
/// `vehicle_health` (only riding a mount), `contextual_info_bar` (the
|
||||
/// mount's jump bar, the locator bar or the experience bar, with the level
|
||||
/// number), `player_health` (the health, armor, food and air rows, drawn in
|
||||
/// one pass), `selected_item_name`, `sleep_overlay`, `overlay_message`
|
||||
/// (the action bar), `title`, `scoreboard_sidebar`, `subtitle_overlay`,
|
||||
/// `chat` and `tab_list`. A layer F1 hides fires nothing, and the chat
|
||||
/// screen draws the focused chat itself, so `chat` is only the unfocused
|
||||
/// lines. The camera overlays and the debug screen are not layers here:
|
||||
/// they are not drawn in this pass. Draw with `native_fill`, `native_text`
|
||||
/// and the sprite helpers; `batch.width` and `batch.height` are the GUI
|
||||
/// size. Together with `native_gui_layer_post` this is NeoForge's
|
||||
/// `RegisterGuiLayersEvent`: a mod keeps its own list of what it draws
|
||||
/// above or below which layer. API @API:gui_layers@.
|
||||
pub(super) fn native_gui_layer_pre(
|
||||
&mut self,
|
||||
layer: &str,
|
||||
batch: &mut HudBatch,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (layer, batch, canceled);
|
||||
}
|
||||
/// After one of vanilla's HUD layers (or after the layer a mod hid), with
|
||||
/// the ids of `native_gui_layer_pre`: draw here to put something above
|
||||
/// the layer (`RenderGuiLayerEvent.Post`). API @API:gui_layers@.
|
||||
pub(super) fn native_gui_layer_post(&mut self, layer: &str, batch: &mut HudBatch) {
|
||||
let _ = (layer, batch);
|
||||
}
|
||||
/// A player right-clicks the entity `entity` (`Entity.getId`) of type
|
||||
/// `kind` (`minecraft:cow`) with `self.hand`, holding `stack` (none in an
|
||||
/// empty hand): where `Player.interactOn` starts, before the entity's own
|
||||
@@ -1412,6 +1464,27 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (scroll, accumulated, buttons, cursor, modifiers, canceled);
|
||||
}
|
||||
/// An interaction key acts on what the crosshair points at
|
||||
/// (`InputEvent.InteractionKeyMappingTriggered`), while playing: `button` 0 is
|
||||
/// the attack key (`key_mapping` `key.attack`), 1 the use key (`key.use`, once
|
||||
/// for each hand it tries, the main hand first), 2 pick block (`key.pickItem`,
|
||||
/// only with something aimed at). The attack fires when an attack is allowed
|
||||
/// and again each tick a held attack key keeps digging a block. Set `canceled`
|
||||
/// to skip the action: a cancelled use also skips the other hand. A cancelled
|
||||
/// attack or use still swings `hand` unless the mod clears `swing_hand` (it
|
||||
/// starts true). A server's player and a single player's window only; a mod
|
||||
/// that claims an item's use (`mod_use_start`) sees the event first. API
|
||||
/// @API:interaction_key@.
|
||||
pub(super) fn native_interaction_key(
|
||||
&mut self,
|
||||
button: i32,
|
||||
key_mapping: &str,
|
||||
hand: horizon_client::first_person::Hand,
|
||||
swing_hand: &mut bool,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (button, key_mapping, hand, swing_hand, canceled);
|
||||
}
|
||||
/// A command typed or clicked in chat, without its slash, before the
|
||||
/// server and Horizon's commands see it: set `ran` when the mod ran it.
|
||||
pub(super) fn native_command(&mut self, command: &str, ran: &mut bool) {
|
||||
@@ -1548,6 +1621,48 @@ impl Game<'_> {
|
||||
pub(super) fn native_component_types(types: &mut Vec<String>) {
|
||||
let _ = types;
|
||||
}
|
||||
/// Blocks this mod adds, declared in code rather than one file each
|
||||
/// (NeoForge's `DeferredRegister.Blocks` drained by `RegisterEvent`):
|
||||
/// push a `horizon_mods::content::Block` per block, the same declaration
|
||||
/// a content mod's `data/<id>/horizon/block/<path>.json` holds (a
|
||||
/// vanilla `template`, `properties`, `state_properties`, `shapes`...),
|
||||
/// with its id in your mod's namespace. `item: true` gives it a block
|
||||
/// item, as a file does unless told not to (`..Default::default()`
|
||||
/// leaves it false). They join the content mods' blocks: the one whose
|
||||
/// id is that namespace when it is on (your models, textures, loot and
|
||||
/// recipes are its files), else one of their own, with the template's
|
||||
/// look. Ids, saves and the check that a joining client has the same
|
||||
/// blocks are the files' (docs/mods.md). Asked once at start, on every
|
||||
/// side, before any resource loads: push the same ones every time, from
|
||||
/// nothing but your code. Declarations that don't pass the files' checks
|
||||
/// are logged and that namespace's are left out. API
|
||||
/// @API:native_blocks_items@.
|
||||
pub(super) fn native_blocks(blocks: &mut Vec<horizon_mods::content::Block>) {
|
||||
let _ = blocks;
|
||||
}
|
||||
/// Items this mod adds, declared in code (NeoForge's
|
||||
/// `DeferredRegister.Items`), as `native_blocks` declares blocks: a
|
||||
/// `horizon_mods::content::Item` per item, a content mod's
|
||||
/// `data/<id>/horizon/item/<path>.json` (a vanilla `template`,
|
||||
/// `max_stack_size`, `rarity`, `components`, `crafting_remainder`). A
|
||||
/// block's item comes with its block (`item: true`), not from here.
|
||||
/// Asked once at start, on every side. API @API:native_blocks_items@.
|
||||
pub(super) fn native_items(items: &mut Vec<horizon_mods::content::Item>) {
|
||||
let _ = items;
|
||||
}
|
||||
/// Models no blockstate names that this client bakes beside the block
|
||||
/// models (NeoForge's `ModelEvent.RegisterStandalone`): push their ids
|
||||
/// (`mymod:block/shaft_half`, the model at
|
||||
/// `assets/mymod/models/block/shaft_half.json`). Parts a block entity or
|
||||
/// a mob draws, with `native_draw_model`, so that no block is registered
|
||||
/// only to bake its model. Asked once at start, before the resources
|
||||
/// load; every resource load, a pack reload included, bakes them again
|
||||
/// from that pack's files. A model that doesn't read is logged and left
|
||||
/// out; an id without a namespace is dropped. Every side asks it, but
|
||||
/// only a window bakes models. API @API:standalone_models@.
|
||||
pub(super) fn native_standalone_models(models: &mut Vec<String>) {
|
||||
let _ = models;
|
||||
}
|
||||
/// Options > Mods lists the native mods: push yours.
|
||||
pub(super) fn native_mods(&mut self, mods: &mut Vec<NativeMod>) {
|
||||
let _ = mods;
|
||||
@@ -1638,6 +1753,33 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (block, fluids);
|
||||
}
|
||||
/// Block `block`'s block entity has an energy buffer
|
||||
/// (`Capabilities.Energy.BLOCK`): set `energy` when the block is yours,
|
||||
/// as `SimpleEnergyHandler` is built: its capacity, the most one insert
|
||||
/// and one extract moves, and the faces a fill may come through and a
|
||||
/// drain may leave by. Asked once for each mod block when the ticking
|
||||
/// table is built; a capacity under 1 leaves the buffer out, and the
|
||||
/// limits are clamped to 0..=capacity. The amount is kept in the entity's
|
||||
/// saved compound, as the int `energy`, and the energy helpers below read
|
||||
/// and write it. API @API:block_entity_energy@.
|
||||
pub(super) fn native_block_entity_energy(
|
||||
&self,
|
||||
block: &str,
|
||||
energy: &mut Option<ModEnergyBuffer>,
|
||||
) {
|
||||
let _ = (block, energy);
|
||||
}
|
||||
/// Item `item` (`mod_id:name`) is a fluid container
|
||||
/// (`Capabilities.FluidHandler.ITEM`): set `capacity`, in millibuckets,
|
||||
/// when the item is yours (clamped to 1..=1 000 000; vanilla's buckets
|
||||
/// are built in and cannot be changed). Asked at each call of the item
|
||||
/// fluid helpers (`native_item_fluid`, `native_item_fluid_fill`,
|
||||
/// `native_item_fluid_drain`, in `native_item_fluids.rs`). What the
|
||||
/// container holds is kept in the stack's `minecraft:custom_data`, under
|
||||
/// `horizon_fluid`. API @API:item_fluid_tank@.
|
||||
pub(super) fn native_item_fluid_tank(&self, item: &str, capacity: &mut Option<i32>) {
|
||||
let _ = (item, capacity);
|
||||
}
|
||||
/// A server tick of the block entity at `pos`, in vanilla's order among
|
||||
/// the others (`Level.tickBlockEntities`), when its `id` is yours: read
|
||||
/// and change its saved compound `nbt`, its item slots `items` (keep
|
||||
@@ -1736,6 +1878,67 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (mob, draw, drawn);
|
||||
}
|
||||
/// Whether a mod listens to `native_player_submit` or `native_mob_layer`: a
|
||||
/// mod that hooks either hooks this one too and sets `listened` (HEAD,
|
||||
/// `*listened = true`). Without it the game poses no player or mob for
|
||||
/// them, each frame. Client-side. API @API:player_render@.
|
||||
pub(super) fn native_player_render_listened(&self, listened: &mut bool) {
|
||||
let _ = listened;
|
||||
}
|
||||
/// Draw the player `player` (`who`: its profile name, and whether it is
|
||||
/// this window's own player) with a model of your own, once a frame while
|
||||
/// it is in view (`RenderPlayerEvent.Pre`), and set `drawn` to leave out its
|
||||
/// vanilla model, armour, held items, cape and the other layers. This
|
||||
/// window's player is drawn only in third person (and F5 front view); an
|
||||
/// invisible player is not asked. `draw` is the frame of `native_mob_submit`:
|
||||
/// at the player's feet, turned with the body. The name tag, shadow,
|
||||
/// flames and glowing outline draw as before. API @API:player_render@.
|
||||
pub(super) fn native_player_submit(
|
||||
&self,
|
||||
player: &NativeMobRender<'_>,
|
||||
who: &NativePlayerRender<'_>,
|
||||
draw: &mut BlockEntityDraw,
|
||||
drawn: &mut bool,
|
||||
) {
|
||||
let _ = (player, who, draw, drawn);
|
||||
}
|
||||
/// A layer on every mob and player in view, once a frame after its
|
||||
/// vanilla model and layers (`EntityRenderersEvent.AddLayers`, with
|
||||
/// `RenderLivingEvent.Post` and `RenderPlayerEvent.Post`): add geometry
|
||||
/// in the entity's frame, which starts at its feet turned with the body
|
||||
/// (`mob.head_yaw`, `mob.pitch` and the walk cycle place a part, the eyes
|
||||
/// being at the entity type's eye height). `who` is set for a player.
|
||||
/// Not asked for what a mod drew itself (`native_mob_submit`,
|
||||
/// `native_player_submit`) nor for a mob that isn't drawn at all.
|
||||
/// API @API:player_render@.
|
||||
pub(super) fn native_mob_layer(
|
||||
&self,
|
||||
mob: &NativeMobRender<'_>,
|
||||
who: Option<&NativePlayerRender<'_>>,
|
||||
draw: &mut BlockEntityDraw,
|
||||
) {
|
||||
let _ = (mob, who, draw);
|
||||
}
|
||||
/// Icons a mod puts on map `map` each time it is drawn: held, in an
|
||||
/// item frame or on the cartography table's screen (a map's pixels are
|
||||
/// its own; nothing is saved in the map). Push `NativeMapDecoration`s
|
||||
/// (an atlas sprite, a position in half map pixels, a rotation, and
|
||||
/// whether it shows in a frame and on the screen like vanilla's markers
|
||||
/// do); they are drawn after the vanilla ones with the same pose, and a
|
||||
/// sprite the atlas lacks is left out. `map.center`, `map.scale` and
|
||||
/// `map.dimension` say which blocks the map shows, and
|
||||
/// `NativeMapDecoration::x` has the formula. Vanilla's decorations are
|
||||
/// not passed in. Asked every frame a map is drawn, so keep it cheap.
|
||||
/// The client half of `MapDecorationType` and `addDecoration`: a type
|
||||
/// registered on the server and saved in the map's data is not here.
|
||||
/// API @API:map_decorations@.
|
||||
pub(super) fn native_map_decorations(
|
||||
&self,
|
||||
map: &NativeMapInfo<'_>,
|
||||
decorations: &mut Vec<NativeMapDecoration>,
|
||||
) {
|
||||
let _ = (map, decorations);
|
||||
}
|
||||
/// A menu opens on this client for block `block`, a mod's: set `screen`
|
||||
/// when the block is yours, to lay its screen out (`native_screen_draw`
|
||||
/// draws it). The server asks it too: a slot it hides is not in the menu
|
||||
@@ -2202,6 +2405,34 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (player, command, ran);
|
||||
}
|
||||
/// A player finished editing a sign: `ServerGamePacketListenerImpl.updateSignText`
|
||||
/// has taken their update packet and every check has passed (they were
|
||||
/// editing that sign, within reach, the sign is not waxed, the chat filter
|
||||
/// let the text through), right before the lines go on the sign. `pos` in
|
||||
/// `dimension`, the `front` or back side, and the four `lines` as written,
|
||||
/// plain text with the formatting codes still in (vanilla strips them
|
||||
/// afterwards). Rewrite `lines` (made four lines again) and that is what
|
||||
/// the sign gets, without a second look from the chat filter, or set
|
||||
/// `cancel` and the sign keeps its old text. With the player's state
|
||||
/// swapped in, so `native_tell` and the helpers that act on the player
|
||||
/// reach them. Once for each update a player sends on a server. Not told:
|
||||
/// the host's own editing of a sign on a LAN or single-player world
|
||||
/// (the editor writes into their world directly), and the sign changes
|
||||
/// that are no edit of the text (dyeing, glow ink, waxing, `/data`,
|
||||
/// `/setblock`, mods' `native_with_mod_block_entity`).
|
||||
/// @API:native_sign_changed@.
|
||||
pub(super) fn native_sign_changed(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
front: bool,
|
||||
lines: &mut Vec<String>,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
let _ = (player, name, dimension, pos, front, lines, cancel);
|
||||
}
|
||||
/// A command a player typed (NeoForge's `CommandEvent`, before
|
||||
/// `Commands.performCommand` runs it), without its slash: any command,
|
||||
/// vanilla's and the mods' own, also one the player may not run. Set
|
||||
@@ -2691,6 +2922,118 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (dimension, pos, viewers, previous);
|
||||
}
|
||||
/// `player` (`name`) opened a menu (NeoForge's `PlayerContainerEvent.Open`,
|
||||
/// posted when `ServerPlayer.openMenu` sets the new `containerMenu`): any
|
||||
/// menu, vanilla or a mod's. `menu` is the menu type's name
|
||||
/// (`minecraft:generic_9x3`, `minecraft:furnace`, `minecraft:merchant`,
|
||||
/// `minecraft:crafting`, `minecraft:lectern`, a mod's own), `pos` the
|
||||
/// block it is of in `dimension` (none for a trade, a vehicle's, a mod's
|
||||
/// native menu or a mount's inventory, which has no menu type in vanilla
|
||||
/// and is told as `horizon:mount_inventory`). The player's own inventory
|
||||
/// is not one. Heard once a tick, so a menu opened and closed again
|
||||
/// inside one tick is not; one that replaces another comes after that
|
||||
/// one's close. The player's state is swapped in. `native_player_container_close`
|
||||
/// follows. Server-side. API @API:player_container_events@.
|
||||
pub(super) fn native_player_container_open(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
dimension: &str,
|
||||
menu: &str,
|
||||
pos: Option<[i32; 3]>,
|
||||
) {
|
||||
let _ = (player, name, dimension, menu, pos);
|
||||
}
|
||||
/// `player` (`name`) closed the menu `native_player_container_open` told
|
||||
/// of (NeoForge's `PlayerContainerEvent.Close`, posted in
|
||||
/// `ServerPlayer.doCloseContainer` after the menu's `removed`): they
|
||||
/// closed it, walked out of reach, died, changed level, or another menu
|
||||
/// opened. A player who leaves closes it before `native_player_logged_out`
|
||||
/// (vanilla posts nothing for a disconnect; Horizon does, so each open has
|
||||
/// its close). Once for each open; not told when the server stops. The
|
||||
/// menu's items are where it left them: the block's, and the cursor stack
|
||||
/// back in the inventory. State swapped in, when the player has a body.
|
||||
/// Server-side. API @API:player_container_events@.
|
||||
pub(super) fn native_player_container_close(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
dimension: &str,
|
||||
menu: &str,
|
||||
pos: Option<[i32; 3]>,
|
||||
) {
|
||||
let _ = (player, name, dimension, menu, pos);
|
||||
}
|
||||
/// A client started tracking an entity (NeoForge's
|
||||
/// `PlayerEvent.StartTracking`, posted at the end of
|
||||
/// `ServerEntity.addPairing`): `player` (`name`) is now sent `entity`
|
||||
/// (its ID, `uuid` its UUID, `kind` its type, `minecraft:zombie`) and its
|
||||
/// changes. It comes into range, its client's chunks reach it, or it is
|
||||
/// put in the level; another player is an entity too, and a player never
|
||||
/// tracks their own. A mod sends the player what they need to show it
|
||||
/// (`native_net_send_to_player`). The entity is as it was last tick: it
|
||||
/// may be gone by now. Heard after the tick's players and entities have
|
||||
/// moved, in order, with no player's state swapped in. Server-side.
|
||||
/// API @API:player_tracking@.
|
||||
pub(super) fn native_player_start_tracking(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
entity: u32,
|
||||
uuid: u128,
|
||||
kind: &str,
|
||||
) {
|
||||
let _ = (player, name, entity, uuid, kind);
|
||||
}
|
||||
/// A client stopped tracking an entity (NeoForge's
|
||||
/// `PlayerEvent.StopTracking`, posted at the end of
|
||||
/// `ServerEntity.removePairing`): it went out of range, left the level or
|
||||
/// died, or `player` (`name`) left, changed level or respawned, which
|
||||
/// stops everything they tracked. `entity`, `uuid` and `kind` are as
|
||||
/// `native_player_start_tracking` told. Once for each start, a leaving
|
||||
/// player's after `native_player_logged_out`. The entity is not
|
||||
/// necessarily still there, and a player who left is gone: no player's
|
||||
/// state is swapped in. Server-side. API @API:player_tracking@.
|
||||
pub(super) fn native_player_stop_tracking(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
entity: u32,
|
||||
uuid: u128,
|
||||
kind: &str,
|
||||
) {
|
||||
let _ = (player, name, entity, uuid, kind);
|
||||
}
|
||||
/// Whether a mod listens to the mobs' `native_living_equipment_change`: a
|
||||
/// mod that hooks it hooks this one too and sets `listened` (HEAD,
|
||||
/// `*listened = true`). Without it the game does not read every mob's
|
||||
/// equipment each tick, and mobs are not told (armor stands still are). A
|
||||
/// mob that ticks once it is set counts from empty. Asked once a tick, after
|
||||
/// the mobs' tick. Server-side. API @API:living_equipment_change@.
|
||||
pub(super) fn native_living_equipment_listened(&mut self, listened: &mut bool) {
|
||||
let _ = listened;
|
||||
}
|
||||
/// A mob's or an armor stand's equipment slot took a different stack
|
||||
/// (NeoForge's `LivingEquipmentChangeEvent`, posted in
|
||||
/// `LivingEntity.collectEquipmentChanges`): `entity` is its ID and `kind`
|
||||
/// its type (`minecraft:zombie`), `slot` the `EquipmentSlot` (`mainhand`,
|
||||
/// `offhand`, `feet`, `legs`, `chest`, `head`, `body`, `saddle`), `from`
|
||||
/// what the slot held when this was last heard (none for empty) and `to`
|
||||
/// what it holds now. Once for each slot that changed, in that order,
|
||||
/// after the mobs' tick, so a mob that joins the level wearing something
|
||||
/// counts from empty on its first tick. It only hears: change the mob with
|
||||
/// its own helpers. Players are heard by `native_on_equip`. Server-side.
|
||||
/// API @API:living_equipment_change@.
|
||||
pub(super) fn native_living_equipment_change(
|
||||
&mut self,
|
||||
entity: u32,
|
||||
kind: &str,
|
||||
slot: &str,
|
||||
from: Option<&ItemStack>,
|
||||
to: Option<&ItemStack>,
|
||||
) {
|
||||
let _ = (entity, kind, slot, from, to);
|
||||
}
|
||||
/// `player` died: where `ServerPlayer.die` starts, with the death
|
||||
/// message's key when the game knows it (`death.attack.lava`; none for a
|
||||
/// message it builds from the combat). It only hears. Server-side, for
|
||||
@@ -2748,6 +3091,75 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (dimension, entity, kind, pos, loaded_from_disk, canceled);
|
||||
}
|
||||
/// An entity mounts or dismounts a vehicle (NeoForge's `EntityMountEvent`, posted from
|
||||
/// `Entity.startRiding` and `removeVehicle`): `entity` of type `kind` (`minecraft:player`,
|
||||
/// `minecraft:zombie`) in the server level `dimension`, getting on (`mounting`) or off the
|
||||
/// `vehicle` of type `vehicle_kind` (`minecraft:oak_boat`, `minecraft:minecart`,
|
||||
/// `minecraft:horse`, `minecraft:cushion`). Set `canceled` and it does not: a player stays
|
||||
/// where they were, a mob does not board, a `/ride` fails, and a rider whose dismount is
|
||||
/// canceled keeps riding. Told for a player boarding a boat, minecart, cushion or a mount (pig,
|
||||
/// strider, horse and its kin, llama, nautilus, happy ghast) and getting off one by their
|
||||
/// own input, `/ride`, a stab, a buck, a teleport or spectator mode; and for the mobs
|
||||
/// Horizon seats on a vehicle (jockeys, raiders on a ravager, `/ride`) and lets off one
|
||||
/// (`/ride`, a stab). It is not told for a rider whose vehicle was removed from the
|
||||
/// level (a boat or minecart destroyed, a broken cushion), nor for riders loaded with a
|
||||
/// save. The acting player is the one whose action runs, a guest on a server. Server-side.
|
||||
/// API @API:entity_mount@.
|
||||
pub(super) fn native_entity_mount(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
entity: u32,
|
||||
kind: &str,
|
||||
vehicle: u32,
|
||||
vehicle_kind: &str,
|
||||
mounting: bool,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (
|
||||
dimension,
|
||||
entity,
|
||||
kind,
|
||||
vehicle,
|
||||
vehicle_kind,
|
||||
mounting,
|
||||
canceled,
|
||||
);
|
||||
}
|
||||
/// Whether a mod listens to `native_entity_tick_pre` and `native_entity_tick_post`: a mod
|
||||
/// that hooks them hooks this one too and sets `listened` (HEAD, `*listened = true`).
|
||||
/// Without it the game tells no entity's tick, and keeps no list of them. Asked once for
|
||||
/// each family (mobs, items, minecarts, boats) each tick. Server-side. API @API:entity_tick@.
|
||||
pub(super) fn native_entity_tick_listened(&mut self, listened: &mut bool) {
|
||||
let _ = listened;
|
||||
}
|
||||
/// An entity of type `kind` (`minecraft:zombie`, `minecraft:item`) with ID `entity` is
|
||||
/// about to tick in the server level `dimension` (NeoForge's `EntityTickEvent.Pre`, posted
|
||||
/// around `Entity.commonTick` and `tick` in `ServerLevel.tickNonPassenger`). Set `canceled`
|
||||
/// and it does not tick this tick: a mob has no AI, physics or effects, an item no movement,
|
||||
/// ageing or merging, a minecart or boat no motion, and `native_entity_tick_post` is not told.
|
||||
/// Horizon ticks a family of entities together, so mobs and items hear every Pre before the
|
||||
/// first of them ticks; minecarts and boats hear theirs one by one. Told for the mobs, item
|
||||
/// entities, minecarts and boats that tick: those in an entity-ticking chunk, not a player's
|
||||
/// boat or cart (it ticks with the player). Later APIs add other kinds, so check `kind`. It
|
||||
/// runs for every ticking entity each tick, so keep it cheap. Server-side. API
|
||||
/// @API:entity_tick@.
|
||||
pub(super) fn native_entity_tick_pre(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
entity: u32,
|
||||
kind: &str,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (dimension, entity, kind, canceled);
|
||||
}
|
||||
/// The entity `entity` of type `kind` ticked in the server level `dimension` (NeoForge's
|
||||
/// `EntityTickEvent.Post`): for each entity `native_entity_tick_pre` let through, after it
|
||||
/// ticked, also one that died or was removed in its tick. Mobs and items hear their Posts
|
||||
/// once the whole family has ticked; minecarts and boats right after their own tick. Server-side.
|
||||
/// API @API:entity_tick@.
|
||||
pub(super) fn native_entity_tick_post(&mut self, dimension: &str, entity: u32, kind: &str) {
|
||||
let _ = (dimension, entity, kind);
|
||||
}
|
||||
/// Whether the experience orb `into` takes the orb `from` in (vanilla's
|
||||
/// `ExperienceOrb.canMerge`): the same value, and ids a multiple of 40 apart. Asked
|
||||
/// for each pair whose boxes meet, when an orb spawns (`tryMergeToExisting`: `from` is
|
||||
@@ -2987,6 +3399,61 @@ impl Game<'_> {
|
||||
canceled,
|
||||
);
|
||||
}
|
||||
/// Whether a mod hears projectiles' impacts (`native_projectile_impact`):
|
||||
/// set `hears`. Asked once a server tick before the projectiles fly; only
|
||||
/// then does the tick keep the copy of each flying projectile that a
|
||||
/// canceled impact flies on from, so a game without such a mod pays
|
||||
/// nothing. Server-side. API @API:projectile_impact@.
|
||||
pub(super) fn native_hears_projectile_impacts(&self, hears: &mut bool) {
|
||||
let _ = hears;
|
||||
}
|
||||
/// A projectile hit something (NeoForge's `ProjectileImpactEvent`, posted
|
||||
/// where vanilla's projectile has found what it meets, before the hit
|
||||
/// acts): `projectile` (its entity id) of type `kind` (`minecraft:arrow`),
|
||||
/// shot by `owner` (a mob's or a player's entity id, 0 for none) in the level
|
||||
/// `dimension`. It hit the entity `entity` of type `entity_kind` (a player is
|
||||
/// `minecraft:player`; a dragon's part is `minecraft:ender_dragon`), or, with
|
||||
/// `entity` None, the block at `block` on its `face` (the face's normal,
|
||||
/// `[0, 1, 0]` for the top), at the point `at`. Set `canceled` and the impact
|
||||
/// does not happen: no damage, no knockback, no sound, no block reaction, the
|
||||
/// arrow does not stick, a pearl does not teleport, a potion or a fireball
|
||||
/// does not burst. The projectile flies on and is not discarded, so a mod
|
||||
/// that cancels also decides what becomes of it: it hits again the next tick
|
||||
/// unless the mod moves, turns or removes it. An arrow stays where it would
|
||||
/// have hit, and the other projectiles go on through the whole of their
|
||||
/// motion. Covers what `tick_projectiles` flies: arrows, tridents, snowballs,
|
||||
/// eggs, pearls, potions, fireballs, wither skulls, wind charges, llama spit and
|
||||
/// shulker bullets, from players, mobs and dispensers. It does not fire for
|
||||
/// a breeze's deflection, for a hit on a wind charge, for fireworks or for
|
||||
/// fishing bobbers. A dragon part or End crystal is told by the id of the
|
||||
/// dragon or crystal. Heard only when a mod also sets
|
||||
/// `native_hears_projectile_impacts`. Server-side. API @API:projectile_impact@.
|
||||
pub(super) fn native_projectile_impact(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
projectile: u32,
|
||||
kind: &str,
|
||||
owner: u32,
|
||||
entity: Option<u32>,
|
||||
entity_kind: &str,
|
||||
block: [i32; 3],
|
||||
face: [i32; 3],
|
||||
at: [f64; 3],
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (
|
||||
dimension,
|
||||
projectile,
|
||||
kind,
|
||||
owner,
|
||||
entity,
|
||||
entity_kind,
|
||||
block,
|
||||
face,
|
||||
at,
|
||||
canceled,
|
||||
);
|
||||
}
|
||||
|
||||
/// `Block.stepOn` for a mod block, on its authoritative server level.
|
||||
/// No self: the simulation owns the entity during this callback.
|
||||
@@ -3781,6 +4248,21 @@ impl Game<'_> {
|
||||
self.audio.play_after(sound, at, volume, pitch, delay);
|
||||
true
|
||||
}
|
||||
/// Draws one vanilla HUD layer between the mods' hooks: `native_gui_layer_pre`
|
||||
/// (which may hide it), the layer, then `native_gui_layer_post`. `None` when
|
||||
/// a mod hid the layer.
|
||||
pub(super) fn gui_layer<R>(
|
||||
&mut self,
|
||||
layer: &str,
|
||||
batch: &mut HudBatch,
|
||||
draw: impl FnOnce(&mut Self, &mut HudBatch) -> R,
|
||||
) -> Option<R> {
|
||||
let mut canceled = false;
|
||||
self.native_gui_layer_pre(layer, batch, &mut canceled);
|
||||
let drawn = (!canceled).then(|| draw(self, batch));
|
||||
self.native_gui_layer_post(layer, batch);
|
||||
drawn
|
||||
}
|
||||
/// Whether a box in `corner` shows: F3's debug screen takes both top
|
||||
/// corners (its left and right columns), and a bottom corner while one
|
||||
/// of its charts is drawn there.
|
||||
@@ -4470,11 +4952,14 @@ impl Game<'_> {
|
||||
let mut entity = None;
|
||||
self.native_block_entity(name, &mut entity);
|
||||
let (id, nbt) = entity?;
|
||||
Some(BlockEntity::Mod(Box::new(ModBlockEntity {
|
||||
id,
|
||||
nbt,
|
||||
..ModBlockEntity::default()
|
||||
})))
|
||||
Some(BlockEntity::Mod(Box::new(
|
||||
ModBlockEntityFields {
|
||||
id,
|
||||
nbt,
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
)))
|
||||
}
|
||||
/// The containers of the mod blocks' block entities, by block state
|
||||
/// (`native_block_entity_container`), checked.
|
||||
@@ -4546,6 +5031,34 @@ impl Game<'_> {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The energy buffers of the mod blocks' block entities, by block state
|
||||
/// (`native_block_entity_energy`), checked as the containers are.
|
||||
pub(super) fn native_energy_tables(&self) -> FxHashMap<u32, Arc<ModEnergyBuffer>> {
|
||||
let registry = self.pack.assets.registry;
|
||||
let mut by_block: FxHashMap<String, Option<Arc<ModEnergyBuffer>>> = FxHashMap::default();
|
||||
let mut out = FxHashMap::default();
|
||||
for i in 0..registry.len() as u32 {
|
||||
let Some(state) = registry.state(horizon_world::registry::BlockStateId(i)) else {
|
||||
continue;
|
||||
};
|
||||
if state.name.starts_with("minecraft:") {
|
||||
continue;
|
||||
}
|
||||
let buffer = by_block.entry(state.name.clone()).or_insert_with(|| {
|
||||
let mut energy = None;
|
||||
self.native_block_entity_energy(&state.name, &mut energy);
|
||||
energy.filter(|e| e.capacity >= 1).map(|mut e| {
|
||||
e.max_insert = e.max_insert.clamp(0, e.capacity);
|
||||
e.max_extract = e.max_extract.clamp(0, e.capacity);
|
||||
Arc::new(e)
|
||||
})
|
||||
});
|
||||
if let Some(b) = buffer {
|
||||
out.insert(i, Arc::clone(b));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Whether a native mod's block `name` has a ticking block entity.
|
||||
pub(super) fn native_ticks(&self, name: &str) -> bool {
|
||||
let mut ticks = false;
|
||||
@@ -4993,6 +5506,23 @@ impl Game<'_> {
|
||||
self.native_key(name, pressed);
|
||||
}
|
||||
}
|
||||
/// `ClientHooks.onClickInput`: tells `native_interaction_key` that `button`
|
||||
/// (0 attack, 1 use, 2 pick) is about to act with `hand`. `None` when the
|
||||
/// action goes on; when a mod cancelled it, `Some` of whether the hand swings.
|
||||
pub(super) fn native_interaction(
|
||||
&mut self,
|
||||
button: i32,
|
||||
hand: horizon_client::first_person::Hand,
|
||||
) -> Option<bool> {
|
||||
let key_mapping = match button {
|
||||
0 => "key.attack",
|
||||
1 => "key.use",
|
||||
_ => "key.pickItem",
|
||||
};
|
||||
let (mut swing_hand, mut canceled) = (true, false);
|
||||
self.native_interaction_key(button, key_mapping, hand, &mut swing_hand, &mut canceled);
|
||||
canceled.then_some(swing_hand)
|
||||
}
|
||||
/// A mouse button while playing, for `native_key`.
|
||||
pub(super) fn native_mouse_event(&mut self, button: MouseButton, pressed: bool) {
|
||||
let name = keybinds::mouse_name(button);
|
||||
@@ -5471,6 +6001,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_breathing_stays_hookable() {
|
||||
let source = include_str!("../breathing.rs");
|
||||
let hook = source.find("pub fn native_living_breathe(").unwrap();
|
||||
// From its doc comment's first line, so an attribute above it counts.
|
||||
let start = source[..hook].find("/// An entity's breathing").unwrap();
|
||||
let body = &source[start..hook + source[hook..].find("\n}\n").unwrap()];
|
||||
assert!(body.contains("#[inline(never)]"));
|
||||
for line in body.lines() {
|
||||
assert!(!line.contains("cfg"), "a cfg in the breathing hook: {line}");
|
||||
for mac in ["!(", "![", "! {", "!{"] {
|
||||
assert!(!line.contains(mac), "a macro in the breathing hook: {line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_loot_kinds_stay_hookable() {
|
||||
let source = include_str!("../../../horizon-world/src/loot.rs");
|
||||
@@ -5592,6 +6138,8 @@ mod tests {
|
||||
let loot_hooks = free_hooks(include_str!("../../../horizon-world/src/loot.rs"));
|
||||
// The menu engine's (`horizon_client::menu`).
|
||||
let menu_hooks = free_hooks(include_str!("../menu.rs"));
|
||||
// The air supply's (`horizon_client::breathing`).
|
||||
let breath_hooks = free_hooks(include_str!("../breathing.rs"));
|
||||
let declared: BTreeSet<_> = section
|
||||
.lines()
|
||||
.chain(level_section.lines())
|
||||
@@ -5605,6 +6153,7 @@ mod tests {
|
||||
.chain(item_hooks.iter().copied())
|
||||
.chain(loot_hooks.iter().copied())
|
||||
.chain(menu_hooks.iter().copied())
|
||||
.chain(breath_hooks.iter().copied())
|
||||
.collect();
|
||||
let mut names = BTreeSet::new();
|
||||
let mut contracts = BTreeSet::new();
|
||||
@@ -5624,6 +6173,8 @@ mod tests {
|
||||
format!("horizon_world::loot::native_{name}")
|
||||
} else if menu_hooks.contains(name) {
|
||||
format!("horizon_client::menu::native_{name}")
|
||||
} else if breath_hooks.contains(name) {
|
||||
format!("horizon_client::breathing::native_{name}")
|
||||
} else if level_hooks.contains(name) {
|
||||
format!("horizon_client::game::native_hooks::GameLevel<'_>::native_{name}")
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
//! Fluids held by item stacks for native mods (docs/native-mods.md): vanilla's
|
||||
//! buckets, and a mod's own containers (`native_item_fluid_tank`), read,
|
||||
//! filled and drained in millibuckets. NeoForge's
|
||||
//! `Capabilities.FluidHandler.ITEM` acts on one item of a stack at a time and
|
||||
//! may swap the item for another; so do these. They take a stack and answer
|
||||
//! without changing it: the caller shrinks its stack by one and hands out the
|
||||
//! changed container they return. They hold no world state, so they work on
|
||||
//! any side.
|
||||
use super::Game;
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_world::nbt::{Compound, JavaString, Tag};
|
||||
|
||||
/// What one item of a stack holds (`native_item_fluid`).
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct NativeItemFluid {
|
||||
/// `minecraft:water` or `minecraft:lava`; none when it is empty.
|
||||
pub(crate) fluid: Option<String>,
|
||||
/// In millibuckets: a bucket is a thousand.
|
||||
pub(crate) amount: i32,
|
||||
pub(crate) capacity: i32,
|
||||
}
|
||||
|
||||
const CUSTOM_DATA: &str = "minecraft:custom_data";
|
||||
/// The key under `minecraft:custom_data` that keeps a mod container's
|
||||
/// contents: `{Fluid: "minecraft:water", Amount: 250}`.
|
||||
const TANK: &str = "horizon_fluid";
|
||||
/// A bucket's capacity, vanilla's `FluidType.BUCKET_VOLUME`.
|
||||
const BUCKET: i32 = 1000;
|
||||
const MOD_CAPACITY: std::ops::RangeInclusive<i32> = 1..=1_000_000;
|
||||
|
||||
/// The bucket items: (empty, full), by the fluid they hold. A bucket is all
|
||||
/// or nothing, as `BucketItem` is. Milk, powder snow and fish buckets hold
|
||||
/// no fluid Horizon models.
|
||||
const BUCKETS: [(&str, &str); 2] = [
|
||||
("minecraft:water", "minecraft:water_bucket"),
|
||||
("minecraft:lava", "minecraft:lava_bucket"),
|
||||
];
|
||||
|
||||
fn is_fluid(name: &str) -> bool {
|
||||
BUCKETS.iter().any(|(fluid, _)| *fluid == name)
|
||||
}
|
||||
|
||||
/// The contents a mod container's stack keeps in its `custom_data`: none and
|
||||
/// 0 when empty, or when what is there isn't a tank.
|
||||
fn read_tank(stack: &ItemStack) -> (Option<String>, i32) {
|
||||
let Some(Tag::Compound(data)) = stack.data().and_then(|d| d.other(CUSTOM_DATA)) else {
|
||||
return (None, 0);
|
||||
};
|
||||
let Some(Tag::Compound(tank)) = data.get(&JavaString::from(TANK)) else {
|
||||
return (None, 0);
|
||||
};
|
||||
let amount = match tank.get(&JavaString::from("Amount")) {
|
||||
Some(Tag::Int(amount)) => (*amount).max(0),
|
||||
_ => 0,
|
||||
};
|
||||
match tank.get(&JavaString::from("Fluid")) {
|
||||
Some(Tag::String(fluid)) if amount > 0 => (Some(fluid.to_string_lossy()), amount),
|
||||
_ => (None, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// One item of `stack` holding `amount` of `fluid`, its other data kept. An
|
||||
/// empty tank carries nothing, so empty containers stack.
|
||||
fn with_tank(stack: &ItemStack, fluid: &str, amount: i32) -> ItemStack {
|
||||
let mut one = stack.clone();
|
||||
one.count = 1;
|
||||
let mut data = match one.data().and_then(|d| d.other(CUSTOM_DATA)) {
|
||||
Some(Tag::Compound(data)) => data.clone(),
|
||||
_ => Compound::new(),
|
||||
};
|
||||
if amount > 0 {
|
||||
data.insert(
|
||||
JavaString::from(TANK),
|
||||
Tag::compound([
|
||||
("Fluid", Tag::String(JavaString::from(fluid))),
|
||||
("Amount", Tag::Int(amount)),
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
data.remove(&JavaString::from(TANK));
|
||||
}
|
||||
let value = (!data.is_empty()).then_some(Tag::Compound(data));
|
||||
one.data_mut().set_other(CUSTOM_DATA, value);
|
||||
// What a server sent for the stack no longer describes it; kept, an
|
||||
// emptied container would not stack with a plain one.
|
||||
one.data_mut().wire = Default::default();
|
||||
one.tidy();
|
||||
one
|
||||
}
|
||||
|
||||
/// `stack` as a fresh single `item`, as `BucketItem` makes its result.
|
||||
fn bucket(item: &str) -> ItemStack {
|
||||
ItemStack::new(item, 1)
|
||||
}
|
||||
|
||||
/// What one item of `stack` holds, if it is a container: a bucket, or an item
|
||||
/// whose mod gave it `capacity`.
|
||||
fn contents(stack: &ItemStack, capacity: Option<i32>) -> Option<NativeItemFluid> {
|
||||
if stack.count == 0 {
|
||||
return None;
|
||||
}
|
||||
if &*stack.item == "minecraft:bucket" {
|
||||
return Some(NativeItemFluid {
|
||||
fluid: None,
|
||||
amount: 0,
|
||||
capacity: BUCKET,
|
||||
});
|
||||
}
|
||||
if let Some((fluid, _)) = BUCKETS.iter().find(|(_, full)| *full == &*stack.item) {
|
||||
return Some(NativeItemFluid {
|
||||
fluid: Some((*fluid).to_owned()),
|
||||
amount: BUCKET,
|
||||
capacity: BUCKET,
|
||||
});
|
||||
}
|
||||
let capacity = capacity?;
|
||||
let (fluid, amount) = read_tank(stack);
|
||||
Some(NativeItemFluid {
|
||||
fluid,
|
||||
amount,
|
||||
capacity,
|
||||
})
|
||||
}
|
||||
|
||||
/// How much of `amount` of `fluid` one item of `stack` takes, and the item
|
||||
/// it becomes.
|
||||
fn fill_one(
|
||||
stack: &ItemStack,
|
||||
capacity: Option<i32>,
|
||||
fluid: &str,
|
||||
amount: i32,
|
||||
) -> Option<(i32, ItemStack)> {
|
||||
let held = contents(stack, capacity)?;
|
||||
let mut unchanged = stack.clone();
|
||||
unchanged.count = 1;
|
||||
if !is_fluid(fluid) || amount <= 0 {
|
||||
return Some((0, unchanged));
|
||||
}
|
||||
if &*stack.item == "minecraft:bucket" {
|
||||
// All or nothing: a bucket takes a whole bucket.
|
||||
let full = BUCKETS.iter().find(|(f, _)| *f == fluid)?.1;
|
||||
return Some(if amount >= BUCKET {
|
||||
(BUCKET, bucket(full))
|
||||
} else {
|
||||
(0, unchanged)
|
||||
});
|
||||
}
|
||||
if capacity.is_none() || held.fluid.as_deref().is_some_and(|f| f != fluid) {
|
||||
return Some((0, unchanged));
|
||||
}
|
||||
let take = (held.capacity - held.amount).max(0).min(amount);
|
||||
if take == 0 {
|
||||
return Some((0, unchanged));
|
||||
}
|
||||
Some((take, with_tank(stack, fluid, held.amount + take)))
|
||||
}
|
||||
|
||||
/// The fluid and how much one item of `stack` gives of at most `max`, and
|
||||
/// the item it becomes.
|
||||
fn drain_one(
|
||||
stack: &ItemStack,
|
||||
capacity: Option<i32>,
|
||||
max: i32,
|
||||
) -> Option<(String, i32, ItemStack)> {
|
||||
let held = contents(stack, capacity)?;
|
||||
let fluid = held.fluid?;
|
||||
if max <= 0 {
|
||||
return None;
|
||||
}
|
||||
if capacity.is_none() || BUCKETS.iter().any(|(_, full)| *full == &*stack.item) {
|
||||
// A full bucket gives its whole bucket or nothing.
|
||||
return (max >= BUCKET).then(|| (fluid, BUCKET, bucket("minecraft:bucket")));
|
||||
}
|
||||
let give = held.amount.min(max);
|
||||
Some((
|
||||
fluid.clone(),
|
||||
give,
|
||||
with_tank(stack, &fluid, held.amount - give),
|
||||
))
|
||||
}
|
||||
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// The capacity a mod gave item `item`, clamped; none for any other item.
|
||||
fn native_item_tank_capacity(&self, item: &str) -> Option<i32> {
|
||||
if item.starts_with("minecraft:") {
|
||||
return None;
|
||||
}
|
||||
let mut capacity = None;
|
||||
self.native_item_fluid_tank(item, &mut capacity);
|
||||
capacity.map(|c| c.clamp(*MOD_CAPACITY.start(), *MOD_CAPACITY.end()))
|
||||
}
|
||||
/// `Capabilities.FluidHandler.ITEM`, read: the fluid, amount and capacity
|
||||
/// of one item of `stack`. A bucket holds a bucket of water or lava or
|
||||
/// nothing; a mod's container what it keeps in its `custom_data`. None
|
||||
/// for any other item, and for an empty stack.
|
||||
pub(in crate::game) fn native_item_fluid(&self, stack: &ItemStack) -> Option<NativeItemFluid> {
|
||||
contents(stack, self.native_item_tank_capacity(&stack.item))
|
||||
}
|
||||
/// Fills one item of `stack` with up to `amount` millibuckets of `fluid`
|
||||
/// (`minecraft:water`, `minecraft:lava`), as `IFluidHandler.fill`: a
|
||||
/// bucket takes a whole bucket or nothing and becomes the full one; a
|
||||
/// mod's container takes what fits of one fluid. The amount it takes, and
|
||||
/// the single item it becomes (the item itself when it took nothing). The
|
||||
/// stack given is not changed: shrink it by one and hand out the result.
|
||||
/// None where the stack is no container.
|
||||
pub(in crate::game) fn native_item_fluid_fill(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
fluid: &str,
|
||||
amount: i32,
|
||||
) -> Option<(i32, ItemStack)> {
|
||||
fill_one(
|
||||
stack,
|
||||
self.native_item_tank_capacity(&stack.item),
|
||||
fluid,
|
||||
amount,
|
||||
)
|
||||
}
|
||||
/// Drains up to `max` millibuckets out of one item of `stack`, as
|
||||
/// `IFluidHandler.drain`: a full bucket gives its whole bucket or nothing
|
||||
/// and becomes an empty one; a mod's container gives what it holds, up to
|
||||
/// `max`. The fluid, the amount, and the single item it becomes. The stack
|
||||
/// given is not changed. None where it is no container, holds nothing, or
|
||||
/// `max` is less than a bucket's for a bucket.
|
||||
pub(in crate::game) fn native_item_fluid_drain(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
max: i32,
|
||||
) -> Option<(String, i32, ItemStack)> {
|
||||
drain_one(stack, self.native_item_tank_capacity(&stack.item), max)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
const W: &str = "minecraft:water";
|
||||
const L: &str = "minecraft:lava";
|
||||
|
||||
fn canister(count: u32) -> ItemStack {
|
||||
ItemStack::new("mymod:canister", count)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_bucket_takes_a_whole_bucket_or_nothing() {
|
||||
let empty = ItemStack::new("minecraft:bucket", 3);
|
||||
let (took, result) = fill_one(&empty, None, W, 1500).unwrap();
|
||||
assert_eq!(
|
||||
(took, &*result.item, result.count),
|
||||
(1000, "minecraft:water_bucket", 1)
|
||||
);
|
||||
let (took, result) = fill_one(&empty, None, L, 1000).unwrap();
|
||||
assert_eq!((took, &*result.item), (1000, "minecraft:lava_bucket"));
|
||||
let (took, result) = fill_one(&empty, None, W, 999).unwrap();
|
||||
assert_eq!(
|
||||
(took, &*result.item, result.count),
|
||||
(0, "minecraft:bucket", 1)
|
||||
);
|
||||
// Not a fluid Horizon models.
|
||||
assert_eq!(fill_one(&empty, None, "minecraft:milk", 1000).unwrap().0, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_bucket_gives_a_whole_bucket_and_takes_nothing() {
|
||||
let full = ItemStack::new("minecraft:lava_bucket", 1);
|
||||
assert_eq!(fill_one(&full, None, L, 1000).unwrap().0, 0);
|
||||
assert_eq!(drain_one(&full, None, 999), None);
|
||||
let (fluid, took, result) = drain_one(&full, None, 5000).unwrap();
|
||||
assert_eq!(
|
||||
(fluid.as_str(), took, &*result.item),
|
||||
(L, 1000, "minecraft:bucket")
|
||||
);
|
||||
let empty = ItemStack::new("minecraft:bucket", 1);
|
||||
assert_eq!(drain_one(&empty, None, 1000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_items_are_no_containers() {
|
||||
let stone = ItemStack::new("minecraft:stone", 1);
|
||||
assert_eq!(contents(&stone, None), None);
|
||||
assert_eq!(fill_one(&stone, None, W, 1000), None);
|
||||
assert_eq!(drain_one(&stone, None, 1000), None);
|
||||
assert_eq!(contents(&ItemStack::new("minecraft:bucket", 0), None), None);
|
||||
// A mod item nobody gave a capacity is none either.
|
||||
assert_eq!(contents(&canister(1), None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mod_container_keeps_one_fluid_up_to_its_capacity() {
|
||||
let stack = canister(4);
|
||||
let (took, filled) = fill_one(&stack, Some(750), W, 500).unwrap();
|
||||
assert_eq!((took, filled.count), (500, 1));
|
||||
assert_eq!(read_tank(&filled), (Some(W.to_owned()), 500));
|
||||
// Only the room is taken, and only of the same fluid.
|
||||
let (took, full) = fill_one(&filled, Some(750), W, 500).unwrap();
|
||||
assert_eq!(took, 250);
|
||||
assert_eq!(read_tank(&full), (Some(W.to_owned()), 750));
|
||||
assert_eq!(fill_one(&full, Some(750), W, 1).unwrap().0, 0);
|
||||
assert_eq!(fill_one(&filled, Some(750), L, 100).unwrap().0, 0);
|
||||
assert_eq!(
|
||||
contents(&full, Some(750)),
|
||||
Some(NativeItemFluid {
|
||||
fluid: Some(W.to_owned()),
|
||||
amount: 750,
|
||||
capacity: 750
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draining_a_mod_container_empties_it_back_to_a_plain_stack() {
|
||||
let (_, filled) = fill_one(&canister(1), Some(1000), L, 300).unwrap();
|
||||
let (fluid, took, rest) = drain_one(&filled, Some(1000), 100).unwrap();
|
||||
assert_eq!((fluid.as_str(), took), (L, 100));
|
||||
assert_eq!(read_tank(&rest), (Some(L.to_owned()), 200));
|
||||
let (_, took, empty) = drain_one(&rest, Some(1000), 5000).unwrap();
|
||||
assert_eq!(took, 200);
|
||||
// Nothing left over, so it stacks with a container never filled.
|
||||
assert_eq!(empty, canister(1));
|
||||
assert_eq!(drain_one(&empty, Some(1000), 100), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_emptied_container_from_a_server_stacks_with_a_plain_one() {
|
||||
let mut sent = canister(2);
|
||||
sent.data_mut().wire =
|
||||
horizon_client::item_data::Wire::new(canister(2), Arc::from(vec![1u8]));
|
||||
let (_, filled) = fill_one(&sent, Some(100), W, 40).unwrap();
|
||||
let (_, _, emptied) = drain_one(&filled, Some(100), 40).unwrap();
|
||||
assert!(emptied.same_item_same_components(&canister(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_container_keeps_the_rest_of_its_custom_data() {
|
||||
let mut stack = canister(1);
|
||||
stack.data_mut().set_other(
|
||||
CUSTOM_DATA,
|
||||
Some(Tag::compound([(
|
||||
"owner",
|
||||
Tag::String(JavaString::from("Alex")),
|
||||
)])),
|
||||
);
|
||||
let (_, filled) = fill_one(&stack, Some(1000), W, 10).unwrap();
|
||||
let Some(Tag::Compound(data)) = filled.data().and_then(|d| d.other(CUSTOM_DATA)) else {
|
||||
panic!("no custom data");
|
||||
};
|
||||
assert!(data.contains_key(&JavaString::from("owner")));
|
||||
assert!(data.contains_key(&JavaString::from(TANK)));
|
||||
let (_, _, emptied) = drain_one(&filled, Some(1000), 10).unwrap();
|
||||
assert_eq!(emptied, stack);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
//! `native_exchange_items` swaps two players' stacks in one step, checked
|
||||
//! first, so a trade built on these menus never moves an item before both
|
||||
//! sides are settled.
|
||||
use super::Game;
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
lan::{data::component_nbt, menu::NativeMenuEvent},
|
||||
@@ -216,8 +216,148 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A menu a player has open, as the mods last heard it
|
||||
/// (`native_player_container_open`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(super) struct HeardMenu {
|
||||
/// The window id the player's client knows it by.
|
||||
window: i32,
|
||||
/// The menu type's name; a mount's inventory has none in vanilla.
|
||||
menu: String,
|
||||
/// The block the menu is of.
|
||||
pos: Option<[i32; 3]>,
|
||||
}
|
||||
|
||||
/// What a mount's inventory is called, as vanilla's `HorseInventoryMenu` has
|
||||
/// no menu type.
|
||||
const MOUNT_MENU: &str = "horizon:mount_inventory";
|
||||
|
||||
/// The menu `conn` has open, whichever way the game keeps it: a block's
|
||||
/// container, a trade, a crafting table, a workstation, a lectern or a
|
||||
/// mount's inventory. Not the player's own inventory menu.
|
||||
fn open_menu_of(host: &LanHost, conn: u32) -> Option<(i32, &str, Option<[i32; 3]>)> {
|
||||
let g = host.hub.guest(conn)?;
|
||||
let play = host.play.get(&conn);
|
||||
if let Some(m) = &g.menu {
|
||||
let menu = host.hub.registry_name("minecraft:menu", m.kind)?;
|
||||
// A vehicle's menu is keyed by a stand-in, not a block.
|
||||
let pos = m
|
||||
.pos
|
||||
.filter(|p| p.1 != horizon_client::lan::menu::VEHICLE_KEY_Y)
|
||||
.map(|p| [p.0, p.1, p.2]);
|
||||
return Some((m.window, menu, pos));
|
||||
}
|
||||
let play = play?;
|
||||
let (window, menu, pos) = if let Some(gm) = &play.merchant {
|
||||
(gm.window, "minecraft:merchant", None)
|
||||
} else if let Some((window, _, _, pos)) = &play.crafting.table {
|
||||
(*window, "minecraft:crafting", Some(*pos))
|
||||
} else if let Some(st) = &play.station {
|
||||
(
|
||||
st.window,
|
||||
super::lan_stations::menu_of(st.kind)?.0,
|
||||
Some(st.pos),
|
||||
)
|
||||
} else if let Some(l) = &play.lectern {
|
||||
(
|
||||
l.window,
|
||||
"minecraft:lectern",
|
||||
Some([l.key.0, l.key.1, l.key.2]),
|
||||
)
|
||||
} else if let Some(m) = &play.mount_window {
|
||||
(m.window, MOUNT_MENU, None)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some((window, menu, pos))
|
||||
}
|
||||
|
||||
/// Where the game calls the menu hook points.
|
||||
impl Game<'_> {
|
||||
/// The mods hear the menus the players here opened or closed since the
|
||||
/// last tick (`PlayerContainerEvent`), in that order for a menu another
|
||||
/// replaced: what each player has open is compared with what the mods last
|
||||
/// heard. A menu opened and closed again inside one tick is not heard.
|
||||
pub(super) fn tick_player_menus(&mut self, host: &mut LanHost) {
|
||||
let conns: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g))
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
// Compared as borrowed: nothing is built each tick while it stays open.
|
||||
let now = open_menu_of(host, conn);
|
||||
let heard = host.play.get(&conn).and_then(|p| p.menu_heard.as_ref());
|
||||
if now == heard.map(|h| (h.window, h.menu.as_str(), h.pos)) {
|
||||
continue;
|
||||
}
|
||||
let now = now.map(|(window, menu, pos)| HeardMenu {
|
||||
window,
|
||||
menu: menu.to_owned(),
|
||||
pos,
|
||||
});
|
||||
let heard = host.play.get(&conn).and_then(|p| p.menu_heard.clone());
|
||||
// Remembered first: a hook can run commands that close menus.
|
||||
if let Some(p) = host.play.get_mut(&conn) {
|
||||
p.menu_heard.clone_from(&now);
|
||||
}
|
||||
if let Some(old) = heard {
|
||||
self.player_menu_closed(host, conn, old);
|
||||
}
|
||||
if let Some(new) = now {
|
||||
self.player_menu_opened(host, conn, new);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `conn`'s menu closes with the guest in the level in place
|
||||
/// (`ServerPlayer.doCloseContainer`): the mods hear it, if they heard it
|
||||
/// open.
|
||||
pub(super) fn player_menu_closing(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let heard = host.play.get_mut(&conn).and_then(|p| p.menu_heard.take());
|
||||
if let Some(old) = heard {
|
||||
self.player_menu_closed(host, conn, old);
|
||||
}
|
||||
}
|
||||
|
||||
fn player_menu_opened(&mut self, host: &mut LanHost, conn: u32, menu: HeardMenu) {
|
||||
let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else {
|
||||
return;
|
||||
};
|
||||
let dimension = self.dim.id();
|
||||
self.player_hook(host, conn, move |g| {
|
||||
g.native_player_container_open(uuid, &name, dimension, &menu.menu, menu.pos);
|
||||
});
|
||||
}
|
||||
|
||||
fn player_menu_closed(&mut self, host: &mut LanHost, conn: u32, menu: HeardMenu) {
|
||||
let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else {
|
||||
return;
|
||||
};
|
||||
let dimension = self.dim.id();
|
||||
self.player_hook(host, conn, move |g| {
|
||||
g.native_player_container_close(uuid, &name, dimension, &menu.menu, menu.pos);
|
||||
});
|
||||
}
|
||||
|
||||
/// Runs a player hook with the guest's state in place, or as it is when
|
||||
/// the guest has no body to swap in.
|
||||
fn player_hook(&mut self, host: &mut LanHost, conn: u32, f: impl FnOnce(&mut Self)) {
|
||||
let mut f = Some(f);
|
||||
if self.lan_acting != Some(conn) {
|
||||
self.as_guest(host, conn, |g| {
|
||||
if let Some(f) = f.take() {
|
||||
f(g);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(f) = f {
|
||||
f(self);
|
||||
}
|
||||
}
|
||||
|
||||
/// The clicks on and closings of native menus the hub collected, each
|
||||
/// told to the mods with the hub back in `self.lan`, no player's state
|
||||
/// swapped in.
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
//! it cancels) gets the mob's render state and a [`BlockEntityDraw`] posed at
|
||||
//! its feet and turned with its body, so a mod draws a model of its own with
|
||||
//! the helpers block entities draw with (`native_draw_cube`,
|
||||
//! `native_draw_quad`, `native_draw_block`, `native_draw_item`).
|
||||
//! `native_draw_quad`, `native_draw_block`, `native_draw_item`). A player
|
||||
//! has `native_player_submit` (`RenderPlayerEvent.Pre`), and every mob and
|
||||
//! player in view gets `native_mob_layer` after its vanilla model
|
||||
//! (`EntityRenderersEvent.AddLayers`), in the same frame.
|
||||
|
||||
use super::{Dim, Game, native_block_entity_draw::BlockEntityDraw};
|
||||
use glam::{DVec3, Mat4};
|
||||
@@ -77,6 +80,18 @@ impl<'a> NativeMobRender<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which player a [`NativeMobRender`] is, for `native_player_submit` and
|
||||
/// `native_mob_layer`.
|
||||
// Native mods read it; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct NativePlayerRender<'a> {
|
||||
/// The player's profile name.
|
||||
pub name: &'a str,
|
||||
/// Whether it is the player of this window, drawn in third person.
|
||||
pub local: bool,
|
||||
}
|
||||
|
||||
/// The pose a mod's mob model starts from: at the feet, `at` (relative to the
|
||||
/// camera), turned with the body so +z is the way it faces, +x its left and +y
|
||||
/// up, in blocks.
|
||||
@@ -96,6 +111,69 @@ impl Game<'_> {
|
||||
camera: DVec3,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) -> bool {
|
||||
self.mod_entity_draw(mob, partial, camera, light, out, |render, draw| {
|
||||
let mut drawn = false;
|
||||
self.native_mob_submit(render, draw, &mut drawn);
|
||||
drawn
|
||||
})
|
||||
}
|
||||
/// `native_player_submit` for the player `mob` (`who` says which): true
|
||||
/// when a mod drew it and the vanilla model, armour, held items, cape and
|
||||
/// other layers are left out.
|
||||
pub(super) fn mod_player_geometry(
|
||||
&self,
|
||||
mob: &Mob,
|
||||
who: NativePlayerRender<'_>,
|
||||
partial: f32,
|
||||
camera: DVec3,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) -> bool {
|
||||
// Posing a player for the mods costs, each frame: only when one listens.
|
||||
let mut listened = false;
|
||||
self.native_player_render_listened(&mut listened);
|
||||
if !listened {
|
||||
return false;
|
||||
}
|
||||
self.mod_entity_draw(mob, partial, camera, light, out, |render, draw| {
|
||||
let mut drawn = false;
|
||||
self.native_player_submit(render, &who, draw, &mut drawn);
|
||||
drawn
|
||||
})
|
||||
}
|
||||
/// `native_mob_layer` for `mob` (`who` set for a player): what a mod adds
|
||||
/// is kept, drawn after the vanilla model and its layers.
|
||||
pub(super) fn mod_mob_layer_geometry(
|
||||
&self,
|
||||
mob: &Mob,
|
||||
who: Option<NativePlayerRender<'_>>,
|
||||
partial: f32,
|
||||
camera: DVec3,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
// Every mob in view each frame: only when a mod listens.
|
||||
let mut listened = false;
|
||||
self.native_player_render_listened(&mut listened);
|
||||
if !listened {
|
||||
return;
|
||||
}
|
||||
self.mod_entity_draw(mob, partial, camera, light, out, |render, draw| {
|
||||
self.native_mob_layer(render, who.as_ref(), draw);
|
||||
true
|
||||
});
|
||||
}
|
||||
/// Gives `hook` the frame of `mob` and a draw posed at its feet; what it
|
||||
/// added to `out` stays when it answers true.
|
||||
fn mod_entity_draw(
|
||||
&self,
|
||||
mob: &Mob,
|
||||
partial: f32,
|
||||
camera: DVec3,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
hook: impl FnOnce(&NativeMobRender<'_>, &mut BlockEntityDraw) -> bool,
|
||||
) -> bool {
|
||||
let render = NativeMobRender::of(mob, partial);
|
||||
let a = f64::from(partial);
|
||||
@@ -111,13 +189,12 @@ impl Game<'_> {
|
||||
diffuse_lights(self.dim == Dim::Nether),
|
||||
std::mem::take(out),
|
||||
);
|
||||
let mut drawn = false;
|
||||
self.native_mob_submit(&render, &mut draw, &mut drawn);
|
||||
let keep = hook(&render, &mut draw);
|
||||
*out = draw.into_vertices();
|
||||
if !drawn {
|
||||
if !keep {
|
||||
out.truncate(start);
|
||||
}
|
||||
drawn
|
||||
keep
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,53 @@
|
||||
use super::{Game, commands::Target};
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
/// The `EquipmentSlot` names of `Mob::equipment_stacks`, in its order.
|
||||
const MOB_SLOTS: [&str; 8] = [
|
||||
"mainhand", "offhand", "feet", "legs", "chest", "head", "body", "saddle",
|
||||
];
|
||||
|
||||
impl Game<'_> {
|
||||
/// `LivingEntity.collectEquipmentChanges` for the mobs that tick: each
|
||||
/// slot that holds another stack than the mods last heard is told
|
||||
/// (`native_living_equipment_change`). A mob new to the level counts from
|
||||
/// empty, as vanilla's `lastEquipmentItems` does. Only while a mod
|
||||
/// listens (`native_living_equipment_listened`): a mob's first tick after
|
||||
/// that counts from empty too.
|
||||
pub(super) fn native_mobs_equipment(&mut self) {
|
||||
if self.remote.is_some() || self.options.execution_role.is_client() {
|
||||
return;
|
||||
}
|
||||
// Reading every mob's equipment each tick costs: only when a mod listens.
|
||||
let mut listened = false;
|
||||
self.native_living_equipment_listened(&mut listened);
|
||||
if !listened {
|
||||
return;
|
||||
}
|
||||
let ticking = self.entity_ticking_area();
|
||||
let mut changes = Vec::new();
|
||||
for m in self.mobs.iter_mut().filter(|m| !m.removed) {
|
||||
if !ticking(m.position.x.floor() as i32, m.position.z.floor() as i32) {
|
||||
continue;
|
||||
}
|
||||
let now = m.equipment_stacks();
|
||||
let heard = m.extra.equipment_heard.get_or_insert_with(Default::default);
|
||||
if **heard == now {
|
||||
continue;
|
||||
}
|
||||
for (slot, name) in MOB_SLOTS.into_iter().enumerate() {
|
||||
if now[slot] != heard[slot] {
|
||||
let from = heard[slot].take();
|
||||
changes.push((m.id, m.kind.id.clone(), name, from, now[slot].clone()));
|
||||
}
|
||||
}
|
||||
**heard = now;
|
||||
}
|
||||
for (id, kind, slot, from, to) in changes {
|
||||
self.native_living_equipment_change(id, &kind, slot, from.as_ref(), to.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A mob as a native mod reads it.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Mounting for native mods (`native_entity_mount`: NeoForge's `EntityMountEvent`). Each place
|
||||
//! the acting player or a mob gets on or off a vehicle asks `native_may_board` or
|
||||
//! `native_may_leave` first, and does nothing when a mod says no.
|
||||
use super::{Game, native_hooks::mob_kind};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether this game is a server whose mounts the mods hear: the server thread or a
|
||||
/// dedicated server, never a client nor the deprecated in-frame path.
|
||||
fn native_hears_mounts(&self) -> bool {
|
||||
self.serving.is_some() || self.options.dedicated.is_some()
|
||||
}
|
||||
|
||||
/// The entity id of the acting player: a guest's while their action runs, else the host's.
|
||||
fn native_acting_entity(&self) -> u32 {
|
||||
let guests = self.lan_guest_entities();
|
||||
match self.lan_acting {
|
||||
Some(conn) => guests.iter().find(|g| g.0 == conn).map_or(0, |g| g.1),
|
||||
None => self.lan.as_ref().map_or(0, |host| host.host_entity as u32),
|
||||
}
|
||||
}
|
||||
|
||||
/// The type of the vehicle with entity id `id`.
|
||||
fn native_vehicle_kind(&self, id: u32) -> String {
|
||||
if let Some(b) = self.boats.iter().find(|b| b.id == id) {
|
||||
b.entity_type()
|
||||
} else if let Some(c) = self.carts.iter().find(|c| c.id == id) {
|
||||
c.kind.id().into()
|
||||
} else if self.cushions.iter().any(|c| c.id == id) {
|
||||
"minecraft:cushion".into()
|
||||
} else if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
|
||||
mob_kind(m)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// `native_entity_mount` for `rider` (the acting player when `None`) and `vehicle`: whether
|
||||
/// the mounting or dismounting goes ahead.
|
||||
fn native_mount_allowed(&mut self, rider: Option<u32>, vehicle: u32, mounting: bool) -> bool {
|
||||
if !self.native_hears_mounts() {
|
||||
return true;
|
||||
}
|
||||
let (entity, kind) = match rider {
|
||||
Some(id) => (
|
||||
id,
|
||||
self.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map_or_else(String::new, mob_kind),
|
||||
),
|
||||
None => (self.native_acting_entity(), "minecraft:player".into()),
|
||||
};
|
||||
let vehicle_kind = self.native_vehicle_kind(vehicle);
|
||||
let mut canceled = false;
|
||||
self.native_entity_mount(
|
||||
self.dim.id(),
|
||||
entity,
|
||||
&kind,
|
||||
vehicle,
|
||||
&vehicle_kind,
|
||||
mounting,
|
||||
&mut canceled,
|
||||
);
|
||||
!canceled
|
||||
}
|
||||
|
||||
/// The acting player is about to board `vehicle`: false when a mod canceled it.
|
||||
pub(super) fn native_may_board(&mut self, vehicle: u32) -> bool {
|
||||
self.native_mount_allowed(None, vehicle, true)
|
||||
}
|
||||
|
||||
/// The acting player is about to get off `vehicle`: false when a mod canceled it.
|
||||
pub(super) fn native_may_leave(&mut self, vehicle: u32) -> bool {
|
||||
self.native_mount_allowed(None, vehicle, false)
|
||||
}
|
||||
|
||||
/// The mob `rider` is about to board (`mounting`) or leave `vehicle`: false when a mod
|
||||
/// canceled it.
|
||||
pub(super) fn native_mob_may_ride(&mut self, rider: u32, vehicle: u32, mounting: bool) -> bool {
|
||||
self.native_mount_allowed(Some(rider), vehicle, mounting)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
//! Projectile impacts for native mods (`native_projectile_impact`, NeoForge's
|
||||
//! `ProjectileImpactEvent`). `tick_projectiles` flies every projectile first and
|
||||
//! collects the impacts; once its worlds are let go, the mods hear each one, and a
|
||||
//! projectile whose impact a mod canceled flies the tick again from where it was,
|
||||
//! through whatever it hit.
|
||||
use super::{
|
||||
Game,
|
||||
creatures::{ProjWorld, wind_charge_hit},
|
||||
dragon::{CRYSTAL_IDS, SPECIAL},
|
||||
lan_pvp::meets_players,
|
||||
native_hooks::mob_kind,
|
||||
};
|
||||
use horizon_client::{
|
||||
arrows::ArrowEvent,
|
||||
mobs::Who,
|
||||
projectiles::{Hit, Projectile},
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::ops::Range;
|
||||
|
||||
/// A projectile's impact in one tick of `tick_projectiles`.
|
||||
pub(super) struct Impact {
|
||||
/// Where the projectile was in the tick's list.
|
||||
pub index: usize,
|
||||
/// The projectile before the tick.
|
||||
pub before: Projectile,
|
||||
pub hit: Hit,
|
||||
/// Where its hit is in the tick's `hits`.
|
||||
pub hit_slot: usize,
|
||||
/// Where its arrow events are in the tick's `arrow_events`.
|
||||
pub events: Range<usize>,
|
||||
}
|
||||
|
||||
/// The type the mods know a projectile by: a spectral arrow is its own.
|
||||
fn projectile_type(p: &Projectile) -> &'static str {
|
||||
let spectral = p
|
||||
.arrow
|
||||
.as_ref()
|
||||
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow");
|
||||
if spectral {
|
||||
"minecraft:spectral_arrow"
|
||||
} else {
|
||||
p.kind.id()
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether this game is a server that flies projectiles for the mods (the
|
||||
/// server thread or a dedicated server, never the deprecated in-frame path
|
||||
/// nor a client) and a mod asked to hear their impacts
|
||||
/// (`native_hears_projectile_impacts`). Without one, the tick keeps no copy
|
||||
/// of its projectiles: it costs nothing.
|
||||
pub(super) fn native_hears_impacts(&self) -> bool {
|
||||
if self.serving.is_none() && self.options.dedicated.is_none() {
|
||||
return false;
|
||||
}
|
||||
let mut hears = false;
|
||||
self.native_hears_projectile_impacts(&mut hears);
|
||||
hears
|
||||
}
|
||||
|
||||
/// The entity id of the one who shot `p`: a mob's, a player's, else 0.
|
||||
fn native_impact_owner(&self, p: &Projectile) -> u32 {
|
||||
match p.owner {
|
||||
Some(Who::Mob(id)) => id,
|
||||
Some(Who::Player) => match p.guest {
|
||||
Some(conn) => self
|
||||
.lan_guest_entities()
|
||||
.into_iter()
|
||||
.find(|&(c, _)| c == conn)
|
||||
.map_or(0, |(_, id)| id),
|
||||
None => self.lan.as_ref().map_or(0, |host| host.host_entity as u32),
|
||||
},
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The entity id and type a projectile's hit on the reserved or ordinary
|
||||
/// `id` stands for: a dragon part is the dragon, an End crystal its own id.
|
||||
fn native_impact_entity(&self, id: u32) -> (u32, String) {
|
||||
if id >= SPECIAL {
|
||||
return if id < CRYSTAL_IDS {
|
||||
let dragon = self.dragon.as_ref().map_or(0, |d| d.id);
|
||||
(dragon, "minecraft:ender_dragon".into())
|
||||
} else {
|
||||
let crystal = self.crystals.get((id - CRYSTAL_IDS) as usize);
|
||||
(crystal.map_or(0, |c| c.id), "minecraft:end_crystal".into())
|
||||
};
|
||||
}
|
||||
if let Some(mob) = self.mobs.iter().find(|m| m.id == id) {
|
||||
return (id, mob_kind(mob));
|
||||
}
|
||||
if let Some(cart) = self.carts.iter().find(|c| c.id == id) {
|
||||
return (id, cart.kind.id().into());
|
||||
}
|
||||
if self.lan_guest_entities().iter().any(|&(_, e)| e == id) {
|
||||
return (id, "minecraft:player".into());
|
||||
}
|
||||
(id, String::new())
|
||||
}
|
||||
|
||||
/// `native_projectile_impact` for each of `impacts`: whether the mods
|
||||
/// canceled it. A hit on a wind charge is not told.
|
||||
fn native_projectile_impacts(&mut self, impacts: &[Impact]) -> Vec<bool> {
|
||||
let dimension = self.dim.id();
|
||||
let mut canceled = Vec::with_capacity(impacts.len());
|
||||
for impact in impacts {
|
||||
let p = &impact.before;
|
||||
let (entity, entity_kind, block, face, at) = match impact.hit {
|
||||
Hit::Block { pos, at, face } => (None, String::new(), pos, face, at),
|
||||
Hit::Entity {
|
||||
who: Who::Mob(id),
|
||||
at,
|
||||
} => {
|
||||
if wind_charge_hit(id).is_some() {
|
||||
canceled.push(false);
|
||||
continue;
|
||||
}
|
||||
let (entity, kind) = self.native_impact_entity(id);
|
||||
(Some(entity), kind, [0; 3], [0; 3], at)
|
||||
}
|
||||
Hit::Entity {
|
||||
who: Who::Player,
|
||||
at,
|
||||
} => {
|
||||
let Some(host) = self.lan.as_ref().map(|h| h.host_entity as u32) else {
|
||||
canceled.push(false);
|
||||
continue;
|
||||
};
|
||||
(Some(host), "minecraft:player".into(), [0; 3], [0; 3], at)
|
||||
}
|
||||
Hit::None => {
|
||||
canceled.push(false);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let owner = self.native_impact_owner(p);
|
||||
let mut stop = false;
|
||||
self.native_projectile_impact(
|
||||
dimension,
|
||||
p.id,
|
||||
projectile_type(p),
|
||||
owner,
|
||||
entity,
|
||||
&entity_kind,
|
||||
block,
|
||||
face,
|
||||
[at.x, at.y, at.z],
|
||||
&mut stop,
|
||||
);
|
||||
canceled.push(stop);
|
||||
}
|
||||
canceled
|
||||
}
|
||||
|
||||
/// The end of a tick of `tick_projectiles` that met `impacts`, with
|
||||
/// `projectiles` in place (those it keeps, as `listed` says of the tick's
|
||||
/// list): the mods hear each, and a projectile one canceled flies the tick
|
||||
/// again from before it, through what it hit. Its hit and its arrow events are
|
||||
/// taken out of `hits` and `arrow_events`, and it goes back among the
|
||||
/// projectiles. One a mod removed in its hook stays removed.
|
||||
pub(super) fn native_settle_impacts(
|
||||
&mut self,
|
||||
projectiles: &mut Vec<Projectile>,
|
||||
listed: &[bool],
|
||||
impacts: Vec<Impact>,
|
||||
hits: &mut Vec<(Projectile, Hit)>,
|
||||
arrow_events: &mut Vec<(Vec3, ArrowEvent)>,
|
||||
) {
|
||||
// The mods see the projectiles where they will be, as `projectile_hit` does.
|
||||
self.creatures.projectiles = std::mem::take(projectiles);
|
||||
let canceled = self.native_projectile_impacts(&impacts);
|
||||
*projectiles = std::mem::take(&mut self.creatures.projectiles);
|
||||
if !canceled.contains(&true) {
|
||||
return;
|
||||
}
|
||||
let mut drop_hits = vec![false; hits.len()];
|
||||
let mut drop_events = vec![false; arrow_events.len()];
|
||||
let mut flown = Vec::new();
|
||||
for (impact, _) in impacts.into_iter().zip(canceled).filter(|(_, c)| *c) {
|
||||
drop_hits[impact.hit_slot] = true;
|
||||
drop_events[impact.events.clone()].fill(true);
|
||||
let present = projectiles.iter().position(|q| q.id == impact.before.id);
|
||||
if present.is_none() && listed[impact.index] {
|
||||
continue;
|
||||
}
|
||||
let (p, keep, events) = self.native_projectile_through(impact.before);
|
||||
flown.extend(events);
|
||||
match (present, keep) {
|
||||
(Some(i), true) => projectiles[i] = p,
|
||||
(Some(i), false) => {
|
||||
projectiles.remove(i);
|
||||
}
|
||||
(None, true) => projectiles.push(p),
|
||||
(None, false) => {}
|
||||
}
|
||||
}
|
||||
let mut n = 0;
|
||||
hits.retain(|_| {
|
||||
n += 1;
|
||||
!drop_hits[n - 1]
|
||||
});
|
||||
let mut n = 0;
|
||||
arrow_events.retain(|_| {
|
||||
n += 1;
|
||||
!drop_events[n - 1]
|
||||
});
|
||||
arrow_events.extend(flown);
|
||||
}
|
||||
|
||||
/// `p`, as it was before its tick, flown that tick through what it hit
|
||||
/// (`Projectile::tick_through`, `arrows::tick_through`): the projectile after
|
||||
/// it, whether it stays, and the arrow events it made.
|
||||
fn native_projectile_through(
|
||||
&self,
|
||||
mut p: Projectile,
|
||||
) -> (Projectile, bool, Vec<(Vec3, ArrowEvent)>) {
|
||||
let mob_shot = !matches!(p.owner, Some(Who::Player));
|
||||
let pvp = !mob_shot && meets_players(&p.kind);
|
||||
// A guest's shot flies from that guest, as in `tick_projectiles`.
|
||||
let shooter = p
|
||||
.guest
|
||||
.filter(|_| pvp)
|
||||
.and_then(|c| self.lan_guest_entities().into_iter().find(|&(g, _)| g == c))
|
||||
.map(|(_, id)| Who::Mob(id));
|
||||
if shooter.is_some() {
|
||||
p.owner = shooter;
|
||||
}
|
||||
let mut targets = self.projectile_targets();
|
||||
if p.arrow.is_some() {
|
||||
targets.extend(self.carts.iter().map(|c| (Who::Mob(c.id), c.bounds())));
|
||||
}
|
||||
if mob_shot || pvp {
|
||||
targets.extend(self.lan_projectile_targets());
|
||||
}
|
||||
let rain = self.arrow_rain();
|
||||
let scene = self.arrow_scene(&rain);
|
||||
let (keep, _) = if p.arrow.is_some() {
|
||||
horizon_client::arrows::tick_through(&mut p, &scene, &targets)
|
||||
} else {
|
||||
let w = self.mob_world_base(&[]);
|
||||
p.tick_through(&ProjWorld { w: &w, targets })
|
||||
};
|
||||
if shooter.is_some() {
|
||||
p.owner = Some(Who::Player);
|
||||
}
|
||||
let at = p.position;
|
||||
let events = p
|
||||
.arrow
|
||||
.as_mut()
|
||||
.map(|a| a.events.drain(..).map(|e| (at, e)).collect())
|
||||
.unwrap_or_default();
|
||||
(p, keep, events)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Entities ticking for native mods (`native_entity_tick_pre`, `native_entity_tick_post`:
|
||||
//! NeoForge's `EntityTickEvent.Pre` and `.Post`). A family of entities ticks in its own loop,
|
||||
//! and the loops of the mobs and the items hold their lists out of `Game`, so a family hands
|
||||
//! its ticking entities to a `TickRound` before the loop and hears the Posts after it; the
|
||||
//! minecarts and boats, whose loops can call the hooks, ask for each one in turn.
|
||||
use super::Game;
|
||||
use horizon_client::mobs::MobType;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The type of an entity in a round, as the hooks name it.
|
||||
pub(super) enum TickKind {
|
||||
Fixed(&'static str),
|
||||
Mob(Arc<MobType>),
|
||||
}
|
||||
|
||||
impl TickKind {
|
||||
fn id(&self) -> &str {
|
||||
match self {
|
||||
Self::Fixed(id) => id,
|
||||
Self::Mob(kind) => &kind.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The entities of one family in one tick.
|
||||
#[derive(Default)]
|
||||
pub(super) struct TickRound {
|
||||
/// Whether this game is a server the mods hear: otherwise nothing is told or kept.
|
||||
listening: bool,
|
||||
/// The entities the Pre let through, in order, for the Post.
|
||||
ticking: Vec<(u32, TickKind)>,
|
||||
/// The entities a Pre canceled.
|
||||
canceled: Vec<u32>,
|
||||
}
|
||||
|
||||
impl TickRound {
|
||||
/// Whether a mod canceled the tick of entity `id`.
|
||||
pub(super) fn canceled(&self, id: u32) -> bool {
|
||||
!self.canceled.is_empty() && self.canceled.contains(&id)
|
||||
}
|
||||
/// Whether the mods hear this round.
|
||||
pub(super) fn listening(&self) -> bool {
|
||||
self.listening
|
||||
}
|
||||
/// The entity `id`, told its Pre, went before it ticked (an item merged into an earlier
|
||||
/// one): it hears no Post.
|
||||
pub(super) fn not_ticked(&mut self, id: u32) {
|
||||
if self.listening {
|
||||
self.ticking.retain(|(other, _)| *other != id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A round for a family about to tick: on a server only (the server thread or a dedicated
|
||||
/// server, never a client nor the deprecated in-frame path), and only while a mod listens
|
||||
/// (`native_entity_tick_listened`). Its list is kept between ticks.
|
||||
pub(super) fn native_tick_round(&mut self) -> TickRound {
|
||||
let server = self.serving.is_some() || self.options.dedicated.is_some();
|
||||
let mut listened = false;
|
||||
if server {
|
||||
self.native_entity_tick_listened(&mut listened);
|
||||
}
|
||||
let listening = server && listened;
|
||||
TickRound {
|
||||
listening,
|
||||
ticking: if listening {
|
||||
std::mem::take(&mut self.creatures.tick_buffer)
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
canceled: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `native_entity_tick_pre` for the entity `id` of `kind` about to tick in a family that
|
||||
/// hears its Posts together: whether it ticks.
|
||||
pub(super) fn native_tick_pre_batch(
|
||||
&mut self,
|
||||
round: &mut TickRound,
|
||||
id: u32,
|
||||
kind: TickKind,
|
||||
) -> bool {
|
||||
if !round.listening {
|
||||
return true;
|
||||
}
|
||||
let mut canceled = false;
|
||||
self.native_entity_tick_pre(self.dim.id(), id, kind.id(), &mut canceled);
|
||||
if canceled {
|
||||
round.canceled.push(id);
|
||||
} else {
|
||||
round.ticking.push((id, kind));
|
||||
}
|
||||
!canceled
|
||||
}
|
||||
|
||||
/// `native_entity_tick_post` for each entity the round let through, and the round is over.
|
||||
pub(super) fn native_tick_post_batch(&mut self, mut round: TickRound) {
|
||||
if !round.listening {
|
||||
return;
|
||||
}
|
||||
let dimension = self.dim.id();
|
||||
for (id, kind) in &round.ticking {
|
||||
self.native_entity_tick_post(dimension, *id, kind.id());
|
||||
}
|
||||
round.ticking.clear();
|
||||
self.creatures.tick_buffer = round.ticking;
|
||||
}
|
||||
|
||||
/// `native_entity_tick_pre` for one entity about to tick: whether it does.
|
||||
pub(super) fn native_tick_pre_one(
|
||||
&mut self,
|
||||
round: &mut TickRound,
|
||||
id: u32,
|
||||
kind: &str,
|
||||
) -> bool {
|
||||
if !round.listening {
|
||||
return true;
|
||||
}
|
||||
let mut canceled = false;
|
||||
self.native_entity_tick_pre(self.dim.id(), id, kind, &mut canceled);
|
||||
if canceled {
|
||||
round.canceled.push(id);
|
||||
}
|
||||
!canceled
|
||||
}
|
||||
|
||||
/// `native_entity_tick_post` for one entity that ticked.
|
||||
pub(super) fn native_tick_post_one(&mut self, round: &TickRound, id: u32, kind: &str) {
|
||||
if round.listening {
|
||||
self.native_entity_tick_post(self.dim.id(), id, kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn round() -> TickRound {
|
||||
TickRound {
|
||||
listening: true,
|
||||
..TickRound::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A canceled entity is skipped, and one that went before its tick (an item merged into an
|
||||
/// earlier one) hears no Post; a round nobody listens to keeps nothing.
|
||||
#[test]
|
||||
fn a_round_keeps_the_entities_that_tick() {
|
||||
let mut r = round();
|
||||
r.ticking.push((1, TickKind::Fixed("minecraft:item")));
|
||||
r.ticking.push((2, TickKind::Fixed("minecraft:item")));
|
||||
r.canceled.push(3);
|
||||
assert!(r.canceled(3));
|
||||
assert!(!r.canceled(1));
|
||||
r.not_ticked(2);
|
||||
let left: Vec<_> = r
|
||||
.ticking
|
||||
.iter()
|
||||
.map(|(id, kind)| (*id, kind.id()))
|
||||
.collect();
|
||||
assert_eq!(left, [(1, "minecraft:item")]);
|
||||
let mut quiet = TickRound::default();
|
||||
assert!(!quiet.listening());
|
||||
quiet.not_ticked(1);
|
||||
assert!(quiet.ticking.is_empty() && !quiet.canceled(1));
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,9 @@ impl Game<'_> {
|
||||
if tame && !food {
|
||||
// doPlayerRide.
|
||||
let id = self.mobs[index].id;
|
||||
if !self.native_may_board(id) {
|
||||
return true;
|
||||
}
|
||||
self.break_leash(id, true, false);
|
||||
self.yaw = self.mobs[index].yaw;
|
||||
self.riding = Some(id);
|
||||
|
||||
@@ -372,7 +372,8 @@ impl Game<'_> {
|
||||
.cem
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = cem;
|
||||
// The random entity textures the new atlas packs: every mob chooses again.
|
||||
// The random entity textures the new atlas packs. A new scan is a new generation:
|
||||
// every mob (every splitscreen player's too) chooses again on its next tick.
|
||||
*self
|
||||
.res
|
||||
.mob_data()
|
||||
@@ -380,9 +381,6 @@ impl Game<'_> {
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) =
|
||||
std::mem::take(&mut self.gui.atlas.random_textures);
|
||||
for m in &mut self.mobs {
|
||||
m.random_textures.forget_picks();
|
||||
}
|
||||
horizon_client::ui::load_metadata(&root);
|
||||
lang::set(lang::Language::load(&[&root], &lang::code()));
|
||||
self.refresh_remote_language();
|
||||
|
||||
@@ -27,7 +27,15 @@ fn zombie_kind(s: Species) -> bool {
|
||||
impl Game<'_> {
|
||||
/// `Entity.startRiding` for two mobs.
|
||||
pub(super) fn mount_mob(&mut self, rider: u32, vehicle: u32) {
|
||||
self.try_mount_mob(rider, vehicle);
|
||||
}
|
||||
/// `Entity.startRiding` for two mobs: false when a mod canceled it.
|
||||
pub(super) fn try_mount_mob(&mut self, rider: u32, vehicle: u32) -> bool {
|
||||
if !self.native_mob_may_ride(rider, vehicle, true) {
|
||||
return false;
|
||||
}
|
||||
riders::link(&mut self.mobs, rider, vehicle);
|
||||
true
|
||||
}
|
||||
/// Riders sit on their vehicles (`Entity.positionRider`), mobs or boats,
|
||||
/// and let go when the vehicle dies or is gone.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! in front, cycled by F5), the detached camera (`Camera.setup` with
|
||||
//! `getMaxZoom`), and the local player drawn as a model: in third person
|
||||
//! and in the inventory (`InventoryScreen.extractEntityInInventoryFollowsMouse`).
|
||||
use super::{Game, Screen, shoulders::shoulder_variants};
|
||||
use super::{Game, Screen, native_mob_draw::NativePlayerRender, shoulders::shoulder_variants};
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
camera::{CameraEntity, LivingCamera},
|
||||
@@ -625,6 +625,17 @@ impl Game<'_> {
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let start = out.len();
|
||||
let body = self.own_body_visible();
|
||||
// `native_player_submit`: a native mod's own model for this player,
|
||||
// in place of the vanilla one and its layers (an invisible player is
|
||||
// not asked, as for the others).
|
||||
let name = self.own_profile_name();
|
||||
let who = NativePlayerRender {
|
||||
name: &name,
|
||||
local: true,
|
||||
};
|
||||
if body && self.mod_player_geometry(m, who, alpha as f32, camera, packed, out) {
|
||||
return;
|
||||
}
|
||||
// A spectator still draws its own model in third person: the poses'
|
||||
// visibility (set by player_model_visibility) hides body parts, and
|
||||
// translucent_body marks the body OIT for the ghostly look. Only first
|
||||
@@ -664,6 +675,8 @@ impl Game<'_> {
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
// CustomHeadLayer: a pumpkin, skull or other head item.
|
||||
self.head_item_geometry(m, camera, alpha, packed, true, out);
|
||||
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
|
||||
self.mod_mob_layer_geometry(m, Some(who), alpha as f32, camera, packed, out);
|
||||
// LivingEntity.isCurrentlyGlowing: the Glowing effect.
|
||||
if self.potions.effects.get("glowing").is_some() {
|
||||
let color = super::glowing::team_color(self.own_team());
|
||||
@@ -737,16 +750,19 @@ impl Game<'_> {
|
||||
} else {
|
||||
horizon_client::mobs::render::render_layers
|
||||
};
|
||||
render(
|
||||
&m,
|
||||
1.0,
|
||||
camera,
|
||||
&lookup,
|
||||
240 | 240 << 8,
|
||||
self.res.mob_data(),
|
||||
&self.player_armor_layers(),
|
||||
&mut verts,
|
||||
);
|
||||
// A screen preview: custom entity model animations see is_in_gui = 1.
|
||||
horizon_client::mobs::cem_anim::in_gui(|| {
|
||||
render(
|
||||
&m,
|
||||
1.0,
|
||||
camera,
|
||||
&lookup,
|
||||
240 | 240 << 8,
|
||||
self.res.mob_data(),
|
||||
&self.player_armor_layers(),
|
||||
&mut verts,
|
||||
);
|
||||
});
|
||||
let chest = self.armor[1].as_ref().map(|s| &*s.item);
|
||||
if !self.own_invisible() {
|
||||
self.player_cape_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! OptiFine random entity textures: each mob's variants, chosen when this window first sees it
|
||||
//! and checked again once a second (`horizon_client::mobs::random_textures`).
|
||||
//! and checked again once a second, counted in this window's ticks so a frozen world still
|
||||
//! checks (`horizon_client::mobs::random_textures`).
|
||||
|
||||
use super::Game;
|
||||
use horizon_client::mobs::random_textures::{Seen, choose, seed};
|
||||
@@ -17,15 +18,16 @@ impl Game<'_> {
|
||||
if textures.is_empty() {
|
||||
return;
|
||||
}
|
||||
let tick = self.world.game_time;
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if !m.random_textures.due(tick, m.id) {
|
||||
let m = &mut self.mobs[i];
|
||||
let id = m.id;
|
||||
if !m.random_textures.due(&textures, id) {
|
||||
continue;
|
||||
}
|
||||
let seed = seed(m, self.persist.uuids.get(&m.id).copied());
|
||||
let m = &self.mobs[i];
|
||||
let seed = seed(m, self.persist.uuids.get(&id).copied());
|
||||
let seen = (!m.random_textures.has_seen()).then(|| self.seen_at(m.position));
|
||||
choose(&mut self.mobs[i], &textures, seen, seed);
|
||||
choose(&mut self.mobs[i], &textures, res.mob_data(), seen, seed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -709,7 +709,9 @@ impl Game<'_> {
|
||||
if let Some(search) = self.recipe_ui.search.as_mut() {
|
||||
search.x = xo + 25.0;
|
||||
search.y = yo + 13.0;
|
||||
let magnifier = inside(cursor, xo + 8.0, yo + 13.0, 17.0, 14.0);
|
||||
// RecipeBookComponent's magnifierIconPlacement: from the book's
|
||||
// left edge plus eight to the search box's own left edge.
|
||||
let magnifier = inside(cursor, xo + 8.0, yo + 13.0, search.x - xo, 14.0);
|
||||
if magnifier || inside(cursor, search.x, search.y, search.width, search.height) {
|
||||
self.recipe_ui.search_drag = true;
|
||||
search.focused = true;
|
||||
|
||||
@@ -2188,8 +2188,7 @@ impl Game<'_> {
|
||||
let block = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if let Some(BlockEntity::Mod(m)) = self.world.block_entities.get_mut(&key) {
|
||||
if *self.res.facts().get(block).name == *name {
|
||||
m.nbt
|
||||
.extend(fields.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
m.merge_update_tag(fields);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6917,6 +6916,14 @@ impl Game<'_> {
|
||||
if self.hands_busy() {
|
||||
return;
|
||||
}
|
||||
// InputEvent.InteractionKeyMappingTriggered for the main hand: a cancel
|
||||
// ends the whole click, the off hand gets no turn.
|
||||
if let Some(swing) = self.native_interaction(1, Hand::Main) {
|
||||
if swing {
|
||||
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// An item a mod claims: its use is the mod's, and the server never hears of it.
|
||||
if self.mod_use_start() {
|
||||
return;
|
||||
@@ -7877,7 +7884,13 @@ impl Game<'_> {
|
||||
self.heights.item_used(Hand::Main);
|
||||
return;
|
||||
}
|
||||
// The off hand's turn: the entity again, or the block.
|
||||
// The off hand's turn: the event again, then the entity or the block.
|
||||
if let Some(swing) = self.native_interaction(1, Hand::Off) {
|
||||
if swing {
|
||||
self.swing_hand(Hand::Off, SwingAnimation::DEFAULT);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let off_target = if self.entity_targeted() {
|
||||
self.remote_use_entity(Hand::Off)
|
||||
} else {
|
||||
@@ -8158,16 +8171,25 @@ impl Game<'_> {
|
||||
self.send(S::SpectatorAction(play::SpectatorAction { target }));
|
||||
return true;
|
||||
}
|
||||
let spear = self
|
||||
.held()
|
||||
.and_then(|s| horizon_client::spear::Kinetic::of(&s.item));
|
||||
if spear.is_some() && self.cannot_attack_with_item(self.hand_strength.ticker, 0) {
|
||||
return false;
|
||||
}
|
||||
// InputEvent.InteractionKeyMappingTriggered, after the checks that stop
|
||||
// an attack: a cancel ends it there, swinging unless the mod said not to.
|
||||
if let Some(swing) = self.native_interaction(0, Hand::Main) {
|
||||
if swing {
|
||||
let animation = spear.map_or(SwingAnimation::DEFAULT, |k| k.attack_animation());
|
||||
self.swing_hand(Hand::Main, animation);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// A spear jabs (`MultiPlayerGameMode.piercingAttack`): the server
|
||||
// stabs, this client swings, plays the sound and starts its charge
|
||||
// again.
|
||||
if let Some(kinetic) = self
|
||||
.held()
|
||||
.and_then(|s| horizon_client::spear::Kinetic::of(&s.item))
|
||||
{
|
||||
if self.cannot_attack_with_item(self.hand_strength.ticker, 0) {
|
||||
return false;
|
||||
}
|
||||
if let Some(kinetic) = spear {
|
||||
// ServerboundPlayerActionPacket.Action.STAB.
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
action: 8,
|
||||
@@ -8305,9 +8327,22 @@ impl Game<'_> {
|
||||
&& let Some(hit) = self.pick()
|
||||
{
|
||||
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
if self.remote_break_restricted() {
|
||||
let air = self.res.facts().is_air(state);
|
||||
// InputEvent.InteractionKeyMappingTriggered, before the dig goes on:
|
||||
// a cancel digs nothing and swings unless the mod said not to.
|
||||
let canceled = if air {
|
||||
None
|
||||
} else {
|
||||
self.native_interaction(0, Hand::Main)
|
||||
};
|
||||
if let Some(swing) = canceled {
|
||||
if swing {
|
||||
self.swing_arm();
|
||||
self.send(S::Punch(Empty));
|
||||
}
|
||||
} else if self.remote_break_restricted() {
|
||||
// continueDestroyBlock returned false: no swing.
|
||||
} else if !self.res.facts().is_air(state) {
|
||||
} else if !air {
|
||||
self.remote_mining(false);
|
||||
self.swing_arm();
|
||||
self.send(S::Punch(Empty));
|
||||
@@ -8894,10 +8929,11 @@ impl Game<'_> {
|
||||
// A content mod's menu type: its container's slots, which its
|
||||
// mod's screen places.
|
||||
_ if mod_type.is_some() => Some(BlockEntity::Mod(Box::new(
|
||||
horizon_client::containers::ModBlockEntity {
|
||||
horizon_client::containers::ModBlockEntityFields {
|
||||
items: vec![None; mod_type.map_or(0, |t| t.slots)],
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
.into(),
|
||||
))),
|
||||
_ => {
|
||||
// Unsupported menu: tell the server we closed it.
|
||||
@@ -9731,12 +9767,18 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
|
||||
// for (`handlePickItemFromEntity`), else the block.
|
||||
if let Some(entity) = self.0.pick_entity_hit() {
|
||||
if let Some(id) = entity {
|
||||
if self.0.native_interaction(2, Hand::Main).is_some() {
|
||||
return;
|
||||
}
|
||||
self.0.send(S::PickItemFromEntity(play::PickItemFromEntity {
|
||||
id,
|
||||
include_data: self.0.has_control_down(),
|
||||
}));
|
||||
}
|
||||
} else if let Some(hit) = self.0.pick() {
|
||||
if self.0.native_interaction(2, Hand::Main).is_some() {
|
||||
return;
|
||||
}
|
||||
self.0.send(S::PickItemFromBlock(play::PickItemFromBlock {
|
||||
pos: hit.pos,
|
||||
include_data: self.0.has_control_down(),
|
||||
|
||||
@@ -391,7 +391,12 @@ impl Game<'_> {
|
||||
/// codes stripped, go on the sign the guest may write on unless it is
|
||||
/// waxed (`SignBlockEntity.updateSignText`); a guest gone out of reach
|
||||
/// may no longer write (`playerIsTooFarAwayToEdit`).
|
||||
pub(super) fn lan_sign_update(&mut self, host: &mut LanHost, conn: u32, p: play::SignUpdate) {
|
||||
pub(super) fn lan_sign_update(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
mut p: play::SignUpdate,
|
||||
) {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
@@ -422,9 +427,51 @@ impl Game<'_> {
|
||||
self.mark_block_entity_updated(key);
|
||||
return;
|
||||
}
|
||||
if !matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
Some(BlockEntity::Sign(sign)) if !sign.waxed
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Everything vanilla checks has passed: the mods hear what is about to
|
||||
// go on the sign, with the player's state swapped in, and may rewrite
|
||||
// or refuse it.
|
||||
if let Some((uuid, name, dimension)) = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.map(|g| (g.uuid, g.name.clone(), g.dimension.clone()))
|
||||
{
|
||||
let mut lines = p.lines.to_vec();
|
||||
let mut cancel = false;
|
||||
self.as_guest_messaged(host, conn, |game| {
|
||||
game.native_sign_changed(
|
||||
uuid,
|
||||
&name,
|
||||
&dimension,
|
||||
p.pos,
|
||||
p.front,
|
||||
&mut lines,
|
||||
&mut cancel,
|
||||
);
|
||||
});
|
||||
if cancel {
|
||||
// The sign keeps its text: the editor closes, and the player's
|
||||
// client, which shows what it sent, gets the sign as it is.
|
||||
if let Some(play) = host.play.get_mut(&conn) {
|
||||
play.sign_editing = None;
|
||||
}
|
||||
self.mark_block_entity_updated(key);
|
||||
return;
|
||||
}
|
||||
lines.resize(4, String::new());
|
||||
for (line, new) in p.lines.iter_mut().zip(lines) {
|
||||
*line = new;
|
||||
}
|
||||
}
|
||||
let Some(play) = host.play.get_mut(&conn) else {
|
||||
return;
|
||||
};
|
||||
// A hook may have broken the sign.
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -11,16 +11,24 @@ use horizon_client::{
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Hud.extractOverlayMessage` and `extractTitle`.
|
||||
pub(super) fn draw_titles(&self, batch: &mut HudBatch, bar_height: i32) {
|
||||
/// `Hud.extractOverlayMessage`: the action bar.
|
||||
pub(super) fn draw_overlay_message(&self, batch: &mut HudBatch, bar_height: i32) {
|
||||
let (w, h) = (batch.width as i32, batch.height as i32);
|
||||
let partial = self.clock.fraction() as f32;
|
||||
let background = self.settings.text_background(0.0);
|
||||
let font = &self.gui.atlas.font;
|
||||
let mut draws = self
|
||||
let draws = self
|
||||
.titles
|
||||
.overlay_message(w, h, bar_height, partial, font, background);
|
||||
draws.extend(self.titles.title(w, h, partial, font, background));
|
||||
hud_layout::paint(batch, &self.gui.atlas, &draws);
|
||||
}
|
||||
/// `Hud.extractTitle`.
|
||||
pub(super) fn draw_title(&self, batch: &mut HudBatch) {
|
||||
let (w, h) = (batch.width as i32, batch.height as i32);
|
||||
let partial = self.clock.fraction() as f32;
|
||||
let background = self.settings.text_background(0.0);
|
||||
let font = &self.gui.atlas.font;
|
||||
let draws = self.titles.title(w, h, partial, font, background);
|
||||
hud_layout::paint(batch, &self.gui.atlas, &draws);
|
||||
}
|
||||
/// `Hud.extractSavingIndicator`, under the screens: the option, then the
|
||||
|
||||
@@ -624,7 +624,7 @@ mod tests {
|
||||
}
|
||||
world.set(20, 4, 20, water).unwrap();
|
||||
world.set(21, 4, 20, flowing).unwrap();
|
||||
let at = Vec3::new(20.5, 6.0, 20.5);
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
let mut tnt = PrimedTnt {
|
||||
id: 0,
|
||||
position: at,
|
||||
@@ -702,10 +702,15 @@ mod tests {
|
||||
.registry
|
||||
.default_state("minecraft:soul_sand")
|
||||
.unwrap();
|
||||
let column = assets
|
||||
// Soul sand makes an up-pushing column (`drag=false`); the block's
|
||||
// default state is the down-dragging one.
|
||||
let drag_down = assets
|
||||
.registry
|
||||
.default_state("minecraft:bubble_column")
|
||||
.unwrap();
|
||||
let mut up = assets.registry.state(drag_down).unwrap().clone();
|
||||
up.properties.insert("drag".into(), "false".into());
|
||||
let column = assets.registry.resolve(&up).unwrap();
|
||||
for y in 3..=8 {
|
||||
world.set(20, y, 20, column).unwrap();
|
||||
}
|
||||
|
||||
@@ -632,7 +632,7 @@ impl Game<'_> {
|
||||
};
|
||||
if m.session.offers.len() > 7 {
|
||||
let max = m.session.offers.len() - 7;
|
||||
m.scroll = (m.scroll as i32 - d.signum() as i32).clamp(0, max as i32) as usize;
|
||||
m.scroll = scrolled(m.scroll, max, d);
|
||||
}
|
||||
}
|
||||
/// `MerchantScreen` rendering.
|
||||
@@ -930,10 +930,30 @@ fn drag_scroll(y: f32, offers: usize) -> usize {
|
||||
((offs * max as f32 + 0.5) as i32).clamp(0, max) as usize
|
||||
}
|
||||
|
||||
/// `MerchantScreen.mouseScrolled`: `Mth.clamp((int)(scrollOff - scrollY), 0, max)`,
|
||||
/// so a fractional wheel step that truncates to zero does not move the list.
|
||||
fn scrolled(scroll: usize, max: usize, d: f32) -> usize {
|
||||
((scroll as f64 - f64::from(d)) as i32).clamp(0, max as i32) as usize
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `MerchantScreen.mouseScrolled`: the offset is `(int)(scrollOff - scrollY)`
|
||||
/// clamped, so the list moves by as many rows as the event's delta and a
|
||||
/// fractional delta still moves one row (the truncation is after the
|
||||
/// subtraction). Horizon moved exactly one row per event.
|
||||
#[test]
|
||||
fn the_offers_scroll_by_the_wheel_delta() {
|
||||
assert_eq!(scrolled(3, 5, 1.0), 2, "one notch down");
|
||||
assert_eq!(scrolled(3, 5, -1.0), 4, "one notch up");
|
||||
assert_eq!(scrolled(3, 5, 0.25), 2, "a fraction still moves a row");
|
||||
assert_eq!(scrolled(3, 5, 2.5), 0, "a two-and-a-half notch event");
|
||||
assert_eq!(scrolled(0, 5, 1.0), 0, "clamped at the top");
|
||||
assert_eq!(scrolled(5, 5, -1.0), 5, "clamped at the bottom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroller_takes_presses_only_with_more_than_seven_offers() {
|
||||
let x = SHIFT + 97.0;
|
||||
|
||||
@@ -228,7 +228,16 @@ impl Game<'_> {
|
||||
self.combat.explosion_in_water = p.touching_water;
|
||||
// The blast's source names a player owner, for the dragon's kill credit.
|
||||
self.set_blast_by_player(live_owner == Some(Who::Player));
|
||||
// WitherSkull.getBlockExplosionResistance: only a blue skull's blast
|
||||
// caps a destroyable block's resistance at 0.8 (`isDangerous`).
|
||||
if matches!(
|
||||
p.kind,
|
||||
horizon_client::projectiles::ProjectileKind::WitherSkull { dangerous: true }
|
||||
) {
|
||||
self.combat.blast_resistance_cap = Some(0.8);
|
||||
}
|
||||
let result = self.with_hive_emptying_blast(|g| g.explode(at, 1.0));
|
||||
self.combat.blast_resistance_cap = None;
|
||||
self.set_blast_by_player(false);
|
||||
self.combat.explosion_owner = None;
|
||||
self.combat.explosion_in_water = false;
|
||||
|
||||
@@ -1479,10 +1479,10 @@ mod tests {
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
chunk.set(1, MIN_Y + 2, 3, 1);
|
||||
chunk.set(5, MIN_Y + 2, 5, 2);
|
||||
let mut machine = ModBlockEntity {
|
||||
let mut machine = ModBlockEntity::new(crate::containers::ModBlockEntityFields {
|
||||
id: "test:machine".into(),
|
||||
..ModBlockEntity::default()
|
||||
};
|
||||
..Default::default()
|
||||
});
|
||||
machine.nbt.insert("active".into(), Tag::Byte(1));
|
||||
machine.nbt.insert("recipe".into(), Tag::Int(7));
|
||||
let block_entities =
|
||||
|
||||
@@ -16,6 +16,7 @@ use horizon_protocol::{
|
||||
SetEntityMotion, SetEquipment, SetPassengers, UpdateAttributes, VecDelta,
|
||||
},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The attribute instances of `now` that differ from what `sent` held (an
|
||||
/// instance is never removed: one that disappears is left as it was sent).
|
||||
@@ -203,6 +204,8 @@ impl Riding {
|
||||
/// What a level's clients were last told about one entity (ServerEntity's
|
||||
/// fields), shared by every client that tracks it.
|
||||
struct ServerEntity {
|
||||
/// What the mods hear of it when a client starts or stops tracking it.
|
||||
tracked: Arc<Tracked>,
|
||||
/// The registry id of its type (None: a type the clients don't know,
|
||||
/// never paired).
|
||||
kind: Option<i32>,
|
||||
@@ -265,6 +268,10 @@ impl ServerEntity {
|
||||
fn new(e: &EntityState, kind: Option<i32>, riding: &Riding) -> Self {
|
||||
let (range, interval, track_deltas) = tracking::settings(&e.kind);
|
||||
Self {
|
||||
tracked: Arc::new(Tracked {
|
||||
uuid: e.uuid,
|
||||
kind: Arc::from(e.kind.as_str()),
|
||||
}),
|
||||
kind,
|
||||
range: i32::from(range),
|
||||
interval,
|
||||
@@ -583,27 +590,61 @@ impl ServerEntity {
|
||||
}
|
||||
}
|
||||
|
||||
/// An entity a client tracks, as the mods hear of it.
|
||||
#[derive(Debug)]
|
||||
pub struct Tracked {
|
||||
pub uuid: u128,
|
||||
/// Its type's registry id (`minecraft:zombie`).
|
||||
pub kind: Arc<str>,
|
||||
}
|
||||
|
||||
/// A client started or stopped tracking an entity (`ServerEntity.addPairing`
|
||||
/// and `removePairing`).
|
||||
#[derive(Debug)]
|
||||
pub struct TrackEvent {
|
||||
pub start: bool,
|
||||
pub entity: i32,
|
||||
pub tracked: Arc<Tracked>,
|
||||
}
|
||||
|
||||
/// The entities one client tracks (`TrackedEntity.seenBy`, from the
|
||||
/// client's side).
|
||||
/// client's side), and what started and stopped since the game last took
|
||||
/// the events.
|
||||
#[derive(Default)]
|
||||
pub struct Tracker {
|
||||
seen: crate::fast_hash::FxHashSet<i32>,
|
||||
seen: crate::fast_hash::FxHashMap<i32, Arc<Tracked>>,
|
||||
events: Vec<TrackEvent>,
|
||||
}
|
||||
|
||||
impl Tracker {
|
||||
pub fn is_tracking(&self, id: i32) -> bool {
|
||||
self.seen.contains(&id)
|
||||
self.seen.contains_key(&id)
|
||||
}
|
||||
pub fn ids(&self) -> Vec<i32> {
|
||||
self.seen.iter().copied().collect()
|
||||
self.seen.keys().copied().collect()
|
||||
}
|
||||
/// Stop tracking everything without a packet: the caller removes them
|
||||
/// from the client.
|
||||
pub fn clear(&mut self) {
|
||||
self.seen.clear();
|
||||
let events = &mut self.events;
|
||||
events.extend(self.seen.drain().map(|(entity, tracked)| TrackEvent {
|
||||
start: false,
|
||||
entity,
|
||||
tracked,
|
||||
}));
|
||||
}
|
||||
/// Stop tracking `id` without a packet: the caller removes it from the
|
||||
/// client, and the next tick pairs it again if it is still in range.
|
||||
pub fn forget(&mut self, id: i32) -> bool {
|
||||
self.seen.remove(&id)
|
||||
let Some(tracked) = self.seen.remove(&id) else {
|
||||
return false;
|
||||
};
|
||||
self.events.push(TrackEvent {
|
||||
start: false,
|
||||
entity: id,
|
||||
tracked,
|
||||
});
|
||||
true
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.seen.len()
|
||||
@@ -611,6 +652,10 @@ impl Tracker {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.seen.is_empty()
|
||||
}
|
||||
/// The starts and stops since the last call, in order.
|
||||
pub fn take_events(&mut self) -> Vec<TrackEvent> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
}
|
||||
|
||||
/// Who a level's entities are tracked by this tick: a client's position,
|
||||
@@ -829,7 +874,7 @@ impl Entities {
|
||||
flags.extend(self.probes.iter().zip(pairs.iter_mut()).map(|(p, pairs)| {
|
||||
if p.id == v.entity_id || !sees(v, p, chunks, held) {
|
||||
0
|
||||
} else if v.tracker.seen.contains(&p.id) {
|
||||
} else if v.tracker.seen.contains_key(&p.id) {
|
||||
kept += 1;
|
||||
SEES | TRACKS
|
||||
} else {
|
||||
@@ -844,7 +889,7 @@ impl Entities {
|
||||
let is_moved = &self.is_moved;
|
||||
flags.extend(self.probes.iter().zip(pairs.iter_mut()).zip(is_moved).map(
|
||||
|((p, pairs), &moved)| {
|
||||
let tracks = v.tracker.seen.contains(&p.id);
|
||||
let tracks = v.tracker.seen.contains_key(&p.id);
|
||||
if p.id == v.entity_id || (moved && !sees(v, p, chunks, held)) {
|
||||
0
|
||||
} else if tracks {
|
||||
@@ -861,7 +906,7 @@ impl Entities {
|
||||
} else {
|
||||
// One tracking little of it, from what it tracks.
|
||||
flags.resize(self.probes.len(), 0);
|
||||
for id in &v.tracker.seen {
|
||||
for id in v.tracker.seen.keys() {
|
||||
if let Some(s) = self.states.get(id).filter(|s| s.seen == now) {
|
||||
flags[s.index] = SEES | TRACKS;
|
||||
kept += 1;
|
||||
@@ -885,13 +930,19 @@ impl Entities {
|
||||
let mut removed = Vec::new();
|
||||
if kept != v.tracker.seen.len() {
|
||||
let states = &self.states;
|
||||
v.tracker.seen.retain(|id| match states.get(id) {
|
||||
let Tracker { seen, events } = &mut *v.tracker;
|
||||
seen.retain(|id, tracked| match states.get(id) {
|
||||
Some(s) if s.seen == now && flags[s.index] & TRACKS != 0 => true,
|
||||
other => {
|
||||
if let (Some(gone), Some(s)) = (v.gone.as_deref_mut(), other) {
|
||||
gone.push(s.base);
|
||||
}
|
||||
removed.push(*id);
|
||||
events.push(TrackEvent {
|
||||
start: false,
|
||||
entity: *id,
|
||||
tracked: Arc::clone(tracked),
|
||||
});
|
||||
false
|
||||
}
|
||||
});
|
||||
@@ -938,7 +989,17 @@ impl Entities {
|
||||
packets.iter().map(play::Clientbound::encode).collect()
|
||||
});
|
||||
v.out.extend(bodies.iter().cloned());
|
||||
v.tracker.seen.insert(e.id);
|
||||
if v.tracker
|
||||
.seen
|
||||
.insert(e.id, Arc::clone(&s.tracked))
|
||||
.is_none()
|
||||
{
|
||||
v.tracker.events.push(TrackEvent {
|
||||
start: true,
|
||||
entity: e.id,
|
||||
tracked: Arc::clone(&s.tracked),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1594,6 +1655,47 @@ mod tests {
|
||||
assert!(c.trackers[0].is_tracking(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairing_and_unpairing_are_kept_for_the_game() {
|
||||
let mut level = Entities::default();
|
||||
let mut c = Clients::new();
|
||||
let zombie = state(0.0);
|
||||
c.tick(&mut level, &[&zombie]);
|
||||
c.tick(&mut level, &[&zombie]);
|
||||
// Both clients were paired with it, once.
|
||||
for t in &mut c.trackers {
|
||||
let events = t.take_events();
|
||||
assert_eq!(events.len(), 1, "{events:?}");
|
||||
assert!(events[0].start && events[0].entity == 7);
|
||||
assert_eq!(&*events[0].tracked.kind, "minecraft:zombie");
|
||||
assert_eq!(events[0].tracked.uuid, 1);
|
||||
assert!(t.take_events().is_empty());
|
||||
}
|
||||
// It leaves the level: both clients lose it.
|
||||
c.tick(&mut level, &[]);
|
||||
for t in &mut c.trackers {
|
||||
let events = t.take_events();
|
||||
assert!(
|
||||
matches!(&events[..], [e] if !e.start && e.entity == 7),
|
||||
"{events:?}"
|
||||
);
|
||||
}
|
||||
// Back, then forgotten and cleared without a tick.
|
||||
c.tick(&mut level, &[&zombie]);
|
||||
c.tick(&mut level, &[&zombie]);
|
||||
c.trackers[0].take_events();
|
||||
c.trackers[1].take_events();
|
||||
assert!(c.trackers[0].forget(7));
|
||||
c.trackers[1].clear();
|
||||
for t in &mut c.trackers {
|
||||
let events = t.take_events();
|
||||
assert!(
|
||||
matches!(&events[..], [e] if !e.start && e.entity == 7),
|
||||
"{events:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forgotten_entity_is_paired_again_at_the_next_tick() {
|
||||
// A respawned player: the hub removes it from the other clients and
|
||||
|
||||
@@ -159,6 +159,14 @@ pub struct Guest {
|
||||
/// (`clientIsFloating`), and the ticks it has (`aboveGroundTickCount`).
|
||||
pub client_is_floating: bool,
|
||||
pub above_ground_ticks: i32,
|
||||
/// The same for the vehicle the player drives (`clientVehicleIsFloating`,
|
||||
/// `aboveGroundVehicleTickCount`), with that vehicle's gravity
|
||||
/// (`getMaximumFlyingTicks(lastVehicle)`) and whether its last accepted
|
||||
/// move came down onto something (`verticalCollisionBelow`).
|
||||
pub vehicle_floating: bool,
|
||||
pub above_ground_vehicle_ticks: i32,
|
||||
pub vehicle_gravity: f64,
|
||||
pub vehicle_rests: bool,
|
||||
/// A position move arrived since the client's last ClientTickEnd
|
||||
/// (receivedPositionThisTick): a second one is a protocol error.
|
||||
pub position_this_tick: bool,
|
||||
@@ -371,6 +379,10 @@ impl Guest {
|
||||
known_moves: 0,
|
||||
client_is_floating: false,
|
||||
above_ground_ticks: 0,
|
||||
vehicle_floating: false,
|
||||
above_ground_vehicle_ticks: 0,
|
||||
vehicle_gravity: 0.0,
|
||||
vehicle_rests: false,
|
||||
position_this_tick: false,
|
||||
anticheat: Default::default(),
|
||||
replay: None,
|
||||
|
||||
@@ -198,9 +198,32 @@ pub enum HostEvent {
|
||||
},
|
||||
}
|
||||
|
||||
/// What one player started and stopped tracking, in order, for the game to
|
||||
/// tell the mods.
|
||||
pub struct TrackingBatch {
|
||||
pub player: u128,
|
||||
pub name: String,
|
||||
pub events: Vec<super::entity::TrackEvent>,
|
||||
}
|
||||
|
||||
/// Moves a guest's tracking events into `out`, one batch.
|
||||
fn take_tracking(g: &mut Guest, out: &mut Vec<TrackingBatch>) {
|
||||
let events = g.tracker.take_events();
|
||||
if !events.is_empty() {
|
||||
out.push(TrackingBatch {
|
||||
player: g.uuid,
|
||||
name: g.name.clone(),
|
||||
events,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Hub {
|
||||
/// Clicks on and closings of native mods' menus, for the game.
|
||||
pub native_menu_events: Vec<super::menu::NativeMenuEvent>,
|
||||
/// The entities players started and stopped tracking, collected by
|
||||
/// `collect_tracking`, for the game to tell the mods.
|
||||
pub native_tracking: Vec<TrackingBatch>,
|
||||
pub lan: Lan,
|
||||
pub config: HubConfig,
|
||||
pub encoder: ChunkEncoder,
|
||||
@@ -539,6 +562,7 @@ impl Hub {
|
||||
entity_gone: Vec::new(),
|
||||
mod_commands: (serde_json::Value::Null, None),
|
||||
native_menu_events: Vec::new(),
|
||||
native_tracking: Vec::new(),
|
||||
teams: Vec::new(),
|
||||
nicknames: std::collections::HashMap::new(),
|
||||
}
|
||||
@@ -1551,6 +1575,14 @@ impl Hub {
|
||||
}
|
||||
}
|
||||
|
||||
/// The starts and stops of tracking the guests had since the last call
|
||||
/// are kept in `native_tracking`.
|
||||
pub fn collect_tracking(&mut self) {
|
||||
for g in &mut self.guests {
|
||||
take_tracking(g, &mut self.native_tracking);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_guest(&mut self, i: usize, reason: &str) -> Guest {
|
||||
let mut g = self.guests.remove(i);
|
||||
if let Some(m) = g
|
||||
@@ -1573,6 +1605,9 @@ impl Hub {
|
||||
if !g.placed {
|
||||
return g;
|
||||
}
|
||||
// `PlayerList.remove`: the entities it tracked stop (`removePairing`).
|
||||
g.tracker.clear();
|
||||
take_tracking(&mut g, &mut self.native_tracking);
|
||||
self.broadcast(&C::PlayerInfoRemove(play::PlayerInfoRemove {
|
||||
uuids: vec![g.uuid],
|
||||
}));
|
||||
@@ -3177,8 +3212,7 @@ impl Hub {
|
||||
/// move after joining, before the spawn chunks are generated) is not
|
||||
/// known to be air, so it never makes the player float.
|
||||
fn no_blocks_around(&self, i: usize, at: [f64; 3], world: &World) -> bool {
|
||||
let g = &self.guests[i];
|
||||
let d = g.dimensions();
|
||||
let d = self.guests[i].dimensions();
|
||||
let half = f64::from(d.width) / 2.0 + 0.0625;
|
||||
let lo = [at[0] - half, at[1] - 0.0625 - 0.55, at[2] - half];
|
||||
let hi = [
|
||||
@@ -3186,13 +3220,7 @@ impl Hub {
|
||||
at[1] + f64::from(d.height) + 0.0625,
|
||||
at[2] + half,
|
||||
];
|
||||
let cell = |v: f64| v.floor() as i32;
|
||||
(cell(lo[0])..=cell(hi[0])).all(|x| {
|
||||
(cell(lo[2])..=cell(hi[2])).all(|z| {
|
||||
world.loaded(x, z)
|
||||
&& (cell(lo[1])..=cell(hi[1])).all(|y| world.is_air(world.block(x, y, z)))
|
||||
})
|
||||
})
|
||||
no_blocks_in(world, lo, hi)
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.tickPlayer`'s flying check: a player the
|
||||
@@ -3212,6 +3240,20 @@ impl Hub {
|
||||
g.client_is_floating = false;
|
||||
g.above_ground_ticks = 0;
|
||||
}
|
||||
// The vehicle the player drives, by its own gravity: a boat may hover 160 ticks, a horse 80.
|
||||
let g = &mut self.guests[i];
|
||||
if g.vehicle_floating && g.vehicle.is_some() {
|
||||
g.above_ground_vehicle_ticks += 1;
|
||||
if g.above_ground_vehicle_ticks > maximum_flying_ticks(g.vehicle_gravity) {
|
||||
let line = format!("{} was kicked for floating a vehicle too long!", g.name);
|
||||
let conn = g.conn;
|
||||
events.push(HostEvent::Warn(line));
|
||||
self.kick(conn, translatable("multiplayer.disconnect.flying", &[]));
|
||||
}
|
||||
} else {
|
||||
g.vehicle_floating = false;
|
||||
g.above_ground_vehicle_ticks = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// The collision part of handlePlayerPositionChange: whether the move
|
||||
@@ -3703,6 +3745,18 @@ const PLAYER_GRAVITY: f64 = 0.08;
|
||||
|
||||
/// `ServerGamePacketListenerImpl.getMaximumFlyingTicks`: 80 ticks, more where
|
||||
/// gravity is weaker, none at all (never kicked) without gravity.
|
||||
/// `ServerGamePacketListenerImpl.noBlocksAround` over a box: every block from `lo` to `hi` is air, in
|
||||
/// loaded chunks (a chunk not loaded yet is not known to be air: nothing floats there).
|
||||
pub fn no_blocks_in(world: &World, lo: [f64; 3], hi: [f64; 3]) -> bool {
|
||||
let cell = |v: f64| v.floor() as i32;
|
||||
(cell(lo[0])..=cell(hi[0])).all(|x| {
|
||||
(cell(lo[2])..=cell(hi[2])).all(|z| {
|
||||
world.loaded(x, z)
|
||||
&& (cell(lo[1])..=cell(hi[1])).all(|y| world.is_air(world.block(x, y, z)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn maximum_flying_ticks(gravity: f64) -> i32 {
|
||||
if gravity < f64::from(1.0E-5_f32) {
|
||||
return i32::MAX;
|
||||
|
||||
@@ -865,7 +865,7 @@ mod tests {
|
||||
/// menu reads and writes only its count, the rest stay saved.
|
||||
#[test]
|
||||
fn mod_menu_shows_only_its_container_slots() {
|
||||
use crate::containers::{ModBlockEntity, ModContainer};
|
||||
use crate::containers::{ModBlockEntityFields, ModContainer};
|
||||
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
|
||||
let container = ModContainer {
|
||||
slots: 5,
|
||||
@@ -880,11 +880,14 @@ mod tests {
|
||||
let k = (0, 64, 0);
|
||||
world.block_entities.insert(
|
||||
k,
|
||||
BlockEntity::Mod(Box::new(ModBlockEntity {
|
||||
id: "gtmod:machine".into(),
|
||||
items,
|
||||
..Default::default()
|
||||
})),
|
||||
BlockEntity::Mod(Box::new(
|
||||
ModBlockEntityFields {
|
||||
id: "gtmod:machine".into(),
|
||||
items,
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
)),
|
||||
);
|
||||
let mut g = guest();
|
||||
let slots = Slots::Block(k);
|
||||
|
||||
@@ -1073,6 +1073,10 @@ pub struct Extra {
|
||||
/// What a piglin's or hoglin's brain remembers: its avoid target, the
|
||||
/// adults its sensor sees and the activity it is in.
|
||||
pub piglin: piglin::Memories,
|
||||
/// What the native mods last heard it hold and wear
|
||||
/// (`LivingEntity.lastEquipmentItems`, empty at first), by
|
||||
/// `EquipmentSlot` ordinal: see `Mob::equipment_stacks`.
|
||||
pub equipment_heard: Option<Box<[Option<crate::items::ItemStack>; 8]>>,
|
||||
}
|
||||
impl Extra {
|
||||
pub fn set_main_hand(&mut self, item: Option<Arc<str>>) {
|
||||
@@ -1991,6 +1995,62 @@ impl Seen {
|
||||
}
|
||||
}
|
||||
impl Mob {
|
||||
/// What the mob holds and wears, by `EquipmentSlot` ordinal: main hand,
|
||||
/// off hand, feet, legs, chest, head, body and saddle
|
||||
/// (`ClientboundSetEquipmentPacket`'s slots).
|
||||
pub fn equipment_stacks(&self) -> [Option<crate::items::ItemStack>; 8] {
|
||||
use crate::items::ItemStack;
|
||||
let e = &self.extra;
|
||||
// A horse's armour, a llama's carpet, a harness: Horizon keeps the body
|
||||
// slot's item (6) in `head`.
|
||||
let body = drops::head_is_body(self.species());
|
||||
let wolf = self.species() == Species::Wolf;
|
||||
let slots = [
|
||||
(0usize, &e.main_hand, e.main_hand_stack()),
|
||||
(1, &e.off_hand, None),
|
||||
(2, &e.feet, e.armor[3].as_ref()),
|
||||
(3, &e.legs, e.armor[2].as_ref()),
|
||||
(4, &e.chest, e.armor[1].as_ref()),
|
||||
// The body slot's item is no worn armour.
|
||||
(
|
||||
if body { 6 } else { 5 },
|
||||
&e.head,
|
||||
e.armor[0].as_ref().filter(|_| !body),
|
||||
),
|
||||
];
|
||||
let mut out: [Option<ItemStack>; 8] = Default::default();
|
||||
for (slot, item, worn) in slots {
|
||||
let Some(item) = item else {
|
||||
continue;
|
||||
};
|
||||
// The worn armour keeps its stack: a dyed leather piece keeps
|
||||
// its colour.
|
||||
out[slot] = Some(worn.cloned().unwrap_or_else(|| ItemStack {
|
||||
item: item.clone(),
|
||||
count: 1,
|
||||
// A wolf's armor shows its cracks.
|
||||
damage: if slot == 6 && wolf {
|
||||
e.body_damage.max(0) as u32
|
||||
} else {
|
||||
0
|
||||
},
|
||||
extra: if slot == 6 && wolf {
|
||||
e.body_extra.clone()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}));
|
||||
}
|
||||
if e.saddled {
|
||||
out[7] = Some(e.saddle_stack.clone().unwrap_or(ItemStack {
|
||||
item: Arc::from("minecraft:saddle"),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
}));
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn burning_time(&self) -> f64 {
|
||||
let base = finalize::value(self, finalize::Attr::BurningTime);
|
||||
let protection: u32 = gear::ARMOR
|
||||
|
||||
@@ -12,6 +12,7 @@ use super::{
|
||||
undead,
|
||||
};
|
||||
use crate::blocks::Fluid;
|
||||
use crate::breathing::{Breath, apply, breathe};
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
@@ -1013,18 +1014,26 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
} else {
|
||||
m.air = 300;
|
||||
}
|
||||
} else if m.fluids.eye_in_water && !m.kind.flags.breathes_water {
|
||||
m.air -= 1;
|
||||
if m.air == -20 {
|
||||
m.air = 0;
|
||||
} else {
|
||||
// LivingEntity.baseTick's air supply (LivingBreatheEvent): an
|
||||
// entity with its eyes in water that can't breathe there loses
|
||||
// air, any other gains it.
|
||||
let drowns = m.fluids.eye_in_water && !m.kind.flags.breathes_water;
|
||||
let vanilla = Breath {
|
||||
can_breathe: !drowns,
|
||||
consume: 1,
|
||||
refill: if drowns { 0 } else { 4 },
|
||||
};
|
||||
let breath = breathe(m.id, &m.kind.id, vanilla);
|
||||
let (air, drowned) = apply(m.air, 300, breath);
|
||||
m.air = air;
|
||||
if drowned {
|
||||
events.push(MobEvent::Drown {
|
||||
mob: m.id,
|
||||
at: [m.position.x, m.position.y, m.position.z],
|
||||
});
|
||||
m.damage(2.0, Damage::of(DamageKind::Drown));
|
||||
}
|
||||
} else if m.air < 300 {
|
||||
m.air = (m.air + 4).min(300);
|
||||
}
|
||||
|
||||
if m.kind.flags.water_sensitive && (m.fluids.in_water || in_rain(m, w)) {
|
||||
@@ -1184,6 +1193,12 @@ fn play_ambient(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
Species::Guardian if !m.fluids.in_water => "entity.guardian.ambient_land",
|
||||
Species::ElderGuardian if !m.fluids.in_water => "entity.elder_guardian.ambient_land",
|
||||
Species::Axolotl if !m.fluids.in_water => "entity.axolotl.idle_air",
|
||||
// Villager.getAmbientSound: silent while asleep, the trade sound
|
||||
// while a player has its menu open.
|
||||
Species::Villager if super::villager::sleeping(m) => return,
|
||||
Species::Villager if m.extra.flag2 => "entity.villager.trade",
|
||||
// WanderingTrader.getAmbientSound: only the trade branch, it never sleeps.
|
||||
Species::WanderingTrader if m.extra.flag2 => "entity.wandering_trader.trade",
|
||||
Species::Bat if m.extra.flag && m.random.next_int_bound(4).unwrap_or(1) != 0 => return,
|
||||
Species::Allay if m.extra.main_hand.is_some() => "entity.allay.ambient_with_item",
|
||||
// Strider.getAmbientSound: quiet while panicking or tempted.
|
||||
@@ -2899,6 +2914,49 @@ mod same_vehicle_push_tests {
|
||||
use super::*;
|
||||
use crate::mobs::world::with_test_world;
|
||||
|
||||
/// What a mob holds and wears, by `EquipmentSlot`: the hand's item, the
|
||||
/// worn armour's stack (a dyed piece keeps its colour), a horse's
|
||||
/// armour in the body slot and its saddle, and nothing else.
|
||||
#[test]
|
||||
fn a_mobs_equipment_is_listed_by_slot() {
|
||||
with_test_world(&[], |_, types| {
|
||||
let mob = |name: &str| {
|
||||
Mob::new(
|
||||
1,
|
||||
std::sync::Arc::new(types.types[name].clone()),
|
||||
Vec3::new(0.5, 64.0, 0.5),
|
||||
0.0,
|
||||
)
|
||||
};
|
||||
let mut zombie = mob("minecraft:zombie");
|
||||
assert!(zombie.equipment_stacks().iter().all(Option::is_none));
|
||||
zombie
|
||||
.extra
|
||||
.set_main_hand(Some(std::sync::Arc::from("minecraft:iron_sword")));
|
||||
zombie.extra.feet = Some(std::sync::Arc::from("minecraft:iron_boots"));
|
||||
let worn = zombie.equipment_stacks();
|
||||
assert_eq!(
|
||||
worn[0].as_ref().unwrap().item.as_ref(),
|
||||
"minecraft:iron_sword"
|
||||
);
|
||||
assert_eq!(
|
||||
worn[2].as_ref().unwrap().item.as_ref(),
|
||||
"minecraft:iron_boots"
|
||||
);
|
||||
assert_eq!(worn.iter().flatten().count(), 2);
|
||||
let mut horse = mob("minecraft:horse");
|
||||
horse.extra.head = Some(std::sync::Arc::from("minecraft:iron_horse_armor"));
|
||||
horse.set_saddle(Some(crate::items::ItemStack::new("minecraft:saddle", 1)));
|
||||
let gear = horse.equipment_stacks();
|
||||
assert_eq!(
|
||||
gear[6].as_ref().unwrap().item.as_ref(),
|
||||
"minecraft:iron_horse_armor"
|
||||
);
|
||||
assert_eq!(gear[7].as_ref().unwrap().item.as_ref(), "minecraft:saddle");
|
||||
assert!(gear[5].is_none());
|
||||
});
|
||||
}
|
||||
|
||||
/// `Entity.push(Entity)` returns at once for `isPassengerOfSameVehicle`
|
||||
/// (the same `getRootVehicle`): a camel and the husk on its back, seated
|
||||
/// half a block toward its front and so inside its box, never push each
|
||||
@@ -3153,6 +3211,69 @@ mod endermite_goal_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod villager_ambient_tests {
|
||||
use super::*;
|
||||
use crate::mobs::world::with_test_world;
|
||||
|
||||
/// The ambient sound one tick of `play_ambient` makes, after `setup`.
|
||||
fn sound(
|
||||
w: &MobWorld<'_>,
|
||||
types: &crate::mobs::MobTypes,
|
||||
kind: &str,
|
||||
setup: impl FnOnce(&mut Mob),
|
||||
) -> Option<&'static str> {
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
std::sync::Arc::new(types.types[kind].clone()),
|
||||
Vec3::new(0.5, 64.0, 0.5),
|
||||
0.0,
|
||||
);
|
||||
setup(&mut m);
|
||||
let mut events = Vec::new();
|
||||
play_ambient(&mut m, w, &mut events);
|
||||
events.into_iter().find_map(|e| match e {
|
||||
MobEvent::Sound(s, ..) => Some(s),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `Villager.getAmbientSound`: silent while asleep, the trade sound while a
|
||||
/// player has its menu open, ambient otherwise. `WanderingTrader` only has
|
||||
/// the trade branch.
|
||||
#[test]
|
||||
fn a_villager_is_silent_asleep_and_noisy_while_trading() {
|
||||
with_test_world(&[], |w, types| {
|
||||
assert_eq!(
|
||||
sound(w, types, "minecraft:villager", |_| {}),
|
||||
Some("entity.villager.ambient")
|
||||
);
|
||||
assert_eq!(
|
||||
sound(w, types, "minecraft:villager", |m| m.extra.flag2 = true),
|
||||
Some("entity.villager.trade")
|
||||
);
|
||||
assert_eq!(
|
||||
sound(w, types, "minecraft:villager", |m| m.extra.ints[1] = 1),
|
||||
None,
|
||||
"asleep"
|
||||
);
|
||||
assert_eq!(
|
||||
sound(w, types, "minecraft:villager", |m| {
|
||||
m.extra.ints[1] = 1;
|
||||
m.extra.flag2 = true;
|
||||
}),
|
||||
None,
|
||||
"sleeping wins over trading"
|
||||
);
|
||||
let trading = |m: &mut Mob| m.extra.flag2 = true;
|
||||
assert_eq!(
|
||||
sound(w, types, "minecraft:wandering_trader", trading),
|
||||
Some("entity.wandering_trader.trade")
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fox_screech_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -146,7 +146,11 @@ impl Poses {
|
||||
self.skipped.insert(name.to_owned());
|
||||
}
|
||||
pub fn skips(&self, part: &Part) -> bool {
|
||||
self.skipped.contains(&part.name)
|
||||
self.skips_named(&part.name)
|
||||
}
|
||||
/// `skips` by the part's name.
|
||||
pub fn skips_named(&self, name: &str) -> bool {
|
||||
self.skipped.contains(name)
|
||||
}
|
||||
pub fn has(&self, name: &str) -> bool {
|
||||
self.map.contains_key(name)
|
||||
@@ -169,7 +173,11 @@ impl Poses {
|
||||
}
|
||||
}
|
||||
pub fn is_hidden(&self, part: &Part) -> bool {
|
||||
self.hidden.contains(&part.name)
|
||||
self.is_hidden_named(&part.name)
|
||||
}
|
||||
/// `is_hidden` by the part's name.
|
||||
pub fn is_hidden_named(&self, name: &str) -> bool {
|
||||
self.hidden.contains(name)
|
||||
}
|
||||
}
|
||||
/// The LivingEntityRenderState values setupAnim reads.
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
//! OptiFine custom entity models over Horizon's mob model layers: `optifine/cem/<name>.jem` (or
|
||||
//! `cem/<name>/<name>.jem`, and Entity Model Features' `emf/cem/`, which wins) maps to one layer
|
||||
//! (`zombie.jem` to `zombie#main`, `sheep_wool.jem` to `sheep#wool`), its parts to the layer's
|
||||
//! vanilla parts, and its boxes join or replace theirs. A baby layer without its own model
|
||||
//! takes the adult's (OptiFine's fallbacks, and Entity Model Features' for horses too).
|
||||
//! vanilla parts, and its boxes join or replace theirs. A layer without its own model takes the
|
||||
//! one it falls back to: a baby's adult (OptiFine's fallbacks, and Entity Model Features' for
|
||||
//! horses too), and the few of [`FALLBACKS`], following their chains (`cat_baby_collar` to
|
||||
//! `cat_collar` to `cat`).
|
||||
//!
|
||||
//! Built when the packs load; a mob's draw only swaps the layer's part tree. Without CEM files
|
||||
//! nothing is built and every model stays vanilla's.
|
||||
//! nothing is built and every model stays vanilla's, and a file no layer uses isn't parsed.
|
||||
//!
|
||||
//! The seam with random entity textures (ETF): a `.jem`'s own `texture` replaces the layer's
|
||||
//! vanilla texture name, and the texture selection then works on that name as on vanilla's.
|
||||
//! vanilla texture name, and the random texture lookup then gets that name. Variants without
|
||||
//! rules (`green2.png` beside `green.png`) follow it; a `.properties` with rules for it is not
|
||||
//! matched yet, because the random-texture picks are made for the vanilla names. That half
|
||||
//! belongs with random textures. The `.jem` texture paints the layer the file is for, not the overlays drawn on that layer's model (a wolf's
|
||||
//! collar, a spider's eyes, the cracks on a wolf's armour): those keep their textures over the
|
||||
//! new geometry.
|
||||
|
||||
use super::{MobType, Part, Polygon};
|
||||
use horizon_optifine::cem::model::{Bone, EntityModel, MAX_BYTES, parse_jem};
|
||||
use std::{collections::HashMap, fs, path::Path, sync::Arc};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
fs,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
/// The CEM models of the enabled packs, by model layer (`zombie#main`).
|
||||
#[derive(Default)]
|
||||
@@ -30,62 +42,85 @@ pub struct CemLayer {
|
||||
pub texture: Option<String>,
|
||||
}
|
||||
|
||||
/// How many fallbacks a chain follows (`cat_baby_collar`, `cat_collar`, `cat` takes two).
|
||||
const FALLBACK_STEPS: usize = 3;
|
||||
|
||||
impl CemModels {
|
||||
/// The `.jem` files under `root` applied to the layers of `types`.
|
||||
pub fn load(root: &Path, types: &HashMap<String, Arc<MobType>>) -> Self {
|
||||
let vanilla: HashMap<&str, &Arc<Part>> = types
|
||||
.values()
|
||||
.flat_map(|t| t.layers.iter().map(|(k, v)| (k.as_str(), v)))
|
||||
.collect();
|
||||
Self::over(root, &vanilla)
|
||||
}
|
||||
|
||||
/// The `.jem` files under `root` applied to the `vanilla` layers.
|
||||
fn over(root: &Path, vanilla: &HashMap<&str, &Arc<Part>>) -> Self {
|
||||
let files = jem_files(root);
|
||||
if files.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
let mut read = |path: &str| read_small(&root.join(path));
|
||||
let mut layers = HashMap::new();
|
||||
// A baby layer without its own file takes the adult's: those go after every own file.
|
||||
let mut fallbacks = Vec::new();
|
||||
for (name, path) in &files {
|
||||
let Some(text) = read_small(&root.join(path)) else {
|
||||
log::warn!(
|
||||
"Horizon: custom entity model {path}: unreadable or over {MAX_BYTES} bytes"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let jem = match parse_jem(path, &text, &mut read) {
|
||||
Ok(jem) => jem,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: custom entity model {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for warning in &jem.warnings {
|
||||
log::warn!("Horizon: custom entity model {warning}");
|
||||
}
|
||||
let jem = Arc::new(jem);
|
||||
Self::from_files(root, &files, &vanilla_layers(types))
|
||||
}
|
||||
|
||||
/// `files` ([`jem_files`]) applied to the `vanilla` layers.
|
||||
fn from_files(
|
||||
root: &Path,
|
||||
files: &BTreeMap<String, Vec<String>>,
|
||||
vanilla: &HashMap<&str, &Arc<Part>>,
|
||||
) -> Self {
|
||||
// Every model name a file can shape: the files' own, and the names that fall back to
|
||||
// them, a chain of fallbacks deep.
|
||||
let mut names: BTreeSet<String> = files.keys().cloned().collect();
|
||||
let mut frontier: Vec<String> = names.iter().cloned().collect();
|
||||
for _ in 0..FALLBACK_STEPS {
|
||||
frontier = frontier
|
||||
.iter()
|
||||
.flat_map(|name| fallback_names(name))
|
||||
.filter(|name| names.insert(name.clone()))
|
||||
.collect();
|
||||
}
|
||||
// The files each layer may take, best first: the first that parses wins. Every name of
|
||||
// the layer's own file in name order, then the files its names fall back to, nearest
|
||||
// first. Two names of one layer (`llama_decor` and `trader_llama_decor` are both
|
||||
// `llama#decor`, `skeleton_horse_armor` and `zombie_horse_armor` both
|
||||
// `undead_horse_armor#main`) give it the first's, and the second's if the first is
|
||||
// broken; within one name, EMF's file before OptiFine's.
|
||||
let mut plan: HashMap<String, Vec<(usize, &str)>> = HashMap::new();
|
||||
for name in &names {
|
||||
let key = layer_key(name);
|
||||
if let Some(part) = vanilla.get(key.as_str()) {
|
||||
layers.insert(key, CemLayer::build(part, &jem, name));
|
||||
if !vanilla.contains_key(key.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if !name.contains("_baby") {
|
||||
fallbacks.extend(baby_names(name).map(|baby| (baby, name, jem.clone())));
|
||||
let chain = std::iter::successors(Some(name.clone()), |n| falls_back_to(n));
|
||||
let candidates = plan.entry(key).or_default();
|
||||
for (depth, target) in chain.take(FALLBACK_STEPS + 1).enumerate() {
|
||||
if let Some((file, _)) = files.get_key_value(&target) {
|
||||
candidates.push((depth, file.as_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (baby, adult, jem) in fallbacks {
|
||||
if files.contains_key(&baby) {
|
||||
// Each file parsed once, when a layer first tries it.
|
||||
let mut parsed: HashMap<&str, Option<EntityModel>> = HashMap::new();
|
||||
let mut layers = HashMap::new();
|
||||
for (key, mut candidates) in plan {
|
||||
candidates.sort_by_key(|&(depth, _)| depth);
|
||||
let found = candidates
|
||||
.iter()
|
||||
.flat_map(move |&(_, name)| {
|
||||
files[name].iter().map(move |path| (name, path.as_str()))
|
||||
})
|
||||
.find(|&(_, path)| {
|
||||
parsed
|
||||
.entry(path)
|
||||
.or_insert_with(|| load_jem(root, path))
|
||||
.is_some()
|
||||
});
|
||||
let Some((name, path)) = found else {
|
||||
continue;
|
||||
};
|
||||
let Some(Some(jem)) = parsed.get(path) else {
|
||||
continue;
|
||||
};
|
||||
let mut layer = CemLayer::build(vanilla[key.as_str()], jem, name);
|
||||
// A fallback onto another layer (the cat's collar from `cat`) takes the geometry
|
||||
// only: the layer keeps its own texture.
|
||||
if layer_suffix(&key) != layer_suffix(&layer_key(name)) {
|
||||
layer.texture = None;
|
||||
}
|
||||
let key = layer_key(&baby);
|
||||
if let Some(part) = vanilla.get(key.as_str()) {
|
||||
layers
|
||||
.entry(key)
|
||||
.or_insert_with(|| CemLayer::build(part, &jem, adult));
|
||||
}
|
||||
layers.insert(key, layer);
|
||||
}
|
||||
Self { layers }
|
||||
}
|
||||
@@ -95,6 +130,47 @@ impl CemModels {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `.jem` at `path`, its warnings logged; None, with a warning, when it can't be read or
|
||||
/// parsed.
|
||||
fn load_jem(root: &Path, path: &str) -> Option<EntityModel> {
|
||||
let Some(text) = read_small(&root.join(path)) else {
|
||||
log::warn!("Horizon: custom entity model {path}: unreadable or over {MAX_BYTES} bytes");
|
||||
return None;
|
||||
};
|
||||
let jem = match parse_jem(path, &text, &mut |p: &str| read_small(&root.join(p))) {
|
||||
Ok(jem) => jem,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: custom entity model {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
for warning in &jem.warnings {
|
||||
log::warn!("Horizon: custom entity model {warning}");
|
||||
}
|
||||
if let Some(texture) = &jem.texture {
|
||||
if atlas_name(texture).is_none() {
|
||||
log::warn!(
|
||||
"Horizon: custom entity model {path}: its texture {texture} is outside \
|
||||
assets/minecraft/textures/, so not in the atlas: the vanilla one draws"
|
||||
);
|
||||
} else if !root.join(texture).is_file() {
|
||||
log::warn!(
|
||||
"Horizon: custom entity model {path}: its texture {texture} doesn't exist, \
|
||||
so not in the atlas: the vanilla one draws"
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(jem)
|
||||
}
|
||||
|
||||
/// Every model layer of `types` by its key.
|
||||
fn vanilla_layers(types: &HashMap<String, Arc<MobType>>) -> HashMap<&str, &Arc<Part>> {
|
||||
types
|
||||
.values()
|
||||
.flat_map(|t| t.layers.iter().map(|(k, v)| (k.as_str(), v)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A pack file's text, None when it's missing or larger than a model may read: its size is
|
||||
/// checked before anything is read.
|
||||
fn read_small(path: &Path) -> Option<String> {
|
||||
@@ -105,26 +181,65 @@ fn read_small(path: &Path) -> Option<String> {
|
||||
fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
/// Every `.jem` of the CEM folders by model name, `emf/cem` over `optifine/cem`.
|
||||
fn jem_files(root: &Path) -> HashMap<String, String> {
|
||||
let mut out = HashMap::new();
|
||||
for base in ["assets/minecraft/optifine/cem", "assets/minecraft/emf/cem"] {
|
||||
/// Every `.jem` of the CEM folders by model name, sorted, each name's files best first:
|
||||
/// `emf/cem` before `optifine/cem` whichever form either uses, and within one folder the flat
|
||||
/// `<name>.jem` before `<name>/<name>.jem`.
|
||||
fn jem_files(root: &Path) -> BTreeMap<String, Vec<String>> {
|
||||
let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
for base in ["assets/minecraft/emf/cem", "assets/minecraft/optifine/cem"] {
|
||||
let Ok(entries) = fs::read_dir(root.join(base)) else {
|
||||
continue;
|
||||
};
|
||||
let (mut flat, mut nested) = (Vec::new(), Vec::new());
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if let Some(model) = name.strip_suffix(".jem") {
|
||||
out.insert(model.to_owned(), format!("{base}/{name}"));
|
||||
} else if root.join(format!("{base}/{name}/{name}.jem")).is_file() {
|
||||
out.entry(name.clone())
|
||||
.or_insert_with(|| format!("{base}/{name}/{name}.jem"));
|
||||
// A directory named `x.jem` isn't a model.
|
||||
if e.path().is_file() {
|
||||
flat.push((model.to_owned(), format!("{base}/{name}")));
|
||||
}
|
||||
} else {
|
||||
let path = format!("{base}/{name}/{name}.jem");
|
||||
if root.join(&path).is_file() {
|
||||
nested.push((name, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (model, path) in flat.into_iter().chain(nested) {
|
||||
out.entry(model).or_default().push(path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The CEM model names with a layer key of their own; any other name `n` is `n#main`.
|
||||
const LAYERS: &[(&str, &str)] = &[
|
||||
("sheep_wool", "sheep#wool"),
|
||||
("creeper_charge", "creeper#armor"),
|
||||
("slime_outer", "slime#outer"),
|
||||
("drowned_outer", "drowned#outer"),
|
||||
("stray_outer", "stray#outer"),
|
||||
("bogged_outer", "bogged#outer"),
|
||||
("wither_armor", "wither#armor"),
|
||||
("breeze_wind", "breeze#wind"),
|
||||
// Horizon draws both llamas' decor on one layer, and both undead horses' armour.
|
||||
("llama_decor", "llama#decor"),
|
||||
("trader_llama_decor", "llama#decor"),
|
||||
("cat_collar", "cat#collar"),
|
||||
("pig_saddle", "pig#saddle"),
|
||||
("strider_saddle", "strider#saddle"),
|
||||
("nautilus_saddle", "nautilus#saddle"),
|
||||
("tropical_fish_a", "tropical_fish_small#main"),
|
||||
("tropical_fish_b", "tropical_fish_large#main"),
|
||||
("tropical_fish_pattern_a", "tropical_fish_small#pattern"),
|
||||
("tropical_fish_pattern_b", "tropical_fish_large#pattern"),
|
||||
("puffer_fish_big", "pufferfish_big#main"),
|
||||
("puffer_fish_medium", "pufferfish_medium#main"),
|
||||
("puffer_fish_small", "pufferfish_small#main"),
|
||||
("skeleton_horse_armor", "undead_horse_armor#main"),
|
||||
("zombie_horse_armor", "undead_horse_armor#main"),
|
||||
];
|
||||
|
||||
/// The model layer a CEM model name reshapes.
|
||||
fn layer_key(name: &str) -> String {
|
||||
// `<model>_baby[_<layer>]`: the adult's layer on the baby model.
|
||||
@@ -133,31 +248,49 @@ fn layer_key(name: &str) -> String {
|
||||
let (model, layer) = adult.split_once('#').unwrap_or((&adult, "main"));
|
||||
return format!("{model}_baby#{layer}");
|
||||
}
|
||||
match name {
|
||||
"sheep_wool" => "sheep#wool",
|
||||
"creeper_charge" => "creeper#armor",
|
||||
"slime_outer" => "slime#outer",
|
||||
"drowned_outer" => "drowned#outer",
|
||||
"stray_outer" => "stray#outer",
|
||||
"bogged_outer" => "bogged#outer",
|
||||
"wither_armor" => "wither#armor",
|
||||
"breeze_wind" => "breeze#wind",
|
||||
"llama_decor" | "trader_llama_decor" => "llama#decor",
|
||||
"cat_collar" => "cat#collar",
|
||||
"pig_saddle" => "pig#saddle",
|
||||
"strider_saddle" => "strider#saddle",
|
||||
"nautilus_saddle" => "nautilus#saddle",
|
||||
"tropical_fish_a" => "tropical_fish_small#main",
|
||||
"tropical_fish_b" => "tropical_fish_large#main",
|
||||
"tropical_fish_pattern_a" => "tropical_fish_small#pattern",
|
||||
"tropical_fish_pattern_b" => "tropical_fish_large#pattern",
|
||||
"puffer_fish_big" => "pufferfish_big#main",
|
||||
"puffer_fish_medium" => "pufferfish_medium#main",
|
||||
"puffer_fish_small" => "pufferfish_small#main",
|
||||
"zombie_horse_armor" | "skeleton_horse_armor" => "undead_horse_armor#main",
|
||||
_ => return format!("{name}#main"),
|
||||
match LAYERS.iter().find(|(n, _)| *n == name) {
|
||||
Some((_, key)) => (*key).to_owned(),
|
||||
None => format!("{name}#main"),
|
||||
}
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// The layer of a model layer key: `wool` of `sheep#wool`.
|
||||
fn layer_suffix(key: &str) -> &str {
|
||||
key.split_once('#').map_or(key, |(_, layer)| layer)
|
||||
}
|
||||
|
||||
/// Models without a file of their own that take another's (OptiFine's and Entity Model
|
||||
/// Features' tables), besides the babies: (model, the model it takes).
|
||||
const FALLBACKS: &[(&str, &str)] = &[
|
||||
("cat_collar", "cat"),
|
||||
("player_slim", "player"),
|
||||
("salmon_large", "salmon"),
|
||||
("salmon_small", "salmon"),
|
||||
];
|
||||
|
||||
/// The model `name` takes when it has no file: [`FALLBACKS`], or a baby's adult (`sheep_wool`
|
||||
/// for `sheep_baby_wool`). The baby nautilus has none (OptiFine's and Entity Model Features'
|
||||
/// tables).
|
||||
fn falls_back_to(name: &str) -> Option<String> {
|
||||
if let Some((_, to)) = FALLBACKS.iter().find(|(from, _)| *from == name) {
|
||||
return Some((*to).to_owned());
|
||||
}
|
||||
(name.contains("_baby") && !name.starts_with("nautilus_baby"))
|
||||
.then(|| name.replacen("_baby", "", 1))
|
||||
}
|
||||
|
||||
/// The model names that take `name`'s file when they have none ([`falls_back_to`] the other
|
||||
/// way): the babies of an adult and [`FALLBACKS`].
|
||||
fn fallback_names(name: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = FALLBACKS
|
||||
.iter()
|
||||
.filter(|(_, to)| *to == name)
|
||||
.map(|(from, _)| (*from).to_owned())
|
||||
.collect();
|
||||
if !name.contains("_baby") {
|
||||
out.extend(baby_names(name).filter(|baby| !baby.starts_with("nautilus_baby")));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The baby model names that fall back to the adult `name`: `_baby` before any of its
|
||||
@@ -286,8 +419,13 @@ fn vanilla_part(model: &str, part: &str) -> String {
|
||||
("cod" | "tropical_fish_a" | "tropical_fish_b", "fin_back" | "fin_top") => {
|
||||
Some("top_fin".into())
|
||||
}
|
||||
(_, "fin_right") if model.starts_with("puffer") => Some("right_blue_fin".into()),
|
||||
(_, "fin_left") if model.starts_with("puffer") => Some("left_blue_fin".into()),
|
||||
// The big and medium pufferfish have blue fins; the small one has plain `right_fin`s
|
||||
// (the fin animation in aquatic.rs).
|
||||
("puffer_fish_big" | "puffer_fish_medium", "fin_right") => Some("right_blue_fin".into()),
|
||||
("puffer_fish_big" | "puffer_fish_medium", "fin_left") => Some("left_blue_fin".into()),
|
||||
// RabbitModel's hind feet (rabbit.rs).
|
||||
("rabbit", "left_foot") => Some("left_hind_foot".into()),
|
||||
("rabbit", "right_foot") => Some("right_hind_foot".into()),
|
||||
(_, "fin_right") => Some("right_fin".into()),
|
||||
(_, "fin_left") => Some("left_fin".into()),
|
||||
(_, "fin_bottom") => Some("bottom_fin".into()),
|
||||
@@ -325,7 +463,7 @@ impl CemLayer {
|
||||
let mut entries = Vec::new();
|
||||
for p in &jem.parts {
|
||||
let target = vanilla_part(&model, &p.part);
|
||||
let target = if find(&out, &target) {
|
||||
let target = if super::render::find_part(&out, &target).is_some() {
|
||||
target
|
||||
} else {
|
||||
p.part.clone()
|
||||
@@ -369,10 +507,6 @@ fn atlas_name(path: &str) -> Option<String> {
|
||||
Some(rest.strip_suffix(".png").unwrap_or(rest).to_owned())
|
||||
}
|
||||
|
||||
fn find(part: &Part, name: &str) -> bool {
|
||||
part.name == name || part.children.iter().any(|c| find(c, name))
|
||||
}
|
||||
|
||||
/// The first part named `name`, depth first (`ModelPart.getChild` through the tree).
|
||||
fn find_mut<'a>(part: &'a mut Part, name: &str) -> Option<&'a mut Part> {
|
||||
if part.name == name {
|
||||
@@ -468,6 +602,9 @@ mod tests {
|
||||
("strider", "hair_left_top", "left_top_bristle"),
|
||||
("puffer_fish_big", "spikes_front_top", "top_front_fin"),
|
||||
("squid", "tentacle0", "tentacle0"),
|
||||
("puffer_fish_medium", "fin_left", "left_blue_fin"),
|
||||
("puffer_fish_small", "fin_right", "right_fin"),
|
||||
("rabbit", "left_foot", "left_hind_foot"),
|
||||
] {
|
||||
assert_eq!(vanilla_part(model, part), vanilla, "{model} {part}");
|
||||
}
|
||||
@@ -510,7 +647,7 @@ mod tests {
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let models = CemModels::over(&root, &vanilla);
|
||||
let models = CemModels::from_files(&root, &jem_files(&root), &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
|
||||
let adult = models.layer("zombie#main").unwrap();
|
||||
@@ -530,4 +667,511 @@ mod tests {
|
||||
assert_eq!((leg.polygons.len(), leg.children.len()), (0, 1));
|
||||
assert!(models.layer("sheep#main").is_none());
|
||||
}
|
||||
|
||||
const BOX: &str = r#""boxes": [{"coordinates": [0, 0, 0, 1, 1, 1], "textureOffset": [0, 0]}]"#;
|
||||
|
||||
/// A `.jem` with one entry on `part`, attached or replacing it.
|
||||
fn jem(part: &str, attach: bool) -> String {
|
||||
format!(r#"{{"models": [{{"part": "{part}", "attach": {attach}, {BOX}}}]}}"#)
|
||||
}
|
||||
|
||||
/// A fresh pack folder for the test `test` holding `files` (paths under `assets/minecraft/`).
|
||||
fn pack(test: &str, files: &[(&str, &str)]) -> std::path::PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("horizon-cem-{test}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
for (path, text) in files {
|
||||
let path = root.join("assets/minecraft").join(path);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
/// The models of the pack `files` over the `layers`, the same tree for each.
|
||||
fn models(test: &str, files: &[(&str, &str)], layers: &[&str], tree: &Part) -> CemModels {
|
||||
let root = pack(test, files);
|
||||
let tree = Arc::new(tree.clone());
|
||||
let vanilla: HashMap<&str, &Arc<Part>> = layers.iter().map(|k| (*k, &tree)).collect();
|
||||
let models = CemModels::from_files(&root, &jem_files(&root), &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
models
|
||||
}
|
||||
|
||||
/// Whether the layer `key`'s part `name` was replaced (lost its own face) or had a box
|
||||
/// attached (kept it); None without the layer. Either way the box joins as a child.
|
||||
fn attached(models: &CemModels, key: &str, name: &str) -> Option<bool> {
|
||||
let part = get(&models.layer(key)?.model, name);
|
||||
assert_eq!(part.children.len(), 1, "{key} {name}: one custom box");
|
||||
Some(part.polygons.len() == 1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emf_files_win_and_a_flat_file_beats_its_folder() {
|
||||
let (replace, attach) = (jem("leg1", false), jem("leg1", true));
|
||||
let files = [
|
||||
// EMF's folder form over OptiFine's flat file.
|
||||
("optifine/cem/cow.jem", attach.as_str()),
|
||||
("emf/cem/cow/cow.jem", replace.as_str()),
|
||||
// One folder: the flat file over the folder form.
|
||||
("optifine/cem/pig.jem", replace.as_str()),
|
||||
("optifine/cem/pig/pig.jem", attach.as_str()),
|
||||
// A directory named `sheep.jem` is no file: the folder form stands.
|
||||
("optifine/cem/sheep.jem/readme.txt", ""),
|
||||
("optifine/cem/sheep/sheep.jem", attach.as_str()),
|
||||
];
|
||||
let tree = plane("root", vec![plane("right_hind_leg", vec![])]);
|
||||
let models = models(
|
||||
"precedence",
|
||||
&files,
|
||||
&["cow#main", "pig#main", "sheep#main"],
|
||||
&tree,
|
||||
);
|
||||
assert_eq!(attached(&models, "cow#main", "right_hind_leg"), Some(false));
|
||||
assert_eq!(attached(&models, "pig#main", "right_hind_leg"), Some(false));
|
||||
assert_eq!(
|
||||
attached(&models, "sheep#main", "right_hind_leg"),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baby_with_its_own_file_takes_it_not_the_adults() {
|
||||
let (adult, baby) = (jem("head", false), jem("head", true));
|
||||
let files = [
|
||||
("optifine/cem/zombie.jem", adult.as_str()),
|
||||
("optifine/cem/zombie_baby.jem", baby.as_str()),
|
||||
];
|
||||
let tree = plane("root", vec![plane("head", vec![])]);
|
||||
let models = models("baby", &files, &["zombie#main", "zombie_baby#main"], &tree);
|
||||
assert_eq!(attached(&models, "zombie#main", "head"), Some(false));
|
||||
assert_eq!(attached(&models, "zombie_baby#main", "head"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_names_of_one_layer_give_it_the_first_by_name() {
|
||||
let (llama, trader) = (jem("body", false), jem("body", true));
|
||||
let files = [
|
||||
("optifine/cem/trader_llama_decor.jem", trader.as_str()),
|
||||
("optifine/cem/llama_decor.jem", llama.as_str()),
|
||||
];
|
||||
let tree = plane("root", vec![plane("body", vec![])]);
|
||||
// Written trader first, `llama_decor` still shapes the layer, and the baby's (both
|
||||
// fall back to the adult files).
|
||||
let models = models("decor", &files, &["llama#decor", "llama_baby#decor"], &tree);
|
||||
assert_eq!(attached(&models, "llama#decor", "body"), Some(false));
|
||||
assert_eq!(attached(&models, "llama_baby#decor", "body"), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_models_fall_back_but_the_baby_nautilus_does_not() {
|
||||
let (replace, attach) = (jem("body", false), jem("body", true));
|
||||
let cat = format!(
|
||||
r#"{{"texture": "textures/entity/cat/custom.png", "models": [
|
||||
{{"part": "body", {BOX}}}]}}"#
|
||||
);
|
||||
let files = [
|
||||
("optifine/cem/salmon.jem", replace.as_str()),
|
||||
("optifine/cem/salmon_large.jem", attach.as_str()),
|
||||
("optifine/cem/nautilus.jem", replace.as_str()),
|
||||
("optifine/cem/cat.jem", cat.as_str()),
|
||||
];
|
||||
let tree = plane("root", vec![plane("body", vec![])]);
|
||||
let models = models(
|
||||
"fallbacks",
|
||||
&files,
|
||||
&[
|
||||
"salmon#main",
|
||||
"salmon_small#main",
|
||||
"salmon_large#main",
|
||||
"nautilus#main",
|
||||
"nautilus_baby#main",
|
||||
"cat#main",
|
||||
"cat#collar",
|
||||
"cat_baby#collar",
|
||||
],
|
||||
&tree,
|
||||
);
|
||||
// The small salmon has no file: salmon.jem's. The large one has its own.
|
||||
assert_eq!(attached(&models, "salmon_small#main", "body"), Some(false));
|
||||
assert_eq!(attached(&models, "salmon_large#main", "body"), Some(true));
|
||||
assert_eq!(attached(&models, "nautilus#main", "body"), Some(false));
|
||||
assert!(models.layer("nautilus_baby#main").is_none());
|
||||
// The collar takes the cat's geometry, but its own collar texture stays.
|
||||
let texture = |key: &str| models.layer(key).unwrap().texture.clone();
|
||||
assert_eq!(texture("cat#main").as_deref(), Some("entity/cat/custom"));
|
||||
assert_eq!(attached(&models, "cat#collar", "body"), Some(false));
|
||||
assert_eq!(texture("cat#collar"), None);
|
||||
// So does the baby's, two fallbacks down: `cat_baby_collar`, `cat_collar`, `cat`.
|
||||
assert_eq!(attached(&models, "cat_baby#collar", "body"), Some(false));
|
||||
assert_eq!(texture("cat_baby#collar"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_file_gives_way_to_the_next() {
|
||||
let (replace, attach) = (jem("body", false), jem("body", true));
|
||||
let files = [
|
||||
// The first name of the layer is broken: the second shapes it.
|
||||
("optifine/cem/llama_decor.jem", "{"),
|
||||
("optifine/cem/trader_llama_decor.jem", attach.as_str()),
|
||||
// EMF's file is broken: OptiFine's stands.
|
||||
("emf/cem/cow.jem", "{"),
|
||||
("optifine/cem/cow.jem", replace.as_str()),
|
||||
// The baby's own file is broken: the adult's falls back onto it.
|
||||
("optifine/cem/zombie_baby.jem", "{"),
|
||||
("optifine/cem/zombie.jem", attach.as_str()),
|
||||
];
|
||||
let tree = plane("root", vec![plane("body", vec![])]);
|
||||
let models = models(
|
||||
"broken",
|
||||
&files,
|
||||
&["llama#decor", "cow#main", "zombie_baby#main"],
|
||||
&tree,
|
||||
);
|
||||
assert_eq!(attached(&models, "llama#decor", "body"), Some(true));
|
||||
assert_eq!(attached(&models, "cow#main", "body"), Some(false));
|
||||
assert_eq!(attached(&models, "zombie_baby#main", "body"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_part_is_skipped_and_the_rest_applies() {
|
||||
let file =
|
||||
format!(r#"{{"models": [{{"part": "wings", {BOX}}}, {{"part": "head", {BOX}}}]}}"#);
|
||||
let tree = plane("root", vec![plane("head", vec![])]);
|
||||
let models = models(
|
||||
"unknown",
|
||||
&[("optifine/cem/zombie.jem", file.as_str())],
|
||||
&["zombie#main"],
|
||||
&tree,
|
||||
);
|
||||
assert_eq!(attached(&models, "zombie#main", "head"), Some(false));
|
||||
fn custom(p: &Part) -> usize {
|
||||
usize::from(p.name.starts_with("cem#")) + p.children.iter().map(custom).sum::<usize>()
|
||||
}
|
||||
assert_eq!(custom(&models.layer("zombie#main").unwrap().model), 1);
|
||||
}
|
||||
|
||||
/// A mob type `id` of `species` whose body and `layers` are all `tree`, with the vanilla
|
||||
/// `texture`.
|
||||
fn mob_kind(
|
||||
id: &str,
|
||||
species: super::super::Species,
|
||||
anim: super::super::Anim,
|
||||
texture: &str,
|
||||
layers: &[&str],
|
||||
tree: &Part,
|
||||
) -> Arc<MobType> {
|
||||
let mut kind = (*super::super::sensor_scan_tests::kind()).clone();
|
||||
kind.id = id.into();
|
||||
kind.texture = texture.into();
|
||||
kind.species = species;
|
||||
kind.anim = anim;
|
||||
kind.model = Arc::new(tree.clone());
|
||||
kind.layers = layers
|
||||
.iter()
|
||||
.map(|k| ((*k).to_owned(), Arc::new(tree.clone())))
|
||||
.collect();
|
||||
Arc::new(kind)
|
||||
}
|
||||
|
||||
/// How many vertices `render` draws of `mob` in each of `sprites`, laid far apart in the
|
||||
/// atlas: the sprite a vertex's UV falls in says what it drew with. `sprites[0]`, the
|
||||
/// `.jem`'s texture, is in the atlas only with `custom`.
|
||||
fn sprite_counts(
|
||||
mob: &super::super::Mob,
|
||||
models: &Arc<CemModels>,
|
||||
sprites: &[&str],
|
||||
custom: bool,
|
||||
) -> Vec<usize> {
|
||||
let data = super::super::MobData {
|
||||
states: Vec::new(),
|
||||
placements: Default::default(),
|
||||
heightmaps: Default::default(),
|
||||
animations: Default::default(),
|
||||
biome_tags: Default::default(),
|
||||
entity_tags: Default::default(),
|
||||
villager_hats: Default::default(),
|
||||
cem: std::sync::RwLock::new(models.clone()),
|
||||
random_textures: Default::default(),
|
||||
pois: Default::default(),
|
||||
species_placements: Default::default(),
|
||||
};
|
||||
// Sprite i spans u and v from 0.2 i to 0.2 i + 0.1.
|
||||
let atlas = |name: &str| -> Option<[f32; 4]> {
|
||||
let i = sprites.iter().position(|s| *s == name)?;
|
||||
let lo = i as f32 * 0.2;
|
||||
(custom || i != 0).then_some([lo, lo, lo + 0.1, lo + 0.1])
|
||||
};
|
||||
let mut vertices = Vec::new();
|
||||
super::super::render::render(
|
||||
mob,
|
||||
0.0,
|
||||
glam::DVec3::ZERO,
|
||||
&atlas,
|
||||
0xf0f0,
|
||||
&data,
|
||||
&mut vertices,
|
||||
);
|
||||
let mut counts = vec![0; sprites.len()];
|
||||
for v in &vertices {
|
||||
let u = v.uv[0];
|
||||
let i = ((u + 0.05) / 0.2).floor() as usize;
|
||||
assert!(
|
||||
i < sprites.len() && u - i as f32 * 0.2 <= 0.1 + 1e-4,
|
||||
"a UV between the sprites: {u}"
|
||||
);
|
||||
counts[i] += 1;
|
||||
}
|
||||
counts
|
||||
}
|
||||
|
||||
/// A spider whose `.jem` replaces its body: the body draws with the `.jem`'s texture, the
|
||||
/// eyes (an overlay on the body's model) with theirs on the new geometry, and a texture the
|
||||
/// atlas lacks leaves the vanilla one. A sheep's wool, a layer with a model of its own,
|
||||
/// takes its `.jem`'s texture.
|
||||
#[test]
|
||||
fn a_jem_texture_paints_its_layer_and_leaves_the_overlays() {
|
||||
use super::super::{Anim, Mob, Species};
|
||||
let tree = |part: &str| Part {
|
||||
name: "root".into(),
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: Vec::new(),
|
||||
children: vec![plane(part, vec![])],
|
||||
};
|
||||
let textured = |texture: &str| {
|
||||
format!(
|
||||
r#"{{"texture": "textures/{texture}.png", "models": [
|
||||
{{"part": "body", {BOX}}}]}}"#
|
||||
)
|
||||
};
|
||||
let spider = textured("entity/spider/custom");
|
||||
let cem = Arc::new(models(
|
||||
"render",
|
||||
&[("optifine/cem/spider.jem", spider.as_str())],
|
||||
&["spider#main"],
|
||||
&tree("body1"),
|
||||
));
|
||||
let mob = Mob::new(
|
||||
1,
|
||||
mob_kind(
|
||||
"minecraft:spider",
|
||||
Species::Spider,
|
||||
Anim::Spider,
|
||||
"entity/spider/spider",
|
||||
&["spider#main"],
|
||||
&tree("body1"),
|
||||
),
|
||||
horizon_core::geometry::Vec3::ZERO,
|
||||
0.0,
|
||||
);
|
||||
let sprites = [
|
||||
"entity/spider/custom",
|
||||
"entity/spider/spider_eyes",
|
||||
"entity/spider/spider",
|
||||
];
|
||||
// The custom box's six faces, four corners each, for the body and again for the eyes;
|
||||
// the replaced body part's own face is gone.
|
||||
assert_eq!(sprite_counts(&mob, &cem, &sprites, true), [24, 24, 0]);
|
||||
assert_eq!(sprite_counts(&mob, &cem, &sprites, false), [0, 24, 24]);
|
||||
|
||||
let wool = textured("entity/sheep/custom_wool");
|
||||
let cem = Arc::new(models(
|
||||
"render-sheep",
|
||||
&[("optifine/cem/sheep_wool.jem", wool.as_str())],
|
||||
&["sheep#main", "sheep#wool"],
|
||||
&tree("body"),
|
||||
));
|
||||
let mob = Mob::new(
|
||||
1,
|
||||
mob_kind(
|
||||
"minecraft:sheep",
|
||||
Species::Sheep,
|
||||
Anim::Sheep,
|
||||
"entity/sheep/sheep",
|
||||
&["sheep#main", "sheep#wool"],
|
||||
&tree("body"),
|
||||
),
|
||||
horizon_core::geometry::Vec3::ZERO,
|
||||
0.0,
|
||||
);
|
||||
let sprites = [
|
||||
"entity/sheep/custom_wool",
|
||||
"entity/sheep/sheep_wool",
|
||||
"entity/sheep/sheep",
|
||||
];
|
||||
// The wool's custom box in the `.jem`'s texture; the vanilla body's one face.
|
||||
assert_eq!(sprite_counts(&mob, &cem, &sprites, true), [24, 0, 4]);
|
||||
assert_eq!(sprite_counts(&mob, &cem, &sprites, false), [0, 24, 4]);
|
||||
}
|
||||
|
||||
/// A wolf armour `.jem`'s texture paints the armour, the first layer on its model, and
|
||||
/// not the cracks drawn over it on the same model.
|
||||
#[test]
|
||||
fn armour_cracks_keep_their_texture() {
|
||||
use super::super::{Anim, Mob, Species, WOLF_ARMOR_DURABILITY};
|
||||
let tree = Part {
|
||||
name: "root".into(),
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: Vec::new(),
|
||||
children: vec![plane("body", vec![])],
|
||||
};
|
||||
let file = format!(
|
||||
r#"{{"texture": "textures/entity/wolf/custom_armor.png", "models": [
|
||||
{{"part": "body", {BOX}}}]}}"#
|
||||
);
|
||||
let layers = ["wolf#main", "wolf_armor#main"];
|
||||
let cem = Arc::new(models(
|
||||
"cracks",
|
||||
&[("optifine/cem/wolf_armor.jem", file.as_str())],
|
||||
&layers,
|
||||
&tree,
|
||||
));
|
||||
let mut mob = Mob::new(
|
||||
1,
|
||||
mob_kind(
|
||||
"minecraft:wolf",
|
||||
Species::Wolf,
|
||||
Anim::Wolf,
|
||||
"entity/wolf/wolf",
|
||||
&layers,
|
||||
&tree,
|
||||
),
|
||||
horizon_core::geometry::Vec3::ZERO,
|
||||
0.0,
|
||||
);
|
||||
mob.extra.tame = true;
|
||||
mob.extra.head = Some("minecraft:wolf_armor".into());
|
||||
// Worn through: the high cracks.
|
||||
mob.extra.body_damage = WOLF_ARMOR_DURABILITY;
|
||||
let sprites = [
|
||||
"entity/wolf/custom_armor",
|
||||
"entity/equipment/wolf_body/armadillo_scute",
|
||||
"entity/wolf/wolf_armor_crackiness_high",
|
||||
"entity/wolf/wolf_collar",
|
||||
];
|
||||
// The armour's custom box in the `.jem`'s texture, the cracks' over the same box in
|
||||
// theirs, and the collar on the vanilla body (whose own texture isn't in the atlas).
|
||||
assert_eq!(sprite_counts(&mob, &cem, &sprites, true), [24, 0, 24, 4]);
|
||||
assert_eq!(sprite_counts(&mob, &cem, &sprites, false), [0, 24, 24, 4]);
|
||||
}
|
||||
|
||||
/// Every layer of [`LAYERS`] and [`FALLBACKS`] is in Horizon's own models, every baby layer
|
||||
/// there is one a baby model name maps to, and so are the parts a sample of part names is
|
||||
/// renamed to.
|
||||
#[test]
|
||||
#[ignore = "requires prepared entity models in HORIZON_TEST_RESOURCES"]
|
||||
fn names_reach_horizons_models() {
|
||||
let root = std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES");
|
||||
let types: HashMap<String, Arc<MobType>> = super::super::MobTypes::load(Path::new(&root))
|
||||
.unwrap()
|
||||
.types
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, Arc::new(v)))
|
||||
.collect();
|
||||
let vanilla = vanilla_layers(&types);
|
||||
let mut missing = Vec::new();
|
||||
for (name, key) in LAYERS {
|
||||
if !vanilla.contains_key(key) {
|
||||
missing.push(format!("{name}: no layer {key}"));
|
||||
}
|
||||
}
|
||||
for name in FALLBACKS.iter().flat_map(|(name, takes)| [*name, *takes]) {
|
||||
let key = layer_key(name);
|
||||
if !vanilla.contains_key(key.as_str()) {
|
||||
missing.push(format!("{name}: no layer {key}"));
|
||||
}
|
||||
}
|
||||
// `sheep_baby#wool` is `sheep_baby_wool`'s: the baby name of a name of `sheep#wool`.
|
||||
for key in vanilla.keys() {
|
||||
let Some((model, layer)) = key.split_once('#') else {
|
||||
continue;
|
||||
};
|
||||
let Some(adult) = model.strip_suffix("_baby") else {
|
||||
continue;
|
||||
};
|
||||
let adult_key = format!("{adult}#{layer}");
|
||||
let reached = LAYERS
|
||||
.iter()
|
||||
.map(|(name, _)| *name)
|
||||
.chain([adult])
|
||||
.filter(|name| layer_key(name) == adult_key)
|
||||
.flat_map(baby_names)
|
||||
.any(|baby| layer_key(&baby) == *key);
|
||||
if !reached {
|
||||
missing.push(format!("{key}: no baby model name maps to it"));
|
||||
}
|
||||
}
|
||||
// A sample of the renamed parts.
|
||||
for (model, part) in [
|
||||
("zombie", "headwear"),
|
||||
("cow", "leg1"),
|
||||
("cow", "leg4"),
|
||||
("camel", "back_left_leg"),
|
||||
("camel", "front_right_leg"),
|
||||
("spider", "leg1"),
|
||||
("spider", "leg8"),
|
||||
("spider", "neck"),
|
||||
("spider", "body"),
|
||||
("villager", "headwear2"),
|
||||
("villager", "bodywear"),
|
||||
("witch", "headwear2"),
|
||||
("witch", "bodywear"),
|
||||
("chicken", "bill"),
|
||||
("chicken", "chin"),
|
||||
("llama", "chest_right"),
|
||||
("llama", "chest_left"),
|
||||
("turtle", "body2"),
|
||||
("ravager", "jaw"),
|
||||
("bee", "body"),
|
||||
("bee", "torso"),
|
||||
("warden", "body"),
|
||||
("warden", "torso"),
|
||||
("snow_golem", "body"),
|
||||
("snow_golem", "body_bottom"),
|
||||
("snow_golem", "right_hand"),
|
||||
("snow_golem", "left_hand"),
|
||||
("slime", "body"),
|
||||
("magma_cube", "core"),
|
||||
("magma_cube", "segment1"),
|
||||
("blaze", "stick1"),
|
||||
("ghast", "tentacle1"),
|
||||
("squid", "tentacle1"),
|
||||
("silverfish", "body1"),
|
||||
("silverfish", "wing1"),
|
||||
("endermite", "body1"),
|
||||
("guardian", "spine1"),
|
||||
("guardian", "tail1"),
|
||||
("wither", "body1"),
|
||||
("wither", "head1"),
|
||||
("phantom", "left_wing"),
|
||||
("phantom", "tail2"),
|
||||
("cat", "tail"),
|
||||
("ocelot", "tail"),
|
||||
("bat", "outer_right_wing"),
|
||||
("cod", "fin_back"),
|
||||
("cod", "fin_right"),
|
||||
("cod", "tail"),
|
||||
("tropical_fish_b", "fin_top"),
|
||||
("tropical_fish_b", "fin_bottom"),
|
||||
("puffer_fish_big", "fin_right"),
|
||||
("puffer_fish_medium", "fin_left"),
|
||||
("puffer_fish_small", "fin_right"),
|
||||
("puffer_fish_big", "spikes_front_top"),
|
||||
("rabbit", "left_foot"),
|
||||
("rabbit", "right_foot"),
|
||||
("sniffer", "middle_left_leg"),
|
||||
("strider", "hair_left_top"),
|
||||
("horse", "neck"),
|
||||
("horse", "mouth"),
|
||||
] {
|
||||
let key = layer_key(model);
|
||||
let target = vanilla_part(model, part);
|
||||
let found = vanilla
|
||||
.get(key.as_str())
|
||||
.is_some_and(|tree| super::super::render::find_part(tree, &target).is_some());
|
||||
if !found {
|
||||
missing.push(format!("{model} {part}: no part {target} in {key}"));
|
||||
}
|
||||
}
|
||||
assert!(missing.is_empty(), "{missing:#?}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2590,19 +2590,27 @@ impl Goal for EatBlock {
|
||||
m.position.z.floor() as i32,
|
||||
);
|
||||
let here = cx.w.block_name(x, y, z).to_owned();
|
||||
// EatBlockGoal.tick: both halves of the world change sit behind
|
||||
// MOB_GRIEFING (`ifeq 89`, `ifeq 182` in the bytecode); `Mob.ate`
|
||||
// runs either way, so a sheep still sheds its wool and ages up.
|
||||
if cx.w.has_tag("minecraft:edible_for_sheep", &here) {
|
||||
cx.events.push(MobEvent::BreakBlock {
|
||||
pos: [x, y, z],
|
||||
drop: false,
|
||||
});
|
||||
if cx.w.mob_griefing {
|
||||
cx.events.push(MobEvent::BreakBlock {
|
||||
pos: [x, y, z],
|
||||
drop: false,
|
||||
});
|
||||
}
|
||||
super::ai::ate(m);
|
||||
} else if cx.w.block_name(x, y - 1, z) == "minecraft:grass_block" {
|
||||
cx.events.push(MobEvent::SetBlockName {
|
||||
pos: [x, y - 1, z],
|
||||
name: "minecraft:dirt",
|
||||
effect: true,
|
||||
flags: horizon_world::level::flags::UPDATE_CLIENTS,
|
||||
});
|
||||
// The particles and the dirt `setBlock` are the same gate.
|
||||
if cx.w.mob_griefing {
|
||||
cx.events.push(MobEvent::SetBlockName {
|
||||
pos: [x, y - 1, z],
|
||||
name: "minecraft:dirt",
|
||||
effect: true,
|
||||
flags: horizon_world::level::flags::UPDATE_CLIENTS,
|
||||
});
|
||||
}
|
||||
super::ai::ate(m);
|
||||
}
|
||||
}
|
||||
@@ -2821,6 +2829,114 @@ impl MoveToBlock {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod eat_block_tests {
|
||||
use super::*;
|
||||
use crate::mobs::world::with_built_test_world_rules;
|
||||
|
||||
/// A sheep on a wide grass shelf, ticked long enough for its `EatBlockGoal`
|
||||
/// (1 in 1000 a tick) to fire several times: every event it pushed, with
|
||||
/// the `mob_griefing` game rule at `griefing`. `tufts` puts short grass at
|
||||
/// the sheep's own feet, so the goal takes the `destroyBlock` half; without
|
||||
/// it the sheep stands on the grass and takes the grass-to-dirt half.
|
||||
fn sheep_events(griefing: bool, tufts: bool) -> Option<Vec<MobEvent>> {
|
||||
let mut out = None;
|
||||
let build = |world: &mut crate::world::World,
|
||||
registry: &horizon_world::registry::BlockRegistry| {
|
||||
let grass = registry.default_state("minecraft:grass_block").unwrap();
|
||||
let tuft = registry.default_state("minecraft:short_grass").unwrap();
|
||||
for cx in -1..1 {
|
||||
for cz in -1..1 {
|
||||
world
|
||||
.chunks
|
||||
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
|
||||
}
|
||||
}
|
||||
for x in -8..=8 {
|
||||
for z in -8..=8 {
|
||||
world.set(x, 63, z, grass).unwrap();
|
||||
if tufts {
|
||||
world.set(x, 64, z, tuft).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
with_built_test_world_rules(&[], griefing, build, |w, types| {
|
||||
let kind = Arc::new(types.types["minecraft:sheep"].clone());
|
||||
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
let mut random = LegacyRandom::new(0);
|
||||
let mut events = Vec::new();
|
||||
for _ in 0..6000 {
|
||||
crate::mobs::ai::tick(&mut m, w, &mut random, &mut events);
|
||||
}
|
||||
out = Some(events);
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// `EatBlockGoal.tick` puts the `levelEvent` + `setBlock` behind
|
||||
/// `MOB_GRIEFING` and leaves `Mob.ate` outside it, so with the rule off the
|
||||
/// goal still runs (`EntityEvent` 10) and still shears the coat, and the
|
||||
/// grass stays.
|
||||
#[test]
|
||||
fn the_eat_goal_turns_grass_to_dirt_only_with_mob_griefing() {
|
||||
let Some(on) = sheep_events(true, false) else {
|
||||
return;
|
||||
};
|
||||
let Some(off) = sheep_events(false, false) else {
|
||||
return;
|
||||
};
|
||||
let dirt = |events: &[MobEvent]| {
|
||||
events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, MobEvent::SetBlockName { name, .. } if *name == "minecraft:dirt"))
|
||||
.count()
|
||||
};
|
||||
let ate = |events: &[MobEvent]| {
|
||||
events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, MobEvent::EntityEvent { event: 10, .. }))
|
||||
.count()
|
||||
};
|
||||
assert!(dirt(&on) > 0, "mob_griefing on: {}", dirt(&on));
|
||||
assert_eq!(dirt(&off), 0, "mob_griefing off must leave the grass alone");
|
||||
assert!(ate(&off) > 0, "the goal still runs with the rule off");
|
||||
}
|
||||
|
||||
/// The edible half of the same gate (`ifeq 89` skips
|
||||
/// `Level.destroyBlock(pos, false)`): short grass at the sheep's feet
|
||||
/// disappears with the rule on and stays with it off, while the goal eats
|
||||
/// either way.
|
||||
#[test]
|
||||
fn the_eat_goal_breaks_short_grass_only_with_mob_griefing() {
|
||||
let Some(on) = sheep_events(true, true) else {
|
||||
return;
|
||||
};
|
||||
let Some(off) = sheep_events(false, true) else {
|
||||
return;
|
||||
};
|
||||
let bitten = |events: &[MobEvent]| {
|
||||
events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, MobEvent::BreakBlock { drop: false, .. }))
|
||||
.count()
|
||||
};
|
||||
let ate = |events: &[MobEvent]| {
|
||||
events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, MobEvent::EntityEvent { event: 10, .. }))
|
||||
.count()
|
||||
};
|
||||
assert!(bitten(&on) > 0, "mob_griefing on: {}", bitten(&on));
|
||||
assert_eq!(
|
||||
bitten(&off),
|
||||
0,
|
||||
"mob_griefing off must leave the tufts alone"
|
||||
);
|
||||
assert!(ate(&off) > 0, "the goal still runs with the rule off");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod built_by_player_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1703,6 +1703,21 @@ pub fn ai_step(
|
||||
if !w.nether && !m.extra.flag2 && m.alive() {
|
||||
m.extra.counter2 += 1;
|
||||
if m.extra.counter2 > CONVERSION {
|
||||
// `Piglin.finishConversion` runs `PiglinAi.cancelAdmiring` first:
|
||||
// the item the piglin is admiring leaves the off hand and lands
|
||||
// where it stands, so the zombified piglin never carries it.
|
||||
if m.species() == Species::Piglin {
|
||||
let admiring = (m.extra.off_hand.is_some() && m.extra.using_item > 0)
|
||||
|| m.extra.piglin.seen_admire > 0;
|
||||
if admiring && let Some(item) = m.extra.off_hand.take() {
|
||||
events.push(MobEvent::DropItem {
|
||||
item,
|
||||
count: 1,
|
||||
at: [m.position.x, m.position.y, m.position.z],
|
||||
});
|
||||
m.extra.using_item = 0;
|
||||
}
|
||||
}
|
||||
// `Hoglin.finishConversion` is `ConversionParams.single(this, true,
|
||||
// false)`, `AbstractPiglin.finishConversion` `(this, true, true)`;
|
||||
// both leave the new mob nauseous (the game adds it).
|
||||
@@ -2172,3 +2187,47 @@ mod crossbow_tests {
|
||||
assert_eq!(f.seen_admire, 40);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod conversion_tests {
|
||||
use super::*;
|
||||
use crate::mobs::world::with_test_world;
|
||||
|
||||
/// `Piglin.finishConversion` cancels the admire before it converts: the
|
||||
/// item in the off hand is dropped at the piglin's feet and the zombified
|
||||
/// piglin does not carry it, where Horizon used to hand it over.
|
||||
#[test]
|
||||
fn a_converted_piglin_drops_the_item_it_was_admiring() {
|
||||
let ran = with_test_world(&[], |w, types| {
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
std::sync::Arc::new(types.types["minecraft:piglin"].clone()),
|
||||
Vec3::new(0.5, 64.0, 0.5),
|
||||
0.0,
|
||||
);
|
||||
m.extra.off_hand = Some("minecraft:gold_ingot".into());
|
||||
m.extra.using_item = 60;
|
||||
m.extra.counter2 = CONVERSION + 1;
|
||||
let mut lr = LegacyRandom::new(0);
|
||||
let mut events = Vec::new();
|
||||
ai_step(&mut m, w, &mut lr, &mut events);
|
||||
assert!(
|
||||
events.iter().any(|e| matches!(
|
||||
e,
|
||||
MobEvent::DropItem { item, count, .. }
|
||||
if &**item == "minecraft:gold_ingot" && *count == 1
|
||||
)),
|
||||
"the admired item is dropped"
|
||||
);
|
||||
let from = events
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
MobEvent::Convert { from, .. } => Some(from),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the piglin converts");
|
||||
assert_eq!(from.extra.off_hand, None, "the new mob carries nothing");
|
||||
});
|
||||
let _ = ran;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
//! OptiFine random entity textures on mobs: which variant of each of its textures a mob wears
|
||||
//! (`horizon_optifine::random_textures` finds them in the packs).
|
||||
//!
|
||||
//! [`choose`] runs when the client first sees a mob and about once a second after that, never
|
||||
//! per frame. The spawn-time facts (biome, height, blocks, moon, time, weather) are fixed when
|
||||
//! the mob is first seen; the others (name, profession, colour, baby, health, size) are read
|
||||
//! fresh each time. Drawing only looks the choice up ([`texture`]).
|
||||
//! [`choose`] runs when the client first sees a mob, about once a second after that, and on the
|
||||
//! next tick when the mob draws a texture with rules it hasn't chosen for (a wolf just tamed;
|
||||
//! at most once between those checks), never per frame. The spawn-time facts (biome, height, blocks, moon, time, weather) are fixed
|
||||
//! when the mob is first seen; the others (name, profession, colour, baby, health, size) are
|
||||
//! read fresh each time. Drawing only looks the choice up ([`texture`]). It walks the textures
|
||||
//! drawing does ([`super::render::drawn_textures`]): a custom model's own texture and the
|
||||
//! warden's glowing layers included.
|
||||
//!
|
||||
//! Each texture with its own rules (or those of the name it extends) wears that rule file's
|
||||
//! pick. A texture without rules follows the body's variant, so a sheep wearing `sheep3` has
|
||||
@@ -12,9 +15,10 @@
|
||||
//! choose their own, as ETF does.
|
||||
|
||||
use super::render::{DYE_NAMES, PROFESSIONS};
|
||||
use super::{Mob, Species};
|
||||
use super::{Mob, MobData, Species};
|
||||
use horizon_optifine::random::{self, Block, Facts, Weather};
|
||||
use horizon_optifine::random_textures::RandomTextures;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Where the mob was when the client first saw it.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -30,33 +34,74 @@ pub struct Seen {
|
||||
}
|
||||
|
||||
/// A mob's variants.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Choice {
|
||||
seen: Option<Seen>,
|
||||
seed: i32,
|
||||
/// The [`RandomTextures::generation`] the picks are from: another (a pack reload) makes
|
||||
/// them stale.
|
||||
generation: u64,
|
||||
/// The variant each rule file chose, by its index in [`RandomTextures::rules`].
|
||||
picks: Vec<(usize, u32)>,
|
||||
/// The body texture's variant.
|
||||
primary: Option<u32>,
|
||||
/// The packs changed since the picks.
|
||||
stale: bool,
|
||||
/// Ticks until the next check of the facts that change.
|
||||
countdown: u8,
|
||||
/// Drawing found a texture with rules and no pick: choose on the next tick, unless a miss
|
||||
/// was answered since the last regular check.
|
||||
missed: AtomicBool,
|
||||
/// A miss was answered since the last regular check: one per check, so a texture that
|
||||
/// drawing keeps missing (one `drawn_textures` doesn't list) can't make the mob choose
|
||||
/// every tick.
|
||||
answered: bool,
|
||||
}
|
||||
|
||||
impl Clone for Choice {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
seen: self.seen.clone(),
|
||||
seed: self.seed,
|
||||
generation: self.generation,
|
||||
picks: self.picks.clone(),
|
||||
primary: self.primary,
|
||||
countdown: self.countdown,
|
||||
missed: AtomicBool::new(self.missed.load(Ordering::Relaxed)),
|
||||
answered: self.answered,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ticks between checks of the facts that change.
|
||||
const RECHECK: u8 = 20;
|
||||
|
||||
impl Choice {
|
||||
/// Whether the mob's first sighting is recorded.
|
||||
pub fn has_seen(&self) -> bool {
|
||||
self.seen.is_some()
|
||||
}
|
||||
|
||||
/// Whether [`choose`] should run now: on first sight, after the packs changed, and on
|
||||
/// one tick in 20 (staggered by `stagger`, the entity id) for the facts that change.
|
||||
pub fn due(&self, tick: i64, stagger: u32) -> bool {
|
||||
!self.has_seen() || self.stale || (tick + i64::from(stagger)).rem_euclid(20) == 0
|
||||
}
|
||||
|
||||
/// The packs changed: choose again, from the same first sighting.
|
||||
pub fn forget_picks(&mut self) {
|
||||
self.stale = true;
|
||||
/// Called once a tick: whether [`choose`] should run now. It does on first sight, after the
|
||||
/// packs changed, every [`RECHECK`] ticks (the first spread by `stagger`, the entity id, so
|
||||
/// a crowd seen at once doesn't check at once), and when drawing missed a pick, once
|
||||
/// between those checks.
|
||||
pub fn due(&mut self, textures: &RandomTextures, stagger: u32) -> bool {
|
||||
let fresh = !self.has_seen() || self.generation != textures.generation();
|
||||
if fresh || self.countdown == 0 {
|
||||
self.countdown = if fresh {
|
||||
(stagger % u32::from(RECHECK)) as u8
|
||||
} else {
|
||||
RECHECK - 1
|
||||
};
|
||||
self.answered = false;
|
||||
self.missed.store(false, Ordering::Relaxed);
|
||||
return true;
|
||||
}
|
||||
self.countdown -= 1;
|
||||
if self.missed.swap(false, Ordering::Relaxed) && !self.answered {
|
||||
self.answered = true;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,9 +114,17 @@ pub fn seed(m: &Mob, uuid: Option<[i32; 4]>) -> i32 {
|
||||
})
|
||||
}
|
||||
|
||||
/// Chooses the variant of each texture `m` draws now. `seen` describes its surroundings the
|
||||
/// first time (when [`Choice::has_seen`] is false); later calls keep that.
|
||||
pub fn choose(m: &mut Mob, textures: &RandomTextures, seen: Option<Seen>, seed: i32) {
|
||||
/// Chooses the variant of each texture `m` draws now (its custom models' from `data`).
|
||||
/// `seen` describes its surroundings the first time (when [`Choice::has_seen`] is false);
|
||||
/// later calls keep that.
|
||||
pub fn choose(
|
||||
m: &mut Mob,
|
||||
textures: &RandomTextures,
|
||||
data: &MobData,
|
||||
seen: Option<Seen>,
|
||||
seed: i32,
|
||||
) {
|
||||
let names = super::render::drawn_textures(m, data);
|
||||
let mut choice = std::mem::take(&mut m.random_textures);
|
||||
if choice.seen.is_none() {
|
||||
choice.seen = seen;
|
||||
@@ -81,9 +134,9 @@ pub fn choose(m: &mut Mob, textures: &RandomTextures, seen: Option<Seen>, seed:
|
||||
return;
|
||||
};
|
||||
choice.seed = seed;
|
||||
choice.generation = textures.generation();
|
||||
choice.picks.clear();
|
||||
choice.primary = None;
|
||||
choice.stale = false;
|
||||
let states: Vec<Vec<(&str, &str)>> = seen
|
||||
.blocks
|
||||
.iter()
|
||||
@@ -114,10 +167,7 @@ pub fn choose(m: &mut Mob, textures: &RandomTextures, seen: Option<Seen>, seed:
|
||||
weather: Some(seen.weather),
|
||||
size: size(m),
|
||||
};
|
||||
let (_, body) = super::render::body(m);
|
||||
let layers = super::render::layers(m, m.age as f32);
|
||||
let names = std::iter::once(body.as_str()).chain(layers.iter().map(|l| l.texture.as_str()));
|
||||
for (i, name) in names.enumerate() {
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
let Some(entry) = textures.get(name) else {
|
||||
continue;
|
||||
};
|
||||
@@ -125,8 +175,8 @@ pub fn choose(m: &mut Mob, textures: &RandomTextures, seen: Option<Seen>, seed:
|
||||
Some(id) => match choice.picks.iter().find(|p| p.0 == id) {
|
||||
Some(&(_, index)) => index,
|
||||
None => {
|
||||
// ETF's 0 is the default texture too.
|
||||
let index = textures.rules(id).select(&facts, seed).max(1);
|
||||
// No rule matching is the texture itself, as is ETF's 0.
|
||||
let index = textures.rules(id).matching(&facts, seed).unwrap_or(0);
|
||||
choice.picks.push((id, index));
|
||||
index
|
||||
}
|
||||
@@ -145,11 +195,17 @@ pub fn choose(m: &mut Mob, textures: &RandomTextures, seen: Option<Seen>, seed:
|
||||
pub fn texture<'a>(m: &Mob, textures: &'a RandomTextures, texture: &str) -> Option<&'a str> {
|
||||
let entry = textures.get(texture)?;
|
||||
let choice = &m.random_textures;
|
||||
if !choice.has_seen() || choice.stale {
|
||||
if !choice.has_seen() || choice.generation != textures.generation() {
|
||||
return None;
|
||||
}
|
||||
let index = match entry.rules {
|
||||
Some(id) => choice.picks.iter().find(|p| p.0 == id)?.1,
|
||||
Some(id) => match choice.picks.iter().find(|p| p.0 == id) {
|
||||
Some(&(_, index)) => index,
|
||||
None => {
|
||||
choice.missed.store(true, Ordering::Relaxed);
|
||||
return None;
|
||||
}
|
||||
},
|
||||
None => match choice.primary {
|
||||
Some(primary)
|
||||
if !matches!(m.species(), Species::Villager | Species::ZombieVillager) =>
|
||||
@@ -163,16 +219,13 @@ pub fn texture<'a>(m: &Mob, textures: &'a RandomTextures, texture: &str) -> Opti
|
||||
}
|
||||
|
||||
/// `colors`: a sheep's wool, a wolf's or cat's collar, a llama's carpet, a shulker's shell.
|
||||
fn color(m: &Mob) -> Option<&'static str> {
|
||||
fn color(m: &Mob) -> Option<&str> {
|
||||
match m.species() {
|
||||
Species::Sheep | Species::Wolf | Species::Cat => {
|
||||
Some(DYE_NAMES[usize::from(m.extra.color & 15)])
|
||||
}
|
||||
Species::Shulker => DYE_NAMES.get(usize::from(m.extra.color)).copied(),
|
||||
Species::Llama | Species::TraderLlama => {
|
||||
let carpet = super::llama::carpet(m)?;
|
||||
DYE_NAMES.iter().find(|&&d| d == carpet).copied()
|
||||
}
|
||||
Species::Llama | Species::TraderLlama => super::llama::carpet(m),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -199,6 +252,23 @@ mod tests {
|
||||
Mob::new(1, kind, Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
/// No custom models.
|
||||
fn data() -> MobData {
|
||||
MobData {
|
||||
states: Vec::new(),
|
||||
placements: Default::default(),
|
||||
heightmaps: Default::default(),
|
||||
animations: Default::default(),
|
||||
villager_hats: Default::default(),
|
||||
cem: Default::default(),
|
||||
random_textures: Default::default(),
|
||||
biome_tags: Default::default(),
|
||||
entity_tags: Default::default(),
|
||||
pois: Default::default(),
|
||||
species_placements: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn seen(biome: &str) -> Seen {
|
||||
Seen {
|
||||
biome: Some(biome.into()),
|
||||
@@ -213,15 +283,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A temp pack of `files` (path, contents), scanned.
|
||||
/// A temp pack of `files` (path under `optifine/random/entity`, or from the root when it
|
||||
/// starts with `assets/`; contents), scanned.
|
||||
fn pack(name: &str, files: &[(&str, &str)]) -> RandomTextures {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-mob-random-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
for (path, text) in files {
|
||||
let path = root
|
||||
.join("assets/minecraft/optifine/random/entity")
|
||||
.join(path);
|
||||
let path = if path.starts_with("assets/") {
|
||||
root.join(path)
|
||||
} else {
|
||||
root.join("assets/minecraft/optifine/random/entity")
|
||||
.join(path)
|
||||
};
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, text).unwrap();
|
||||
}
|
||||
@@ -251,32 +325,62 @@ mod tests {
|
||||
("sheep/sheep_wool3.png", ""),
|
||||
],
|
||||
);
|
||||
let data = data();
|
||||
let mut m = mob(Species::Sheep);
|
||||
assert_eq!(texture(&m, &t, SHEEP), None, "not chosen yet");
|
||||
assert!(m.random_textures.due(5, m.id));
|
||||
// Entity 1: chosen at first sight, then 1 tick later (the stagger), then every 20.
|
||||
assert!(m.random_textures.due(&t, m.id));
|
||||
m.extra.color = 14; // red
|
||||
choose(&mut m, &t, Some(seen("minecraft:plains")), 0);
|
||||
assert_eq!(
|
||||
texture(&m, &t, SHEEP),
|
||||
Some(variant("entity/sheep/sheep3").as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
texture(&m, &t, WOOL),
|
||||
Some(variant("entity/sheep/sheep_wool3").as_str())
|
||||
);
|
||||
// Checked again on one tick in 20 (tick + id), the dyed sheep changes.
|
||||
assert!(!m.random_textures.due(5, m.id));
|
||||
assert!(m.random_textures.due(19, m.id));
|
||||
choose(&mut m, &t, &data, Some(seen("minecraft:plains")), 0);
|
||||
let sheep3 = variant("entity/sheep/sheep3");
|
||||
assert_eq!(texture(&m, &t, SHEEP), Some(sheep3.as_str()));
|
||||
let wool3 = variant("entity/sheep/sheep_wool3");
|
||||
assert_eq!(texture(&m, &t, WOOL), Some(wool3.as_str()));
|
||||
let ticks: Vec<bool> = (0..23).map(|_| m.random_textures.due(&t, 1)).collect();
|
||||
let due: Vec<usize> = (0..23).filter(|&i| ticks[i]).collect();
|
||||
assert_eq!(due, [1, 21]);
|
||||
m.extra.color = 0;
|
||||
choose(&mut m, &t, None, 0);
|
||||
assert_eq!(
|
||||
texture(&m, &t, SHEEP),
|
||||
Some(variant("entity/sheep/sheep2").as_str())
|
||||
choose(&mut m, &t, &data, None, 0);
|
||||
let sheep2 = variant("entity/sheep/sheep2");
|
||||
assert_eq!(texture(&m, &t, SHEEP), Some(sheep2.as_str()));
|
||||
// Another scan (a pack reload): nothing until chosen again, at once, from the same
|
||||
// first sighting.
|
||||
let reloaded = pack(
|
||||
"colour-again",
|
||||
&[
|
||||
("sheep/sheep.properties", "textures.1=2"),
|
||||
("sheep/sheep2.png", ""),
|
||||
],
|
||||
);
|
||||
// A pack reload: nothing until chosen again, at once.
|
||||
m.random_textures.forget_picks();
|
||||
assert_eq!(texture(&m, &t, SHEEP), None);
|
||||
assert!(m.random_textures.due(5, m.id));
|
||||
assert_eq!(texture(&m, &reloaded, SHEEP), None);
|
||||
assert!(m.random_textures.due(&reloaded, m.id));
|
||||
choose(&mut m, &reloaded, &data, None, 0);
|
||||
assert_eq!(texture(&m, &reloaded, SHEEP), Some(sheep2.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_rule_matching_is_the_texture_itself() {
|
||||
// ETF: `sheep1.png` replaces variant 1, but a mob no rule matches (or a rule's 0)
|
||||
// wears vanilla's texture, and so does the wool that follows it.
|
||||
let t = pack(
|
||||
"zero",
|
||||
&[
|
||||
(
|
||||
"sheep/sheep.properties",
|
||||
"textures.1=2\nbiomes.1=desert\ntextures.2=0\nbiomes.2=forest",
|
||||
),
|
||||
("sheep/sheep1.png", ""),
|
||||
("sheep/sheep2.png", ""),
|
||||
("sheep/sheep_wool1.png", ""),
|
||||
],
|
||||
);
|
||||
let data = data();
|
||||
for biome in ["minecraft:plains", "minecraft:forest"] {
|
||||
let mut m = mob(Species::Sheep);
|
||||
choose(&mut m, &t, &data, Some(seen(biome)), 0);
|
||||
assert_eq!(texture(&m, &t, SHEEP), None, "{biome}");
|
||||
assert_eq!(texture(&m, &t, WOOL), None, "{biome}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -288,21 +392,17 @@ mod tests {
|
||||
("sheep/sheep2.png", ""),
|
||||
],
|
||||
);
|
||||
let data = data();
|
||||
let mut m = mob(Species::Sheep);
|
||||
choose(&mut m, &t, Some(seen("minecraft:desert")), 0);
|
||||
assert_eq!(
|
||||
texture(&m, &t, SHEEP),
|
||||
Some(variant("entity/sheep/sheep2").as_str())
|
||||
);
|
||||
choose(&mut m, &t, &data, Some(seen("minecraft:desert")), 0);
|
||||
let sheep2 = variant("entity/sheep/sheep2");
|
||||
assert_eq!(texture(&m, &t, SHEEP), Some(sheep2.as_str()));
|
||||
// Walking into plains doesn't change it: biomes are read when first seen.
|
||||
choose(&mut m, &t, Some(seen("minecraft:plains")), 0);
|
||||
assert_eq!(
|
||||
texture(&m, &t, SHEEP),
|
||||
Some(variant("entity/sheep/sheep2").as_str())
|
||||
);
|
||||
choose(&mut m, &t, &data, Some(seen("minecraft:plains")), 0);
|
||||
assert_eq!(texture(&m, &t, SHEEP), Some(sheep2.as_str()));
|
||||
// Without a first sighting nothing is chosen.
|
||||
let mut unseen = mob(Species::Sheep);
|
||||
choose(&mut unseen, &t, None, 0);
|
||||
choose(&mut unseen, &t, &data, None, 0);
|
||||
assert_eq!(texture(&unseen, &t, SHEEP), None);
|
||||
}
|
||||
|
||||
@@ -317,26 +417,168 @@ mod tests {
|
||||
("sheep/sheep_wool2.png", ""),
|
||||
],
|
||||
);
|
||||
let data = data();
|
||||
let mut m = mob(Species::Sheep);
|
||||
choose(&mut m, &t, Some(seen("minecraft:plains")), 4);
|
||||
choose(&mut m, &t, &data, Some(seen("minecraft:plains")), 4);
|
||||
// 4 % 3 + 1 = 2.
|
||||
assert_eq!(
|
||||
texture(&m, &t, SHEEP),
|
||||
Some(variant("entity/sheep/sheep2").as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
texture(&m, &t, WOOL),
|
||||
Some(variant("entity/sheep/sheep_wool2").as_str())
|
||||
);
|
||||
choose(&mut m, &t, None, 5);
|
||||
let wool2 = variant("entity/sheep/sheep_wool2");
|
||||
assert_eq!(texture(&m, &t, WOOL), Some(wool2.as_str()));
|
||||
choose(&mut m, &t, &data, None, 5);
|
||||
// 5 % 3 + 1 = 3: there is no sheep_wool3, so the wool is vanilla's.
|
||||
assert_eq!(
|
||||
texture(&m, &t, SHEEP),
|
||||
Some(variant("entity/sheep/sheep3").as_str())
|
||||
);
|
||||
let sheep3 = variant("entity/sheep/sheep3");
|
||||
assert_eq!(texture(&m, &t, SHEEP), Some(sheep3.as_str()));
|
||||
assert_eq!(texture(&m, &t, WOOL), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_with_its_own_rules_and_a_warden_glow() {
|
||||
// The warden's pulsating spots, OptiFine's own example: a layer `render_visible` adds
|
||||
// with rules of its own (and a name ending in a digit).
|
||||
let t = pack(
|
||||
"warden",
|
||||
&[
|
||||
("warden/warden_pulsating_spots_2.properties", "textures.1=2"),
|
||||
("warden/warden_pulsating_spots_2.2.png", ""),
|
||||
],
|
||||
);
|
||||
let mut m = mob(Species::Warden);
|
||||
choose(&mut m, &t, &data(), Some(seen("minecraft:deep_dark")), 0);
|
||||
let spots = variant("entity/warden/warden_pulsating_spots_2.2");
|
||||
assert_eq!(
|
||||
texture(&m, &t, "entity/warden/warden_pulsating_spots_2"),
|
||||
Some(spots.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_texture_drawn_without_a_pick_is_chosen_next_tick() {
|
||||
let t = pack(
|
||||
"missed",
|
||||
&[
|
||||
("sheep/sheep.properties", "textures.1=2"),
|
||||
("sheep/sheep2.png", ""),
|
||||
("cow/cow.properties", "textures.1=2"),
|
||||
("cow/cow2.png", ""),
|
||||
],
|
||||
);
|
||||
let mut m = mob(Species::Sheep);
|
||||
choose(&mut m, &t, &data(), Some(seen("minecraft:plains")), 0);
|
||||
assert!(m.random_textures.due(&t, 1), "checked, then 19 quiet ticks");
|
||||
assert!(!m.random_textures.due(&t, 1));
|
||||
// The sheep never drew a cow texture: drawing one asks for a choice at once.
|
||||
assert_eq!(texture(&m, &t, "entity/cow/cow"), None);
|
||||
assert!(m.random_textures.due(&t, 1));
|
||||
assert!(!m.random_textures.due(&t, 1));
|
||||
// Still missing (never listed): no second choice until the regular check.
|
||||
assert_eq!(texture(&m, &t, "entity/cow/cow"), None);
|
||||
assert!(!m.random_textures.due(&t, 1));
|
||||
// 15 more quiet ticks, the regular check, then a miss is answered at once again.
|
||||
for _ in 0..15 {
|
||||
assert!(!m.random_textures.due(&t, 1));
|
||||
}
|
||||
assert!(m.random_textures.due(&t, 1));
|
||||
assert_eq!(texture(&m, &t, "entity/cow/cow"), None);
|
||||
assert!(m.random_textures.due(&t, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_without_rules_follows_the_bodys_pick_or_miss() {
|
||||
// The wool's variants are in ETF's folder, which holds no rules, so it follows the body
|
||||
// (whose rules are in OptiFine's): `sheep_wool1.png` doesn't stand in for a miss.
|
||||
let t = pack(
|
||||
"follow",
|
||||
&[
|
||||
("sheep/sheep.properties", "textures.1=2\nbiomes.1=desert"),
|
||||
("sheep/sheep2.png", ""),
|
||||
(
|
||||
"assets/minecraft/etf/random/entity/sheep/sheep_wool1.png",
|
||||
"",
|
||||
),
|
||||
(
|
||||
"assets/minecraft/etf/random/entity/sheep/sheep_wool2.png",
|
||||
"",
|
||||
),
|
||||
],
|
||||
);
|
||||
let mut plains = mob(Species::Sheep);
|
||||
choose(&mut plains, &t, &data(), Some(seen("minecraft:plains")), 0);
|
||||
assert_eq!(texture(&plains, &t, WOOL), None);
|
||||
let mut desert = mob(Species::Sheep);
|
||||
choose(&mut desert, &t, &data(), Some(seen("minecraft:desert")), 0);
|
||||
assert_eq!(
|
||||
texture(&desert, &t, WOOL),
|
||||
Some("minecraft:etf/random/entity/sheep/sheep_wool2")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn villager_layers_choose_for_themselves() {
|
||||
let t = pack(
|
||||
"villager",
|
||||
&[
|
||||
("villager/villager2.png", ""),
|
||||
("villager/type/plains2.png", ""),
|
||||
("villager/type/plains3.png", ""),
|
||||
],
|
||||
);
|
||||
let plains = "entity/villager/type/plains";
|
||||
let mut m = mob(Species::Villager);
|
||||
// Seed 2: the body 1 + 2 % 2 = 1 (itself), the plains layer its own 1 + 2 % 3 = 3.
|
||||
choose(&mut m, &t, &data(), Some(seen("minecraft:plains")), 2);
|
||||
assert_eq!(texture(&m, &t, "entity/villager/villager"), None);
|
||||
let plains3 = variant("entity/villager/type/plains3");
|
||||
assert_eq!(texture(&m, &t, plains), Some(plains3.as_str()));
|
||||
// Seed 3: the body's 2 would give plains2; the layer's own 1 + 3 % 3 = 1 is itself.
|
||||
choose(&mut m, &t, &data(), None, 3);
|
||||
let villager2 = variant("entity/villager/villager2");
|
||||
assert_eq!(
|
||||
texture(&m, &t, "entity/villager/villager"),
|
||||
Some(villager2.as_str())
|
||||
);
|
||||
assert_eq!(texture(&m, &t, plains), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_facts_a_mob_gives() {
|
||||
let mut slime = mob(Species::Slime);
|
||||
slime.extra.size = 4;
|
||||
assert_eq!(size(&slime), Some(3), "big: the docs' 3");
|
||||
slime.extra.size = 1;
|
||||
assert_eq!(size(&slime), Some(0));
|
||||
let mut phantom = mob(Species::Phantom);
|
||||
phantom.extra.size = 5;
|
||||
assert_eq!(size(&phantom), Some(5));
|
||||
assert_eq!(size(&mob(Species::Sheep)), None);
|
||||
let mut shulker = mob(Species::Shulker);
|
||||
shulker.extra.color = 16;
|
||||
assert_eq!(color(&shulker), None, "16: no colour");
|
||||
shulker.extra.color = 3;
|
||||
assert_eq!(color(&shulker), Some("light_blue"));
|
||||
// A librarian of level 3 matches `professions.1=librarian:3`.
|
||||
let t = pack(
|
||||
"librarian",
|
||||
&[
|
||||
(
|
||||
"villager/villager.properties",
|
||||
"textures.1=2\nprofessions.1=librarian:3",
|
||||
),
|
||||
("villager/villager2.png", ""),
|
||||
],
|
||||
);
|
||||
let mut m = mob(Species::Villager);
|
||||
m.extra.variant2 = PROFESSIONS.iter().position(|&p| p == "librarian").unwrap() as u8;
|
||||
m.extra.variant3 = 3;
|
||||
choose(&mut m, &t, &data(), Some(seen("minecraft:plains")), 0);
|
||||
let villager2 = variant("entity/villager/villager2");
|
||||
assert_eq!(
|
||||
texture(&m, &t, "entity/villager/villager"),
|
||||
Some(villager2.as_str())
|
||||
);
|
||||
m.extra.variant3 = 2;
|
||||
choose(&mut m, &t, &data(), None, 0);
|
||||
assert_eq!(texture(&m, &t, "entity/villager/villager"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeds_from_uuid_ints() {
|
||||
let m = mob(Species::Sheep);
|
||||
|
||||
@@ -1646,6 +1646,77 @@ pub fn render_posed(
|
||||
let base = base_transform_at(m, alpha, origin);
|
||||
render_visible(m, alpha, base, atlas, light, data, &[], true, out);
|
||||
}
|
||||
/// The custom model's key of a body model: `main` is the wide player model, `kind.model`.
|
||||
fn cem_key(model_key: &str) -> &str {
|
||||
if model_key == "main" {
|
||||
"player#main"
|
||||
} else {
|
||||
model_key
|
||||
}
|
||||
}
|
||||
/// Every texture [`render_visible`] may look up for `m` now (keep the two in step: a texture
|
||||
/// drawn but not listed here never gets a random pick): the body's and each layer's, a
|
||||
/// custom model's own texture beside them (`cem_texture` falls back to the vanilla one when
|
||||
/// the atlas lacks it, and only the layer a `.jem` was made for takes it, `paints_with_jem`),
|
||||
/// and the warden's glowing layers (whether they glow this frame or not). Random textures
|
||||
/// choose a variant for each.
|
||||
pub fn drawn_textures(m: &Mob, data: &MobData) -> Vec<String> {
|
||||
fn push(names: &mut Vec<String>, custom: Option<&super::cem::CemLayer>, vanilla: String) {
|
||||
if let Some(texture) = custom.and_then(|c| c.texture.clone()) {
|
||||
names.push(texture);
|
||||
}
|
||||
names.push(vanilla);
|
||||
}
|
||||
let cem = data
|
||||
.cem
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (model_key, texture) = body(m);
|
||||
let cem_key = cem_key(&model_key);
|
||||
let mut names = Vec::new();
|
||||
push(&mut names, cem.layer(cem_key), texture);
|
||||
let mut all_layers = layers(m, m.age as f32);
|
||||
if m.species() == Species::Warden {
|
||||
for (model, texture, _) in super::warden::emissive_layers(m, 0.0) {
|
||||
all_layers.push(Layer {
|
||||
model: model.into(),
|
||||
texture: texture.into(),
|
||||
color: 0,
|
||||
full_bright: true,
|
||||
scroll: 0.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
let body_keys = [model_key.as_str(), cem_key];
|
||||
for (i, layer) in all_layers.iter().enumerate() {
|
||||
let custom = cem
|
||||
.layer(&layer.model)
|
||||
.filter(|_| paints_with_jem(&all_layers, i, body_keys));
|
||||
push(&mut names, custom, layer.texture.clone());
|
||||
}
|
||||
names
|
||||
}
|
||||
/// The texture a model drawn as `custom` takes: the `.jem`'s when the atlas has it, else
|
||||
/// `vanilla` (a texture the atlas lacks would hide the model).
|
||||
fn cem_texture<'a>(
|
||||
custom: Option<&'a super::cem::CemLayer>,
|
||||
vanilla: &'a str,
|
||||
atlas: Atlas<'_>,
|
||||
) -> &'a str {
|
||||
custom
|
||||
.and_then(|c| c.texture.as_deref())
|
||||
.filter(|t| atlas(t).is_some())
|
||||
.unwrap_or(vanilla)
|
||||
}
|
||||
/// Whether `layers[i]` is the layer its model's `.jem` was made for, so the `.jem`'s texture
|
||||
/// paints it: the first drawn with that model (`sheep#wool`, a wolf's armour). Later layers
|
||||
/// on the same model are overlays (the armour's cracks), like those on the body's model
|
||||
/// (`body`: eyes, collars, a villager's type and profession), and keep their own textures
|
||||
/// over the new geometry.
|
||||
fn paints_with_jem(layers: &[Layer], i: usize, body: [&str; 2]) -> bool {
|
||||
let key = layers[i].model.as_str();
|
||||
!body.contains(&key) && layers[..i].iter().all(|l| l.model != key)
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_visible(
|
||||
m: &Mob,
|
||||
@@ -1667,26 +1738,18 @@ fn render_visible(
|
||||
.cem
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// `main` is the wide player model, `kind.model`.
|
||||
let cem_key = if model_key == "main" {
|
||||
"player#main"
|
||||
} else {
|
||||
model_key.as_str()
|
||||
};
|
||||
let body_animation = cem.layer(cem_key).and_then(|c| c.animation.as_ref());
|
||||
let (model, texture) = match cem.layer(cem_key) {
|
||||
Some(custom) => (
|
||||
custom.model.clone(),
|
||||
custom.texture.clone().unwrap_or(texture),
|
||||
),
|
||||
None => (
|
||||
m.kind
|
||||
.layer(&model_key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| m.kind.model.clone()),
|
||||
texture,
|
||||
),
|
||||
let cem_key = cem_key(&model_key);
|
||||
let custom_body = cem.layer(cem_key);
|
||||
let body_animation = custom_body.and_then(|c| c.animation.as_ref());
|
||||
let model = match custom_body {
|
||||
Some(custom) => custom.model.clone(),
|
||||
None => m
|
||||
.kind
|
||||
.layer(&model_key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| m.kind.model.clone()),
|
||||
};
|
||||
let texture = cem_texture(custom_body, &texture, atlas);
|
||||
// OptiFine random textures: the sprite of the variant the mob wears for a texture, when
|
||||
// the atlas has it. The texture keeps its name for everything else.
|
||||
let random = data
|
||||
@@ -1707,7 +1770,7 @@ fn render_visible(
|
||||
let draw_body = |out: &mut Vec<Vertex>| {
|
||||
// A player or mannequin whose skin has no atlas sprite (an evicted or
|
||||
// never stored download) draws with its default skin, not invisibly.
|
||||
let uv = random_uv(&texture).or_else(|| atlas(&texture)).or_else(|| {
|
||||
let uv = random_uv(texture).or_else(|| atlas(texture)).or_else(|| {
|
||||
if avatar(m) {
|
||||
atlas(&m.kind.texture)
|
||||
} else {
|
||||
@@ -1757,21 +1820,24 @@ fn render_visible(
|
||||
// One Poses refilled for every layer (armour draws up to seven): it keeps
|
||||
// its maps' capacity.
|
||||
let mut layer_poses = Poses::default();
|
||||
for mut layer in all_layers {
|
||||
let body_keys = [model_key.as_str(), cem_key];
|
||||
for (i, layer) in all_layers.iter().enumerate() {
|
||||
let custom = cem.layer(&layer.model);
|
||||
// `layer.texture` stays the vanilla name (the villager hats below look for its
|
||||
// `/type/` folder).
|
||||
let texture = cem_texture(
|
||||
custom.filter(|_| paints_with_jem(&all_layers, i, body_keys)),
|
||||
&layer.texture,
|
||||
atlas,
|
||||
);
|
||||
let part = match custom {
|
||||
Some(custom) => {
|
||||
if let Some(texture) = &custom.texture {
|
||||
layer.texture.clone_from(texture);
|
||||
}
|
||||
custom.model.clone()
|
||||
}
|
||||
Some(custom) => custom.model.clone(),
|
||||
None => match m.kind.layer(&layer.model) {
|
||||
Some(part) => part.clone(),
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
let Some(uv) = random_uv(&layer.texture).or_else(|| atlas(&layer.texture)) else {
|
||||
let Some(uv) = random_uv(texture).or_else(|| atlas(texture)) else {
|
||||
continue;
|
||||
};
|
||||
layer_poses.reset(&part);
|
||||
@@ -1815,7 +1881,7 @@ fn render_visible(
|
||||
overlay,
|
||||
out,
|
||||
);
|
||||
if translucent_layer(m, &layer) {
|
||||
if translucent_layer(m, layer) {
|
||||
mark_oit(&mut out[start..]);
|
||||
}
|
||||
// The eyes render type's glowing eyes also shine in HDR output, at
|
||||
@@ -1927,7 +1993,8 @@ pub fn part_matrix(
|
||||
.iter()
|
||||
.find_map(|c| part_matrix(c, m, poses, name, x_offset))
|
||||
}
|
||||
fn find_part<'a>(part: &'a Part, name: &str) -> Option<&'a Part> {
|
||||
/// The first part named `name`, depth first.
|
||||
pub(super) fn find_part<'a>(part: &'a Part, name: &str) -> Option<&'a Part> {
|
||||
if part.name == name {
|
||||
return Some(part);
|
||||
}
|
||||
@@ -2950,3 +3017,97 @@ mod scroll_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod random_texture_tests {
|
||||
use super::*;
|
||||
use crate::mobs::{Polygon, Species};
|
||||
use horizon_optifine::random_textures::RandomTextures;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
/// A silverfish (drawn with its type's texture) whose model is one quad.
|
||||
fn silverfish() -> Mob {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let k = Arc::make_mut(&mut kind);
|
||||
k.species = Species::Silverfish;
|
||||
k.texture = "entity/silverfish".into();
|
||||
k.model = Arc::new(Part {
|
||||
name: "root".into(),
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: vec![Polygon {
|
||||
normal: [0.0, 0.0, -1.0],
|
||||
vertices: [
|
||||
[0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 0.0, 1.0, 0.0],
|
||||
[1.0, 1.0, 0.0, 1.0, 1.0],
|
||||
[0.0, 1.0, 0.0, 0.0, 1.0],
|
||||
],
|
||||
}],
|
||||
children: Vec::new(),
|
||||
});
|
||||
Mob::new(1, kind, horizon_core::geometry::Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
/// A pack whose silverfish has a second variant, every silverfish wearing it.
|
||||
fn data() -> MobData {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-render-random-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let dir = root.join("assets/minecraft/optifine/random/entity");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("silverfish.properties"), "textures.1=2").unwrap();
|
||||
std::fs::write(dir.join("silverfish2.png"), "").unwrap();
|
||||
let random = RandomTextures::scan(&root);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
MobData {
|
||||
states: Vec::new(),
|
||||
placements: Default::default(),
|
||||
heightmaps: Default::default(),
|
||||
animations: Default::default(),
|
||||
villager_hats: Default::default(),
|
||||
cem: Default::default(),
|
||||
random_textures: RwLock::new(random),
|
||||
biome_tags: Default::default(),
|
||||
entity_tags: Default::default(),
|
||||
pois: Default::default(),
|
||||
species_placements: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn uvs(m: &Mob, data: &MobData, atlas: Atlas) -> Vec<[f32; 2]> {
|
||||
let mut out = Vec::new();
|
||||
render_with(m, 0.0, glam::DVec3::ZERO, atlas, 0, data, &[], &mut out);
|
||||
out.iter().map(|v| v.uv).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mob_draws_its_variant_and_the_texture_when_the_atlas_lacks_it() {
|
||||
let data = data();
|
||||
let mut m = silverfish();
|
||||
{
|
||||
let textures = data.random_textures.read().unwrap();
|
||||
let seen = crate::mobs::random_textures::Seen {
|
||||
biome: None,
|
||||
y: 64,
|
||||
blocks: Vec::new(),
|
||||
moon_phase: 0,
|
||||
day_time: 0,
|
||||
weather: horizon_optifine::random::Weather::Clear,
|
||||
};
|
||||
crate::mobs::random_textures::choose(&mut m, &textures, &data, Some(seen), 0);
|
||||
}
|
||||
let both = |name: &str| match name {
|
||||
"entity/silverfish" => Some([0.0, 0.0, 0.5, 0.5]),
|
||||
"minecraft:optifine/random/entity/silverfish2" => Some([0.5, 0.5, 1.0, 1.0]),
|
||||
_ => None,
|
||||
};
|
||||
let drawn = uvs(&m, &data, &both);
|
||||
assert_eq!(drawn.len(), 4);
|
||||
assert!(drawn.iter().all(|uv| uv[0] >= 0.5 && uv[1] >= 0.5));
|
||||
let vanilla_only =
|
||||
|name: &str| (name == "entity/silverfish").then_some([0.0, 0.0, 0.5, 0.5]);
|
||||
let drawn = uvs(&m, &data, &vanilla_only);
|
||||
assert_eq!(drawn.len(), 4, "still drawn");
|
||||
assert!(drawn.iter().all(|uv| uv[0] <= 0.5 && uv[1] <= 0.5));
|
||||
}
|
||||
}
|
||||
@@ -403,6 +403,16 @@ pub(crate) fn with_built_test_world(
|
||||
others: &[Seen],
|
||||
build: impl FnOnce(&mut crate::world::World, &horizon_world::registry::BlockRegistry),
|
||||
f: impl FnOnce(&MobWorld<'_>, &super::MobTypes),
|
||||
) -> bool {
|
||||
with_built_test_world_rules(others, true, build, f)
|
||||
}
|
||||
/// `with_built_test_world` with the `mob_griefing` game rule the world reports.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_built_test_world_rules(
|
||||
others: &[Seen],
|
||||
mob_griefing: bool,
|
||||
build: impl FnOnce(&mut crate::world::World, &horizon_world::registry::BlockRegistry),
|
||||
f: impl FnOnce(&MobWorld<'_>, &super::MobTypes),
|
||||
) -> bool {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
@@ -454,7 +464,7 @@ pub(crate) fn with_built_test_world(
|
||||
block_tags: &items.block_tags,
|
||||
landing: &motion.landing,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
mob_griefing,
|
||||
universal_anger: false,
|
||||
mob_drops: true,
|
||||
max_entity_cramming: 24,
|
||||
|
||||
@@ -225,6 +225,10 @@ struct Rename {
|
||||
/// template state moves to the first of them, which is the one every
|
||||
/// declared property is at its default for.
|
||||
fanout: u32,
|
||||
/// The lookup from a template state and a declared combination to the state's row, as an
|
||||
/// offset from `first`: `rows[template offset * fanout + combination]`, laid out as vanilla
|
||||
/// does (`state_order`). Empty: the template's states times the combinations, in that order.
|
||||
rows: Vec<u32>,
|
||||
}
|
||||
impl Rename {
|
||||
fn new(from: &str, to: &str) -> Self {
|
||||
@@ -234,6 +238,7 @@ impl Rename {
|
||||
states: 0..0,
|
||||
first: 0,
|
||||
fanout: 1,
|
||||
rows: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The id itself, `Block{id}`, and `block.<ns>.<path>` / `item.<ns>.<path>` keys.
|
||||
@@ -252,13 +257,100 @@ impl Rename {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// The row of a template state's `combination`, as an offset from `first`.
|
||||
fn row(&self, at: u32, combination: u32) -> u32 {
|
||||
let index = at * self.fanout + combination;
|
||||
self.rows.get(index as usize).copied().unwrap_or(index)
|
||||
}
|
||||
/// Where a template state moves: its combination with every declared property at its default.
|
||||
fn id(&self, id: u32) -> Option<u32> {
|
||||
self.states
|
||||
.contains(&id)
|
||||
.then(|| self.first + (id - self.states.start) * self.fanout)
|
||||
.then(|| self.first + self.row(id - self.states.start, 0))
|
||||
}
|
||||
}
|
||||
|
||||
/// A template's own state properties, sorted by name, each with how many values it takes across
|
||||
/// the template's `states`: how `StateTable::new` reads them back, the first name slowest.
|
||||
fn template_properties<'a>(
|
||||
states: &Range<u32>,
|
||||
properties: impl Fn(u32) -> Option<&'a J>,
|
||||
) -> Vec<(String, u32)> {
|
||||
let Some(J::Obj(own)) = states.clone().next().and_then(&properties) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut names: Vec<&str> = own.iter().map(|(k, _)| k.as_str()).collect();
|
||||
names.sort_unstable();
|
||||
let mut out = Vec::new();
|
||||
for name in names {
|
||||
let mut seen: Vec<&J> = Vec::new();
|
||||
for id in states.clone() {
|
||||
if let Some(v) = properties(id).and_then(|p| p.get(name))
|
||||
&& !seen.contains(&v)
|
||||
{
|
||||
seen.push(v);
|
||||
}
|
||||
}
|
||||
out.push((name.to_owned(), (seen.len() as u32).max(1)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A block's states: in vanilla's order when the template's own properties multiply out to its
|
||||
/// states (every vanilla block's do), else the template's states times the combinations.
|
||||
fn block_order<'a>(
|
||||
states: &Range<u32>,
|
||||
properties: impl Fn(u32) -> Option<&'a J>,
|
||||
declared: &[(String, u32)],
|
||||
combinations: u32,
|
||||
) -> Vec<(u32, u32)> {
|
||||
let template = template_properties(states, properties);
|
||||
if template.iter().map(|p| p.1).product::<u32>() == states.len() as u32 {
|
||||
return state_order(&template, declared);
|
||||
}
|
||||
(0..states.len() as u32)
|
||||
.flat_map(|state| (0..combinations).map(move |combo| (state, combo)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The states of a block in vanilla's order (`StateDefinition`): every property, the template's
|
||||
/// and the declared ones, sorted by name, the first name varying slowest. Each entry is a
|
||||
/// template state's offset and the index of a declared combination (`combinations`), whose own
|
||||
/// order is by declared name, the first slowest. With every declared name after the template's
|
||||
/// this is the template's states times the combinations, as before.
|
||||
fn state_order(template: &[(String, u32)], declared: &[(String, u32)]) -> Vec<(u32, u32)> {
|
||||
// (name, values, its own side's stride, from the template)
|
||||
let mut all: Vec<(&str, u32, u32, bool)> = Vec::new();
|
||||
for (side, from_template) in [(template, true), (declared, false)] {
|
||||
let mut stride = 1;
|
||||
let mut strides = Vec::new();
|
||||
for (_, len) in side.iter().rev() {
|
||||
strides.push(stride);
|
||||
stride *= len;
|
||||
}
|
||||
for ((name, len), stride) in side.iter().zip(strides.into_iter().rev()) {
|
||||
all.push((name, *len, stride, from_template));
|
||||
}
|
||||
}
|
||||
all.sort_by(|a, b| a.0.cmp(b.0));
|
||||
let total: u32 = all.iter().map(|p| p.1).product();
|
||||
(0..total)
|
||||
.map(|position| {
|
||||
let (mut state, mut combo, mut rest) = (0, 0, total);
|
||||
for &(_, len, stride, from_template) in &all {
|
||||
rest /= len;
|
||||
let value = (position / rest) % len;
|
||||
if from_template {
|
||||
state += value * stride;
|
||||
} else {
|
||||
combo += value * stride;
|
||||
}
|
||||
}
|
||||
(state, combo)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The combinations of a block's declared state properties, in a fixed
|
||||
/// order: by name (a `BTreeMap`'s), each one's declared default first, and
|
||||
/// the first name varying slowest. So the first combination is every
|
||||
@@ -450,14 +542,17 @@ struct NewBlock<'a> {
|
||||
/// default first (one empty combination when the block declares none):
|
||||
/// the block's states are its template's states times these.
|
||||
combos: Vec<Vec<(String, String)>>,
|
||||
/// Each state's template state offset and combination index, in registry order
|
||||
/// (`state_order`).
|
||||
order: Vec<(u32, u32)>,
|
||||
}
|
||||
impl NewBlock<'_> {
|
||||
/// Template states and declared combinations in registry order.
|
||||
fn state_combinations(&self) -> impl Iterator<Item = (u32, &[(String, String)])> {
|
||||
self.rename
|
||||
.states
|
||||
.clone()
|
||||
.flat_map(|id| self.combos.iter().map(move |combo| (id, combo.as_slice())))
|
||||
let start = self.rename.states.start;
|
||||
self.order
|
||||
.iter()
|
||||
.map(move |&(state, combo)| (start + state, self.combos[combo as usize].as_slice()))
|
||||
}
|
||||
}
|
||||
/// An item being added (declared, or a block's).
|
||||
@@ -513,6 +608,73 @@ fn component_types(map: &J) -> BTreeSet<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocks and items native mods declare in code (`native_blocks`,
|
||||
/// `native_items`), joined to the content mods: each namespace's go into
|
||||
/// the content mod of that id when one is on (a native mod's own `.hmod`,
|
||||
/// with its models and textures), else into a content mod with no files of
|
||||
/// its own. Checked as files are ([`horizon_mods::Content::declare`],
|
||||
/// [`accept_mod`]); a namespace whose declarations are refused is left out
|
||||
/// whole, and its error returned.
|
||||
pub fn accept_declared(
|
||||
mods: &mut Vec<ContentMod>,
|
||||
blocks: &[horizon_mods::content::Block],
|
||||
items: &[horizon_mods::content::Item],
|
||||
known: &BTreeSet<String>,
|
||||
) -> Vec<String> {
|
||||
let namespace = |id: &str| id.split_once(':').map_or("", |(ns, _)| ns).to_owned();
|
||||
let mut by_namespace: std::collections::BTreeMap<String, (Vec<_>, Vec<_>)> = Default::default();
|
||||
for block in blocks {
|
||||
by_namespace
|
||||
.entry(namespace(&block.id))
|
||||
.or_default()
|
||||
.0
|
||||
.push(block.clone());
|
||||
}
|
||||
for item in items {
|
||||
by_namespace
|
||||
.entry(namespace(&item.id))
|
||||
.or_default()
|
||||
.1
|
||||
.push(item.clone());
|
||||
}
|
||||
let mut errors = Vec::new();
|
||||
for (ns, (blocks, items)) in by_namespace {
|
||||
let result = match mods.iter().position(|m| m.id == ns) {
|
||||
Some(i) => {
|
||||
let mut content = horizon_mods::Content::declared(&ns);
|
||||
content.blocks.clone_from(&mods[i].content.blocks);
|
||||
content.items.clone_from(&mods[i].content.items);
|
||||
content
|
||||
.declare(&blocks, &items)
|
||||
.and_then(|()| {
|
||||
let mut added = horizon_mods::Content::declared(&ns);
|
||||
added.blocks.clone_from(&blocks);
|
||||
added.items.clone_from(&items);
|
||||
validate_components(&added, known).map_err(|e| e.to_string())
|
||||
})
|
||||
.map(|()| {
|
||||
mods[i].content.blocks.extend(blocks);
|
||||
mods[i].content.items.extend(items);
|
||||
})
|
||||
}
|
||||
None => {
|
||||
let mut content = horizon_mods::Content::declared(&ns);
|
||||
content.declare(&blocks, &items).and_then(|()| {
|
||||
let candidate = ContentMod {
|
||||
id: ns.clone(),
|
||||
content,
|
||||
};
|
||||
accept_mod(mods, candidate, known).map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
};
|
||||
if let Err(e) = result {
|
||||
errors.push(format!("native blocks and items of {ns}: {e}"));
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
/// Check and accept one mod without disturbing previously accepted mods.
|
||||
pub fn accept_mod(
|
||||
mods: &mut Vec<ContentMod>,
|
||||
@@ -764,25 +926,6 @@ impl<'a> Plan<'a> {
|
||||
.into());
|
||||
}
|
||||
}
|
||||
// A block's states are its template's times the declared combinations, the
|
||||
// template's properties varying slowest, but the game reads a state table the
|
||||
// way vanilla's `StateDefinition` does: sorted by property name, the first name
|
||||
// slowest. The two agree only when every declared name sorts after the template's,
|
||||
// and a table that disagrees stops the game at load, so such a block is refused.
|
||||
let last = match properties(default) {
|
||||
Some(J::Obj(own)) => own.iter().map(|(k, _)| k.as_str()).max(),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(last) = last
|
||||
&& let Some(early) = decl.state_properties.keys().find(|k| k.as_str() < last)
|
||||
{
|
||||
return Err(format!(
|
||||
"{}: state property {early} sorts before {last}, a property of {}: the \
|
||||
game orders states by property name, so name it after {last}",
|
||||
decl.id, decl.template
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let combos = combinations(&decl.state_properties, &decl.default_state)
|
||||
.map_err(|e| format!("{}: {e}", decl.id))?;
|
||||
// A `default_state` key the block itself declares is its own,
|
||||
@@ -836,9 +979,25 @@ impl<'a> Plan<'a> {
|
||||
}
|
||||
default = matches[0];
|
||||
}
|
||||
// The game reads a state table as vanilla's `StateDefinition` does: every
|
||||
// property, the template's and the declared ones, sorted by name, the first
|
||||
// name slowest. A declared name that sorts before the template's has to
|
||||
// interleave with them, so the states are laid out in that order.
|
||||
let declared: Vec<(String, u32)> = decl
|
||||
.state_properties
|
||||
.iter()
|
||||
.map(|(name, property)| (name.clone(), property.values().len() as u32))
|
||||
.collect();
|
||||
let order = block_order(&states, properties, &declared, combos.len() as u32);
|
||||
// The lookup from (template state, combination) to the state's row.
|
||||
let mut rows = vec![0; order.len()];
|
||||
for (position, &(state, combo)) in order.iter().enumerate() {
|
||||
rows[state as usize * combos.len() + combo as usize] = position as u32;
|
||||
}
|
||||
let mut rename = Rename::new(&decl.template, &decl.id);
|
||||
rename.first = next;
|
||||
rename.fanout = combos.len() as u32;
|
||||
rename.rows = rows;
|
||||
let count =
|
||||
u32::try_from(states.len() as u64 * combos.len() as u64).unwrap_or(u32::MAX);
|
||||
next = next.saturating_add(count);
|
||||
@@ -856,6 +1015,7 @@ impl<'a> Plan<'a> {
|
||||
shapes,
|
||||
default,
|
||||
combos,
|
||||
order,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1338,15 +1498,20 @@ impl<'a> Plan<'a> {
|
||||
};
|
||||
for block in &self.blocks {
|
||||
if let Some(shapes) = &block.shapes {
|
||||
let fanout = block.combos.len();
|
||||
for (i, shape) in shapes.iter().enumerate() {
|
||||
let id = entries.len() as i32;
|
||||
entries.push(encode_shape(shape));
|
||||
// One shape per template state, shared by every combination
|
||||
// of it: the geometry is the template's, the properties are
|
||||
// the block's.
|
||||
for c in 0..fanout {
|
||||
indices.push((block.rename.first as usize + i * fanout + c, id));
|
||||
// One shape per template state, shared by every combination
|
||||
// of it: the geometry is the template's, the properties are
|
||||
// the block's.
|
||||
let ids: Vec<i32> = shapes
|
||||
.iter()
|
||||
.map(|shape| {
|
||||
entries.push(encode_shape(shape));
|
||||
entries.len() as i32 - 1
|
||||
})
|
||||
.collect();
|
||||
for (at, &shape) in ids.iter().enumerate() {
|
||||
for combination in 0..block.rename.fanout {
|
||||
let row = block.rename.row(at as u32, combination);
|
||||
indices.push((block.rename.first as usize + row as usize, shape));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1454,10 +1619,11 @@ impl<'a> Plan<'a> {
|
||||
for b in &self.blocks {
|
||||
// Parallel to `states`: every combination of a template
|
||||
// state keeps that state's shape.
|
||||
let mut copy = Vec::new();
|
||||
for id in b.rename.states.clone() {
|
||||
for _ in &b.combos {
|
||||
copy.push(a[id as usize]);
|
||||
let mut copy = vec![0; b.order.len()];
|
||||
for (at, id) in b.rename.states.clone().enumerate() {
|
||||
for combination in 0..b.rename.fanout {
|
||||
copy[b.rename.row(at as u32, combination) as usize] =
|
||||
a[id as usize];
|
||||
}
|
||||
}
|
||||
a.extend(copy);
|
||||
@@ -2262,7 +2428,10 @@ pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result<Vec<String>
|
||||
/// and mod files reuse it. With ATM10's carried mods a build takes about
|
||||
/// 25 s and peaks above 3 GB, more than everything the game keeps.
|
||||
pub fn resource_root(base: &Path, mods: &[ContentMod], cache: &Path) -> Result<PathBuf> {
|
||||
if mods.iter().all(|m| m.content.is_empty()) {
|
||||
if mods
|
||||
.iter()
|
||||
.all(|m| m.content.is_empty() && m.content.blocks.is_empty() && m.content.items.is_empty())
|
||||
{
|
||||
return Ok(base.to_owned());
|
||||
}
|
||||
let out = cache.join("mod-content");
|
||||
@@ -2317,8 +2486,12 @@ fn stamp(base: &Path, mods: &[ContentMod]) -> Result<String> {
|
||||
}
|
||||
for m in mods {
|
||||
parts.push(m.id.clone());
|
||||
// What it declares, so blocks and items from code count too.
|
||||
parts.push(format!("{:?} {:?}", m.content.blocks, m.content.items));
|
||||
let source = m.content.source();
|
||||
if source.is_dir() {
|
||||
if m.content.files.is_empty() {
|
||||
// Declared in code only: nothing to read.
|
||||
} else if source.is_dir() {
|
||||
for file in &m.content.files {
|
||||
parts.push(meta(&source.join(file))?);
|
||||
}
|
||||
|
||||
@@ -2608,6 +2608,98 @@ fn content_block_state_properties_fan_out_its_states() {
|
||||
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
|
||||
}
|
||||
|
||||
/// A declared property whose name sorts before the template's goes in vanilla's order
|
||||
/// (`StateDefinition`): all the names sorted, the first slowest, so `alpha` varies slower than the
|
||||
/// template's `axis`. A state table that read the rows in the other order stopped the game at
|
||||
/// load ("registry state order differs from vanilla's state definition").
|
||||
#[test]
|
||||
fn content_block_state_properties_follow_vanilla_name_order() {
|
||||
let (base, mods) = block_base(
|
||||
"block-name-order",
|
||||
&[r#"{"template":"minecraft:test","item":false,
|
||||
"state_properties":{"alpha":{"type":"boolean"}}}"#],
|
||||
);
|
||||
let plan = Plan::new(&base, &mods).unwrap();
|
||||
let facts = fs::read_to_string(base.join("facts.json")).unwrap();
|
||||
let mut doc: J = serde_json::from_str(&facts).unwrap();
|
||||
plan.json("facts.json", &mut doc).unwrap();
|
||||
let Some(J::Arr(rows)) = doc.get("states") else {
|
||||
panic!("no states")
|
||||
};
|
||||
assert_eq!(rows.len(), 2 + 4);
|
||||
let properties = |i: usize| -> (String, String) {
|
||||
let J::Arr(row) = &rows[i] else {
|
||||
panic!("row {i} is not a state")
|
||||
};
|
||||
let Some(J::Obj(state)) = row.get(4) else {
|
||||
panic!("row {i} has no state")
|
||||
};
|
||||
let Some((_, J::Obj(p))) = state.iter().find(|(k, _)| k == "properties") else {
|
||||
panic!("row {i} has no properties")
|
||||
};
|
||||
let value = |name: &str| match p.iter().find(|(k, _)| k == name) {
|
||||
Some((_, J::Str(s))) => s.clone(),
|
||||
_ => panic!("row {i} has no {name}"),
|
||||
};
|
||||
(value("alpha"), value("axis"))
|
||||
};
|
||||
let own = |a: &str, b: &str| (a.to_owned(), b.to_owned());
|
||||
// `alpha` slowest, then `axis`: the first name varies slowest, as in vanilla.
|
||||
assert_eq!(
|
||||
(2..6).map(properties).collect::<Vec<_>>(),
|
||||
[
|
||||
own("false", "x"),
|
||||
own("false", "y"),
|
||||
own("true", "x"),
|
||||
own("true", "y")
|
||||
]
|
||||
);
|
||||
// The default is the template's default state with every declared property at its default.
|
||||
assert_eq!(
|
||||
doc.get("defaults").unwrap().get("testmod:test"),
|
||||
Some(&J::Num(2.into()))
|
||||
);
|
||||
fs::remove_dir_all(&base).unwrap();
|
||||
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
|
||||
}
|
||||
|
||||
/// `state_order` over the template's and the declared properties together.
|
||||
#[test]
|
||||
fn block_states_are_laid_out_in_vanilla_order() {
|
||||
let p = |name: &str, len: u32| (name.to_owned(), len);
|
||||
// Every declared name after the template's: the template's states times the combinations,
|
||||
// as before, so ids of existing blocks do not move.
|
||||
let order = state_order(&[p("axis", 2)], &[p("lit", 2)]);
|
||||
assert_eq!(order, [(0, 0), (0, 1), (1, 0), (1, 1)]);
|
||||
// No declared property, or no template property: the identity, whichever side it is on.
|
||||
assert_eq!(state_order(&[p("axis", 3)], &[]), [(0, 0), (1, 0), (2, 0)]);
|
||||
assert_eq!(state_order(&[], &[p("lit", 2)]), [(0, 0), (0, 1)]);
|
||||
assert_eq!(state_order(&[], &[]), [(0, 0)]);
|
||||
// `bites` (4 values) before `facing` (4 values): the declared one slowest.
|
||||
let order = state_order(&[p("facing", 4)], &[p("bites", 4)]);
|
||||
assert_eq!(order.len(), 16);
|
||||
assert!(
|
||||
order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(j, &(s, c))| (s, c) == (j as u32 % 4, j as u32 / 4))
|
||||
);
|
||||
// Interleaved: `b` between the template's `a` and `c`. a (2), b (2), c (3), a slowest.
|
||||
let order = state_order(&[p("a", 2), p("c", 3)], &[p("b", 2)]);
|
||||
assert_eq!(order.len(), 12);
|
||||
// Template state = a * 3 + c, combo = b.
|
||||
assert_eq!(order[0], (0, 0));
|
||||
assert_eq!(order[1], (1, 0));
|
||||
assert_eq!(order[3], (0, 1));
|
||||
assert_eq!(order[6], (3, 0));
|
||||
assert_eq!(order[11], (5, 1));
|
||||
// Every pair appears once.
|
||||
let mut seen = order.clone();
|
||||
seen.sort_unstable();
|
||||
seen.dedup();
|
||||
assert_eq!(seen.len(), 12);
|
||||
}
|
||||
|
||||
/// The declared default comes first in the run, so its id is the block's
|
||||
/// default with no arithmetic of its own.
|
||||
#[test]
|
||||
@@ -2677,24 +2769,6 @@ fn content_block_state_properties_are_validated() {
|
||||
fs::remove_dir_all(&base).unwrap();
|
||||
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
|
||||
|
||||
// A declared name that sorts before the template's `axis` would make the state table
|
||||
// disagree with vanilla's order and stop the game at load: refused, naming the block.
|
||||
let (base, mods) = block_base(
|
||||
"block-name-order",
|
||||
&[r#"{"template":"minecraft:test","state_properties":{"alpha":{"type":"boolean"}}}"#],
|
||||
);
|
||||
let error = match Plan::new(&base, &mods) {
|
||||
Err(e) => e.to_string(),
|
||||
Ok(_) => panic!("a property sorting before the template's was accepted"),
|
||||
};
|
||||
assert!(
|
||||
error.contains("testmod:") && error.contains("alpha sorts before axis, a property of"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(!error.contains(" "), "one space between words: {error}");
|
||||
fs::remove_dir_all(&base).unwrap();
|
||||
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
|
||||
|
||||
// A default is one of the values the block declared.
|
||||
let (base, mods) = block_base(
|
||||
"block-bad-default",
|
||||
@@ -3369,3 +3443,101 @@ fn biome_modifiers_take_one_biome_or_a_list() {
|
||||
);
|
||||
let _ = fs::remove_dir_all(&scratch);
|
||||
}
|
||||
|
||||
/// Blocks and items a native mod declares in code join the content plan:
|
||||
/// inside the content mod of their namespace, or as one of their own, with
|
||||
/// ids after vanilla's as files' are.
|
||||
#[test]
|
||||
#[ignore = "full content root: CI and pr_ready run it"]
|
||||
fn blocks_and_items_declared_in_code_get_ids() {
|
||||
use horizon_mods::content::{Block, Item};
|
||||
let Some(base) = prepared() else {
|
||||
return;
|
||||
};
|
||||
let scratch = std::env::temp_dir().join(format!("horizon-mod-declared-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&scratch);
|
||||
let known = known_components(&base).unwrap();
|
||||
let block = |id: &str| Block {
|
||||
id: id.into(),
|
||||
template: "minecraft:oak_log".into(),
|
||||
item: true,
|
||||
..Block::default()
|
||||
};
|
||||
let wrench = Item {
|
||||
id: "gears:wrench".into(),
|
||||
template: "minecraft:stick".into(),
|
||||
max_stack_size: Some(1),
|
||||
..Item::default()
|
||||
};
|
||||
let mut mods = vec![ruby_mod(&scratch.join("ruby"))];
|
||||
let errors = accept_declared(
|
||||
&mut mods,
|
||||
&[block("ruby:ruby_log"), block("gears:shaft")],
|
||||
&[wrench],
|
||||
&known,
|
||||
);
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
assert_eq!(mods.len(), 2);
|
||||
assert!(
|
||||
mods[0]
|
||||
.content
|
||||
.blocks
|
||||
.iter()
|
||||
.any(|b| b.id == "ruby:ruby_log")
|
||||
);
|
||||
assert_eq!(mods[1].id, "gears");
|
||||
// A namespace another mod owns, and a clash with a file's block, are refused whole.
|
||||
let refused = accept_declared(
|
||||
&mut mods,
|
||||
&[block("ruby:ruby_block"), block("ruby:ruby_post")],
|
||||
&[],
|
||||
&known,
|
||||
);
|
||||
assert_eq!(refused.len(), 1, "{refused:?}");
|
||||
assert!(
|
||||
!mods[0]
|
||||
.content
|
||||
.blocks
|
||||
.iter()
|
||||
.any(|b| b.id == "ruby:ruby_post")
|
||||
);
|
||||
|
||||
let root = resource_root(&base, &mods, &scratch.join("cache")).unwrap();
|
||||
let vanilla = Assets::load(&base).unwrap();
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let n = vanilla.registry.len() as u32;
|
||||
let log_states = (0..n)
|
||||
.filter(|&s| vanilla.registry.state(BlockStateId(s)).unwrap().name == "minecraft:oak_log")
|
||||
.count() as u32;
|
||||
// Sorted by mod, then block: gears:shaft, then ruby's ruby_block, ruby_door, ruby_glass, ruby_log.
|
||||
let shaft = (n..assets.registry.len() as u32)
|
||||
.find(|&s| assets.registry.state(BlockStateId(s)).unwrap().name == "gears:shaft")
|
||||
.unwrap();
|
||||
assert_eq!(shaft, n);
|
||||
assert!(assets.registry.default_state("ruby:ruby_log").is_some());
|
||||
assert_eq!(
|
||||
assets.registry.len() as u32,
|
||||
n + log_states + 1 + 64 + 1 + log_states
|
||||
);
|
||||
let ids = horizon_protocol::registry::Builtin::load(&root.join("registries.json")).unwrap();
|
||||
for item in ["gears:shaft", "gears:wrench", "ruby:ruby_log"] {
|
||||
assert!(
|
||||
ids.get("minecraft:item").unwrap().id(item).is_some(),
|
||||
"{item}"
|
||||
);
|
||||
}
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let items = Items::load(&root, assets.registry, &facts, &PackOverlay::NONE).unwrap();
|
||||
assert_eq!(items.items["gears:wrench"].max_stack, 1);
|
||||
assert!(items.items["gears:shaft"].block.is_some());
|
||||
// The same declarations reuse the root; others build a new one.
|
||||
assert_eq!(
|
||||
resource_root(&base, &mods, &scratch.join("cache")).unwrap(),
|
||||
root
|
||||
);
|
||||
let stamp = fs::read(root.join(STAMP)).unwrap();
|
||||
mods[1].content.items[0].max_stack_size = Some(2);
|
||||
resource_root(&base, &mods, &scratch.join("cache")).unwrap();
|
||||
assert_ne!(fs::read(root.join(STAMP)).unwrap(), stamp);
|
||||
let _ = fs::remove_dir_all(&scratch);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Player liquid travel and environmental damage over the shared fluid state.
|
||||
use super::*;
|
||||
use crate::breathing::{Breath, apply, breathe};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_world::{
|
||||
fluid::FluidKind,
|
||||
@@ -57,12 +58,12 @@ impl MotionWorld<'_, '_, '_> {
|
||||
.state(self.block(eye))
|
||||
.is_some_and(|state| state.name == "minecraft:bubble_column");
|
||||
let submerged = self.player.is_eye_in_fluid(WATER) && !bubble_column;
|
||||
if submerged
|
||||
let drowns = submerged
|
||||
&& !self.player.status.water_breathing
|
||||
&& !self.player.status.breath_of_the_nautilus
|
||||
&& !self.player.invulnerable
|
||||
&& !self.player.spectator
|
||||
{
|
||||
&& !self.player.spectator;
|
||||
let vanilla = if drowns {
|
||||
// decreaseAirSupply's getAttribute: the instance exists from now
|
||||
// on, even unmodified.
|
||||
let key = self.player.attributes.key("minecraft:oxygen_bonus");
|
||||
@@ -74,22 +75,37 @@ impl MotionWorld<'_, '_, '_> {
|
||||
.player
|
||||
.attribute("minecraft:oxygen_bonus")?
|
||||
.max(f64::from(self.player.respiration));
|
||||
if oxygen <= 0.0 || self.scene.random.next_double() < 1.0 / (oxygen + 1.0) {
|
||||
self.player.air -= 1;
|
||||
let sparing = oxygen > 0.0 && self.scene.random.next_double() >= 1.0 / (oxygen + 1.0);
|
||||
Breath {
|
||||
can_breathe: false,
|
||||
consume: i32::from(!sparing),
|
||||
refill: 0,
|
||||
}
|
||||
if self.player.air <= -20 {
|
||||
self.player.air = 0;
|
||||
self.player.events.push(MotionEvent::Drown);
|
||||
self.player.events.push(MotionEvent::Health(
|
||||
crate::health::HealthEvent::DrownDamage(2.0),
|
||||
));
|
||||
self.environment_hurt("minecraft:drown", 2.0)?;
|
||||
} else {
|
||||
// The nautilus' breath keeps a submerged player's air as it is.
|
||||
let held = submerged
|
||||
&& self.player.status.breath_of_the_nautilus
|
||||
&& !self.player.status.water_breathing;
|
||||
Breath {
|
||||
can_breathe: true,
|
||||
consume: 1,
|
||||
refill: if held { 0 } else { 4 },
|
||||
}
|
||||
} else if !submerged
|
||||
|| !self.player.status.breath_of_the_nautilus
|
||||
|| self.player.status.water_breathing
|
||||
{
|
||||
self.player.air = (self.player.air + 4).min(300);
|
||||
};
|
||||
// LivingBreatheEvent, on the server's copy of the player only.
|
||||
let breath = if self.player.client_side {
|
||||
vanilla
|
||||
} else {
|
||||
breathe(self.player.entity_id as u32, "minecraft:player", vanilla)
|
||||
};
|
||||
let (air, drowned) = apply(self.player.air, 300, breath);
|
||||
self.player.air = air;
|
||||
if drowned {
|
||||
self.player.events.push(MotionEvent::Drown);
|
||||
self.player.events.push(MotionEvent::Health(
|
||||
crate::health::HealthEvent::DrownDamage(2.0),
|
||||
));
|
||||
self.environment_hurt("minecraft:drown", 2.0)?;
|
||||
}
|
||||
if self.player.is_in_lava() {
|
||||
self.player.ignite_for_ticks(300)?;
|
||||
|
||||
@@ -656,7 +656,7 @@ impl Projectile {
|
||||
}
|
||||
}
|
||||
/// `ShulkerBullet.tick`.
|
||||
fn tick_bullet(&mut self, w: &impl ProjectileWorld) -> (bool, Hit) {
|
||||
fn tick_bullet(&mut self, w: &impl ProjectileWorld, impact: bool) -> (bool, Hit) {
|
||||
let target = self.target_box(w);
|
||||
let ProjectileKind::ShulkerBullet { delta, .. } = &mut self.kind else {
|
||||
return (false, Hit::None);
|
||||
@@ -700,11 +700,11 @@ impl Projectile {
|
||||
self.left_owner =
|
||||
owner_box.is_none_or(|b| !b.inflate(Vec3::new(1.0, 1.0, 1.0)).contains(from));
|
||||
}
|
||||
if let Some((who, at, _)) = best {
|
||||
if impact && let Some((who, at, _)) = best {
|
||||
self.position = at;
|
||||
return (false, Hit::Entity { who, at });
|
||||
}
|
||||
if let Some((at, pos, face)) = block {
|
||||
if impact && let Some((at, pos, face)) = block {
|
||||
self.position = at;
|
||||
return (false, Hit::Block { pos, at, face });
|
||||
}
|
||||
@@ -768,8 +768,17 @@ impl Projectile {
|
||||
|
||||
/// One tick: returns whether to keep the projectile and what it hit.
|
||||
pub fn tick(&mut self, w: &impl ProjectileWorld) -> (bool, Hit) {
|
||||
self.tick_with(w, true)
|
||||
}
|
||||
/// One tick that meets nothing: what `tick` would have done had its hit been
|
||||
/// canceled (`ProjectileImpactEvent`). The projectile goes the whole way of its
|
||||
/// motion and flies on, with no hit to act on.
|
||||
pub fn tick_through(&mut self, w: &impl ProjectileWorld) -> (bool, Hit) {
|
||||
self.tick_with(w, false)
|
||||
}
|
||||
fn tick_with(&mut self, w: &impl ProjectileWorld, impact: bool) -> (bool, Hit) {
|
||||
let from = self.position;
|
||||
let result = self.tick_inner(w);
|
||||
let result = self.tick_inner(w, impact);
|
||||
w.block_contacts(
|
||||
self.id as i32,
|
||||
self.kind.id(),
|
||||
@@ -779,11 +788,11 @@ impl Projectile {
|
||||
);
|
||||
result
|
||||
}
|
||||
fn tick_inner(&mut self, w: &impl ProjectileWorld) -> (bool, Hit) {
|
||||
fn tick_inner(&mut self, w: &impl ProjectileWorld, impact: bool) -> (bool, Hit) {
|
||||
self.previous = self.position;
|
||||
self.age += 1;
|
||||
if matches!(self.kind, ProjectileKind::ShulkerBullet { .. }) {
|
||||
return self.tick_bullet(w);
|
||||
return self.tick_bullet(w, impact);
|
||||
}
|
||||
// ThrowableProjectile and AbstractHurtingProjectile.applyInertia read
|
||||
// `isInWater` before their move and `Projectile.tick`'s base tick;
|
||||
@@ -852,11 +861,11 @@ impl Projectile {
|
||||
self.left_owner = owner_box
|
||||
.is_none_or(|b| !b.inflate(Vec3::new(1.0, 1.0, 1.0)).contains(self.position));
|
||||
}
|
||||
if let Some((who, at, _)) = best {
|
||||
if impact && let Some((who, at, _)) = best {
|
||||
self.position = at;
|
||||
return (false, Hit::Entity { who, at });
|
||||
}
|
||||
if let Some((at, pos, face)) = block {
|
||||
if impact && let Some((at, pos, face)) = block {
|
||||
self.position = at;
|
||||
return (false, Hit::Block { pos, at, face });
|
||||
}
|
||||
@@ -990,6 +999,80 @@ mod tests {
|
||||
assert!((p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9);
|
||||
assert!((p.position.x - 0.99).abs() < 1e-9);
|
||||
}
|
||||
/// A wall of blocks across x = 1.
|
||||
struct Wall(Vec<(Who, Aabb)>);
|
||||
impl ProjectileWorld for Wall {
|
||||
fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
|
||||
(from.x < 1.0 && to.x >= 1.0)
|
||||
.then(|| (Vec3::new(1.0, from.y, from.z), [1, 100, 0], [-1, 0, 0]))
|
||||
}
|
||||
fn in_water(&self, _: Aabb) -> bool {
|
||||
false
|
||||
}
|
||||
fn targets(&self) -> &[(Who, Aabb)] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
fn thrown(kind: ProjectileKind) -> Projectile {
|
||||
let mut p = Projectile::new(kind, Vec3::new(0.0, 100.0, 0.0), Some(Who::Player));
|
||||
(p.left_owner, p.velocity) = (true, Vec3::new(1.5, 0.0, 0.0));
|
||||
p
|
||||
}
|
||||
#[test]
|
||||
fn a_tick_through_a_wall_flies_the_whole_way_and_hits_nothing() {
|
||||
for kind in [
|
||||
ProjectileKind::Snowball,
|
||||
ProjectileKind::SmallFireball,
|
||||
ProjectileKind::LlamaSpit,
|
||||
] {
|
||||
let wall = Wall(Vec::new());
|
||||
let mut hit = thrown(kind.clone());
|
||||
let (keep, hits) = hit.tick(&wall);
|
||||
assert!(
|
||||
!keep
|
||||
&& matches!(
|
||||
hits,
|
||||
Hit::Block {
|
||||
pos: [1, 100, 0],
|
||||
..
|
||||
}
|
||||
)
|
||||
);
|
||||
assert_eq!(hit.position.x, 1.0);
|
||||
// The same tick, canceled: on, as in open air.
|
||||
let mut through = thrown(kind.clone());
|
||||
let mut open = thrown(kind);
|
||||
let (kept, none) = through.tick_through(&wall);
|
||||
assert_eq!(none, Hit::None);
|
||||
let _ = open.tick(&Empty(Vec::new()));
|
||||
assert_eq!(
|
||||
(through.position, through.velocity),
|
||||
(open.position, open.velocity)
|
||||
);
|
||||
assert!(kept);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_tick_through_an_entity_does_not_hit_it() {
|
||||
let pig = Aabb::new(Vec3::new(1.0, 99.5, -0.5), Vec3::new(2.0, 100.5, 0.5));
|
||||
let world = Empty(vec![(Who::Mob(7), pig)]);
|
||||
let mut hit = thrown(ProjectileKind::EnderPearl);
|
||||
assert!(matches!(
|
||||
hit.tick(&world),
|
||||
(
|
||||
false,
|
||||
Hit::Entity {
|
||||
who: Who::Mob(7),
|
||||
..
|
||||
}
|
||||
)
|
||||
));
|
||||
let mut through = thrown(ProjectileKind::EnderPearl);
|
||||
let (keep, none) = through.tick_through(&world);
|
||||
assert!(keep && none == Hit::None);
|
||||
// Gravity and drag of the tick, then the whole of its motion.
|
||||
assert!((through.position.x - 1.5 * 0.99).abs() < 1e-9);
|
||||
}
|
||||
#[test]
|
||||
fn a_snowball_with_no_gravity_stays_where_it_was_summoned() {
|
||||
// Entity.getGravity is 0 with NoGravity: at rest, it never moves.
|
||||
|
||||
@@ -885,12 +885,15 @@ fn decode_mod_block_entity(id: String, tag: &Tag) -> Option<crate::containers::M
|
||||
} else {
|
||||
items.clear();
|
||||
}
|
||||
Some(crate::containers::ModBlockEntity {
|
||||
id,
|
||||
nbt,
|
||||
items,
|
||||
data: Vec::new(),
|
||||
})
|
||||
Some(
|
||||
crate::containers::ModBlockEntityFields {
|
||||
id,
|
||||
nbt,
|
||||
items,
|
||||
data: Vec::new(),
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::containers::BlockEntity)> {
|
||||
use crate::containers::BlockEntity;
|
||||
|
||||
@@ -1280,7 +1280,7 @@ fn the_prediction_records_each_client_tick_and_the_server_ticks_it_had_seen() {
|
||||
/// with no `BlockEntityData` for one whose mod gives no tag.
|
||||
#[test]
|
||||
fn a_synced_mod_block_entity_reaches_the_chunks_players_once_a_tick() {
|
||||
use horizon_client::containers::{BlockEntity, ModBlockEntity};
|
||||
use horizon_client::containers::{BlockEntity, ModBlockEntity, ModBlockEntityFields};
|
||||
use horizon_world::nbt::{Compound, JavaString, Tag};
|
||||
let Some(resources) = resources() else {
|
||||
return;
|
||||
@@ -1309,10 +1309,10 @@ fn a_synced_mod_block_entity_reaches_the_chunks_players_once_a_tick() {
|
||||
s.world
|
||||
.set(pos.0, pos.1, pos.2, BlockStateId(MACHINE))
|
||||
.unwrap();
|
||||
let mut machine = ModBlockEntity {
|
||||
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
|
||||
id: "test:machine".into(),
|
||||
..ModBlockEntity::default()
|
||||
};
|
||||
..Default::default()
|
||||
});
|
||||
if let Some(on) = on {
|
||||
machine.nbt.insert("on".into(), Tag::Byte(on));
|
||||
}
|
||||
@@ -1445,3 +1445,46 @@ fn a_respawned_guest_is_immune_until_its_client_loads_the_world() {
|
||||
"the packet marks it loaded"
|
||||
);
|
||||
}
|
||||
|
||||
/// `tickPlayer`'s vehicle check (BoatFly): the driver of a boat its last accepted move left hovering
|
||||
/// over air is kicked once the boat has floated longer than `getMaximumFlyingTicks` (160 ticks at a
|
||||
/// boat's gravity), and a move that doesn't float starts the count again.
|
||||
#[test]
|
||||
fn a_boat_floating_too_long_kicks_its_driver() {
|
||||
let Some(resources) = resources() else {
|
||||
return;
|
||||
};
|
||||
let mut s = server(&resources);
|
||||
let port = s.hub.lan.port;
|
||||
let _bot = run(&mut s, move || connect(port, "Boater", true));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while s.hub.guests.is_empty() {
|
||||
s.tick(&[]);
|
||||
assert!(Instant::now() < deadline, "guest did not join");
|
||||
thread::yield_now();
|
||||
}
|
||||
let g = &mut s.hub.guests[0];
|
||||
(g.vehicle, g.vehicle_floating, g.vehicle_gravity) = (Some(7), true, 0.04);
|
||||
for _ in 0..100 {
|
||||
s.tick(&[]);
|
||||
}
|
||||
assert_eq!(s.hub.guests[0].above_ground_vehicle_ticks, 100);
|
||||
// The boat touched water: the next floating move counts from zero.
|
||||
s.hub.guests[0].vehicle_floating = false;
|
||||
s.tick(&[]);
|
||||
assert_eq!(s.hub.guests[0].above_ground_vehicle_ticks, 0);
|
||||
s.hub.guests[0].vehicle_floating = true;
|
||||
for _ in 0..160 {
|
||||
s.tick(&[]);
|
||||
}
|
||||
assert!(
|
||||
s.hub.guests[0].left_reason.is_empty(),
|
||||
"160 ticks are a boat's allowance: {}",
|
||||
s.hub.guests[0].left_reason
|
||||
);
|
||||
s.tick(&[]);
|
||||
assert!(
|
||||
!s.hub.guests[0].left_reason.is_empty(),
|
||||
"the 161st floating tick kicks the driver"
|
||||
);
|
||||
}
|
||||
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user