mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 10:39:46 -10:00
Merge main into the arrow rotation interpolation
This commit is contained in:
commit
c77c94cb22
78 files changed
+6570
-599
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"
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -135,6 +135,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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
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,
|
||||
ModEnergyBuffer, ModFluidTanks, 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()));
|
||||
|
||||
@@ -127,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;
|
||||
@@ -7794,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.
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -219,10 +219,24 @@ 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)?;
|
||||
for ((name, turns, lock), model) in variants.into_iter().zip(resolved) {
|
||||
let model = model?;
|
||||
let particle = model
|
||||
.material("particle")?
|
||||
.map(|material| sprites.insert(root, &material.sprite))
|
||||
|
||||
@@ -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,12 +340,45 @@ 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();
|
||||
let decoded = match cached {
|
||||
Some(decoded) => decoded,
|
||||
None => {
|
||||
let decoded = Self::decode(root, name)?;
|
||||
let decoded = match self.ready.remove(name) {
|
||||
Some(decoded) => decoded,
|
||||
None => Self::decode(root, name)?,
|
||||
};
|
||||
if let Some(disk) = &mut self.disk {
|
||||
disk.insert(name, decoded.clone());
|
||||
}
|
||||
@@ -357,7 +395,10 @@ 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`.
|
||||
@@ -706,6 +747,79 @@ mod tests {
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
#[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()));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
@@ -3762,6 +3770,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();
|
||||
@@ -3776,6 +3807,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];
|
||||
@@ -3788,7 +3821,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
|
||||
@@ -8133,42 +8167,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 {
|
||||
@@ -8183,47 +8225,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
|
||||
@@ -8268,9 +8319,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
|
||||
@@ -8288,21 +8341,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
|
||||
@@ -8312,8 +8374,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;
|
||||
@@ -11354,6 +11424,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();
|
||||
@@ -18598,11 +18669,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
|
||||
|
||||
@@ -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,6 +1070,10 @@ 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
|
||||
@@ -1242,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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -37,6 +37,25 @@ 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)
|
||||
})
|
||||
}
|
||||
|
||||
/// `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 +111,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,
|
||||
|
||||
@@ -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,9 @@ use horizon_client::{
|
||||
biome::BiomeColors,
|
||||
block_entities::Pos,
|
||||
chat::ChatSource,
|
||||
containers::{BlockEntity, MOD_MENUS, ModBlockEntity, ModContainer, ModFluidTanks},
|
||||
containers::{
|
||||
BlockEntity, MOD_MENUS, ModBlockEntity, ModContainer, ModEnergyBuffer, ModFluidTanks,
|
||||
},
|
||||
crafting::{CustomRecipe, Ingredient, recipe_result},
|
||||
extras::Corner,
|
||||
extras_hud::Corners,
|
||||
@@ -1264,6 +1268,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 +1332,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 +1463,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 +1620,35 @@ 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;
|
||||
}
|
||||
/// Options > Mods lists the native mods: push yours.
|
||||
pub(super) fn native_mods(&mut self, mods: &mut Vec<NativeMod>) {
|
||||
let _ = mods;
|
||||
@@ -1638,6 +1739,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 +1864,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
|
||||
@@ -2691,6 +2880,76 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (dimension, pos, viewers, previous);
|
||||
}
|
||||
/// 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 +3007,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 +3315,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 +4164,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.
|
||||
@@ -4546,6 +4944,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 +5419,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 +5914,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 +6051,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 +6066,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 +6086,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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6900,6 +6900,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;
|
||||
@@ -7860,7 +7868,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 {
|
||||
@@ -8141,16 +8155,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,
|
||||
@@ -8288,9 +8311,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));
|
||||
@@ -9714,12 +9750,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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
}));
|
||||
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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)?;
|
||||
|
||||
@@ -673,7 +673,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);
|
||||
@@ -717,11 +717,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 });
|
||||
}
|
||||
@@ -785,8 +785,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(),
|
||||
@@ -796,11 +805,14 @@ 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) {
|
||||
// `Entity.setOldPosAndRot` (the previous position and rotation the
|
||||
// renderer interpolates from), kept across the `impact` flag's arrival
|
||||
// from #6279.
|
||||
self.set_old_pos_and_rot();
|
||||
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;
|
||||
@@ -869,11 +881,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 });
|
||||
}
|
||||
@@ -1007,6 +1019,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.
|
||||
|
||||
@@ -181,8 +181,10 @@ fn yes() -> bool {
|
||||
}
|
||||
|
||||
/// A block the mod adds: a vanilla template's behaviour, states and shapes
|
||||
/// under a new id, with `properties` applied.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
/// under a new id, with `properties` applied. A native mod may build one in
|
||||
/// code (`native_blocks`): `..Default::default()` leaves the rest as a file
|
||||
/// leaves it out, except `item`, which a file sets unless told not to.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Block {
|
||||
/// `<mod id>:<path>`.
|
||||
pub id: String,
|
||||
@@ -248,8 +250,9 @@ struct ItemFile {
|
||||
}
|
||||
|
||||
/// An item the mod adds: a vanilla template item's components under a new
|
||||
/// id, with these changed.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
/// id, with these changed. A native mod may build one in code
|
||||
/// (`native_items`).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Item {
|
||||
pub id: String,
|
||||
/// The vanilla item it copies, `minecraft:<path>`.
|
||||
@@ -334,6 +337,8 @@ pub const MAX_MENU_SLOTS: u16 = 256;
|
||||
enum Source {
|
||||
Dir(PathBuf),
|
||||
Zip(PathBuf),
|
||||
/// No files: blocks and items a native mod declares in code.
|
||||
Code,
|
||||
}
|
||||
|
||||
/// A mod's content: its files and declarations.
|
||||
@@ -445,10 +450,95 @@ impl Content {
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
/// Content with no files, for the blocks and items native mods declare
|
||||
/// in code in `namespace` ([`Content::declare`]).
|
||||
pub fn declared(namespace: &str) -> Content {
|
||||
Content {
|
||||
source: Source::Code,
|
||||
files: Vec::new(),
|
||||
blocks: Vec::new(),
|
||||
items: Vec::new(),
|
||||
effects: Vec::new(),
|
||||
creative_tabs: Vec::new(),
|
||||
entity_types: Vec::new(),
|
||||
menu_types: Vec::new(),
|
||||
namespaces: vec![namespace.to_owned()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds blocks and items a native mod declares in code (`native_blocks`,
|
||||
/// `native_items`), checked as declaration files are: ids in this mod's
|
||||
/// own namespace (its id, as `data/<id>/horizon/` is) and declared once
|
||||
/// with what it already has, a vanilla template, a crafting remainder
|
||||
/// that is vanilla or this mod's. All or nothing: on an error nothing is
|
||||
/// added.
|
||||
pub fn declare(&mut self, blocks: &[Block], items: &[Item]) -> Result<(), String> {
|
||||
let own = self
|
||||
.namespaces
|
||||
.first()
|
||||
.map(String::as_str)
|
||||
.unwrap_or_default();
|
||||
let mut ids: BTreeSet<&str> = self
|
||||
.blocks
|
||||
.iter()
|
||||
.map(|b| b.id.as_str())
|
||||
.chain(self.items.iter().map(|i| i.id.as_str()))
|
||||
.collect();
|
||||
let mut item_ids: BTreeSet<&str> = self
|
||||
.blocks
|
||||
.iter()
|
||||
.filter(|b| b.item)
|
||||
.map(|b| b.id.as_str())
|
||||
.chain(self.items.iter().map(|i| i.id.as_str()))
|
||||
.collect();
|
||||
let in_own =
|
||||
|id: &str| namespaced_id(id) && id.split_once(':').is_some_and(|(ns, _)| ns == own);
|
||||
for block in blocks {
|
||||
let id = block.id.as_str();
|
||||
if !in_own(id) {
|
||||
return Err(format!("block {id:?} is not an id in {own}:"));
|
||||
}
|
||||
check_block(block).map_err(|e| format!("block {id}: {e}"))?;
|
||||
if !ids.insert(id) {
|
||||
return Err(format!("block {id} is declared twice"));
|
||||
}
|
||||
if block.item {
|
||||
item_ids.insert(id);
|
||||
}
|
||||
}
|
||||
for item in items {
|
||||
let id = item.id.as_str();
|
||||
if !in_own(id) {
|
||||
return Err(format!("item {id:?} is not an id in {own}:"));
|
||||
}
|
||||
check_item(item).map_err(|e| format!("item {id}: {e}"))?;
|
||||
if !item_ids.insert(id) {
|
||||
return Err(format!(
|
||||
"item {id} is declared twice, or is also a block's item"
|
||||
));
|
||||
}
|
||||
}
|
||||
for item in items {
|
||||
if let Some(rest) = &item.crafting_remainder
|
||||
&& (rest == "minecraft:air"
|
||||
|| !vanilla_id(rest) && !item_ids.contains(rest.as_str()))
|
||||
{
|
||||
return Err(format!(
|
||||
"item {}: crafting_remainder {rest:?} is not a vanilla item (minecraft:...) or one of this mod's",
|
||||
item.id
|
||||
));
|
||||
}
|
||||
}
|
||||
self.blocks.extend_from_slice(blocks);
|
||||
self.items.extend_from_slice(items);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where the files are read from: the mod's folder or its `.hmod`.
|
||||
pub fn source(&self) -> &Path {
|
||||
match &self.source {
|
||||
Source::Dir(path) | Source::Zip(path) => path,
|
||||
Source::Code => Path::new(""),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,6 +558,7 @@ impl Content {
|
||||
Ok(match &self.source {
|
||||
Source::Dir(root) => FileReader::Dir(root),
|
||||
Source::Zip(path) => FileReader::Zip(Box::new(open_zip(path)?)),
|
||||
Source::Code => FileReader::Dir(Path::new("")),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -543,11 +634,16 @@ impl Content {
|
||||
for (block, name) in blocks {
|
||||
let file: BlockFile =
|
||||
serde_json::from_slice(&self.file(&name)?).map_err(|e| format!("{name}: {e}"))?;
|
||||
check_block(&file).map_err(|e| format!("{name}: {e}"))?;
|
||||
if file.item {
|
||||
ids.insert(block.clone());
|
||||
if file
|
||||
.connects_to
|
||||
.as_ref()
|
||||
.is_some_and(|t| !t.starts_with('#'))
|
||||
{
|
||||
return Err(format!(
|
||||
"{name}: connects_to is a block tag, #namespace:path"
|
||||
));
|
||||
}
|
||||
self.blocks.push(Block {
|
||||
let block = Block {
|
||||
id: block,
|
||||
template: file.template,
|
||||
properties: file.properties,
|
||||
@@ -560,37 +656,33 @@ impl Content {
|
||||
.connects_to
|
||||
.map(|t| t.trim_start_matches('#').to_owned()),
|
||||
thickness: file.thickness,
|
||||
});
|
||||
};
|
||||
check_block(&block).map_err(|e| format!("{name}: {e}"))?;
|
||||
if block.item {
|
||||
ids.insert(block.id.clone());
|
||||
}
|
||||
self.blocks.push(block);
|
||||
}
|
||||
let mut remainders = Vec::new();
|
||||
for (item, name) in items {
|
||||
let file: ItemFile =
|
||||
serde_json::from_slice(&self.file(&name)?).map_err(|e| format!("{name}: {e}"))?;
|
||||
if !vanilla_id(&file.template) {
|
||||
return Err(format!(
|
||||
"{name}: template {:?} is not a vanilla item (minecraft:...)",
|
||||
file.template
|
||||
));
|
||||
}
|
||||
if file.max_stack_size.is_some_and(|n| !(1..=99).contains(&n)) {
|
||||
return Err(format!("{name}: max_stack_size is 1 to 99"));
|
||||
}
|
||||
check_components(&file.components, &["max_stack_size", "rarity"])
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
if !ids.insert(item.clone()) {
|
||||
return Err(format!("{name}: {item} is also a block's item"));
|
||||
}
|
||||
if let Some(rest) = &file.crafting_remainder {
|
||||
remainders.push((name, rest.clone()));
|
||||
}
|
||||
self.items.push(Item {
|
||||
let item = Item {
|
||||
id: item,
|
||||
template: file.template,
|
||||
max_stack_size: file.max_stack_size,
|
||||
rarity: file.rarity,
|
||||
components: file.components,
|
||||
crafting_remainder: file.crafting_remainder,
|
||||
});
|
||||
};
|
||||
check_item(&item).map_err(|e| format!("{name}: {e}"))?;
|
||||
if !ids.insert(item.id.clone()) {
|
||||
return Err(format!("{name}: {} is also a block's item", item.id));
|
||||
}
|
||||
if let Some(rest) = &item.crafting_remainder {
|
||||
remainders.push((name, rest.clone()));
|
||||
}
|
||||
self.items.push(item);
|
||||
}
|
||||
for (effect, name) in effects {
|
||||
let file: MobEffect =
|
||||
@@ -712,7 +804,23 @@ fn check_components(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_block(file: &BlockFile) -> Result<(), String> {
|
||||
/// An item's fields, as a file or code gives them.
|
||||
fn check_item(item: &Item) -> Result<(), String> {
|
||||
if !vanilla_id(&item.template) {
|
||||
return Err(format!(
|
||||
"template {:?} is not a vanilla item (minecraft:...)",
|
||||
item.template
|
||||
));
|
||||
}
|
||||
if item.max_stack_size.is_some_and(|n| !(1..=99).contains(&n)) {
|
||||
return Err("max_stack_size is 1 to 99".into());
|
||||
}
|
||||
check_components(&item.components, &["max_stack_size", "rarity"])
|
||||
}
|
||||
|
||||
/// A block's fields, as a file or code gives them (`connects_to` without
|
||||
/// its `#`).
|
||||
fn check_block(file: &Block) -> Result<(), String> {
|
||||
if !vanilla_id(&file.template) {
|
||||
return Err(format!(
|
||||
"template {:?} is not a vanilla block (minecraft:...)",
|
||||
@@ -817,7 +925,7 @@ fn check_block(file: &BlockFile) -> Result<(), String> {
|
||||
if file.template != "minecraft:chorus_plant" {
|
||||
return Err("connects_to needs template minecraft:chorus_plant (a PipeBlock)".into());
|
||||
}
|
||||
let id = tag.strip_prefix('#').unwrap_or_default();
|
||||
let id = tag.as_str();
|
||||
let valid = |s: &str| {
|
||||
!s.is_empty()
|
||||
&& s.chars()
|
||||
@@ -906,6 +1014,83 @@ capabilities = ["registries"]
|
||||
content
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_and_items_declared_in_code_pass_the_files_checks() {
|
||||
let block = |id: &str| Block {
|
||||
id: id.into(),
|
||||
template: "minecraft:iron_block".into(),
|
||||
item: true,
|
||||
..Block::default()
|
||||
};
|
||||
let item = |id: &str| Item {
|
||||
id: id.into(),
|
||||
template: "minecraft:diamond".into(),
|
||||
..Item::default()
|
||||
};
|
||||
let mut content = Content::declared("ruby");
|
||||
assert!(content.is_empty());
|
||||
let wrench = Item {
|
||||
crafting_remainder: Some("ruby:gear".into()),
|
||||
..item("ruby:wrench")
|
||||
};
|
||||
content
|
||||
.declare(&[block("ruby:gear"), block("ruby:casing")], &[wrench])
|
||||
.unwrap();
|
||||
assert_eq!(content.blocks.len(), 2);
|
||||
assert_eq!(content.items[0].id, "ruby:wrench");
|
||||
// Each refusal adds nothing.
|
||||
let refused = |blocks: &[Block], items: &[Item]| {
|
||||
let mut c = Content::declared("ruby");
|
||||
c.declare(&[block("ruby:gear")], &[]).unwrap();
|
||||
let e = c.declare(blocks, items).unwrap_err();
|
||||
assert_eq!((c.blocks.len(), c.items.len()), (1, 0), "{e}");
|
||||
e
|
||||
};
|
||||
assert!(refused(&[block("emerald:gear")], &[]).contains("not an id in ruby:"));
|
||||
assert!(refused(&[block("minecraft:gear")], &[]).contains("not an id in ruby:"));
|
||||
assert!(refused(&[], &[item("ruby")]).contains("not an id in ruby:"));
|
||||
assert!(refused(&[block("ruby:gear")], &[]).contains("declared twice"));
|
||||
assert!(refused(&[], &[item("ruby:gear")]).contains("block's item"));
|
||||
let modded = Block {
|
||||
template: "ruby:other".into(),
|
||||
..block("ruby:casing")
|
||||
};
|
||||
assert!(refused(&[modded], &[]).contains("not a vanilla block"));
|
||||
let dim = Block {
|
||||
properties: BlockProperties {
|
||||
light: Some(16),
|
||||
..BlockProperties::default()
|
||||
},
|
||||
..block("ruby:lamp")
|
||||
};
|
||||
assert!(refused(&[dim], &[]).contains("light is 0 to 15"));
|
||||
let stack = Item {
|
||||
max_stack_size: Some(100),
|
||||
..item("ruby:ingot")
|
||||
};
|
||||
assert!(refused(&[], &[stack]).contains("max_stack_size"));
|
||||
let rest = Item {
|
||||
crafting_remainder: Some("emerald:dust".into()),
|
||||
..item("ruby:dust")
|
||||
};
|
||||
assert!(refused(&[block("ruby:pipe")], &[rest]).contains("crafting_remainder"));
|
||||
// A cable's tag comes bare from code.
|
||||
let cable = Block {
|
||||
template: "minecraft:chorus_plant".into(),
|
||||
connects_to: Some("ruby:cables".into()),
|
||||
..block("ruby:cable")
|
||||
};
|
||||
content.declare(&[cable], &[]).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connecting_block_file_names_its_tag_with_a_hash() {
|
||||
let cable = br#"{"template": "minecraft:chorus_plant", "connects_to": "ruby:cables"}"#;
|
||||
let files: &[(&str, &[u8])] = &[("data/ruby/horizon/block/cable.json", cable)];
|
||||
let e = hmod("bare-tag", MANIFEST_TEXT, files).unwrap_err();
|
||||
assert!(e.contains("#namespace:path"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_only_mod_round_trips() {
|
||||
let files: &[(&str, &[u8])] = &[
|
||||
|
||||
+6
-2
@@ -95,8 +95,12 @@ for a vanilla block that ticks, and its sections count it.
|
||||
A block copies its template's states. It can also declare state properties of
|
||||
its own, which its template does not have — how a pipe says which sides it
|
||||
connects to, or a machine whether it is running. Its states then become its
|
||||
template's states times the combinations of the ones it declares, in a fixed
|
||||
order with every declared default first, so ids do not move between loads:
|
||||
template's states times the combinations of the ones it declares, laid out as
|
||||
vanilla lays out a block's states (`StateDefinition`): every property, the
|
||||
template's and the declared ones, sorted by name with the first name varying
|
||||
slowest, and each declared property's default value first. The order is fixed,
|
||||
so ids do not move between loads, and a declared name may sort before one of
|
||||
the template's (`bites` on a carved pumpkin, whose `facing` comes after it):
|
||||
|
||||
```json
|
||||
{ "template": "minecraft:stone",
|
||||
|
||||
@@ -1126,6 +1126,13 @@ items are left out (NeoForge's `RenderLivingEvent.Pre`, cancelled); its name tag
|
||||
- A mod's own creature is an ordinary mob (`native_spawn_mob`, API 37) drawn this way: a quiet villager, an allay or a bat
|
||||
gives it movement, sounds and saving, and the mod gives it a model and animations of its own. Tell yours apart by the
|
||||
ids the server half sends with mod messages (API 40), or by its custom name.
|
||||
- Players have their own hook, `native_player_submit(player, who, draw, drawn)` (`RenderPlayerEvent.Pre`): once a frame for
|
||||
each other player in view and for this window's player in third person, with the same frame and `who` (the profile name,
|
||||
and `local`). `drawn` leaves out the vanilla model, armour, held items, cape and other layers.
|
||||
- `native_mob_layer(mob, who, draw)` (`EntityRenderersEvent.AddLayers`, `RenderLivingEvent.Post`) adds to every mob and player
|
||||
after its vanilla model and layers, in the same frame (not for one a mod drew itself); `who` is set for a player. The
|
||||
geometry hangs from the entity's frame, not from a model bone: place a hat from `head_yaw`, `pitch` and the entity type's
|
||||
eye height.
|
||||
|
||||
### Advancement triggers
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hooks": [
|
||||
{"name": "block_entity_energy", "target": "horizon_client::game::native_hooks::Game<'_>::native_block_entity_energy", "neoforge": "Capabilities.Energy.BLOCK", "side": "server"}
|
||||
],
|
||||
"rust_summary": "an energy buffer on a mod block's block entity (`native_block_entity_energy`), and the energy helpers in `machines/native_energy.rs`: `native_energy`, `native_energy_insert`, `native_energy_extract`, `native_move_energy`.",
|
||||
"doc_summary": "an energy buffer on a mod block's block entity (`Capabilities.Energy.BLOCK`), and the helpers that read and change it. `native_block_entity_energy` declares the buffer 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. The amount lives in the entity's saved compound as the int `energy`, so it survives restarts and the block's own tick reads it there. `native_energy(dimension, pos, face)` reads it through a side; `native_energy_insert(pos, face, amount, simulate)` and `native_energy_extract(dimension, pos, face, amount, simulate)` answer the amount moved (`simulate` changes nothing); `native_move_energy(dimension, from, from_face, to, to_face, max)` is `EnergyHandlerUtil.move` between two blocks and answers the amount moved. Bundles need \"hooks\": @API:block_entity_energy@.",
|
||||
"comments": ["crates/horizon-client/src/game/machines/native_energy.rs"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hooks": [
|
||||
{"name": "data_map_types", "target": "horizon_client::game::native_hooks::Game<'_>::native_data_map_types", "neoforge": "RegisterDataMapTypesEvent", "side": "server"}
|
||||
],
|
||||
"rust_summary": "data map types a native mod registers (`native_data_map_types`), merged from the data packs' and mods' files each time the server's tables are built, read with `native_data_map` and `native_data_map_entries` (`native_data_maps.rs`).",
|
||||
"doc_summary": "data maps (`RegisterDataMapTypesEvent`). `native_data_map_types(types)` pushes the types a mod registers: an `id` (`mod_id:name`, not in `minecraft`) for a `registry`, `minecraft:item` or `minecraft:block` so far; asked once per load of the server's data (a world's first open and every `/reload`). The values come from `data/<mod_id>/data_maps/<registry path>/<name>.json` in the world's data packs and from files a mod pushes through `native_data_pack_files` under the key `data_maps/<mod_id>:<registry path>/<name>`: `{\"replace\": false, \"values\": {\"minecraft:coal\": ..., \"#minecraft:logs\": ...}, \"remove\": [...]}`, an entry's value being the data itself or `{\"value\": ..., \"replace\": false}`; `replace` clears what earlier files set, a tag gives every entry in it the value, `remove` applies after the file's own values, and a newer value overrides an older one. `native_data_map(registry, type, entry)` reads an entry's value (the JSON the file held) and `native_data_map_entries(registry, type)` lists them. Server side only and not synced to clients; mergers, conditions, other registries and mod datapack registries are not here yet. Bundles need \"hooks\": @API:data_map_types@.",
|
||||
"comments": ["crates/horizon-client/src/game/native_data_maps.rs"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "entity_mount",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_entity_mount",
|
||||
"neoforge": "EntityMountEvent",
|
||||
"side": "server",
|
||||
"cancellable": true
|
||||
}
|
||||
],
|
||||
"rust_summary": "an entity getting on or off a vehicle, which a mod can cancel (`native_entity_mount`).",
|
||||
"doc_summary": "an entity getting on or off a vehicle, which a mod can cancel, through `native_entity_mount` (NeoForge's `EntityMountEvent`): the rider's id and type (`minecraft:player`, `minecraft:zombie`), the vehicle's id and type (`minecraft:oak_boat`, `minecraft:horse`), whether it is mounting, and a `canceled` flag. Set `canceled` and the rider does not board, or keeps riding; a `/ride` that is canceled fails. It is told for a player boarding a boat, minecart, cushion or mount (pig, strider, horse and its kin, llama, nautilus, happy ghast) and getting off 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 with `/ride` or a stab. It is not told for a rider whose vehicle is gone or dies, nor for riders loaded with a save. On the single player server thread and on a dedicated server. Bundles that use it need \"hooks\": @API:entity_mount@."
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "entity_tick_pre",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_entity_tick_pre",
|
||||
"neoforge": "EntityTickEvent.Pre",
|
||||
"side": "server",
|
||||
"cancellable": true
|
||||
},
|
||||
{
|
||||
"name": "entity_tick_post",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_entity_tick_post",
|
||||
"neoforge": "EntityTickEvent.Post",
|
||||
"side": "server",
|
||||
"cancellable": false
|
||||
},
|
||||
{
|
||||
"name": "entity_tick_listened",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_entity_tick_listened",
|
||||
"side": "server",
|
||||
"cancellable": false
|
||||
}
|
||||
],
|
||||
"rust_summary": "entities ticking, which a mod can stop (`native_entity_tick_pre`, `native_entity_tick_post`, told while a mod sets `native_entity_tick_listened`).",
|
||||
"doc_summary": "entities ticking, through `native_entity_tick_pre` (NeoForge's `EntityTickEvent.Pre`) and `native_entity_tick_post` (`EntityTickEvent.Post`), with the entity's id and type (`minecraft:zombie`, `minecraft:item`) and the level. Set `canceled` in the Pre and the entity doesn't tick this tick (and its Post isn't told). Told for the mobs, item entities, minecarts and boats that tick in an entity-ticking chunk, on the single player server thread and on a dedicated server; not for a player's own boat or minecart, projectiles, orbs, primed TNT, falling blocks or armor stands yet (check `kind`: later APIs add kinds). Mobs and items hear every Pre before the family's first tick and every Post after its last; minecarts and boats hear theirs around each one's tick. A mod that hooks them also hooks `native_entity_tick_listened(listened)` and sets `*listened = true`: otherwise no entity's tick is told. It runs each tick for every ticking entity, so keep it cheap. Bundles that use them need \"hooks\": @API:entity_tick@."
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "gui_layer_pre",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_gui_layer_pre",
|
||||
"neoforge": "RenderGuiLayerEvent.Pre",
|
||||
"side": "client",
|
||||
"cancellable": true
|
||||
},
|
||||
{
|
||||
"name": "gui_layer_post",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_gui_layer_post",
|
||||
"neoforge": "RenderGuiLayerEvent.Post",
|
||||
"side": "client",
|
||||
"cancellable": false
|
||||
}
|
||||
],
|
||||
"rust_summary": "HUD layers around vanilla's own (`native_gui_layer_pre`, `native_gui_layer_post`).",
|
||||
"doc_summary": "`native_gui_layer_pre(layer, batch, canceled)` and `native_gui_layer_post(layer, batch)` fire around each vanilla HUD layer Horizon draws, in its paint order, so a mod places its own drawing below a layer (in `pre`) or above it (in `post`), NeoForge's `RegisterGuiLayersEvent` registerBelow/registerAbove, and hides a layer by setting `canceled` in `pre` (`RenderGuiLayerEvent.Pre`). Layer ids: `minecraft:crosshair`, `hotbar`, `effects`, `boss_overlay`, `vehicle_health` (riding a mount), `contextual_info_bar` (the jump bar, locator bar or experience bar with the level number), `player_health` (health, armor, food and air rows, one pass), `selected_item_name`, `sleep_overlay`, `overlay_message` (action bar), `title`, `scoreboard_sidebar`, `subtitle_overlay`, `chat` (the unfocused lines) and `tab_list`. F1 hides the layers it hides in vanilla, and then nothing fires for them. The camera overlays (drawn by the screen-effects pass) and the debug screen are not layers. A hidden layer still runs its game logic (the health rows' blink, the bubble pop sound) and `post` still fires. Bundles need \"hooks\": @API:gui_layers@."
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "interaction_key",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_interaction_key",
|
||||
"neoforge": "InputEvent.InteractionKeyMappingTriggered",
|
||||
"side": "client",
|
||||
"cancellable": true
|
||||
}
|
||||
],
|
||||
"rust_summary": "a cancellable attack, use and pick-block key hook (`native_interaction_key`).",
|
||||
"doc_summary": "`native_interaction_key(button, key_mapping, hand, swing_hand, canceled)` fires while playing when an interaction key is about to act on the crosshair: button 0 the attack key (`key.attack`; when an attack is allowed, and each tick a held key keeps digging a block), 1 the use key (`key.use`; once for each hand tried, main first, before the hand's target and item), 2 pick block (`key.pickItem`; only with a block or entity aimed at). Set `canceled` to skip the action; a cancelled use ends the click, so the other hand gets no turn. A cancelled attack or use still swings `hand` unless the mod clears `swing_hand` (true on entry). A single player's window and a server's player (any window that plays on a server); the deprecated in-frame path is not covered. Bundles need \"hooks\": @API:interaction_key@."
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hooks": [
|
||||
{"name": "item_fluid_tank", "target": "horizon_client::game::native_hooks::Game<'_>::native_item_fluid_tank", "neoforge": "Capabilities.FluidHandler.ITEM"}
|
||||
],
|
||||
"rust_summary": "a mod item as a fluid container (`native_item_fluid_tank`), and the item fluid helpers in `native_item_fluids.rs`: `native_item_fluid`, `native_item_fluid_fill`, `native_item_fluid_drain`.",
|
||||
"doc_summary": "a fluid handler on items (`Capabilities.FluidHandler.ITEM`). `native_item_fluid_tank(item, capacity)` makes a mod item a container with a capacity in millibuckets; what it holds lives in the stack's `minecraft:custom_data` under `horizon_fluid` (`Fluid`, `Amount`), so it is saved, sent to Horizon clients and stacks only with equal contents. `native_item_fluid(stack)` reads one item of a stack (vanilla's buckets are built in: empty, water and lava, a whole bucket or nothing); `native_item_fluid_fill(stack, fluid, amount)` and `native_item_fluid_drain(stack, max)` answer how much one item takes or gives and the single item it becomes, as `IFluidHandler.fill`/`drain` through an `ItemAccess`; the stack given is not changed, so the caller shrinks it by one and hands out the result. They work on any side. Bundles need \"hooks\": @API:item_fluid_tank@.",
|
||||
"comments": ["crates/horizon-client/src/game/native_item_fluids.rs"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "living_breathe",
|
||||
"target": "horizon_client::breathing::native_living_breathe",
|
||||
"neoforge": "LivingBreatheEvent",
|
||||
"side": "server",
|
||||
"cancellable": false
|
||||
}
|
||||
],
|
||||
"rust_summary": "a player's or mob's breathing each tick (`native_living_breathe`, a free function in `horizon_client::breathing`).",
|
||||
"doc_summary": "a player's or mob's breathing, on the single player server thread and on a dedicated server: `native_living_breathe(entity, kind, can_breathe, consume, refill)` every tick for a player and for a mob with the generic air logic (NeoForge's `LivingBreatheEvent`, vanilla's `LivingEntity.baseTick`), with Horizon's answers as the defaults: set `can_breathe` and how much air `consume` takes or `refill` gives (up to 300); at -20 the entity is set to 0 air and takes 2 drowning damage. It is a free function in `horizon_client::breathing`, so it answers and cannot call helpers. Axolotls and `WaterAnimal`s keep their own air logic. Bundles that use it need \"hooks\": @API:living_breathe@.",
|
||||
"comments": [
|
||||
"crates/horizon-client/src/breathing.rs"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "living_equipment_change",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_living_equipment_change",
|
||||
"neoforge": "LivingEquipmentChangeEvent",
|
||||
"side": "server",
|
||||
"cancellable": false
|
||||
},
|
||||
{
|
||||
"name": "living_equipment_listened",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_living_equipment_listened",
|
||||
"side": "server",
|
||||
"cancellable": false
|
||||
}
|
||||
],
|
||||
"rust_summary": "a mob's or an armor stand's equipment slot taking another stack (`native_living_equipment_change`), and whether a mod listens to the mobs' (`native_living_equipment_listened`).",
|
||||
"doc_summary": "a mob's or an armor stand's equipment changing, on the single player server thread and on a dedicated server: `native_living_equipment_change(entity, kind, slot, from, to)` once for each slot (`mainhand`, `offhand`, `feet`, `legs`, `chest`, `head`, `body`, `saddle`) that holds another stack than the mods last heard (NeoForge's `LivingEquipmentChangeEvent`, vanilla's `LivingEntity.collectEquipmentChanges`). A mob new to the level counts from empty on its first tick. A mod that hooks it also hooks `native_living_equipment_listened(listened)` and sets `*listened = true`: otherwise the game does not read every mob's equipment each tick, and only armor stands are told. Players keep `native_on_equip`. It only hears. Bundles that use it need \"hooks\": @API:living_equipment_change@."
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "map_decorations",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_map_decorations",
|
||||
"neoforge": "MapDecorationType",
|
||||
"side": "client",
|
||||
"cancellable": false
|
||||
}
|
||||
],
|
||||
"rust_summary": "a mod's own icons on filled maps (`native_map_decorations`).",
|
||||
"doc_summary": "`native_map_decorations(map, decorations)` is asked each time a map is drawn (held, in an item frame, or on the cartography table's screen) with `NativeMapInfo` (id, centre block, scale, dimension, locked, whether it is in a frame); push `NativeMapDecoration { sprite, x, y, rot, shown_on_frame }` (an atlas sprite, a position in half map pixels from -128 to 127, a rotation 0 to 15, and whether it shows in frames and on the screen like vanilla's markers) and it is drawn after the vanilla decorations with the same pose; a sprite the atlas lacks is left out. Nothing is saved in the map and vanilla's decorations are not passed in. This is the client half of NeoForge's `MapDecorationType`; types registered on the server and saved in map data need code-side registration. Bundles need \"hooks\": @API:map_decorations@."
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "blocks",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_blocks",
|
||||
"neoforge": "RegisterEvent"
|
||||
},
|
||||
{
|
||||
"name": "items",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_items",
|
||||
"neoforge": "RegisterEvent"
|
||||
}
|
||||
],
|
||||
"rust_summary": "blocks and items declared in code (`native_blocks`, `native_items`).",
|
||||
"doc_summary": "blocks and items a native mod declares in code, as NeoForge's `DeferredRegister.Blocks` and `.Items` drained by `RegisterEvent`, instead of one content file each: `native_blocks(blocks)` and `native_items(items)`, asked once at start on every side, take `horizon_mods::content::Block` and `Item`, the declarations a content mod's `data/<id>/horizon/block/` and `item/` files hold (a vanilla template and what differs; `item: true` gives a block its item). Ids are in the mod's namespace. They join the content mods' blocks and items, inside the content mod whose id is that namespace when it is on (its models, textures, loot and recipes), else on their own with the template's look, so their network ids, saves and the joining client's content check are the files' (docs/mods.md). They pass the files' checks; a namespace whose declarations don't is logged and left out. Bundles that use them need `\"hooks\": @API:native_blocks_items@`."
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "player_submit",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_submit",
|
||||
"neoforge": "RenderPlayerEvent.Pre",
|
||||
"side": "client",
|
||||
"cancellable": true
|
||||
},
|
||||
{
|
||||
"name": "mob_layer",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_mob_layer",
|
||||
"neoforge": "EntityRenderersEvent.AddLayers",
|
||||
"side": "client",
|
||||
"cancellable": false
|
||||
},
|
||||
{
|
||||
"name": "player_render_listened",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_render_listened",
|
||||
"side": "client",
|
||||
"cancellable": false
|
||||
}
|
||||
],
|
||||
"rust_summary": "a player drawn with a mod's own model, and layers added to mobs and players (`native_player_submit`, `native_mob_layer`, told while a mod sets `native_player_render_listened`).",
|
||||
"doc_summary": "`native_player_submit(player, who, draw, drawn)` fires once a frame for each other player in view and for this window's player in third person (not while invisible), with the `native_mob_submit` frame (`NativeMobRender`, a `BlockEntityDraw` at the feet turned with the body) and `who` (profile name, whether it is the local player); set `drawn` to leave out the vanilla model, armour, held items, cape and other layers (`RenderPlayerEvent.Pre`). `native_mob_layer(mob, who, draw)` fires once a frame after the vanilla model and layers of every mob and player in view that a mod did not draw itself, and what the mod adds in that frame is drawn with it (`EntityRenderersEvent.AddLayers`, `RenderLivingEvent.Post`); `who` is set for a player. Geometry attaches to the entity's frame, not to the model's bones: head yaw, pitch and the walk cycle are in `NativeMobRender`. A mod that hooks either also hooks `native_player_render_listened(listened)` and sets `*listened = true`: otherwise neither is asked. Bundles need \"hooks\": @API:player_render@."
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "player_start_tracking",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_start_tracking",
|
||||
"neoforge": "PlayerEvent.StartTracking",
|
||||
"side": "server",
|
||||
"cancellable": false
|
||||
},
|
||||
{
|
||||
"name": "player_stop_tracking",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_stop_tracking",
|
||||
"neoforge": "PlayerEvent.StopTracking",
|
||||
"side": "server",
|
||||
"cancellable": false
|
||||
}
|
||||
],
|
||||
"rust_summary": "a player starting and stopping tracking an entity (`native_player_start_tracking`, `native_player_stop_tracking`).",
|
||||
"doc_summary": "a player's client starting and stopping tracking an entity, on the single player server thread and on a dedicated server: `native_player_start_tracking(player, name, entity, uuid, kind)` when the entity is sent to the player (NeoForge's `PlayerEvent.StartTracking`, vanilla's `ServerEntity.addPairing`) and `native_player_stop_tracking(...)` when it stops (`PlayerEvent.StopTracking`, `removePairing`): out of range, gone from the level, or the player left, changed level or respawned. They name the entity by ID, UUID and type and run with no player's state swapped in, so a mod sends what the player needs with `native_net_send_to_player`. Each start has one stop. Bundles that use them need \"hooks\": @API:player_tracking@."
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "hears_projectile_impacts",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_hears_projectile_impacts",
|
||||
"side": "server"
|
||||
},
|
||||
{
|
||||
"name": "projectile_impact",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_projectile_impact",
|
||||
"neoforge": "ProjectileImpactEvent",
|
||||
"side": "server",
|
||||
"cancellable": true
|
||||
}
|
||||
],
|
||||
"rust_summary": "a projectile's impact a mod can cancel (`native_projectile_impact`, heard once a mod sets `native_hears_projectile_impacts`).",
|
||||
"doc_summary": "a projectile's impact, which a mod can cancel, through `native_projectile_impact` (NeoForge's `ProjectileImpactEvent`): it fires when a projectile has found what it meets, before the hit acts, with the projectile's id, type (`minecraft:arrow`) and owner, the level, and what it hit: an entity (its id and type) or a block (its position and the face's normal) and the point. Set `canceled` and the impact does not happen: no damage, knockback, sound or block reaction, an 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 decides what becomes of it. It covers arrows, tridents, thrown items, fireballs, wither skulls, wind charges, llama spit and shulker bullets, on the single player server thread and on a dedicated server. It does not fire for a breeze's deflection, a hit on a wind charge, fireworks or fishing bobbers. A mod hooks `native_hears_projectile_impacts(hears)` too and sets `hears`: without it the server keeps no copy of its flying projectiles and the impact hook is not called. Bundles that use it need \"hooks\": @API:projectile_impact@."
|
||||
}
|
||||
Reference in new issue
Block a user