mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84a7405bb5 |
No files matched your search
@@ -15,11 +15,6 @@ type_complexity = "allow"
|
||||
needless_range_loop = "allow"
|
||||
large_enum_variant = "allow"
|
||||
|
||||
# Line tables keep file:line in backtraces; full debug info made every
|
||||
# example and test binary hundreds of megabytes and slow to link.
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
@@ -38,19 +38,19 @@ round-trip harness has not been rerun.
|
||||
| Lighting | 1 | None: saved and relit light matches vanilla, slabs and stairs included |
|
||||
| World generation | 1 | None: 2,352 whole chunks over 225 structure sites, in all three dimensions, identical to vanilla |
|
||||
| Region files and saves | 0.75 | Save round-trip harness fails; older DataVersions are not upgraded |
|
||||
| Entities and physics | 0.75 | Attributes, dimensions, death loot, spawn rules and natural spawning (15,500 NaturalSpawner cases) match; AI not compared; drowned, villagers, traders and the wither unverified |
|
||||
| Entities and physics | 0.75 | Attributes and dimensions match; AI, loot and spawn rules not compared; drowned, villagers, traders and the wither unverified |
|
||||
| Interaction and inventory | 1 | None: menu clicks match over 6,000 click sequences |
|
||||
| Menus, HUD and chat | 0.75 | Commands parse as vanilla over 39,685 inputs, but /item modify, /spectate, /waypoint and /dialog do not run; no tab list or world border rendering |
|
||||
| Audio | 0.75 | Sound engine, music and ambience not compared with vanilla |
|
||||
| Block behaviours and ticking | 0.75 | Block updates, random ticks, redstone and dispensers match vanilla; a container replaced by another block keeps its contents instead of spilling them, and there are no copper golems |
|
||||
| Block behaviours and ticking | 0.75 | Block updates, random ticks and redstone match vanilla; dispensers lack several behaviours |
|
||||
|
||||
### Next steps
|
||||
|
||||
1. Rerun the save round-trip harness and upgrade saves from older DataVersions.
|
||||
2. Draw every item through the item model pipeline, and add the resource pack screen.
|
||||
3. An audio harness: sound engine attenuation, weighted sound events, music and ambience.
|
||||
4. A mob AI harness, and an oracle for drowned, villagers, traders and the wither.
|
||||
5. The missing commands, containers spilling when replaced, copper golems, the tab list and world border rendering.
|
||||
4. Mob AI, loot and spawn-rule harnesses, and an oracle for drowned, villagers, traders and the wither.
|
||||
5. The missing commands and dispenser behaviours, the tab list and world border rendering.
|
||||
6. Test LAN hosting with a vanilla client, and add a dedicated server mode.
|
||||
|
||||
```sh
|
||||
|
||||
@@ -240,8 +240,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let sprites = Sprites {
|
||||
root: root.clone(),
|
||||
permutations: PalettedPermutations::from_atlas(
|
||||
&fs::read_to_string(root.join("assets/minecraft/atlases/items.json"))
|
||||
.unwrap_or_default(),
|
||||
&fs::read_to_string(root.join("assets/minecraft/atlases/items.json")).unwrap_or_default(),
|
||||
),
|
||||
cache: RefCell::default(),
|
||||
};
|
||||
|
||||
@@ -59,10 +59,7 @@ impl Mat4 {
|
||||
if m[0][3] == 0.0 && m[1][3] == 0.0 {
|
||||
if m[2][3] == 0.0 && m[3][3] == 1.0 {
|
||||
properties |= AFFINE;
|
||||
if m[0] == [1.0, 0.0, 0.0, 0.0]
|
||||
&& m[1] == [0.0, 1.0, 0.0, 0.0]
|
||||
&& m[2] == [0.0, 0.0, 1.0, 0.0]
|
||||
{
|
||||
if m[0] == [1.0, 0.0, 0.0, 0.0] && m[1] == [0.0, 1.0, 0.0, 0.0] && m[2] == [0.0, 0.0, 1.0, 0.0] {
|
||||
properties |= TRANSLATION | ORTHONORMAL;
|
||||
if m[3][0] == 0.0 && m[3][1] == 0.0 && m[3][2] == 0.0 {
|
||||
properties |= IDENTITY;
|
||||
@@ -114,20 +111,12 @@ impl Mat4 {
|
||||
// mulAffine
|
||||
for c in 0..3 {
|
||||
for row in 0..3 {
|
||||
out[c][row] = fma(
|
||||
m[0][row],
|
||||
r[c][0],
|
||||
fma(m[1][row], r[c][1], m[2][row] * r[c][2]),
|
||||
);
|
||||
out[c][row] = fma(m[0][row], r[c][0], fma(m[1][row], r[c][1], m[2][row] * r[c][2]));
|
||||
}
|
||||
out[c][3] = m[c][3];
|
||||
}
|
||||
for row in 0..3 {
|
||||
out[3][row] = fma(
|
||||
m[0][row],
|
||||
r[3][0],
|
||||
fma(m[1][row], r[3][1], fma(m[2][row], r[3][2], m[3][row])),
|
||||
);
|
||||
out[3][row] = fma(m[0][row], r[3][0], fma(m[1][row], r[3][1], fma(m[2][row], r[3][2], m[3][row])));
|
||||
}
|
||||
out[3][3] = m[3][3];
|
||||
return Self(out, AFFINE | self.1 & right.1 & ORTHONORMAL);
|
||||
@@ -135,12 +124,7 @@ impl Mat4 {
|
||||
if self.has(PERSPECTIVE) && right.has(AFFINE) {
|
||||
// mulPerspectiveAffine
|
||||
for c in 0..4 {
|
||||
out[c] = [
|
||||
m[0][0] * r[c][0],
|
||||
m[1][1] * r[c][1],
|
||||
m[2][2] * r[c][2],
|
||||
m[2][3] * r[c][2],
|
||||
];
|
||||
out[c] = [m[0][0] * r[c][0], m[1][1] * r[c][1], m[2][2] * r[c][2], m[2][3] * r[c][2]];
|
||||
}
|
||||
out[3][2] = m[2][2] * r[3][2] + m[3][2];
|
||||
out[3][3] = m[2][3] * r[3][2];
|
||||
@@ -150,24 +134,13 @@ impl Mat4 {
|
||||
// mulAffineR
|
||||
for c in 0..3 {
|
||||
for row in 0..4 {
|
||||
out[c][row] = fma(
|
||||
m[0][row],
|
||||
r[c][0],
|
||||
fma(m[1][row], r[c][1], m[2][row] * r[c][2]),
|
||||
);
|
||||
out[c][row] = fma(m[0][row], r[c][0], fma(m[1][row], r[c][1], m[2][row] * r[c][2]));
|
||||
}
|
||||
}
|
||||
for row in 0..4 {
|
||||
out[3][row] = fma(
|
||||
m[0][row],
|
||||
r[3][0],
|
||||
fma(m[1][row], r[3][1], fma(m[2][row], r[3][2], m[3][row])),
|
||||
);
|
||||
out[3][row] = fma(m[0][row], r[3][0], fma(m[1][row], r[3][1], fma(m[2][row], r[3][2], m[3][row])));
|
||||
}
|
||||
return Self(
|
||||
out,
|
||||
self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION | ORTHONORMAL),
|
||||
);
|
||||
return Self(out, self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION | ORTHONORMAL));
|
||||
}
|
||||
// mul0
|
||||
for c in 0..4 {
|
||||
@@ -175,11 +148,7 @@ impl Mat4 {
|
||||
out[c][row] = fma(
|
||||
m[0][row],
|
||||
r[c][0],
|
||||
fma(
|
||||
m[1][row],
|
||||
r[c][1],
|
||||
fma(m[2][row], r[c][2], m[3][row] * r[c][3]),
|
||||
),
|
||||
fma(m[1][row], r[c][1], fma(m[2][row], r[c][2], m[3][row] * r[c][3])),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -193,15 +162,7 @@ impl Mat4 {
|
||||
// translateGeneric
|
||||
let mut out = *self;
|
||||
for row in 0..4 {
|
||||
out.0[3][row] = fma(
|
||||
self.0[0][row],
|
||||
t[0],
|
||||
fma(
|
||||
self.0[1][row],
|
||||
t[1],
|
||||
fma(self.0[2][row], t[2], self.0[3][row]),
|
||||
),
|
||||
);
|
||||
out.0[3][row] = fma(self.0[0][row], t[0], fma(self.0[1][row], t[1], fma(self.0[2][row], t[2], self.0[3][row])));
|
||||
}
|
||||
out.1 = self.1 & !(PERSPECTIVE | IDENTITY);
|
||||
out
|
||||
@@ -225,8 +186,7 @@ impl Mat4 {
|
||||
out.0[c][row] = self.0[c][row] * s[c];
|
||||
}
|
||||
}
|
||||
out.1 =
|
||||
self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION | if one { 0 } else { ORTHONORMAL });
|
||||
out.1 = self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION | if one { 0 } else { ORTHONORMAL });
|
||||
out
|
||||
}
|
||||
/// `Matrix4f.rotate(Quaternionfc)`.
|
||||
@@ -268,11 +228,7 @@ impl Mat4 {
|
||||
// rotateGeneric
|
||||
for c in 0..3 {
|
||||
for row in 0..4 {
|
||||
out.0[c][row] = fma(
|
||||
m[0][row],
|
||||
r[c][0],
|
||||
fma(m[1][row], r[c][1], m[2][row] * r[c][2]),
|
||||
);
|
||||
out.0[c][row] = fma(m[0][row], r[c][0], fma(m[1][row], r[c][1], m[2][row] * r[c][2]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,11 +281,7 @@ pub fn quaternion_xyz(x: f32, y: f32, z: f32) -> [f32; 4] {
|
||||
/// [0, 2π) and the axis is used as given.
|
||||
pub fn quaternion_from_axis_angle(angle: f32, axis: [f32; 3]) -> [f32; 4] {
|
||||
let tau = (std::f64::consts::PI * 2.0) as f32;
|
||||
let angle = if angle < 0.0 {
|
||||
tau + angle % tau
|
||||
} else {
|
||||
angle
|
||||
} % tau;
|
||||
let angle = if angle < 0.0 { tau + angle % tau } else { angle } % tau;
|
||||
let sin = joml_sin(angle * 0.5);
|
||||
let cos = cos_from_sin(sin, angle * 0.5);
|
||||
[axis[0] * sin, axis[1] * sin, axis[2] * sin, cos]
|
||||
|
||||
@@ -25,11 +25,7 @@ pub struct Permutation<'a> {
|
||||
}
|
||||
|
||||
fn qualified(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
if id.contains(':') { id.to_owned() } else { format!("minecraft:{id}") }
|
||||
}
|
||||
|
||||
impl PalettedPermutations {
|
||||
@@ -38,29 +34,13 @@ impl PalettedPermutations {
|
||||
let Ok(json) = serde_json::from_str::<Value>(atlas) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let sources = json
|
||||
.get("sources")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let sources = json.get("sources").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
sources
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
s.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.map(qualified)
|
||||
.as_deref()
|
||||
== Some("minecraft:paletted_permutations")
|
||||
})
|
||||
.filter(|s| s.get("type").and_then(Value::as_str).map(qualified).as_deref() == Some("minecraft:paletted_permutations"))
|
||||
.filter_map(|s| {
|
||||
Some(Self {
|
||||
textures: s
|
||||
.get("textures")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(qualified)
|
||||
.collect(),
|
||||
textures: s.get("textures")?.as_array()?.iter().filter_map(Value::as_str).map(qualified).collect(),
|
||||
palette_key: qualified(s.get("palette_key")?.as_str()?),
|
||||
permutations: s
|
||||
.get("permutations")?
|
||||
@@ -68,11 +48,7 @@ impl PalettedPermutations {
|
||||
.iter()
|
||||
.filter_map(|(k, v)| Some((k.clone(), qualified(v.as_str()?))))
|
||||
.collect(),
|
||||
separator: s
|
||||
.get("separator")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("_")
|
||||
.to_owned(),
|
||||
separator: s.get("separator").and_then(Value::as_str).unwrap_or("_").to_owned(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -81,14 +57,8 @@ impl PalettedPermutations {
|
||||
pub fn find(&self, sprite: &str) -> Option<Permutation<'_>> {
|
||||
let sprite = qualified(sprite);
|
||||
self.textures.iter().find_map(|base| {
|
||||
let suffix = sprite
|
||||
.strip_prefix(base.as_str())?
|
||||
.strip_prefix(self.separator.as_str())?;
|
||||
Some(Permutation {
|
||||
base,
|
||||
palette_key: &self.palette_key,
|
||||
palette: self.permutations.get(suffix)?,
|
||||
})
|
||||
let suffix = sprite.strip_prefix(base.as_str())?.strip_prefix(self.separator.as_str())?;
|
||||
Some(Permutation { base, palette_key: &self.palette_key, palette: self.permutations.get(suffix)? })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -125,18 +95,9 @@ mod tests {
|
||||
let atlas = r#"{"sources":[{"type":"minecraft:paletted_permutations","palette_key":"minecraft:trim_base",
|
||||
"permutations":{"quartz":"minecraft:trim/quartz"},"textures":["minecraft:trims/items/boots_trim"]}]}"#;
|
||||
let sources = PalettedPermutations::from_atlas(atlas);
|
||||
let p = sources[0]
|
||||
.find("minecraft:trims/items/boots_trim_quartz")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(p.base, p.palette),
|
||||
("minecraft:trims/items/boots_trim", "minecraft:trim/quartz")
|
||||
);
|
||||
assert!(
|
||||
sources[0]
|
||||
.find("minecraft:trims/items/boots_trim_gold")
|
||||
.is_none()
|
||||
);
|
||||
let p = sources[0].find("minecraft:trims/items/boots_trim_quartz").unwrap();
|
||||
assert_eq!((p.base, p.palette), ("minecraft:trims/items/boots_trim", "minecraft:trim/quartz"));
|
||||
assert!(sources[0].find("minecraft:trims/items/boots_trim_gold").is_none());
|
||||
let mut pixels = [0xFF11_2233, 0x8011_2233, 0x0011_2233, 0xFF44_5566];
|
||||
apply_palette(&mut pixels, &[0xFF11_2233], &[0xFFAA_BBCC]).unwrap();
|
||||
assert_eq!(pixels, [0xFFAA_BBCC, 0x80AA_BBCC, 0x0011_2233, 0xFF44_5566]);
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
//! Replays `tools/test_ambient_sound_parity.py` scripts through Horizon's
|
||||
//! ambient sound handlers over its `SoundEngine`:
|
||||
//! `ambient_fixture ROOT INPUT OUTPUT`.
|
||||
#[path = "audio_common/mod.rs"]
|
||||
mod common;
|
||||
use common::{Fake, d, f, flush};
|
||||
use horizon_client::ambience::{Ambience, AmbientContext, AmbientSounds, Mood, bubble_column_in};
|
||||
use horizon_client::sound_engine::{Bound, SoundEngine, TickContext, Ticking};
|
||||
use horizon_client::sound_events::{SoundRegistry, normalize};
|
||||
use horizon_core::random::RandomSource;
|
||||
use std::io::Write;
|
||||
|
||||
/// The oracle's scripted brightness.
|
||||
fn light(mode: i32, x: i32, y: i32, z: i32) -> (u8, u8) {
|
||||
let h = x.wrapping_mul(73856093) ^ y.wrapping_mul(19349663) ^ z.wrapping_mul(83492791);
|
||||
let u = h as u32;
|
||||
match mode {
|
||||
0 => (15, 0),
|
||||
1 => (0, 0),
|
||||
2 => (0, ((u >> 4) & 15) as u8),
|
||||
_ => (
|
||||
if h & 3 == 0 { ((u >> 8) & 15) as u8 } else { 0 },
|
||||
((u >> 12) & 15) as u8,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn sounds(a: &[&str]) -> AmbientSounds {
|
||||
AmbientSounds {
|
||||
r#loop: (a[1] != "-").then(|| normalize(a[1])),
|
||||
mood: (a[2] != "-").then(|| {
|
||||
let m: Vec<&str> = a[2].split(',').collect();
|
||||
Mood {
|
||||
sound: normalize(m[0]),
|
||||
tick_delay: m[1].parse().expect("delay"),
|
||||
block_search_extent: m[2].parse().expect("extent"),
|
||||
offset: d(m[3]),
|
||||
}
|
||||
}),
|
||||
additions: a[3..]
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let p: Vec<&str> = s.split(',').collect();
|
||||
(normalize(p[0]), d(p[1]))
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut engine = SoundEngine::new(SoundRegistry::load(std::path::Path::new(&args[1])));
|
||||
engine.listening = true;
|
||||
let mut backend = Fake::default();
|
||||
let mut ambience = Ambience::new(0);
|
||||
let mut current = AmbientSounds::default();
|
||||
let (mut position, mut eye, mut eye_in_water, mut spectator, mut removed, mut mode) =
|
||||
([0.0f64; 3], 0.0f32, false, false, false, 0);
|
||||
let mut height = 1.8f32;
|
||||
let mut columns: Vec<([i32; 3], bool)> = Vec::new();
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
for line in input.lines() {
|
||||
writeln!(out, "{line}").expect("write");
|
||||
let a: Vec<&str> = line.split(' ').collect();
|
||||
match a[0] {
|
||||
"start" => {
|
||||
engine.stop_all(&mut backend);
|
||||
ambience.clear(&mut Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
});
|
||||
ambience = Ambience::new(a[1].parse().expect("seed"));
|
||||
current = AmbientSounds::default();
|
||||
(position, eye, eye_in_water, spectator, removed, mode) =
|
||||
([0.0; 3], 0.0, false, false, false, 0);
|
||||
height = 1.8;
|
||||
columns.clear();
|
||||
}
|
||||
"sounds" => current = sounds(&a),
|
||||
"player" => {
|
||||
position = [d(a[1]), d(a[2]), d(a[3])];
|
||||
eye = f(a[4]);
|
||||
eye_in_water = a[5] == "1";
|
||||
spectator = a[6] == "1";
|
||||
removed = a[7] == "1";
|
||||
mode = a[8].parse().expect("mode");
|
||||
height = f(a[9]);
|
||||
columns = a[10..]
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let p: Vec<&str> = c.split(',').collect();
|
||||
let n = |i: usize| p[i].parse::<i32>().expect("column");
|
||||
([n(0), n(1), n(2)], p[3] == "1")
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
"volume" => {
|
||||
let source: usize = a[1].parse().expect("source");
|
||||
engine.source_volumes[source] = f(a[2]);
|
||||
engine.refresh_category_volume(source, &mut backend);
|
||||
}
|
||||
"tick" => {
|
||||
let light = |b: [i32; 3]| light(mode, b[0], b[1], b[2]);
|
||||
let ctx = AmbientContext {
|
||||
sounds: ¤t,
|
||||
position,
|
||||
eye_y: position[1] + f64::from(eye),
|
||||
underwater: eye_in_water,
|
||||
spectator,
|
||||
light: &light,
|
||||
bubble_column: bubble_column_in(
|
||||
[
|
||||
position[0] - f64::from(0.3f32),
|
||||
position[1],
|
||||
position[2] - f64::from(0.3f32),
|
||||
position[0] + f64::from(0.3f32),
|
||||
position[1] + f64::from(height),
|
||||
position[2] + f64::from(0.3f32),
|
||||
],
|
||||
|b| columns.iter().find(|c| c.0 == b).map(|c| c.1),
|
||||
),
|
||||
};
|
||||
ambience.tick(
|
||||
&ctx,
|
||||
&mut Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
},
|
||||
);
|
||||
let tick = TickContext {
|
||||
player_underwater: ambience.was_underwater(),
|
||||
player_removed: removed,
|
||||
};
|
||||
engine.tick(false, &tick, &mut backend);
|
||||
}
|
||||
_ => panic!("{line}"),
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" rand={} mood={:08x} underwater={} delay={}",
|
||||
ambience.random.clone().next_long(),
|
||||
ambience.moodiness.to_bits(),
|
||||
ambience.was_underwater(),
|
||||
ambience.underwater_delay()
|
||||
)
|
||||
.expect("write");
|
||||
for (event, id) in ambience.loops() {
|
||||
let i = engine.instance(*id).expect("loop");
|
||||
let Ticking::BiomeLoop { fade, direction } = i.ticking else {
|
||||
panic!("loop kind");
|
||||
};
|
||||
writeln!(
|
||||
out,
|
||||
" loop {event} fade={fade} direction={direction} stopped={} active={}",
|
||||
i.stopped,
|
||||
engine.is_active(*id)
|
||||
)
|
||||
.expect("write");
|
||||
}
|
||||
let mut playing: Vec<String> = engine
|
||||
.active_instances()
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let i = engine.instance(id).expect("instance");
|
||||
format!(
|
||||
" playing {} volume={:08x} looping={} relative={} at={:016x},{:016x},{:016x} stopped={}",
|
||||
i.identifier,
|
||||
i.volume.to_bits(),
|
||||
i.looping,
|
||||
i.relative,
|
||||
i.position[0].to_bits(),
|
||||
i.position[1].to_bits(),
|
||||
i.position[2].to_bits(),
|
||||
i.tickable() && i.stopped
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
playing.sort();
|
||||
for p in playing {
|
||||
writeln!(out, "{p}").expect("write");
|
||||
}
|
||||
flush(&mut engine, &mut out);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
//! Audio environment attributes from Horizon's `MusicTables` and
|
||||
//! `AmbientTables` for `tools/test_audio_attributes_parity.py`:
|
||||
//! `audio_attributes_fixture ROOT INPUT OUTPUT`.
|
||||
use horizon_client::ambience::AmbientTables;
|
||||
use horizon_client::music::{Music, MusicTables};
|
||||
use horizon_client::sound_events::normalize;
|
||||
use std::io::Write;
|
||||
|
||||
fn music(m: Option<&Music>) -> String {
|
||||
m.map_or("-".to_owned(), |m| {
|
||||
format!(
|
||||
"{},{},{},{}",
|
||||
normalize(&m.sound),
|
||||
m.min_delay,
|
||||
m.max_delay,
|
||||
m.replace_current_music
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = std::path::Path::new(&args[1]);
|
||||
let tables = MusicTables::load(root);
|
||||
let ambient = AmbientTables::load(root);
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
for (name, m) in [
|
||||
("MENU", Music::menu()),
|
||||
("CREDITS", Music::credits()),
|
||||
("END_BOSS", Music::end_boss()),
|
||||
("CREATIVE", Music::creative()),
|
||||
("END", Music::end()),
|
||||
("UNDER_WATER", Music::under_water()),
|
||||
("GAME", Music::game()),
|
||||
] {
|
||||
writeln!(out, "{name} {}", music(Some(&m))).expect("write");
|
||||
}
|
||||
for line in input.lines() {
|
||||
let f: Vec<&str> = line.split(' ').collect();
|
||||
let (dimension, biome) = (f[0], f[1]);
|
||||
let bg = tables.background(dimension, biome);
|
||||
let mut s = format!(
|
||||
"{line} default={} creative={} underwater={}",
|
||||
music(bg.default.as_ref()),
|
||||
music(bg.creative.as_ref()),
|
||||
music(bg.underwater.as_ref())
|
||||
);
|
||||
for creative in [false, true] {
|
||||
for underwater in [false, true] {
|
||||
s.push_str(&format!(
|
||||
" select={}",
|
||||
music(bg.select(creative, underwater))
|
||||
));
|
||||
}
|
||||
}
|
||||
s.push_str(&format!(
|
||||
" volume={:08x}",
|
||||
tables.volume(dimension, biome).to_bits()
|
||||
));
|
||||
let a = ambient.get(dimension, biome);
|
||||
s.push_str(&format!(
|
||||
" loop={}",
|
||||
a.r#loop.as_deref().map_or("-".to_owned(), normalize)
|
||||
));
|
||||
s.push_str(&format!(
|
||||
" mood={}",
|
||||
a.mood.as_ref().map_or("-".to_owned(), |m| format!(
|
||||
"{},{},{},{:016x}",
|
||||
normalize(&m.sound),
|
||||
m.tick_delay,
|
||||
m.block_search_extent,
|
||||
m.offset.to_bits()
|
||||
))
|
||||
));
|
||||
for (sound, chance) in &a.additions {
|
||||
s.push_str(&format!(
|
||||
" addition={},{:016x}",
|
||||
normalize(sound),
|
||||
chance.to_bits()
|
||||
));
|
||||
}
|
||||
writeln!(out, "{s}").expect("write");
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
//! Shared by the audio fixtures: a backend recording what an OpenAL source
|
||||
//! would hold, and the per-step dump the Java oracles print.
|
||||
#![allow(dead_code)]
|
||||
use horizon_client::sound_engine::{Backend, ChannelId, InstanceId, PlayResult, SoundEngine};
|
||||
use horizon_client::sound_events::Sound;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
const INITIAL: u32 = 0x1011;
|
||||
const PLAYING: u32 = 0x1012;
|
||||
const PAUSED: u32 = 0x1013;
|
||||
const STOPPED: u32 = 0x1014;
|
||||
|
||||
/// An OpenAL source's state as `alGetSource*` reports it.
|
||||
#[derive(Clone)]
|
||||
pub struct Source {
|
||||
pub state: u32,
|
||||
pub gain: f32,
|
||||
pub pitch: f32,
|
||||
pub position: [f32; 3],
|
||||
pub relative: bool,
|
||||
pub looping: bool,
|
||||
pub model: u32,
|
||||
pub max: f32,
|
||||
pub rolloff: f32,
|
||||
pub reference: f32,
|
||||
}
|
||||
impl Default for Source {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: INITIAL,
|
||||
gain: 1.0,
|
||||
pitch: 1.0,
|
||||
position: [0.0; 3],
|
||||
relative: false,
|
||||
looping: false,
|
||||
model: 0xD002,
|
||||
max: f32::MAX,
|
||||
rolloff: 1.0,
|
||||
reference: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Buffers and streams are ready at once (silence in the oracle); stopping an
|
||||
/// initial source leaves it initial, as in OpenAL Soft.
|
||||
#[derive(Default)]
|
||||
pub struct Fake {
|
||||
pub sources: HashMap<ChannelId, Source>,
|
||||
next: ChannelId,
|
||||
}
|
||||
impl Backend for Fake {
|
||||
fn create(&mut self, _streaming: bool) -> Option<ChannelId> {
|
||||
self.next += 1;
|
||||
self.sources.insert(self.next, Source::default());
|
||||
Some(self.next)
|
||||
}
|
||||
fn destroy(&mut self, channel: ChannelId) {
|
||||
self.sources.remove(&channel);
|
||||
}
|
||||
fn set_pitch(&mut self, c: ChannelId, pitch: f32) {
|
||||
self.sources.get_mut(&c).expect("source").pitch = pitch;
|
||||
}
|
||||
fn set_volume(&mut self, c: ChannelId, volume: f32) {
|
||||
self.sources.get_mut(&c).expect("source").gain = volume;
|
||||
}
|
||||
fn set_attenuation(&mut self, c: ChannelId, linear: Option<f32>) {
|
||||
let s = self.sources.get_mut(&c).expect("source");
|
||||
match linear {
|
||||
Some(max) => {
|
||||
s.model = 0xD003;
|
||||
s.max = max;
|
||||
s.rolloff = 1.0;
|
||||
s.reference = 0.0;
|
||||
}
|
||||
None => s.model = 0,
|
||||
}
|
||||
}
|
||||
fn set_looping(&mut self, c: ChannelId, looping: bool) {
|
||||
self.sources.get_mut(&c).expect("source").looping = looping;
|
||||
}
|
||||
fn set_position(&mut self, c: ChannelId, p: [f64; 3]) {
|
||||
self.sources.get_mut(&c).expect("source").position =
|
||||
[p[0] as f32, p[1] as f32, p[2] as f32];
|
||||
}
|
||||
fn set_relative(&mut self, c: ChannelId, relative: bool) {
|
||||
self.sources.get_mut(&c).expect("source").relative = relative;
|
||||
}
|
||||
fn start(&mut self, c: ChannelId, _sound: &Sound, _looping: bool) {
|
||||
self.sources.get_mut(&c).expect("source").state = PLAYING;
|
||||
}
|
||||
fn stop(&mut self, c: ChannelId) {
|
||||
if let Some(s) = self.sources.get_mut(&c)
|
||||
&& s.state != INITIAL
|
||||
{
|
||||
s.state = STOPPED;
|
||||
}
|
||||
}
|
||||
fn pause(&mut self, c: ChannelId) {
|
||||
let s = self.sources.get_mut(&c).expect("source");
|
||||
if s.state == PLAYING {
|
||||
s.state = PAUSED;
|
||||
}
|
||||
}
|
||||
fn unpause(&mut self, c: ChannelId) {
|
||||
let s = self.sources.get_mut(&c).expect("source");
|
||||
if s.state == PAUSED {
|
||||
s.state = PLAYING;
|
||||
}
|
||||
}
|
||||
fn stopped(&mut self, c: ChannelId) -> bool {
|
||||
self.sources.get(&c).is_none_or(|s| s.state == STOPPED)
|
||||
}
|
||||
}
|
||||
pub fn bits(v: f32) -> String {
|
||||
format!("{:08x}", v.to_bits())
|
||||
}
|
||||
pub fn f(hex: &str) -> f32 {
|
||||
f32::from_bits(u32::from_str_radix(hex, 16).expect("float"))
|
||||
}
|
||||
pub fn d(hex: &str) -> f64 {
|
||||
f64::from_bits(u64::from_str_radix(hex, 16).expect("double"))
|
||||
}
|
||||
/// The shared per-step dump (instances, notifications, pool counts).
|
||||
pub fn describe(engine: &SoundEngine, backend: &Fake, name: &str, id: InstanceId) -> String {
|
||||
let mut s = format!(" {name} active={}", engine.is_active(id));
|
||||
match engine.channel(id).and_then(|c| backend.sources.get(&c)) {
|
||||
None => s.push_str(" channel=-"),
|
||||
Some(src) => s.push_str(&format!(
|
||||
" state={:x} gain={} pitch={} pos={},{},{} rel={} loop={} model={:x} max={} rolloff={} ref={}",
|
||||
src.state,
|
||||
bits(src.gain),
|
||||
bits(src.pitch),
|
||||
bits(src.position[0]),
|
||||
bits(src.position[1]),
|
||||
bits(src.position[2]),
|
||||
u8::from(src.relative),
|
||||
u8::from(src.looping),
|
||||
src.model,
|
||||
bits(src.max),
|
||||
bits(src.rolloff),
|
||||
bits(src.reference)
|
||||
)),
|
||||
}
|
||||
s
|
||||
}
|
||||
pub fn flush(engine: &mut SoundEngine, out: &mut impl Write) {
|
||||
for n in std::mem::take(&mut engine.notifications) {
|
||||
writeln!(
|
||||
out,
|
||||
"notify {} {} {} {} {:016x} {:016x} {:016x}",
|
||||
n.identifier,
|
||||
n.subtitle.as_deref().unwrap_or("-"),
|
||||
bits(n.range),
|
||||
n.looping,
|
||||
n.position[0].to_bits(),
|
||||
n.position[1].to_bits(),
|
||||
n.position[2].to_bits()
|
||||
)
|
||||
.expect("write");
|
||||
}
|
||||
let [used, streaming] = engine.used_channels();
|
||||
writeln!(
|
||||
out,
|
||||
" Sounds: {used}/{} + {streaming}/{}",
|
||||
engine.limits[0], engine.limits[1]
|
||||
)
|
||||
.expect("write");
|
||||
}
|
||||
|
||||
pub fn result_name(r: PlayResult) -> &'static str {
|
||||
match r {
|
||||
PlayResult::Started => "STARTED",
|
||||
PlayResult::StartedSilently => "STARTED_SILENTLY",
|
||||
PlayResult::NotStarted => "NOT_STARTED",
|
||||
}
|
||||
}
|
||||
@@ -64,10 +64,7 @@ fn main() -> Result<()> {
|
||||
.properties
|
||||
.insert(name.clone(), value.as_str().unwrap().into());
|
||||
}
|
||||
state = assets
|
||||
.registry
|
||||
.id(&definition)
|
||||
.ok_or("missing fixture state")?;
|
||||
state = assets.registry.id(&definition).ok_or("missing fixture state")?;
|
||||
}
|
||||
restore.push((x, y, z, scene.block(x, y, z)));
|
||||
scene.set(x, y, z, state)?;
|
||||
@@ -79,10 +76,7 @@ fn main() -> Result<()> {
|
||||
player.shift_key_down = p["shift"].as_bool().unwrap();
|
||||
player.yaw = fl(&p["yr"]);
|
||||
player.pitch = fl(&p["xr"]);
|
||||
let key = player
|
||||
.attributes
|
||||
.key("minecraft:movement_speed")
|
||||
.ok_or("speed")?;
|
||||
let key = player.attributes.key("minecraft:movement_speed").ok_or("speed")?;
|
||||
let handle = player
|
||||
.attributes
|
||||
.get_instance(Some(key))
|
||||
|
||||
@@ -86,11 +86,7 @@ impl RayWorld for Cells {
|
||||
) -> std::result::Result<VoxelShape, Infallible> {
|
||||
Ok(self.shapes[s].clone())
|
||||
}
|
||||
fn interaction_shape(
|
||||
&mut self,
|
||||
_: usize,
|
||||
_: BlockPos,
|
||||
) -> std::result::Result<VoxelShape, Infallible> {
|
||||
fn interaction_shape(&mut self, _: usize, _: BlockPos) -> std::result::Result<VoxelShape, Infallible> {
|
||||
Ok(VoxelShape::empty())
|
||||
}
|
||||
fn fluid_is_source(&mut self, _: ()) -> std::result::Result<bool, Infallible> {
|
||||
@@ -124,9 +120,7 @@ fn cells(c: &Value) -> Result<Cells> {
|
||||
ShapeOp::OR,
|
||||
)?;
|
||||
}
|
||||
world
|
||||
.index
|
||||
.insert(BlockPos::new(at[0], at[1], at[2]), world.shapes.len());
|
||||
world.index.insert(BlockPos::new(at[0], at[1], at[2]), world.shapes.len());
|
||||
world.shapes.push(shape);
|
||||
}
|
||||
Ok(world)
|
||||
@@ -153,7 +147,9 @@ fn entity(s: &Value) -> (CameraEntity, Option<FovPlayer>) {
|
||||
dead: s["dead"].as_i64().map(|t| t as i32),
|
||||
});
|
||||
let mount = if player {
|
||||
s["mount"].as_array().map(|a| (db(&a[0]) as f32, db(&a[1])))
|
||||
s["mount"]
|
||||
.as_array()
|
||||
.map(|a| (db(&a[0]) as f32, db(&a[1])))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -54,9 +54,7 @@ fn parser_id(ty: &ArgType) -> String {
|
||||
ArgType::ResOrTagKey(_) => "minecraft:resource_or_tag_key".into(),
|
||||
ArgType::StrEnum(values, _) => match values.first().copied() {
|
||||
Some("world_surface") => "minecraft:heightmap".into(),
|
||||
Some("none") if values.len() == 3 && values[1] == "left_right" => {
|
||||
"minecraft:template_mirror".into()
|
||||
}
|
||||
Some("none") if values.len() == 3 && values[1] == "left_right" => "minecraft:template_mirror".into(),
|
||||
Some("none") if values.len() == 3 => "minecraft:swing_animation".into(),
|
||||
_ => "minecraft:template_rotation".into(),
|
||||
},
|
||||
@@ -196,12 +194,10 @@ fn main() -> horizon_client::Result<()> {
|
||||
reg.persistent_components = strings(&r["persistent_components"]);
|
||||
reg.learnable_recipes = strings(&r["learnable_recipes"]);
|
||||
for (clock, markers) in r["clock_time_markers"].as_object().into_iter().flatten() {
|
||||
reg.clock_time_markers
|
||||
.insert(clock.clone(), strings(markers));
|
||||
reg.clock_time_markers.insert(clock.clone(), strings(markers));
|
||||
}
|
||||
for (id, criteria) in r["advancement_criteria"].as_object().into_iter().flatten() {
|
||||
reg.advancement_criteria
|
||||
.insert(id.clone(), strings(criteria));
|
||||
reg.advancement_criteria.insert(id.clone(), strings(criteria));
|
||||
}
|
||||
reg.datapacks_enabled = strings(&r["datapacks_selected"]);
|
||||
let enabled = reg.datapacks_enabled.clone();
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
//! Entity death drops for differential tests against vanilla
|
||||
//! (tools/test_entity_death_loot_parity.py): reads the cases of
|
||||
//! `VanillaEntityDeathLootOracle` and prints what `mobs::drops::die` drops.
|
||||
//!
|
||||
//! usage: entity_death_loot_fixture RESOURCES INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
mobs::{Mob, MobTypes, Species, cube, drops, gear, raider, render::DYE_NAMES, sulfur_cube},
|
||||
random_sequences::RandomSequences,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::LegacyRandom};
|
||||
use horizon_world::loot::{LootDamage, LootData, LootEntity, LootStack};
|
||||
use std::{collections::BTreeMap, fmt::Write as _, fs, path::Path, sync::Arc};
|
||||
|
||||
/// `id[;enchantment:level...]`.
|
||||
fn stack(spec: &str) -> LootStack {
|
||||
let mut parts = spec.split(';');
|
||||
let mut s = LootStack {
|
||||
item: parts.next().unwrap_or("minecraft:air").to_owned(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
enchantments: Vec::new(),
|
||||
potion: None,
|
||||
map: false,
|
||||
ominous_amplifier: None,
|
||||
};
|
||||
for e in parts {
|
||||
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
|
||||
let name = if name.contains(':') {
|
||||
name.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{name}")
|
||||
};
|
||||
s.enchantments.push((name, level.parse().unwrap_or(1)));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn line(s: &LootStack) -> String {
|
||||
let mut out = format!("drop {} {}", s.item, s.count);
|
||||
if s.damage != 0 {
|
||||
let _ = write!(out, " damage={}", s.damage);
|
||||
}
|
||||
for (e, l) in &s.enchantments {
|
||||
let _ = write!(out, " ench={e}:{l}");
|
||||
}
|
||||
if let Some(p) = &s.potion {
|
||||
let _ = write!(out, " potion={p}");
|
||||
}
|
||||
if let Some(a) = s.ominous_amplifier {
|
||||
let _ = write!(out, " ominous={a}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = Path::new(
|
||||
args.get(1)
|
||||
.ok_or("usage: entity_death_loot_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let types = MobTypes::load(root)?;
|
||||
let loot = LootData::load(root)?;
|
||||
let mut out = String::new();
|
||||
for raw in input.lines() {
|
||||
if raw.trim().is_empty() || raw.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let mut c = BTreeMap::new();
|
||||
let mut name = "";
|
||||
for token in raw.split_whitespace() {
|
||||
match token.split_once('=') {
|
||||
Some((k, v)) => {
|
||||
c.insert(k, v);
|
||||
}
|
||||
None => name = token,
|
||||
}
|
||||
}
|
||||
let get =
|
||||
|k: &str, d: &'static str| -> String { c.get(k).copied().unwrap_or(d).to_owned() };
|
||||
let _ = writeln!(out, "== {name}");
|
||||
let Some(kind) = types.types.get(c["type"]) else {
|
||||
let _ = writeln!(out, "error unsupported {}", c["type"]);
|
||||
continue;
|
||||
};
|
||||
let kind = Arc::new(kind.clone());
|
||||
let world_seed: i64 = get("seed", "0").parse()?;
|
||||
let kills: i64 = get("kills", "1").parse()?;
|
||||
let rand: i64 = get("rand", "0").parse()?;
|
||||
let mut sequences = RandomSequences::default();
|
||||
for k in 0..kills {
|
||||
let _ = writeln!(out, "kill {k}");
|
||||
let mut m = Mob::new(1, kind.clone(), Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
// EntityType.create(LOAD) gives no equipment.
|
||||
for slot in drops::SLOTS {
|
||||
drops::clear_slot(&mut m, slot);
|
||||
}
|
||||
if get("baby", "0") == "1" && drops::can_be_baby(m.species()) {
|
||||
m.extra.age = -24000;
|
||||
}
|
||||
if get("fire", "0") == "1" {
|
||||
m.fire_ticks = 100;
|
||||
}
|
||||
// A cube made with EntityType.create(LOAD) was never sized.
|
||||
if matches!(m.species(), Species::Slime | Species::MagmaCube) {
|
||||
m.extra.size = 0;
|
||||
}
|
||||
if let Some(size) = c.get("size") {
|
||||
let size: i32 = size.parse()?;
|
||||
if m.species() == Species::SulfurCube {
|
||||
sulfur_cube::set_size(&mut m, size, true);
|
||||
} else {
|
||||
cube::set_size(&mut m, size);
|
||||
}
|
||||
}
|
||||
if let Some(color) = c.get("color") {
|
||||
m.extra.color = DYE_NAMES.iter().position(|d| d == color).unwrap_or(0) as u8;
|
||||
}
|
||||
m.extra.sheared = get("sheared", "0") == "1";
|
||||
if get("leader", "0") == "1" {
|
||||
raider::state_mut(&mut m).patrol_leader = true;
|
||||
}
|
||||
if get("captain", "0") == "1" {
|
||||
m.extra.head = Some(Arc::from(raider::BANNER));
|
||||
}
|
||||
let mut chances = [drops::DEFAULT_DROP_CHANCE; 8];
|
||||
for (i, slot) in drops::SLOTS.iter().enumerate() {
|
||||
if let Some(spec) = c.get(slot) {
|
||||
let s = stack(spec);
|
||||
let item: Option<Arc<str>> = Some(Arc::from(s.item.as_str()));
|
||||
let index = match *slot {
|
||||
"mainhand" => {
|
||||
m.extra.main_hand = item;
|
||||
Some(gear::MAIN)
|
||||
}
|
||||
"offhand" => {
|
||||
m.extra.off_hand = item;
|
||||
Some(gear::OFF)
|
||||
}
|
||||
"head" => {
|
||||
m.extra.head = item;
|
||||
Some(gear::HEAD)
|
||||
}
|
||||
"body" if drops::head_is_body(m.species()) => {
|
||||
m.extra.head = item;
|
||||
None
|
||||
}
|
||||
"saddle" => {
|
||||
m.set_saddle(Some(horizon_client::items::ItemStack::new(&s.item, 1)));
|
||||
None
|
||||
}
|
||||
"chest" => {
|
||||
m.extra.chest = item;
|
||||
Some(gear::CHEST)
|
||||
}
|
||||
"legs" => {
|
||||
m.extra.legs = item;
|
||||
Some(gear::LEGS)
|
||||
}
|
||||
"feet" => {
|
||||
m.extra.feet = item;
|
||||
Some(gear::FEET)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(index) = index
|
||||
&& !s.enchantments.is_empty()
|
||||
{
|
||||
let levels = s
|
||||
.enchantments
|
||||
.iter()
|
||||
.map(|(e, l)| (Arc::from(e.as_str()), *l as u32))
|
||||
.collect();
|
||||
gear::set(&mut m, index, levels);
|
||||
}
|
||||
}
|
||||
if let Some(chance) = c.get(format!("chance_{slot}").as_str()) {
|
||||
chances[i] = chance.parse()?;
|
||||
}
|
||||
}
|
||||
m.extra.drop_chances = Some(Box::new(chances));
|
||||
m.random = LegacyRandom::new(rand + k);
|
||||
// The killer, the last player to hurt the victim and the damage source.
|
||||
let killer_id = get("killer", "none");
|
||||
let weapon = c.get("weapon").map(|w| stack(w));
|
||||
let armed = |kind: &str| {
|
||||
let mut e = LootEntity::new(kind);
|
||||
if let Some(w) = &weapon {
|
||||
e.equipment.push(("mainhand".into(), w.clone()));
|
||||
}
|
||||
e
|
||||
};
|
||||
let player = (killer_id == "player" || get("pkill", "0") == "1").then(|| {
|
||||
if killer_id == "player" {
|
||||
armed("minecraft:player")
|
||||
} else {
|
||||
LootEntity::new("minecraft:player")
|
||||
}
|
||||
});
|
||||
let killer = match killer_id.as_str() {
|
||||
"player" => player.clone(),
|
||||
"none" => None,
|
||||
id => {
|
||||
let mut e = armed(id);
|
||||
e.frog_variant = c.get("frog").map(|f| f.to_string());
|
||||
e.powered = get("charged", "0") == "1";
|
||||
Some(e)
|
||||
}
|
||||
};
|
||||
let pkill = get("pkill", if killer_id == "player" { "1" } else { "0" }) == "1";
|
||||
let projectile = |kind: &str| LootEntity {
|
||||
kind: kind.to_owned(),
|
||||
..LootEntity::default()
|
||||
};
|
||||
let damage =
|
||||
|kind: &str, source: Option<LootEntity>, direct: Option<LootEntity>| LootDamage {
|
||||
kind: kind.to_owned(),
|
||||
source,
|
||||
direct,
|
||||
};
|
||||
let source = match get("source", "attack").as_str() {
|
||||
"attack" => match &killer {
|
||||
None => damage("minecraft:generic", None, None),
|
||||
Some(k) if k.kind == "minecraft:player" => {
|
||||
damage("minecraft:player_attack", Some(k.clone()), Some(k.clone()))
|
||||
}
|
||||
Some(k) => damage("minecraft:mob_attack", Some(k.clone()), Some(k.clone())),
|
||||
},
|
||||
"arrow" => damage(
|
||||
"minecraft:arrow",
|
||||
killer.clone(),
|
||||
Some(projectile("minecraft:arrow")),
|
||||
),
|
||||
"fireball" => match &killer {
|
||||
Some(k) => damage(
|
||||
"minecraft:fireball",
|
||||
Some(k.clone()),
|
||||
Some(projectile("minecraft:fireball")),
|
||||
),
|
||||
None => damage(
|
||||
"minecraft:unattributed_fireball",
|
||||
Some(projectile("minecraft:fireball")),
|
||||
Some(projectile("minecraft:fireball")),
|
||||
),
|
||||
},
|
||||
"explosion" => match &killer {
|
||||
Some(k) => damage(
|
||||
"minecraft:player_explosion",
|
||||
Some(k.clone()),
|
||||
Some(k.clone()),
|
||||
),
|
||||
None => damage("minecraft:explosion", None, None),
|
||||
},
|
||||
"lightning" => damage("minecraft:lightning_bolt", None, None),
|
||||
"fire" => damage("minecraft:on_fire", None, None),
|
||||
_ => damage("minecraft:generic", None, None),
|
||||
};
|
||||
let death = drops::Death {
|
||||
loot: &loot,
|
||||
damage: Some(source),
|
||||
player: player.filter(|_| pkill),
|
||||
luck: get("luck", "0").parse()?,
|
||||
vehicle: c.get("vehicle").map(|v| LootEntity::new(v)),
|
||||
mob_drops: true,
|
||||
world_seed,
|
||||
};
|
||||
let (items, _) = drops::die(&mut m, &death, &mut sequences);
|
||||
for d in items {
|
||||
let _ = match d {
|
||||
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
|
||||
drops::Drop::Orb(v) => writeln!(out, "orb {v}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -62,26 +62,15 @@ fn main() -> Result<()> {
|
||||
now: i("now"),
|
||||
call: i("call"),
|
||||
spin: i("spin"),
|
||||
overshoot: c["overshoot"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_i64().unwrap())
|
||||
.collect(),
|
||||
overshoot: c["overshoot"].as_array().unwrap().iter().map(|v| v.as_i64().unwrap()).collect(),
|
||||
next_overshoot: 0,
|
||||
interrupts: c["interrupts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_bool().unwrap())
|
||||
.collect(),
|
||||
interrupts: c["interrupts"].as_array().unwrap().iter().map(|v| v.as_bool().unwrap()).collect(),
|
||||
next_interrupt: 0,
|
||||
parks: Vec::new(),
|
||||
spins: 0,
|
||||
queries: 0,
|
||||
};
|
||||
let mut limiter =
|
||||
FramerateLimiter::with_state(i("last"), i("average"), i("last_limit") as i32);
|
||||
let mut limiter = FramerateLimiter::with_state(i("last"), i("average"), i("last_limit") as i32);
|
||||
for frame in c["frames"].as_array().unwrap() {
|
||||
clock.now = clock.now.wrapping_add(frame[0].as_i64().unwrap());
|
||||
clock.parks.clear();
|
||||
|
||||
@@ -87,7 +87,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
dimension: "minecraft:overworld".into(),
|
||||
seed: 0,
|
||||
max_players: 8,
|
||||
dedicated: false,
|
||||
},
|
||||
);
|
||||
let mut next_entity = 100u32;
|
||||
@@ -111,15 +110,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
world.day_time += 1;
|
||||
let mut ctx = TickCtx {
|
||||
world: &mut world,
|
||||
host: Some(&host),
|
||||
host: &host,
|
||||
entities: &[],
|
||||
next_entity: &mut next_entity,
|
||||
spawn: [8.5, 60.0, 8.5],
|
||||
difficulty: 2,
|
||||
difficulty_locked: false,
|
||||
clock_rate: 1.0,
|
||||
tick_rate: 20.0,
|
||||
tick_frozen: false,
|
||||
};
|
||||
for event in hub.tick(&mut ctx) {
|
||||
match event {
|
||||
|
||||
@@ -1,517 +0,0 @@
|
||||
//! Replays `tools/test_mob_ai_parity.py` scenarios on Horizon's mob AI and
|
||||
//! prints each mob's AI-visible state after every tick in the format of
|
||||
//! `VanillaMobAiOracle`.
|
||||
//!
|
||||
//! usage: mob_ai_probe RESOURCES SCENARIOS OUTPUT
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, HEIGHT, MIN_Y},
|
||||
collisions,
|
||||
items::Items,
|
||||
light::{LightTables, light_chunk},
|
||||
mobs::{
|
||||
Mob, MobData, MobEvent, MobTypes, MobWorld, PlayerView, Seen, Species, Who,
|
||||
control::Operation, spawning::LocalDifficulty,
|
||||
},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::LegacyRandom,
|
||||
};
|
||||
use horizon_world::{chunk::BlockState, registry::BlockRegistry, registry::BlockStateId};
|
||||
use std::{collections::BTreeMap, fmt::Write as _, fs, path::Path, sync::Arc};
|
||||
|
||||
struct MobSpec {
|
||||
kind: String,
|
||||
pos: [f64; 3],
|
||||
yaw: f32,
|
||||
seed: i64,
|
||||
baby: bool,
|
||||
flags: Vec<String>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Scenario {
|
||||
name: String,
|
||||
ticks: i32,
|
||||
time: i64,
|
||||
rain: bool,
|
||||
thunder: bool,
|
||||
origin: [i32; 2],
|
||||
blocks: Vec<Vec<String>>,
|
||||
player: Option<[f64; 3]>,
|
||||
creative: bool,
|
||||
mobs: Vec<MobSpec>,
|
||||
}
|
||||
|
||||
fn parse(text: &str) -> Vec<Scenario> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur: Option<Scenario> = None;
|
||||
for line in text.lines() {
|
||||
let f: Vec<&str> = line.split_whitespace().collect();
|
||||
if f.is_empty() || f[0].starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let s = cur.get_or_insert_with(Scenario::default);
|
||||
match f[0] {
|
||||
"scenario" => {
|
||||
s.name = f[1].to_owned();
|
||||
s.time = 6000;
|
||||
for kv in &f[2..] {
|
||||
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
|
||||
match k {
|
||||
"ticks" => s.ticks = v.parse().unwrap(),
|
||||
"time" => s.time = v.parse().unwrap(),
|
||||
"rain" => s.rain = true,
|
||||
"thunder" => s.thunder = true,
|
||||
"levelseed" => {}
|
||||
_ => panic!("unknown scenario option {kv}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
"origin" => s.origin = [f[1].parse().unwrap(), f[2].parse().unwrap()],
|
||||
"fill" | "block" => s.blocks.push(f.iter().map(|x| (*x).to_owned()).collect()),
|
||||
"player" => {
|
||||
s.player = Some([
|
||||
f[1].parse().unwrap(),
|
||||
f[2].parse().unwrap(),
|
||||
f[3].parse().unwrap(),
|
||||
]);
|
||||
s.creative = f.get(4) == Some(&"creative");
|
||||
}
|
||||
"mob" => s.mobs.push(MobSpec {
|
||||
kind: f[1].to_owned(),
|
||||
pos: [
|
||||
f[2].parse().unwrap(),
|
||||
f[3].parse().unwrap(),
|
||||
f[4].parse().unwrap(),
|
||||
],
|
||||
yaw: f[5].parse().unwrap(),
|
||||
seed: f[6].parse().unwrap(),
|
||||
baby: f[7..].contains(&"baby"),
|
||||
flags: f[7..]
|
||||
.iter()
|
||||
.filter(|x| **x != "baby")
|
||||
.map(|x| (*x).to_owned())
|
||||
.collect(),
|
||||
}),
|
||||
"end" => out.push(cur.take().unwrap()),
|
||||
other => panic!("unknown scenario line {other}"),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `minecraft:name[key=value,...]`
|
||||
fn state(registry: &BlockRegistry, text: &str) -> BlockStateId {
|
||||
let (name, props) = match text.split_once('[') {
|
||||
Some((n, rest)) => (n, rest.trim_end_matches(']')),
|
||||
None => (text, ""),
|
||||
};
|
||||
let mut properties = BTreeMap::new();
|
||||
for kv in props.split(',').filter(|s| !s.is_empty()) {
|
||||
let (k, v) = kv.split_once('=').expect("property");
|
||||
properties.insert(k.to_owned(), v.to_owned());
|
||||
}
|
||||
registry
|
||||
.resolve(&BlockState {
|
||||
name: name.to_owned(),
|
||||
properties,
|
||||
})
|
||||
.unwrap_or_else(|e| panic!("{text}: {e}"))
|
||||
}
|
||||
|
||||
fn d(v: f64) -> String {
|
||||
format!("{:x}", v.to_bits())
|
||||
}
|
||||
fn f(v: f32) -> String {
|
||||
format!("{:x}", v.to_bits())
|
||||
}
|
||||
fn who(w: Option<Who>) -> String {
|
||||
match w {
|
||||
None => "none".into(),
|
||||
Some(Who::Player) => "player".into(),
|
||||
Some(Who::Mob(id)) => format!("e{id}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Vanilla's goal class for a Horizon goal (most share the name).
|
||||
fn goal_name(m: &Mob, name: &str) -> String {
|
||||
let vanilla = match name {
|
||||
"Float" => "FloatGoal",
|
||||
"Stroll" => "WaterAvoidingRandomStrollGoal",
|
||||
"LookAtPlayer" => "LookAtPlayerGoal",
|
||||
"RandomLookAround" => "RandomLookAroundGoal",
|
||||
"HurtByTarget" => "HurtByTargetGoal",
|
||||
"NearestTarget" => "NearestAttackableTargetGoal",
|
||||
"Panic" => "PanicGoal",
|
||||
"Breed" => "BreedGoal",
|
||||
"Tempt" => "TemptGoal",
|
||||
"FollowParent" => "FollowParentGoal",
|
||||
"MeleeAttack" => match m.species() {
|
||||
Species::Zombie | Species::Husk | Species::ZombieVillager => "ZombieAttackGoal",
|
||||
_ => "MeleeAttackGoal",
|
||||
},
|
||||
"MoveThroughVillage" => "MoveThroughVillageGoal",
|
||||
"RestrictSun" => "RestrictSunGoal",
|
||||
"FleeSun" => "FleeSunGoal",
|
||||
"AvoidEntity" => "AvoidEntityGoal",
|
||||
"BowAttack" => "RangedBowAttackGoal",
|
||||
"Swell" => "SwellGoal",
|
||||
"LeapAtTarget" => "LeapAtTargetGoal",
|
||||
"EatBlock" => "EatBlockGoal",
|
||||
"MoveTowardsRestriction" => "MoveTowardsRestrictionGoal",
|
||||
"TradeWithPlayer" => "TradeWithPlayerGoal",
|
||||
"LookAtTradingPlayer" => "LookAtTradingPlayerGoal",
|
||||
"WanderToPosition" => "WanderToPositionGoal",
|
||||
"DrinkItem" => "UseItemGoal",
|
||||
"BreathAir" => "BreathAirGoal",
|
||||
"TryFindWater" => "TryFindWaterGoal",
|
||||
"RangedAttack" => "RangedAttackGoal",
|
||||
"MoveTowardsTarget" => "MoveTowardsTargetGoal",
|
||||
other => return other.to_owned(),
|
||||
};
|
||||
vanilla.to_owned()
|
||||
}
|
||||
|
||||
fn goals(m: &Mob, names: Vec<&'static str>) -> String {
|
||||
if names.is_empty() {
|
||||
return "-".into();
|
||||
}
|
||||
names
|
||||
.iter()
|
||||
.map(|n| goal_name(m, n))
|
||||
.collect::<Vec<_>>()
|
||||
.join("|")
|
||||
}
|
||||
|
||||
fn seen(m: &Mob) -> Seen {
|
||||
Seen {
|
||||
id: m.id,
|
||||
species: m.species(),
|
||||
position: m.position,
|
||||
bounds: m.bounds(),
|
||||
eye_y: m.eye_y(),
|
||||
alive: m.alive(),
|
||||
baby: m.is_baby(),
|
||||
in_love: if m.species() == Species::Villager {
|
||||
horizon_client::mobs::villager::can_breed(m)
|
||||
} else {
|
||||
m.extra.in_love > 0
|
||||
},
|
||||
tame: m.extra.tame,
|
||||
hostile: m.hostile(),
|
||||
target: m.ai.target,
|
||||
health: m.health,
|
||||
variant: m.extra.variant,
|
||||
color: m.extra.color,
|
||||
job: (m.species() == Species::Villager)
|
||||
.then_some(m.extra.home)
|
||||
.flatten(),
|
||||
bed: (m.species() == Species::Villager)
|
||||
.then_some(m.extra.bed)
|
||||
.flatten(),
|
||||
yaw: m.yaw,
|
||||
velocity: m.velocity,
|
||||
leash: m.extra.leash,
|
||||
caravan: horizon_client::mobs::caravan::head_of(m),
|
||||
delay: m.extra.ints[5],
|
||||
hurt_by: m.ai.last_hurt_by.map(|w| (w, m.ai.last_hurt_by_time)),
|
||||
active_raid: horizon_client::mobs::raider::has_active_raid(m),
|
||||
player_rep: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn print(out: &mut String, t: i32, i: usize, m: &Mob) {
|
||||
let _ = write!(out, "{t} {i}");
|
||||
if m.removed {
|
||||
let _ = writeln!(out, " removed DISCARDED");
|
||||
return;
|
||||
}
|
||||
let p = m.position;
|
||||
let v = m.velocity;
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tp {},{},{}\tv {},{},{}",
|
||||
d(p.x),
|
||||
d(p.y),
|
||||
d(p.z),
|
||||
d(v.x),
|
||||
d(v.y),
|
||||
d(v.z)
|
||||
);
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tr {},{},{},{}",
|
||||
f(m.yaw),
|
||||
f(m.head_pitch),
|
||||
f(m.head_yaw),
|
||||
f(m.body_yaw)
|
||||
);
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tg {}{}\thp {}",
|
||||
u8::from(m.on_ground),
|
||||
u8::from(m.horizontal_collision),
|
||||
f(m.health)
|
||||
);
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tgoals {}\ttargets {}\ttarget {}",
|
||||
goals(m, m.ai.goals.running_names()),
|
||||
goals(m, m.ai.targets.running_names()),
|
||||
who(m.ai.target)
|
||||
);
|
||||
let _ = write!(out, "\tnav ");
|
||||
match &m.ai.nav.path {
|
||||
None => out.push_str("none"),
|
||||
Some(path) => {
|
||||
let _ = write!(
|
||||
out,
|
||||
"{},{},{} {}/{}{}",
|
||||
path.target[0],
|
||||
path.target[1],
|
||||
path.target[2],
|
||||
path.next,
|
||||
path.nodes.len(),
|
||||
if path.next >= path.nodes.len() {
|
||||
" done"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
let mv = &m.ai.mov;
|
||||
let op = match mv.operation {
|
||||
Operation::Wait => "WAIT",
|
||||
Operation::MoveTo => "MOVE_TO",
|
||||
Operation::Strafe => "STRAFE",
|
||||
Operation::Jumping => "JUMPING",
|
||||
};
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tmove {op} {},{},{} {}",
|
||||
d(mv.wanted[0]),
|
||||
d(mv.wanted[1]),
|
||||
d(mv.wanted[2]),
|
||||
d(mv.speed_modifier)
|
||||
);
|
||||
let lk = &m.ai.look;
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tlook {} {},{},{}",
|
||||
lk.cooldown,
|
||||
d(lk.wanted[0]),
|
||||
d(lk.wanted[1]),
|
||||
d(lk.wanted[2])
|
||||
);
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tin {},{},{},{}",
|
||||
f(m.ai.xxa),
|
||||
f(m.ai.zza),
|
||||
f(m.ai.speed),
|
||||
u8::from(m.ai.jumping)
|
||||
);
|
||||
let _ = writeln!(out, "\trng {:x}", m.random.state());
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.len() != 3 {
|
||||
return Err("usage: mob_ai_probe RESOURCES SCENARIOS OUTPUT".into());
|
||||
}
|
||||
let root = Path::new(&args[0]);
|
||||
let assets = Assets::load(root)?;
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let types = MobTypes::load(root)?;
|
||||
let biomes = Biomes::load(root)?;
|
||||
let items = Items::load(root, registry, &facts)?;
|
||||
let tables = LightTables::load(root, registry, &facts)?;
|
||||
let collision = collisions::load(root, registry)?;
|
||||
let air = registry.default_state("minecraft:air").ok_or("no air")?;
|
||||
let layers = [
|
||||
state(registry, "minecraft:bedrock"),
|
||||
state(registry, "minecraft:dirt"),
|
||||
state(registry, "minecraft:dirt"),
|
||||
state(registry, "minecraft:grass_block"),
|
||||
];
|
||||
let plains = biomes.id("minecraft:plains");
|
||||
let mut out = String::new();
|
||||
for s in parse(&fs::read_to_string(&args[1])?) {
|
||||
let _ = writeln!(out, "== {}", s.name);
|
||||
let mut world = World::new(air, 0);
|
||||
let (ocx, ocz) = (s.origin[0] >> 4, s.origin[1] >> 4);
|
||||
const R: i32 = 4;
|
||||
for cz in ocz - R..=ocz + R {
|
||||
for cx in ocx - R..=ocx + R {
|
||||
let mut chunk = Chunk::empty(cx, cz);
|
||||
for qy in 0..HEIGHT / 4 {
|
||||
for qz in 0..4 {
|
||||
for qx in 0..4 {
|
||||
chunk.set_biome_cell(qx, qy, qz, plains);
|
||||
}
|
||||
}
|
||||
}
|
||||
world.chunks.insert((cx, cz), Arc::new(chunk));
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
for (y, layer) in layers.iter().enumerate() {
|
||||
world.set(cx * 16 + x, MIN_Y + y as i32, cz * 16 + z, *layer)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for b in &s.blocks {
|
||||
let n = |i: usize| b[i].parse::<i32>().unwrap();
|
||||
if b[0] == "block" {
|
||||
world.set(n(1), n(2), n(3), state(registry, &b[4]))?;
|
||||
} else {
|
||||
let st = state(registry, &b[7]);
|
||||
for x in n(1).min(n(4))..=n(1).max(n(4)) {
|
||||
for y in n(2).min(n(5))..=n(2).max(n(5)) {
|
||||
for z in n(3).min(n(6))..=n(3).max(n(6)) {
|
||||
world.set(x, y, z, st)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let keys: Vec<(i32, i32)> = world.chunks.keys().copied().collect();
|
||||
let lit: Vec<((i32, i32), Chunk)> = keys
|
||||
.iter()
|
||||
.map(|&(x, z)| {
|
||||
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| {
|
||||
world
|
||||
.chunks
|
||||
.get(&(x + dx as i32 - 1, z + dz as i32 - 1))
|
||||
.map(|c| &**c)
|
||||
})
|
||||
});
|
||||
let mut c = (*world.chunks[&(x, z)]).clone();
|
||||
c.sections = light_chunk(®ion, &tables, true);
|
||||
c.lit = true;
|
||||
((x, z), c)
|
||||
})
|
||||
.collect();
|
||||
for (k, c) in lit {
|
||||
world.chunks.insert(k, Arc::new(c));
|
||||
}
|
||||
world.day_time = s.time;
|
||||
world.game_time = 1000;
|
||||
let mut mobs: Vec<Mob> = Vec::new();
|
||||
for (i, spec) in s.mobs.iter().enumerate() {
|
||||
let kind = types
|
||||
.types
|
||||
.get(&spec.kind)
|
||||
.ok_or_else(|| format!("unknown mob {}", spec.kind))?;
|
||||
let mut m = Mob::new(
|
||||
100_000 + i as u32,
|
||||
Arc::new(kind.clone()),
|
||||
Vec3::new(spec.pos[0], spec.pos[1], spec.pos[2]),
|
||||
spec.yaw,
|
||||
);
|
||||
if spec.baby {
|
||||
m.extra.age = -24000;
|
||||
}
|
||||
for flag in &spec.flags {
|
||||
match flag.as_str() {
|
||||
"persistent" => m.persistent = true,
|
||||
"noai" => m.no_ai = true,
|
||||
other => panic!("unknown mob flag {other}"),
|
||||
}
|
||||
}
|
||||
m.random = LegacyRandom::new(spec.seed);
|
||||
mobs.push(m);
|
||||
}
|
||||
let player = s.player.map(|p| {
|
||||
let position = Vec3::new(p[0], p[1], p[2]);
|
||||
PlayerView {
|
||||
position,
|
||||
bounds: Aabb::new(
|
||||
Vec3::new(p[0] - 0.3, p[1], p[2] - 0.3),
|
||||
Vec3::new(p[0] + 0.3, p[1] + 1.8, p[2] + 0.3),
|
||||
),
|
||||
eye: [p[0], p[1] + f64::from(1.62_f32), p[2]],
|
||||
alive: true,
|
||||
creative: s.creative,
|
||||
sneaking: false,
|
||||
look: [0.0, 0.0, 1.0],
|
||||
main_hand: None,
|
||||
off_hand: None,
|
||||
head: None,
|
||||
armor_cover: 0.0,
|
||||
spectator: false,
|
||||
gold_armor: false,
|
||||
hurt_by: None,
|
||||
last_hurt: None,
|
||||
velocity: Vec3::ZERO,
|
||||
yaw: 0.0,
|
||||
hero: false,
|
||||
uuid: [0, 0x1234, 0, 0x5678],
|
||||
}
|
||||
});
|
||||
let mut scratch = LegacyRandom::new(0);
|
||||
for (i, m) in mobs.iter().enumerate() {
|
||||
print(&mut out, 0, i, m);
|
||||
}
|
||||
for t in 1..=s.ticks {
|
||||
world.game_time += 1;
|
||||
let snapshot: Vec<Seen> = mobs.iter().filter(|m| !m.removed).map(seen).collect();
|
||||
let w = MobWorld {
|
||||
world: &world,
|
||||
registry,
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
data: &data,
|
||||
player: player.clone(),
|
||||
others: &snapshot,
|
||||
daylight: (s.time.rem_euclid(24000)) < 12000,
|
||||
game_time: world.game_time,
|
||||
day_time: world.day_time,
|
||||
raining: s.rain,
|
||||
thundering: s.thunder,
|
||||
nether: false,
|
||||
block_tags: &items.block_tags,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
difficulty: LocalDifficulty::new(2, world.day_time, 0, 0.0),
|
||||
end: false,
|
||||
world_seed: 0,
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
for m in &mut mobs {
|
||||
if m.removed {
|
||||
continue;
|
||||
}
|
||||
if !m.tick(&w, &mut scratch, &mut events) {
|
||||
m.removed = true;
|
||||
}
|
||||
}
|
||||
for (i, m) in mobs.iter().enumerate() {
|
||||
print(&mut out, t, i, m);
|
||||
}
|
||||
for e in &events {
|
||||
if let MobEvent::Sound(name, _, volume, pitch) = e {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{t} sound minecraft:{name} {} {}",
|
||||
f(*volume),
|
||||
f(*pitch)
|
||||
);
|
||||
} else if let MobEvent::SoundId(name, _, volume, pitch) = e {
|
||||
let _ = writeln!(out, "{t} sound {name} {} {}", f(*volume), f(*pitch));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fs::write(&args[2], out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
//! Replays `tools/test_music_parity.py` scripts through Horizon's
|
||||
//! `MusicManager` over its `SoundEngine`: `music_fixture ROOT INPUT OUTPUT`.
|
||||
#[path = "audio_common/mod.rs"]
|
||||
mod common;
|
||||
use common::{Fake, describe, f, flush};
|
||||
use horizon_client::music::{Music, MusicFrequency, MusicManager};
|
||||
use horizon_client::sound_engine::{Bound, MUSIC, SoundEngine, TickContext};
|
||||
use horizon_client::sound_events::SoundRegistry;
|
||||
use std::io::Write;
|
||||
|
||||
fn music(spec: &str) -> Option<Music> {
|
||||
if spec == "-" {
|
||||
return None;
|
||||
}
|
||||
let p: Vec<&str> = spec.split(',').collect();
|
||||
Some(Music::new(
|
||||
&horizon_client::sound_events::normalize(p[0]),
|
||||
p[1].parse().expect("min"),
|
||||
p[2].parse().expect("max"),
|
||||
p[3] == "1",
|
||||
))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut engine = SoundEngine::new(SoundRegistry::load(std::path::Path::new(&args[1])));
|
||||
engine.listening = true;
|
||||
let mut backend = Fake::default();
|
||||
let mut manager = MusicManager::new(0);
|
||||
let (mut situation, mut volume) = (None::<Music>, 1.0f32);
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
for line in input.lines() {
|
||||
writeln!(out, "{line}").expect("write");
|
||||
let a: Vec<&str> = line.split(' ').collect();
|
||||
match a[0] {
|
||||
"start" => {
|
||||
if let Some(id) = manager.current_instance() {
|
||||
engine.release(id);
|
||||
}
|
||||
manager = MusicManager::new(a[1].parse().expect("seed"));
|
||||
manager.frequency = MusicFrequency::parse(a[2]);
|
||||
engine.stop_all(&mut backend);
|
||||
}
|
||||
"situation" => {
|
||||
situation = music(a[1]);
|
||||
volume = f(a[2]);
|
||||
}
|
||||
"tick" => {
|
||||
let mut sounds = Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
};
|
||||
manager.tick(situation.as_ref(), volume, a[1] == "1", &mut sounds);
|
||||
engine.tick(a[2] == "1", &TickContext::default(), &mut backend);
|
||||
}
|
||||
"finish" => {
|
||||
if let Some(c) = manager.current_instance().and_then(|id| engine.channel(id)) {
|
||||
horizon_client::sound_engine::Backend::stop(&mut backend, c);
|
||||
}
|
||||
}
|
||||
"stop" => manager.stop_playing(
|
||||
situation.as_ref(),
|
||||
&mut Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
},
|
||||
),
|
||||
"stopmusic" => {
|
||||
let m = music(a[1]).expect("music");
|
||||
manager.stop_playing_music(
|
||||
&m,
|
||||
situation.as_ref(),
|
||||
&mut Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
},
|
||||
);
|
||||
}
|
||||
"freq" => manager.set_frequency(MusicFrequency::parse(a[1]), situation.as_ref()),
|
||||
"toast" => manager.show_now_playing_toast_if_needed(),
|
||||
"volume" => {
|
||||
let source: usize = a[1].parse().expect("source");
|
||||
engine.source_volumes[source] = f(a[2]);
|
||||
engine.refresh_category_volume(source, &mut backend);
|
||||
}
|
||||
"stopall" => engine.stop_all(&mut backend),
|
||||
_ => panic!("{line}"),
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" current={} delay={} gain={:08x} toastShown={} musicGain={:08x}",
|
||||
manager.current(&engine).unwrap_or("-"),
|
||||
manager.next_song_delay(),
|
||||
manager.current_gain().to_bits(),
|
||||
manager.toast_shown(),
|
||||
engine.category_gain(MUSIC).to_bits()
|
||||
)
|
||||
.expect("write");
|
||||
for show in std::mem::take(&mut manager.toast_requests) {
|
||||
writeln!(out, " toast {}", if show { "show" } else { "hide" }).expect("write");
|
||||
}
|
||||
if let Some(id) = manager.current_instance() {
|
||||
writeln!(out, "{}", describe(&engine, &backend, "music", id)).expect("write");
|
||||
}
|
||||
flush(&mut engine, &mut out);
|
||||
}
|
||||
}
|
||||
@@ -1,688 +0,0 @@
|
||||
//! Natural spawning decisions for differential tests against vanilla
|
||||
//! (tools/test_natural_spawn_parity.py): reads the box-world cases of
|
||||
//! `VanillaNaturalSpawnOracle` and prints what `mobs::spawn_selection` does in
|
||||
//! the same order and format.
|
||||
//!
|
||||
//! The oracle runs `finalizeSpawn` with a fixed random (nextInt 0, nextFloat
|
||||
//! 0.75); `neutral_box` is the box that leaves a mob with: polar bears,
|
||||
//! ocelots and rabbits after the first of their group are babies (chance
|
||||
//! 1.0), foxes and axolotls from the third, sulfur cubes have size 2, salmon
|
||||
//! are small, slimes and magma cubes keep size 1, tropical fish school.
|
||||
//!
|
||||
//! usage: natural_spawn_fixture RESOURCES INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::{Biomes, SpawnEntry},
|
||||
blocks::BlockFacts,
|
||||
collisions::{self, LookupCollisions, no_collision_empty},
|
||||
items::Items,
|
||||
mobs::{
|
||||
MobTypes, Species,
|
||||
data::MobData,
|
||||
spawn_rules::{Dimension, SpawnLevel},
|
||||
spawn_selection::{
|
||||
self, Category, Counted, Event, GenEvent, Heightmap, NaturalLevel, SpawnCost,
|
||||
SpawnPlayer, SpawnRequest, SpawnState, Spawned, StructureAt, StructureSpawns,
|
||||
spawn_box,
|
||||
},
|
||||
species::{self, Baby},
|
||||
},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::Aabb,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::{
|
||||
block_collision::BlockCollisionRegistry, chunk::BlockState, registry::BlockStateId,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
fmt::Write as _,
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Bx([i32; 6]);
|
||||
impl Bx {
|
||||
fn parse(s: &str) -> Self {
|
||||
let v: Vec<i32> = s.split(',').map(|n| n.parse().unwrap_or(0)).collect();
|
||||
Self([v[0], v[1], v[2], v[3], v[4], v[5]])
|
||||
}
|
||||
fn has(&self, x: i32, y: i32, z: i32) -> bool {
|
||||
let b = self.0;
|
||||
x >= b[0] && x <= b[3] && y >= b[1] && y <= b[4] && z >= b[2] && z <= b[5]
|
||||
}
|
||||
}
|
||||
struct Start {
|
||||
id: String,
|
||||
pieces: Vec<Bx>,
|
||||
/// The start's bounding box (pieces encapsulated, inflated by 12 for
|
||||
/// terrain adaptation).
|
||||
bounds: Bx,
|
||||
}
|
||||
|
||||
struct Shared<'a> {
|
||||
facts: &'a BlockFacts,
|
||||
data: &'a MobData,
|
||||
block_tags: &'a HashMap<String, HashSet<String>>,
|
||||
types: &'a MobTypes,
|
||||
biomes: &'a Biomes,
|
||||
structures: &'a StructureSpawns,
|
||||
collision: &'a BlockCollisionRegistry<'a>,
|
||||
air: BlockStateId,
|
||||
}
|
||||
struct World<'a> {
|
||||
s: &'a Shared<'a>,
|
||||
blocks: Vec<(Bx, BlockStateId)>,
|
||||
lights: Vec<(Bx, i32, i32)>,
|
||||
biomes: Vec<(Bx, String)>,
|
||||
biome: String,
|
||||
sky: i32,
|
||||
light: i32,
|
||||
darken: i32,
|
||||
thunder: bool,
|
||||
sea: i32,
|
||||
seed: i64,
|
||||
peaceful: bool,
|
||||
dimension: Dimension,
|
||||
dimension_id: String,
|
||||
slime: f32,
|
||||
starts: Vec<Start>,
|
||||
players: Vec<([f64; 3], String)>,
|
||||
respawn: Option<[i32; 3]>,
|
||||
unloaded: HashSet<(i32, i32)>,
|
||||
spawn_mobs: bool,
|
||||
/// Boxes of living entities (case entities, then spawned mobs).
|
||||
entities: RefCell<Vec<Aabb>>,
|
||||
}
|
||||
impl World<'_> {
|
||||
fn biome_at(&self, x: i32, y: i32, z: i32) -> &str {
|
||||
self.biomes
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(b, _)| b.has(x, y, z))
|
||||
.map_or(&self.biome, |(_, n)| n)
|
||||
}
|
||||
fn biome_info(&self, x: i32, y: i32, z: i32) -> Option<&horizon_client::biome::BiomeInfo> {
|
||||
let i = *self.s.biomes.index.get(self.biome_at(x, y, z))?;
|
||||
Some(self.s.biomes.get(i))
|
||||
}
|
||||
fn lights_at(&self, x: i32, y: i32, z: i32) -> (i32, i32) {
|
||||
self.lights
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(b, ..)| b.has(x, y, z))
|
||||
.map_or((self.sky, self.light), |(_, s, b)| (*s, *b))
|
||||
}
|
||||
}
|
||||
impl SpawnLevel for World<'_> {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
self.blocks
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(b, _)| b.has(x, y, z))
|
||||
.map_or(self.s.air, |(_, s)| *s)
|
||||
}
|
||||
fn facts(&self) -> &BlockFacts {
|
||||
self.s.facts
|
||||
}
|
||||
fn mob_data(&self) -> &MobData {
|
||||
self.s.data
|
||||
}
|
||||
fn block_tag(&self, tag: &str, block: &str) -> bool {
|
||||
self.s
|
||||
.block_tags
|
||||
.get(tag)
|
||||
.is_some_and(|s| s.contains(block))
|
||||
}
|
||||
fn biome_tag(&self, tag: &str, x: i32, y: i32, z: i32) -> bool {
|
||||
self.s
|
||||
.data
|
||||
.biome_tags
|
||||
.get(tag)
|
||||
.is_some_and(|s| s.contains(self.biome_at(x, y, z)))
|
||||
}
|
||||
fn sky_light(&self, x: i32, y: i32, z: i32) -> i32 {
|
||||
self.lights_at(x, y, z).0
|
||||
}
|
||||
fn block_light(&self, x: i32, y: i32, z: i32) -> i32 {
|
||||
self.lights_at(x, y, z).1
|
||||
}
|
||||
fn sky_darken(&self) -> i32 {
|
||||
self.darken
|
||||
}
|
||||
fn thundering(&self) -> bool {
|
||||
self.thunder
|
||||
}
|
||||
fn sea_level(&self) -> i32 {
|
||||
self.sea
|
||||
}
|
||||
fn world_surface(&self, x: i32, z: i32) -> i32 {
|
||||
self.heightmap(Heightmap::WorldSurface, x, z)
|
||||
}
|
||||
fn peaceful(&self) -> bool {
|
||||
self.peaceful
|
||||
}
|
||||
fn dimension(&self) -> Dimension {
|
||||
self.dimension
|
||||
}
|
||||
fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 {
|
||||
self.slime
|
||||
}
|
||||
fn world_seed(&self) -> i64 {
|
||||
self.seed
|
||||
}
|
||||
fn player_near(&self, x: f64, y: f64, z: f64, distance: f64) -> bool {
|
||||
self.players.iter().any(|(p, mode)| {
|
||||
let d = (p[0] - x).powi(2) + (p[1] - y).powi(2) + (p[2] - z).powi(2);
|
||||
(mode == "survival" || mode == "adventure") && d < distance * distance
|
||||
})
|
||||
}
|
||||
}
|
||||
impl NaturalLevel for World<'_> {
|
||||
fn min_y(&self) -> i32 {
|
||||
if self.dimension == Dimension::Overworld {
|
||||
-64
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
fn max_y(&self) -> i32 {
|
||||
if self.dimension == Dimension::Overworld {
|
||||
319
|
||||
} else {
|
||||
255
|
||||
}
|
||||
}
|
||||
fn ambient_light(&self) -> f32 {
|
||||
match self.dimension {
|
||||
Dimension::Overworld => 0.0,
|
||||
Dimension::Nether => 0.1,
|
||||
Dimension::End => 0.25,
|
||||
}
|
||||
}
|
||||
fn has_ceiling(&self) -> bool {
|
||||
self.dimension == Dimension::Nether
|
||||
}
|
||||
fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)> {
|
||||
let t = self.s.types.types.get(entity)?;
|
||||
Some((
|
||||
t.species,
|
||||
t.width as f32,
|
||||
t.height as f32,
|
||||
Category::from_name(&t.category),
|
||||
))
|
||||
}
|
||||
fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap<String, Vec<SpawnEntry>>> {
|
||||
self.biome_info(x, y, z).map(|b| &b.spawns)
|
||||
}
|
||||
fn spawn_cost(&self, entity: &str, x: i32, y: i32, z: i32) -> Option<SpawnCost> {
|
||||
self.biome_info(x, y, z)?.spawn_costs.get(entity).copied()
|
||||
}
|
||||
fn creature_probability(&self, x: i32, y: i32, z: i32) -> f32 {
|
||||
self.biome_info(x, y, z)
|
||||
.map_or(0.1, |b| b.creature_probability)
|
||||
}
|
||||
fn structures_at(&self, x: i32, y: i32, z: i32) -> Vec<StructureAt> {
|
||||
let (cx, cz) = (x >> 4, z >> 4);
|
||||
let mut out: Vec<StructureAt> = Vec::new();
|
||||
for start in &self.starts {
|
||||
let b = start.bounds.0;
|
||||
// Referenced: the start's box reaches the chunk's columns.
|
||||
if b[3] < cx * 16 || b[0] > cx * 16 + 15 || b[5] < cz * 16 || b[2] > cz * 16 + 15 {
|
||||
continue;
|
||||
}
|
||||
let in_piece = start.pieces.iter().any(|p| p.has(x, y, z));
|
||||
let in_bounds = start.bounds.has(x, y, z);
|
||||
match out.iter_mut().find(|s| s.id == start.id) {
|
||||
Some(s) => {
|
||||
s.in_piece |= in_piece;
|
||||
s.in_bounds |= in_bounds;
|
||||
}
|
||||
None => out.push(StructureAt {
|
||||
id: start.id.clone(),
|
||||
in_piece,
|
||||
in_bounds,
|
||||
}),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn structure_spawns(&self) -> &StructureSpawns {
|
||||
self.s.structures
|
||||
}
|
||||
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
|
||||
let mut best: Option<f64> = None;
|
||||
for (p, mode) in &self.players {
|
||||
if mode == "spectator" {
|
||||
continue;
|
||||
}
|
||||
let d = [p[0] - x, p[1] - y, p[2] - z];
|
||||
let d = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
if best.is_none_or(|b| d < b) {
|
||||
best = Some(d);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
fn respawn(&self) -> Option<[i32; 3]> {
|
||||
self.respawn
|
||||
}
|
||||
fn can_spawn_in_chunk(&self, cx: i32, cz: i32) -> bool {
|
||||
!self.unloaded.contains(&(cx, cz))
|
||||
}
|
||||
fn no_collision(&self, bounds: Aabb) -> bool {
|
||||
let lookup = |x: i32, y: i32, z: i32| self.state(x, y, z);
|
||||
let mut world = LookupCollisions {
|
||||
block: &lookup,
|
||||
binding: self.s.collision,
|
||||
};
|
||||
no_collision_empty(&mut world, bounds)
|
||||
}
|
||||
fn unobstructed(&self, bounds: Aabb) -> bool {
|
||||
!self.entities.borrow().iter().any(|b| b.intersects(bounds))
|
||||
}
|
||||
fn spawn_mobs(&self) -> bool {
|
||||
self.spawn_mobs
|
||||
}
|
||||
fn heightmap(&self, kind: Heightmap, x: i32, z: i32) -> i32 {
|
||||
(self.min_y()..=self.max_y())
|
||||
.rev()
|
||||
.find(|&y| spawn_selection::heightmap_blocks(self, kind, x, y, z))
|
||||
.map_or(self.min_y(), |y| y + 1)
|
||||
}
|
||||
fn pathfindable_land(&self, x: i32, y: i32, z: i32) -> bool {
|
||||
self.s.data.state(self.state(x, y, z)).flags & horizon_client::mobs::data::LAND != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// The box a mob is left with by `finalizeSpawn` under the oracle's fixed random.
|
||||
fn neutral_box(l: &World, r: &SpawnRequest) -> Aabb {
|
||||
let t = &l.s.types.types[r.entity];
|
||||
let (mut w, mut h) = (t.width as f32, t.height as f32);
|
||||
let baby = match r.species {
|
||||
Species::PolarBear | Species::Ocelot | Species::Rabbit => r.group_index > 0,
|
||||
Species::Fox | Species::Axolotl => r.group_index >= 2,
|
||||
_ => false,
|
||||
};
|
||||
if baby {
|
||||
match species::def(r.species).baby {
|
||||
Baby::Abs(bw, bh, _) => (w, h) = (bw as f32, bh as f32),
|
||||
Baby::Scale(s, _) => (w, h) = (w * s as f32, h * s as f32),
|
||||
Baby::None => {}
|
||||
}
|
||||
}
|
||||
match r.species {
|
||||
Species::SulfurCube => (w, h) = (w * 2.0, h * 2.0),
|
||||
Species::Salmon => (w, h) = (w * 0.5, h * 0.5),
|
||||
_ => {}
|
||||
}
|
||||
let [x, y, z] = r.position;
|
||||
spawn_box(x, y, z, w, h)
|
||||
}
|
||||
|
||||
fn parse_state(spec: &str) -> BlockState {
|
||||
let (name, props) = match spec.split_once('[') {
|
||||
Some((n, rest)) => (n, rest.trim_end_matches(']')),
|
||||
None => (spec, ""),
|
||||
};
|
||||
BlockState {
|
||||
name: name.to_owned(),
|
||||
properties: props
|
||||
.split(',')
|
||||
.filter(|p| !p.is_empty())
|
||||
.filter_map(|p| p.split_once('='))
|
||||
.map(|(k, v)| (k.to_owned(), v.to_owned()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
fn bits(v: f64) -> String {
|
||||
format!("{:x}", v.to_bits())
|
||||
}
|
||||
/// The yaw as the oracle prints it: a cube mob grown by `finalizeSpawn`
|
||||
/// (sulfur cubes, size 2 under the fixed random) takes its constructor's
|
||||
/// head rotation from the entity's unseeded random.
|
||||
fn yaw_text(entity: &str, v: f32) -> String {
|
||||
if entity == "minecraft:sulfur_cube" {
|
||||
"head".to_owned()
|
||||
} else {
|
||||
format!("{:x}", v.to_bits())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = Path::new(
|
||||
args.get(1)
|
||||
.ok_or("usage: natural_spawn_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let assets = Assets::load(root)?;
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let items = Items::tags_only(root)?;
|
||||
let types = MobTypes::load(root)?;
|
||||
let biomes = Biomes::load(root)?;
|
||||
let structures = StructureSpawns::load(root);
|
||||
let collision = collisions::load(root, registry)?;
|
||||
let shared = Shared {
|
||||
facts: &facts,
|
||||
data: &data,
|
||||
block_tags: &items.block_tags,
|
||||
types: &types,
|
||||
biomes: &biomes,
|
||||
structures: &structures,
|
||||
collision: &collision,
|
||||
air: facts.air,
|
||||
};
|
||||
let mut adaptation: HashMap<String, bool> = HashMap::new();
|
||||
let mut out = String::new();
|
||||
for raw in input.lines() {
|
||||
if raw.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut c = BTreeMap::new();
|
||||
let mut name = "";
|
||||
for token in raw.split_whitespace() {
|
||||
match token.split_once('=') {
|
||||
Some((k, v)) => {
|
||||
c.insert(k, v);
|
||||
}
|
||||
None => name = token,
|
||||
}
|
||||
}
|
||||
let get =
|
||||
|k: &str, d: &'static str| -> String { c.get(k).copied().unwrap_or(d).to_owned() };
|
||||
let indexed = |prefix: &str| -> Vec<&str> {
|
||||
(0..)
|
||||
.map_while(|i| c.get(format!("{prefix}{i}").as_str()).copied())
|
||||
.collect()
|
||||
};
|
||||
let mut blocks = Vec::new();
|
||||
for k in indexed("k") {
|
||||
let (b, s) = k.split_once('/').ok_or("bad block box")?;
|
||||
blocks.push((Bx::parse(b), registry.resolve(&parse_state(s))?));
|
||||
}
|
||||
let lights = indexed("l")
|
||||
.into_iter()
|
||||
.filter_map(|l| {
|
||||
let (b, v) = l.split_once('/')?;
|
||||
let (s, k) = v.split_once(',')?;
|
||||
Some((Bx::parse(b), s.parse().ok()?, k.parse().ok()?))
|
||||
})
|
||||
.collect();
|
||||
let biome_boxes = indexed("b")
|
||||
.into_iter()
|
||||
.filter_map(|l| {
|
||||
let (b, n) = l.split_once('/')?;
|
||||
Some((Bx::parse(b), n.to_owned()))
|
||||
})
|
||||
.collect();
|
||||
let mut starts = Vec::new();
|
||||
for s in indexed("s") {
|
||||
let (id, pieces) = s.split_once('/').ok_or("bad structure")?;
|
||||
let pieces: Vec<Bx> = pieces.split(';').map(Bx::parse).collect();
|
||||
let mut b = pieces[0].0;
|
||||
for p in &pieces[1..] {
|
||||
for i in 0..3 {
|
||||
b[i] = b[i].min(p.0[i]);
|
||||
b[i + 3] = b[i + 3].max(p.0[i + 3]);
|
||||
}
|
||||
}
|
||||
let adapts = *adaptation.entry(id.to_owned()).or_insert_with(|| {
|
||||
let path = root.join(format!(
|
||||
"data/minecraft/worldgen/structure/{}.json",
|
||||
id.trim_start_matches("minecraft:")
|
||||
));
|
||||
fs::read(path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
|
||||
.and_then(|v| v["terrain_adaptation"].as_str().map(|a| a != "none"))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if adapts {
|
||||
for i in 0..3 {
|
||||
b[i] -= 12;
|
||||
b[i + 3] += 12;
|
||||
}
|
||||
}
|
||||
starts.push(Start {
|
||||
id: id.to_owned(),
|
||||
pieces,
|
||||
bounds: Bx(b),
|
||||
});
|
||||
}
|
||||
let players = indexed("p")
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let mut parts = p.split('/');
|
||||
let v: Vec<f64> = parts
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.split(',')
|
||||
.map(|n| n.parse().unwrap_or(0.0))
|
||||
.collect();
|
||||
(
|
||||
[v[0], v[1], v[2]],
|
||||
parts.next().unwrap_or("survival").to_owned(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let dimension_id = get("dim", "minecraft:overworld");
|
||||
let dimension = match dimension_id.as_str() {
|
||||
"minecraft:the_nether" => Dimension::Nether,
|
||||
"minecraft:the_end" => Dimension::End,
|
||||
_ => Dimension::Overworld,
|
||||
};
|
||||
let respawn = {
|
||||
let r = get("respawn", "0,-1000,0/minecraft:overworld");
|
||||
let (p, d) = r.split_once('/').ok_or("bad respawn")?;
|
||||
let v: Vec<i32> = p.split(',').map(|n| n.parse().unwrap_or(0)).collect();
|
||||
(d == dimension_id).then_some([v[0], v[1], v[2]])
|
||||
};
|
||||
let unloaded = c
|
||||
.get("unloaded")
|
||||
.map(|u| {
|
||||
u.split(';')
|
||||
.filter_map(|p| {
|
||||
let (x, z) = p.split_once(',')?;
|
||||
Some((x.parse().ok()?, z.parse().ok()?))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// Case entities: (type, position, persistent).
|
||||
let mut case_entities = Vec::new();
|
||||
for e in indexed("e") {
|
||||
let parts: Vec<&str> = e.split('/').collect();
|
||||
let v: Vec<f64> = parts[1]
|
||||
.split(',')
|
||||
.map(|n| n.parse().unwrap_or(0.0))
|
||||
.collect();
|
||||
// EntityType.create makes no monsters in peaceful.
|
||||
if get("difficulty", "normal") == "peaceful"
|
||||
&& types.types.get(parts[0]).is_some_and(|t| {
|
||||
!horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species)
|
||||
})
|
||||
{
|
||||
continue;
|
||||
}
|
||||
case_entities.push((
|
||||
parts[0].to_owned(),
|
||||
[v[0], v[1], v[2]],
|
||||
parts.get(2) == Some(&"p"),
|
||||
));
|
||||
}
|
||||
let world = World {
|
||||
s: &shared,
|
||||
blocks,
|
||||
lights,
|
||||
biomes: biome_boxes,
|
||||
biome: get("biome", "minecraft:plains"),
|
||||
sky: get("sky", "15").parse()?,
|
||||
light: get("light", "0").parse()?,
|
||||
darken: get("darken", "0").parse()?,
|
||||
thunder: get("thunder", "0") == "1",
|
||||
sea: get("sea", "63").parse()?,
|
||||
seed: get("wseed", "0").parse()?,
|
||||
peaceful: get("difficulty", "normal") == "peaceful",
|
||||
dimension,
|
||||
dimension_id: dimension_id.clone(),
|
||||
slime: get("slime", "0.0").parse()?,
|
||||
starts,
|
||||
players,
|
||||
respawn,
|
||||
unloaded,
|
||||
spawn_mobs: get("rules", "1") != "0",
|
||||
entities: RefCell::new(
|
||||
case_entities
|
||||
.iter()
|
||||
.filter_map(|(t, p, _)| {
|
||||
let t = types.types.get(t)?;
|
||||
Some(spawn_box(p[0], p[1], p[2], t.width as f32, t.height as f32))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
let _ = &world.dimension_id;
|
||||
let chunk = {
|
||||
let v: Vec<i32> = get("chunk", "0,0")
|
||||
.split(',')
|
||||
.map(|n| n.parse().unwrap_or(0))
|
||||
.collect();
|
||||
(v[0], v[1])
|
||||
};
|
||||
let mut random = LegacyRandom::new(get("seed", "0").parse()?);
|
||||
let mut log: Vec<String> = Vec::new();
|
||||
let mut spawn = |l: &World, r: &SpawnRequest| -> Option<Spawned> {
|
||||
let bounds = neutral_box(l, r);
|
||||
l.entities.borrow_mut().push(bounds);
|
||||
Some(Spawned {
|
||||
bounds,
|
||||
school: true,
|
||||
})
|
||||
};
|
||||
let mode = get("mode", "chunk");
|
||||
if mode == "gen" {
|
||||
let mut gen_random = LegacyRandom::new(get("gseed", "0").parse()?);
|
||||
// The level random of the spawn predicates: no creature's predicate
|
||||
// draws from it (the oracle's answers fixed values).
|
||||
let mut rules = LegacyRandom::new(0);
|
||||
let mut spawns = Vec::new();
|
||||
let source = [chunk.0 * 16, world.max_y(), chunk.1 * 16];
|
||||
spawn_selection::spawn_mobs_for_chunk_generation(
|
||||
&world,
|
||||
source,
|
||||
chunk,
|
||||
&mut gen_random,
|
||||
&mut rules,
|
||||
&mut spawn,
|
||||
&mut |e| match e {
|
||||
GenEvent::Column(x, z) => log.push(format!("col {x} {z}")),
|
||||
GenEvent::Spawn(t, p, yaw) => spawns.push(format!(
|
||||
"spawn {t} {} {} {} {}",
|
||||
bits(p[0]),
|
||||
bits(p[1]),
|
||||
bits(p[2]),
|
||||
yaw_text(&t, yaw)
|
||||
)),
|
||||
},
|
||||
);
|
||||
log.extend(spawns);
|
||||
log.push(format!(
|
||||
"next {}",
|
||||
gen_random.next_int_bound(1000).unwrap_or(0)
|
||||
));
|
||||
} else {
|
||||
let counted: Vec<Counted> = case_entities
|
||||
.iter()
|
||||
.filter_map(|(t, p, persistent)| {
|
||||
let kind = types.types.get(t)?;
|
||||
Some(Counted {
|
||||
entity: t,
|
||||
category: Category::from_name(&kind.category),
|
||||
block: [
|
||||
p[0].floor() as i32,
|
||||
p[1].floor() as i32,
|
||||
p[2].floor() as i32,
|
||||
],
|
||||
is_mob: true,
|
||||
persistent: *persistent,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let players = world
|
||||
.players
|
||||
.iter()
|
||||
.map(|(p, mode)| SpawnPlayer {
|
||||
position: *p,
|
||||
spectator: mode == "spectator",
|
||||
})
|
||||
.collect();
|
||||
let mut state = SpawnState::new(
|
||||
&world,
|
||||
get("chunkcount", "289").parse()?,
|
||||
players,
|
||||
&counted,
|
||||
|cx, cz| !world.unloaded.contains(&(cx, cz)),
|
||||
);
|
||||
let mut trace = |e: Event| {
|
||||
log.push(match e {
|
||||
Event::Categories(list) => format!(
|
||||
"cats {}",
|
||||
list.iter().map(|c| c.name()).collect::<Vec<_>>().join(",")
|
||||
),
|
||||
Event::Local(c, ok) => format!("local {} {ok}", c.name()),
|
||||
Event::Start(c, p) => format!("start {} {} {} {}", c.name(), p[0], p[1], p[2]),
|
||||
Event::Try(x, z) => format!("try {x} {z}"),
|
||||
Event::Test(t, p, ok) => format!("test {t} {} {} {} {ok}", p[0], p[1], p[2]),
|
||||
Event::Spawn(t, p, yaw) => format!(
|
||||
"spawn {t} {} {} {} {}",
|
||||
bits(p[0]),
|
||||
bits(p[1]),
|
||||
bits(p[2]),
|
||||
yaw_text(&t, yaw)
|
||||
),
|
||||
})
|
||||
};
|
||||
if mode == "pos" {
|
||||
let category = Category::from_name(&get("cat", "monster"));
|
||||
let v: Vec<i32> = get("start", "0,0,0")
|
||||
.split(',')
|
||||
.map(|n| n.parse().unwrap_or(0))
|
||||
.collect();
|
||||
spawn_selection::spawn_category_for_position(
|
||||
&world,
|
||||
&mut random,
|
||||
&mut state,
|
||||
category,
|
||||
chunk,
|
||||
[v[0], v[1], v[2]],
|
||||
&mut spawn,
|
||||
&mut trace,
|
||||
);
|
||||
} else {
|
||||
let categories = state
|
||||
.filtered_categories(get("enemies", "1") == "1", get("persistent", "1") == "1");
|
||||
trace(Event::Categories(categories.clone()));
|
||||
spawn_selection::spawn_for_chunk(
|
||||
&world,
|
||||
&mut random,
|
||||
&mut state,
|
||||
&categories,
|
||||
chunk,
|
||||
&mut spawn,
|
||||
&mut trace,
|
||||
);
|
||||
}
|
||||
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
|
||||
}
|
||||
let _ = writeln!(out, "{name} {}", log.join(" ; "));
|
||||
}
|
||||
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
//! `getSituationalMusic`/`getMusicVolume` through Horizon's `Situation` for
|
||||
//! `tools/test_situational_music_parity.py`:
|
||||
//! `situational_music_fixture ROOT INPUT OUTPUT`.
|
||||
use horizon_client::music::{BackgroundMusic, Music, MusicTables, Situation};
|
||||
use horizon_client::sound_events::normalize;
|
||||
use std::io::Write;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let tables = MusicTables::load(std::path::Path::new(&args[1]));
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
for line in input.lines() {
|
||||
let f: Vec<&str> = line.split(' ').collect();
|
||||
let in_world = f[1] == "1";
|
||||
let (background, probe_volume) = if in_world {
|
||||
(tables.background(f[2], f[3]), tables.volume(f[2], f[3]))
|
||||
} else {
|
||||
(BackgroundMusic::default(), 1.0)
|
||||
};
|
||||
let situation = Situation {
|
||||
screen_music: (f[0] == "win").then(Music::credits),
|
||||
in_world,
|
||||
in_end: f[2] == "minecraft:the_end",
|
||||
boss_music: f[4].contains("music"),
|
||||
background: &background,
|
||||
probe_volume,
|
||||
creative: f[5] == "1" && f[6] == "1",
|
||||
underwater: f[7] == "1",
|
||||
};
|
||||
let music = situation.music().map_or("-".to_owned(), |m| {
|
||||
format!(
|
||||
"{},{},{},{}",
|
||||
normalize(&m.sound),
|
||||
m.min_delay,
|
||||
m.max_delay,
|
||||
m.replace_current_music
|
||||
)
|
||||
});
|
||||
writeln!(
|
||||
out,
|
||||
"{line} music={music} volume={:08x}",
|
||||
situation.volume().to_bits()
|
||||
)
|
||||
.expect("write");
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
//! Replays `tools/test_sound_engine_parity.py` scripts through Horizon's
|
||||
//! `SoundEngine` with a backend recording what an OpenAL source would hold:
|
||||
//! `sound_engine_fixture ROOT INPUT OUTPUT`.
|
||||
#[path = "audio_common/mod.rs"]
|
||||
mod common;
|
||||
use common::{Fake, d, describe, f, flush, result_name};
|
||||
use horizon_client::sound_engine::{
|
||||
Attenuation, Backend, InstanceId, SoundEngine, SoundInstance, TickContext,
|
||||
};
|
||||
use horizon_client::sound_events::SoundRegistry;
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut engine = SoundEngine::new(SoundRegistry::load(std::path::Path::new(&args[1])));
|
||||
engine.listening = true;
|
||||
let mut backend = Fake::default();
|
||||
let mut ctx = TickContext::default();
|
||||
let mut instances: BTreeMap<String, InstanceId> = BTreeMap::new();
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
for line in input.lines() {
|
||||
writeln!(out, "{line}").expect("write");
|
||||
let a: Vec<&str> = line.split(' ').collect();
|
||||
let source = |i: usize| a[i].parse::<usize>().expect("source");
|
||||
match a[0] {
|
||||
"play" => {
|
||||
let event = horizon_client::sound_events::normalize(a[3]);
|
||||
let random = LegacyRandom::new(a[14].parse().expect("seed"));
|
||||
let instance = match a[2] {
|
||||
"simple" => SoundInstance::new(
|
||||
&event,
|
||||
source(4),
|
||||
f(a[5]),
|
||||
f(a[6]),
|
||||
random,
|
||||
a[10] == "1",
|
||||
a[11].parse().expect("delay"),
|
||||
if a[12] == "linear" {
|
||||
Attenuation::Linear
|
||||
} else {
|
||||
Attenuation::None
|
||||
},
|
||||
[d(a[7]), d(a[8]), d(a[9])],
|
||||
a[13] == "1",
|
||||
),
|
||||
"loop" => SoundInstance::biome_loop(&event, random),
|
||||
"uloop" => SoundInstance::under_liquid_loop(&event, random),
|
||||
"usub" => SoundInstance::under_liquid_sub(&event, random),
|
||||
_ => panic!("{line}"),
|
||||
};
|
||||
if let Some(old) = instances.remove(a[1]) {
|
||||
engine.release(old);
|
||||
}
|
||||
let (id, result) = engine.play_new(instance, true, &mut backend);
|
||||
instances.insert(a[1].to_owned(), id);
|
||||
writeln!(out, " result {}", result_name(result)).expect("write");
|
||||
}
|
||||
"replay" => {
|
||||
let result = engine.play(instances[a[1]], &mut backend);
|
||||
writeln!(out, " result {}", result_name(result)).expect("write");
|
||||
}
|
||||
"queue" => engine.queue_ticking(instances[a[1]]),
|
||||
"delayed" => engine.play_delayed(instances[a[1]], a[2].parse().expect("delay")),
|
||||
"fade" => {
|
||||
let i = engine.instance_mut(instances[a[1]]).expect("instance");
|
||||
if a[2] == "in" {
|
||||
i.fade_in();
|
||||
} else {
|
||||
i.fade_out();
|
||||
}
|
||||
}
|
||||
"stop" => engine.stop(instances[a[1]], &mut backend),
|
||||
"stopmatch" => {
|
||||
let sound = (a[1] != "-").then(|| horizon_client::sound_events::normalize(a[1]));
|
||||
let src = (a[2] != "-").then(|| source(2));
|
||||
engine.stop_matching(sound.as_deref(), src, &mut backend);
|
||||
}
|
||||
"finish" => {
|
||||
if let Some(c) = engine.channel(instances[a[1]]) {
|
||||
backend.stop(c);
|
||||
}
|
||||
}
|
||||
"gain" => engine.update_category_volume(source(1), f(a[2]), &mut backend),
|
||||
"volume" => engine.source_volumes[source(1)] = f(a[2]),
|
||||
"refresh" => engine.refresh_category_volume(source(1), &mut backend),
|
||||
"pause" => engine.pause_all_except(&[1, 10], &mut backend),
|
||||
"resume" => engine.resume(&mut backend),
|
||||
"player" => {
|
||||
ctx.player_underwater = a[1] == "1";
|
||||
ctx.player_removed = a[2] == "1";
|
||||
}
|
||||
"tick" => engine.tick(a[1] == "1", &ctx, &mut backend),
|
||||
"forget" => {
|
||||
if let Some(id) = instances.remove(a[1]) {
|
||||
engine.release(id);
|
||||
}
|
||||
}
|
||||
_ => panic!("{line}"),
|
||||
}
|
||||
for (name, id) in &instances {
|
||||
writeln!(out, "{}", describe(&engine, &backend, name, *id)).expect("write");
|
||||
}
|
||||
flush(&mut engine, &mut out);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
//! Resolves sound instances through Horizon's sound event registry for
|
||||
//! `tools/test_sound_event_parity.py`: `sound_event_fixture ROOT INPUT OUTPUT [NAMES]`.
|
||||
use horizon_client::sound_engine::{Attenuation, MASTER, SoundEngine, SoundInstance};
|
||||
use horizon_client::sound_events::{Sentinel, SoundRegistry};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use std::io::Write;
|
||||
|
||||
fn bits(v: f32) -> String {
|
||||
format!("{:08x}", v.to_bits())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let registry = SoundRegistry::load(std::path::Path::new(&args[1]));
|
||||
let mut engine = SoundEngine::new(registry);
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
let mut names: Vec<String> = engine.registry.names().cloned().collect();
|
||||
names.sort();
|
||||
for name in names {
|
||||
writeln!(out, "registered {name}").expect("write");
|
||||
}
|
||||
for line in input.lines() {
|
||||
let f: Vec<&str> = line.split(' ').collect();
|
||||
let seed: i64 = f[1].parse().expect("seed");
|
||||
let volume = f32::from_bits(u32::from_str_radix(f[2], 16).expect("volume"));
|
||||
let pitch = f32::from_bits(u32::from_str_radix(f[3], 16).expect("pitch"));
|
||||
let event = horizon_client::sound_events::normalize(f[0]);
|
||||
let instance = SoundInstance::new(
|
||||
&event,
|
||||
MASTER,
|
||||
volume,
|
||||
pitch,
|
||||
LegacyRandom::new(seed),
|
||||
false,
|
||||
0,
|
||||
Attenuation::Linear,
|
||||
[0.0; 3],
|
||||
false,
|
||||
);
|
||||
let id = engine.add(instance, true);
|
||||
engine.resolve(id);
|
||||
let weight = engine.registry.weight(&event);
|
||||
let i = engine.instance_mut(id).expect("instance");
|
||||
let mut s = line.to_owned();
|
||||
match &i.event {
|
||||
None => s.push_str(" event=none"),
|
||||
Some(subtitle) => s.push_str(&format!(
|
||||
" event weight={weight} subtitle={}",
|
||||
subtitle.as_deref().unwrap_or("-")
|
||||
)),
|
||||
}
|
||||
let v = i.get_volume();
|
||||
let p = i.get_pitch();
|
||||
let sound = i.sound.clone().expect("resolved");
|
||||
let (namespace, path) = sound.path();
|
||||
let sentinel = match sound.sentinel {
|
||||
Sentinel::None => "-",
|
||||
Sentinel::Empty => "empty",
|
||||
Sentinel::IntentionallyEmpty => "intentionally_empty",
|
||||
};
|
||||
s.push_str(&format!(
|
||||
" sound={} path={namespace}:{path} sentinel={sentinel} weight={} type={} stream={} preload={} attenuation={} volume={} pitch={} range={} next={}",
|
||||
sound.location,
|
||||
sound.weight,
|
||||
if sound.event { "SOUND_EVENT" } else { "FILE" },
|
||||
sound.stream,
|
||||
sound.preload,
|
||||
sound.attenuation_distance,
|
||||
bits(v),
|
||||
bits(p),
|
||||
bits(sound.attenuation_distance(v)),
|
||||
i.random.next_long()
|
||||
));
|
||||
writeln!(out, "{s}").expect("write");
|
||||
engine.release(id);
|
||||
}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
//! Spawn placement and spawn predicate answers for differential tests against
|
||||
//! vanilla (tools/test_spawn_rules_parity.py): reads the column-world cases of
|
||||
//! `VanillaSpawnRulesOracle` and prints `mobs::spawn_rules`' answers.
|
||||
//!
|
||||
//! usage: spawn_rules_fixture RESOURCES INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
blocks::BlockFacts,
|
||||
items::Items,
|
||||
mobs::{
|
||||
MobTypes,
|
||||
data::MobData,
|
||||
spawn_rules::{self, Dimension, Reason, SpawnLevel},
|
||||
},
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::{chunk::BlockState, registry::BlockStateId};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
fmt::Write as _,
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
struct Column<'a> {
|
||||
facts: &'a BlockFacts,
|
||||
data: &'a MobData,
|
||||
block_tags: &'a HashMap<String, HashSet<String>>,
|
||||
air: BlockStateId,
|
||||
blocks: HashMap<i32, BlockStateId>,
|
||||
x0: i32,
|
||||
z0: i32,
|
||||
sky: i32,
|
||||
light: i32,
|
||||
darken: i32,
|
||||
thunder: bool,
|
||||
sea: i32,
|
||||
surface: i32,
|
||||
seed: i64,
|
||||
peaceful: bool,
|
||||
dimension: Dimension,
|
||||
biome: String,
|
||||
slime: f32,
|
||||
/// A player position and whether it is survival or adventure.
|
||||
player: Option<([f64; 3], bool)>,
|
||||
}
|
||||
impl SpawnLevel for Column<'_> {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
if x != self.x0 || z != self.z0 {
|
||||
return self.air;
|
||||
}
|
||||
self.blocks.get(&y).copied().unwrap_or(self.air)
|
||||
}
|
||||
fn facts(&self) -> &BlockFacts {
|
||||
self.facts
|
||||
}
|
||||
fn mob_data(&self) -> &MobData {
|
||||
self.data
|
||||
}
|
||||
fn block_tag(&self, tag: &str, block: &str) -> bool {
|
||||
self.block_tags.get(tag).is_some_and(|s| s.contains(block))
|
||||
}
|
||||
fn biome_tag(&self, tag: &str, _: i32, _: i32, _: i32) -> bool {
|
||||
self.data
|
||||
.biome_tags
|
||||
.get(tag)
|
||||
.is_some_and(|s| s.contains(&self.biome))
|
||||
}
|
||||
fn sky_light(&self, _: i32, _: i32, _: i32) -> i32 {
|
||||
self.sky
|
||||
}
|
||||
fn block_light(&self, _: i32, _: i32, _: i32) -> i32 {
|
||||
self.light
|
||||
}
|
||||
fn sky_darken(&self) -> i32 {
|
||||
self.darken
|
||||
}
|
||||
fn thundering(&self) -> bool {
|
||||
self.thunder
|
||||
}
|
||||
fn sea_level(&self) -> i32 {
|
||||
self.sea
|
||||
}
|
||||
fn world_surface(&self, _: i32, _: i32) -> i32 {
|
||||
self.surface
|
||||
}
|
||||
fn peaceful(&self) -> bool {
|
||||
self.peaceful
|
||||
}
|
||||
fn dimension(&self) -> Dimension {
|
||||
self.dimension
|
||||
}
|
||||
fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 {
|
||||
self.slime
|
||||
}
|
||||
fn world_seed(&self) -> i64 {
|
||||
self.seed
|
||||
}
|
||||
fn player_near(&self, x: f64, y: f64, z: f64, distance: f64) -> bool {
|
||||
self.player.is_some_and(|(p, targetable)| {
|
||||
let d = (p[0] - x).powi(2) + (p[1] - y).powi(2) + (p[2] - z).powi(2);
|
||||
targetable && d < distance * distance
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `name[key=value,...]`.
|
||||
fn parse_state(spec: &str) -> BlockState {
|
||||
let (name, props) = match spec.split_once('[') {
|
||||
Some((n, rest)) => (n, rest.trim_end_matches(']')),
|
||||
None => (spec, ""),
|
||||
};
|
||||
let properties: BTreeMap<String, String> = props
|
||||
.split(',')
|
||||
.filter(|p| !p.is_empty())
|
||||
.filter_map(|p| p.split_once('='))
|
||||
.map(|(k, v)| (k.to_owned(), v.to_owned()))
|
||||
.collect();
|
||||
BlockState {
|
||||
name: name.to_owned(),
|
||||
properties,
|
||||
}
|
||||
}
|
||||
|
||||
fn reason(name: &str) -> Reason {
|
||||
match name {
|
||||
"chunk_generation" => Reason::ChunkGeneration,
|
||||
"spawner" => Reason::Spawner,
|
||||
"trial_spawner" => Reason::TrialSpawner,
|
||||
"reinforcement" => Reason::Reinforcement,
|
||||
"patrol" => Reason::Patrol,
|
||||
"structure" => Reason::Structure,
|
||||
"event" => Reason::Event,
|
||||
"command" => Reason::Command,
|
||||
_ => Reason::Natural,
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = Path::new(
|
||||
args.get(1)
|
||||
.ok_or("usage: spawn_rules_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let assets = Assets::load(root)?;
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let items = Items::tags_only(root)?;
|
||||
let types = MobTypes::load(root)?;
|
||||
let mut out = String::new();
|
||||
for raw in input.lines() {
|
||||
if raw.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut c = BTreeMap::new();
|
||||
let mut name = "";
|
||||
for token in raw.split_whitespace() {
|
||||
match token.split_once('=') {
|
||||
Some((k, v)) => {
|
||||
c.insert(k, v);
|
||||
}
|
||||
None => name = token,
|
||||
}
|
||||
}
|
||||
let get =
|
||||
|k: &str, d: &'static str| -> String { c.get(k).copied().unwrap_or(d).to_owned() };
|
||||
let Some(kind) = types.types.get(c["type"]) else {
|
||||
let _ = writeln!(out, "{name} error unsupported {}", c["type"]);
|
||||
continue;
|
||||
};
|
||||
let y: i32 = get("y", "64").parse()?;
|
||||
let (x0, z0): (i32, i32) = (get("x", "8").parse()?, get("z", "8").parse()?);
|
||||
let mut blocks = HashMap::new();
|
||||
for (k, v) in &c {
|
||||
if let Some(offset) = k.strip_prefix("at") {
|
||||
let offset: i32 = offset.parse()?;
|
||||
blocks.insert(y + offset, registry.resolve(&parse_state(v))?);
|
||||
}
|
||||
}
|
||||
let player = c.get("player").map(|p| {
|
||||
let f: Vec<&str> = p.split(',').collect();
|
||||
let at = |i: usize, base: i32| f64::from(base) + f[i].parse::<f64>().unwrap_or(0.0);
|
||||
let mode = f.get(3).copied().unwrap_or("survival");
|
||||
(
|
||||
[at(0, x0), at(1, y), at(2, z0)],
|
||||
mode == "survival" || mode == "adventure",
|
||||
)
|
||||
});
|
||||
let level = Column {
|
||||
facts: &facts,
|
||||
data: &data,
|
||||
block_tags: &items.block_tags,
|
||||
air: facts.air,
|
||||
blocks,
|
||||
x0,
|
||||
z0,
|
||||
sky: get("sky", "15").parse()?,
|
||||
light: get("light", "0").parse()?,
|
||||
darken: get("darken", "0").parse()?,
|
||||
thunder: get("thunder", "0") == "1",
|
||||
sea: get("sea", "63").parse()?,
|
||||
surface: get("surface", "0").parse()?,
|
||||
seed: get("wseed", "0").parse()?,
|
||||
peaceful: get("difficulty", "normal") == "peaceful",
|
||||
dimension: match get("dim", "minecraft:overworld").as_str() {
|
||||
"minecraft:the_nether" => Dimension::Nether,
|
||||
"minecraft:the_end" => Dimension::End,
|
||||
_ => Dimension::Overworld,
|
||||
},
|
||||
biome: get("biome", "minecraft:plains"),
|
||||
slime: get("slime", "0.0").parse()?,
|
||||
player,
|
||||
};
|
||||
let mut random = LegacyRandom::new(get("seed", "0").parse()?);
|
||||
let s = kind.species;
|
||||
let ok = spawn_rules::is_spawn_position_ok(s, &level, x0, y, z0);
|
||||
let rules = spawn_rules::check_spawn_rules(
|
||||
s,
|
||||
&level,
|
||||
reason(&get("reason", "natural")),
|
||||
&mut random,
|
||||
x0,
|
||||
y,
|
||||
z0,
|
||||
);
|
||||
let next = random.next_int_bound(1000).unwrap_or(0);
|
||||
let _ = writeln!(out, "{name} {ok} {rules} {next}");
|
||||
}
|
||||
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
//! The player's ambient sound handlers: `BiomeAmbientSoundsHandler` (biome
|
||||
//! loops with 40-tick cross-fades, random additions, cave mood) driven by the
|
||||
//! `minecraft:audio/ambient_sounds` attribute, `UnderwaterAmbientSoundHandler`
|
||||
//! and `LocalPlayer.updateIsUnderwater` (enter/exit sounds and the
|
||||
//! `UnderLiquidAmbientSoundInstance` loop), played through the sound engine.
|
||||
use crate::sound_engine::{AMBIENT, InstanceId, PLAYERS, SoundEngine, SoundInstance, Sounds};
|
||||
//! and the underwater loop (`UnderLiquidAmbientSoundInstance`).
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
@@ -82,83 +80,87 @@ impl AmbientTables {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
/// The sound engine side of the handlers.
|
||||
pub trait AmbientOutput {
|
||||
/// A relative tickable instance; None when it could not start.
|
||||
fn play_tracked(&mut self, event: &str, volume: f32, looping: bool) -> Option<u64>;
|
||||
fn set_volume(&mut self, id: u64, volume: f32);
|
||||
fn stop(&mut self, id: u64);
|
||||
fn is_active(&self, id: u64) -> bool;
|
||||
/// A positional one-shot (`SimpleSoundInstance`, linear attenuation).
|
||||
fn play_at(&mut self, event: &str, at: [f64; 3]);
|
||||
}
|
||||
impl AmbientOutput for crate::audio::Audio {
|
||||
fn play_tracked(&mut self, event: &str, volume: f32, looping: bool) -> Option<u64> {
|
||||
crate::audio::Audio::play_tracked(self, event, volume, looping)
|
||||
}
|
||||
fn set_volume(&mut self, id: u64, volume: f32) {
|
||||
self.set_tracked_volume(id, volume);
|
||||
}
|
||||
fn stop(&mut self, id: u64) {
|
||||
self.stop_tracked(id);
|
||||
}
|
||||
fn is_active(&self, id: u64) -> bool {
|
||||
self.tracked_active(id)
|
||||
}
|
||||
fn play_at(&mut self, event: &str, at: [f64; 3]) {
|
||||
self.play(event, Some(at), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
/// `BiomeAmbientSoundsHandler.LoopSoundInstance`.
|
||||
struct LoopSound {
|
||||
id: Option<u64>,
|
||||
fade: i32,
|
||||
direction: i32,
|
||||
stopped: bool,
|
||||
}
|
||||
impl LoopSound {
|
||||
fn tick(&mut self) -> f32 {
|
||||
if self.fade < 0 {
|
||||
self.stopped = true;
|
||||
}
|
||||
self.fade += self.direction;
|
||||
(self.fade as f32 / 40.0).clamp(0.0, 1.0)
|
||||
}
|
||||
fn fade_out(&mut self) {
|
||||
self.fade = self.fade.min(40);
|
||||
self.direction = -1;
|
||||
}
|
||||
fn fade_in(&mut self) {
|
||||
self.fade = self.fade.max(0);
|
||||
self.direction = 1;
|
||||
}
|
||||
}
|
||||
/// What the handlers read about the player and level each tick.
|
||||
pub struct AmbientContext<'a> {
|
||||
/// `AMBIENT_SOUNDS` at the player's position.
|
||||
pub sounds: &'a AmbientSounds,
|
||||
/// The player's position (feet) and eye height.
|
||||
/// Feet and eye position.
|
||||
pub position: [f64; 3],
|
||||
pub eye_y: f64,
|
||||
/// `isEyeInFluid(WATER)` this tick (`Entity.updateIsUnderwater`).
|
||||
pub underwater: bool,
|
||||
pub spectator: bool,
|
||||
/// `Level.getBrightness` (sky, block) at a block.
|
||||
/// Raw (sky, block) light at a block.
|
||||
pub light: &'a dyn Fn([i32; 3]) -> (u8, u8),
|
||||
/// The bubble column in the player's box (its `drag` property), from
|
||||
/// `bubble_column_in`.
|
||||
pub bubble_column: Option<bool>,
|
||||
}
|
||||
/// `BubbleColumnAmbientSoundHandler`'s lookup: the first bubble column
|
||||
/// (x fastest, then y, then z) in the player's bounding box inflated by
|
||||
/// (0, -0.4F, 0) and deflated by 1.0E-6, as `drag_down`. `state` gives the
|
||||
/// bubble column at a block (None for anything else).
|
||||
pub fn bubble_column_in(bb: [f64; 6], state: impl Fn([i32; 3]) -> Option<bool>) -> Option<bool> {
|
||||
// AABB keeps min <= max on every construction.
|
||||
let make = |a: [f64; 6]| {
|
||||
[
|
||||
a[0].min(a[3]),
|
||||
a[1].min(a[4]),
|
||||
a[2].min(a[5]),
|
||||
a[0].max(a[3]),
|
||||
a[1].max(a[4]),
|
||||
a[2].max(a[5]),
|
||||
]
|
||||
};
|
||||
let inflate = |b: [f64; 6], x: f64, y: f64, z: f64| {
|
||||
make([b[0] - x, b[1] - y, b[2] - z, b[3] + x, b[4] + y, b[5] + z])
|
||||
};
|
||||
let b = inflate(make(bb), 0.0, f64::from(-0.4f32), 0.0);
|
||||
let b = inflate(b, -1.0E-6, -1.0E-6, -1.0E-6);
|
||||
let (x0, y0, z0) = (
|
||||
b[0].floor() as i32,
|
||||
b[1].floor() as i32,
|
||||
b[2].floor() as i32,
|
||||
);
|
||||
let (x1, y1, z1) = (
|
||||
b[3].floor() as i32,
|
||||
b[4].floor() as i32,
|
||||
b[5].floor() as i32,
|
||||
);
|
||||
for z in z0..=z1 {
|
||||
for y in y0..=y1 {
|
||||
for x in x0..=x1 {
|
||||
if let Some(drag) = state([x, y, z]) {
|
||||
return Some(drag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
/// The local player's ambient sound handlers.
|
||||
pub struct Ambience {
|
||||
/// The client level's random.
|
||||
pub random: LegacyRandom,
|
||||
/// `BiomeAmbientSoundsHandler.loopSounds` (insertion order).
|
||||
loops: Vec<(String, InstanceId)>,
|
||||
random: LegacyRandom,
|
||||
loops: Vec<(String, LoopSound)>,
|
||||
previous_loop: Option<String>,
|
||||
pub moodiness: f32,
|
||||
/// `UnderwaterAmbientSoundHandler.tickDelay`.
|
||||
underwater_delay: i32,
|
||||
/// `LocalPlayer.wasUnderwater`.
|
||||
/// The underwater loop instance and its fade.
|
||||
underwater_loop: Option<(u64, i32)>,
|
||||
/// UnderLiquidSubSound instances (stop when the player leaves the water).
|
||||
sub_sounds: Vec<u64>,
|
||||
was_underwater: bool,
|
||||
/// `BubbleColumnAmbientSoundHandler.wasInBubbleColumn` / `firstTick`.
|
||||
was_in_bubble_column: bool,
|
||||
first_tick: bool,
|
||||
}
|
||||
impl Default for Ambience {
|
||||
fn default() -> Self {
|
||||
Self::new(crate::sound_engine::unique_seed())
|
||||
let seed = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos() as i64);
|
||||
Self::new(seed)
|
||||
}
|
||||
}
|
||||
impl Ambience {
|
||||
@@ -169,95 +171,53 @@ impl Ambience {
|
||||
previous_loop: None,
|
||||
moodiness: 0.0,
|
||||
underwater_delay: 0,
|
||||
underwater_loop: None,
|
||||
sub_sounds: Vec::new(),
|
||||
was_underwater: false,
|
||||
was_in_bubble_column: false,
|
||||
first_tick: true,
|
||||
}
|
||||
}
|
||||
/// `LocalPlayer.isUnderWater`.
|
||||
pub fn was_underwater(&self) -> bool {
|
||||
self.was_underwater
|
||||
}
|
||||
/// `BiomeAmbientSoundsHandler.loopSounds`.
|
||||
pub fn loops(&self) -> &[(String, InstanceId)] {
|
||||
&self.loops
|
||||
}
|
||||
/// `UnderwaterAmbientSoundHandler.tickDelay`.
|
||||
pub fn underwater_delay(&self) -> i32 {
|
||||
self.underwater_delay
|
||||
}
|
||||
/// A new player (leaving or changing worlds): fresh handlers.
|
||||
pub fn clear(&mut self, sounds: &mut impl Sounds) {
|
||||
for (_, id) in self.loops.drain(..) {
|
||||
sounds.release(id);
|
||||
/// Stop everything (leaving the world).
|
||||
pub fn clear(&mut self, out: &mut impl AmbientOutput) {
|
||||
for (_, l) in self.loops.drain(..) {
|
||||
if let Some(id) = l.id {
|
||||
out.stop(id);
|
||||
}
|
||||
}
|
||||
if let Some((id, _)) = self.underwater_loop.take() {
|
||||
out.stop(id);
|
||||
}
|
||||
for id in self.sub_sounds.drain(..) {
|
||||
out.stop(id);
|
||||
}
|
||||
self.previous_loop = None;
|
||||
self.moodiness = 0.0;
|
||||
self.underwater_delay = 0;
|
||||
self.was_underwater = false;
|
||||
self.was_in_bubble_column = false;
|
||||
self.first_tick = true;
|
||||
}
|
||||
/// `ClientLevel.playLocalSound(x, y, z, sound, source, 1, 1, false)`.
|
||||
fn play_local(&mut self, event: &str, source: usize, at: [f64; 3], sounds: &mut impl Sounds) {
|
||||
let seed = self.random.next_long();
|
||||
let instance = SoundInstance::at(event, source, 1.0, 1.0, LegacyRandom::new(seed), at);
|
||||
sounds.play_instance(instance, false);
|
||||
}
|
||||
/// `LocalPlayer.updateIsUnderwater`, then the `ambientSoundHandlers`
|
||||
/// (underwater, bubble column, biome) as `LocalPlayer.tick` runs them.
|
||||
pub fn tick(&mut self, ctx: &AmbientContext, sounds: &mut impl Sounds) {
|
||||
let old = self.was_underwater;
|
||||
/// `LocalPlayer.updateIsUnderwater` enter/exit sounds and loop, then
|
||||
/// `UnderwaterAmbientSoundHandler`, `BubbleColumn` (absent) and
|
||||
/// `BiomeAmbientSoundsHandler` ticks, then the tickable instances.
|
||||
pub fn tick(&mut self, ctx: &AmbientContext, out: &mut impl AmbientOutput) {
|
||||
let feet = ctx.position;
|
||||
if !self.was_underwater && ctx.underwater {
|
||||
out.play_at("minecraft:ambient.underwater.enter", feet);
|
||||
if let Some((id, _)) = self.underwater_loop.take() {
|
||||
out.stop(id);
|
||||
}
|
||||
self.underwater_loop = out
|
||||
.play_tracked("minecraft:ambient.underwater.loop", 0.0, true)
|
||||
.map(|id| (id, 0));
|
||||
}
|
||||
if self.was_underwater && !ctx.underwater {
|
||||
out.play_at("minecraft:ambient.underwater.exit", feet);
|
||||
}
|
||||
self.was_underwater = ctx.underwater;
|
||||
if !ctx.spectator {
|
||||
if !old && ctx.underwater {
|
||||
self.play_local(
|
||||
"minecraft:ambient.underwater.enter",
|
||||
AMBIENT,
|
||||
ctx.position,
|
||||
sounds,
|
||||
);
|
||||
let instance = SoundInstance::under_liquid_loop(
|
||||
"minecraft:ambient.underwater.loop",
|
||||
SoundEngine::unseeded(),
|
||||
);
|
||||
sounds.play_instance(instance, false);
|
||||
}
|
||||
if old && !ctx.underwater {
|
||||
self.play_local(
|
||||
"minecraft:ambient.underwater.exit",
|
||||
AMBIENT,
|
||||
ctx.position,
|
||||
sounds,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.tick_underwater(sounds);
|
||||
self.tick_bubble_column(ctx, sounds);
|
||||
self.tick_biome(ctx, sounds);
|
||||
self.tick_underwater(ctx, out);
|
||||
self.tick_biome(ctx, out);
|
||||
self.tick_instances(ctx, out);
|
||||
}
|
||||
/// `BubbleColumnAmbientSoundHandler.tick`: entering a column plays its
|
||||
/// inside sound (`LocalPlayer.playSound`, on PLAYERS).
|
||||
fn tick_bubble_column(&mut self, ctx: &AmbientContext, sounds: &mut impl Sounds) {
|
||||
if let Some(drag_down) = ctx.bubble_column {
|
||||
if !self.was_in_bubble_column && !self.first_tick && !ctx.spectator {
|
||||
let event = if drag_down {
|
||||
"minecraft:block.bubble_column.whirlpool_inside"
|
||||
} else {
|
||||
"minecraft:block.bubble_column.upwards_inside"
|
||||
};
|
||||
self.play_local(event, PLAYERS, ctx.position, sounds);
|
||||
}
|
||||
self.was_in_bubble_column = true;
|
||||
} else {
|
||||
self.was_in_bubble_column = false;
|
||||
}
|
||||
self.first_tick = false;
|
||||
}
|
||||
/// `UnderwaterAmbientSoundHandler.tick`.
|
||||
fn tick_underwater(&mut self, sounds: &mut impl Sounds) {
|
||||
fn tick_underwater(&mut self, ctx: &AmbientContext, out: &mut impl AmbientOutput) {
|
||||
self.underwater_delay -= 1;
|
||||
if self.underwater_delay <= 0 && self.was_underwater {
|
||||
if self.underwater_delay <= 0 && ctx.underwater {
|
||||
let rand = self.random.next_float();
|
||||
let sound = if rand < 1.0E-4 {
|
||||
Some("minecraft:ambient.underwater.loop.additions.ultra_rare")
|
||||
@@ -270,121 +230,147 @@ impl Ambience {
|
||||
};
|
||||
if let Some(sound) = sound {
|
||||
self.underwater_delay = 0;
|
||||
let instance = SoundInstance::under_liquid_sub(sound, SoundEngine::unseeded());
|
||||
sounds.play_instance(instance, false);
|
||||
if let Some(id) = out.play_tracked(sound, 1.0, false) {
|
||||
self.sub_sounds.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `BiomeAmbientSoundsHandler.tick`.
|
||||
fn tick_biome(&mut self, ctx: &AmbientContext, sounds: &mut impl Sounds) {
|
||||
let mut kept = Vec::new();
|
||||
for (event, id) in std::mem::take(&mut self.loops) {
|
||||
if sounds.engine().instance(id).is_some_and(|i| i.stopped) {
|
||||
sounds.release(id);
|
||||
} else {
|
||||
kept.push((event, id));
|
||||
fn tick_biome(&mut self, ctx: &AmbientContext, out: &mut impl AmbientOutput) {
|
||||
self.loops.retain(|(_, l)| !l.stopped);
|
||||
let sounds = ctx.sounds;
|
||||
if sounds.r#loop != self.previous_loop {
|
||||
self.previous_loop = sounds.r#loop.clone();
|
||||
for (_, l) in &mut self.loops {
|
||||
l.fade_out();
|
||||
}
|
||||
}
|
||||
self.loops = kept;
|
||||
let current = ctx
|
||||
.sounds
|
||||
.r#loop
|
||||
.as_deref()
|
||||
.map(crate::sound_events::normalize);
|
||||
if current != self.previous_loop {
|
||||
self.previous_loop = current.clone();
|
||||
for (_, id) in &self.loops {
|
||||
if let Some(i) = sounds.engine().instance_mut(*id) {
|
||||
i.fade_out();
|
||||
if let Some(event) = &sounds.r#loop {
|
||||
if !self.loops.iter().any(|(e, _)| e == event) {
|
||||
let id = out.play_tracked(event, 0.0, true);
|
||||
self.loops.push((
|
||||
event.clone(),
|
||||
LoopSound {
|
||||
id,
|
||||
fade: 0,
|
||||
direction: 0,
|
||||
stopped: false,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(event) = current {
|
||||
let id = match self.loops.iter().find(|(e, _)| *e == event) {
|
||||
Some((_, id)) => *id,
|
||||
None => {
|
||||
let instance = SoundInstance::biome_loop(&event, SoundEngine::unseeded());
|
||||
let id = sounds.engine().add(instance, true);
|
||||
sounds.play_id(id);
|
||||
self.loops.push((event, id));
|
||||
id
|
||||
}
|
||||
};
|
||||
if let Some(i) = sounds.engine().instance_mut(id) {
|
||||
i.fade_in();
|
||||
if let Some((_, l)) = self.loops.iter_mut().find(|(e, _)| e == event) {
|
||||
l.fade_in();
|
||||
}
|
||||
}
|
||||
}
|
||||
for (sound, chance) in &ctx.sounds.additions {
|
||||
if self.random.next_double() < *chance {
|
||||
let event = crate::sound_events::normalize(sound);
|
||||
let instance = SoundInstance::for_ambient_addition(&event, SoundEngine::unseeded());
|
||||
sounds.play_instance(instance, false);
|
||||
for (sound, chance) in &sounds.additions {
|
||||
if self.random.next_double() < *chance
|
||||
&& let Some(id) = out.play_tracked(sound, 1.0, false)
|
||||
{
|
||||
// forAmbientAddition: a one-shot nobody ticks.
|
||||
let _ = id;
|
||||
}
|
||||
}
|
||||
let Some(mood) = &ctx.sounds.mood else { return };
|
||||
let Some(mood) = &sounds.mood else { return };
|
||||
let extent = mood.block_search_extent;
|
||||
let span = extent * 2 + 1;
|
||||
let x = ctx.position[0] + f64::from(self.random.next_int_bound(span).unwrap_or(0))
|
||||
- f64::from(extent);
|
||||
let y = ctx.eye_y + f64::from(self.random.next_int_bound(span).unwrap_or(0))
|
||||
- f64::from(extent);
|
||||
let z = ctx.position[2] + f64::from(self.random.next_int_bound(span).unwrap_or(0))
|
||||
- f64::from(extent);
|
||||
let sample = [x.floor() as i32, y.floor() as i32, z.floor() as i32];
|
||||
let mut offset = || self.random.next_int_bound(span).unwrap_or(0) - extent;
|
||||
let (dx, dy, dz) = (offset(), offset(), offset());
|
||||
let sample = [
|
||||
(ctx.position[0] + f64::from(dx)).floor() as i32,
|
||||
(ctx.eye_y + f64::from(dy)).floor() as i32,
|
||||
(ctx.position[2] + f64::from(dz)).floor() as i32,
|
||||
];
|
||||
let (sky, block) = (ctx.light)(sample);
|
||||
if sky > 0 {
|
||||
self.moodiness -= f32::from(sky) / 15.0 * 0.001;
|
||||
} else {
|
||||
self.moodiness -= (i32::from(block) - 1) as f32 / mood.tick_delay as f32;
|
||||
self.moodiness -= (f32::from(block) - 1.0) / mood.tick_delay as f32;
|
||||
}
|
||||
if self.moodiness >= 1.0 {
|
||||
let sample_x = f64::from(sample[0]) + 0.5;
|
||||
let sample_y = f64::from(sample[1]) + 0.5;
|
||||
let sample_z = f64::from(sample[2]) + 0.5;
|
||||
let dx = sample_x - ctx.position[0];
|
||||
let dy = sample_y - ctx.eye_y;
|
||||
let dz = sample_z - ctx.position[2];
|
||||
let distance = (dx * dx + dy * dy + dz * dz).sqrt();
|
||||
let source = distance + mood.offset;
|
||||
let at = [
|
||||
ctx.position[0] + dx / distance * source,
|
||||
ctx.eye_y + dy / distance * source,
|
||||
ctx.position[2] + dz / distance * source,
|
||||
let d = [
|
||||
f64::from(sample[0]) + 0.5 - ctx.position[0],
|
||||
f64::from(sample[1]) + 0.5 - ctx.eye_y,
|
||||
f64::from(sample[2]) + 0.5 - ctx.position[2],
|
||||
];
|
||||
// The mood sound draws from the level random itself.
|
||||
let event = crate::sound_events::normalize(&mood.sound);
|
||||
let instance = SoundInstance::for_ambient_mood(&event, self.random.clone(), at);
|
||||
let id = sounds.engine().add(instance, true);
|
||||
sounds.play_id(id);
|
||||
if let Some(i) = sounds.engine().instance(id) {
|
||||
self.random = i.random.clone();
|
||||
}
|
||||
sounds.release(id);
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
let source = distance + mood.offset;
|
||||
out.play_at(
|
||||
&mood.sound,
|
||||
[
|
||||
ctx.position[0] + d[0] / distance * source,
|
||||
ctx.eye_y + d[1] / distance * source,
|
||||
ctx.position[2] + d[2] / distance * source,
|
||||
],
|
||||
);
|
||||
self.moodiness = 0.0;
|
||||
} else {
|
||||
self.moodiness = self.moodiness.max(0.0);
|
||||
}
|
||||
}
|
||||
/// The tickable instances' own ticks (SoundEngine.tickInGameSound).
|
||||
fn tick_instances(&mut self, ctx: &AmbientContext, out: &mut impl AmbientOutput) {
|
||||
for (_, l) in &mut self.loops {
|
||||
let volume = l.tick();
|
||||
if let Some(id) = l.id {
|
||||
if l.stopped {
|
||||
out.stop(id);
|
||||
} else {
|
||||
out.set_volume(id, volume);
|
||||
}
|
||||
}
|
||||
}
|
||||
// UnderLiquidAmbientSoundInstance: +1 per tick under water, -2 out of it.
|
||||
if let Some((id, fade)) = self.underwater_loop.as_mut() {
|
||||
if *fade >= 0 {
|
||||
*fade += if ctx.underwater { 1 } else { -2 };
|
||||
*fade = (*fade).min(40);
|
||||
out.set_volume(*id, (*fade as f32 / 40.0).clamp(0.0, 1.0));
|
||||
} else {
|
||||
out.stop(*id);
|
||||
self.underwater_loop = None;
|
||||
}
|
||||
}
|
||||
// UnderLiquidSubSound: stops when the player leaves the water.
|
||||
self.sub_sounds.retain(|&id| {
|
||||
let keep = ctx.underwater && out.is_active(id);
|
||||
if !keep {
|
||||
out.stop(id);
|
||||
}
|
||||
keep
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sound_engine::{Bound, TickContext, test_backend::Null};
|
||||
use crate::sound_events::SoundRegistry;
|
||||
|
||||
fn engine() -> SoundEngine {
|
||||
let mut registry = SoundRegistry::default();
|
||||
registry.add_file(
|
||||
"minecraft",
|
||||
br#"{"crimson": {"sounds": ["c"]}, "warped": {"sounds": ["w"]},
|
||||
"ambient.cave": {"sounds": ["a", "b"]},
|
||||
"ambient.underwater.loop": {"sounds": ["u"]},
|
||||
"ambient.underwater.enter": {"sounds": ["e"]},
|
||||
"ambient.underwater.exit": {"sounds": ["x"]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
SoundEngine::new(registry)
|
||||
#[derive(Default)]
|
||||
struct Fake {
|
||||
next: u64,
|
||||
live: HashMap<u64, (String, f32)>,
|
||||
at: Vec<String>,
|
||||
}
|
||||
impl AmbientOutput for Fake {
|
||||
fn play_tracked(&mut self, event: &str, volume: f32, _looping: bool) -> Option<u64> {
|
||||
self.next += 1;
|
||||
self.live.insert(self.next, (event.to_owned(), volume));
|
||||
Some(self.next)
|
||||
}
|
||||
fn set_volume(&mut self, id: u64, volume: f32) {
|
||||
if let Some(v) = self.live.get_mut(&id) {
|
||||
v.1 = volume;
|
||||
}
|
||||
}
|
||||
fn stop(&mut self, id: u64) {
|
||||
self.live.remove(&id);
|
||||
}
|
||||
fn is_active(&self, id: u64) -> bool {
|
||||
self.live.contains_key(&id)
|
||||
}
|
||||
fn play_at(&mut self, event: &str, _at: [f64; 3]) {
|
||||
self.at.push(event.to_owned());
|
||||
}
|
||||
}
|
||||
fn ctx<'a>(
|
||||
sounds: &'a AmbientSounds,
|
||||
@@ -396,59 +382,49 @@ mod tests {
|
||||
position: [0.5, 10.0, 0.5],
|
||||
eye_y: 11.62,
|
||||
underwater,
|
||||
spectator: false,
|
||||
light,
|
||||
bubble_column: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loops_cross_fade_over_forty_ticks() {
|
||||
let (mut engine, mut backend) = (engine(), Null::default());
|
||||
let mut a = Ambience::new(1);
|
||||
let lit = |_: [i32; 3]| (15u8, 0u8);
|
||||
let mut out = Fake::default();
|
||||
let dark = |_: [i32; 3]| (15u8, 0u8);
|
||||
let crimson = AmbientSounds {
|
||||
r#loop: Some("minecraft:crimson".into()),
|
||||
r#loop: Some("crimson".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let warped = AmbientSounds {
|
||||
r#loop: Some("minecraft:warped".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let tick =
|
||||
|a: &mut Ambience, s: &AmbientSounds, engine: &mut SoundEngine, backend: &mut Null| {
|
||||
a.tick(
|
||||
&ctx(s, false, &lit),
|
||||
&mut Bound {
|
||||
engine: &mut *engine,
|
||||
backend: &mut *backend,
|
||||
},
|
||||
);
|
||||
engine.tick(false, &TickContext::default(), backend);
|
||||
};
|
||||
for _ in 0..40 {
|
||||
tick(&mut a, &crimson, &mut engine, &mut backend);
|
||||
a.tick(&ctx(&crimson, false, &dark), &mut out);
|
||||
}
|
||||
assert_eq!(out.live.values().map(|v| v.1).collect::<Vec<_>>(), [1.0]);
|
||||
let warped = AmbientSounds {
|
||||
r#loop: Some("warped".into()),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..20 {
|
||||
tick(&mut a, &warped, &mut engine, &mut backend);
|
||||
a.tick(&ctx(&warped, false, &dark), &mut out);
|
||||
}
|
||||
let volumes: Vec<f32> = a
|
||||
.loops
|
||||
.iter()
|
||||
.map(|(_, id)| engine.instance(*id).unwrap().volume)
|
||||
.collect();
|
||||
assert_eq!(volumes, [0.5, 0.5]);
|
||||
let mut volumes: Vec<_> = out.live.values().map(|v| (v.0.clone(), v.1)).collect();
|
||||
volumes.sort_by(|x, y| x.0.cmp(&y.0));
|
||||
assert_eq!(
|
||||
volumes,
|
||||
[("crimson".to_owned(), 0.5), ("warped".to_owned(), 0.5)]
|
||||
);
|
||||
for _ in 0..30 {
|
||||
tick(&mut a, &warped, &mut engine, &mut backend);
|
||||
a.tick(&ctx(&warped, false, &dark), &mut out);
|
||||
}
|
||||
assert_eq!(a.loops.len(), 1);
|
||||
assert_eq!(
|
||||
out.live.values().map(|v| v.0.as_str()).collect::<Vec<_>>(),
|
||||
["warped"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_builds_in_the_dark_and_plays_a_cave_sound() {
|
||||
let (mut engine, mut backend) = (engine(), Null::default());
|
||||
engine.listening = true;
|
||||
let mut a = Ambience::new(2);
|
||||
let mut out = Fake::default();
|
||||
let cave = |_: [i32; 3]| (0u8, 0u8);
|
||||
let sounds = AmbientSounds {
|
||||
mood: Some(Mood {
|
||||
@@ -459,19 +435,31 @@ mod tests {
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
// Block light 0: +1/6000 per tick (float), so the sound plays around tick 6000.
|
||||
let mut ticks = 0;
|
||||
while engine.notifications.is_empty() && ticks < 7000 {
|
||||
a.tick(
|
||||
&ctx(&sounds, false, &cave),
|
||||
&mut Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
},
|
||||
);
|
||||
while out.at.is_empty() && ticks < 7000 {
|
||||
a.tick(&ctx(&sounds, false, &cave), &mut out);
|
||||
ticks += 1;
|
||||
}
|
||||
assert!((5990..=6010).contains(&ticks), "{ticks}");
|
||||
assert_eq!(engine.notifications[0].identifier, "minecraft:ambient.cave");
|
||||
assert_eq!(out.at, ["minecraft:ambient.cave"]);
|
||||
assert_eq!(a.moodiness, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entering_water_plays_enter_and_fades_the_loop_in() {
|
||||
let mut a = Ambience::new(3);
|
||||
let mut out = Fake::default();
|
||||
let lit = |_: [i32; 3]| (15u8, 0u8);
|
||||
let none = AmbientSounds::default();
|
||||
a.tick(&ctx(&none, true, &lit), &mut out);
|
||||
assert_eq!(out.at, ["minecraft:ambient.underwater.enter"]);
|
||||
let id = a.underwater_loop.unwrap().0;
|
||||
assert_eq!(out.live[&id].1, 1.0 / 40.0);
|
||||
a.tick(&ctx(&none, false, &lit), &mut out);
|
||||
assert_eq!(out.at.last().unwrap(), "minecraft:ambient.underwater.exit");
|
||||
// Fade 1 -> -1 stops on the next tick.
|
||||
a.tick(&ctx(&none, false, &lit), &mut out);
|
||||
assert!(!out.live.contains_key(&id));
|
||||
}
|
||||
}
|
||||
@@ -1,464 +0,0 @@
|
||||
//! Atlas sprite sources (`SpriteSourceList`): the `atlases/*.json`
|
||||
//! definitions listing which sprites an atlas holds. `directory`, `single`,
|
||||
//! `filter`, `unstitch` and `paletted_permutations` sources run in order
|
||||
//! against the resource tree; a later source replaces an earlier sprite of
|
||||
//! the same id and a filter removes the matching ones.
|
||||
use crate::packs::pattern::Pattern;
|
||||
use horizon_assets::palette::apply_palette;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// One region of an `unstitch` source, in units of the divisors.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Region {
|
||||
pub sprite: String,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum SpriteSource {
|
||||
Directory {
|
||||
source: String,
|
||||
prefix: String,
|
||||
},
|
||||
Single {
|
||||
resource: String,
|
||||
sprite: Option<String>,
|
||||
},
|
||||
Filter {
|
||||
namespace: Option<Pattern>,
|
||||
path: Option<Pattern>,
|
||||
},
|
||||
Unstitch {
|
||||
resource: String,
|
||||
regions: Vec<Region>,
|
||||
divisor_x: f64,
|
||||
divisor_y: f64,
|
||||
},
|
||||
PalettedPermutations {
|
||||
textures: Vec<String>,
|
||||
palette_key: String,
|
||||
permutations: BTreeMap<String, String>,
|
||||
separator: String,
|
||||
},
|
||||
}
|
||||
/// Where a listed sprite's pixels come from.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SpriteOrigin {
|
||||
/// A whole texture (`textures/<path>.png`), by texture identifier.
|
||||
File(String),
|
||||
/// `Unstitcher.RegionInstance`: part of a texture.
|
||||
Region {
|
||||
texture: String,
|
||||
region: Region,
|
||||
divisor_x: f64,
|
||||
divisor_y: f64,
|
||||
},
|
||||
/// `PalettedSpriteSupplier`: a texture with its colours remapped.
|
||||
Paletted {
|
||||
texture: String,
|
||||
palette_key: String,
|
||||
palette: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// `Identifier.parse` default namespace.
|
||||
pub fn qualified(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
fn string(v: &Value, key: &str) -> Option<String> {
|
||||
v.get(key)?.as_str().map(str::to_owned)
|
||||
}
|
||||
fn number(v: &Value, key: &str, default: f64) -> Option<f64> {
|
||||
match v.get(key) {
|
||||
None => Some(default),
|
||||
Some(n) => n.as_f64(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `SpriteSources.FILE_CODEC`: the `sources` list of an atlas definition.
|
||||
/// A malformed source fails the whole file, as the codec does.
|
||||
pub fn decode(text: &str) -> Option<Vec<SpriteSource>> {
|
||||
let json: Value = serde_json::from_str(text).ok()?;
|
||||
json.get("sources")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(decode_source)
|
||||
.collect()
|
||||
}
|
||||
fn decode_source(s: &Value) -> Option<SpriteSource> {
|
||||
Some(match qualified(s.get("type")?.as_str()?).as_str() {
|
||||
"minecraft:directory" => SpriteSource::Directory {
|
||||
source: string(s, "source")?,
|
||||
prefix: string(s, "prefix")?,
|
||||
},
|
||||
"minecraft:single" => SpriteSource::Single {
|
||||
resource: qualified(&string(s, "resource")?),
|
||||
sprite: match s.get("sprite") {
|
||||
None => None,
|
||||
Some(v) => Some(qualified(v.as_str()?)),
|
||||
},
|
||||
},
|
||||
"minecraft:filter" => {
|
||||
let p = s.get("pattern")?;
|
||||
let pattern = |key: &str| -> Option<Option<Pattern>> {
|
||||
match p.get(key) {
|
||||
None => Some(None),
|
||||
Some(v) => Pattern::new(v.as_str()?).map(Some),
|
||||
}
|
||||
};
|
||||
SpriteSource::Filter {
|
||||
namespace: pattern("namespace")?,
|
||||
path: pattern("path")?,
|
||||
}
|
||||
}
|
||||
"minecraft:unstitch" => {
|
||||
let regions = s
|
||||
.get("regions")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(|r| {
|
||||
Some(Region {
|
||||
sprite: qualified(&string(r, "sprite")?),
|
||||
x: r.get("x")?.as_f64()?,
|
||||
y: r.get("y")?.as_f64()?,
|
||||
width: r.get("width")?.as_f64()?,
|
||||
height: r.get("height")?.as_f64()?,
|
||||
})
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
if regions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
SpriteSource::Unstitch {
|
||||
resource: qualified(&string(s, "resource")?),
|
||||
regions,
|
||||
divisor_x: number(s, "divisor_x", 1.0)?,
|
||||
divisor_y: number(s, "divisor_y", 1.0)?,
|
||||
}
|
||||
}
|
||||
"minecraft:paletted_permutations" => SpriteSource::PalettedPermutations {
|
||||
textures: s
|
||||
.get("textures")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(|t| t.as_str().map(qualified))
|
||||
.collect::<Option<_>>()?,
|
||||
palette_key: qualified(&string(s, "palette_key")?),
|
||||
permutations: s
|
||||
.get("permutations")?
|
||||
.as_object()?
|
||||
.iter()
|
||||
.map(|(k, v)| Some((k.clone(), qualified(v.as_str()?))))
|
||||
.collect::<Option<_>>()?,
|
||||
separator: match s.get("separator") {
|
||||
None => "_".to_owned(),
|
||||
Some(v) => v.as_str()?.to_owned(),
|
||||
},
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The texture files of a resource tree: `namespace:path` for every
|
||||
/// `assets/<namespace>/textures/<path>.png`.
|
||||
pub fn list_textures(root: &Path) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
|
||||
return out;
|
||||
};
|
||||
for ns in namespaces.flatten() {
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
let base = ns.path().join("textures");
|
||||
let mut stack = vec![base.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else if p.extension().is_some_and(|x| x == "png")
|
||||
&& let Ok(rel) = p.strip_prefix(&base)
|
||||
{
|
||||
let rel = rel.with_extension("").to_string_lossy().replace('\\', "/");
|
||||
out.push(format!("{namespace}:{rel}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
/// The file of a texture identifier.
|
||||
pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
|
||||
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
|
||||
root.join(format!("assets/{namespace}/{directory}/{path}.png"))
|
||||
}
|
||||
|
||||
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
|
||||
/// `textures` are the available texture identifiers ([`list_textures`]).
|
||||
pub fn list(sources: &[SpriteSource], textures: &[String]) -> BTreeMap<String, SpriteOrigin> {
|
||||
let mut out = BTreeMap::new();
|
||||
let exists = |t: &str| textures.binary_search_by(|x| x.as_str().cmp(t)).is_ok();
|
||||
for source in sources {
|
||||
match source {
|
||||
SpriteSource::Directory { source, prefix } => {
|
||||
// FileToIdConverter("textures/" + source, ".png").
|
||||
let dir = format!("{source}/");
|
||||
for t in textures {
|
||||
let (namespace, path) = t.split_once(':').unwrap_or(("minecraft", t));
|
||||
if let Some(rest) = path.strip_prefix(&dir) {
|
||||
out.insert(
|
||||
format!("{namespace}:{prefix}{rest}"),
|
||||
SpriteOrigin::File(t.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
SpriteSource::Single { resource, sprite } => {
|
||||
if exists(resource) {
|
||||
out.insert(
|
||||
sprite.clone().unwrap_or_else(|| resource.clone()),
|
||||
SpriteOrigin::File(resource.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
SpriteSource::Filter { namespace, path } => {
|
||||
out.retain(|id: &String, _| {
|
||||
let (ns, p) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
!(namespace.as_ref().is_none_or(|m| m.is_match(ns))
|
||||
&& path.as_ref().is_none_or(|m| m.is_match(p)))
|
||||
});
|
||||
}
|
||||
SpriteSource::Unstitch {
|
||||
resource,
|
||||
regions,
|
||||
divisor_x,
|
||||
divisor_y,
|
||||
} => {
|
||||
if exists(resource) {
|
||||
for region in regions {
|
||||
out.insert(
|
||||
region.sprite.clone(),
|
||||
SpriteOrigin::Region {
|
||||
texture: resource.clone(),
|
||||
region: region.clone(),
|
||||
divisor_x: *divisor_x,
|
||||
divisor_y: *divisor_y,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
SpriteSource::PalettedPermutations {
|
||||
textures: bases,
|
||||
palette_key,
|
||||
permutations,
|
||||
separator,
|
||||
} => {
|
||||
for base in bases {
|
||||
if !exists(base) {
|
||||
continue;
|
||||
}
|
||||
for (suffix, palette) in permutations {
|
||||
out.insert(
|
||||
format!("{base}{separator}{suffix}"),
|
||||
SpriteOrigin::Paletted {
|
||||
texture: base.clone(),
|
||||
palette_key: palette_key.clone(),
|
||||
palette: palette.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An RGBA image.
|
||||
pub type Image = (u32, u32, Vec<u8>);
|
||||
|
||||
fn argb(rgba: &[u8]) -> Vec<u32> {
|
||||
rgba.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| u32::from_be_bytes([p[3], p[0], p[1], p[2]]))
|
||||
.collect()
|
||||
}
|
||||
/// The pixels of a generated sprite (`Region` or `Paletted`); `None` for a
|
||||
/// plain file, which loads as itself.
|
||||
pub fn generate(
|
||||
origin: &SpriteOrigin,
|
||||
load: &dyn Fn(&str, &str) -> Option<Image>,
|
||||
) -> Option<Image> {
|
||||
match origin {
|
||||
SpriteOrigin::File(_) => None,
|
||||
SpriteOrigin::Region {
|
||||
texture,
|
||||
region,
|
||||
divisor_x,
|
||||
divisor_y,
|
||||
} => {
|
||||
let (w, h, pixels) = load(texture, "textures")?;
|
||||
let x_scale = f64::from(w) / divisor_x;
|
||||
let y_scale = f64::from(h) / divisor_y;
|
||||
let x = (region.x * x_scale).floor() as i64;
|
||||
let y = (region.y * y_scale).floor() as i64;
|
||||
let width = (region.width * x_scale).floor() as i64;
|
||||
let height = (region.height * y_scale).floor() as i64;
|
||||
if width <= 0 || height <= 0 {
|
||||
return None;
|
||||
}
|
||||
let mut out = vec![0u8; (width * height * 4) as usize];
|
||||
for row in 0..height {
|
||||
for col in 0..width {
|
||||
let (sx, sy) = (x + col, y + row);
|
||||
if sx < 0 || sy < 0 || sx >= i64::from(w) || sy >= i64::from(h) {
|
||||
continue;
|
||||
}
|
||||
let s = ((sy * i64::from(w) + sx) * 4) as usize;
|
||||
let d = ((row * width + col) * 4) as usize;
|
||||
out[d..d + 4].copy_from_slice(&pixels[s..s + 4]);
|
||||
}
|
||||
}
|
||||
Some((width as u32, height as u32, out))
|
||||
}
|
||||
SpriteOrigin::Paletted {
|
||||
texture,
|
||||
palette_key,
|
||||
palette,
|
||||
} => {
|
||||
let (w, h, pixels) = load(texture, "textures")?;
|
||||
let (_, _, key) = load(palette_key, "textures/palettes")?;
|
||||
let (_, _, target) = load(palette, "textures/palettes")?;
|
||||
let mut mapped = argb(&pixels);
|
||||
apply_palette(&mut mapped, &argb(&key), &argb(&target))?;
|
||||
let out = mapped
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
let [a, r, g, b] = p.to_be_bytes();
|
||||
[r, g, b, a]
|
||||
})
|
||||
.collect();
|
||||
Some((w, h, out))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every atlas definition of a resource tree (`atlases/*.json` in every
|
||||
/// namespace), by atlas id.
|
||||
pub fn atlases(root: &Path) -> BTreeMap<String, Vec<SpriteSource>> {
|
||||
let mut out = BTreeMap::new();
|
||||
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
|
||||
return out;
|
||||
};
|
||||
for ns in namespaces.flatten() {
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
let Ok(files) = fs::read_dir(ns.path().join("atlases")) else {
|
||||
continue;
|
||||
};
|
||||
for f in files.flatten() {
|
||||
let path = f.path();
|
||||
if path.extension().is_none_or(|e| e != "json") {
|
||||
continue;
|
||||
}
|
||||
let name = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
if let Some(sources) = fs::read_to_string(&path).ok().and_then(|t| decode(&t)) {
|
||||
out.insert(format!("{namespace}:{name}"), sources);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn textures() -> Vec<String> {
|
||||
let mut t: Vec<String> = [
|
||||
"minecraft:item/apple",
|
||||
"minecraft:item/stick",
|
||||
"minecraft:trims/items/boots_trim",
|
||||
"minecraft:block/stone",
|
||||
"minecraft:entity/sheet",
|
||||
]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
t.sort();
|
||||
t
|
||||
}
|
||||
#[test]
|
||||
fn sources_run_in_order() {
|
||||
let sources = decode(
|
||||
r#"{"sources":[
|
||||
{"type":"directory","source":"item","prefix":"item/"},
|
||||
{"type":"minecraft:single","resource":"block/stone","sprite":"item/stone"},
|
||||
{"type":"filter","pattern":{"path":"stick"}},
|
||||
{"type":"unstitch","resource":"entity/sheet","divisor_x":2,"regions":[{"sprite":"a","x":1,"y":0,"width":1,"height":1}]},
|
||||
{"type":"paletted_permutations","textures":["trims/items/boots_trim"],"palette_key":"trims/color_palettes/trim_palette","permutations":{"quartz":"trims/color_palettes/quartz"}}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let listed = list(&sources, &textures());
|
||||
let ids: Vec<&str> = listed.keys().map(String::as_str).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
[
|
||||
"minecraft:a",
|
||||
"minecraft:item/apple",
|
||||
"minecraft:item/stone",
|
||||
"minecraft:trims/items/boots_trim_quartz"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
listed["minecraft:item/stone"],
|
||||
SpriteOrigin::File("minecraft:block/stone".into())
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn malformed_sources_fail_the_file() {
|
||||
assert!(decode(r#"{"sources":[{"type":"directory","source":"item"}]}"#).is_none());
|
||||
assert!(
|
||||
decode(r#"{"sources":[{"type":"unstitch","resource":"x","regions":[]}]}"#).is_none()
|
||||
);
|
||||
assert!(decode(r#"{"sources":[{"type":"horizon"}]}"#).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn unstitch_cuts_by_divisor() {
|
||||
let pixels: Vec<u8> = (0..16u8).flat_map(|i| [i, i, i, 255]).collect();
|
||||
let load = |_: &str, _: &str| Some((4u32, 4u32, pixels.clone()));
|
||||
let origin = SpriteOrigin::Region {
|
||||
texture: "t".into(),
|
||||
region: Region {
|
||||
sprite: "s".into(),
|
||||
x: 1.0,
|
||||
y: 1.0,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
},
|
||||
divisor_x: 2.0,
|
||||
divisor_y: 2.0,
|
||||
};
|
||||
let (w, h, out) = generate(&origin, &load).unwrap();
|
||||
assert_eq!((w, h), (2, 2));
|
||||
assert_eq!(out[0], 10);
|
||||
assert_eq!(out[4], 11);
|
||||
assert_eq!(out[8], 14);
|
||||
}
|
||||
}
|
||||
+435
-451
@@ -1,16 +1,9 @@
|
||||
//! Sound playback: vanilla's `SoundEngine` bookkeeping (`sound_engine`) over a
|
||||
//! small mixer standing in for OpenAL: one channel per source with its gain,
|
||||
//! pitch, linear distance attenuation (`AL_LINEAR_DISTANCE`, reference 0,
|
||||
//! rolloff 1), looping, relative flag and source state, Ogg/Vorbis decoding
|
||||
//! (streams on a worker thread) and stereo panning.
|
||||
//! Sound playback: `sounds.json` events with weighted variants, Ogg/Vorbis
|
||||
//! decoding, and a small mixer with vanilla's linear distance attenuation
|
||||
//! (`SoundInstance.Attenuation.LINEAR`, default range 16 blocks) and stereo panning.
|
||||
use crate::Result;
|
||||
use crate::sound_engine::{
|
||||
Attenuation, Backend, Bound, ChannelId, InstanceId, PlayResult, SoundEngine, SoundInstance,
|
||||
Sounds, TickContext,
|
||||
};
|
||||
use crate::sound_events::{Sound, SoundRegistry};
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
@@ -18,32 +11,46 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
/// An OpenAL source's state.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum State {
|
||||
Initial,
|
||||
Playing,
|
||||
Paused,
|
||||
Stopped,
|
||||
}
|
||||
/// An OpenAL source.
|
||||
struct Channel {
|
||||
samples: Option<(Arc<Vec<f32>>, u32)>,
|
||||
position: f64,
|
||||
state: State,
|
||||
gain: f32,
|
||||
#[derive(Clone, Debug)]
|
||||
struct Variant {
|
||||
name: String,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
/// `AL_LINEAR_DISTANCE` max distance, or no attenuation.
|
||||
linear: Option<f32>,
|
||||
looping: bool,
|
||||
at: [f32; 3],
|
||||
relative: bool,
|
||||
weight: u32,
|
||||
attenuation: f32,
|
||||
is_event: bool,
|
||||
}
|
||||
struct Voice {
|
||||
samples: Arc<Vec<f32>>,
|
||||
position: f64,
|
||||
step: f64,
|
||||
left: f32,
|
||||
right: f32,
|
||||
/// The sound event and `SoundSource` index (what `/stopsound` matches).
|
||||
event: Arc<str>,
|
||||
source: usize,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Mixer {
|
||||
channels: HashMap<ChannelId, Channel>,
|
||||
listener: [f64; 3],
|
||||
listener_yaw: f32,
|
||||
voices: Vec<Voice>,
|
||||
/// The streaming music track (`SoundSource.MUSIC`), kept apart from the
|
||||
/// voice cap and scaled by `music_gain` (MusicManager's fade).
|
||||
music: Option<Voice>,
|
||||
music_gain: f32,
|
||||
/// Tickable sound instances the game keeps a handle to (ambient loops).
|
||||
tracked: Vec<Tracked>,
|
||||
}
|
||||
struct Tracked {
|
||||
id: u64,
|
||||
voice: Voice,
|
||||
looping: bool,
|
||||
volume: f32,
|
||||
}
|
||||
/// The current music track: decoding on a worker until the voice exists.
|
||||
struct MusicTrack {
|
||||
event: String,
|
||||
/// Set by the decoder thread once the voice is in the mixer (or failed).
|
||||
ready: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
|
||||
/// what the subtitle overlay needs.
|
||||
@@ -51,31 +58,30 @@ struct Mixer {
|
||||
pub struct Heard {
|
||||
/// The `subtitle` translation key of the sound event.
|
||||
pub subtitle: String,
|
||||
/// The instance's position (relative sounds report their raw coordinates).
|
||||
/// Where it played (None = relative to the listener, e.g. UI sounds).
|
||||
pub at: Option<[f64; 3]>,
|
||||
/// `SoundEngine.getRange`: infinite for relative and non-attenuated sounds.
|
||||
pub range: f32,
|
||||
pub looping: bool,
|
||||
}
|
||||
/// The mixer side of the engine (`Backend`).
|
||||
pub struct MixerBackend {
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
root: PathBuf,
|
||||
cache: HashMap<String, Option<(Arc<Vec<f32>>, u32)>>,
|
||||
next: ChannelId,
|
||||
live: bool,
|
||||
}
|
||||
pub struct Audio {
|
||||
_stream: Option<cpal::Stream>,
|
||||
pub engine: SoundEngine,
|
||||
backend: MixerBackend,
|
||||
/// `subtitle` keys of `sounds.json` events.
|
||||
subtitles: HashMap<String, String>,
|
||||
/// Record `Heard` sounds (the subtitle overlay is listening).
|
||||
pub record_heard: bool,
|
||||
heard: Vec<Heard>,
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
rate: u32,
|
||||
root: PathBuf,
|
||||
events: HashMap<String, Vec<Variant>>,
|
||||
cache: HashMap<String, Option<(Arc<Vec<f32>>, u32)>>,
|
||||
seed: u64,
|
||||
pub listener: [f64; 3],
|
||||
pub listener_yaw: f32,
|
||||
pub master: f32,
|
||||
/// Seeds of positional plays (the client level random's `nextLong`).
|
||||
random: LegacyRandom,
|
||||
music: Option<MusicTrack>,
|
||||
next_tracked: u64,
|
||||
/// A `SoundSource` index the next sound plays on (`/playsound`).
|
||||
source_override: Option<usize>,
|
||||
/// Positional sounds played since the last drain, kept while the world
|
||||
@@ -124,247 +130,75 @@ fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
}
|
||||
Some((mono, rate))
|
||||
}
|
||||
impl MixerBackend {
|
||||
fn with(&self, channel: ChannelId, f: impl FnOnce(&mut Channel)) {
|
||||
if let Ok(mut m) = self.mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
f(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Backend for MixerBackend {
|
||||
fn create(&mut self, _streaming: bool) -> Option<ChannelId> {
|
||||
self.next += 1;
|
||||
let id = self.next;
|
||||
self.mixer.lock().ok()?.channels.insert(
|
||||
id,
|
||||
Channel {
|
||||
samples: None,
|
||||
position: 0.0,
|
||||
state: State::Initial,
|
||||
gain: 1.0,
|
||||
pitch: 1.0,
|
||||
linear: None,
|
||||
looping: false,
|
||||
at: [0.0; 3],
|
||||
relative: false,
|
||||
},
|
||||
);
|
||||
Some(id)
|
||||
}
|
||||
fn destroy(&mut self, channel: ChannelId) {
|
||||
if let Ok(mut m) = self.mixer.lock() {
|
||||
m.channels.remove(&channel);
|
||||
}
|
||||
}
|
||||
fn set_pitch(&mut self, channel: ChannelId, pitch: f32) {
|
||||
self.with(channel, |c| c.pitch = pitch);
|
||||
}
|
||||
fn set_volume(&mut self, channel: ChannelId, volume: f32) {
|
||||
self.with(channel, |c| c.gain = volume);
|
||||
}
|
||||
fn set_attenuation(&mut self, channel: ChannelId, linear: Option<f32>) {
|
||||
self.with(channel, |c| c.linear = linear);
|
||||
}
|
||||
fn set_looping(&mut self, channel: ChannelId, looping: bool) {
|
||||
self.with(channel, |c| c.looping = looping);
|
||||
}
|
||||
fn set_position(&mut self, channel: ChannelId, p: [f64; 3]) {
|
||||
self.with(channel, |c| c.at = [p[0] as f32, p[1] as f32, p[2] as f32]);
|
||||
}
|
||||
fn set_relative(&mut self, channel: ChannelId, relative: bool) {
|
||||
self.with(channel, |c| c.relative = relative);
|
||||
}
|
||||
/// `getCompleteBuffer` (decoded now, cached) or `getStream` (decoded on a
|
||||
/// worker), then attach and `alSourcePlay`. A sound that cannot load
|
||||
/// leaves its source initial, as a failed buffer future does in vanilla.
|
||||
fn start(&mut self, channel: ChannelId, sound: &Sound, looping: bool) {
|
||||
if !self.live {
|
||||
return;
|
||||
}
|
||||
let (namespace, path) = sound.path();
|
||||
let file = self.root.join("assets").join(namespace).join(&path);
|
||||
let attach = move |mixer: &Mutex<Mixer>, samples: Option<(Arc<Vec<f32>>, u32)>| {
|
||||
if let Some(samples) = samples
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
c.samples = Some(samples);
|
||||
c.position = 0.0;
|
||||
c.state = State::Playing;
|
||||
}
|
||||
};
|
||||
if sound.stream {
|
||||
// A streamed sound loops by restarting its stream.
|
||||
let mixer = self.mixer.clone();
|
||||
std::thread::spawn(move || {
|
||||
let samples = decode(&file).map(|(s, r)| (Arc::new(s), r));
|
||||
if looping
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
c.looping = true;
|
||||
}
|
||||
attach(&mixer, samples);
|
||||
});
|
||||
} else {
|
||||
let samples = self
|
||||
.cache
|
||||
.entry(format!("{namespace}:{path}"))
|
||||
.or_insert_with(|| decode(&file).map(|(s, r)| (Arc::new(s), r)))
|
||||
.clone();
|
||||
attach(&self.mixer, samples);
|
||||
}
|
||||
}
|
||||
fn stop(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| {
|
||||
if c.state != State::Initial {
|
||||
c.state = State::Stopped;
|
||||
}
|
||||
});
|
||||
}
|
||||
fn pause(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| {
|
||||
if c.state == State::Playing {
|
||||
c.state = State::Paused;
|
||||
}
|
||||
});
|
||||
}
|
||||
fn unpause(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| {
|
||||
if c.state == State::Paused {
|
||||
c.state = State::Playing;
|
||||
}
|
||||
});
|
||||
}
|
||||
fn stopped(&mut self, channel: ChannelId) -> bool {
|
||||
self.mixer
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.channels
|
||||
.get(&channel)
|
||||
.is_none_or(|c| c.state == State::Stopped)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
/// The gain OpenAL applies for distance (`AL_LINEAR_DISTANCE` with reference
|
||||
/// distance 0 and rolloff 1: the distance clamps to the max distance).
|
||||
fn distance_gain(linear: Option<f32>, distance: f32) -> f32 {
|
||||
match linear {
|
||||
None => 1.0,
|
||||
Some(max) if max > 0.0 => (1.0 - distance.min(max) / max).clamp(0.0, 1.0),
|
||||
Some(_) => 0.0,
|
||||
}
|
||||
}
|
||||
fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
data.fill(0.0);
|
||||
let listener = mixer.listener;
|
||||
let yaw = mixer.listener_yaw.to_radians();
|
||||
// Gains and pans per source for this buffer.
|
||||
let mut voices: Vec<(ChannelId, f32, f32, f64)> = Vec::new();
|
||||
for (id, c) in &mixer.channels {
|
||||
let Some((_, source_rate)) = &c.samples else {
|
||||
continue;
|
||||
};
|
||||
if c.state != State::Playing {
|
||||
continue;
|
||||
}
|
||||
let d = if c.relative {
|
||||
[f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])]
|
||||
} else {
|
||||
[
|
||||
f64::from(c.at[0]) - listener[0],
|
||||
f64::from(c.at[1]) - listener[1],
|
||||
f64::from(c.at[2]) - listener[2],
|
||||
]
|
||||
};
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
|
||||
let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
|
||||
// The listener's right vector for a Minecraft yaw.
|
||||
let side = if c.relative {
|
||||
(d[0] as f32) / distance.max(0.001)
|
||||
} else {
|
||||
(-(yaw.cos()) as f64 * d[0] - (yaw.sin()) as f64 * d[2]) as f32 / distance.max(0.001)
|
||||
};
|
||||
let pan = side.clamp(-1.0, 1.0);
|
||||
let left = gain * (1.0 - pan.max(0.0) * 0.6);
|
||||
let right = gain * (1.0 + pan.min(0.0) * 0.6);
|
||||
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
|
||||
voices.push((*id, left, right, step));
|
||||
}
|
||||
for (id, left, right, step) in voices {
|
||||
let Some(c) = mixer.channels.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
let Some((samples, _)) = c.samples.clone() else {
|
||||
continue;
|
||||
};
|
||||
for frame in data.chunks_mut(channels) {
|
||||
let mut i = c.position as usize;
|
||||
if i + 1 >= samples.len() {
|
||||
if c.looping && samples.len() > 1 {
|
||||
c.position = 0.0;
|
||||
i = 0;
|
||||
} else {
|
||||
c.state = State::Stopped;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let t = (c.position - i as f64) as f32;
|
||||
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
|
||||
if channels >= 2 {
|
||||
frame[0] += s * left;
|
||||
frame[1] += s * right;
|
||||
} else {
|
||||
frame[0] += s * (left + right) / 2.0;
|
||||
}
|
||||
c.position += step;
|
||||
}
|
||||
}
|
||||
for s in data.iter_mut() {
|
||||
*s = s.clamp(-1.0, 1.0);
|
||||
}
|
||||
}
|
||||
impl Audio {
|
||||
pub fn new(root: &Path) -> Self {
|
||||
let registry = SoundRegistry::load(root);
|
||||
let mixer = Arc::new(Mutex::new(Mixer::default()));
|
||||
let (stream, live) = match Self::open(mixer.clone()) {
|
||||
Ok(v) => (Some(v), true),
|
||||
let mut events = HashMap::new();
|
||||
let mut subtitles = HashMap::new();
|
||||
if let Ok(text) = fs::read(root.join("assets/minecraft/sounds.json"))
|
||||
&& let Ok(Value::Object(map)) = serde_json::from_slice::<Value>(&text)
|
||||
{
|
||||
for (name, def) in map {
|
||||
let variants = def["sounds"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|s| match s {
|
||||
Value::String(n) => Variant {
|
||||
name: n.clone(),
|
||||
volume: 1.0,
|
||||
pitch: 1.0,
|
||||
weight: 1,
|
||||
attenuation: 16.0,
|
||||
is_event: false,
|
||||
},
|
||||
_ => Variant {
|
||||
name: s["name"].as_str().unwrap_or("").to_owned(),
|
||||
volume: s["volume"].as_f64().unwrap_or(1.0) as f32,
|
||||
pitch: s["pitch"].as_f64().unwrap_or(1.0) as f32,
|
||||
weight: s["weight"].as_u64().unwrap_or(1) as u32,
|
||||
attenuation: s["attenuation_distance"].as_f64().unwrap_or(16.0) as f32,
|
||||
is_event: s["type"].as_str() == Some("event"),
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(key) = def["subtitle"].as_str() {
|
||||
subtitles.insert(format!("minecraft:{name}"), key.to_owned());
|
||||
}
|
||||
events.insert(format!("minecraft:{name}"), variants);
|
||||
}
|
||||
}
|
||||
let mixer = Arc::new(Mutex::new(Mixer {
|
||||
music_gain: 1.0,
|
||||
..Mixer::default()
|
||||
}));
|
||||
let (stream, rate) = match Self::open(mixer.clone()) {
|
||||
Ok(v) => (Some(v.0), v.1),
|
||||
Err(e) => {
|
||||
eprintln!("Horizon: audio unavailable: {e}");
|
||||
(None, false)
|
||||
(None, 48_000)
|
||||
}
|
||||
};
|
||||
let mut engine = SoundEngine::new(registry);
|
||||
engine.loaded = live;
|
||||
Self {
|
||||
_stream: stream,
|
||||
engine,
|
||||
backend: MixerBackend {
|
||||
mixer,
|
||||
root: root.to_owned(),
|
||||
cache: HashMap::new(),
|
||||
next: 0,
|
||||
live,
|
||||
},
|
||||
subtitles,
|
||||
record_heard: false,
|
||||
heard: Vec::new(),
|
||||
mixer,
|
||||
rate,
|
||||
root: root.to_owned(),
|
||||
events,
|
||||
cache: HashMap::new(),
|
||||
seed: 0x5DEE_CE66,
|
||||
listener: [0.0; 3],
|
||||
listener_yaw: 0.0,
|
||||
master: 1.0,
|
||||
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
|
||||
music: None,
|
||||
next_tracked: 0,
|
||||
source_override: None,
|
||||
capture: None,
|
||||
}
|
||||
}
|
||||
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<cpal::Stream> {
|
||||
// Test runs (harnesses, agents) launch the game on a virtual screen;
|
||||
// keep them off the user's speakers.
|
||||
if std::env::var_os("HORIZON_NO_AUDIO").is_some() {
|
||||
return Err("audio output disabled by HORIZON_NO_AUDIO".into());
|
||||
}
|
||||
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<(cpal::Stream, u32)> {
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_output_device()
|
||||
@@ -375,110 +209,269 @@ impl Audio {
|
||||
let stream = device.build_output_stream(
|
||||
&config.into(),
|
||||
move |data: &mut [f32], _| {
|
||||
if let Ok(mut m) = mixer.lock() {
|
||||
mix(&mut m, data, channels, rate);
|
||||
} else {
|
||||
data.fill(0.0);
|
||||
data.fill(0.0);
|
||||
let Ok(mut mixer) = mixer.lock() else { return };
|
||||
for frame in data.chunks_mut(channels) {
|
||||
let (mut l, mut r) = (0.0f32, 0.0f32);
|
||||
for v in mixer.voices.iter_mut() {
|
||||
let i = v.position as usize;
|
||||
if i + 1 >= v.samples.len() {
|
||||
continue;
|
||||
}
|
||||
let t = (v.position - i as f64) as f32;
|
||||
let s = v.samples[i] * (1.0 - t) + v.samples[i + 1] * t;
|
||||
l += s * v.left;
|
||||
r += s * v.right;
|
||||
v.position += v.step;
|
||||
}
|
||||
for t in mixer.tracked.iter_mut() {
|
||||
let v = &mut t.voice;
|
||||
let mut i = v.position as usize;
|
||||
if t.looping && i + 1 >= v.samples.len() {
|
||||
v.position = 0.0;
|
||||
i = 0;
|
||||
}
|
||||
if i + 1 < v.samples.len() {
|
||||
let f = (v.position - i as f64) as f32;
|
||||
let s = (v.samples[i] * (1.0 - f) + v.samples[i + 1] * f) * t.volume;
|
||||
l += s * v.left;
|
||||
r += s * v.right;
|
||||
v.position += v.step;
|
||||
}
|
||||
}
|
||||
let gain = mixer.music_gain;
|
||||
if let Some(v) = mixer.music.as_mut() {
|
||||
let i = v.position as usize;
|
||||
if i + 1 < v.samples.len() {
|
||||
let t = (v.position - i as f64) as f32;
|
||||
let s = (v.samples[i] * (1.0 - t) + v.samples[i + 1] * t) * gain;
|
||||
l += s * v.left;
|
||||
r += s * v.right;
|
||||
v.position += v.step;
|
||||
}
|
||||
}
|
||||
if channels >= 2 {
|
||||
frame[0] = l.clamp(-1.0, 1.0);
|
||||
frame[1] = r.clamp(-1.0, 1.0);
|
||||
} else {
|
||||
frame[0] = ((l + r) / 2.0).clamp(-1.0, 1.0);
|
||||
}
|
||||
}
|
||||
mixer
|
||||
.voices
|
||||
.retain(|v| (v.position as usize) + 1 < v.samples.len());
|
||||
mixer.tracked.retain(|t| {
|
||||
t.looping || (t.voice.position as usize) + 1 < t.voice.samples.len()
|
||||
});
|
||||
if mixer
|
||||
.music
|
||||
.as_ref()
|
||||
.is_some_and(|v| (v.position as usize) + 1 >= v.samples.len())
|
||||
{
|
||||
mixer.music = None;
|
||||
}
|
||||
},
|
||||
|e| eprintln!("Horizon: audio stream error: {e}"),
|
||||
None,
|
||||
)?;
|
||||
stream.play()?;
|
||||
Ok(stream)
|
||||
Ok((stream, rate))
|
||||
}
|
||||
fn bound(&mut self) -> Bound<'_, MixerBackend> {
|
||||
Bound {
|
||||
engine: &mut self.engine,
|
||||
backend: &mut self.backend,
|
||||
}
|
||||
fn random(&mut self) -> u64 {
|
||||
self.seed ^= self.seed << 13;
|
||||
self.seed ^= self.seed >> 7;
|
||||
self.seed ^= self.seed << 17;
|
||||
self.seed
|
||||
}
|
||||
/// `SoundManager.updateSource`: the camera position and yaw.
|
||||
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32) {
|
||||
self.listener = position;
|
||||
self.listener_yaw = yaw;
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
m.listener = position;
|
||||
m.listener_yaw = yaw;
|
||||
fn resolve(&mut self, event: &str, depth: u32) -> Option<Variant> {
|
||||
let variants = self.events.get(event)?.clone();
|
||||
let total: u32 = variants.iter().map(|v| v.weight).sum();
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
/// Sync the volume options, refreshing the categories that changed
|
||||
/// (the option sliders' `refreshCategoryVolume`).
|
||||
fn sync_volumes(&mut self) {
|
||||
let mut volumes = [self.master; 11];
|
||||
for (i, v) in volumes.iter_mut().enumerate().skip(1) {
|
||||
*v = crate::settings::source_volume(i);
|
||||
}
|
||||
for source in 0..11 {
|
||||
if self.engine.source_volumes[source] != volumes[source] {
|
||||
self.engine.source_volumes[source] = volumes[source];
|
||||
self.engine
|
||||
.refresh_category_volume(source, &mut self.backend);
|
||||
let mut pick = (self.random() % u64::from(total)) as u32;
|
||||
let chosen = variants.into_iter().find(|v| {
|
||||
if pick < v.weight {
|
||||
true
|
||||
} else {
|
||||
pick -= v.weight;
|
||||
false
|
||||
}
|
||||
})?;
|
||||
if chosen.is_event && depth < 4 {
|
||||
let name = if chosen.name.contains(':') {
|
||||
chosen.name.clone()
|
||||
} else {
|
||||
format!("minecraft:{}", chosen.name)
|
||||
};
|
||||
let mut inner = self.resolve(&name, depth + 1)?;
|
||||
inner.volume *= chosen.volume;
|
||||
inner.pitch *= chosen.pitch;
|
||||
return Some(inner);
|
||||
}
|
||||
self.engine.listening = self.record_heard;
|
||||
Some(chosen)
|
||||
}
|
||||
/// `SoundManager.tick(paused)`, once per client tick.
|
||||
pub fn tick(&mut self, paused: bool, ctx: &TickContext) {
|
||||
self.sync_volumes();
|
||||
self.engine.tick(paused, ctx, &mut self.backend);
|
||||
}
|
||||
/// `SoundManager.pauseAllExcept(MUSIC, UI)`.
|
||||
pub fn pause(&mut self) {
|
||||
self.engine.pause_all_except(
|
||||
&[crate::sound_engine::MUSIC, crate::sound_engine::UI],
|
||||
&mut self.backend,
|
||||
);
|
||||
}
|
||||
/// `SoundManager.resume`.
|
||||
pub fn resume(&mut self) {
|
||||
self.engine.resume(&mut self.backend);
|
||||
}
|
||||
/// `SoundManager.stop()`: every sound.
|
||||
pub fn stop_all(&mut self) {
|
||||
self.engine.stop_all(&mut self.backend);
|
||||
}
|
||||
/// Play a sound event at a world position, as `ClientLevel.playLocalSound`
|
||||
/// (a `SimpleSoundInstance` seeded from the level random), or relative to
|
||||
/// the listener without attenuation (`None`).
|
||||
/// Play a sound event at a world position (None = non-positional UI sound).
|
||||
pub fn play(&mut self, event: &str, at: Option<[f64; 3]>, volume: f32, pitch: f32) {
|
||||
let seed = self.random.next_long();
|
||||
self.play_seeded(event, at, volume, pitch, seed);
|
||||
}
|
||||
/// `play` with the seed a sound packet carries.
|
||||
pub fn play_seeded(
|
||||
&mut self,
|
||||
event: &str,
|
||||
at: Option<[f64; 3]>,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
seed: i64,
|
||||
) {
|
||||
if let (Some(capture), Some(at)) = (self.capture.as_mut(), at) {
|
||||
capture.push((event.to_owned(), at, volume, pitch));
|
||||
}
|
||||
self.sync_volumes();
|
||||
let event = crate::sound_events::normalize(event);
|
||||
let Some(variant) = self.resolve(event, 0) else {
|
||||
return;
|
||||
};
|
||||
if self.record_heard
|
||||
&& let Some(key) = self.subtitles.get(event)
|
||||
{
|
||||
let range = if at.is_some() {
|
||||
variant.attenuation * volume.max(1.0)
|
||||
} else {
|
||||
f32::INFINITY
|
||||
};
|
||||
self.heard.push(Heard {
|
||||
subtitle: key.clone(),
|
||||
at,
|
||||
range,
|
||||
looping: false,
|
||||
});
|
||||
}
|
||||
if self._stream.is_none() {
|
||||
return;
|
||||
}
|
||||
let file = variant.name.trim_start_matches("minecraft:").to_owned();
|
||||
let entry = self.cache.entry(file.clone()).or_insert_with(|| {
|
||||
decode(
|
||||
&self
|
||||
.root
|
||||
.join(format!("assets/minecraft/sounds/{file}.ogg")),
|
||||
)
|
||||
.map(|(s, r)| (Arc::new(s), r))
|
||||
});
|
||||
let Some((samples, rate)) = entry.clone() else {
|
||||
return;
|
||||
};
|
||||
let source = self
|
||||
.source_override
|
||||
.unwrap_or_else(|| crate::settings::sound_source(&event));
|
||||
let random = LegacyRandom::new(seed);
|
||||
let instance = match at {
|
||||
Some(at) => SoundInstance::at(&event, source, volume, pitch, random, at),
|
||||
None => SoundInstance::new(
|
||||
&event,
|
||||
.unwrap_or_else(|| crate::settings::sound_source(event));
|
||||
let volume = (volume * variant.volume).clamp(0.0, 1.0)
|
||||
* self.master
|
||||
* crate::settings::source_volume(source);
|
||||
let pitch = (pitch * variant.pitch).clamp(0.5, 2.0);
|
||||
let (mut left, mut right) = (volume, volume);
|
||||
if let Some(p) = at {
|
||||
let d = [
|
||||
p[0] - self.listener[0],
|
||||
p[1] - self.listener[1],
|
||||
p[2] - self.listener[2],
|
||||
];
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
|
||||
let range = variant.attenuation * volume.max(1.0);
|
||||
let gain = (1.0 - distance / range).clamp(0.0, 1.0);
|
||||
if gain <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let yaw = self.listener_yaw.to_radians();
|
||||
// Right vector of a Minecraft yaw.
|
||||
let side = (-(yaw.cos()) as f64 * d[0] - (yaw.sin()) as f64 * d[2]) as f32
|
||||
/ distance.max(0.001);
|
||||
let pan = side.clamp(-1.0, 1.0);
|
||||
left = volume * gain * (1.0 - pan.max(0.0) * 0.6);
|
||||
right = volume * gain * (1.0 + pan.min(0.0) * 0.6);
|
||||
}
|
||||
let step = f64::from(rate) / f64::from(self.rate) * f64::from(pitch);
|
||||
if let Ok(mut m) = self.mixer.lock() {
|
||||
if m.voices.len() > 64 {
|
||||
m.voices.remove(0);
|
||||
}
|
||||
m.voices.push(Voice {
|
||||
samples,
|
||||
position: 0.0,
|
||||
step,
|
||||
left,
|
||||
right,
|
||||
event: event.into(),
|
||||
source,
|
||||
volume,
|
||||
pitch,
|
||||
random,
|
||||
false,
|
||||
0,
|
||||
Attenuation::None,
|
||||
[0.0; 3],
|
||||
true,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `SoundManager.play` of a music instance (`SimpleSoundInstance.forMusic`:
|
||||
/// non-positional, streamed). Decoding runs on a worker thread; the track
|
||||
/// counts as active from now on. Returns false when nothing can play.
|
||||
pub fn play_music(&mut self, event: &str) -> bool {
|
||||
self.stop_music();
|
||||
if self._stream.is_none() {
|
||||
return false;
|
||||
}
|
||||
let Some(variant) = self.resolve(event, 0) else {
|
||||
return false;
|
||||
};
|
||||
self.play_instance(instance, false);
|
||||
let path = self.root.join(format!(
|
||||
"assets/minecraft/sounds/{}.ogg",
|
||||
variant.name.trim_start_matches("minecraft:")
|
||||
));
|
||||
let volume =
|
||||
variant.volume.clamp(0.0, 1.0) * self.master * crate::settings::event_volume(event);
|
||||
let step_scale = f64::from(variant.pitch.clamp(0.5, 2.0)) / f64::from(self.rate);
|
||||
let ready = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let (mixer, done) = (self.mixer.clone(), ready.clone());
|
||||
let music_event = event.to_owned();
|
||||
std::thread::spawn(move || {
|
||||
if let Some((samples, rate)) = decode(&path)
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& !done.load(std::sync::atomic::Ordering::Acquire)
|
||||
{
|
||||
m.music = Some(Voice {
|
||||
samples: Arc::new(samples),
|
||||
position: 0.0,
|
||||
step: f64::from(rate) * step_scale,
|
||||
left: volume,
|
||||
right: volume,
|
||||
event: music_event.as_str().into(),
|
||||
source: 1,
|
||||
});
|
||||
}
|
||||
done.store(true, std::sync::atomic::Ordering::Release);
|
||||
});
|
||||
self.music = Some(MusicTrack {
|
||||
event: event.to_owned(),
|
||||
ready,
|
||||
});
|
||||
true
|
||||
}
|
||||
/// `SoundManager.stop` of the music instance.
|
||||
pub fn stop_music(&mut self) {
|
||||
if let Some(track) = self.music.take() {
|
||||
// A decoder still running sees the flag and drops its result.
|
||||
track
|
||||
.ready
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
if let Ok(mut m) = self.mixer.lock() {
|
||||
m.music = None;
|
||||
}
|
||||
}
|
||||
/// `SoundManager.isActive`: decoding or still sounding.
|
||||
pub fn music_active(&mut self) -> bool {
|
||||
let Some(track) = &self.music else {
|
||||
return false;
|
||||
};
|
||||
if !track.ready.load(std::sync::atomic::Ordering::Acquire) {
|
||||
return true;
|
||||
}
|
||||
let playing = self.mixer.lock().is_ok_and(|m| m.music.is_some());
|
||||
if !playing {
|
||||
self.music = None;
|
||||
}
|
||||
playing
|
||||
}
|
||||
/// The event of the current music track.
|
||||
pub fn music_event(&self) -> Option<&str> {
|
||||
self.music.as_ref().map(|t| t.event.as_str())
|
||||
}
|
||||
/// Every sound event id (for command completion).
|
||||
pub fn event_names(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.events.keys().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
/// Play a sound on a given `SoundSource` index (`/playsound`).
|
||||
pub fn play_on(
|
||||
@@ -493,111 +486,102 @@ impl Audio {
|
||||
self.play(event, at, volume, pitch);
|
||||
self.source_override = None;
|
||||
}
|
||||
/// `SimpleSoundInstance.forJukeboxSong` at a jukebox; the handle stays
|
||||
/// until `release`.
|
||||
pub fn play_jukebox_song(&mut self, event: &str, pos: [i32; 3]) -> InstanceId {
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
let event = crate::sound_events::normalize(event);
|
||||
let instance = SoundInstance::for_jukebox_song(&event, at, SoundEngine::unseeded());
|
||||
self.play_instance(instance, true).0
|
||||
}
|
||||
/// Stop and drop a held instance.
|
||||
pub fn stop_and_release(&mut self, id: InstanceId) {
|
||||
self.stop_instance(id);
|
||||
self.release(id);
|
||||
}
|
||||
/// Every sound event id (for command completion).
|
||||
pub fn event_names(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.engine.registry.names().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
/// `ClientboundStopSoundPacket`: stop the playing sounds of an event
|
||||
/// and/or a source.
|
||||
pub fn stop_sounds(&mut self, event: Option<&str>, source: Option<usize>) {
|
||||
let event = event.map(crate::sound_events::normalize);
|
||||
self.engine
|
||||
.stop_matching(event.as_deref(), source, &mut self.backend);
|
||||
let matches = |v: &Voice| {
|
||||
event.is_none_or(|e| &*v.event == e) && source.is_none_or(|s| v.source == s)
|
||||
};
|
||||
if let Ok(mut m) = self.mixer.lock() {
|
||||
m.voices.retain(|v| !matches(v));
|
||||
m.tracked.retain(|t| !matches(&t.voice));
|
||||
if m.music.as_ref().is_some_and(matches) {
|
||||
m.music = None;
|
||||
}
|
||||
}
|
||||
if self
|
||||
.music
|
||||
.as_ref()
|
||||
.is_some_and(|t| event.is_none_or(|e| t.event == e) && source.is_none_or(|s| s == 1))
|
||||
{
|
||||
self.music = None;
|
||||
}
|
||||
}
|
||||
/// The sounds played since the last call, for the subtitle overlay.
|
||||
pub fn take_heard(&mut self) -> Vec<Heard> {
|
||||
std::mem::take(&mut self.engine.notifications)
|
||||
.into_iter()
|
||||
.filter_map(|n| {
|
||||
Some(Heard {
|
||||
subtitle: n.subtitle?,
|
||||
at: Some(n.position),
|
||||
range: n.range,
|
||||
looping: n.looping,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
std::mem::take(&mut self.heard)
|
||||
}
|
||||
}
|
||||
impl Sounds for Audio {
|
||||
fn engine(&mut self) -> &mut SoundEngine {
|
||||
&mut self.engine
|
||||
}
|
||||
fn play_instance(&mut self, instance: SoundInstance, retain: bool) -> (InstanceId, PlayResult) {
|
||||
self.sync_volumes();
|
||||
self.bound().play_instance(instance, retain)
|
||||
}
|
||||
fn play_id(&mut self, id: InstanceId) -> PlayResult {
|
||||
self.sync_volumes();
|
||||
self.bound().play_id(id)
|
||||
}
|
||||
fn stop_instance(&mut self, id: InstanceId) {
|
||||
self.bound().stop_instance(id);
|
||||
}
|
||||
fn update_category_volume(&mut self, source: usize, gain: f32) {
|
||||
self.bound().update_category_volume(source, gain);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn channel(samples: Vec<f32>, linear: Option<f32>, at: [f32; 3], looping: bool) -> Channel {
|
||||
Channel {
|
||||
samples: Some((Arc::new(samples), 48_000)),
|
||||
position: 0.0,
|
||||
state: State::Playing,
|
||||
gain: 1.0,
|
||||
pitch: 1.0,
|
||||
linear,
|
||||
looping,
|
||||
at,
|
||||
relative: false,
|
||||
/// `SoundManager.updateCategoryVolume(MUSIC, gain)`.
|
||||
pub fn set_music_gain(&mut self, gain: f32) {
|
||||
if let Ok(mut m) = self.mixer.lock() {
|
||||
m.music_gain = gain;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_attenuation_reaches_zero_at_the_max_distance() {
|
||||
assert_eq!(distance_gain(None, 100.0), 1.0);
|
||||
assert_eq!(distance_gain(Some(16.0), 0.0), 1.0);
|
||||
assert_eq!(distance_gain(Some(16.0), 8.0), 0.5);
|
||||
assert_eq!(distance_gain(Some(16.0), 16.0), 0.0);
|
||||
assert_eq!(distance_gain(Some(16.0), 40.0), 0.0);
|
||||
/// Play a relative (non-positional) tickable sound instance the caller
|
||||
/// keeps a handle to: ambient loops (`looping`) and sub-sounds.
|
||||
pub fn play_tracked(&mut self, event: &str, volume: f32, looping: bool) -> Option<u64> {
|
||||
let variant = self.resolve(event, 0)?;
|
||||
if self.record_heard
|
||||
&& let Some(key) = self.subtitles.get(event)
|
||||
{
|
||||
self.heard.push(Heard {
|
||||
subtitle: key.clone(),
|
||||
at: None,
|
||||
range: f32::INFINITY,
|
||||
looping,
|
||||
});
|
||||
}
|
||||
self._stream.as_ref()?;
|
||||
let file = variant.name.trim_start_matches("minecraft:").to_owned();
|
||||
let root = &self.root;
|
||||
let (samples, rate) = self
|
||||
.cache
|
||||
.entry(file.clone())
|
||||
.or_insert_with(|| {
|
||||
decode(&root.join(format!("assets/minecraft/sounds/{file}.ogg")))
|
||||
.map(|(s, r)| (Arc::new(s), r))
|
||||
})
|
||||
.clone()?;
|
||||
let gain =
|
||||
variant.volume.clamp(0.0, 1.0) * self.master * crate::settings::event_volume(event);
|
||||
let step =
|
||||
f64::from(rate) / f64::from(self.rate) * f64::from(variant.pitch.clamp(0.5, 2.0));
|
||||
self.next_tracked += 1;
|
||||
let id = self.next_tracked;
|
||||
let mut m = self.mixer.lock().ok()?;
|
||||
m.tracked.push(Tracked {
|
||||
id,
|
||||
voice: Voice {
|
||||
samples,
|
||||
position: 0.0,
|
||||
step,
|
||||
left: gain,
|
||||
right: gain,
|
||||
event: event.into(),
|
||||
source: crate::settings::sound_source(event),
|
||||
},
|
||||
looping,
|
||||
volume: volume.clamp(0.0, 1.0),
|
||||
});
|
||||
Some(id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sources_play_attenuated_then_stop_or_loop() {
|
||||
let mut m = Mixer::default();
|
||||
m.channels
|
||||
.insert(1, channel(vec![1.0; 4], Some(16.0), [8.0, 0.0, 0.0], false));
|
||||
m.channels
|
||||
.insert(2, channel(vec![1.0; 4], None, [0.0; 3], true));
|
||||
let mut data = [0.0f32; 16];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
assert_eq!(m.channels[&1].state, State::Stopped);
|
||||
assert_eq!(m.channels[&2].state, State::Playing);
|
||||
// Mono sum of the two at the first frame: the far one is half as loud.
|
||||
let first = data[0] + data[1];
|
||||
assert!(first > 1.4 && first < 3.1, "{first}");
|
||||
/// A tracked instance's volume (`AbstractTickableSoundInstance.volume`).
|
||||
pub fn set_tracked_volume(&mut self, id: u64, volume: f32) {
|
||||
if let Ok(mut m) = self.mixer.lock()
|
||||
&& let Some(t) = m.tracked.iter_mut().find(|t| t.id == id)
|
||||
{
|
||||
t.volume = volume.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
pub fn stop_tracked(&mut self, id: u64) {
|
||||
if let Ok(mut m) = self.mixer.lock() {
|
||||
m.tracked.retain(|t| t.id != id);
|
||||
}
|
||||
}
|
||||
/// Still sounding (a finished one-shot is gone).
|
||||
pub fn tracked_active(&self, id: u64) -> bool {
|
||||
self.mixer
|
||||
.lock()
|
||||
.is_ok_and(|m| m.tracked.iter().any(|t| t.id == id))
|
||||
}
|
||||
}
|
||||
@@ -148,8 +148,7 @@ pub fn update_auto_jump<W: AutoJumpWorld>(
|
||||
let right_end = seg_end.add_vector(right_offset);
|
||||
let mut obstacle = NO_OBSTACLE;
|
||||
for b in world.collision_boxes(test_box)? {
|
||||
if intersects_segment(b, left_begin, left_end)
|
||||
|| intersects_segment(b, right_begin, right_end)
|
||||
if intersects_segment(b, left_begin, left_end) || intersects_segment(b, right_begin, right_end)
|
||||
{
|
||||
obstacle = b.max_position().y as f32;
|
||||
let obstacle_pos = BlockPos::containing(b.center());
|
||||
@@ -176,10 +175,7 @@ pub fn update_auto_jump<W: AutoJumpWorld>(
|
||||
}
|
||||
if obstacle != NO_OBSTACLE {
|
||||
let y_delta = (f64::from(obstacle) - p.position.y) as f32;
|
||||
// Negated as in LocalPlayer.updateAutoJump, so a NaN delta still jumps.
|
||||
#[allow(clippy::neg_cmp_op_on_partial_ord)]
|
||||
let jumps = !(y_delta <= 0.5) && !(y_delta > jump_height);
|
||||
if jumps {
|
||||
if !(y_delta <= 0.5) && !(y_delta > jump_height) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
@@ -201,11 +197,7 @@ mod tests {
|
||||
}
|
||||
fn collision_boxes(&mut self, bounds: Aabb) -> Result<Vec<Aabb>, ()> {
|
||||
let b = Aabb::new(Vec3::new(1.0, 0.0, -5.0), Vec3::new(2.0, 1.0, 5.0));
|
||||
Ok(if b.intersects(bounds) {
|
||||
vec![b]
|
||||
} else {
|
||||
Vec::new()
|
||||
})
|
||||
Ok(if b.intersects(bounds) { vec![b] } else { Vec::new() })
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -17,16 +17,11 @@ pub struct SpawnEntry {
|
||||
pub weight: u32,
|
||||
pub min: u32,
|
||||
pub max: u32,
|
||||
/// A `ConstantInt` count (sampling draws nothing).
|
||||
pub constant: bool,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BiomeInfo {
|
||||
/// `natural_mob_spawns` by category (creature, monster, ...).
|
||||
pub spawns: HashMap<String, Vec<SpawnEntry>>,
|
||||
/// `natural_mob_spawns` spawn costs by entity type.
|
||||
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
|
||||
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
|
||||
pub creature_probability: f32,
|
||||
pub name: String,
|
||||
pub temperature: f32,
|
||||
@@ -108,26 +103,37 @@ impl Biomes {
|
||||
_ => GrassModifier::None,
|
||||
};
|
||||
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
|
||||
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
|
||||
// A modifier object ({argument, modifier: overlay}) or the plain value;
|
||||
// the overlay over the dimension's empty default is the biome's value.
|
||||
let natural = if natural["argument"].is_object() {
|
||||
&natural["argument"]
|
||||
} else {
|
||||
natural
|
||||
};
|
||||
let categories = &natural["spawns_by_category"];
|
||||
let categories = &attributes["minecraft:gameplay/natural_mob_spawns"]["argument"]["spawns_by_category"];
|
||||
for (category, entries) in categories.as_object().into_iter().flatten() {
|
||||
let list = crate::mobs::spawn_selection::parse_entries(entries);
|
||||
let list = entries
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let (min, max) = match &e["count"] {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64().unwrap_or(1) as u32;
|
||||
(n, n)
|
||||
}
|
||||
c => (
|
||||
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
),
|
||||
};
|
||||
Some(SpawnEntry {
|
||||
entity: e["type"].as_str()?.to_owned(),
|
||||
weight: e["weight"].as_u64().unwrap_or(1) as u32,
|
||||
min,
|
||||
max,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
spawns.insert(category.clone(), list);
|
||||
}
|
||||
list.push(BiomeInfo {
|
||||
spawns,
|
||||
spawn_costs: crate::mobs::spawn_selection::parse_costs(&natural["spawn_costs"]),
|
||||
creature_probability:
|
||||
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
|
||||
.as_f64()
|
||||
.unwrap_or(0.1) as f32,
|
||||
creature_probability: v["creature_spawn_probability"].as_f64().unwrap_or(0.1)
|
||||
as f32,
|
||||
temperature,
|
||||
downfall,
|
||||
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
|
||||
|
||||
@@ -1434,92 +1434,6 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// `BeaconRenderer.submit`: for each beam section (ARGB colour, height) the
|
||||
/// rotating solid beam and its translucent glow, the last section 2048
|
||||
/// blocks tall. `origin` is the beacon block's corner relative to the camera,
|
||||
/// `time` the animation time (game time mod 40 plus partial tick) and `scale`
|
||||
/// the radius scale. The sprite's v range rides in the light bytes (the
|
||||
/// entity shader wraps v for layer 6).
|
||||
pub fn beacon_beam(
|
||||
origin: Vec3,
|
||||
sections: &[(u32, i32)],
|
||||
time: f32,
|
||||
scale: f32,
|
||||
s: Rect,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let q = |v: f32| (v * 65535.0).round().clamp(0.0, 65535.0) as u32;
|
||||
let wrap = q(s.v0) | q(s.v1 - s.v0) << 16;
|
||||
let o = origin + Vec3::new(0.5, 0.0, 0.5);
|
||||
let mut start = 0i32;
|
||||
for (i, &(color, height)) in sections.iter().enumerate() {
|
||||
let height = if i == sections.len() - 1 {
|
||||
2048
|
||||
} else {
|
||||
height
|
||||
};
|
||||
let end = start + height;
|
||||
let scroll = if height < 0 { time } else { -time };
|
||||
let v_off = frac(scroll * 0.2 - (scroll * 0.1).floor());
|
||||
let abgr =
|
||||
|a: u32| a << 24 | (color & 255) << 16 | (color >> 8 & 255) << 8 | (color >> 16 & 255);
|
||||
// Solid beam: radius 0.2 * scale, rotated with time.
|
||||
let r = 0.2 * scale;
|
||||
let rot = glam::Mat3::from_rotation_y((time * 2.25 - 45.0).to_radians());
|
||||
let corners = [
|
||||
rot * Vec3::new(0.0, 0.0, r),
|
||||
rot * Vec3::new(r, 0.0, 0.0),
|
||||
rot * Vec3::new(-r, 0.0, 0.0),
|
||||
rot * Vec3::new(0.0, 0.0, -r),
|
||||
];
|
||||
let v2 = -1.0 + v_off;
|
||||
let v1 = height as f32 * (0.5 / r) + v2;
|
||||
beam_part(out, o, start, end, corners, [v1, v2], abgr(255), s, wrap);
|
||||
// Glow: radius 0.25 * scale, translucent.
|
||||
let g = 0.25 * scale;
|
||||
let corners = [
|
||||
Vec3::new(-g, 0.0, -g),
|
||||
Vec3::new(g, 0.0, -g),
|
||||
Vec3::new(-g, 0.0, g),
|
||||
Vec3::new(g, 0.0, g),
|
||||
];
|
||||
let v1 = height as f32 + v2;
|
||||
beam_part(out, o, start, end, corners, [v1, v2], abgr(32), s, wrap);
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
/// `BeaconRenderer.renderPart`: four quads between the corners (wn, en, ws, es).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn beam_part(
|
||||
out: &mut Vec<Vertex>,
|
||||
o: Vec3,
|
||||
start: i32,
|
||||
end: i32,
|
||||
[wn, en, ws, es]: [Vec3; 4],
|
||||
[v1, v2]: [f32; 2],
|
||||
color: u32,
|
||||
s: Rect,
|
||||
wrap: u32,
|
||||
) {
|
||||
for (a, b) in [(wn, en), (es, ws), (en, es), (ws, wn)] {
|
||||
let at = |c: Vec3, y: i32| o + c + Vec3::new(0.0, y as f32, 0.0);
|
||||
for (p, uv) in [
|
||||
(at(a, end), [s.u1, v1]),
|
||||
(at(a, start), [s.u1, v2]),
|
||||
(at(b, start), [s.u0, v2]),
|
||||
(at(b, end), [s.u0, v1]),
|
||||
] {
|
||||
out.push(Vertex {
|
||||
pos: p.to_array(),
|
||||
uv,
|
||||
layer: 6,
|
||||
color,
|
||||
light: wrap,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1646,3 +1560,89 @@ mod tests {
|
||||
assert_eq!(out[2].uv, [0.5, 0.5]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `BeaconRenderer.submit`: for each beam section (ARGB colour, height) the
|
||||
/// rotating solid beam and its translucent glow, the last section 2048
|
||||
/// blocks tall. `origin` is the beacon block's corner relative to the camera,
|
||||
/// `time` the animation time (game time mod 40 plus partial tick) and `scale`
|
||||
/// the radius scale. The sprite's v range rides in the light bytes (the
|
||||
/// entity shader wraps v for layer 6).
|
||||
pub fn beacon_beam(
|
||||
origin: Vec3,
|
||||
sections: &[(u32, i32)],
|
||||
time: f32,
|
||||
scale: f32,
|
||||
s: Rect,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let q = |v: f32| (v * 65535.0).round().clamp(0.0, 65535.0) as u32;
|
||||
let wrap = q(s.v0) | q(s.v1 - s.v0) << 16;
|
||||
let o = origin + Vec3::new(0.5, 0.0, 0.5);
|
||||
let mut start = 0i32;
|
||||
for (i, &(color, height)) in sections.iter().enumerate() {
|
||||
let height = if i == sections.len() - 1 {
|
||||
2048
|
||||
} else {
|
||||
height
|
||||
};
|
||||
let end = start + height;
|
||||
let scroll = if height < 0 { time } else { -time };
|
||||
let v_off = frac(scroll * 0.2 - (scroll * 0.1).floor());
|
||||
let abgr =
|
||||
|a: u32| a << 24 | (color & 255) << 16 | (color >> 8 & 255) << 8 | (color >> 16 & 255);
|
||||
// Solid beam: radius 0.2 * scale, rotated with time.
|
||||
let r = 0.2 * scale;
|
||||
let rot = glam::Mat3::from_rotation_y((time * 2.25 - 45.0).to_radians());
|
||||
let corners = [
|
||||
rot * Vec3::new(0.0, 0.0, r),
|
||||
rot * Vec3::new(r, 0.0, 0.0),
|
||||
rot * Vec3::new(-r, 0.0, 0.0),
|
||||
rot * Vec3::new(0.0, 0.0, -r),
|
||||
];
|
||||
let v2 = -1.0 + v_off;
|
||||
let v1 = height as f32 * (0.5 / r) + v2;
|
||||
beam_part(out, o, start, end, corners, [v1, v2], abgr(255), s, wrap);
|
||||
// Glow: radius 0.25 * scale, translucent.
|
||||
let g = 0.25 * scale;
|
||||
let corners = [
|
||||
Vec3::new(-g, 0.0, -g),
|
||||
Vec3::new(g, 0.0, -g),
|
||||
Vec3::new(-g, 0.0, g),
|
||||
Vec3::new(g, 0.0, g),
|
||||
];
|
||||
let v1 = height as f32 + v2;
|
||||
beam_part(out, o, start, end, corners, [v1, v2], abgr(32), s, wrap);
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
/// `BeaconRenderer.renderPart`: four quads between the corners (wn, en, ws, es).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn beam_part(
|
||||
out: &mut Vec<Vertex>,
|
||||
o: Vec3,
|
||||
start: i32,
|
||||
end: i32,
|
||||
[wn, en, ws, es]: [Vec3; 4],
|
||||
[v1, v2]: [f32; 2],
|
||||
color: u32,
|
||||
s: Rect,
|
||||
wrap: u32,
|
||||
) {
|
||||
for (a, b) in [(wn, en), (es, ws), (en, es), (ws, wn)] {
|
||||
let at = |c: Vec3, y: i32| o + c + Vec3::new(0.0, y as f32, 0.0);
|
||||
for (p, uv) in [
|
||||
(at(a, end), [s.u1, v1]),
|
||||
(at(a, start), [s.u1, v2]),
|
||||
(at(b, start), [s.u0, v2]),
|
||||
(at(b, end), [s.u0, v1]),
|
||||
] {
|
||||
out.push(Vertex {
|
||||
pos: p.to_array(),
|
||||
uv,
|
||||
layer: 6,
|
||||
color,
|
||||
light: wrap,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,11 +57,7 @@ pub fn wrap_degrees(angle: f32) -> f32 {
|
||||
}
|
||||
/// `Mth.clamp(float, float, float)`.
|
||||
pub fn clamp(value: f32, min: f32, max: f32) -> f32 {
|
||||
if value < min {
|
||||
min
|
||||
} else {
|
||||
java_min(value, max)
|
||||
}
|
||||
if value < min { min } else { java_min(value, max) }
|
||||
}
|
||||
|
||||
/// JOML's float trigonometry (`org.joml.Math` without fast math).
|
||||
@@ -134,9 +130,12 @@ pub mod joml {
|
||||
let yw = self.y * self.w;
|
||||
let k = 1.0 / (xx + yy + zz + ww);
|
||||
[
|
||||
(xx - yy - zz + ww) * k * x + (2.0 * (xy - zw) * k * y + 2.0 * (xz + yw) * k * z),
|
||||
2.0 * (xy + zw) * k * x + ((yy - xx - zz + ww) * k * y + 2.0 * (yz - xw) * k * z),
|
||||
2.0 * (xz - yw) * k * x + (2.0 * (yz + xw) * k * y + (zz - xx - yy + ww) * k * z),
|
||||
(xx - yy - zz + ww) * k * x
|
||||
+ (2.0 * (xy - zw) * k * y + 2.0 * (xz + yw) * k * z),
|
||||
2.0 * (xy + zw) * k * x
|
||||
+ ((yy - xx - zz + ww) * k * y + 2.0 * (yz - xw) * k * z),
|
||||
2.0 * (xz - yw) * k * x
|
||||
+ (2.0 * (yz + xw) * k * y + (zz - xx - yy + ww) * k * z),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -569,7 +568,8 @@ impl Camera {
|
||||
if panoramic {
|
||||
return 90.0;
|
||||
}
|
||||
let fov = fov_option as f32 * lerp(partial, self.old_fov_modifier, self.fov_modifier);
|
||||
let fov =
|
||||
fov_option as f32 * lerp(partial, self.old_fov_modifier, self.fov_modifier);
|
||||
modify_fov_based_on_death_or_fluid(partial, fov, effect_scale, dead, fluid)
|
||||
}
|
||||
/// `Camera.calculateHudFov`.
|
||||
@@ -704,11 +704,7 @@ pub fn bob_view(state: &BobState, pose: &mut Mat4) {
|
||||
0.0,
|
||||
);
|
||||
rotate_degrees(pose, 2, trig::sin(f64::from(walk * PI)) * bob * 3.0);
|
||||
rotate_degrees(
|
||||
pose,
|
||||
0,
|
||||
(trig::cos(f64::from(walk * PI - 0.2)) * bob).abs() * 5.0,
|
||||
);
|
||||
rotate_degrees(pose, 0, (trig::cos(f64::from(walk * PI - 0.2)) * bob).abs() * 5.0);
|
||||
}
|
||||
/// The bob matrix `GameRenderer.renderLevel` multiplies into the projection
|
||||
/// (and `renderItemInHand` into the hand's pose).
|
||||
@@ -771,10 +767,7 @@ mod tests {
|
||||
movement_speed: 0.13,
|
||||
using: None,
|
||||
};
|
||||
assert_eq!(
|
||||
field_of_view_modifier(&p, true, 1.0),
|
||||
(0.13f32 / 0.1 + 1.0) / 2.0
|
||||
);
|
||||
assert_eq!(field_of_view_modifier(&p, true, 1.0), (0.13f32 / 0.1 + 1.0) / 2.0);
|
||||
assert_eq!(field_of_view_modifier(&p, true, 0.0), 1.0);
|
||||
p.using = Some((UseItem::Spyglass, 3));
|
||||
assert_eq!(field_of_view_modifier(&p, true, 1.0), 0.1);
|
||||
|
||||
@@ -78,23 +78,6 @@ impl SceneCollisions<'_, '_> {
|
||||
)?)
|
||||
}
|
||||
}
|
||||
/// `noCollision(null, box)`: block collisions under the empty collision
|
||||
/// context, as entity-less queries such as spawn checks use.
|
||||
pub fn no_collision_empty<W, E>(world: &mut W, bounds: Aabb) -> bool
|
||||
where
|
||||
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
|
||||
{
|
||||
no_world_collision(
|
||||
world,
|
||||
CollisionCheckRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context: CollisionContext::empty(false),
|
||||
bounds,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
impl BlockCollisionWorld for SceneCollisions<'_, '_> {
|
||||
type Error = io::Error;
|
||||
type Chunk = ChunkPos;
|
||||
@@ -183,91 +166,3 @@ impl PowderSnowWorld for SceneCollisions<'_, '_> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Collision queries over any block lookup (test worlds without chunks):
|
||||
/// every chunk is present, nothing is suffocating, no entities collide.
|
||||
pub struct LookupCollisions<'w, 'r> {
|
||||
pub block: &'w dyn Fn(i32, i32, i32) -> horizon_world::registry::BlockStateId,
|
||||
pub binding: &'w BlockCollisionRegistry<'r>,
|
||||
}
|
||||
impl BlockCollisionWorld for LookupCollisions<'_, '_> {
|
||||
type Error = io::Error;
|
||||
type Chunk = ChunkPos;
|
||||
fn collision_chunk(&mut self, p: ChunkPos) -> io::Result<Option<ChunkPos>> {
|
||||
Ok(Some(p))
|
||||
}
|
||||
fn collision_block(&mut self, _: ChunkPos, p: BlockPos) -> io::Result<CollisionBlock> {
|
||||
self.binding.collision_block((self.block)(p.x, p.y, p.z))
|
||||
}
|
||||
fn is_suffocating(&mut self, _: ChunkPos, _: CollisionBlock, _: BlockPos) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
fn context_shape(
|
||||
&mut self,
|
||||
block: CollisionBlock,
|
||||
p: BlockPos,
|
||||
context: &CollisionContext<'_>,
|
||||
) -> io::Result<VoxelShape> {
|
||||
self.binding
|
||||
.collision_shape_with_context(self, block.state, p, context)
|
||||
.map_err(io::Error::other)
|
||||
}
|
||||
}
|
||||
impl EntityMovementWorld for LookupCollisions<'_, '_> {
|
||||
type Error = io::Error;
|
||||
fn entity_collisions(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Vec<VoxelShape>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn movement_border(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Option<VoxelShape>> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
impl DynamicSupportWorld for LookupCollisions<'_, '_> {
|
||||
type Error = io::Error;
|
||||
fn shulker_is_closed(&mut self, _: [i32; 3]) -> io::Result<Option<bool>> {
|
||||
Ok(None)
|
||||
}
|
||||
fn moving_piston_shape(&mut self, _: [i32; 3]) -> io::Result<Option<VoxelShape>> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
impl DynamicCollisionWorld for LookupCollisions<'_, '_> {
|
||||
fn shulker_collision_state(
|
||||
&mut self,
|
||||
_: [i32; 3],
|
||||
) -> io::Result<Option<ShulkerCollisionState>> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
impl LiquidCollisionWorld for LookupCollisions<'_, '_> {
|
||||
type Error = io::Error;
|
||||
fn entity_liquid_shape(&mut self, _: EntityIdentity) -> io::Result<VoxelShape> {
|
||||
Ok(VoxelShape::empty())
|
||||
}
|
||||
fn fluid(&mut self, p: BlockPos) -> io::Result<FluidState> {
|
||||
FluidState::from_block(
|
||||
self.binding
|
||||
.registry()
|
||||
.state((self.block)(p.x, p.y, p.z))
|
||||
.ok_or_else(|| io::Error::other("fluid state outside registry"))?,
|
||||
)
|
||||
}
|
||||
fn entity_can_stand(&mut self, _: EntityIdentity, _: FluidState) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
impl PowderSnowWorld for LookupCollisions<'_, '_> {
|
||||
type Error = io::Error;
|
||||
fn powder_snow_entity(&mut self, _: EntityIdentity) -> io::Result<PowderSnowEntity> {
|
||||
Ok(PowderSnowEntity {
|
||||
fall_distance: 0.0,
|
||||
falling_block: false,
|
||||
})
|
||||
}
|
||||
fn powder_snow_walkable_tag(&mut self, _: EntityIdentity) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
fn feet_are_leather_boots(&mut self, _: EntityIdentity) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
@@ -540,9 +540,9 @@ pub fn parse_identifier(raw: &str) -> Option<String> {
|
||||
&& ns
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-'));
|
||||
let path_ok = path.chars().all(|c| {
|
||||
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/')
|
||||
});
|
||||
let path_ok = path
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/'));
|
||||
(ns_ok && path_ok).then(|| format!("{ns}:{path}"))
|
||||
}
|
||||
pub fn normalize(id: &str) -> String {
|
||||
@@ -690,11 +690,7 @@ pub fn read_double_bounds(reader: &mut Reader) -> CmdResult<DoubleRange> {
|
||||
}
|
||||
/// MinMaxBounds.FloatDegrees.fromReader (no swap check).
|
||||
pub fn read_float_bounds(reader: &mut Reader) -> CmdResult<(Option<f32>, Option<f32>)> {
|
||||
read_bounds(
|
||||
reader,
|
||||
|s| java_parse_double(s).map(|v| v as f32),
|
||||
"parsing.float.invalid",
|
||||
)
|
||||
read_bounds(reader, |s| java_parse_double(s).map(|v| v as f32), "parsing.float.invalid")
|
||||
}
|
||||
/// Integer.parseInt over number characters.
|
||||
pub fn java_parse_int(s: &str) -> Option<i32> {
|
||||
@@ -890,12 +886,7 @@ fn java_split(s: &str) -> Vec<&str> {
|
||||
}
|
||||
/// SharedSuggestionProvider.suggestCoordinates / suggest2DCoordinates: the
|
||||
/// candidates `ty` parses completely.
|
||||
fn suggest_coordinates(
|
||||
builder: &mut SuggestionsBuilder,
|
||||
reg: &Registries,
|
||||
ty: &ArgType,
|
||||
relevant: bool,
|
||||
) {
|
||||
fn suggest_coordinates(builder: &mut SuggestionsBuilder, reg: &Registries, ty: &ArgType, relevant: bool) {
|
||||
let remaining = builder.remaining().to_owned();
|
||||
let three = matches!(ty, ArgType::BlockPos | ArgType::Vec3 { .. });
|
||||
let coords: [String; 3] = if remaining.starts_with('^') {
|
||||
@@ -912,11 +903,7 @@ fn suggest_coordinates(
|
||||
let mut result: Vec<String> = Vec::new();
|
||||
let [x, y, z] = &coords;
|
||||
if remaining.is_empty() {
|
||||
let full = if three {
|
||||
format!("{x} {y} {z}")
|
||||
} else {
|
||||
format!("{x} {z}")
|
||||
};
|
||||
let full = if three { format!("{x} {y} {z}") } else { format!("{x} {z}") };
|
||||
if valid(&full) {
|
||||
result.push(x.clone());
|
||||
if three {
|
||||
@@ -978,11 +965,7 @@ fn parse_nbt_path(reader: &mut Reader) -> CmdResult<()> {
|
||||
// StringReader.peek past the end (StringIndexOutOfBoundsException).
|
||||
let len = reader.string.encode_utf16().count();
|
||||
let message = format!("Index {len} out of bounds for length {len}");
|
||||
return Err(CommandError::tr_at(
|
||||
"command.exception",
|
||||
&[&message],
|
||||
reader,
|
||||
));
|
||||
return Err(CommandError::tr_at("command.exception", &[&message], reader));
|
||||
}
|
||||
match reader.peek() {
|
||||
'{' => {
|
||||
@@ -1005,10 +988,7 @@ fn parse_nbt_path(reader: &mut Reader) -> CmdResult<()> {
|
||||
_ => {
|
||||
let s = reader.cursor;
|
||||
while reader.can_read()
|
||||
&& !matches!(
|
||||
reader.peek(),
|
||||
' ' | '"' | '\'' | '[' | ']' | '.' | '{' | '}'
|
||||
)
|
||||
&& !matches!(reader.peek(), ' ' | '"' | '\'' | '[' | ']' | '.' | '{' | '}')
|
||||
{
|
||||
reader.skip();
|
||||
}
|
||||
@@ -1069,9 +1049,10 @@ fn particle_options(id: &str, t: &horizon_world::nbt::Tag) -> Result<(), String>
|
||||
{
|
||||
// withAlternative(BlockState.CODEC, the block id codec).
|
||||
let first = match state {
|
||||
horizon_world::nbt::Tag::Compound(_) => {
|
||||
super::codec::either("Not a string".into(), no_key("id", state))
|
||||
}
|
||||
horizon_world::nbt::Tag::Compound(_) => super::codec::either(
|
||||
"Not a string".into(),
|
||||
no_key("id", state),
|
||||
),
|
||||
_ => not_map(state),
|
||||
};
|
||||
let _ = snbt;
|
||||
@@ -1140,10 +1121,7 @@ impl ArgType {
|
||||
reader.cursor = start;
|
||||
return Err(CommandError::tr_at(
|
||||
"argument.double.low",
|
||||
&[
|
||||
&crate::snbt::java_double(*min),
|
||||
&crate::snbt::java_double(v),
|
||||
],
|
||||
&[&crate::snbt::java_double(*min), &crate::snbt::java_double(v)],
|
||||
reader,
|
||||
));
|
||||
}
|
||||
@@ -1151,10 +1129,7 @@ impl ArgType {
|
||||
reader.cursor = start;
|
||||
return Err(CommandError::tr_at(
|
||||
"argument.double.big",
|
||||
&[
|
||||
&crate::snbt::java_double(*max),
|
||||
&crate::snbt::java_double(v),
|
||||
],
|
||||
&[&crate::snbt::java_double(*max), &crate::snbt::java_double(v)],
|
||||
reader,
|
||||
));
|
||||
}
|
||||
@@ -1171,7 +1146,10 @@ impl ArgType {
|
||||
// MessageArgument.Message.parseText.
|
||||
let len = reader.remaining().encode_utf16().count();
|
||||
if len > 256 {
|
||||
return Err(CommandError::tr("argument.message.too_long", &[&len, &256]));
|
||||
return Err(CommandError::tr(
|
||||
"argument.message.too_long",
|
||||
&[&len, &256],
|
||||
));
|
||||
}
|
||||
let s = reader.remaining().to_owned();
|
||||
if reg.permission >= 2 {
|
||||
@@ -1351,11 +1329,7 @@ impl ArgType {
|
||||
ArgType::HexColor => {
|
||||
let text = reader.read_unquoted_string();
|
||||
if !matches!(text.len(), 3 | 6) {
|
||||
return Err(CommandError::tr_at(
|
||||
"argument.hexcolor.invalid",
|
||||
&[&text],
|
||||
reader,
|
||||
));
|
||||
return Err(CommandError::tr_at("argument.hexcolor.invalid", &[&text], reader));
|
||||
}
|
||||
// Integer.parseInt(s, begin, end, 16) per channel: a bad digit
|
||||
// escapes Brigadier as a dispatcher parse exception.
|
||||
@@ -1367,11 +1341,7 @@ impl ArgType {
|
||||
if let Some(i) = digits.find(|ch: char| !ch.is_ascii_hexdigit()) {
|
||||
let index = i + usize::from(signed);
|
||||
let message = format!("Error at index {index} in: \"{c}\"");
|
||||
return Err(CommandError::tr_at(
|
||||
"command.exception",
|
||||
&[&message],
|
||||
reader,
|
||||
));
|
||||
return Err(CommandError::tr_at("command.exception", &[&message], reader));
|
||||
}
|
||||
}
|
||||
let v = i64::from_str_radix(text, 16).unwrap_or(0);
|
||||
@@ -1391,10 +1361,7 @@ impl ArgType {
|
||||
.take_while(|c| c.is_ascii_hexdigit() || *c == '-')
|
||||
.map(char::len_utf8)
|
||||
.sum();
|
||||
match (run > 0)
|
||||
.then(|| super::selector::java_uuid(&rest[..run]))
|
||||
.flatten()
|
||||
{
|
||||
match (run > 0).then(|| super::selector::java_uuid(&rest[..run])).flatten() {
|
||||
Some(_) => {
|
||||
let text = rest[..run].to_owned();
|
||||
reader.cursor += run;
|
||||
@@ -1424,11 +1391,7 @@ impl ArgType {
|
||||
let v = reader.read_float()?;
|
||||
let unit = reader.read_unquoted_string();
|
||||
let Some(m) = time_unit(unit) else {
|
||||
return Err(CommandError::tr_at(
|
||||
"argument.time.invalid_unit",
|
||||
&[],
|
||||
reader,
|
||||
));
|
||||
return Err(CommandError::tr_at("argument.time.invalid_unit", &[], reader));
|
||||
};
|
||||
// Math.round(float): floor(x + 0.5).
|
||||
let ticks = (v * m as f32 + 0.5).floor() as i32;
|
||||
@@ -1462,10 +1425,7 @@ impl ArgType {
|
||||
// ParserBasedArgument.withCodec: SNBT, then the codec (errors at the start).
|
||||
let tag = super::snbt::parse_tag_argument(reader)?;
|
||||
let (decoded, key) = if matches!(self, ArgType::Style) {
|
||||
(
|
||||
super::component::decode_style(&tag),
|
||||
"argument.style.invalid",
|
||||
)
|
||||
(super::component::decode_style(&tag), "argument.style.invalid")
|
||||
} else {
|
||||
(super::component::decode(&tag), "argument.component.invalid")
|
||||
};
|
||||
|
||||
@@ -379,8 +379,7 @@ impl<'r, 'a> BlockParser<'r, 'a> {
|
||||
}
|
||||
let prefix = b.remaining_lower();
|
||||
for (name, _) in definition {
|
||||
if !self.properties.iter().any(|(n, _)| n == name) && name.starts_with(&prefix)
|
||||
{
|
||||
if !self.properties.iter().any(|(n, _)| n == name) && name.starts_with(&prefix) {
|
||||
b.suggest(format!("{name}="));
|
||||
}
|
||||
}
|
||||
@@ -440,7 +439,11 @@ impl<'r, 'a> BlockParser<'r, 'a> {
|
||||
}
|
||||
|
||||
/// BlockStateParser.parseForBlock / parseForTesting (the reader is rewound on errors).
|
||||
pub fn parse(reader: &mut Reader, reg: &Registries, for_testing: bool) -> CmdResult<BlockInput> {
|
||||
pub fn parse(
|
||||
reader: &mut Reader,
|
||||
reg: &Registries,
|
||||
for_testing: bool,
|
||||
) -> CmdResult<BlockInput> {
|
||||
let cursor = reader.cursor;
|
||||
let r = BlockParser::new(reader, reg, for_testing, true).parse();
|
||||
if r.is_err() {
|
||||
|
||||
@@ -86,7 +86,10 @@ fn provider_fields(id: &str, map: &Compound, t: &Tag) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
/// A holder list codec: a single element, a `#tag`, or a list.
|
||||
fn element_or_list(t: &Tag, element: &dyn Fn(&Tag) -> Result<(), String>) -> Result<(), String> {
|
||||
fn element_or_list(
|
||||
t: &Tag,
|
||||
element: &dyn Fn(&Tag) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let first = match element(t) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => e,
|
||||
@@ -176,6 +179,7 @@ pub fn resource_or_id(reader: &mut Reader, reg: &Registries, registry: &str) ->
|
||||
}
|
||||
reader.cursor = start;
|
||||
let tag = super::snbt::parse_tag_argument(reader)?;
|
||||
decode(registry, &tag, reg)
|
||||
.map_err(|m| CommandError::tr_at("argument.resource_or_id.failed_to_parse", &[&m], reader))
|
||||
decode(registry, &tag, reg).map_err(|m| {
|
||||
CommandError::tr_at("argument.resource_or_id.failed_to_parse", &[&m], reader)
|
||||
})
|
||||
}
|
||||
@@ -47,11 +47,7 @@ fn is_string(t: &Tag) -> bool {
|
||||
matches!(t, Tag::String(_))
|
||||
}
|
||||
fn string(t: &Tag) -> R {
|
||||
if is_string(t) {
|
||||
Ok(())
|
||||
} else {
|
||||
err("Not a string")
|
||||
}
|
||||
if is_string(t) { Ok(()) } else { err("Not a string") }
|
||||
}
|
||||
fn string_of(t: &Tag) -> Option<String> {
|
||||
match t {
|
||||
@@ -135,9 +131,9 @@ pub fn identifier(t: &Tag) -> Result<(), String> {
|
||||
"Not a valid resource location: {s} Non [a-z0-9_.-] character in namespace of location: {ns}:{path}"
|
||||
));
|
||||
}
|
||||
let path_ok = path.chars().all(|c| {
|
||||
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/')
|
||||
});
|
||||
let path_ok = path
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/'));
|
||||
if !path_ok {
|
||||
return Err(format!(
|
||||
"Not a valid resource location: {s} Non [a-z0-9/._-] character in path of location: {ns}:{path}"
|
||||
@@ -212,13 +208,7 @@ fn style_fields(map: &Compound) -> R {
|
||||
if let Some(c) = get(map, "color") {
|
||||
color(c)?;
|
||||
}
|
||||
for key in [
|
||||
"bold",
|
||||
"italic",
|
||||
"underlined",
|
||||
"strikethrough",
|
||||
"obfuscated",
|
||||
] {
|
||||
for key in ["bold", "italic", "underlined", "strikethrough", "obfuscated"] {
|
||||
if let Some(v) = get(map, key)
|
||||
&& bool_value(v).is_err()
|
||||
{
|
||||
@@ -272,13 +262,12 @@ fn content(kind: &str, map: &Compound) -> bool {
|
||||
_ => false,
|
||||
},
|
||||
"selector" => {
|
||||
get(map, "selector").is_some_and(selector_string)
|
||||
&& optional_component(map, "separator")
|
||||
get(map, "selector").is_some_and(selector_string) && optional_component(map, "separator")
|
||||
}
|
||||
"nbt" => {
|
||||
let path_ok = get(map, "nbt")
|
||||
.and_then(string_of)
|
||||
.is_some_and(|p| crate::nbt_path::NbtPath::parse(&p).is_ok());
|
||||
let path_ok = get(map, "nbt").and_then(string_of).is_some_and(|p| {
|
||||
crate::nbt_path::NbtPath::parse(&p).is_ok()
|
||||
});
|
||||
let source = if let Some(e) = get(map, "entity") {
|
||||
selector_string(e)
|
||||
} else if let Some(b) = get(map, "block") {
|
||||
@@ -347,9 +336,10 @@ fn full(t: &Tag) -> R {
|
||||
style_fields(map)
|
||||
}
|
||||
fn component(t: &Tag) -> R {
|
||||
either(either(string(t), || non_empty_list(t, &component)), || {
|
||||
full(t)
|
||||
})
|
||||
either(
|
||||
either(string(t), || non_empty_list(t, &component)),
|
||||
|| full(t),
|
||||
)
|
||||
}
|
||||
/// ComponentSerialization.CODEC.parse: the error message on failure.
|
||||
pub fn decode(t: &Tag) -> Result<(), String> {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
//! ItemParser (item stacks with components, `stone[damage=5]`) and
|
||||
//! ItemPredicateArgument (ComponentPredicateParser's packrat grammar:
|
||||
//! `#logs[count~1..|!damage]`), with vanilla's errors and suggestions.
|
||||
use super::args::{
|
||||
ItemInput, Registries, is_id_char, parse_identifier, read_identifier, suggest_resources,
|
||||
};
|
||||
use super::args::{ItemInput, Registries, is_id_char, parse_identifier, read_identifier, suggest_resources};
|
||||
use super::codec::{either, is_number, not_map, snbt};
|
||||
use super::packrat::{Grammar, Reason, State, Sugg, Term, Value, alt, ch, opt, seq};
|
||||
use super::{CmdResult, CommandError, Reader, SuggestionsBuilder};
|
||||
@@ -161,10 +159,7 @@ impl ItemState<'_, '_> {
|
||||
self.sugg = ISugg::Component;
|
||||
let ty = self.component_type()?;
|
||||
if visited.contains(&ty) {
|
||||
return Err(CommandError::tr(
|
||||
"arguments.item.component.repeated",
|
||||
&[&ty],
|
||||
));
|
||||
return Err(CommandError::tr("arguments.item.component.repeated", &[&ty]));
|
||||
}
|
||||
visited.push(ty);
|
||||
self.sugg = ISugg::Nothing;
|
||||
@@ -172,10 +167,7 @@ impl ItemState<'_, '_> {
|
||||
} else {
|
||||
let ty = self.component_type()?;
|
||||
if visited.contains(&ty) {
|
||||
return Err(CommandError::tr(
|
||||
"arguments.item.component.repeated",
|
||||
&[&ty],
|
||||
));
|
||||
return Err(CommandError::tr("arguments.item.component.repeated", &[&ty]));
|
||||
}
|
||||
visited.push(ty.clone());
|
||||
self.sugg = ISugg::Assignment;
|
||||
@@ -409,12 +401,10 @@ fn predicate_grammar() -> Grammar<IV> {
|
||||
"type",
|
||||
alt(vec![element, seq(vec![ch('#'), Term::Cut, tag]), any]),
|
||||
|s| {
|
||||
Some(IV::Type(
|
||||
match s.scope.get_any(&["element_type", "tag_type"]) {
|
||||
Some(IV::Elem(t)) => Some(t),
|
||||
_ => None,
|
||||
},
|
||||
))
|
||||
Some(IV::Type(match s.scope.get_any(&["element_type", "tag_type"]) {
|
||||
Some(IV::Elem(t)) => Some(t),
|
||||
_ => None,
|
||||
}))
|
||||
},
|
||||
);
|
||||
g.put_term("any_type", ch('*'), |_| Some(IV::Unit));
|
||||
@@ -425,11 +415,7 @@ fn predicate_grammar() -> Grammar<IV> {
|
||||
if r.ids("minecraft:item").iter().any(|i| i == id) {
|
||||
Ok(IV::Elem(TypeRes::Item(id.to_owned())))
|
||||
} else {
|
||||
Err(CommandError::tr_at(
|
||||
"argument.item.id.invalid",
|
||||
&[&id],
|
||||
reader,
|
||||
))
|
||||
Err(CommandError::tr_at("argument.item.id.invalid", &[&id], reader))
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -440,11 +426,7 @@ fn predicate_grammar() -> Grammar<IV> {
|
||||
if r.tags("minecraft:item").iter().any(|i| i == id) {
|
||||
Ok(IV::Elem(TypeRes::Tag(id.to_owned())))
|
||||
} else {
|
||||
Err(CommandError::tr_at(
|
||||
"arguments.item.tag.unknown",
|
||||
&[&id],
|
||||
reader,
|
||||
))
|
||||
Err(CommandError::tr_at("arguments.item.tag.unknown", &[&id], reader))
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -527,49 +509,29 @@ fn predicate_grammar() -> Grammar<IV> {
|
||||
if id == "minecraft:count" {
|
||||
return Ok(IV::Comp(id.to_owned()));
|
||||
}
|
||||
let known = r
|
||||
.ids("minecraft:data_component_type")
|
||||
.iter()
|
||||
.any(|c| c == id);
|
||||
let known = r.ids("minecraft:data_component_type").iter().any(|c| c == id);
|
||||
if known && r.persistent_components.iter().any(|c| c == id) {
|
||||
Ok(IV::Comp(id.to_owned()))
|
||||
} else {
|
||||
Err(CommandError::tr_at(
|
||||
"arguments.item.component.unknown",
|
||||
&[&id],
|
||||
reader,
|
||||
))
|
||||
Err(CommandError::tr_at("arguments.item.component.unknown", &[&id], reader))
|
||||
}
|
||||
})
|
||||
});
|
||||
g.put_custom("predicate_type", move |s| {
|
||||
let r = reg(s);
|
||||
let mut list = vec!["minecraft:count".to_owned()];
|
||||
list.extend(
|
||||
r.ids("minecraft:data_component_predicate_type")
|
||||
.iter()
|
||||
.cloned(),
|
||||
);
|
||||
list.extend(r.ids("minecraft:data_component_predicate_type").iter().cloned());
|
||||
lookup(s, id, Rc::new(list), &|id, reader| {
|
||||
if id == "minecraft:count"
|
||||
|| r.ids("minecraft:data_component_predicate_type")
|
||||
.iter()
|
||||
.any(|c| c == id)
|
||||
|| r.ids("minecraft:data_component_predicate_type").iter().any(|c| c == id)
|
||||
{
|
||||
return Ok(IV::Pred(id.to_owned()));
|
||||
}
|
||||
if r.ids("minecraft:data_component_type")
|
||||
.iter()
|
||||
.any(|c| c == id)
|
||||
{
|
||||
if r.ids("minecraft:data_component_type").iter().any(|c| c == id) {
|
||||
// A component existence predicate (decodes Unit).
|
||||
return Ok(IV::Pred(format!("exists:{id}")));
|
||||
}
|
||||
Err(CommandError::tr_at(
|
||||
"arguments.item.predicate.unknown",
|
||||
&[&id],
|
||||
reader,
|
||||
))
|
||||
Err(CommandError::tr_at("arguments.item.predicate.unknown", &[&id], reader))
|
||||
})
|
||||
});
|
||||
g.put_custom("tag", |s| super::snbt::rule_in(s, IV::Tag));
|
||||
|
||||
@@ -616,8 +616,7 @@ pub fn merge_suggestions(input: &str, list: Vec<Suggestion>) -> (Range, Vec<Sugg
|
||||
// A stable insertion sort (the comparator mixes numeric and text order).
|
||||
let mut sorted: Vec<Suggestion> = Vec::with_capacity(out.len());
|
||||
for s in out {
|
||||
let pos =
|
||||
sorted.partition_point(|x| compare_suggestions(x, &s) != std::cmp::Ordering::Greater);
|
||||
let pos = sorted.partition_point(|x| compare_suggestions(x, &s) != std::cmp::Ordering::Greater);
|
||||
sorted.insert(pos, s);
|
||||
}
|
||||
((start, end), sorted)
|
||||
@@ -645,8 +644,7 @@ impl Suggests {
|
||||
for id in ®.summonable {
|
||||
if args::resource_matches(&contents, id) {
|
||||
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
let name =
|
||||
crate::lang::get(&format!("entity.{ns}.{}", path.replace('/', ".")));
|
||||
let name = crate::lang::get(&format!("entity.{ns}.{}", path.replace('/', ".")));
|
||||
builder.suggest_tooltip(id.clone(), name);
|
||||
}
|
||||
}
|
||||
@@ -675,9 +673,7 @@ impl Suggests {
|
||||
.trigger_scores
|
||||
.iter()
|
||||
.filter(|(_, scores)| {
|
||||
targets
|
||||
.iter()
|
||||
.any(|t| scores.get(t).is_none_or(|locked| *locked))
|
||||
targets.iter().any(|t| scores.get(t).is_none_or(|locked| *locked))
|
||||
})
|
||||
.map(|(o, _)| o.as_str())
|
||||
.collect();
|
||||
|
||||
@@ -94,11 +94,13 @@ impl<V: Clone> Scope<V> {
|
||||
let current = self.top_marker;
|
||||
let n = self.top_entry - self.top_marker;
|
||||
self.setup_new_frame();
|
||||
let mut source = current + 1;
|
||||
let mut target = self.top_entry;
|
||||
for source in current + 1..current + 1 + n {
|
||||
for _ in 0..n {
|
||||
target += 1;
|
||||
let key = self.stack[source].0;
|
||||
self.set(target, key, None);
|
||||
source += 1;
|
||||
}
|
||||
self.top_entry = target;
|
||||
}
|
||||
@@ -137,13 +139,11 @@ impl<V: Clone> Scope<V> {
|
||||
}
|
||||
}
|
||||
pub fn get(&self, atom: Atom) -> Option<V> {
|
||||
self.value_index(&[atom])
|
||||
.and_then(|i| self.stack[i].1.clone())
|
||||
self.value_index(&[atom]).and_then(|i| self.stack[i].1.clone())
|
||||
}
|
||||
/// Scope.getAny: the newest of several atoms.
|
||||
pub fn get_any(&self, atoms: &[Atom]) -> Option<V> {
|
||||
self.value_index(atoms)
|
||||
.and_then(|i| self.stack[i].1.clone())
|
||||
self.value_index(atoms).and_then(|i| self.stack[i].1.clone())
|
||||
}
|
||||
/// Whether the atom has an entry (even a null one).
|
||||
pub fn has(&self, atom: Atom) -> bool {
|
||||
@@ -385,7 +385,7 @@ impl<'g, V: Value> State<'g, V> {
|
||||
r
|
||||
}
|
||||
fn first_error(&self) -> CommandError {
|
||||
if let Some((_, reason)) = self.errors.first() {
|
||||
for (_, reason) in &self.errors {
|
||||
return match reason {
|
||||
Reason::Delayed(key, args) => {
|
||||
let args: Vec<&dyn std::fmt::Display> =
|
||||
|
||||
@@ -143,12 +143,7 @@ pub fn java_uuid(name: &str) -> Option<u128> {
|
||||
Some((u128::from(msb) << 64) | u128::from(lsb))
|
||||
}
|
||||
|
||||
fn rollback(
|
||||
reader: &mut Reader,
|
||||
start: usize,
|
||||
key: &str,
|
||||
args: &[&dyn std::fmt::Display],
|
||||
) -> CommandError {
|
||||
fn rollback(reader: &mut Reader, start: usize, key: &str, args: &[&dyn std::fmt::Display]) -> CommandError {
|
||||
reader.cursor = start;
|
||||
CommandError::tr_at(key, args, reader)
|
||||
}
|
||||
|
||||
@@ -153,9 +153,7 @@ fn parse_small(s: &str, radix: u32, lo: i32, hi: i32) -> Result<i32, String> {
|
||||
}
|
||||
fn parse_unsigned_int(s: &str, radix: u32) -> Result<i32, String> {
|
||||
if s.starts_with('-') {
|
||||
return Err(format!(
|
||||
"Illegal leading minus sign on unsigned string {s}."
|
||||
));
|
||||
return Err(format!("Illegal leading minus sign on unsigned string {s}."));
|
||||
}
|
||||
let len = s.chars().count();
|
||||
if len <= 5 || (radix == 10 && len <= 9) {
|
||||
@@ -170,9 +168,7 @@ fn parse_unsigned_int(s: &str, radix: u32) -> Result<i32, String> {
|
||||
}
|
||||
fn parse_unsigned_long(s: &str, radix: u32) -> Result<i64, String> {
|
||||
if s.starts_with('-') {
|
||||
return Err(format!(
|
||||
"Illegal leading minus sign on unsigned string {s}."
|
||||
));
|
||||
return Err(format!("Illegal leading minus sign on unsigned string {s}."));
|
||||
}
|
||||
let digits = s.strip_prefix('+').unwrap_or(s);
|
||||
match u64::from_str_radix(digits, radix) {
|
||||
@@ -342,11 +338,7 @@ pub fn grammar() -> Grammar<SV> {
|
||||
|s| s.scope.get("integer_suffix"),
|
||||
);
|
||||
g.put_custom("binary_numeral", |s| {
|
||||
number_run(
|
||||
s,
|
||||
|c| matches!(c, '0' | '1' | '_'),
|
||||
"snbt.parser.expected_binary_numeral",
|
||||
)
|
||||
number_run(s, |c| matches!(c, '0' | '1' | '_'), "snbt.parser.expected_binary_numeral")
|
||||
});
|
||||
g.put_custom("decimal_numeral", |s| {
|
||||
number_run(
|
||||
@@ -402,10 +394,7 @@ pub fn grammar() -> Grammar<SV> {
|
||||
} else if let Some(h) = get_str(s, "hex_numeral") {
|
||||
(Base::Hex, h)
|
||||
} else {
|
||||
(
|
||||
Base::Binary,
|
||||
get_str(s, "binary_numeral").unwrap_or_default(),
|
||||
)
|
||||
(Base::Binary, get_str(s, "binary_numeral").unwrap_or_default())
|
||||
};
|
||||
Some(SV::Int(IntLit {
|
||||
minus,
|
||||
@@ -419,14 +408,8 @@ pub fn grammar() -> Grammar<SV> {
|
||||
g.put_term(
|
||||
"float_type_suffix",
|
||||
alt(vec![
|
||||
seq(vec![
|
||||
chs('f', 'F'),
|
||||
Term::Marker("float_type_suffix", SV::Type(TypeSuffix::Float)),
|
||||
]),
|
||||
seq(vec![
|
||||
chs('d', 'D'),
|
||||
Term::Marker("float_type_suffix", SV::Type(TypeSuffix::Double)),
|
||||
]),
|
||||
seq(vec![chs('f', 'F'), Term::Marker("float_type_suffix", SV::Type(TypeSuffix::Float))]),
|
||||
seq(vec![chs('d', 'D'), Term::Marker("float_type_suffix", SV::Type(TypeSuffix::Double))]),
|
||||
]),
|
||||
|s| s.scope.get("float_type_suffix"),
|
||||
);
|
||||
@@ -437,10 +420,7 @@ pub fn grammar() -> Grammar<SV> {
|
||||
seq(vec![chs('e', 'E'), opt(sign), decimal]),
|
||||
|s| {
|
||||
let minus = matches!(s.scope.get("sign"), Some(SV::Sign(true)));
|
||||
Some(SV::Exp(
|
||||
minus,
|
||||
get_str(s, "decimal_numeral").unwrap_or_default(),
|
||||
))
|
||||
Some(SV::Exp(minus, get_str(s, "decimal_numeral").unwrap_or_default()))
|
||||
},
|
||||
);
|
||||
let sign = g.named("sign");
|
||||
@@ -490,11 +470,7 @@ pub fn grammar() -> Grammar<SV> {
|
||||
float_tag(&text, ty, s).map(SV::Tag)
|
||||
},
|
||||
);
|
||||
for (name, n) in [
|
||||
("string_hex_2", 2),
|
||||
("string_hex_4", 4),
|
||||
("string_hex_8", 8),
|
||||
] {
|
||||
for (name, n) in [("string_hex_2", 2), ("string_hex_4", 4), ("string_hex_8", 8)] {
|
||||
g.put_custom(name, move |s| {
|
||||
greedy(
|
||||
s,
|
||||
@@ -545,9 +521,7 @@ pub fn grammar() -> Grammar<SV> {
|
||||
if let Some(SV::Str(plain)) = s.scope.get_any(&["string_escape_sequence"]) {
|
||||
return Some(SV::Str(plain));
|
||||
}
|
||||
if let Some(SV::Str(h)) =
|
||||
s.scope
|
||||
.get_any(&["string_hex_2", "string_hex_4", "string_hex_8"])
|
||||
if let Some(SV::Str(h)) = s.scope.get_any(&["string_hex_2", "string_hex_4", "string_hex_8"])
|
||||
{
|
||||
let cp = u32::from_str_radix(&h, 16).unwrap_or(u32::MAX);
|
||||
if cp > 0x10ffff {
|
||||
@@ -557,9 +531,7 @@ pub fn grammar() -> Grammar<SV> {
|
||||
));
|
||||
return None;
|
||||
}
|
||||
return Some(SV::Str(
|
||||
char::from_u32(cp).unwrap_or('\u{fffd}').to_string(),
|
||||
));
|
||||
return Some(SV::Str(char::from_u32(cp).unwrap_or('\u{fffd}').to_string()));
|
||||
}
|
||||
// Character.codePointOf: Unicode names are not tabulated here.
|
||||
s.store_at_mark(delayed("snbt.parser.invalid_character_name"));
|
||||
@@ -691,8 +663,7 @@ pub fn grammar() -> Grammar<SV> {
|
||||
let qs = g.named("quoted_string_literal");
|
||||
let uq = g.named("unquoted_string");
|
||||
g.put_term("map_key", alt(vec![qs, uq]), |s| {
|
||||
s.scope
|
||||
.get_any(&["quoted_string_literal", "unquoted_string"])
|
||||
s.scope.get_any(&["quoted_string_literal", "unquoted_string"])
|
||||
});
|
||||
let key = g.named("map_key");
|
||||
let lit = g.named("literal");
|
||||
@@ -741,18 +712,9 @@ pub fn grammar() -> Grammar<SV> {
|
||||
g.put_term(
|
||||
"array_prefix",
|
||||
alt(vec![
|
||||
seq(vec![
|
||||
ch('B'),
|
||||
Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Byte)),
|
||||
]),
|
||||
seq(vec![
|
||||
ch('L'),
|
||||
Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Long)),
|
||||
]),
|
||||
seq(vec![
|
||||
ch('I'),
|
||||
Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Int)),
|
||||
]),
|
||||
seq(vec![ch('B'), Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Byte))]),
|
||||
seq(vec![ch('L'), Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Long))]),
|
||||
seq(vec![ch('I'), Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Int))]),
|
||||
]),
|
||||
|s| s.scope.get("array_prefix"),
|
||||
);
|
||||
@@ -928,11 +890,7 @@ thread_local! {
|
||||
pub fn parse_tag_argument(reader: &mut Reader) -> CmdResult<Tag> {
|
||||
GRAMMAR.with(|g| match g.parse_for_commands(reader)? {
|
||||
SV::Tag(t) => Ok(t),
|
||||
_ => Err(CommandError::tr_at(
|
||||
"argument.nbt.expected.compound",
|
||||
&[],
|
||||
reader,
|
||||
)),
|
||||
_ => Err(CommandError::tr_at("argument.nbt.expected.compound", &[], reader)),
|
||||
})
|
||||
}
|
||||
/// TagParser.parseCompoundAsArgument.
|
||||
|
||||
@@ -37,18 +37,9 @@ fn reg() -> Registries {
|
||||
},
|
||||
);
|
||||
};
|
||||
registry(
|
||||
"minecraft:worldgen/biome",
|
||||
&["minecraft:desert", "minecraft:plains"],
|
||||
);
|
||||
registry(
|
||||
"minecraft:damage_type",
|
||||
&["minecraft:fall", "minecraft:magic"],
|
||||
);
|
||||
registry(
|
||||
"minecraft:attribute",
|
||||
&["minecraft:max_health", "minecraft:scale"],
|
||||
);
|
||||
registry("minecraft:worldgen/biome", &["minecraft:desert", "minecraft:plains"]);
|
||||
registry("minecraft:damage_type", &["minecraft:fall", "minecraft:magic"]);
|
||||
registry("minecraft:attribute", &["minecraft:max_health", "minecraft:scale"]);
|
||||
registry("minecraft:item_modifier", &["a:b"]);
|
||||
registry("minecraft:predicate", &["a:b"]);
|
||||
registry(
|
||||
@@ -63,14 +54,7 @@ fn reg() -> Registries {
|
||||
"minecraft:entity_type",
|
||||
&["minecraft:pig", "minecraft:player", "minecraft:zombie"],
|
||||
);
|
||||
registry(
|
||||
"minecraft:item",
|
||||
&[
|
||||
"minecraft:diamond",
|
||||
"minecraft:diamond_sword",
|
||||
"minecraft:stone",
|
||||
],
|
||||
);
|
||||
registry("minecraft:item", &["minecraft:diamond", "minecraft:diamond_sword", "minecraft:stone"]);
|
||||
r
|
||||
}
|
||||
fn run(input: &str) -> CmdResult<(String, Vec<String>)> {
|
||||
@@ -452,10 +436,7 @@ fn world_command_trees() {
|
||||
"particle minecraft:dust{color:[1.0,0.0,0.0],scale:2.0} ~ ~ ~ 0.1 0.1 0.1 0 10 force @a",
|
||||
"particle",
|
||||
),
|
||||
(
|
||||
"particle minecraft:block{block_state:\"minecraft:stone\"} ~ ~ ~",
|
||||
"particle",
|
||||
),
|
||||
("particle minecraft:block{block_state:\"minecraft:stone\"} ~ ~ ~", "particle"),
|
||||
("attribute @s minecraft:max_health get 2", "attribute.get"),
|
||||
(
|
||||
"attribute @s minecraft:max_health base set 40",
|
||||
|
||||
@@ -52,11 +52,7 @@ pub fn register(d: &mut Dispatcher, root: usize) {
|
||||
d.requires(compute, GAMEMASTERS);
|
||||
context_sources(d, compute, &mut |d, node| {
|
||||
let f = d.literal(node, "float");
|
||||
let p = d.argument(
|
||||
f,
|
||||
"provider",
|
||||
ArgType::ResourceOrId("minecraft:context_float_provider"),
|
||||
);
|
||||
let p = d.argument(f, "provider", ArgType::ResourceOrId("minecraft:context_float_provider"));
|
||||
d.executes(p, "compute");
|
||||
let scale = d.argument(
|
||||
p,
|
||||
@@ -68,11 +64,7 @@ pub fn register(d: &mut Dispatcher, root: usize) {
|
||||
);
|
||||
d.executes(scale, "compute");
|
||||
let i = d.literal(node, "integer");
|
||||
let p = d.argument(
|
||||
i,
|
||||
"provider",
|
||||
ArgType::ResourceOrId("minecraft:context_int_provider"),
|
||||
);
|
||||
let p = d.argument(i, "provider", ArgType::ResourceOrId("minecraft:context_int_provider"));
|
||||
d.executes(p, "compute");
|
||||
});
|
||||
|
||||
|
||||
@@ -48,11 +48,7 @@ pub fn register(d: &mut Dispatcher, root: usize) {
|
||||
let t = d.argument(as_, "targets", ENTITIES);
|
||||
d.redirect(t, ex);
|
||||
let over = d.literal(positioned, "over");
|
||||
let hm = d.argument(
|
||||
over,
|
||||
"heightmap",
|
||||
ArgType::StrEnum(HEIGHTMAPS, "argument.enum.invalid"),
|
||||
);
|
||||
let hm = d.argument(over, "heightmap", ArgType::StrEnum(HEIGHTMAPS, "argument.enum.invalid"));
|
||||
d.redirect(hm, ex);
|
||||
let rotated = d.literal(ex, "rotated");
|
||||
let rot = d.argument(rotated, "rot", ArgType::Rotation);
|
||||
@@ -183,11 +179,7 @@ fn conditionals(d: &mut Dispatcher, m: usize, ex: usize) {
|
||||
conditional(d, targets, ex);
|
||||
|
||||
let predicate = d.literal(m, "predicate");
|
||||
let p = d.argument(
|
||||
predicate,
|
||||
"predicate",
|
||||
ArgType::ResourceOrId("minecraft:predicate"),
|
||||
);
|
||||
let p = d.argument(predicate, "predicate", ArgType::ResourceOrId("minecraft:predicate"));
|
||||
conditional(d, p, ex);
|
||||
|
||||
let stopwatch = d.literal(m, "stopwatch");
|
||||
|
||||
@@ -169,11 +169,7 @@ fn item(d: &mut Dispatcher, root: usize) {
|
||||
d.argument(p, "targetPos", ArgType::BlockPos)
|
||||
};
|
||||
let slot = d.argument(target, "slot", ArgType::SlotSource);
|
||||
let modifier = d.argument(
|
||||
slot,
|
||||
"modifier",
|
||||
ArgType::ResourceOrId("minecraft:item_modifier"),
|
||||
);
|
||||
let modifier = d.argument(slot, "modifier", ArgType::ResourceOrId("minecraft:item_modifier"));
|
||||
d.executes(modifier, "item.modify");
|
||||
}
|
||||
for mode in ["replace", "fill", "override"] {
|
||||
|
||||
@@ -56,31 +56,19 @@ pub fn register(d: &mut Dispatcher, root: usize) {
|
||||
let place = d.literal(root, "place");
|
||||
d.requires(place, GAMEMASTERS);
|
||||
let feature = d.literal(place, "feature");
|
||||
let f = d.argument(
|
||||
feature,
|
||||
"feature",
|
||||
ArgType::ResourceOrId("minecraft:worldgen/feature"),
|
||||
);
|
||||
let f = d.argument(feature, "feature", ArgType::ResourceOrId("minecraft:worldgen/feature"));
|
||||
d.executes(f, "place.feature");
|
||||
let pos = d.argument(f, "pos", ArgType::BlockPos);
|
||||
d.executes(pos, "place.feature");
|
||||
let jigsaw = d.literal(place, "jigsaw");
|
||||
let pool = d.argument(
|
||||
jigsaw,
|
||||
"pool",
|
||||
ArgType::ResKey("minecraft:worldgen/template_pool"),
|
||||
);
|
||||
let pool = d.argument(jigsaw, "pool", ArgType::ResKey("minecraft:worldgen/template_pool"));
|
||||
let target = d.argument(pool, "target", ArgType::Id("none"));
|
||||
let depth = d.argument(target, "max_depth", ArgType::Integer { min: 1, max: 20 });
|
||||
d.executes(depth, "place.jigsaw");
|
||||
let position = d.argument(depth, "position", ArgType::BlockPos);
|
||||
d.executes(position, "place.jigsaw");
|
||||
let structure = d.literal(place, "structure");
|
||||
let st = d.argument(
|
||||
structure,
|
||||
"structure",
|
||||
ArgType::ResKey("minecraft:worldgen/structure"),
|
||||
);
|
||||
let st = d.argument(structure, "structure", ArgType::ResKey("minecraft:worldgen/structure"));
|
||||
d.executes(st, "place.structure");
|
||||
let pos = d.argument(st, "pos", ArgType::BlockPos);
|
||||
d.executes(pos, "place.structure");
|
||||
@@ -101,10 +89,7 @@ pub fn register(d: &mut Dispatcher, root: usize) {
|
||||
let mirror = d.argument(
|
||||
rotation,
|
||||
"mirror",
|
||||
ArgType::StrEnum(
|
||||
&["none", "left_right", "front_back"],
|
||||
"argument.enum.invalid",
|
||||
),
|
||||
ArgType::StrEnum(&["none", "left_right", "front_back"], "argument.enum.invalid"),
|
||||
);
|
||||
d.executes(mirror, "place");
|
||||
let integrity = d.argument(mirror, "integrity", ArgType::Float { min: 0.0, max: 1.0 });
|
||||
@@ -175,14 +160,7 @@ fn rotate_swing(d: &mut Dispatcher, root: usize) {
|
||||
for hand in ["mainhand", "offhand"] {
|
||||
let h = d.literal(targets, hand);
|
||||
d.executes(h, "swing");
|
||||
let animation = d.argument(
|
||||
h,
|
||||
"animation",
|
||||
ArgType::StrEnum(
|
||||
&["none", "whack", "stab"],
|
||||
"argument.swing_animation.invalid",
|
||||
),
|
||||
);
|
||||
let animation = d.argument(h, "animation", ArgType::StrEnum(&["none", "whack", "stab"], "argument.swing_animation.invalid"));
|
||||
d.executes(animation, "swing");
|
||||
let duration = d.argument(animation, "duration", ArgType::Time { min: 1 });
|
||||
d.executes(duration, "swing");
|
||||
@@ -307,11 +285,7 @@ fn fillbiome(d: &mut Dispatcher, root: usize) {
|
||||
let biome = d.argument(to, "biome", ArgType::Res("minecraft:worldgen/biome"));
|
||||
d.executes(biome, "fillbiome");
|
||||
let replace = d.literal(biome, "replace");
|
||||
let filter = d.argument(
|
||||
replace,
|
||||
"filter",
|
||||
ArgType::ResOrTag("minecraft:worldgen/biome"),
|
||||
);
|
||||
let filter = d.argument(replace, "filter", ArgType::ResOrTag("minecraft:worldgen/biome"));
|
||||
d.executes(filter, "fillbiome");
|
||||
}
|
||||
|
||||
@@ -383,11 +357,7 @@ fn loot(d: &mut Dispatcher, root: usize) {
|
||||
// Sources.
|
||||
for t in targets {
|
||||
let fish = d.literal(t, "fish");
|
||||
let table = d.argument(
|
||||
fish,
|
||||
"loot_table",
|
||||
ArgType::ResourceOrId("minecraft:loot_table"),
|
||||
);
|
||||
let table = d.argument(fish, "loot_table", ArgType::ResourceOrId("minecraft:loot_table"));
|
||||
let pos = d.argument(table, "pos", ArgType::BlockPos);
|
||||
d.executes(pos, "loot.fish");
|
||||
for hand in ["mainhand", "offhand"] {
|
||||
@@ -397,11 +367,7 @@ fn loot(d: &mut Dispatcher, root: usize) {
|
||||
let tool = d.argument(pos, "tool", ArgType::Item);
|
||||
d.executes(tool, "loot.fish");
|
||||
let l = d.literal(t, "loot");
|
||||
let lt = d.argument(
|
||||
l,
|
||||
"loot_table",
|
||||
ArgType::ResourceOrId("minecraft:loot_table"),
|
||||
);
|
||||
let lt = d.argument(l, "loot_table", ArgType::ResourceOrId("minecraft:loot_table"));
|
||||
d.executes(lt, "loot.loot");
|
||||
let kill = d.literal(t, "kill");
|
||||
let target = d.argument(kill, "target", ENTITY);
|
||||
|
||||
@@ -322,21 +322,13 @@ pub fn build_with(dev: bool) -> Dispatcher {
|
||||
let locate = d.literal(root, "locate");
|
||||
d.requires(locate, GAMEMASTERS);
|
||||
let s = d.literal(locate, "structure");
|
||||
let a = d.argument(
|
||||
s,
|
||||
"structure",
|
||||
ArgType::ResOrTagKey("minecraft:worldgen/structure"),
|
||||
);
|
||||
let a = d.argument(s, "structure", ArgType::ResOrTagKey("minecraft:worldgen/structure"));
|
||||
d.executes(a, "locate.structure");
|
||||
let b = d.literal(locate, "biome");
|
||||
let a = d.argument(b, "biome", ArgType::ResOrTag("minecraft:worldgen/biome"));
|
||||
d.executes(a, "locate.biome");
|
||||
let p = d.literal(locate, "poi");
|
||||
let a = d.argument(
|
||||
p,
|
||||
"poi",
|
||||
ArgType::ResOrTag("minecraft:point_of_interest_type"),
|
||||
);
|
||||
let a = d.argument(p, "poi", ArgType::ResOrTag("minecraft:point_of_interest_type"));
|
||||
d.executes(a, "locate.poi");
|
||||
|
||||
if dev {
|
||||
|
||||
@@ -75,9 +75,6 @@ pub struct Crafter {
|
||||
pub disabled: [bool; 9],
|
||||
pub triggered: bool,
|
||||
pub crafting_ticks: i32,
|
||||
/// The block state's `triggered` when last ticked (not saved): the block
|
||||
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
|
||||
pub state_triggered: Option<bool>,
|
||||
}
|
||||
impl Crafter {
|
||||
/// `slotCanBeDisabled`: only empty slots.
|
||||
@@ -187,12 +184,7 @@ impl BlockEntity {
|
||||
return Some(Self::Sculk(Box::new(sculk)));
|
||||
}
|
||||
if name == "minecraft:crafter" {
|
||||
// CrafterBlock.newBlockEntity copies the placed state's flag: the
|
||||
// first tick sees it as a change from untriggered.
|
||||
return Some(Self::Crafter(Crafter {
|
||||
state_triggered: Some(false),
|
||||
..Crafter::default()
|
||||
}));
|
||||
return Some(Self::Crafter(Crafter::default()));
|
||||
}
|
||||
if name.ends_with("_banner") {
|
||||
return Some(Self::Banner(Banner::default()));
|
||||
|
||||
@@ -253,37 +253,17 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
} else {
|
||||
map.insert("equipment".into(), Tag::compound(equipment));
|
||||
}
|
||||
let modelled = [
|
||||
("minecraft:max_health", f64::from(m.attrs.max_health)),
|
||||
("minecraft:movement_speed", m.attrs.speed),
|
||||
("minecraft:jump_strength", m.extra.vector[0]),
|
||||
];
|
||||
// AttributeMap.pack writes the instances that exist: the ones the
|
||||
// entity was loaded with, or all three randomized by finalizeSpawn.
|
||||
let attributes = match map.get(&"attributes".into()) {
|
||||
Some(Tag::List(list)) => list
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let id = text(a.get("id"));
|
||||
let value = modelled.iter().find(|(m, _)| Some(*m) == id.as_deref());
|
||||
match (a, value) {
|
||||
(Tag::Compound(c), Some((_, v))) => {
|
||||
let mut c = c.clone();
|
||||
c.insert("base".into(), Tag::Double(v.to_bits()));
|
||||
Tag::Compound(c)
|
||||
}
|
||||
_ => a.clone(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
_ => modelled
|
||||
.iter()
|
||||
.map(|(id, v)| {
|
||||
Tag::compound([("id", string(id)), ("base", Tag::Double(v.to_bits()))])
|
||||
})
|
||||
.collect(),
|
||||
let attr = |id: &str, v: f64| {
|
||||
Tag::compound([("id", string(id)), ("base", Tag::Double(v.to_bits()))])
|
||||
};
|
||||
map.insert("attributes".into(), Tag::List(attributes));
|
||||
map.insert(
|
||||
"attributes".into(),
|
||||
Tag::List(vec![
|
||||
attr("minecraft:max_health", f64::from(m.attrs.max_health)),
|
||||
attr("minecraft:movement_speed", m.attrs.speed),
|
||||
attr("minecraft:jump_strength", m.extra.vector[0]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
"minecraft:pig" | "minecraft:strider" => {
|
||||
let mut equipment = match map.get(&"equipment".into()) {
|
||||
@@ -946,8 +926,7 @@ pub fn falling_block_to_nbt(
|
||||
state: Tag,
|
||||
position: Vec3,
|
||||
motion: Vec3,
|
||||
time: i32,
|
||||
drop_item: bool,
|
||||
time: u32,
|
||||
uuid: [i32; 4],
|
||||
) -> Tag {
|
||||
let base = Base {
|
||||
@@ -962,8 +941,8 @@ pub fn falling_block_to_nbt(
|
||||
let mut tag = base.tag(None);
|
||||
for (key, value) in [
|
||||
("BlockState", state),
|
||||
("Time", Tag::Int(time)),
|
||||
("DropItem", Tag::Byte(i8::from(drop_item))),
|
||||
("Time", Tag::Int(time as i32)),
|
||||
("DropItem", Tag::Byte(1)),
|
||||
("HurtEntities", Tag::Byte(0)),
|
||||
("CancelDrop", Tag::Byte(0)),
|
||||
("FallHurtAmount", Tag::Float(0)),
|
||||
@@ -974,23 +953,15 @@ pub fn falling_block_to_nbt(
|
||||
tag
|
||||
}
|
||||
/// (block state tag, position, motion, time)
|
||||
pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, i32)> {
|
||||
pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, u32)> {
|
||||
Some((
|
||||
tag.get("BlockState")?.clone(),
|
||||
position(tag)?,
|
||||
motion(tag),
|
||||
number(tag.get("Time")).unwrap_or(0) as i32,
|
||||
number(tag.get("Time")).unwrap_or(0).max(0) as u32,
|
||||
))
|
||||
}
|
||||
|
||||
/// `NoGravity` and `DropItem` (true when absent) of a saved falling block.
|
||||
pub fn falling_block_flags(tag: &Tag) -> (bool, bool) {
|
||||
(
|
||||
flag(tag, "NoGravity"),
|
||||
number(tag.get("DropItem")).is_none_or(|v| v != 0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Painting.addAdditionalSaveData: `facing`, `variant` and `block_pos`.
|
||||
pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let (lo, hi) = p.bounds();
|
||||
@@ -1136,19 +1107,22 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
|
||||
a.on_ground = flag(tag, "OnGround");
|
||||
Some(a)
|
||||
}
|
||||
/// A boat entity (`minecraft:<wood>_boat`, `minecraft:bamboo_chest_raft`...):
|
||||
/// chest boats keep their slots in `Items` (`ContainerHelper.saveAllItems`).
|
||||
/// A boat entity (`minecraft:<wood>_boat`, `minecraft:bamboo_raft`).
|
||||
pub fn boat_to_nbt(
|
||||
id: &str,
|
||||
wood: &str,
|
||||
position: Vec3,
|
||||
motion: Vec3,
|
||||
yaw: f32,
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&Tag>,
|
||||
items: Option<&[Option<crate::items::ItemStack>]>,
|
||||
) -> Tag {
|
||||
let mut tag = Base {
|
||||
id,
|
||||
let id = if wood == "bamboo" {
|
||||
"minecraft:bamboo_raft".to_owned()
|
||||
} else {
|
||||
format!("minecraft:{wood}_boat")
|
||||
};
|
||||
Base {
|
||||
id: &id,
|
||||
uuid,
|
||||
position,
|
||||
motion,
|
||||
@@ -1156,11 +1130,7 @@ pub fn boat_to_nbt(
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
}
|
||||
.tag(raw);
|
||||
if let (Some(items), Tag::Compound(map)) = (items, &mut tag) {
|
||||
map.insert("Items".into(), crate::save::encode_items(items));
|
||||
}
|
||||
tag
|
||||
.tag(raw)
|
||||
}
|
||||
/// A vehicle's base entity tag (boats and minecarts).
|
||||
pub fn vehicle_to_nbt(
|
||||
|
||||
+612
-152
File diff suppressed because it is too large.
Load diff
@@ -415,8 +415,6 @@ impl Game<'_> {
|
||||
pose[5] = deg(rot[2]);
|
||||
}
|
||||
}
|
||||
// Item layers pose from the hidden arms too (`visible` only skips drawing).
|
||||
let posed = poses.clone();
|
||||
let mut hide = |name: &str| {
|
||||
if poses.has(name) {
|
||||
let pose = poses.get(name);
|
||||
@@ -448,7 +446,6 @@ impl Game<'_> {
|
||||
0,
|
||||
out,
|
||||
);
|
||||
self.armor_stand_items(a, model, base, &posed, packed, out);
|
||||
// HumanoidArmorLayer: head, chest, legs, feet.
|
||||
for (slot, layer) in [
|
||||
(5usize, "helmet"),
|
||||
@@ -565,120 +562,3 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `ItemInHandLayer` and `CustomHeadLayer` of an armour stand: the hand
|
||||
/// items in the third-person contexts, and a non-armour head item on the
|
||||
/// head (`translateToHead`: down a quarter, turned, 0.625 scale).
|
||||
fn armor_stand_items(
|
||||
&self,
|
||||
a: &ArmorStand,
|
||||
model: &Part,
|
||||
base: glam::Mat4,
|
||||
poses: &Poses,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::mobs::render::part_matrix;
|
||||
let owner = super::item_draw::owner_at(
|
||||
[a.position.x, a.position.y, a.position.z],
|
||||
a.yaw,
|
||||
"minecraft:armor_stand",
|
||||
);
|
||||
// `useBabyOffset`: small stands use the baby hand offsets.
|
||||
let (ox, oy, oz) = if a.small {
|
||||
(0.0, 1.0, -4.5)
|
||||
} else {
|
||||
(1.0, 2.0, -10.0)
|
||||
};
|
||||
for (slot, left, context) in [
|
||||
(0usize, false, DisplayContext::ThirdPersonRightHand),
|
||||
(1, true, DisplayContext::ThirdPersonLeftHand),
|
||||
] {
|
||||
let Some(stack) = &a.equipment[slot] else {
|
||||
continue;
|
||||
};
|
||||
let arm = if left { "left_arm" } else { "right_arm" };
|
||||
let Some(hand) = part_matrix(model, base, poses, arm, 0.0) else {
|
||||
continue;
|
||||
};
|
||||
let pose = hand
|
||||
* glam::Mat4::from_rotation_x((-90.0f32).to_radians())
|
||||
* glam::Mat4::from_rotation_y(std::f32::consts::PI)
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(
|
||||
if left { -ox } else { ox } / 16.0,
|
||||
oy / 16.0,
|
||||
oz / 16.0,
|
||||
));
|
||||
let seed = a.id as i32 + if left { 1 } else { 2 };
|
||||
let state = self.resolve_item(stack, context, Some(&owner), seed);
|
||||
self.submit_item_world(&state, pose, light, out);
|
||||
}
|
||||
// `CustomHeadLayer`: a worn skull draws the skull model.
|
||||
if let Some(stack) = &a.equipment[5]
|
||||
&& let Some(kind) = skull_kind(&stack.item)
|
||||
&& let Some(head) = part_matrix(model, base, poses, "head", 0.0)
|
||||
{
|
||||
let pose = head * glam::Mat4::from_scale(glam::Vec3::splat(1.1875));
|
||||
let special = horizon_assets::item_model::SpecialModel {
|
||||
kind: if kind == "player" {
|
||||
horizon_assets::item_model::SpecialKind::PlayerHead
|
||||
} else {
|
||||
horizon_assets::item_model::SpecialKind::Head {
|
||||
kind: kind.to_owned(),
|
||||
texture: None,
|
||||
animation: 0.0,
|
||||
}
|
||||
},
|
||||
};
|
||||
let mut sink = horizon_client::item_render::WorldSink {
|
||||
out,
|
||||
light,
|
||||
lights: Some(horizon_client::block_entity_render::diffuse_lights(
|
||||
self.dim == super::Dim::Nether,
|
||||
)),
|
||||
};
|
||||
self.res.item_renderer.submit_special(
|
||||
&special,
|
||||
&serde_json::Value::Null,
|
||||
false,
|
||||
pose,
|
||||
&self.res.atlas.sprites,
|
||||
&self.item_host(),
|
||||
&mut sink,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A head item that is not worn armour.
|
||||
if let Some(stack) = &a.equipment[5]
|
||||
&& self
|
||||
.res
|
||||
.items
|
||||
.get(&stack.item)
|
||||
.is_none_or(|i| i.equip_asset.is_none())
|
||||
&& let Some(head) = part_matrix(model, base, poses, "head", 0.0)
|
||||
{
|
||||
let pose = head
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0))
|
||||
* glam::Mat4::from_rotation_y(std::f32::consts::PI)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(0.625, -0.625, -0.625));
|
||||
// `updateForLiving`: seeded by the entity id plus HEAD's ordinal.
|
||||
let state =
|
||||
self.resolve_item(stack, DisplayContext::Head, Some(&owner), a.id as i32 + 5);
|
||||
self.submit_item_world(&state, pose, light, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AbstractSkullBlock.getType` of a skull item.
|
||||
fn skull_kind(item: &str) -> Option<&'static str> {
|
||||
Some(match item {
|
||||
"minecraft:skeleton_skull" => "skeleton",
|
||||
"minecraft:wither_skeleton_skull" => "wither_skeleton",
|
||||
"minecraft:zombie_head" => "zombie",
|
||||
"minecraft:creeper_head" => "creeper",
|
||||
"minecraft:dragon_head" => "dragon",
|
||||
"minecraft:piglin_head" => "piglin",
|
||||
"minecraft:player_head" => "player",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -2,10 +2,14 @@
|
||||
//! banner item to the block when placed and back to the dropped item when
|
||||
//! broken (`copy_components` in the banner loot tables).
|
||||
use super::Game;
|
||||
use glam::{EulerRot, Mat4, Quat, Vec3};
|
||||
use horizon_client::{
|
||||
banner::{self, BannerLayer},
|
||||
block_entity_render::{Paint, draw_part},
|
||||
containers::{Banner, BlockEntity},
|
||||
hud::{HudBatch, rgba},
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -30,6 +34,161 @@ impl Game<'_> {
|
||||
b.name = stack.custom_name().map(str::to_owned);
|
||||
}
|
||||
}
|
||||
/// A banner in a slot: `item/template_banner`'s `gui` transform on the
|
||||
/// banner model (pole, bar and flag with every layer), flat-lit and
|
||||
/// depth-sorted. False when the stack is not a banner.
|
||||
pub(super) fn draw_banner_icon(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
stack: &ItemStack,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) -> bool {
|
||||
let Some(base) = banner::banner_color(&stack.item) else {
|
||||
return false;
|
||||
};
|
||||
let Some((main, flag)) = self.res.block_entity_render.banner_models() else {
|
||||
return true;
|
||||
};
|
||||
let banners = &self.res.stations.banners;
|
||||
let sprites = &self.res.atlas.sprites;
|
||||
// display.gui, then the item's special transformation (a banner is
|
||||
// modelled upside down and 2/3 size around the block centre).
|
||||
let m = Mat4::from_translation(Vec3::new(0.0, -3.25 / 16.0, 0.0))
|
||||
* Mat4::from_quat(Quat::from_euler(
|
||||
EulerRot::XYZ,
|
||||
30f32.to_radians(),
|
||||
20f32.to_radians(),
|
||||
0.0,
|
||||
))
|
||||
* Mat4::from_scale(Vec3::splat(0.5325))
|
||||
* Mat4::from_translation(Vec3::new(0.0, -0.5, 0.0))
|
||||
* Mat4::from_scale(Vec3::new(2.0 / 3.0, -2.0 / 3.0, -2.0 / 3.0));
|
||||
let wave = |name: &str, p: &mut [f32; 9]| {
|
||||
if name == "flag" {
|
||||
p[3] = (-0.0125 + 0.01) * std::f32::consts::PI;
|
||||
}
|
||||
};
|
||||
let none = |_: &str, _: &mut [f32; 9]| {};
|
||||
let mut passes: Vec<(&str, u32, bool, String)> = vec![
|
||||
("entity/banner/banner_base", 0xFFFFFF, true, String::new()),
|
||||
(
|
||||
"entity/banner/base",
|
||||
banner::dye_rgb(base),
|
||||
false,
|
||||
String::new(),
|
||||
),
|
||||
];
|
||||
for layer in banner::layers(stack).iter().take(16) {
|
||||
passes.push((
|
||||
"",
|
||||
banner::dye_rgb(&layer.color),
|
||||
false,
|
||||
banners.banner_texture(&layer.pattern),
|
||||
));
|
||||
}
|
||||
// (depth, pass, screen corners, uv, colour)
|
||||
let mut faces: Vec<(f32, usize, [[f32; 2]; 4], [[f32; 2]; 4], u32)> = Vec::new();
|
||||
for (index, (fixed, tint, with_pole, dynamic)) in passes.iter().enumerate() {
|
||||
let name = if fixed.is_empty() {
|
||||
dynamic.as_str()
|
||||
} else {
|
||||
fixed
|
||||
};
|
||||
let Some(sprite) = sprites.get(name).copied() else {
|
||||
continue;
|
||||
};
|
||||
let paint = Paint {
|
||||
sprite,
|
||||
tint: 0xFFFFFF,
|
||||
light: 0,
|
||||
lights: (Vec3::ZERO, Vec3::ZERO),
|
||||
};
|
||||
let mut verts: Vec<Vertex> = Vec::new();
|
||||
if *with_pole {
|
||||
draw_part(main, "root", m, &none, paint, &mut verts);
|
||||
}
|
||||
draw_part(flag, "root", m, &wave, paint, &mut verts);
|
||||
for quad in verts.as_chunks::<4>().0 {
|
||||
let p = |i: usize| Vec3::from(quad[i].pos);
|
||||
let normal = (p(1) - p(0)).cross(p(2) - p(0)).normalize_or_zero();
|
||||
// Faces turned away from the viewer are culled.
|
||||
if normal.z <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let depth = (0..4).map(|i| p(i).z).sum::<f32>() / 4.0;
|
||||
let shade = (0.6 + 0.4 * normal.z).min(1.0);
|
||||
let c = |s: u32| (((tint >> s) & 255) as f32 * shade) as u8;
|
||||
faces.push((
|
||||
depth,
|
||||
index,
|
||||
std::array::from_fn(|i| {
|
||||
[
|
||||
x + 8.0 + quad[i].pos[0] * 16.0,
|
||||
y + 8.0 - quad[i].pos[1] * 16.0,
|
||||
]
|
||||
}),
|
||||
std::array::from_fn(|i| quad[i].uv),
|
||||
rgba(c(16), c(8), c(0), 255),
|
||||
));
|
||||
}
|
||||
}
|
||||
faces.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||
for (_, _, corners, uv, color) in faces {
|
||||
batch.quad(corners, uv, color);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// A banner in the first-person hand: the standing banner model under the
|
||||
/// item's `firstperson_righthand` transform (`m`) and its special
|
||||
/// transformation. False when the stack is not a banner.
|
||||
pub(super) fn held_banner_geometry(
|
||||
&self,
|
||||
m: Mat4,
|
||||
stack: &ItemStack,
|
||||
packed: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) -> bool {
|
||||
let Some(base) = banner::banner_color(&stack.item) else {
|
||||
return false;
|
||||
};
|
||||
let Some((main, flag)) = self.res.block_entity_render.banner_models() else {
|
||||
return true;
|
||||
};
|
||||
let m = m
|
||||
* Mat4::from_translation(Vec3::new(0.5, 0.0, 0.5))
|
||||
* Mat4::from_scale(Vec3::new(2.0 / 3.0, -2.0 / 3.0, -2.0 / 3.0));
|
||||
let wave = |name: &str, p: &mut [f32; 9]| {
|
||||
if name == "flag" {
|
||||
p[3] = (-0.0125 + 0.01) * std::f32::consts::PI;
|
||||
}
|
||||
};
|
||||
let none = |_: &str, _: &mut [f32; 9]| {};
|
||||
let lights = horizon_client::block_entity_render::diffuse_lights(false);
|
||||
let sprites = &self.res.atlas.sprites;
|
||||
let paint = |name: &str, tint: u32| {
|
||||
sprites.get(name).map(|&sprite| Paint {
|
||||
sprite,
|
||||
tint,
|
||||
light: packed,
|
||||
lights,
|
||||
})
|
||||
};
|
||||
if let Some(p) = paint("entity/banner/banner_base", 0xFFFFFF) {
|
||||
draw_part(main, "root", m, &none, p, out);
|
||||
draw_part(flag, "root", m, &wave, p, out);
|
||||
}
|
||||
if let Some(p) = paint("entity/banner/base", banner::dye_rgb(base)) {
|
||||
draw_part(flag, "root", m, &wave, p, out);
|
||||
}
|
||||
for layer in banner::layers(stack).iter().take(16) {
|
||||
let texture = self.res.stations.banners.banner_texture(&layer.pattern);
|
||||
if let Some(p) = paint(&texture, banner::dye_rgb(&layer.color)) {
|
||||
draw_part(flag, "root", m, &wave, p, out);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The layers a banner dropped at `pos` carries.
|
||||
pub(super) fn attach_banner_layers(&mut self, pos: [i32; 3], stack: &mut ItemStack) {
|
||||
if !stack.item.ends_with("_banner") {
|
||||
|
||||
@@ -48,12 +48,7 @@ pub(super) struct Boat {
|
||||
/// left, right, up, down
|
||||
pub input: [bool; 4],
|
||||
pub on_ground: bool,
|
||||
/// `ChestBoat`: a chest with 27 slots in the back seat.
|
||||
pub chest: bool,
|
||||
pub items: Vec<Option<ItemStack>>,
|
||||
}
|
||||
/// `ChestBoat.CONTAINER_SIZE`.
|
||||
pub(super) const CHEST_SLOTS: usize = 27;
|
||||
const WIDTH: f64 = 1.375;
|
||||
const HEIGHT: f64 = 0.5625;
|
||||
/// Player.DEFAULT_VEHICLE_ATTACHMENT minus the boat's passenger attachment.
|
||||
@@ -81,16 +76,8 @@ impl Boat {
|
||||
paddling: [false; 2],
|
||||
input: [false; 4],
|
||||
on_ground: false,
|
||||
chest: false,
|
||||
items: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A chest boat or chest raft.
|
||||
pub fn with_chest(mut self) -> Self {
|
||||
self.chest = true;
|
||||
self.items = vec![None; CHEST_SLOTS];
|
||||
self
|
||||
}
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
let (w, p) = (WIDTH / 2.0, self.position);
|
||||
Aabb::new(
|
||||
@@ -108,30 +95,6 @@ impl Boat {
|
||||
}
|
||||
}
|
||||
/// Item id -> (boat entity id, wood).
|
||||
impl Boat {
|
||||
/// The vanilla entity type id (`oak_boat`, `bamboo_chest_raft`), also
|
||||
/// the item it drops.
|
||||
pub(super) fn entity_type(&self) -> String {
|
||||
let chest = if self.chest { "chest_" } else { "" };
|
||||
if &*self.wood == "bamboo" {
|
||||
format!("minecraft:bamboo_{chest}raft")
|
||||
} else {
|
||||
format!("minecraft:{}_{chest}boat", self.wood)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The wood of a boat or chest boat item or entity type, and whether it
|
||||
/// carries a chest.
|
||||
pub(super) fn any_boat_wood(item: &str) -> Option<(&str, bool)> {
|
||||
let name = item.strip_prefix("minecraft:")?;
|
||||
if let Some(wood) = name
|
||||
.strip_suffix("_chest_boat")
|
||||
.or_else(|| name.strip_suffix("_chest_raft"))
|
||||
{
|
||||
return Some((wood, true));
|
||||
}
|
||||
boat_wood(item).map(|w| (w, false))
|
||||
}
|
||||
pub(super) fn boat_wood(item: &str) -> Option<&str> {
|
||||
let name = item.strip_prefix("minecraft:")?;
|
||||
name.strip_suffix("_boat")
|
||||
@@ -140,20 +103,10 @@ pub(super) fn boat_wood(item: &str) -> Option<&str> {
|
||||
}
|
||||
pub(super) struct BoatModels {
|
||||
pub models: HashMap<String, Part>,
|
||||
pub chest_models: HashMap<String, Part>,
|
||||
}
|
||||
impl BoatModels {
|
||||
/// Whether a boat of this wood (with or without a chest) can be drawn.
|
||||
pub fn has(&self, wood: &str, chest: bool) -> bool {
|
||||
if chest {
|
||||
self.chest_models.contains_key(wood)
|
||||
} else {
|
||||
self.models.contains_key(wood)
|
||||
}
|
||||
}
|
||||
pub fn load(root: &std::path::Path) -> Self {
|
||||
let mut models = HashMap::new();
|
||||
let mut chest_models = HashMap::new();
|
||||
if let Ok(bytes) = std::fs::read(root.join("entity-models.json"))
|
||||
&& let Ok(serde_json::Value::Object(all)) =
|
||||
serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||
@@ -166,19 +119,9 @@ impl BoatModels {
|
||||
{
|
||||
models.insert(wood.to_owned(), part);
|
||||
}
|
||||
if let Some(wood) = key
|
||||
.strip_prefix("minecraft:chest_boat/")
|
||||
.and_then(|k| k.strip_suffix("#main"))
|
||||
&& let Some(part) = horizon_client::mobs::load_model(root, key)
|
||||
{
|
||||
chest_models.insert(wood.to_owned(), part);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
models,
|
||||
chest_models,
|
||||
}
|
||||
Self { models }
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
@@ -514,10 +457,10 @@ impl Game<'_> {
|
||||
}
|
||||
/// `BoatItem.use`: aim at a block or the water surface.
|
||||
pub(super) fn place_boat(&mut self, stack: &ItemStack) -> bool {
|
||||
let Some((wood, chest)) = any_boat_wood(&stack.item) else {
|
||||
let Some(wood) = boat_wood(&stack.item) else {
|
||||
return false;
|
||||
};
|
||||
if !self.boat_models.has(wood, chest) {
|
||||
if !self.boat_models.models.contains_key(wood) {
|
||||
return false;
|
||||
}
|
||||
let Some(eye) = self.eye_position() else {
|
||||
@@ -564,15 +507,12 @@ impl Game<'_> {
|
||||
(None, Some(w)) => w.0,
|
||||
(None, None) => return false,
|
||||
};
|
||||
let mut boat = Boat::new(
|
||||
let boat = Boat::new(
|
||||
self.next_entity + 1,
|
||||
Arc::from(wood),
|
||||
Vec3::new(hit[0], hit[1], hit[2]),
|
||||
self.yaw,
|
||||
);
|
||||
if chest {
|
||||
boat = boat.with_chest();
|
||||
}
|
||||
let bb = boat.bounds();
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
let e = 1.0e-7;
|
||||
@@ -635,24 +575,11 @@ impl Game<'_> {
|
||||
return false;
|
||||
};
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
|
||||
if !self.before_block(d) {
|
||||
return false;
|
||||
}
|
||||
// ChestBoat.interact: sneaking (or a full seat) opens the chest.
|
||||
if self.boats[i].chest && (sneaking || self.riding.is_some()) {
|
||||
let b = &self.boats[i];
|
||||
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, b.id as i32);
|
||||
self.world.block_entities.insert(
|
||||
key,
|
||||
horizon_client::containers::BlockEntity::Chest(b.items.clone()),
|
||||
);
|
||||
self.container = Some(key);
|
||||
self.screen = super::Screen::Inventory;
|
||||
self.capture(false);
|
||||
self.right = false;
|
||||
return true;
|
||||
}
|
||||
if sneaking || self.boats[i].out_of_control >= 60.0 || self.riding.is_some() {
|
||||
if !self.before_block(d)
|
||||
|| sneaking
|
||||
|| self.boats[i].out_of_control >= 60.0
|
||||
|| self.riding.is_some()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.riding = Some(self.boats[i].id);
|
||||
@@ -674,13 +601,13 @@ impl Game<'_> {
|
||||
let destroyed = self.creative || b.damage > 40.0;
|
||||
if destroyed {
|
||||
let b = self.boats.remove(i);
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
if !self.creative && self.ui.rules.bool("entity_drops") {
|
||||
// ChestBoat.destroy: chestVehicleDestroyed spills the contents.
|
||||
for stack in b.items.iter().flatten() {
|
||||
self.spawn_item(stack.clone(), at, super::dispenser::new_item_motion(), 10);
|
||||
}
|
||||
let item = b.entity_type();
|
||||
let item = if &*b.wood == "bamboo" {
|
||||
"minecraft:bamboo_raft".to_owned()
|
||||
} else {
|
||||
format!("minecraft:{}_boat", b.wood)
|
||||
};
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
self.spawn_item(
|
||||
ItemStack {
|
||||
item: Arc::from(item.as_str()),
|
||||
@@ -701,20 +628,14 @@ impl Game<'_> {
|
||||
pub(super) fn boat_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
let a = alpha as f32;
|
||||
for b in &self.boats {
|
||||
let models = if b.chest {
|
||||
&self.boat_models.chest_models
|
||||
} else {
|
||||
&self.boat_models.models
|
||||
};
|
||||
let Some(model) = models.get(&*b.wood) else {
|
||||
let Some(model) = self.boat_models.models.get(&*b.wood) else {
|
||||
continue;
|
||||
};
|
||||
let folder = if b.chest { "chest_boat" } else { "boat" };
|
||||
let Some(uv) = self
|
||||
.res
|
||||
.atlas
|
||||
.sprites
|
||||
.get(&format!("entity/{folder}/{}", b.wood))
|
||||
.get(&format!("entity/boat/{}", b.wood))
|
||||
.map(|r| [r.u0, r.v0, r.u1, r.v1])
|
||||
else {
|
||||
continue;
|
||||
|
||||
@@ -266,11 +266,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Target::Painting(_) => "minecraft:painting".into(),
|
||||
Target::Boat(id) => self
|
||||
.boats
|
||||
.iter()
|
||||
.find(|b| b.id == id)
|
||||
.map_or_else(|| "minecraft:oak_boat".into(), |b| b.entity_type()),
|
||||
Target::Boat(id) => self.boats.iter().find(|b| b.id == id).map_or_else(
|
||||
|| "minecraft:oak_boat".into(),
|
||||
|b| {
|
||||
let kind = if &*b.wood == "bamboo" { "raft" } else { "boat" };
|
||||
format!("minecraft:{}_{kind}", b.wood)
|
||||
},
|
||||
),
|
||||
Target::Cart(id) => self
|
||||
.carts
|
||||
.iter()
|
||||
|
||||
@@ -73,13 +73,12 @@ impl Game<'_> {
|
||||
let uuid = self.uuid_for(id);
|
||||
let b = self.boats.iter().find(|b| b.id == id)?;
|
||||
entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
&b.wood,
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
)
|
||||
}
|
||||
Target::Cart(id) => {
|
||||
|
||||
@@ -509,7 +509,7 @@ impl Game<'_> {
|
||||
"frames": self.frames,
|
||||
"ticks": self.ticks,
|
||||
"day_time": self.world.day_time,
|
||||
"music": self.music.current(&self.audio.engine),
|
||||
"music": self.music.current(),
|
||||
"fishing": self.hook_status(),
|
||||
"tridents": self.tridents.len(),
|
||||
"rockets": self.rockets.len(),
|
||||
|
||||
@@ -37,6 +37,17 @@ pub(super) struct Creatures {
|
||||
/// The wandering trader spawner's clock.
|
||||
pub trader: super::wandering_trader::Spawner,
|
||||
}
|
||||
/// MobCategory caps (maxInstancesPerChunk with the 17x17 spawnable chunk area).
|
||||
const CATEGORIES: [(&str, usize); 7] = [
|
||||
("monster", 70),
|
||||
("creature", 10),
|
||||
("ambient", 15),
|
||||
("axolotls", 5),
|
||||
("underground_water_creature", 5),
|
||||
("water_creature", 5),
|
||||
("water_ambient", 20),
|
||||
];
|
||||
|
||||
struct ProjWorld<'a, 'w> {
|
||||
w: &'a MobWorld<'w>,
|
||||
targets: Vec<(Who, Aabb)>,
|
||||
@@ -344,6 +355,99 @@ impl Game<'_> {
|
||||
}
|
||||
horizon_client::chunk::MIN_Y
|
||||
}
|
||||
fn weighted_entry(
|
||||
&mut self,
|
||||
entries: &[horizon_client::biome::SpawnEntry],
|
||||
) -> Option<horizon_client::biome::SpawnEntry> {
|
||||
let total: u32 = entries.iter().map(|e| e.weight).sum();
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut r = self.world.random.next_int_bound(total as i32).ok()? as u32;
|
||||
for e in entries {
|
||||
if r < e.weight {
|
||||
return Some(e.clone());
|
||||
}
|
||||
r -= e.weight;
|
||||
}
|
||||
None
|
||||
}
|
||||
/// NaturalSpawner.spawnMobsForChunkGeneration (creature packs at generation).
|
||||
pub(super) fn spawn_generation_creatures(&mut self, cx: i32, cz: i32) {
|
||||
let Some(chunk) = self.world.chunks.get(&(cx, cz)).cloned() else {
|
||||
return;
|
||||
};
|
||||
let biome = self.res.biomes.get(chunk.biome(8, 64, 8)).clone();
|
||||
let Some(entries) = biome.spawns.get("creature").cloned() else {
|
||||
return;
|
||||
};
|
||||
let (xo, zo) = (cx * 16, cz * 16);
|
||||
while self.world.random.next_float() < biome.creature_probability {
|
||||
let Some(entry) = self.weighted_entry(&entries) else {
|
||||
return;
|
||||
};
|
||||
let count = entry.min
|
||||
+ self
|
||||
.world
|
||||
.random
|
||||
.next_int_bound((entry.max - entry.min + 1) as i32)
|
||||
.unwrap_or(0) as u32;
|
||||
let mut x = xo + self.world.random.next_int_bound(16).unwrap_or(0);
|
||||
let mut z = zo + self.world.random.next_int_bound(16).unwrap_or(0);
|
||||
let (sx, sz) = (x, z);
|
||||
let Some(kind) = self.res.mob_types.get(&entry.entity).cloned() else {
|
||||
continue;
|
||||
};
|
||||
for _ in 0..count {
|
||||
let mut success = false;
|
||||
let mut attempts = 0;
|
||||
while !success && attempts < 4 {
|
||||
attempts += 1;
|
||||
let mut y = self.motion_top(x, z);
|
||||
let placement = self.res.mob_data.placement(&entry.entity);
|
||||
if placement == horizon_client::mobs::data::Placement::OnGround {
|
||||
let below = self.world.block(x, y - 1, z);
|
||||
if self.res.mob_data.state(below).flags & horizon_client::mobs::data::LAND
|
||||
!= 0
|
||||
{
|
||||
y -= 1;
|
||||
}
|
||||
}
|
||||
let ok = {
|
||||
let w = self.mob_world_base(&[]);
|
||||
spawning::placement_ok(kind.species, &w, x, y, z) && {
|
||||
let mut r = self.world.random.clone();
|
||||
|
||||
spawning::spawn_rules_ok(kind.species, &w, &mut r, x, y, z)
|
||||
}
|
||||
};
|
||||
if ok {
|
||||
let width = kind.width;
|
||||
let fx =
|
||||
f64::from(x).clamp(f64::from(xo) + width, f64::from(xo) + 16.0 - width);
|
||||
let fz =
|
||||
f64::from(z).clamp(f64::from(zo) + width, f64::from(zo) + 16.0 - width);
|
||||
if self
|
||||
.spawn_mob(&entry.entity, [fx, f64::from(y), fz], true)
|
||||
.is_some()
|
||||
{
|
||||
success = true;
|
||||
}
|
||||
}
|
||||
x += self.world.random.next_int_bound(5).unwrap_or(0)
|
||||
- self.world.random.next_int_bound(5).unwrap_or(0);
|
||||
z += self.world.random.next_int_bound(5).unwrap_or(0)
|
||||
- self.world.random.next_int_bound(5).unwrap_or(0);
|
||||
while x < xo || x >= xo + 16 || z < zo || z >= zo + 16 {
|
||||
x = sx + self.world.random.next_int_bound(5).unwrap_or(0)
|
||||
- self.world.random.next_int_bound(5).unwrap_or(0);
|
||||
z = sz + self.world.random.next_int_bound(5).unwrap_or(0)
|
||||
- self.world.random.next_int_bound(5).unwrap_or(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn mob_world_base<'s>(&'s self, others: &'s [Seen]) -> MobWorld<'s> {
|
||||
let (rain, thunder) = self.world.weather.levels(1.0);
|
||||
MobWorld {
|
||||
@@ -363,8 +467,6 @@ impl Game<'_> {
|
||||
block_tags: &self.res.items.block_tags,
|
||||
biomes: &self.res.biomes,
|
||||
mob_griefing: self.ui.rules.bool("mob_griefing"),
|
||||
end: self.dim == Dim::End,
|
||||
world_seed: self.level.seed,
|
||||
difficulty: self.local_difficulty(
|
||||
self.player
|
||||
.as_ref()
|
||||
@@ -412,6 +514,162 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// NaturalSpawner.spawnForChunk over the chunks within 8 of the player.
|
||||
fn spawn_natural(&mut self) {
|
||||
let Some(p) = self.player.as_ref().map(|p| p.position) else {
|
||||
return;
|
||||
};
|
||||
let (pcx, pcz) = (p.x.floor() as i32 >> 4, p.z.floor() as i32 >> 4);
|
||||
let mut counts = [0usize; 7];
|
||||
for m in &self.mobs {
|
||||
if let Some(i) = CATEGORIES.iter().position(|c| c.0 == m.kind.category) {
|
||||
counts[i] += 1;
|
||||
}
|
||||
}
|
||||
let creature_tick = self.world.game_time % 400 == 0;
|
||||
let mut chunks: Vec<(i32, i32)> = (-8..=8)
|
||||
.flat_map(|dx| (-8..=8).map(move |dz| (pcx + dx, pcz + dz)))
|
||||
.filter(|c| self.world.chunks.contains_key(c))
|
||||
.collect();
|
||||
// ChunkMap: shuffled each tick.
|
||||
for i in (1..chunks.len()).rev() {
|
||||
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
|
||||
chunks.swap(i, j);
|
||||
}
|
||||
for (cx, cz) in chunks {
|
||||
for (ci, (category, cap)) in CATEGORIES.iter().enumerate() {
|
||||
if counts[ci] >= *cap || *category == "creature" && !creature_tick {
|
||||
continue;
|
||||
}
|
||||
counts[ci] += self.spawn_category_for_chunk(category, cx, cz, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// NaturalSpawner.spawnCategoryForPosition at a random point in the chunk.
|
||||
fn spawn_category_for_chunk(
|
||||
&mut self,
|
||||
category: &str,
|
||||
cx: i32,
|
||||
cz: i32,
|
||||
player: Vec3,
|
||||
) -> usize {
|
||||
let x0 = cx * 16 + self.world.random.next_int_bound(16).unwrap_or(0);
|
||||
let z0 = cz * 16 + self.world.random.next_int_bound(16).unwrap_or(0);
|
||||
let top = self.motion_top(x0, z0) + 1;
|
||||
let min = horizon_client::chunk::MIN_Y;
|
||||
let y = min + self.world.random.next_int_bound(top - min + 1).unwrap_or(0);
|
||||
if y < min + 1 {
|
||||
return 0;
|
||||
}
|
||||
let start = self.res.facts.get(self.world.block(x0, y, z0));
|
||||
if start.full_collision {
|
||||
return 0;
|
||||
}
|
||||
let mut spawned = 0;
|
||||
for _ in 0..3 {
|
||||
let (mut x, mut z) = (x0, z0);
|
||||
let mut entry: Option<horizon_client::biome::SpawnEntry> = None;
|
||||
let mut group = (self.world.random.next_float() * 4.0).ceil() as u32;
|
||||
let mut cluster = 0;
|
||||
let mut i = 0;
|
||||
while i < group {
|
||||
i += 1;
|
||||
x += self.world.random.next_int_bound(6).unwrap_or(0)
|
||||
- self.world.random.next_int_bound(6).unwrap_or(0);
|
||||
z += self.world.random.next_int_bound(6).unwrap_or(0)
|
||||
- self.world.random.next_int_bound(6).unwrap_or(0);
|
||||
let (fx, fz) = (f64::from(x) + 0.5, f64::from(z) + 0.5);
|
||||
let d2 = (fx - player.x).powi(2)
|
||||
+ (f64::from(y) - player.y).powi(2)
|
||||
+ (fz - player.z).powi(2);
|
||||
if d2 <= 576.0 {
|
||||
continue;
|
||||
}
|
||||
if entry.is_none() {
|
||||
let Some(chunk) = self.world.chunks.get(&(x >> 4, z >> 4)) else {
|
||||
break;
|
||||
};
|
||||
let biome = self.res.biomes.get(chunk.biome(x, y, z)).clone();
|
||||
let list = match self.structure_spawns(category, [x, y, z]) {
|
||||
Some(list) => list,
|
||||
None => match biome.spawns.get(category).cloned() {
|
||||
Some(list) => list,
|
||||
None => break,
|
||||
},
|
||||
};
|
||||
let list: Vec<_> = list
|
||||
.into_iter()
|
||||
.filter(|e| self.res.mob_types.contains_key(&e.entity))
|
||||
.collect();
|
||||
let Some(e) = self.weighted_entry(&list) else {
|
||||
break;
|
||||
};
|
||||
group = e.min
|
||||
+ self
|
||||
.world
|
||||
.random
|
||||
.next_int_bound((e.max - e.min + 1) as i32)
|
||||
.unwrap_or(0) as u32;
|
||||
entry = Some(e);
|
||||
}
|
||||
let e = entry.clone().unwrap_or_else(|| unreachable!());
|
||||
let despawn = if category == "water_ambient" {
|
||||
64.0
|
||||
} else {
|
||||
128.0
|
||||
};
|
||||
if d2 > despawn * despawn {
|
||||
continue;
|
||||
}
|
||||
let Some(kind) = self.res.mob_types.get(&e.entity).cloned() else {
|
||||
break;
|
||||
};
|
||||
let ok = {
|
||||
let w = self.mob_world_base(&[]);
|
||||
let mut r = self.world.random.clone();
|
||||
let placed = spawning::placement_ok(kind.species, &w, x, y, z)
|
||||
&& spawning::spawn_rules_ok(kind.species, &w, &mut r, x, y, z);
|
||||
let half = kind.width / 2.0;
|
||||
let room = w.no_collision(Aabb::new(
|
||||
Vec3::new(fx - half, f64::from(y), fz - half),
|
||||
Vec3::new(fx + half, f64::from(y) + kind.height, fz + half),
|
||||
));
|
||||
// PathfinderMob.checkSpawnRules: walk target value >= 0 (monsters need darkness).
|
||||
let light_ok = !kind.category.eq("monster")
|
||||
|| matches!(
|
||||
kind.species,
|
||||
Species::Slime
|
||||
| Species::SulfurCube
|
||||
| Species::Ghast
|
||||
| Species::MagmaCube
|
||||
| Species::Guardian
|
||||
| Species::ElderGuardian
|
||||
)
|
||||
|| w.brightness(x, y, z) <= 0.5;
|
||||
let consumed = r;
|
||||
(placed && room && light_ok, consumed)
|
||||
};
|
||||
self.world.random = ok.1;
|
||||
if !ok.0 {
|
||||
continue;
|
||||
}
|
||||
if d2 > 16384.0 && kind.category != "creature" {
|
||||
continue;
|
||||
}
|
||||
if self
|
||||
.spawn_mob(&e.entity, [fx, f64::from(y), fz], false)
|
||||
.is_some()
|
||||
{
|
||||
cluster += 1;
|
||||
spawned += 1;
|
||||
if spawned >= 4 || cluster >= group.max(1) {
|
||||
return spawned;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
spawned
|
||||
}
|
||||
/// Advance every mob one tick, then apply their events.
|
||||
pub(super) fn tick_creatures(&mut self) -> Result<()> {
|
||||
self.attack_ticks = self.attack_ticks.saturating_add(1);
|
||||
@@ -430,12 +688,9 @@ impl Game<'_> {
|
||||
}
|
||||
self.tick_inhabited();
|
||||
self.tick_leads();
|
||||
self.tick_wandering_trader();
|
||||
self.spawn_natural();
|
||||
// ServerChunkCache.tickChunks: the custom spawners tick only with spawn_mobs.
|
||||
if self.ui.rules.bool("spawn_mobs") {
|
||||
self.tick_wandering_trader();
|
||||
self.tick_patrol_spawner();
|
||||
}
|
||||
self.tick_patrol_spawner();
|
||||
self.tick_raids();
|
||||
let uuid = self.level.player_uuid;
|
||||
let snapshot: Vec<Seen> = self.mobs.iter().map(|m| seen(m, uuid)).collect();
|
||||
@@ -463,6 +718,7 @@ impl Game<'_> {
|
||||
self.mobs.append(&mut mobs);
|
||||
self.sync_riders();
|
||||
self.tick_skeleton_traps();
|
||||
self.cache_mob_icons();
|
||||
self.tick_apparition();
|
||||
self.villager_reputation_tick();
|
||||
self.villager_pickups();
|
||||
@@ -478,25 +734,18 @@ impl Game<'_> {
|
||||
self.tick_player_effects();
|
||||
self.piglin_pickups();
|
||||
self.sulfur_cube_pickups();
|
||||
// Hurt and death sounds of damage dealt outside the mob ticks.
|
||||
let mut queued = Vec::new();
|
||||
for m in &mut self.mobs {
|
||||
queued.append(&mut m.outbox);
|
||||
}
|
||||
self.handle_mob_events(queued)?;
|
||||
Ok(())
|
||||
}
|
||||
/// LivingEntity.die / dropAllDeathLoot.
|
||||
fn mob_died(&mut self, m: &Mob) {
|
||||
let by_player = self.last_hurt_by_player.remove(&m.id) || m.ai.last_hurt_by_player > 0;
|
||||
let mut dying = m.clone();
|
||||
// LivingEntity.die: the death is heard, and a sculk catalyst nearby
|
||||
// takes the experience (before the loot is dropped).
|
||||
// takes the experience.
|
||||
self.sculk_mob_died(m);
|
||||
let consumed =
|
||||
self.catalyst_takes_experience([m.position.x, m.position.y, m.position.z], |_| {
|
||||
self.catalyst_takes_experience([m.position.x, m.position.y, m.position.z], |game| {
|
||||
if by_player {
|
||||
horizon_client::mobs::drops::base_experience(&mut dying).max(0) as u32
|
||||
experience_reward(m, &mut game.world.random)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
@@ -511,8 +760,103 @@ impl Game<'_> {
|
||||
self.score_entity_removed(m.id);
|
||||
self.raider_died(m, by_player);
|
||||
self.villager_murdered(m, by_player);
|
||||
let held = self.held().cloned();
|
||||
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
|
||||
self.drop_death_loot(&mut dying, by_player, consumed, pos);
|
||||
if !m.is_baby() {
|
||||
let mut drops = self.res.items.entity_drops(
|
||||
&m.kind.id,
|
||||
by_player,
|
||||
held.as_ref(),
|
||||
&mut self.world.random,
|
||||
);
|
||||
if m.species() == Species::Sheep && !m.extra.sheared {
|
||||
let color = horizon_client::mobs::render::DYE_NAMES[m.extra.color as usize & 15];
|
||||
drops.extend(self.res.items.loot_table(
|
||||
&format!("minecraft:entities/sheep/{color}"),
|
||||
by_player,
|
||||
held.as_ref(),
|
||||
&mut self.world.random,
|
||||
));
|
||||
}
|
||||
for d in drops {
|
||||
if d.count > 0 && &*d.item != "minecraft:air" {
|
||||
self.spawn_item(d, pos, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
}
|
||||
// Saddles, horse armour and chests always drop.
|
||||
let mut gear: Vec<&str> = Vec::new();
|
||||
if let Some(saddle) = m.saddle_item() {
|
||||
self.spawn_item(saddle, pos, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
// WitherBoss.dropCustomDeathLoot.
|
||||
if m.species() == Species::Wither {
|
||||
gear.push("minecraft:nether_star");
|
||||
}
|
||||
if species_drops_harness(m)
|
||||
&& let Some(h) = m.extra.head.as_deref()
|
||||
{
|
||||
gear.push(h);
|
||||
}
|
||||
if m.extra.chested && horizon_client::mobs::equine::is_equine(m.species()) {
|
||||
gear.push("minecraft:chest");
|
||||
}
|
||||
if horizon_client::mobs::equine::is_equine(m.species())
|
||||
&& let Some(armor) = m.extra.head.as_deref()
|
||||
{
|
||||
gear.push(armor);
|
||||
}
|
||||
for item in gear {
|
||||
self.spawn_item(ItemStack::new(item, 1), pos, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
// AbstractChestedHorse.dropEquipment: the chest contents.
|
||||
for stack in m.extra.items.clone().into_iter().flatten() {
|
||||
self.spawn_item(stack, pos, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
self.raider_death_loot(m, pos);
|
||||
let worn = if horizon_client::mobs::equine::is_equine(m.species()) {
|
||||
[None, None, None, None]
|
||||
} else {
|
||||
[
|
||||
m.extra.head.clone(),
|
||||
m.extra.chest.clone(),
|
||||
m.extra.legs.clone(),
|
||||
m.extra.feet.clone(),
|
||||
]
|
||||
};
|
||||
for (index, item) in std::iter::once(m.extra.main_hand.clone())
|
||||
.chain(worn)
|
||||
.enumerate()
|
||||
{
|
||||
// Main hand, then head to feet (the enchantment slots).
|
||||
let slot = if index == 0 {
|
||||
horizon_client::mobs::gear::MAIN
|
||||
} else {
|
||||
horizon_client::mobs::gear::HEAD + index - 1
|
||||
};
|
||||
// Equipment drop chance 8.5% (+ player kill only).
|
||||
// Vexes never drop their sword.
|
||||
if let Some(item) = &item
|
||||
&& by_player
|
||||
&& &**item != horizon_client::mobs::raider::BANNER
|
||||
&& m.species() != Species::Vex
|
||||
&& self.world.random.next_float() < 0.085
|
||||
{
|
||||
let mut stack = ItemStack::new(item, 1);
|
||||
if let Some(g) = horizon_client::mobs::gear::gear(m) {
|
||||
for (e, l) in &g.slots[slot] {
|
||||
stack.enchant(e, *l);
|
||||
}
|
||||
}
|
||||
self.spawn_item(stack, pos, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
if by_player && !consumed {
|
||||
let xp = experience_reward(m, &mut self.world.random);
|
||||
if xp > 0 {
|
||||
self.spawn_orbs(pos, xp);
|
||||
}
|
||||
}
|
||||
if m.species() == Species::SulfurCube {
|
||||
self.split_sulfur_cube(m);
|
||||
}
|
||||
@@ -537,89 +881,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `dropAllDeathLoot` through `mobs::drops`: the entity loot table from the
|
||||
/// world's random sequence, equipment by drop chance, species extras and
|
||||
/// the experience orbs, spawned in order.
|
||||
fn drop_death_loot(&mut self, m: &mut Mob, by_player: bool, consumed: bool, pos: [f64; 3]) {
|
||||
use horizon_client::mobs::{DamageKind, Who, drops};
|
||||
use horizon_world::loot::{LootDamage, LootEntity};
|
||||
let Some(loot) = horizon_client::structure_blocks::loot_data(&self.options.resources)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let player = || {
|
||||
let mut e = LootEntity::new("minecraft:player");
|
||||
if let Some(held) = self.held() {
|
||||
e.equipment.push(("mainhand".into(), to_loot(held)));
|
||||
}
|
||||
e
|
||||
};
|
||||
let damage = m.last_damage.map(|(d, _)| {
|
||||
let source = match d.attacker {
|
||||
Some(Who::Player) => Some(player()),
|
||||
Some(Who::Mob(id)) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map(|o| drops::loot_entity(o, None)),
|
||||
None => None,
|
||||
};
|
||||
let direct = match d.kind {
|
||||
DamageKind::Arrow => Some(LootEntity {
|
||||
kind: "minecraft:arrow".into(),
|
||||
..LootEntity::default()
|
||||
}),
|
||||
DamageKind::Fireball => Some(LootEntity {
|
||||
kind: "minecraft:fireball".into(),
|
||||
..LootEntity::default()
|
||||
}),
|
||||
_ => source.clone(),
|
||||
};
|
||||
let player_source = matches!(d.attacker, Some(Who::Player));
|
||||
LootDamage {
|
||||
kind: damage_type(d.kind, player_source).into(),
|
||||
source,
|
||||
direct,
|
||||
}
|
||||
});
|
||||
let vehicle = m
|
||||
.extra
|
||||
.mount
|
||||
.and_then(|id| self.mobs.iter().find(|o| o.id == id))
|
||||
.map(|o| LootEntity::new(&o.kind.id));
|
||||
let death = drops::Death {
|
||||
loot: &loot,
|
||||
damage,
|
||||
player: by_player.then(player),
|
||||
luck: 0.0,
|
||||
vehicle,
|
||||
mob_drops: self.ui.rules.bool("mob_drops"),
|
||||
world_seed: self.level.seed,
|
||||
};
|
||||
let (items, skulls) = drops::die(m, &death, &mut self.rand_sequences);
|
||||
if skulls
|
||||
&& let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker)
|
||||
&& let Some(creeper) = self.mobs.iter_mut().find(|o| o.id == id)
|
||||
{
|
||||
// Creeper.droppedSkulls: one head per charged explosion.
|
||||
creeper.extra.charged = false;
|
||||
}
|
||||
let raider = horizon_client::mobs::raider::is_raider(m.species());
|
||||
for d in items {
|
||||
match d {
|
||||
drops::Drop::Item(stack) => {
|
||||
let item = if raider && stack.item == horizon_client::mobs::raider::BANNER {
|
||||
horizon_client::mobs::raider::ominous_banner()
|
||||
} else {
|
||||
from_loot(&stack)
|
||||
};
|
||||
self.spawn_item(item, pos, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
|
||||
drops::Drop::Orb(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
|
||||
for e in events {
|
||||
match e {
|
||||
@@ -689,18 +950,24 @@ impl Game<'_> {
|
||||
if let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
|
||||
let hit = t.damage(amount, source);
|
||||
if hit {
|
||||
killed = !t.alive();
|
||||
// The hurt or death sound the mob raised.
|
||||
let center = t.center();
|
||||
for event in std::mem::take(&mut t.outbox) {
|
||||
if let MobEvent::Sound(s, _, volume, pitch) = event {
|
||||
self.audio.play(
|
||||
&format!("minecraft:{s}"),
|
||||
Some(center),
|
||||
volume,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
let (voice, center, dead) = (
|
||||
horizon_client::mobs::ai::voice_sounds(t),
|
||||
t.center(),
|
||||
!t.alive(),
|
||||
);
|
||||
killed = dead;
|
||||
let pitch = (self.world.random.next_float()
|
||||
- self.world.random.next_float())
|
||||
* 0.2
|
||||
+ 1.0;
|
||||
let s = voice[if dead { 2 } else { 1 }];
|
||||
if !s.is_empty() {
|
||||
self.audio.play(
|
||||
&format!("minecraft:{s}"),
|
||||
Some(center),
|
||||
1.0,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2347,6 +2614,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn species_drops_harness(m: &Mob) -> bool {
|
||||
// Body equipment set to drop when killed: a harness, nautilus armour, a
|
||||
// sulfur cube's swallowed block.
|
||||
horizon_client::mobs::species::body_slot(m.species())
|
||||
}
|
||||
fn eat_sound(s: Species) -> &'static str {
|
||||
match s {
|
||||
Species::Pig => "entity.pig.eat",
|
||||
@@ -2361,66 +2633,37 @@ fn eat_sound(s: Species) -> &'static str {
|
||||
}
|
||||
}
|
||||
/// Mob.getBaseExperienceReward
|
||||
/// The damage type id of a Horizon damage kind.
|
||||
fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) -> &'static str {
|
||||
use horizon_client::mobs::DamageKind as K;
|
||||
match kind {
|
||||
K::PlayerAttack => "minecraft:player_attack",
|
||||
K::MobAttack => "minecraft:mob_attack",
|
||||
K::Arrow => "minecraft:arrow",
|
||||
K::Thrown => "minecraft:thrown",
|
||||
K::Fireball => "minecraft:fireball",
|
||||
K::Explosion if player => "minecraft:player_explosion",
|
||||
K::Explosion => "minecraft:explosion",
|
||||
K::Fall => "minecraft:fall",
|
||||
K::OnFire => "minecraft:on_fire",
|
||||
K::InFire => "minecraft:in_fire",
|
||||
K::Lava => "minecraft:lava",
|
||||
K::Drown => "minecraft:drown",
|
||||
K::Magic => "minecraft:magic",
|
||||
K::Sting => "minecraft:sting",
|
||||
K::Cactus => "minecraft:cactus",
|
||||
K::Generic => "minecraft:generic",
|
||||
K::InWall => "minecraft:in_wall",
|
||||
K::Thorns => "minecraft:thorns",
|
||||
K::SonicBoom => "minecraft:sonic_boom",
|
||||
fn experience_reward(m: &Mob, random: &mut impl RandomSource) -> u32 {
|
||||
use Species as S;
|
||||
match m.species() {
|
||||
S::Blaze | S::Ghast | S::Hoglin | S::Zoglin | S::PiglinBrute | S::Breeze => 10,
|
||||
S::Slime | S::MagmaCube => m.extra.size.max(1) as u32,
|
||||
S::Endermite => 3,
|
||||
S::Bat
|
||||
| S::Squid
|
||||
| S::GlowSquid
|
||||
| S::Cod
|
||||
| S::Salmon
|
||||
| S::TropicalFish
|
||||
| S::Pufferfish
|
||||
| S::Dolphin => 1 + random.next_int_bound(3).unwrap_or(0) as u32,
|
||||
S::IronGolem | S::SnowGolem | S::Villager | S::WanderingTrader => 0,
|
||||
// SulfurCube.getBaseExperienceReward.
|
||||
S::SulfurCube if m.is_baby() => 0,
|
||||
S::SulfurCube => 1 + random.next_int_bound(2).unwrap_or(0) as u32,
|
||||
// Animals in the monster category (Animal.getBaseExperienceReward).
|
||||
S::CamelHusk | S::ZombieNautilus => 1 + random.next_int_bound(3).unwrap_or(0) as u32,
|
||||
_ if m.hostile() => {
|
||||
5 + if m.extra.main_hand.is_some() {
|
||||
1 + random.next_int_bound(3).unwrap_or(0) as u32
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
_ if m.is_baby() => 0,
|
||||
_ => 1 + random.next_int_bound(3).unwrap_or(0) as u32,
|
||||
}
|
||||
}
|
||||
/// A Horizon item stack as a loot stack (id, count, damage, enchantments).
|
||||
fn to_loot(stack: &ItemStack) -> horizon_world::loot::LootStack {
|
||||
horizon_world::loot::LootStack {
|
||||
item: stack.item.to_string(),
|
||||
count: stack.count as i32,
|
||||
damage: stack.damage as i32,
|
||||
enchantments: stack
|
||||
.enchantments()
|
||||
.iter()
|
||||
.map(|(e, l)| (e.to_string(), *l as i32))
|
||||
.collect(),
|
||||
potion: None,
|
||||
map: false,
|
||||
ominous_amplifier: None,
|
||||
}
|
||||
}
|
||||
/// A generated loot stack as a Horizon item stack.
|
||||
fn from_loot(stack: &horizon_world::loot::LootStack) -> ItemStack {
|
||||
let mut item = ItemStack {
|
||||
item: Arc::from(stack.item.as_str()),
|
||||
count: stack.count.max(1) as u32,
|
||||
damage: stack.damage.max(0) as u32,
|
||||
extra: None,
|
||||
};
|
||||
for (id, level) in &stack.enchantments {
|
||||
item.enchant(id, (*level).max(0) as u32);
|
||||
}
|
||||
if let Some(potion) = &stack.potion {
|
||||
item = item.with_potion(potion);
|
||||
}
|
||||
if let Some(amplifier) = stack.ominous_amplifier.filter(|a| *a > 0) {
|
||||
item.data_mut().ominous_amplifier = Some(amplifier as u8);
|
||||
}
|
||||
item
|
||||
}
|
||||
/// Entity.push between overlapping mobs (LivingEntity.pushEntities).
|
||||
fn push_apart(mobs: &mut [Mob]) {
|
||||
let n = mobs.len();
|
||||
@@ -2464,10 +2707,7 @@ impl Game<'_> {
|
||||
pub(super) fn portal_spawn(&mut self, pos: [i32; 3]) {
|
||||
// ChunkMap.anyPlayerCloseEnoughForSpawning: a non-spectator within 128
|
||||
// blocks of the chunk centre.
|
||||
let centre = [
|
||||
f64::from((pos[0] >> 4) * 16 + 8),
|
||||
f64::from((pos[2] >> 4) * 16 + 8),
|
||||
];
|
||||
let centre = [f64::from((pos[0] >> 4) * 16 + 8), f64::from((pos[2] >> 4) * 16 + 8)];
|
||||
let spectator = self.spectator();
|
||||
let Some(close) = self.player.as_ref().filter(|_| !spectator).map(|p| {
|
||||
let (dx, dz) = (p.position.x - centre[0], p.position.z - centre[1]);
|
||||
@@ -2479,9 +2719,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let mut y = pos[1];
|
||||
while &*self.res.facts.get(self.world.block(pos[0], y, pos[2])).name
|
||||
== "minecraft:nether_portal"
|
||||
{
|
||||
while &*self.res.facts.get(self.world.block(pos[0], y, pos[2])).name == "minecraft:nether_portal" {
|
||||
y -= 1;
|
||||
}
|
||||
// isValidSpawn: a sturdy top face (portal frames emit no light).
|
||||
@@ -2489,11 +2727,7 @@ impl Game<'_> {
|
||||
if below.sturdy_full & (1 << 1) == 0 {
|
||||
return;
|
||||
}
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(y + 1),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
let at = [f64::from(pos[0]) + 0.5, f64::from(y + 1), f64::from(pos[2]) + 0.5];
|
||||
let _ = self.spawn_mob("minecraft:zombified_piglin", at, false);
|
||||
}
|
||||
}
|
||||
@@ -1,510 +0,0 @@
|
||||
//! The Rust side of tools/test_dispenser_parity.py: with
|
||||
//! `HORIZON_DISPENSE_FIXTURE=CASES,OUTPUT` the game loads its resources, runs
|
||||
//! every case (a dispenser or dropper with items, blocks and target entities
|
||||
//! around it) through `dispense_from` on a fresh headless world and prints what
|
||||
//! changed in the format of `VanillaDispenserOracle`.
|
||||
use super::{
|
||||
Game,
|
||||
dispenser::{Slot, body_in_head, mob_set_slot, mob_slot_item},
|
||||
};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
chunk::Chunk,
|
||||
containers::BlockEntity,
|
||||
gamerules::Value as RuleValue,
|
||||
item_data::{canon_stack, parse_stack},
|
||||
mobs::{Mob, Species},
|
||||
projectiles::ProjectileKind,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
position::BlockPos,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::{level::Ticks, registry::BlockStateId};
|
||||
use serde_json::Value;
|
||||
use std::{cell::RefCell, collections::HashMap, fmt::Write as _, io::Write as _, sync::Arc};
|
||||
|
||||
thread_local! {
|
||||
/// Level and game events recorded while a fixture case runs.
|
||||
static LOG: RefCell<Option<Vec<String>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Record a line if a fixture case is running.
|
||||
pub(super) fn record(line: impl FnOnce() -> String) {
|
||||
LOG.with(|log| {
|
||||
if let Some(lines) = log.borrow_mut().as_mut() {
|
||||
lines.push(line());
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `Level.levelEvent` lines for the level events the behaviour library reported.
|
||||
pub(super) fn record_events(events: &[horizon_world::level::LevelEvent]) {
|
||||
for event in events {
|
||||
if let horizon_world::level::LevelEvent::LevelEvent { id, pos, data } = event {
|
||||
record(|| format!("L {id} {},{},{} {data}", pos.x, pos.y, pos.z));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn num(v: f64) -> String {
|
||||
format!("{v:.6}")
|
||||
}
|
||||
fn floor_pos(p: [f64; 3]) -> String {
|
||||
format!(
|
||||
"{},{},{}",
|
||||
p[0].floor() as i32,
|
||||
p[1].floor() as i32,
|
||||
p[2].floor() as i32
|
||||
)
|
||||
}
|
||||
|
||||
/// The targets a case put in front of the dispenser.
|
||||
enum Target {
|
||||
Mob(u32),
|
||||
Stand(u32),
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Run every case of `spec` ("CASES,OUTPUT") and write the results.
|
||||
pub(super) fn dispense_fixture(&mut self, spec: &str) -> Result<()> {
|
||||
let (input, output) = spec
|
||||
.split_once(',')
|
||||
.ok_or("HORIZON_DISPENSE_FIXTURE=CASES,OUTPUT")?;
|
||||
// Saplings and feature placers grow from the live feature registry.
|
||||
self.install_live_features();
|
||||
let text = std::fs::read_to_string(input)?;
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(output)?);
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
let case: Value = serde_json::from_str(line)?;
|
||||
writeln!(out, "case {index}")?;
|
||||
match self.dispense_case(&case) {
|
||||
Ok(lines) => {
|
||||
for l in lines {
|
||||
writeln!(out, "{l}")?;
|
||||
}
|
||||
}
|
||||
Err(e) => writeln!(out, "failed {e}")?,
|
||||
}
|
||||
}
|
||||
LOG.with(|log| *log.borrow_mut() = None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fixture_reset(&mut self, seed: i64) {
|
||||
self.world = World::new(self.res.facts.air, seed);
|
||||
// The oracle level is plains everywhere (bone meal's flowers, freezing).
|
||||
let plains = u32::from(self.res.biomes.id("minecraft:plains"));
|
||||
for cx in -2..=2 {
|
||||
for cz in -2..=2 {
|
||||
let mut chunk = Chunk::empty(cx, cz);
|
||||
for section in &mut chunk.sections {
|
||||
Arc::make_mut(section).biomes = horizon_core::paletted::PalettedContainer::new(
|
||||
horizon_client::chunk::BIOME_STRATEGY,
|
||||
plains,
|
||||
);
|
||||
}
|
||||
self.world.chunks.insert((cx, cz), Arc::new(chunk));
|
||||
}
|
||||
}
|
||||
self.block_ticks = Ticks::new(0);
|
||||
self.tick_chunks.clear();
|
||||
self.sync_tick_chunks();
|
||||
self.mobs.clear();
|
||||
self.dropped.clear();
|
||||
self.creatures.projectiles.clear();
|
||||
self.tnts.clear();
|
||||
self.boats.clear();
|
||||
self.carts.clear();
|
||||
self.armor_stands.clear();
|
||||
self.rockets.clear();
|
||||
self.potions.clear_thrown();
|
||||
self.level_events.clear();
|
||||
self.orbs.clear();
|
||||
self.falling.clear();
|
||||
self.ui.rules = Default::default();
|
||||
// The oracle level plays on normal difficulty.
|
||||
self.ui.difficulty = 2;
|
||||
// The oracle level has world seed 0 and fresh random sequences.
|
||||
self.level.seed = 0;
|
||||
self.rand_sequences = Default::default();
|
||||
}
|
||||
|
||||
fn dispense_case(&mut self, case: &Value) -> Result<Vec<String>> {
|
||||
let seed = case["seed"].as_i64().unwrap_or(0);
|
||||
self.fixture_reset(seed);
|
||||
// The oracle level answers one sky and one block light everywhere.
|
||||
let light = case["light"].as_array();
|
||||
let level = |i: usize, default: i64| {
|
||||
light
|
||||
.and_then(|l| l.get(i))
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(default) as u8
|
||||
};
|
||||
let packed = (level(0, 15) << 4) | level(1, 0);
|
||||
for chunk in self.world.chunks.values_mut() {
|
||||
for section in &mut Arc::make_mut(chunk).sections {
|
||||
Arc::make_mut(section).light = horizon_client::chunk::Light::Uniform(packed);
|
||||
}
|
||||
}
|
||||
self.world.game_time = case["time"].as_i64().unwrap_or(0);
|
||||
// The oracle level's clock stands at day time 6000.
|
||||
self.world.day_time = 6000;
|
||||
if let Some(rules) = case["rules"].as_object() {
|
||||
for (name, value) in rules {
|
||||
if let Some(b) = value.as_bool() {
|
||||
self.ui.rules.set(name, RuleValue::Bool(b));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut before: HashMap<(i32, i32, i32), u32> = HashMap::new();
|
||||
for cell in case["cells"].as_array().into_iter().flatten() {
|
||||
let p = (
|
||||
cell[0].as_i64().unwrap_or(0) as i32,
|
||||
cell[1].as_i64().unwrap_or(0) as i32,
|
||||
cell[2].as_i64().unwrap_or(0) as i32,
|
||||
);
|
||||
let state = BlockStateId(cell[3].as_u64().unwrap_or(0) as u32);
|
||||
self.world.set(p.0, p.1, p.2, state)?;
|
||||
before.insert(p, state.0);
|
||||
if let Some(entity) = BlockEntity::for_block(&self.res.facts.get(state).name) {
|
||||
self.world.block_entities.insert(p, entity);
|
||||
}
|
||||
}
|
||||
for item in case["items"].as_array().into_iter().flatten() {
|
||||
let p = (
|
||||
item[0].as_i64().unwrap_or(0) as i32,
|
||||
item[1].as_i64().unwrap_or(0) as i32,
|
||||
item[2].as_i64().unwrap_or(0) as i32,
|
||||
);
|
||||
let slot = item[3].as_u64().unwrap_or(0) as usize;
|
||||
let stack = parse_stack(item[4].as_str().unwrap_or("-"))?;
|
||||
if let Some(cell) = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_mut(&p)
|
||||
.and_then(|e| e.slots_mut().get_mut(slot))
|
||||
{
|
||||
*cell = stack;
|
||||
}
|
||||
}
|
||||
let mut targets = Vec::new();
|
||||
for e in case["entities"].as_array().into_iter().flatten() {
|
||||
targets.push(self.fixture_target(e)?);
|
||||
}
|
||||
// Leashable.setLeashedTo: a lead from a target to an earlier one.
|
||||
for (e, t) in case["entities"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.zip(&targets)
|
||||
{
|
||||
let (Some(holder), Target::Mob(id)) = (e["leash"].as_u64(), t) else {
|
||||
continue;
|
||||
};
|
||||
let holder = match targets.get(holder as usize) {
|
||||
Some(Target::Mob(h)) => horizon_client::mobs::leash::Holder::Mob(*h),
|
||||
_ => continue,
|
||||
};
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == *id) {
|
||||
m.extra.leash = Some(holder);
|
||||
}
|
||||
}
|
||||
// Creating the targets must not move the stream the dispenser draws from.
|
||||
self.world.random = LegacyRandom::new(seed);
|
||||
self.world.block_log = Some(Vec::new());
|
||||
self.audio.capture = Some(Vec::new());
|
||||
LOG.with(|log| *log.borrow_mut() = Some(Vec::new()));
|
||||
let old_mobs: Vec<u32> = self.mobs.iter().map(|m| m.id).collect();
|
||||
let old_stands: Vec<u32> = self.armor_stands.iter().map(|s| s.id).collect();
|
||||
|
||||
let pos = &case["pos"];
|
||||
let origin = BlockPos::new(
|
||||
pos[0].as_i64().unwrap_or(0) as i32,
|
||||
pos[1].as_i64().unwrap_or(0) as i32,
|
||||
pos[2].as_i64().unwrap_or(0) as i32,
|
||||
);
|
||||
let state = self.world.block(origin.x, origin.y, origin.z);
|
||||
self.dispense_from(origin, state)?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut changed: Vec<(i32, i32, i32)> = self.world.block_log.take().unwrap_or_default();
|
||||
changed.sort_unstable();
|
||||
changed.dedup();
|
||||
for p in changed {
|
||||
let now = self.world.block(p.0, p.1, p.2).0;
|
||||
if before.get(&p).copied().unwrap_or(self.res.facts.air.0) != now {
|
||||
out.push(format!("set {},{},{} {now}", p.0, p.1, p.2));
|
||||
}
|
||||
}
|
||||
let mut containers: Vec<_> = self.world.block_entities.keys().copied().collect();
|
||||
containers.sort_unstable();
|
||||
for p in containers {
|
||||
let entity = &self.world.block_entities[&p];
|
||||
for (i, slot) in entity.slots().iter().enumerate() {
|
||||
if let Some(stack) = slot {
|
||||
out.push(format!(
|
||||
"inv {},{},{} {i} {}",
|
||||
p.0,
|
||||
p.1,
|
||||
p.2,
|
||||
canon_stack(Some(stack))
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.fixture_entities(&old_mobs, &old_stands, &mut out);
|
||||
for (i, t) in targets.iter().enumerate() {
|
||||
out.push(self.fixture_describe(i, t));
|
||||
}
|
||||
let log = LOG.with(|log| log.borrow_mut().take()).unwrap_or_default();
|
||||
out.extend(log);
|
||||
for (sound, at, _, _) in self.audio.capture.take().unwrap_or_default() {
|
||||
out.push(format!("S {} {sound}", floor_pos(at)));
|
||||
}
|
||||
out.push(format!("rng {}", self.world.random.next_long()));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `EntityType.create` and `snapTo` for one target: no spawn finalization.
|
||||
fn fixture_target(&mut self, e: &Value) -> Result<Target> {
|
||||
let kind = e["type"].as_str().unwrap_or("minecraft:pig");
|
||||
let p = &e["pos"];
|
||||
let at = horizon_core::geometry::Vec3::new(
|
||||
p[0].as_f64().unwrap_or(0.0),
|
||||
p[1].as_f64().unwrap_or(0.0),
|
||||
p[2].as_f64().unwrap_or(0.0),
|
||||
);
|
||||
let yaw = e["yaw"].as_f64().unwrap_or(0.0) as f32;
|
||||
self.next_entity += 1;
|
||||
let id = self.next_entity;
|
||||
if kind == "minecraft:armor_stand" {
|
||||
self.armor_stands
|
||||
.push(horizon_client::entities::ArmorStand::new(id, at, yaw));
|
||||
return Ok(Target::Stand(id));
|
||||
}
|
||||
let t = self
|
||||
.res
|
||||
.mob_types
|
||||
.get(kind)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("no mob type {kind}"))?;
|
||||
let mut mob = Mob::new(id, t, at, yaw);
|
||||
// EntityType.create equips nothing (that is finalizeSpawn's job).
|
||||
for slot in [
|
||||
&mut mob.extra.main_hand,
|
||||
&mut mob.extra.off_hand,
|
||||
&mut mob.extra.head,
|
||||
&mut mob.extra.chest,
|
||||
&mut mob.extra.legs,
|
||||
&mut mob.extra.feet,
|
||||
] {
|
||||
*slot = None;
|
||||
}
|
||||
let s = mob.species();
|
||||
let flag = |name: &str| e[name].as_bool().unwrap_or(false);
|
||||
let ageable = !matches!(
|
||||
s,
|
||||
Species::Zombie | Species::Skeleton | Species::Bogged | Species::SnowGolem
|
||||
);
|
||||
if flag("baby") && ageable {
|
||||
mob.extra.age = -24000;
|
||||
}
|
||||
let tamable =
|
||||
body_in_head(s) || matches!(s, Species::Cat | Species::Parrot | Species::Ocelot);
|
||||
if flag("tame") && tamable {
|
||||
mob.extra.tame = true;
|
||||
}
|
||||
if flag("sheared") && matches!(s, Species::Sheep | Species::Bogged | Species::SnowGolem) {
|
||||
mob.extra.sheared = true;
|
||||
}
|
||||
if flag("chest")
|
||||
&& matches!(
|
||||
s,
|
||||
Species::Donkey | Species::Mule | Species::Llama | Species::TraderLlama
|
||||
)
|
||||
{
|
||||
mob.extra.chested = true;
|
||||
}
|
||||
if flag("loot") {
|
||||
mob.extra.pick_up_loot = true;
|
||||
}
|
||||
if flag("brown") && s == Species::Mooshroom {
|
||||
mob.extra.variant = 1;
|
||||
}
|
||||
for (slot, snbt) in e["equip"].as_object().into_iter().flatten() {
|
||||
if let (Some(slot), Some(stack)) = (
|
||||
Slot::from_name(slot),
|
||||
parse_stack(snbt.as_str().unwrap_or("-"))?,
|
||||
) {
|
||||
mob_set_slot(&mut mob, slot, stack);
|
||||
}
|
||||
}
|
||||
self.mobs.push(mob);
|
||||
Ok(Target::Mob(id))
|
||||
}
|
||||
|
||||
/// `T` lines: removal, equipment by `EquipmentSlot`, shearing readiness, chests.
|
||||
fn fixture_describe(&self, index: usize, target: &Target) -> String {
|
||||
let mut line = format!("T {index} ");
|
||||
match *target {
|
||||
Target::Stand(id) => {
|
||||
line.push_str("minecraft:armor_stand");
|
||||
let Some(stand) = self.armor_stands.iter().find(|s| s.id == id) else {
|
||||
line.push_str(" removed");
|
||||
return line;
|
||||
};
|
||||
for (slot, name) in [
|
||||
(0, "mainhand"),
|
||||
(1, "offhand"),
|
||||
(2, "feet"),
|
||||
(3, "legs"),
|
||||
(4, "chest"),
|
||||
(5, "head"),
|
||||
] {
|
||||
if let Some(s) = &stand.equipment[slot] {
|
||||
let _ = write!(line, " {name}={}x{}", s.item, s.count);
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Mob(id) => {
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return line + "removed";
|
||||
};
|
||||
line.push_str(&m.kind.id);
|
||||
if m.removed || !m.alive() {
|
||||
line.push_str(" removed");
|
||||
}
|
||||
let s = m.species();
|
||||
let x = &m.extra;
|
||||
for (slot, name) in [
|
||||
(Slot::MainHand, "mainhand"),
|
||||
(Slot::OffHand, "offhand"),
|
||||
(Slot::Feet, "feet"),
|
||||
(Slot::Legs, "legs"),
|
||||
(Slot::Chest, "chest"),
|
||||
(Slot::Head, "head"),
|
||||
(Slot::Body, "body"),
|
||||
(Slot::Saddle, "saddle"),
|
||||
] {
|
||||
if let Some(item) = mob_slot_item(m, slot) {
|
||||
let _ = write!(line, " {name}={item}x1");
|
||||
}
|
||||
}
|
||||
let ready = match s {
|
||||
Species::Sheep => Some(!x.sheared && !m.is_baby()),
|
||||
Species::Mooshroom => Some(!m.is_baby()),
|
||||
Species::Bogged => Some(!x.sheared),
|
||||
Species::SnowGolem => Some(!x.sheared),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(r) = ready {
|
||||
line.push_str(if r { " ready" } else { " unready" });
|
||||
}
|
||||
if x.chested
|
||||
&& matches!(
|
||||
s,
|
||||
Species::Donkey | Species::Mule | Species::Llama | Species::TraderLlama
|
||||
)
|
||||
{
|
||||
line.push_str(" chest");
|
||||
}
|
||||
if x.leash.is_some() {
|
||||
line.push_str(" leashed");
|
||||
}
|
||||
}
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
/// `N` lines for every entity the dispenser added.
|
||||
fn fixture_entities(&self, old_mobs: &[u32], old_stands: &[u32], out: &mut Vec<String>) {
|
||||
let line = |kind: &str, p: [f64; 3], v: [f64; 3], extra: &str| {
|
||||
let mut l = format!(
|
||||
"N {kind} {} {} {} {} {} {}",
|
||||
num(p[0]),
|
||||
num(p[1]),
|
||||
num(p[2]),
|
||||
num(v[0]),
|
||||
num(v[1]),
|
||||
num(v[2])
|
||||
);
|
||||
if !extra.is_empty() {
|
||||
l.push(' ');
|
||||
l.push_str(extra);
|
||||
}
|
||||
l
|
||||
};
|
||||
for m in self.mobs.iter().filter(|m| !old_mobs.contains(&m.id)) {
|
||||
let (p, v) = (m.position, m.velocity);
|
||||
out.push(line(&m.kind.id, [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
|
||||
}
|
||||
for s in self
|
||||
.armor_stands
|
||||
.iter()
|
||||
.filter(|s| !old_stands.contains(&s.id))
|
||||
{
|
||||
let (p, v) = (s.position, s.velocity);
|
||||
out.push(line(
|
||||
"minecraft:armor_stand",
|
||||
[p.x, p.y, p.z],
|
||||
[v.x, v.y, v.z],
|
||||
"",
|
||||
));
|
||||
}
|
||||
for d in &self.dropped {
|
||||
let (p, v) = (d.position, d.velocity);
|
||||
out.push(line(
|
||||
"minecraft:item",
|
||||
[p.x, p.y, p.z],
|
||||
[v.x, v.y, v.z],
|
||||
&canon_stack(Some(&d.stack)),
|
||||
));
|
||||
}
|
||||
for shot in &self.creatures.projectiles {
|
||||
let kind = match &shot.kind {
|
||||
ProjectileKind::Arrow {
|
||||
effect: Some(("minecraft:glowing", 200)),
|
||||
..
|
||||
} => "minecraft:spectral_arrow",
|
||||
ProjectileKind::Arrow { .. } => "minecraft:arrow",
|
||||
ProjectileKind::Snowball => "minecraft:snowball",
|
||||
ProjectileKind::Egg => "minecraft:egg",
|
||||
ProjectileKind::SmallFireball => "minecraft:small_fireball",
|
||||
ProjectileKind::WindCharge => "minecraft:wind_charge",
|
||||
_ => "minecraft:unknown_projectile",
|
||||
};
|
||||
let (p, v) = (shot.position, shot.velocity);
|
||||
out.push(line(kind, [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
|
||||
}
|
||||
for (kind, p, v) in self.potions.thrown_entities() {
|
||||
out.push(line(kind, [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
|
||||
}
|
||||
for (p, v) in self.rockets.iter().map(|r| r.motion()) {
|
||||
out.push(line(
|
||||
"minecraft:firework_rocket",
|
||||
[p.x, p.y, p.z],
|
||||
[v.x, v.y, v.z],
|
||||
"",
|
||||
));
|
||||
}
|
||||
for t in &self.tnts {
|
||||
let (p, v) = (t.position, t.velocity);
|
||||
out.push(line(
|
||||
"minecraft:tnt",
|
||||
[p.x, p.y, p.z],
|
||||
[v.x, v.y, v.z],
|
||||
&format!("fuse={}", t.fuse),
|
||||
));
|
||||
}
|
||||
for b in &self.boats {
|
||||
let kind = b.entity_type();
|
||||
let (p, v) = (b.position, b.velocity);
|
||||
out.push(line(&kind, [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
|
||||
}
|
||||
for c in &self.carts {
|
||||
let (p, v) = (c.position, c.velocity);
|
||||
out.push(line(c.kind.id(), [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -29,12 +29,6 @@ pub(super) struct Rocket {
|
||||
angled: bool,
|
||||
icon: Option<String>,
|
||||
}
|
||||
impl Rocket {
|
||||
/// Position and motion (the dispenser parity fixture).
|
||||
pub(super) fn motion(&self) -> (Vec3, Vec3) {
|
||||
(self.position, self.velocity)
|
||||
}
|
||||
}
|
||||
fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
|
||||
// The item's default component: flight 1, no explosions.
|
||||
stack
|
||||
@@ -45,9 +39,10 @@ fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
|
||||
impl Game<'_> {
|
||||
pub(super) fn new_rocket(&mut self, at: Vec3, stack: &ItemStack, attached: bool) -> Rocket {
|
||||
let (flight, explosions) = fireworks_of(stack);
|
||||
// FireworkRocketEntity: motion and lifetime come from the rocket's own random.
|
||||
let r = &mut super::dispenser::entity_random();
|
||||
let tri = |r: &mut horizon_core::random::LegacyRandom| r.triangle(0.0, 0.002297);
|
||||
let r = &mut self.world.random;
|
||||
let tri = |r: &mut horizon_core::random::LegacyRandom| {
|
||||
0.002297 * (f64::from(r.next_float()) - f64::from(r.next_float()))
|
||||
};
|
||||
let velocity = Vec3::new(tri(r), 0.05, tri(r));
|
||||
let lifetime = 10 * (1 + i32::from(flight))
|
||||
+ r.next_int_bound(6).unwrap_or(0)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `ItemFrame.survives`, `interact` (put an item in, then rotate it),
|
||||
//! `hurtServer` (take the item out, then break the frame) and
|
||||
//! `ItemFrameRenderer` (the frame model and the item at a 45 degree step).
|
||||
use super::{Game, Hit, view_vector};
|
||||
use super::{Game, Hit, Icon, view_vector};
|
||||
use horizon_assets::{bake::bake_model, model::CuboidModel, resolve::ModelLibrary};
|
||||
use horizon_client::{entities::ItemFrame, items::ItemStack, mesher::Vertex};
|
||||
use horizon_core::{
|
||||
@@ -300,10 +300,17 @@ impl Game<'_> {
|
||||
}
|
||||
/// ItemFrameRenderer: the frame model, then the item half size on its face.
|
||||
pub(super) fn frame_geometry(&mut self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
|
||||
let icons: Vec<Option<Icon>> = (0..self.item_frames.len())
|
||||
.map(|i| {
|
||||
self.item_frames[i]
|
||||
.item
|
||||
.clone()
|
||||
.and_then(|s| self.icon(&s.item))
|
||||
})
|
||||
.collect();
|
||||
let sprites = &self.res.atlas.sprites;
|
||||
let mut maps: Vec<(i32, glam::Mat4, u32)> = Vec::new();
|
||||
let mut items: Vec<(ItemStack, u32, glam::Mat4, u8)> = Vec::new();
|
||||
for f in &self.item_frames {
|
||||
for (f, icon) in self.item_frames.iter().zip(icons) {
|
||||
let map = f.item.as_ref().and_then(super::maps::map_id);
|
||||
let s = f.step();
|
||||
// Pose: to the block centre, then X/Y rotation by facing.
|
||||
@@ -384,33 +391,89 @@ impl Game<'_> {
|
||||
maps.push((id, m.as_mat4(), item_light));
|
||||
continue;
|
||||
}
|
||||
let Some(stack) = f.item.clone() else {
|
||||
continue;
|
||||
};
|
||||
// `GlowItemFrame` items render full bright (15728880).
|
||||
let item_light = if f.glow { 0xFF } else { light };
|
||||
let Some(icon) = icon else { continue };
|
||||
// GlowItemFrame items render full bright.
|
||||
let item_light = if f.glow { 240 | 240 << 8 } else { packed };
|
||||
let depth = if f.invisible { 0.5 } else { 0.4375 };
|
||||
let m = pose
|
||||
* glam::DMat4::from_translation(glam::DVec3::new(0.0, 0.0, depth))
|
||||
* glam::DMat4::from_rotation_z((f64::from(f.rotation) * 45.0).to_radians())
|
||||
* glam::DMat4::from_scale(glam::DVec3::splat(0.5));
|
||||
items.push((stack, f.id, m.as_mat4(), item_light));
|
||||
}
|
||||
// `extractRenderState`: FIXED, seeded by the frame entity.
|
||||
for (stack, id, m, light) in items {
|
||||
let owner = super::item_draw::owner_at([0.0; 3], 0.0, "minecraft:item_frame");
|
||||
let state = self.resolve_item(
|
||||
&stack,
|
||||
horizon_assets::item_model::DisplayContext::Fixed,
|
||||
Some(&owner),
|
||||
id as i32,
|
||||
);
|
||||
self.submit_item_world(
|
||||
&state,
|
||||
m,
|
||||
horizon_client::block_entity_render::vertex_light(light),
|
||||
out,
|
||||
);
|
||||
let full = |r: &horizon_client::hud::Rect| {
|
||||
[[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]]
|
||||
};
|
||||
match icon {
|
||||
// A flat item faces out of the frame (towards -z here).
|
||||
Icon::Flat(sprite) => {
|
||||
if let Some(r) = sprites.get(&sprite) {
|
||||
let z = -0.03125;
|
||||
quad(
|
||||
&m,
|
||||
[
|
||||
[0.5, -0.5, z],
|
||||
[-0.5, -0.5, z],
|
||||
[-0.5, 0.5, z],
|
||||
[0.5, 0.5, z],
|
||||
],
|
||||
full(r),
|
||||
item_light,
|
||||
);
|
||||
}
|
||||
}
|
||||
Icon::Cube {
|
||||
top, left, right, ..
|
||||
} => {
|
||||
let h = 0.25;
|
||||
let face = |name: &str| sprites.get(name).copied();
|
||||
if let (Some(t), Some(l), Some(r)) = (face(&top), face(&left), face(&right)) {
|
||||
let c = |x: f64, y: f64, z: f64| [x * h, y * h, z * h - h];
|
||||
quad(
|
||||
&m,
|
||||
[
|
||||
c(-1., 1., -1.),
|
||||
c(1., 1., -1.),
|
||||
c(1., 1., 1.),
|
||||
c(-1., 1., 1.),
|
||||
],
|
||||
full(&t),
|
||||
item_light,
|
||||
);
|
||||
quad(
|
||||
&m,
|
||||
[
|
||||
c(1., -1., -1.),
|
||||
c(-1., -1., -1.),
|
||||
c(-1., 1., -1.),
|
||||
c(1., 1., -1.),
|
||||
],
|
||||
full(&l),
|
||||
item_light,
|
||||
);
|
||||
quad(
|
||||
&m,
|
||||
[
|
||||
c(-1., -1., -1.),
|
||||
c(-1., -1., 1.),
|
||||
c(-1., 1., 1.),
|
||||
c(-1., 1., -1.),
|
||||
],
|
||||
full(&r),
|
||||
item_light,
|
||||
);
|
||||
quad(
|
||||
&m,
|
||||
[
|
||||
c(1., -1., 1.),
|
||||
c(1., -1., -1.),
|
||||
c(1., 1., -1.),
|
||||
c(1., 1., 1.),
|
||||
],
|
||||
full(&r),
|
||||
item_light,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, m, light) in maps {
|
||||
self.map_vertices(id, m, light, out);
|
||||
|
||||
@@ -24,7 +24,6 @@ impl Game<'_> {
|
||||
if self.golem_block(below(1)) == "minecraft:snow_block"
|
||||
&& self.golem_block(below(2)) == "minecraft:snow_block"
|
||||
{
|
||||
// The pattern ("^", "#", "#") is one column.
|
||||
return Some((
|
||||
"minecraft:snow_golem",
|
||||
vec![head, below(1), below(2)],
|
||||
@@ -39,11 +38,9 @@ impl Game<'_> {
|
||||
&& self.golem_block(side(0, 2)) == "minecraft:iron_block"
|
||||
&& [-1, 1].iter().all(|&s| air(side(s, 0)) && air(side(s, 2)))
|
||||
{
|
||||
// Every cell of ("~^~", "###", "~#~"), the air ones included.
|
||||
let blocks = (-1..=1)
|
||||
.flat_map(|s| (0..3).map(move |row| (s, row)))
|
||||
.map(|(s, row)| side(s, row))
|
||||
.collect();
|
||||
let mut blocks = vec![head];
|
||||
blocks.extend((-1..=1).map(|s| side(s, 1)));
|
||||
blocks.push(side(0, 2));
|
||||
return Some(("minecraft:iron_golem", blocks, side(0, 2)));
|
||||
}
|
||||
}
|
||||
@@ -61,68 +58,27 @@ impl Game<'_> {
|
||||
let Some((kind, blocks, base)) = self.find_golem(head) else {
|
||||
return self.try_spawn_copper_golem(head);
|
||||
};
|
||||
// CarvedPumpkinBlock.spawnGolemInWorld
|
||||
self.clear_pattern_blocks(&blocks)?;
|
||||
for p in blocks {
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
self.destroy_particles(p, state);
|
||||
self.level_set(p, self.world.air)?;
|
||||
}
|
||||
let at = [
|
||||
f64::from(base[0]) + 0.5,
|
||||
f64::from(base[1]) + 0.05,
|
||||
f64::from(base[2]) + 0.5,
|
||||
];
|
||||
self.create_triggered(kind, at, 0.0);
|
||||
self.update_pattern_blocks(&blocks)
|
||||
}
|
||||
/// Whether placing a golem head at `head` completes a snow or iron golem.
|
||||
pub(super) fn completes_golem(&self, head: [i32; 3]) -> bool {
|
||||
self.find_golem(head).is_some()
|
||||
}
|
||||
/// `EntityType.create(level, TRIGGERED)` and `snapTo`: a built golem or
|
||||
/// wither has no spawn finalization and draws nothing from the level.
|
||||
pub(super) fn create_triggered(&mut self, kind: &str, at: [f64; 3], yaw: f32) -> Option<u32> {
|
||||
let t = self.res.mob_types.get(kind).cloned()?;
|
||||
self.next_entity += 1;
|
||||
let mut mob = horizon_client::mobs::Mob::new(
|
||||
self.next_entity,
|
||||
t,
|
||||
horizon_core::geometry::Vec3::new(at[0], at[1], at[2]),
|
||||
yaw,
|
||||
);
|
||||
mob.head_yaw = yaw;
|
||||
mob.body_yaw = yaw;
|
||||
mob.previous_yaw = yaw;
|
||||
mob.previous_body_yaw = yaw;
|
||||
mob.previous_head_yaw = yaw;
|
||||
let id = mob.id;
|
||||
self.mobs.push(mob);
|
||||
Some(id)
|
||||
}
|
||||
/// `CarvedPumpkinBlock.clearPatternBlocks`: every cell of the match
|
||||
/// becomes air (flags 2) with its destroy event (2001).
|
||||
pub(super) fn clear_pattern_blocks(&mut self, cells: &[[i32; 3]]) -> Result<()> {
|
||||
let air = self.world.air;
|
||||
for &p in cells {
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
let pos = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, pos, air, 2));
|
||||
self.level_events
|
||||
.push(horizon_world::level::LevelEvent::LevelEvent {
|
||||
id: 2001,
|
||||
pos,
|
||||
data: state.0 as i32,
|
||||
});
|
||||
if let Some(id) = self.spawn_mob(kind, at, false)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
m.yaw = 0.0;
|
||||
m.body_yaw = 0.0;
|
||||
m.head_yaw = 0.0;
|
||||
m.previous_yaw = 0.0;
|
||||
m.previous_body_yaw = 0.0;
|
||||
m.previous_head_yaw = 0.0;
|
||||
}
|
||||
self.process_level_events()
|
||||
}
|
||||
/// `CarvedPumpkinBlock.updatePatternBlocks`: neighbours of every cell
|
||||
/// hear that air arrived.
|
||||
pub(super) fn update_pattern_blocks(&mut self, cells: &[[i32; 3]]) -> Result<()> {
|
||||
let air = self.world.air;
|
||||
for &p in cells {
|
||||
let pos = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.update_neighbors_at(level, ticks, pos, b.block(air))
|
||||
});
|
||||
}
|
||||
self.process_level_events()
|
||||
Ok(())
|
||||
}
|
||||
/// A pumpkin on a copper block: the block turns into the matching copper
|
||||
/// chest (facing the pumpkin's way) and a copper golem appears where the
|
||||
|
||||
@@ -1,373 +0,0 @@
|
||||
//! Items drawn through the resolved item model pipeline
|
||||
//! (`horizon_client::item_render`): GUI icons (`GuiGraphics.renderItem`),
|
||||
//! dropped items (`ItemEntityRenderer`) and items posed in the world (hands,
|
||||
//! frames, armour stands). This binds the resolver's host services
|
||||
//! (`ItemEnv`) and the special renderers' registries to the game.
|
||||
use super::{Dim, Game};
|
||||
use horizon_assets::item_model::{
|
||||
Components, DisplayContext, GlobalPos, ItemEnv, LevelState, OwnerState, StackView,
|
||||
};
|
||||
use horizon_client::{
|
||||
block_entity_render::{diffuse_lights, vertex_light},
|
||||
hud::HudBatch,
|
||||
item_render::{self, ItemRenderState, SpecialHost, WorldSink},
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// The game's answers to what the resolver cannot read from the stack.
|
||||
pub(super) struct Env<'a, 'g> {
|
||||
game: &'a Game<'g>,
|
||||
}
|
||||
impl ItemEnv for Env<'_, '_> {
|
||||
fn potion_effect_color(&self, contents: &Value) -> Option<i32> {
|
||||
// `PotionContents.getColorOr`: the effect mix when any effect is visible.
|
||||
let id = contents["potion"].as_str();
|
||||
let effects = id
|
||||
.and_then(horizon_client::brewing::potion)
|
||||
.is_some_and(|p| !p.effects.is_empty());
|
||||
effects.then(|| (0xFF00_0000 | horizon_client::brewing::potion_color(id)) as i32)
|
||||
}
|
||||
fn grass_color(&self, temperature: f64, downfall: f64) -> i32 {
|
||||
self.game
|
||||
.res
|
||||
.item_renderer
|
||||
.grass_color(temperature, downfall)
|
||||
}
|
||||
fn bundle_fullness(&self, _stack: &StackView, components: &Components) -> f32 {
|
||||
// `BundleContents.weight`: count / max stack size, summed.
|
||||
let Some(items) = components
|
||||
.get("minecraft:bundle_contents")
|
||||
.and_then(Value::as_array)
|
||||
else {
|
||||
return 0.0;
|
||||
};
|
||||
let prototypes = &self.game.res.item_renderer.models.prototypes;
|
||||
let mut total = 0.0f64;
|
||||
for item in items {
|
||||
let id = item["id"].as_str().unwrap_or("");
|
||||
let count = item["count"].as_i64().unwrap_or(1) as f64;
|
||||
let max = prototypes
|
||||
.get(id)
|
||||
.and_then(|c| c.get("minecraft:max_stack_size"))
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(1) as f64;
|
||||
total += count / max;
|
||||
}
|
||||
total as f32
|
||||
}
|
||||
fn has_shift_down(&self) -> bool {
|
||||
self.game.keys.contains(&KeyCode::ShiftLeft)
|
||||
|| self.game.keys.contains(&KeyCode::ShiftRight)
|
||||
}
|
||||
fn local_time(&self, pattern: &str, _locale: &str, _time_zone: Option<&str>) -> String {
|
||||
local_time(pattern)
|
||||
}
|
||||
fn random_float(&self) -> f32 {
|
||||
let nanos = self.game.started.elapsed().subsec_nanos();
|
||||
(nanos % 1_000_000) as f32 / 1_000_000.0
|
||||
}
|
||||
}
|
||||
/// The days since 1970-01-01 as a civil (year, month, day) date.
|
||||
fn civil(days: i64) -> (i64, u32, u32) {
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(yoe + era * 400 + i64::from(m <= 2), m, d)
|
||||
}
|
||||
/// `DateTimeFormatter.ofPattern(pattern).format(now)` for the month, day,
|
||||
/// year and time letters item models use (UTC: no time zone database).
|
||||
pub(super) fn local_time(pattern: &str) -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as i64);
|
||||
let (y, mo, d) = civil(secs.div_euclid(86_400));
|
||||
let s = secs.rem_euclid(86_400);
|
||||
let (h, mi, se) = (s / 3600, s / 60 % 60, s % 60);
|
||||
let mut out = String::new();
|
||||
let chars: Vec<char> = pattern.chars().collect();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
let mut n = 1;
|
||||
while i + n < chars.len() && chars[i + n] == c {
|
||||
n += 1;
|
||||
}
|
||||
let num = |v: i64| format!("{v:0n$}");
|
||||
match c {
|
||||
'M' => out += &num(i64::from(mo)),
|
||||
'd' => out += &num(i64::from(d)),
|
||||
'y' | 'u' => out += &if n == 2 { num(y % 100) } else { num(y) },
|
||||
'H' => out += &num(h),
|
||||
'm' => out += &num(mi),
|
||||
's' => out += &num(se),
|
||||
'\'' => {
|
||||
// Quoted literal text.
|
||||
let end = chars[i + 1..].iter().position(|&x| x == '\'');
|
||||
if let Some(e) = end {
|
||||
out.extend(&chars[i + 1..i + 1 + e]);
|
||||
i += e + 2;
|
||||
continue;
|
||||
}
|
||||
n = 1;
|
||||
}
|
||||
other => out.extend(std::iter::repeat_n(other, n)),
|
||||
}
|
||||
i += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Banner and shield pattern textures from the banner pattern registry.
|
||||
pub(super) struct Host<'a> {
|
||||
banners: &'a horizon_client::banner::Banners,
|
||||
}
|
||||
impl SpecialHost for Host<'_> {
|
||||
fn banner_texture(&self, pattern: &str) -> Option<String> {
|
||||
self.banners
|
||||
.pattern(pattern)
|
||||
.map(|_| self.banners.banner_texture(pattern))
|
||||
}
|
||||
fn shield_texture(&self, pattern: &str) -> Option<String> {
|
||||
self.banners
|
||||
.pattern(pattern)
|
||||
.map(|_| self.banners.shield_texture(pattern))
|
||||
}
|
||||
}
|
||||
|
||||
/// `ItemOwner` of an entity holding or showing an item.
|
||||
pub(super) fn owner_at(position: [f64; 3], yaw: f32, entity_type: &str) -> OwnerState {
|
||||
OwnerState {
|
||||
position,
|
||||
visual_rotation_y_degrees: yaw,
|
||||
entity_type: entity_type.to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
/// `UseDuration` of a stack: 72000 for drawn items, the consume time for food.
|
||||
fn use_duration(components: &Components) -> i32 {
|
||||
if let Some(c) = components.get("minecraft:consumable") {
|
||||
let seconds = c["consume_seconds"].as_f64().unwrap_or(1.6);
|
||||
return (seconds * 20.0) as i32;
|
||||
}
|
||||
let item_model = components
|
||||
.get("minecraft:item_model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
match item_model {
|
||||
"minecraft:bow"
|
||||
| "minecraft:crossbow"
|
||||
| "minecraft:trident"
|
||||
| "minecraft:spyglass"
|
||||
| "minecraft:goat_horn"
|
||||
| "minecraft:brush" => 72_000,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn item_env(&self) -> Env<'_, '_> {
|
||||
Env { game: self }
|
||||
}
|
||||
pub(super) fn item_host(&self) -> Host<'_> {
|
||||
Host {
|
||||
banners: &self.res.stations.banners,
|
||||
}
|
||||
}
|
||||
/// The client level as item models read it.
|
||||
pub(super) fn item_level(&self) -> LevelState {
|
||||
let dimension = match self.dim {
|
||||
Dim::Overworld => "minecraft:overworld",
|
||||
Dim::Nether => "minecraft:the_nether",
|
||||
Dim::End => "minecraft:the_end",
|
||||
};
|
||||
let sky = self
|
||||
.dim_environment()
|
||||
.sample(self.world.day_time, None, None);
|
||||
LevelState {
|
||||
dimension: dimension.to_owned(),
|
||||
game_time: self.world.game_time,
|
||||
sun_angle: sky.sun_angle,
|
||||
moon_phase: (self.world.day_time.div_euclid(24_000)).rem_euclid(8) as i32,
|
||||
respawn: self.spawn.map(|s| GlobalPos {
|
||||
dimension: "minecraft:overworld".to_owned(),
|
||||
pos: [s.x.floor() as i32, s.y.floor() as i32, s.z.floor() as i32],
|
||||
}),
|
||||
runs_normally: true,
|
||||
}
|
||||
}
|
||||
/// The local player as `ItemOwner`; `stack` is the stack drawn, to tell
|
||||
/// whether it is the one in use.
|
||||
pub(super) fn player_owner(&self, stack: Option<&ItemStack>) -> OwnerState {
|
||||
let position = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map_or([0.0; 3], |p| [p.position.x, p.position.y, p.position.z]);
|
||||
let mut owner = owner_at(position, self.yaw, "minecraft:player");
|
||||
owner.main_arm_left = self.settings.main_hand_left;
|
||||
owner.is_local_player = true;
|
||||
owner.is_view_entity = true;
|
||||
if let (Some((slot, ticks)), Some(stack)) = (self.using, stack)
|
||||
&& self.inventory.get(slot).and_then(Option::as_ref) == Some(stack)
|
||||
{
|
||||
let view = item_render::stack_view(stack);
|
||||
let duration = use_duration(&self.res.item_renderer.models.components(&view));
|
||||
owner.using_this_item = true;
|
||||
owner.use_duration = duration;
|
||||
owner.use_item_remaining_ticks = duration - ticks as i32;
|
||||
owner.crossbow_charge_duration = 25;
|
||||
}
|
||||
owner.is_selected =
|
||||
stack.is_some() && self.inventory.get(self.selected).and_then(Option::as_ref) == stack;
|
||||
owner.is_carried = owner.is_selected;
|
||||
owner
|
||||
}
|
||||
/// `ItemModelResolver.updateForTopItem`.
|
||||
pub(super) fn resolve_item(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
display: DisplayContext,
|
||||
owner: Option<&OwnerState>,
|
||||
seed: i32,
|
||||
) -> ItemRenderState {
|
||||
let level = self.item_level();
|
||||
self.res.item_renderer.resolve(
|
||||
&item_render::stack_view(stack),
|
||||
display,
|
||||
owner,
|
||||
Some(&level),
|
||||
seed,
|
||||
&self.item_env(),
|
||||
)
|
||||
}
|
||||
/// `GuiGraphics.renderItem(player, level, stack, x, y, 0)`.
|
||||
pub(super) fn draw_item_model(&self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) {
|
||||
let owner = self.player_owner(Some(stack));
|
||||
let state = self.resolve_item(stack, DisplayContext::Gui, Some(&owner), 0);
|
||||
item_render::gui::draw(
|
||||
&state,
|
||||
&self.res.item_renderer,
|
||||
&self.res.atlas.sprites,
|
||||
&self.item_host(),
|
||||
batch,
|
||||
x,
|
||||
y,
|
||||
self.started.elapsed().as_secs_f32(),
|
||||
);
|
||||
}
|
||||
/// Submit a resolved item under `pose` (camera-relative) at `light`
|
||||
/// (packed lightmap coordinates).
|
||||
pub(super) fn submit_item_world(
|
||||
&self,
|
||||
state: &ItemRenderState,
|
||||
pose: glam::Mat4,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let mut sink = WorldSink {
|
||||
out,
|
||||
light,
|
||||
lights: Some(diffuse_lights(self.dim == Dim::Nether)),
|
||||
};
|
||||
state.submit(
|
||||
&self.res.item_renderer,
|
||||
&self.res.atlas.sprites,
|
||||
&self.item_host(),
|
||||
pose,
|
||||
&mut sink,
|
||||
);
|
||||
}
|
||||
/// [`Self::submit_item_world`] without the level's diffuse shading.
|
||||
pub(super) fn submit_item_unlit(
|
||||
&self,
|
||||
state: &ItemRenderState,
|
||||
pose: glam::Mat4,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let mut sink = WorldSink {
|
||||
out,
|
||||
light,
|
||||
lights: None,
|
||||
};
|
||||
state.submit(
|
||||
&self.res.item_renderer,
|
||||
&self.res.atlas.sprites,
|
||||
&self.item_host(),
|
||||
pose,
|
||||
&mut sink,
|
||||
);
|
||||
}
|
||||
/// `ItemEntityRenderer.submit` for every dropped item.
|
||||
pub(super) fn dropped_item_geometry(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
for item in &self.dropped {
|
||||
let (p, q) = (item.previous, item.position);
|
||||
let pos = glam::DVec3::new(
|
||||
p.x + (q.x - p.x) * alpha,
|
||||
p.y + (q.y - p.y) * alpha,
|
||||
p.z + (q.z - p.z) * alpha,
|
||||
) - camera;
|
||||
if pos.length_squared() > 64.0 * 64.0 {
|
||||
continue;
|
||||
}
|
||||
// `extractItemGroupRenderState`: GROUND, seeded by the entity id.
|
||||
let owner = owner_at([q.x, q.y, q.z], 0.0, "minecraft:item");
|
||||
let state = self.resolve_item(
|
||||
&item.stack,
|
||||
DisplayContext::Ground,
|
||||
Some(&owner),
|
||||
item.id as i32,
|
||||
);
|
||||
if state.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let age = item.age as f32 + alpha as f32;
|
||||
let (lo, _) = state.bounding_box();
|
||||
let min_offset_y = -lo.y + 0.0625;
|
||||
let bob = (age / 10.0 + item.bob_offset).sin() * 0.1 + 0.1;
|
||||
let spin = item_render::item_spin(age, item.bob_offset);
|
||||
let pose = glam::Mat4::from_translation(pos.as_vec3())
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, bob + min_offset_y, 0.0))
|
||||
* glam::Mat4::from_rotation_y(spin);
|
||||
let packed = self.world.light(
|
||||
q.x.floor() as i32,
|
||||
(q.y + 0.1).floor() as i32,
|
||||
q.z.floor() as i32,
|
||||
);
|
||||
let seed = i64::from(self.res.item_renderer.item_id(&item.stack.item))
|
||||
+ i64::from(item.stack.damage);
|
||||
for copy in item_render::cluster_poses(
|
||||
&state,
|
||||
pose,
|
||||
item_render::rendered_amount(item.stack.count),
|
||||
seed,
|
||||
) {
|
||||
self.submit_item_world(&state, copy, vertex_light(packed), out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn civil_dates_follow_the_gregorian_calendar() {
|
||||
assert_eq!(civil(0), (1970, 1, 1));
|
||||
assert_eq!(civil(19_716), (2023, 12, 25));
|
||||
assert_eq!(civil(11_016), (2000, 2, 29));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ use horizon_client::{
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
use horizon_world::level::flags as level_flags;
|
||||
|
||||
/// Linear attenuation range of a song (`SimpleSoundInstance.forJukeboxSong`: volume 4).
|
||||
const RANGE: f64 = 64.0;
|
||||
|
||||
impl Game<'_> {
|
||||
fn jukebox(&self, pos: [i32; 3]) -> Option<&Jukebox> {
|
||||
match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
|
||||
@@ -98,9 +101,9 @@ impl Game<'_> {
|
||||
let Some(s) = self.res.items.songs.get(song).cloned() else {
|
||||
return;
|
||||
};
|
||||
// LevelRenderer.playJukeboxSong: SimpleSoundInstance.forJukeboxSong.
|
||||
let id = self.audio.play_jukebox_song(&s.sound, pos);
|
||||
self.jukebox_sounds.insert((pos[0], pos[1], pos[2]), id);
|
||||
if let Some(id) = self.audio.play_tracked(&s.sound, 0.0, false) {
|
||||
self.jukebox_sounds.insert((pos[0], pos[1], pos[2]), id);
|
||||
}
|
||||
let description =
|
||||
horizon_client::component::to_text(&s.description, &mut |k| Text::literal(k));
|
||||
self.titles
|
||||
@@ -110,7 +113,7 @@ impl Game<'_> {
|
||||
}
|
||||
fn stop_song(&mut self, pos: [i32; 3]) {
|
||||
if let Some(id) = self.jukebox_sounds.remove(&(pos[0], pos[1], pos[2])) {
|
||||
self.audio.stop_and_release(id);
|
||||
self.audio.stop_tracked(id);
|
||||
}
|
||||
}
|
||||
/// `popOutTheItem`: the disc above the jukebox, and the song stops.
|
||||
@@ -199,20 +202,30 @@ impl Game<'_> {
|
||||
}
|
||||
self.update_song_volumes();
|
||||
}
|
||||
/// Let go of songs that ended or whose jukebox stopped.
|
||||
/// Linear distance attenuation of each song at the listener.
|
||||
fn update_song_volumes(&mut self) {
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return;
|
||||
};
|
||||
let sounds: Vec<_> = self
|
||||
.jukebox_sounds
|
||||
.iter()
|
||||
.map(|(k, id)| (*k, *id))
|
||||
.collect();
|
||||
for (k, id) in sounds {
|
||||
if !self.audio.engine.is_active(id)
|
||||
if !self.audio.tracked_active(id)
|
||||
|| !matches!(self.world.block_entities.get(&k), Some(BlockEntity::Jukebox(j)) if j.playing)
|
||||
{
|
||||
self.audio.stop_and_release(id);
|
||||
self.audio.stop_tracked(id);
|
||||
self.jukebox_sounds.remove(&k);
|
||||
continue;
|
||||
}
|
||||
let d = ((f64::from(k.0) + 0.5 - eye[0]).powi(2)
|
||||
+ (f64::from(k.1) + 0.5 - eye[1]).powi(2)
|
||||
+ (f64::from(k.2) + 0.5 - eye[2]).powi(2))
|
||||
.sqrt();
|
||||
self.audio
|
||||
.set_tracked_volume(id, (1.0 - d / RANGE).clamp(0.0, 1.0) as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,223 +58,38 @@ fn entity_uuid(id: u32) -> u128 {
|
||||
0x4856_5a4e_0000_0000_0000_0000_0000_0000u128 | u128::from(id)
|
||||
}
|
||||
|
||||
/// The SynchedEntityData index of the baby flag: AgeableMob.DATA_BABY_ID and
|
||||
/// Zombie.DATA_BABY_ID are both the first entry after Mob's (16); Piglin's
|
||||
/// comes after AbstractPiglin's zombification flag (17).
|
||||
fn baby_index(kind: &str) -> Option<u8> {
|
||||
match kind {
|
||||
"minecraft:piglin" => Some(17),
|
||||
/// Species that are AgeableMob (or have a baby flag at the same index).
|
||||
fn ageable(kind: &str) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
"minecraft:cow"
|
||||
| "minecraft:mooshroom"
|
||||
| "minecraft:pig"
|
||||
| "minecraft:sheep"
|
||||
| "minecraft:chicken"
|
||||
| "minecraft:horse"
|
||||
| "minecraft:donkey"
|
||||
| "minecraft:mule"
|
||||
| "minecraft:skeleton_horse"
|
||||
| "minecraft:zombie_horse"
|
||||
| "minecraft:llama"
|
||||
| "minecraft:trader_llama"
|
||||
| "minecraft:cat"
|
||||
| "minecraft:wolf"
|
||||
| "minecraft:fox"
|
||||
| "minecraft:rabbit"
|
||||
| "minecraft:panda"
|
||||
| "minecraft:polar_bear"
|
||||
| "minecraft:bee"
|
||||
| "minecraft:frog"
|
||||
| "minecraft:sniffer"
|
||||
| "minecraft:armadillo"
|
||||
| "minecraft:villager"
|
||||
| "minecraft:zombie"
|
||||
| "minecraft:husk"
|
||||
| "minecraft:drowned"
|
||||
| "minecraft:zombie_villager"
|
||||
| "minecraft:zombified_piglin"
|
||||
| "minecraft:hoglin"
|
||||
| "minecraft:goat"
|
||||
| "minecraft:ocelot"
|
||||
| "minecraft:turtle"
|
||||
| "minecraft:strider"
|
||||
| "minecraft:camel"
|
||||
| "minecraft:axolotl"
|
||||
| "minecraft:dolphin"
|
||||
| "minecraft:squid"
|
||||
| "minecraft:glow_squid" => Some(16),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A mob's synched data as ServerEntity sends it (only the values that differ
|
||||
/// from the defaults the client constructs), with vanilla's entry indices:
|
||||
/// Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses.
|
||||
fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> {
|
||||
let mut meta = Vec::new();
|
||||
let kind = m.kind.id.as_str();
|
||||
if m.fire_ticks > 0 {
|
||||
meta.push((0, serializer::BYTE, Value::Byte(1)));
|
||||
}
|
||||
if let Some(name) = &m.custom_name {
|
||||
meta.push((
|
||||
2,
|
||||
serializer::OPTIONAL_COMPONENT,
|
||||
Value::OptText(Some(horizon_protocol::text::literal(name))),
|
||||
));
|
||||
if m.name_visible {
|
||||
meta.push((3, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
if m.health < m.kind.max_health {
|
||||
meta.push((9, serializer::FLOAT, Value::Float(m.health.max(0.0))));
|
||||
}
|
||||
if m.is_baby()
|
||||
&& let Some(index) = baby_index(kind)
|
||||
{
|
||||
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
let e = &m.extra;
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
match kind {
|
||||
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 when sheared.
|
||||
"minecraft:sheep" => {
|
||||
let value = (e.color & 15) | if e.sheared { 16 } else { 0 };
|
||||
if value != 0 {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(value as i8)));
|
||||
}
|
||||
}
|
||||
"minecraft:cat" | "minecraft:wolf" => {
|
||||
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
|
||||
let flags = i8::from(e.sitting) | if e.tame { 4 } else { 0 };
|
||||
if flags != 0 {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
let (index, registry, name) = if kind == "minecraft:cat" {
|
||||
let v = horizon_client::mobs::tame::CAT_VARIANTS[(e.variant as usize).min(10)];
|
||||
(20, "minecraft:cat_variant", v)
|
||||
} else {
|
||||
let v = horizon_client::mobs::tame::WOLF_VARIANTS[(e.variant as usize).min(8)].0;
|
||||
(23, "minecraft:wolf_variant", v)
|
||||
};
|
||||
let serializer = if index == 20 {
|
||||
serializer::CAT_VARIANT
|
||||
} else {
|
||||
serializer::WOLF_VARIANT
|
||||
};
|
||||
if let Some(v) = id(registry, name) {
|
||||
meta.push((index, serializer, Value::Id(v)));
|
||||
}
|
||||
}
|
||||
"minecraft:horse"
|
||||
| "minecraft:donkey"
|
||||
| "minecraft:mule"
|
||||
| "minecraft:llama"
|
||||
| "minecraft:trader_llama"
|
||||
| "minecraft:skeleton_horse"
|
||||
| "minecraft:zombie_horse"
|
||||
| "minecraft:camel" => {
|
||||
// AbstractHorse.DATA_ID_FLAGS: tame 2.
|
||||
if e.tame {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(2)));
|
||||
}
|
||||
if kind == "minecraft:horse" {
|
||||
let v = i32::from(e.variant) | i32::from(e.variant2) << 8;
|
||||
if v != 0 {
|
||||
meta.push((19, serializer::INT, Value::Int(v)));
|
||||
}
|
||||
} else if kind.ends_with("llama") {
|
||||
if e.chested {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
meta.push((
|
||||
20,
|
||||
serializer::INT,
|
||||
Value::Int(i32::from(e.variant2.max(1))),
|
||||
));
|
||||
if e.variant != 0 {
|
||||
meta.push((21, serializer::INT, Value::Int(i32::from(e.variant))));
|
||||
}
|
||||
} else if (kind == "minecraft:donkey" || kind == "minecraft:mule") && e.chested {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after Zombie's three).
|
||||
"minecraft:villager" | "minecraft:zombie_villager" => {
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
let villager_type = VILLAGER_TYPES[e.variant as usize % VILLAGER_TYPES.len()];
|
||||
let profession = PROFESSIONS[e.variant2 as usize % PROFESSIONS.len()];
|
||||
if let (Some(t), Some(p)) = (
|
||||
id("minecraft:villager_type", villager_type),
|
||||
id("minecraft:villager_profession", profession),
|
||||
) {
|
||||
let index = if kind == "minecraft:villager" { 19 } else { 20 };
|
||||
let level = i32::from(e.variant3.max(1));
|
||||
meta.push((
|
||||
index,
|
||||
serializer::VILLAGER_DATA,
|
||||
Value::Ids(vec![t, p, level]),
|
||||
));
|
||||
}
|
||||
}
|
||||
// AbstractCubeMob.ID_SIZE.
|
||||
"minecraft:slime" | "minecraft:magma_cube" => {
|
||||
if e.size > 1 {
|
||||
meta.push((18, serializer::INT, Value::Int(e.size)));
|
||||
}
|
||||
}
|
||||
// Creeper.DATA_IS_POWERED.
|
||||
"minecraft:creeper" if e.charged => {
|
||||
meta.push((17, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
// Enderman.DATA_CARRY_STATE.
|
||||
"minecraft:enderman" => {
|
||||
if let Some(state) = e.carried {
|
||||
meta.push((
|
||||
16,
|
||||
serializer::OPTIONAL_BLOCK_STATE,
|
||||
Value::Id(i32::from(state.0 as u16)),
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
meta
|
||||
}
|
||||
|
||||
/// The equipment a mob shows (ClientboundSetEquipmentPacket slots: main hand,
|
||||
/// off hand, feet, legs, chest, head, body, saddle).
|
||||
fn mob_equipment(m: &Mob, hub: &mut Hub) -> Vec<(u8, Option<horizon_protocol::item::Stack>)> {
|
||||
let e = &m.extra;
|
||||
let mut out = Vec::new();
|
||||
let slots = [
|
||||
(0u8, &e.main_hand),
|
||||
(1, &e.off_hand),
|
||||
(2, &e.feet),
|
||||
(3, &e.legs),
|
||||
(4, &e.chest),
|
||||
(5, &e.head),
|
||||
];
|
||||
for (slot, item) in slots {
|
||||
if let Some(item) = item {
|
||||
let stack = ItemStack {
|
||||
item: item.clone(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
out.push((slot, hub.stack(&Some(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))));
|
||||
}
|
||||
out
|
||||
| "minecraft:mooshroom"
|
||||
| "minecraft:pig"
|
||||
| "minecraft:sheep"
|
||||
| "minecraft:chicken"
|
||||
| "minecraft:horse"
|
||||
| "minecraft:donkey"
|
||||
| "minecraft:mule"
|
||||
| "minecraft:llama"
|
||||
| "minecraft:cat"
|
||||
| "minecraft:wolf"
|
||||
| "minecraft:fox"
|
||||
| "minecraft:rabbit"
|
||||
| "minecraft:panda"
|
||||
| "minecraft:villager"
|
||||
| "minecraft:zombie"
|
||||
| "minecraft:husk"
|
||||
| "minecraft:drowned"
|
||||
| "minecraft:zombie_villager"
|
||||
| "minecraft:piglin"
|
||||
| "minecraft:hoglin"
|
||||
| "minecraft:goat"
|
||||
| "minecraft:ocelot"
|
||||
| "minecraft:turtle"
|
||||
| "minecraft:strider"
|
||||
| "minecraft:camel"
|
||||
| "minecraft:axolotl"
|
||||
)
|
||||
}
|
||||
|
||||
pub const HELP: &[&str] = &[
|
||||
@@ -390,7 +205,6 @@ impl Game<'_> {
|
||||
dimension: self.dim.id().to_owned(),
|
||||
seed: self.level.seed,
|
||||
max_players: MAX_PLAYERS,
|
||||
dedicated: false,
|
||||
},
|
||||
);
|
||||
let port = hub.lan.port;
|
||||
@@ -493,9 +307,31 @@ impl Game<'_> {
|
||||
fn lan_entities(&self, hub: &mut Hub) -> Vec<EntityState> {
|
||||
let mut out = Vec::with_capacity(self.mobs.len() + self.dropped.len());
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
let mut meta = Vec::new();
|
||||
if let Some(name) = &m.custom_name {
|
||||
meta.push((
|
||||
2,
|
||||
serializer::OPTIONAL_COMPONENT,
|
||||
Value::OptText(Some(horizon_protocol::text::literal(name))),
|
||||
));
|
||||
if m.name_visible {
|
||||
meta.push((3, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
if m.fire_ticks > 0 {
|
||||
meta.push((0, serializer::BYTE, Value::Byte(1)));
|
||||
}
|
||||
if m.health < m.kind.max_health {
|
||||
meta.push((9, serializer::FLOAT, Value::Float(m.health.max(0.0))));
|
||||
}
|
||||
let kind = m.kind.id.as_str();
|
||||
let meta = mob_meta(m, hub);
|
||||
let equipment = mob_equipment(m, hub);
|
||||
if ageable(kind) && m.is_baby() {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
if kind == "minecraft:sheep" {
|
||||
let value = (m.extra.color & 15) | if m.extra.sheared { 16 } else { 0 };
|
||||
meta.push((17, serializer::BYTE, Value::Byte(value as i8)));
|
||||
}
|
||||
let (yaw, pitch) = (m.yaw, m.head_pitch);
|
||||
out.push(EntityState {
|
||||
id: m.id as i32,
|
||||
@@ -509,7 +345,8 @@ impl Game<'_> {
|
||||
velocity: [m.velocity.x, m.velocity.y, m.velocity.z],
|
||||
data: 0,
|
||||
meta,
|
||||
equipment,
|
||||
equipment: Vec::new(),
|
||||
send_velocity: false,
|
||||
});
|
||||
}
|
||||
for d in &self.dropped {
|
||||
@@ -527,6 +364,7 @@ impl Game<'_> {
|
||||
data: 0,
|
||||
meta: vec![(8, serializer::ITEM_STACK, Value::Item(stack))],
|
||||
equipment: Vec::new(),
|
||||
send_velocity: true,
|
||||
});
|
||||
}
|
||||
out
|
||||
@@ -548,7 +386,7 @@ impl Game<'_> {
|
||||
let events = {
|
||||
let mut ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
host: Some(&state),
|
||||
host: &state,
|
||||
entities: &entities,
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
@@ -559,8 +397,6 @@ impl Game<'_> {
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
tick_rate: self.clock.rate(),
|
||||
tick_frozen: self.clock.frozen(),
|
||||
};
|
||||
host.hub.tick(&mut ctx)
|
||||
};
|
||||
@@ -618,15 +454,13 @@ impl Game<'_> {
|
||||
}
|
||||
let mut ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
host: Some(state),
|
||||
host: state,
|
||||
entities,
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
difficulty: i32::from(self.ui.difficulty),
|
||||
difficulty_locked: false,
|
||||
clock_rate: self.ui.clock.rate,
|
||||
tick_rate: self.clock.rate(),
|
||||
tick_frozen: self.clock.frozen(),
|
||||
};
|
||||
host.hub.place_new_player(conn, &mut ctx);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
//! block log, the level events and the entity tracker.
|
||||
use super::{Game, Hit, Mining, Screen, lan::LanHost};
|
||||
use horizon_client::{
|
||||
command::vanilla,
|
||||
containers::BlockEntity,
|
||||
entities::Experience,
|
||||
items::ItemStack,
|
||||
@@ -406,11 +405,6 @@ impl Game<'_> {
|
||||
self.guest_command(host, conn, &text);
|
||||
}
|
||||
}
|
||||
S::Unknown(id, payload)
|
||||
if id == horizon_protocol::ids::play::serverbound::COMMAND_SUGGESTION =>
|
||||
{
|
||||
self.guest_suggestions(host, conn, &payload);
|
||||
}
|
||||
S::ClientCommand(c) if c.action == 0 => self.guest_respawn(host, conn),
|
||||
S::PickItemFromBlock(p) => {
|
||||
let state = self.world.block(p.pos[0], p.pos[1], p.pos[2]);
|
||||
@@ -538,46 +532,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerGamePacketListenerImpl.handleCustomCommandSuggestions: the
|
||||
/// completions of the command typed so far (after an optional slash), for
|
||||
/// the nodes the tree marks `ask_server`.
|
||||
fn guest_suggestions(&mut self, host: &mut LanHost, conn: u32, payload: &[u8]) {
|
||||
let mut r = horizon_protocol::buf::Reader::new(payload);
|
||||
let (Ok(id), Ok(text)) = (r.varint(), r.string(32500)) else {
|
||||
return;
|
||||
};
|
||||
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
|
||||
return;
|
||||
};
|
||||
let skip = usize::from(text.starts_with('/'));
|
||||
let command = &text[skip..];
|
||||
let d = vanilla::dispatcher();
|
||||
let level = std::mem::replace(&mut self.ui.registries.permission, permission);
|
||||
let parse = d.parse(command, &self.ui.registries);
|
||||
let ((start, end), suggestions) = d.suggestions(&parse, command.len(), &self.ui.registries);
|
||||
self.ui.registries.permission = level;
|
||||
let utf16 = |byte: usize| command[..byte.min(command.len())].encode_utf16().count() as i32;
|
||||
let mut w = horizon_protocol::buf::Writer::new();
|
||||
w.varint(id);
|
||||
w.varint(utf16(start) + skip as i32);
|
||||
w.varint(utf16(end) - utf16(start));
|
||||
w.varint(suggestions.len() as i32);
|
||||
for s in &suggestions {
|
||||
w.string(&s.text);
|
||||
w.bool(s.tooltip.is_some());
|
||||
if let Some(tip) = &s.tooltip {
|
||||
w.nbt(&literal(tip));
|
||||
}
|
||||
}
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::Unknown(
|
||||
horizon_protocol::ids::play::clientbound::COMMAND_SUGGESTIONS,
|
||||
w.data,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn guest_respawn(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
|
||||
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
|
||||
@@ -8,6 +8,7 @@ use horizon_client::{
|
||||
mesher::Vertex,
|
||||
mobs::leash::{self, Holder, Pending},
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `Vec3.yRot(angle)`.
|
||||
@@ -65,10 +66,13 @@ impl Game<'_> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `Entity.spawnAtLocation(level, Items.LEAD)`: the item entity draws its
|
||||
/// motion from its own random, not the level's.
|
||||
fn drop_lead_item(&mut self, at: [f64; 3]) {
|
||||
let v = super::dispenser::new_item_motion();
|
||||
let r = &mut self.world.random;
|
||||
let v = [
|
||||
f64::from(r.next_float()) * 0.2 - 0.1,
|
||||
0.2,
|
||||
f64::from(r.next_float()) * 0.2 - 0.1,
|
||||
];
|
||||
self.spawn_item(ItemStack::new("minecraft:lead", 1), at, v, 10);
|
||||
}
|
||||
/// `Leashable.dropLeash` / `removeLeash`: the lead falls as an item.
|
||||
|
||||
@@ -605,12 +605,7 @@ impl Game<'_> {
|
||||
let Some(slot) = slot else {
|
||||
self.dispenser_event(1001, key, 0);
|
||||
if !dropper {
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_activate",
|
||||
[pos.x, pos.y, pos.z],
|
||||
None,
|
||||
Some(state),
|
||||
);
|
||||
self.post_block_game_event("minecraft:block_activate", [pos.x, pos.y, pos.z], None, Some(state));
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@@ -72,12 +72,7 @@ impl Game<'_> {
|
||||
let triggered = self.containers().prop(state, "triggered") == Some("true");
|
||||
let mut finished = false;
|
||||
if let Some(BlockEntity::Crafter(c)) = self.world.block_entities.get_mut(&pos) {
|
||||
// CrafterBlock.neighborChanged sets the flag with the state; a
|
||||
// loaded crafter keeps its saved flag until then.
|
||||
if c.state_triggered.is_some_and(|t| t != triggered) {
|
||||
c.triggered = triggered;
|
||||
}
|
||||
c.state_triggered = Some(triggered);
|
||||
c.triggered = triggered;
|
||||
let remaining = c.crafting_ticks - 1;
|
||||
if remaining >= 0 {
|
||||
c.crafting_ticks = remaining;
|
||||
|
||||
@@ -28,9 +28,6 @@ pub(super) enum CartKind {
|
||||
Hopper,
|
||||
Furnace,
|
||||
Tnt,
|
||||
/// `MinecartCommandBlock`; its command block holds no command, so an
|
||||
/// activator rail runs nothing.
|
||||
CommandBlock,
|
||||
}
|
||||
impl CartKind {
|
||||
pub fn from_id(id: &str) -> Option<Self> {
|
||||
@@ -40,7 +37,6 @@ impl CartKind {
|
||||
"hopper_minecart" => Self::Hopper,
|
||||
"furnace_minecart" => Self::Furnace,
|
||||
"tnt_minecart" => Self::Tnt,
|
||||
"command_block_minecart" => Self::CommandBlock,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -51,7 +47,6 @@ impl CartKind {
|
||||
Self::Hopper => "minecraft:hopper_minecart",
|
||||
Self::Furnace => "minecraft:furnace_minecart",
|
||||
Self::Tnt => "minecraft:tnt_minecart",
|
||||
Self::CommandBlock => "minecraft:command_block_minecart",
|
||||
}
|
||||
}
|
||||
fn slots(self) -> usize {
|
||||
@@ -197,7 +192,6 @@ impl CartModels {
|
||||
CartKind::Hopper,
|
||||
CartKind::Furnace,
|
||||
CartKind::Tnt,
|
||||
CartKind::CommandBlock,
|
||||
];
|
||||
let models = kinds
|
||||
.into_iter()
|
||||
@@ -987,8 +981,6 @@ impl Game<'_> {
|
||||
self.prime_cart(&mut c);
|
||||
self.carts[i] = c;
|
||||
}
|
||||
// BaseCommandBlock.usedBy: only an operator in creative opens it.
|
||||
CartKind::CommandBlock => return false,
|
||||
}
|
||||
self.swing_arm();
|
||||
true
|
||||
@@ -998,10 +990,9 @@ impl Game<'_> {
|
||||
let Some(key) = self.container.filter(|k| k.1 == CART_KEY_Y) else {
|
||||
return;
|
||||
};
|
||||
let Some(BlockEntity::Chest(items)) = self.world.block_entities.remove(&key) else {
|
||||
return;
|
||||
};
|
||||
if let Some(c) = self.carts.iter_mut().find(|c| c.id as i32 == key.2) {
|
||||
if let Some(BlockEntity::Chest(items)) = self.world.block_entities.remove(&key)
|
||||
&& let Some(c) = self.carts.iter_mut().find(|c| c.id as i32 == key.2)
|
||||
{
|
||||
c.items = items;
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
if c.kind == CartKind::Chest {
|
||||
@@ -1009,24 +1000,15 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:block.chest.close", Some(at), 0.5, pitch);
|
||||
}
|
||||
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id as i32 == key.2) {
|
||||
b.items = items;
|
||||
}
|
||||
}
|
||||
/// The open cart's kind when a cart container is shown.
|
||||
pub(super) fn open_cart_kind(&self) -> Option<CartKind> {
|
||||
let key = self.container.filter(|k| k.1 == CART_KEY_Y)?;
|
||||
// A chest boat's chest shows like a chest minecart's.
|
||||
self.carts
|
||||
.iter()
|
||||
.find(|c| c.id as i32 == key.2)
|
||||
.map(|c| c.kind)
|
||||
.or_else(|| {
|
||||
self.boats
|
||||
.iter()
|
||||
.any(|b| b.chest && b.id as i32 == key.2)
|
||||
.then_some(CartKind::Chest)
|
||||
})
|
||||
}
|
||||
/// `VehicleEntity.hurtServer` from the player's attack.
|
||||
pub(super) fn attack_cart(&mut self, damage: f32) -> bool {
|
||||
@@ -1139,7 +1121,6 @@ impl Game<'_> {
|
||||
],
|
||||
),
|
||||
CartKind::Tnt => registry.default_state("minecraft:tnt"),
|
||||
CartKind::CommandBlock => registry.default_state("minecraft:command_block"),
|
||||
}
|
||||
}
|
||||
/// `AbstractMinecartRenderer` (old render): on the rail between the
|
||||
@@ -1302,13 +1283,6 @@ impl Minecart {
|
||||
put("Fuel", Tag::Short(self.fuel as i16));
|
||||
}
|
||||
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
|
||||
// BaseCommandBlock.save of an empty command block.
|
||||
CartKind::CommandBlock => {
|
||||
put("Command", Tag::String(JavaString::from("")));
|
||||
put("SuccessCount", Tag::Int(0));
|
||||
put("TrackOutput", Tag::Byte(1));
|
||||
put("UpdateLastExecution", Tag::Byte(1));
|
||||
}
|
||||
CartKind::Rideable => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
//! Blocks mobs carry: block items in a hand (`ItemInHandLayer`) and the
|
||||
//! block an enderman holds (`CarriedBlockLayer`), drawn as shaded cubes like
|
||||
//! dropped block items.
|
||||
use super::Game;
|
||||
use super::{Game, Icon};
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Mob, Species, render},
|
||||
};
|
||||
@@ -177,6 +176,22 @@ impl Game<'_> {
|
||||
render::draw(flag, m, &flag_poses, r, color, light, 0, out);
|
||||
}
|
||||
}
|
||||
/// Resolve the icons of the items mobs hold, so drawing can look them up.
|
||||
pub(super) fn cache_mob_icons(&mut self) {
|
||||
let items: Vec<String> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.extra.main_hand.is_some() || m.extra.off_hand.is_some())
|
||||
.flat_map(|m| [m.extra.main_hand.clone(), m.extra.off_hand.clone()])
|
||||
.flatten()
|
||||
.map(|i| i.to_string())
|
||||
.collect();
|
||||
for item in items {
|
||||
if !self.res.icons.contains_key(&item) {
|
||||
let _ = self.icon(&item);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn push_cube(
|
||||
&self,
|
||||
t: glam::Mat4,
|
||||
@@ -217,7 +232,40 @@ impl Game<'_> {
|
||||
) {
|
||||
let overlay = u32::from(mob.hurt_time > 0);
|
||||
self.captain_banner_geometry(mob, camera, alpha, light, out);
|
||||
self.held_item_geometry(mob, camera, alpha, light, true, out);
|
||||
for (item, left) in [(&mob.extra.main_hand, false), (&mob.extra.off_hand, true)] {
|
||||
let Some(item) = item else { continue };
|
||||
let Some(Icon::Cube {
|
||||
top,
|
||||
left: z,
|
||||
right: x,
|
||||
tint,
|
||||
}) = self.res.icons.get(&**item).cloned().flatten()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(t) =
|
||||
render::held_block_transform(mob, alpha as f32, camera, &self.res.mob_data, left)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let slot = if left {
|
||||
horizon_client::mobs::gear::OFF
|
||||
} else {
|
||||
horizon_client::mobs::gear::MAIN
|
||||
};
|
||||
let glint = horizon_client::mobs::gear::enchanted(mob, slot);
|
||||
let overlay = if glint { 7 } else { overlay };
|
||||
self.push_cube(
|
||||
t,
|
||||
&Cube {
|
||||
sprites: [&top, &z, &x],
|
||||
tint,
|
||||
},
|
||||
light,
|
||||
overlay,
|
||||
out,
|
||||
);
|
||||
}
|
||||
if mob.species() == Species::Enderman
|
||||
&& let Some(state) = mob.extra.carried
|
||||
&& let Some((sprite, tint)) = self.particle_sprite(state)
|
||||
@@ -244,71 +292,4 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `ItemInHandLayer`: both hands through the item model pipeline; `lit`
|
||||
/// applies the level's diffuse lighting (off for GUI previews, which
|
||||
/// light the projected quads themselves).
|
||||
pub(super) fn held_item_geometry(
|
||||
&self,
|
||||
mob: &Mob,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
light: u32,
|
||||
lit: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let overlay = u32::from(mob.hurt_time > 0);
|
||||
for (item, left) in [(&mob.extra.main_hand, false), (&mob.extra.off_hand, true)] {
|
||||
let Some(item) = item else { continue };
|
||||
let Some(t) =
|
||||
render::held_item_transform(mob, alpha as f32, camera, &self.res.mob_data, left)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let slot = if left {
|
||||
horizon_client::mobs::gear::OFF
|
||||
} else {
|
||||
horizon_client::mobs::gear::MAIN
|
||||
};
|
||||
let mut stack = ItemStack::new(item, 1);
|
||||
if horizon_client::mobs::gear::enchanted(mob, slot) {
|
||||
// The gear's enchantments are not tracked, only that it has some.
|
||||
stack.enchant("minecraft:unbreaking", 1);
|
||||
}
|
||||
if &**item == "minecraft:crossbow" && mob.extra.flag2 {
|
||||
stack.data_mut().charged = vec![ItemStack::new("minecraft:arrow", 1)];
|
||||
}
|
||||
let mut owner = super::item_draw::owner_at(
|
||||
[mob.position.x, mob.position.y, mob.position.z],
|
||||
mob.body_yaw,
|
||||
&mob.kind.id,
|
||||
);
|
||||
if !left && mob.extra.using_item > 0 {
|
||||
owner.using_this_item = true;
|
||||
owner.use_duration = 72_000;
|
||||
owner.use_item_remaining_ticks = 72_000 - mob.extra.using_item;
|
||||
owner.crossbow_charge_duration = 25;
|
||||
}
|
||||
let context = if left {
|
||||
horizon_assets::item_model::DisplayContext::ThirdPersonLeftHand
|
||||
} else {
|
||||
horizon_assets::item_model::DisplayContext::ThirdPersonRightHand
|
||||
};
|
||||
// `updateForLiving`: seeded by the entity id plus the context ordinal.
|
||||
let seed = mob.id as i32 + if left { 1 } else { 2 };
|
||||
let state = self.resolve_item(&stack, context, Some(&owner), seed);
|
||||
let start = out.len();
|
||||
if lit {
|
||||
self.submit_item_world(&state, t, light, out);
|
||||
} else {
|
||||
self.submit_item_unlit(&state, t, light, out);
|
||||
}
|
||||
if overlay != 0 {
|
||||
for v in &mut out[start..] {
|
||||
if v.layer == 0 {
|
||||
v.layer = overlay;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -462,8 +462,8 @@ impl Game<'_> {
|
||||
};
|
||||
let m = &self.mobs[i];
|
||||
let atlas = &self.res.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);
|
||||
let hearts = horizon_client::hud_layout::vehicle_max_hearts(m.attrs.max_health as f32);
|
||||
let draws = horizon_client::hud_layout::vehicle_health(w, h, hearts, m.health as f32);
|
||||
horizon_client::hud_layout::paint(batch, atlas, &draws);
|
||||
if m.extra.saddled {
|
||||
let x = (w / 2 - 91) as f32;
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
//! `Minecraft.getSituationalMusic` and `getMusicVolume` for the music manager,
|
||||
//! the local player's ambient sound handlers and the sound engine tick.
|
||||
//! `Minecraft.getSituationalMusic` and `getMusicVolume` for the music manager.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
music::{BackgroundMusic, Music, Situation},
|
||||
sound_engine::TickContext,
|
||||
};
|
||||
use horizon_client::{blocks::Fluid, music::Music};
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Minecraft.tick` -> `MusicManager.tick`, once per client tick.
|
||||
@@ -16,86 +11,41 @@ impl Game<'_> {
|
||||
self.music
|
||||
.tick(music.as_ref(), volume, loading, &mut self.audio);
|
||||
}
|
||||
/// `Minecraft.tick` -> `SoundManager.tick(pause)`, after the music, with
|
||||
/// `pauseAllExcept(MUSIC, UI)` when the game pauses and `resume` when it
|
||||
/// carries on.
|
||||
pub(super) fn tick_sound_engine(&mut self) {
|
||||
if self.pause != self.sound_paused {
|
||||
if self.pause {
|
||||
self.audio.pause();
|
||||
} else {
|
||||
self.audio.resume();
|
||||
}
|
||||
self.sound_paused = self.pause;
|
||||
}
|
||||
let ctx = TickContext {
|
||||
player_underwater: self.ambience.was_underwater(),
|
||||
player_removed: self.player.is_none(),
|
||||
};
|
||||
self.audio.tick(self.pause, &ctx);
|
||||
}
|
||||
/// `getSituationalMusic` and `getMusicVolume`.
|
||||
fn situational_music(&self) -> (Option<Music>, f32) {
|
||||
let empty = BackgroundMusic::default();
|
||||
// The title screen has no player (and no level for the camera probe).
|
||||
let eye = if self.title.is_some() {
|
||||
None
|
||||
} else {
|
||||
self.eye_position()
|
||||
// Title screen: no player, Musics.MENU at full volume.
|
||||
if self.title.is_some() {
|
||||
return (Some(Music::menu()), 1.0);
|
||||
}
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return (Some(Music::menu()), 1.0);
|
||||
};
|
||||
let (background, probe_volume) = match eye {
|
||||
Some(eye) => {
|
||||
let (dimension, biome) = (self.dim.id(), self.biome_at(eye));
|
||||
let tables = &self.res.music;
|
||||
(
|
||||
tables.background(dimension, biome),
|
||||
tables.volume(dimension, biome),
|
||||
)
|
||||
}
|
||||
None => (empty, 1.0),
|
||||
};
|
||||
let situation = Situation {
|
||||
screen_music: None,
|
||||
in_world: eye.is_some(),
|
||||
in_end: self.dim == super::Dim::End,
|
||||
boss_music: self.boss_music(),
|
||||
background: &background,
|
||||
probe_volume,
|
||||
creative: self.creative,
|
||||
underwater: self.ambience.was_underwater(),
|
||||
};
|
||||
(situation.music(), situation.volume())
|
||||
}
|
||||
/// `BiomeManager.getNoiseBiomeAtPosition`: the quart cell holding a position.
|
||||
fn biome_at(&self, p: [f64; 3]) -> &str {
|
||||
let (x, y, z) = (
|
||||
p[0].floor() as i32,
|
||||
p[1].floor() as i32,
|
||||
p[2].floor() as i32,
|
||||
eye[0].floor() as i32,
|
||||
eye[1].floor() as i32,
|
||||
eye[2].floor() as i32,
|
||||
);
|
||||
self.world
|
||||
let biome = self
|
||||
.world
|
||||
.chunks
|
||||
.get(&(x >> 4, z >> 4))
|
||||
.map_or("minecraft:plains", |c| {
|
||||
self.res.biomes.list[c.biome(x, y, z) as usize]
|
||||
.name
|
||||
.as_str()
|
||||
})
|
||||
}
|
||||
/// `isEyeInFluid(WATER)`: the eye inside the water of its block column.
|
||||
fn eye_in_water(&self, x: f64, eye_y: f64, z: f64) -> bool {
|
||||
let (bx, by, bz) = (x.floor() as i32, eye_y.floor() as i32, z.floor() as i32);
|
||||
let f = self.res.facts.get(self.world.block(bx, by, bz));
|
||||
if f.fluid != Fluid::Water {
|
||||
return false;
|
||||
});
|
||||
// Minecraft.getSituationalMusic: the boss bar's music in the End.
|
||||
if self.boss_music() {
|
||||
return (Some(Music::end_boss()), 1.0);
|
||||
}
|
||||
let above = self.res.facts.get(self.world.block(bx, by + 1, bz));
|
||||
let height = if above.fluid == Fluid::Water {
|
||||
1.0
|
||||
} else {
|
||||
f64::from(f.fluid_level) / 9.0
|
||||
};
|
||||
eye_y <= f64::from(by) + height
|
||||
let dimension = self.dim.id();
|
||||
let tables = &self.res.music;
|
||||
// Player.isUnderWater: eyes in water.
|
||||
let underwater = self.res.facts.get(self.world.block(x, y, z)).fluid == Fluid::Water;
|
||||
let music = tables
|
||||
.background(dimension, biome)
|
||||
.select(self.creative, underwater)
|
||||
.cloned();
|
||||
(music, tables.volume(dimension, biome))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,46 +57,33 @@ impl Game<'_> {
|
||||
};
|
||||
let feet = [p.position.x, p.position.y, p.position.z];
|
||||
let eye_y = feet[1] + f64::from(p.dimensions.eye_height);
|
||||
let sounds = self.res.ambient.get(self.dim.id(), self.biome_at(feet));
|
||||
let underwater = self.eye_in_water(feet[0], eye_y, feet[2]);
|
||||
let (x, z) = (feet[0].floor() as i32, feet[2].floor() as i32);
|
||||
let world = &self.world;
|
||||
let biome = world
|
||||
.chunks
|
||||
.get(&(x >> 4, z >> 4))
|
||||
.map_or("minecraft:plains", |c| {
|
||||
self.res.biomes.list[c.biome(x, feet[1].floor() as i32, z) as usize]
|
||||
.name
|
||||
.as_str()
|
||||
});
|
||||
let sounds = self.res.ambient.get(self.dim.id(), biome);
|
||||
let underwater = self
|
||||
.res
|
||||
.facts
|
||||
.get(world.block(x, eye_y.floor() as i32, z))
|
||||
.fluid
|
||||
== Fluid::Water;
|
||||
let light = |b: [i32; 3]| {
|
||||
let l = world.light(b[0], b[1], b[2]);
|
||||
(l >> 4, l & 15)
|
||||
};
|
||||
let (half, height) = (
|
||||
f64::from(p.dimensions.width / 2.0),
|
||||
f64::from(p.dimensions.height),
|
||||
);
|
||||
let registry = &self.res.assets.registry;
|
||||
let facts = &self.res.facts;
|
||||
let bubble_column = horizon_client::ambience::bubble_column_in(
|
||||
[
|
||||
feet[0] - half,
|
||||
feet[1],
|
||||
feet[2] - half,
|
||||
feet[0] + half,
|
||||
feet[1] + height,
|
||||
feet[2] + half,
|
||||
],
|
||||
|b| {
|
||||
let state = world.block(b[0], b[1], b[2]);
|
||||
(&*facts.get(state).name == "minecraft:bubble_column").then(|| {
|
||||
registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("drag"))
|
||||
.is_some_and(|d| d == "true")
|
||||
})
|
||||
},
|
||||
);
|
||||
let ctx = horizon_client::ambience::AmbientContext {
|
||||
sounds: &sounds,
|
||||
position: feet,
|
||||
eye_y,
|
||||
underwater,
|
||||
spectator: self.spectator(),
|
||||
light: &light,
|
||||
bubble_column,
|
||||
};
|
||||
self.ambience.tick(&ctx, &mut self.audio);
|
||||
}
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
//! The game's side of `NaturalSpawner` (`mobs::spawn_selection`): the level
|
||||
//! it reads, the spawning chunks and caps of `ServerChunkCache.tickChunks`,
|
||||
//! and the mobs it adds (finalized with the game's `finalizeSpawn`).
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::{
|
||||
biome::{BiomeInfo, SpawnEntry},
|
||||
blocks::BlockFacts,
|
||||
collisions::{SceneCollisions, no_collision_empty},
|
||||
mobs::{
|
||||
Mob, MobWorld, Species,
|
||||
data::{self, MobData},
|
||||
spawn_rules::{Dimension, SpawnLevel},
|
||||
spawn_selection::{
|
||||
self, Category, Counted, Heightmap, NaturalLevel, SpawnCost, SpawnPlayer, SpawnRequest,
|
||||
SpawnState, Spawned, StructureAt, StructureSpawns,
|
||||
},
|
||||
},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::{LegacyRandom, RandomSource, WorldgenRandom},
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The game level as natural spawning reads it.
|
||||
struct GameLevel<'g, 'a> {
|
||||
game: &'g Game<'a>,
|
||||
w: MobWorld<'g>,
|
||||
/// Boxes of the mobs this pass created, not yet in the game's list.
|
||||
pending: std::cell::RefCell<Vec<Aabb>>,
|
||||
}
|
||||
impl GameLevel<'_, '_> {
|
||||
fn biome(&self, x: i32, y: i32, z: i32) -> Option<&BiomeInfo> {
|
||||
let chunk = self.game.world.chunks.get(&(x >> 4, z >> 4))?;
|
||||
Some(self.game.res.biomes.get(chunk.biome(x, y, z)))
|
||||
}
|
||||
}
|
||||
impl SpawnLevel for GameLevel<'_, '_> {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
self.w.state(x, y, z)
|
||||
}
|
||||
fn facts(&self) -> &BlockFacts {
|
||||
self.w.facts
|
||||
}
|
||||
fn mob_data(&self) -> &MobData {
|
||||
self.w.data
|
||||
}
|
||||
fn block_tag(&self, tag: &str, block: &str) -> bool {
|
||||
self.w.block_tag(tag, block)
|
||||
}
|
||||
fn biome_tag(&self, tag: &str, x: i32, y: i32, z: i32) -> bool {
|
||||
self.w.biome_tag(tag, x, y, z)
|
||||
}
|
||||
fn sky_light(&self, x: i32, y: i32, z: i32) -> i32 {
|
||||
self.w.sky_light(x, y, z)
|
||||
}
|
||||
fn block_light(&self, x: i32, y: i32, z: i32) -> i32 {
|
||||
self.w.block_light(x, y, z)
|
||||
}
|
||||
fn sky_darken(&self) -> i32 {
|
||||
SpawnLevel::sky_darken(&self.w)
|
||||
}
|
||||
fn thundering(&self) -> bool {
|
||||
self.w.thundering
|
||||
}
|
||||
fn sea_level(&self) -> i32 {
|
||||
self.w.sea_level()
|
||||
}
|
||||
fn world_surface(&self, x: i32, z: i32) -> i32 {
|
||||
self.w.world_surface(x, z)
|
||||
}
|
||||
fn peaceful(&self) -> bool {
|
||||
self.w.peaceful()
|
||||
}
|
||||
fn dimension(&self) -> Dimension {
|
||||
self.w.dimension()
|
||||
}
|
||||
fn surface_slime_chance(&self, x: i32, y: i32, z: i32) -> f32 {
|
||||
self.w.surface_slime_chance(x, y, z)
|
||||
}
|
||||
fn world_seed(&self) -> i64 {
|
||||
self.w.world_seed
|
||||
}
|
||||
fn player_near(&self, x: f64, y: f64, z: f64, distance: f64) -> bool {
|
||||
self.w.player_near(x, y, z, distance)
|
||||
}
|
||||
}
|
||||
impl NaturalLevel for GameLevel<'_, '_> {
|
||||
fn min_y(&self) -> i32 {
|
||||
horizon_client::chunk::MIN_Y
|
||||
}
|
||||
fn max_y(&self) -> i32 {
|
||||
horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT - 1
|
||||
}
|
||||
fn ambient_light(&self) -> f32 {
|
||||
match self.game.dim {
|
||||
Dim::Nether => 0.1,
|
||||
Dim::End => 0.25,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
fn has_ceiling(&self) -> bool {
|
||||
self.game.dim == Dim::Nether
|
||||
}
|
||||
fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)> {
|
||||
let t = self.game.res.mob_types.get(entity)?;
|
||||
Some((
|
||||
t.species,
|
||||
t.width as f32,
|
||||
t.height as f32,
|
||||
Category::from_name(&t.category),
|
||||
))
|
||||
}
|
||||
fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap<String, Vec<SpawnEntry>>> {
|
||||
self.biome(x, y, z).map(|b| &b.spawns)
|
||||
}
|
||||
fn spawn_cost(&self, entity: &str, x: i32, y: i32, z: i32) -> Option<SpawnCost> {
|
||||
self.biome(x, y, z)?.spawn_costs.get(entity).copied()
|
||||
}
|
||||
fn creature_probability(&self, x: i32, y: i32, z: i32) -> f32 {
|
||||
self.biome(x, y, z).map_or(0.1, |b| b.creature_probability)
|
||||
}
|
||||
fn structures_at(&self, x: i32, y: i32, z: i32) -> Vec<StructureAt> {
|
||||
self.game
|
||||
.structures_reaching([x, y, z])
|
||||
.into_iter()
|
||||
.map(|(id, in_piece)| StructureAt {
|
||||
id,
|
||||
in_piece,
|
||||
in_bounds: true,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn structure_spawns(&self) -> &StructureSpawns {
|
||||
StructureSpawns::shared(&self.game.options.resources)
|
||||
}
|
||||
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
|
||||
let p = self.game.player.as_ref()?;
|
||||
if self.game.spectator() {
|
||||
return None;
|
||||
}
|
||||
let d = [p.position.x - x, p.position.y - y, p.position.z - z];
|
||||
Some(d[0] * d[0] + d[1] * d[1] + d[2] * d[2])
|
||||
}
|
||||
fn respawn(&self) -> Option<[i32; 3]> {
|
||||
// The world spawn point lives in the overworld.
|
||||
if self.game.dim != Dim::Overworld {
|
||||
return None;
|
||||
}
|
||||
self.game.level.spawn.map(|s| {
|
||||
[
|
||||
s[0].floor() as i32,
|
||||
s[1].floor() as i32,
|
||||
s[2].floor() as i32,
|
||||
]
|
||||
})
|
||||
}
|
||||
fn can_spawn_in_chunk(&self, cx: i32, cz: i32) -> bool {
|
||||
self.game.world.chunks.contains_key(&(cx, cz))
|
||||
}
|
||||
fn no_collision(&self, bounds: Aabb) -> bool {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.game.world,
|
||||
binding: self.w.collision,
|
||||
};
|
||||
no_collision_empty(&mut scene, bounds)
|
||||
}
|
||||
fn unobstructed(&self, bounds: Aabb) -> bool {
|
||||
let player = self
|
||||
.game
|
||||
.player_view()
|
||||
.is_some_and(|p| p.alive && !p.spectator && p.bounds.intersects(bounds));
|
||||
!player
|
||||
&& !self.pending.borrow().iter().any(|b| b.intersects(bounds))
|
||||
&& !self
|
||||
.game
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.alive() && !m.removed && m.bounds().intersects(bounds))
|
||||
}
|
||||
fn spawn_mobs(&self) -> bool {
|
||||
self.game.ui.rules.bool("spawn_mobs")
|
||||
}
|
||||
fn heightmap(&self, kind: Heightmap, x: i32, z: i32) -> i32 {
|
||||
let top = horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT;
|
||||
(horizon_client::chunk::MIN_Y..top)
|
||||
.rev()
|
||||
.find(|&y| spawn_selection::heightmap_blocks(self, kind, x, y, z))
|
||||
.map_or(horizon_client::chunk::MIN_Y, |y| y + 1)
|
||||
}
|
||||
fn pathfindable_land(&self, x: i32, y: i32, z: i32) -> bool {
|
||||
self.w.data.state(self.state(x, y, z)).flags & data::LAND != 0
|
||||
}
|
||||
}
|
||||
impl<'a> Game<'a> {
|
||||
fn natural_level(&self) -> GameLevel<'_, 'a> {
|
||||
GameLevel {
|
||||
game: self,
|
||||
w: self.mob_world_base(&[]),
|
||||
pending: Default::default(),
|
||||
}
|
||||
}
|
||||
/// Creates a mob the spawner accepted, finalized the game's way; the
|
||||
/// caller adds it with [`Game::add_spawned`].
|
||||
fn finalize_spawned(&self, r: &SpawnRequest, pending: &mut Vec<Mob>) -> Option<Spawned> {
|
||||
let kind = self.res.mob_types.get(r.entity)?.clone();
|
||||
let [x, y, z] = r.position;
|
||||
let mut mob = Mob::new(0, kind, Vec3::new(x, y, z), r.yaw);
|
||||
let facts = self.biome_facts(x.floor() as i32, y.floor() as i32, z.floor() as i32);
|
||||
let difficulty = self.local_difficulty(r.position);
|
||||
horizon_client::mobs::spawning::finalize(&mut mob, &facts, &difficulty);
|
||||
horizon_client::mobs::raider::finalize(
|
||||
&mut mob,
|
||||
horizon_client::mobs::raider::Reason::Natural,
|
||||
);
|
||||
let bounds = mob.bounds();
|
||||
pending.push(mob);
|
||||
Some(Spawned {
|
||||
bounds,
|
||||
school: true,
|
||||
})
|
||||
}
|
||||
/// Adds the mobs a spawn pass created: ids, enchanted gear and jockeys.
|
||||
fn add_spawned(&mut self, pending: Vec<Mob>, persistent: bool) {
|
||||
for mut mob in pending {
|
||||
self.next_entity += 1;
|
||||
mob.id = self.next_entity;
|
||||
mob.persistent = persistent;
|
||||
let id = mob.id;
|
||||
self.mobs.push(mob);
|
||||
self.enchant_spawn_equipment(id);
|
||||
if !persistent {
|
||||
self.spawn_jockeys(id, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ServerChunkCache.tickChunks`: the spawn state over the loaded mobs,
|
||||
/// the categories under their global caps (creatures every 400 ticks),
|
||||
/// then `spawnForChunk` in the shuffled chunks within 128 blocks of the
|
||||
/// player.
|
||||
pub(super) fn spawn_natural(&mut self) {
|
||||
let Some(p) = self.player.as_ref().map(|p| p.position) else {
|
||||
return;
|
||||
};
|
||||
let (pcx, pcz) = (p.x.floor() as i32 >> 4, p.z.floor() as i32 >> 4);
|
||||
// ChunkMap.collectSpawningChunks: tracked chunks whose centre is
|
||||
// within 128 blocks of a non-spectator.
|
||||
let spectator = self.spectator();
|
||||
let mut chunks: Vec<(i32, i32)> = (-8..=8)
|
||||
.flat_map(|dx| (-8..=8).map(move |dz| (pcx + dx, pcz + dz)))
|
||||
.filter(|&(cx, cz)| {
|
||||
let (x, z) = (f64::from(cx * 16 + 8) - p.x, f64::from(cz * 16 + 8) - p.z);
|
||||
!spectator && x * x + z * z < 16384.0 && self.world.chunks.contains_key(&(cx, cz))
|
||||
})
|
||||
.collect();
|
||||
let spawn_mobs = self.ui.rules.bool("spawn_mobs");
|
||||
let spawn_enemies = self.ui.rules.bool("spawn_monsters");
|
||||
let mut random = std::mem::replace(&mut self.world.random, LegacyRandom::new(0));
|
||||
let mut pending = Vec::new();
|
||||
{
|
||||
let level = self.natural_level();
|
||||
let counted: Vec<Counted> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.map(|m| Counted {
|
||||
entity: &m.kind.id,
|
||||
category: Category::from_name(&m.kind.category),
|
||||
block: [
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
],
|
||||
is_mob: true,
|
||||
persistent: m.persistent,
|
||||
})
|
||||
.collect();
|
||||
let players = vec![SpawnPlayer {
|
||||
position: [p.x, p.y, p.z],
|
||||
spectator,
|
||||
}];
|
||||
// A single player's natural spawn chunk tracker covers 17 x 17 chunks.
|
||||
let mut state = SpawnState::new(&level, 289, players, &counted, |cx, cz| {
|
||||
self.world.chunks.contains_key(&(cx, cz))
|
||||
});
|
||||
let categories = if spawn_mobs {
|
||||
state.filtered_categories(spawn_enemies, self.world.game_time % 400 == 0)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// Util.shuffle.
|
||||
for i in (1..chunks.len()).rev() {
|
||||
let j = random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
|
||||
chunks.swap(i, j);
|
||||
}
|
||||
if !categories.is_empty() {
|
||||
for chunk in chunks {
|
||||
let mut spawn = |l: &GameLevel, r: &SpawnRequest| {
|
||||
let spawned = l.game.finalize_spawned(r, &mut pending);
|
||||
if let Some(s) = &spawned {
|
||||
l.pending.borrow_mut().push(s.bounds);
|
||||
}
|
||||
spawned
|
||||
};
|
||||
spawn_selection::spawn_for_chunk(
|
||||
&level,
|
||||
&mut random,
|
||||
&mut state,
|
||||
&categories,
|
||||
chunk,
|
||||
&mut spawn,
|
||||
&mut |_| {},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.world.random = random;
|
||||
self.add_spawned(pending, false);
|
||||
}
|
||||
/// `NoiseBasedChunkGenerator.spawnOriginalMobs`: creature packs for a
|
||||
/// freshly generated chunk, from the biome at its corner column's top,
|
||||
/// with the chunk's decoration-seeded random (mobs are not generated in
|
||||
/// the End).
|
||||
pub(super) fn spawn_generation_creatures(&mut self, cx: i32, cz: i32) {
|
||||
if !self.world.chunks.contains_key(&(cx, cz)) || self.dim == Dim::End {
|
||||
return;
|
||||
}
|
||||
let mut random = WorldgenRandom::new(LegacyRandom::new(0));
|
||||
random.set_decoration_seed(self.level.seed, cx * 16, cz * 16);
|
||||
// The region's own random: no creature's spawn predicate draws from it.
|
||||
let mut rules = LegacyRandom::new(self.level.seed ^ (i64::from(cx) << 32 | i64::from(cz)));
|
||||
let mut pending = Vec::new();
|
||||
{
|
||||
let level = self.natural_level();
|
||||
let source = [cx * 16, level.max_y(), cz * 16];
|
||||
let mut spawn = |l: &GameLevel, r: &SpawnRequest| {
|
||||
let spawned = l.game.finalize_spawned(r, &mut pending);
|
||||
if let Some(s) = &spawned {
|
||||
l.pending.borrow_mut().push(s.bounds);
|
||||
}
|
||||
spawned
|
||||
};
|
||||
spawn_selection::spawn_mobs_for_chunk_generation(
|
||||
&level,
|
||||
source,
|
||||
(cx, cz),
|
||||
&mut random,
|
||||
&mut rules,
|
||||
&mut spawn,
|
||||
&mut |_| {},
|
||||
);
|
||||
}
|
||||
self.add_spawned(pending, true);
|
||||
}
|
||||
}
|
||||
@@ -123,10 +123,8 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
self.res.atlas = atlas;
|
||||
match horizon_client::item_render::ItemRenderer::load(&root) {
|
||||
Ok(r) => self.res.item_renderer = std::sync::Arc::new(r),
|
||||
Err(e) => eprintln!("Horizon: item model reload failed: {e}"),
|
||||
}
|
||||
self.res.icons.clear();
|
||||
self.res.displays.clear();
|
||||
} else {
|
||||
eprintln!(
|
||||
"Horizon: resource pack atlas size changed; restart to apply GUI textures"
|
||||
|
||||
@@ -48,16 +48,7 @@ fn with_raw(modelled: Tag, raw: Option<&Tag>) -> Tag {
|
||||
// Defaults Horizon invents for fields it does not simulate give way to the save.
|
||||
let invented = matches!(
|
||||
k.to_string_lossy().as_str(),
|
||||
"Fire"
|
||||
| "Air"
|
||||
| "Invulnerable"
|
||||
| "PortalCooldown"
|
||||
| "HasBeenShot"
|
||||
// FallingBlockEntity fields Horizon does not simulate.
|
||||
| "HurtEntities"
|
||||
| "CancelDrop"
|
||||
| "FallHurtAmount"
|
||||
| "FallHurtMax"
|
||||
"Fire" | "Air" | "Invulnerable" | "PortalCooldown" | "HasBeenShot"
|
||||
);
|
||||
if !(invented && out.contains_key(k)) {
|
||||
out.insert(k.clone(), v.clone());
|
||||
@@ -185,40 +176,27 @@ impl Game<'_> {
|
||||
entity_nbt::falling_block_from_nbt(&tag)
|
||||
&& let Some(state) = self.state_from_nbt(&state)
|
||||
{
|
||||
let (no_gravity, drop_item) = entity_nbt::falling_block_flags(&tag);
|
||||
self.falling.push(FallingBlock {
|
||||
state,
|
||||
position,
|
||||
previous: position,
|
||||
velocity,
|
||||
age,
|
||||
no_gravity,
|
||||
drop_item,
|
||||
saved: uuid.map(|u| Box::new((u, tag.clone()))),
|
||||
});
|
||||
}
|
||||
}
|
||||
k if super::boats::any_boat_wood(k)
|
||||
.is_some_and(|(wood, chest)| self.boat_models.has(wood, chest)) =>
|
||||
k if super::boats::boat_wood(k).is_some()
|
||||
&& self
|
||||
.boat_models
|
||||
.models
|
||||
.contains_key(super::boats::boat_wood(k).unwrap_or("")) =>
|
||||
{
|
||||
if let Some((position, velocity, yaw)) = entity_nbt::boat_from_nbt(&tag) {
|
||||
self.next_entity += 1;
|
||||
let id = self.next_entity;
|
||||
let (wood, chest) =
|
||||
super::boats::any_boat_wood(k).unwrap_or(("oak", false));
|
||||
let mut b = super::boats::Boat::new(
|
||||
id,
|
||||
std::sync::Arc::from(wood),
|
||||
position,
|
||||
yaw,
|
||||
);
|
||||
if chest {
|
||||
b = b.with_chest();
|
||||
b.items = horizon_client::save::decode_items(
|
||||
tag.get("Items"),
|
||||
super::boats::CHEST_SLOTS,
|
||||
);
|
||||
}
|
||||
let wood =
|
||||
std::sync::Arc::from(super::boats::boat_wood(k).unwrap_or("oak"));
|
||||
let mut b = super::boats::Boat::new(id, wood, position, yaw);
|
||||
b.velocity = velocity;
|
||||
self.remember(id, uuid, tag);
|
||||
self.boats.push(b);
|
||||
@@ -519,16 +497,12 @@ impl Game<'_> {
|
||||
}
|
||||
for f in &self.falling {
|
||||
if keys.contains(&chunk_of(f.position)) {
|
||||
let (uuid, raw) = match &f.saved {
|
||||
Some(saved) => (saved.0, Some(saved.1.clone())),
|
||||
None => anon(&self.persist, "minecraft:falling_block", f.position),
|
||||
};
|
||||
let (uuid, raw) = anon(&self.persist, "minecraft:falling_block", f.position);
|
||||
let tag = entity_nbt::falling_block_to_nbt(
|
||||
self.state_to_nbt(f.state),
|
||||
f.position,
|
||||
f.velocity,
|
||||
f.age,
|
||||
f.drop_item,
|
||||
uuid,
|
||||
);
|
||||
out.entry(chunk_of(f.position))
|
||||
@@ -545,13 +519,12 @@ impl Game<'_> {
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid);
|
||||
let tag = entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
&b.wood,
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist.raw.get(&b.id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
out.entry(chunk_of(b.position)).or_default().push(tag);
|
||||
}
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
//! `getMaxZoom`), and the local player drawn as a model: in third person
|
||||
//! and in the inventory (`InventoryScreen.extractEntityInInventoryFollowsMouse`).
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
camera::{CameraEntity, LivingCamera},
|
||||
collisions::SceneCollisions,
|
||||
};
|
||||
use horizon_client::{
|
||||
hud::HudBatch,
|
||||
mobs::{Anim, Brain, Mob, MobType},
|
||||
pose::Pose,
|
||||
};
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
camera::{CameraEntity, LivingCamera},
|
||||
collisions::SceneCollisions,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
|
||||
position::BlockPos,
|
||||
@@ -272,14 +272,10 @@ impl Game<'_> {
|
||||
4096,
|
||||
)
|
||||
.ok()?;
|
||||
(!hit.miss).then_some([hit.hit.location.x, hit.hit.location.y, hit.hit.location.z])
|
||||
(!hit.miss).then(|| [hit.hit.location.x, hit.hit.location.y, hit.hit.location.z])
|
||||
});
|
||||
self.view = view;
|
||||
Some((
|
||||
glam::DVec3::from_array(view.position),
|
||||
view.y_rot,
|
||||
view.x_rot,
|
||||
))
|
||||
Some((glam::DVec3::from_array(view.position), view.y_rot, view.x_rot))
|
||||
}
|
||||
/// Keep the player's model in step with the player (`LivingEntity.tick`
|
||||
/// body turning, `WalkAnimationState`, the swing and the pose).
|
||||
@@ -399,7 +395,6 @@ impl Game<'_> {
|
||||
&self.player_armor_layers(),
|
||||
out,
|
||||
);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
}
|
||||
/// `extractEntityInInventoryFollowsMouse`: the player in the box
|
||||
/// `[x0, x1) x [y0, y1)`, `size` pixels per block, turned toward the
|
||||
@@ -453,7 +448,6 @@ impl Game<'_> {
|
||||
&self.player_armor_layers(),
|
||||
&mut verts,
|
||||
);
|
||||
self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
|
||||
let height = if m.extra.crouching { 1.5 } else { 1.8 };
|
||||
// PictureInPictureRenderer: the box centre, (size, size, -size);
|
||||
// then the translation and Rz(pi) * Rx(y_angle * 20 degrees).
|
||||
|
||||
@@ -65,27 +65,6 @@ pub(super) struct PotionState {
|
||||
std::collections::HashMap<String, horizon_core::random::XoroshiroRandom>,
|
||||
}
|
||||
|
||||
impl PotionState {
|
||||
/// Thrown potions in flight as (entity type, position, motion), for the
|
||||
/// dispenser parity fixture.
|
||||
pub(super) fn thrown_entities(&self) -> Vec<(&'static str, Vec3, Vec3)> {
|
||||
self.thrown
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let kind = match &*t.stack.item {
|
||||
"minecraft:lingering_potion" => "minecraft:lingering_potion",
|
||||
"minecraft:experience_bottle" => "minecraft:experience_bottle",
|
||||
_ => "minecraft:splash_potion",
|
||||
};
|
||||
(kind, t.position, t.velocity)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub(super) fn clear_thrown(&mut self) {
|
||||
self.thrown.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Right-click with a potion, splash/lingering potion or glass bottle.
|
||||
pub(super) fn use_potion_item(&mut self, stack: &ItemStack) -> horizon_client::Result<bool> {
|
||||
|
||||
@@ -921,6 +921,21 @@ impl Game<'_> {
|
||||
}
|
||||
/// A raider's ominous banner (drop chance 2.0: always dropped) and a
|
||||
/// patrol captain's ominous bottle (`entities/pillager`).
|
||||
pub(super) fn raider_death_loot(&mut self, m: &horizon_client::mobs::Mob, pos: [f64; 3]) {
|
||||
if !raider::is_raider(m.species()) {
|
||||
return;
|
||||
}
|
||||
if m.extra.head.as_deref() == Some(raider::BANNER) {
|
||||
self.spawn_item(raider::ominous_banner(), pos, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
if m.species() == Species::Pillager && raider::is_captain(m) {
|
||||
let amplifier = self.world.random.next_int_bound(5).unwrap_or(0) as u8;
|
||||
let amplifier = (amplifier > 0).then_some(amplifier);
|
||||
let mut bottle = horizon_client::items::ItemStack::new("minecraft:ominous_bottle", 1);
|
||||
bottle.data_mut().ominous_amplifier = amplifier;
|
||||
self.spawn_item(bottle, pos, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
}
|
||||
/// `OminousBottleAmplifier.onConsume`: Bad Omen for 100 minutes at the
|
||||
/// bottle's amplifier, then the dispose sound; the bottle is used up.
|
||||
pub(super) fn drink_ominous_bottle(
|
||||
|
||||
@@ -490,8 +490,7 @@ impl Game<'_> {
|
||||
if !open {
|
||||
self.recipe_ui.overlay = None;
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
self.audio.play("minecraft:ui.button.click", None, 1.0, 1.0);
|
||||
return true;
|
||||
}
|
||||
if !self.book_open(book_type) || self.spectator() {
|
||||
@@ -519,14 +518,12 @@ impl Game<'_> {
|
||||
if self.recipe_ui.page + 1 < pages && inside(cursor, xo + 93.0, yo + 137.0, 12.0, 17.0)
|
||||
{
|
||||
self.recipe_ui.page += 1;
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
self.audio.play("minecraft:ui.button.click", None, 1.0, 1.0);
|
||||
return true;
|
||||
}
|
||||
if self.recipe_ui.page > 0 && inside(cursor, xo + 38.0, yo + 137.0, 12.0, 17.0) {
|
||||
self.recipe_ui.page -= 1;
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
self.audio.play("minecraft:ui.button.click", None, 1.0, 1.0);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -573,8 +570,7 @@ impl Game<'_> {
|
||||
let filtering = !self.book_filtering(book_type);
|
||||
self.level.recipe_book.filtering[book_type.index()] = filtering;
|
||||
self.recipe_ui.page = 0;
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
self.audio.play("minecraft:ui.button.click", None, 1.0, 1.0);
|
||||
return true;
|
||||
}
|
||||
// Tabs.
|
||||
@@ -585,8 +581,7 @@ impl Game<'_> {
|
||||
self.recipe_ui.tab = Some(tab);
|
||||
self.recipe_ui.page = 0;
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
self.audio.play("minecraft:ui.button.click", None, 1.0, 1.0);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,14 +437,6 @@ impl Game<'_> {
|
||||
|
||||
/// `Level.gameEvent`: tell the listeners within reach.
|
||||
pub(super) fn post_game_event(&mut self, event: &'static str, pos: [f64; 3], ctx: Context) {
|
||||
super::dispense_fixture::record(|| {
|
||||
format!(
|
||||
"G {},{},{} {event}",
|
||||
pos[0].floor() as i32,
|
||||
pos[1].floor() as i32,
|
||||
pos[2].floor() as i32
|
||||
)
|
||||
});
|
||||
if self.remote.is_some() || !self.sculk.tables.built {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -244,3 +244,132 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The shield in a slot: `ShieldSpecialRenderer`'s plate and handle boxes
|
||||
/// under item/shield's `gui` display transform, flat-lit and depth-sorted.
|
||||
pub(super) fn draw_shield_icon(
|
||||
batch: &mut horizon_client::hud::HudBatch,
|
||||
atlas: &horizon_client::hud::HudAtlas,
|
||||
x: f32,
|
||||
y: f32,
|
||||
passes: &[(String, u32)],
|
||||
) {
|
||||
// display.gui: rotation [15, -25, -5], translation [2, 3, 0], scale 0.65.
|
||||
let (sx, cx) = 15f32.to_radians().sin_cos();
|
||||
let (sy, cy) = (-25f32).to_radians().sin_cos();
|
||||
let (sz, cz) = (-5f32).to_radians().sin_cos();
|
||||
// Quaternionf.rotationXYZ: Rx * Ry * Rz.
|
||||
let rotate = |[x, y, z]: [f32; 3]| {
|
||||
let (x, y) = (x * cz - y * sz, x * sz + y * cz);
|
||||
let (x, z) = (x * cy + z * sy, -x * sy + z * cy);
|
||||
let (y, z) = (y * cx - z * sx, y * sx + z * cx);
|
||||
[x, y, z]
|
||||
};
|
||||
// Model pixels -> slot pixels: S(1,-1,-1), T(-0.5), S(0.65), R, T, and
|
||||
// the GUI's (16, -16, 16) around the slot centre.
|
||||
let place = |[px, py, pz]: [f32; 3]| {
|
||||
let q = [px / 16.0 - 0.5, -py / 16.0 - 0.5, -pz / 16.0 - 0.5].map(|v| v * 0.65);
|
||||
let [qx, qy, qz] = rotate(q);
|
||||
[
|
||||
x + 8.0 + (qx + 2.0 / 16.0) * 16.0,
|
||||
y + 8.0 - (qy + 3.0 / 16.0) * 16.0,
|
||||
qz,
|
||||
]
|
||||
};
|
||||
// ShieldModel: plate texOffs(0, 0) box(-6, -11, -2, 12, 22, 1) and
|
||||
// handle texOffs(26, 0) box(-1, -3, -1, 2, 6, 6).
|
||||
let mut faces: Vec<(f32, [[f32; 2]; 4], [[f32; 2]; 4], f32)> = Vec::new();
|
||||
for (tu, tv, from, size) in [
|
||||
(0.0, 0.0, [-6.0, -11.0, -2.0], [12.0, 22.0, 1.0]),
|
||||
(26.0, 0.0, [-1.0, -3.0, -1.0], [2.0, 6.0, 6.0]),
|
||||
] {
|
||||
let [w, h, d] = size;
|
||||
let (x0, y0, z0) = (from[0], from[1], from[2]);
|
||||
let (x1, y1, z1) = (x0 + w, y0 + h, z0 + d);
|
||||
let t0 = [x0, y0, z0];
|
||||
let t1 = [x1, y0, z0];
|
||||
let t2 = [x1, y1, z0];
|
||||
let t3 = [x0, y1, z0];
|
||||
let l0 = [x0, y0, z1];
|
||||
let l1 = [x1, y0, z1];
|
||||
let l2 = [x1, y1, z1];
|
||||
let l3 = [x0, y1, z1];
|
||||
let (u0, u1, u2) = (tu, tu + d, tu + d + w);
|
||||
let (u22, u3, u4) = (tu + d + w + w, tu + d + w + d, tu + d + w + d + w);
|
||||
let (v0, v1, v2) = (tv, tv + d, tv + d + h);
|
||||
// ModelPart.Cube's polygons: (vertices, u0, v0, u1, v1, normal).
|
||||
for (verts, a, b, c, e, n) in [
|
||||
([l1, l0, t0, t1], u1, v0, u2, v1, [0.0, -1.0, 0.0]),
|
||||
([t2, t3, l3, l2], u2, v1, u22, v0, [0.0, 1.0, 0.0]),
|
||||
([t0, l0, l3, t3], u0, v1, u1, v2, [-1.0, 0.0, 0.0]),
|
||||
([t1, t0, t3, t2], u1, v1, u2, v2, [0.0, 0.0, -1.0]),
|
||||
([l1, t1, t2, l2], u2, v1, u3, v2, [1.0, 0.0, 0.0]),
|
||||
([l0, l1, l2, l3], u3, v1, u4, v2, [0.0, 0.0, 1.0]),
|
||||
] {
|
||||
let normal = rotate([n[0], -n[1], -n[2]]);
|
||||
if normal[2] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let p = verts.map(place);
|
||||
let depth = p.iter().map(|q| q[2]).sum::<f32>() / 4.0;
|
||||
let texels = [[c, b], [a, b], [a, e], [c, e]];
|
||||
// Flat item lighting: faces toward the viewer are brightest.
|
||||
let shade = (0.6 + 0.4 * normal[2]).min(1.0);
|
||||
faces.push((depth, p.map(|q| [q[0], q[1]]), texels, shade));
|
||||
}
|
||||
}
|
||||
// Every pass redraws the same boxes; equal depths keep the pass order.
|
||||
let mut quads = Vec::new();
|
||||
for (index, (name, tint)) in passes.iter().enumerate() {
|
||||
let Some(r) = atlas.sprites.get(name).copied() else {
|
||||
continue;
|
||||
};
|
||||
for (depth, p, texels, shade) in &faces {
|
||||
let uv = texels.map(|[u, v]| {
|
||||
[
|
||||
r.u0 + (r.u1 - r.u0) * u / 64.0,
|
||||
r.v0 + (r.v1 - r.v0) * v / 64.0,
|
||||
]
|
||||
});
|
||||
let c = |s: u32| (((tint >> s) & 255) as f32 * shade) as u8;
|
||||
quads.push((
|
||||
*depth,
|
||||
index,
|
||||
*p,
|
||||
uv,
|
||||
horizon_client::hud::rgba(c(16), c(8), c(0), 255),
|
||||
));
|
||||
}
|
||||
}
|
||||
quads.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||
for (_, _, p, uv, color) in quads {
|
||||
batch.quad(p, uv, color);
|
||||
}
|
||||
}
|
||||
/// The textures a shield draws: the plain or patterned base, then the
|
||||
/// banner layers (`ShieldSpecialRenderer.submit`).
|
||||
pub(super) fn shield_passes(
|
||||
banners: &horizon_client::banner::Banners,
|
||||
stack: &ItemStack,
|
||||
) -> Vec<(String, u32)> {
|
||||
let data = stack.data();
|
||||
let layers = data.map_or(&[][..], |d| &d.banner_patterns[..]);
|
||||
let base = data.and_then(|d| d.base_color.as_deref());
|
||||
if layers.is_empty() && base.is_none() {
|
||||
return vec![("entity/shield/shield_base_nopattern".into(), 0xFFFFFF)];
|
||||
}
|
||||
let mut passes = vec![
|
||||
("entity/shield/shield_base".into(), 0xFFFFFF),
|
||||
(
|
||||
"entity/shield/base".into(),
|
||||
horizon_client::banner::dye_rgb(base.unwrap_or("white")),
|
||||
),
|
||||
];
|
||||
for layer in layers.iter().take(16) {
|
||||
passes.push((
|
||||
banners.shield_texture(&layer.pattern),
|
||||
horizon_client::banner::dye_rgb(&layer.color),
|
||||
));
|
||||
}
|
||||
passes
|
||||
}
|
||||
@@ -1991,6 +1991,100 @@ impl Game<'_> {
|
||||
self.draw_tooltip_lines(batch, &[(text, u32::MAX)], self.cursor.0, self.cursor.1);
|
||||
}
|
||||
}
|
||||
/// Potion items: `layer0` tinted with the potion colour under the bottle
|
||||
/// (`minecraft:potion` item tint source).
|
||||
pub(super) fn draw_potion_layers(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
stack: &ItemStack,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
let (overlay, base) = match &*stack.item {
|
||||
"minecraft:potion" => ("item/potion_overlay", "item/potion"),
|
||||
"minecraft:splash_potion" => ("item/potion_overlay", "item/splash_potion"),
|
||||
"minecraft:lingering_potion" => ("item/potion_overlay", "item/lingering_potion"),
|
||||
"minecraft:tipped_arrow" => ("item/tipped_arrow_head", "item/tipped_arrow_base"),
|
||||
_ => return,
|
||||
};
|
||||
let c = brewing::potion_color(stack.potion());
|
||||
let atlas = &self.res.atlas;
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
overlay,
|
||||
x,
|
||||
y,
|
||||
16.0,
|
||||
16.0,
|
||||
rgba(
|
||||
(c >> 16 & 255) as u8,
|
||||
(c >> 8 & 255) as u8,
|
||||
(c & 255) as u8,
|
||||
255,
|
||||
),
|
||||
);
|
||||
batch.sprite_scaled(atlas, base, x, y, 16.0, 16.0, u32::MAX);
|
||||
}
|
||||
/// The enchantment glint over an item icon: a shimmering purple tint.
|
||||
pub(super) fn draw_glint(&self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) {
|
||||
if !self.res.stations.enchantments.has_glint(stack) {
|
||||
return;
|
||||
}
|
||||
// ItemRenderer glint: the enchanted_glint texture scrolls diagonally over the
|
||||
// item's shape. Approximated with a grid of item-texture quads whose vertex
|
||||
// colours carry a purple base tint plus a moving bright band.
|
||||
let t = self.started.elapsed().as_secs_f32();
|
||||
let glint = |gx: f32, gy: f32| -> u32 {
|
||||
let d = ((gx + gy) / 32.0 - t * 0.35).rem_euclid(1.0);
|
||||
let band = (1.0 - (d - 0.5).abs() * 6.0).max(0.0);
|
||||
let a = 45.0 + 110.0 * band;
|
||||
rgba(
|
||||
(170.0 + 70.0 * band) as u8,
|
||||
(90.0 + 110.0 * band) as u8,
|
||||
255,
|
||||
a as u8,
|
||||
)
|
||||
};
|
||||
match self.res.icons.get(&*stack.item).cloned().flatten() {
|
||||
Some(super::Icon::Flat(name)) => {
|
||||
let Some(r) = self.res.atlas.sprites.get(&name).copied() else {
|
||||
return;
|
||||
};
|
||||
const N: usize = 4;
|
||||
let cell = 16.0 / N as f32;
|
||||
let (du, dv) = ((r.u1 - r.u0) / N as f32, (r.v1 - r.v0) / N as f32);
|
||||
for row in 0..N {
|
||||
for col in 0..N {
|
||||
let (x0, y0) = (col as f32 * cell, row as f32 * cell);
|
||||
let (u0, v0) = (r.u0 + du * col as f32, r.v0 + dv * row as f32);
|
||||
let corners = [
|
||||
(x0, y0, u0, v0),
|
||||
(x0 + cell, y0, u0 + du, v0),
|
||||
(x0 + cell, y0 + cell, u0 + du, v0 + dv),
|
||||
(x0, y0 + cell, u0, v0 + dv),
|
||||
];
|
||||
for i in [0usize, 1, 2, 0, 2, 3] {
|
||||
let (cx, cy, u, v) = corners[i];
|
||||
batch.vertices.push(horizon_client::hud::HudVertex {
|
||||
pos: [x + cx, y + cy],
|
||||
uv: [u + 2.0, v],
|
||||
color: glint(cx, cy),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(super::Icon::Cube { .. }) => batch.fill(
|
||||
&self.res.atlas,
|
||||
x + 1.0,
|
||||
y + 1.0,
|
||||
14.0,
|
||||
14.0,
|
||||
glint(8.0, 8.0) & 0x7FFF_FFFF,
|
||||
),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- brewing stand
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use super::Game;
|
||||
impl Game<'_> {
|
||||
/// Structures reaching `pos`: (id, inside a piece). `getStructureAt`
|
||||
/// tests the start's whole box, `getStructureWithPieceAt` a piece's.
|
||||
pub(super) fn structures_reaching(&self, pos: [i32; 3]) -> Vec<(String, bool)> {
|
||||
fn structures_reaching(&self, pos: [i32; 3]) -> Vec<(String, bool)> {
|
||||
let Some(chunk) = self.world.chunks.get(&(pos[0] >> 4, pos[2] >> 4)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -39,4 +39,83 @@ impl Game<'_> {
|
||||
.map(|(n, _)| n)
|
||||
.collect()
|
||||
}
|
||||
/// `ChunkGenerator.getMobsAt` / `NaturalSpawner.mobsAt`: a structure's
|
||||
/// `spawn_overrides` for the category replace the biome's list where its
|
||||
/// box (or piece) holds `pos`; fortress spawns on nether bricks.
|
||||
pub(super) fn structure_spawns(
|
||||
&mut self,
|
||||
category: &str,
|
||||
pos: [i32; 3],
|
||||
) -> Option<Vec<horizon_client::biome::SpawnEntry>> {
|
||||
let reaching = self.structures_reaching(pos);
|
||||
if reaching.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let below = self.world.block(pos[0], pos[1] - 1, pos[2]);
|
||||
let bricks = &*self.res.facts.get(below).name == "minecraft:nether_bricks";
|
||||
for (id, piece) in &reaching {
|
||||
let Some(o) = self.spawn_override(id, category) else {
|
||||
continue;
|
||||
};
|
||||
if !o.0 || *piece {
|
||||
return Some(o.1);
|
||||
}
|
||||
}
|
||||
if category == "monster"
|
||||
&& bricks
|
||||
&& reaching.iter().any(|(n, _)| n == "minecraft:fortress")
|
||||
{
|
||||
return self
|
||||
.spawn_override("minecraft:fortress", "monster")
|
||||
.map(|o| o.1);
|
||||
}
|
||||
None
|
||||
}
|
||||
/// A structure's override for a category: (piece-only, spawns).
|
||||
fn spawn_override(
|
||||
&mut self,
|
||||
structure: &str,
|
||||
category: &str,
|
||||
) -> Option<(bool, Vec<horizon_client::biome::SpawnEntry>)> {
|
||||
if !self.spawn_overrides.contains_key(structure) {
|
||||
let (ns, path) = structure
|
||||
.split_once(':')
|
||||
.unwrap_or(("minecraft", structure));
|
||||
let file = self
|
||||
.options
|
||||
.resources
|
||||
.join(format!("data/{ns}/worldgen/structure/{path}.json"));
|
||||
let v: serde_json::Value = std::fs::read(file)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for (cat, o) in v["spawn_overrides"].as_object().into_iter().flatten() {
|
||||
let piece = o["bounding_box"].as_str() == Some("piece");
|
||||
let spawns = o["spawns"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|e| {
|
||||
let count = &e["count"];
|
||||
horizon_client::biome::SpawnEntry {
|
||||
entity: e["type"].as_str().unwrap_or_default().to_owned(),
|
||||
weight: e["weight"].as_u64().unwrap_or(1) as u32,
|
||||
min: count["min_inclusive"]
|
||||
.as_u64()
|
||||
.or_else(|| e["minCount"].as_u64())
|
||||
.unwrap_or(1) as u32,
|
||||
max: count["max_inclusive"]
|
||||
.as_u64()
|
||||
.or_else(|| e["maxCount"].as_u64())
|
||||
.unwrap_or(1) as u32,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
map.insert(cat.clone(), (piece, spawns));
|
||||
}
|
||||
self.spawn_overrides.insert(structure.to_owned(), map);
|
||||
}
|
||||
self.spawn_overrides.get(structure)?.get(category).cloned()
|
||||
}
|
||||
}
|
||||
@@ -48,11 +48,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.audio.play(
|
||||
"minecraft:entity.tnt.primed",
|
||||
Some([
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y),
|
||||
f64::from(pos.z) + 0.5,
|
||||
]),
|
||||
Some([f64::from(pos.x) + 0.5, f64::from(pos.y), f64::from(pos.z) + 0.5]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
|
||||
@@ -40,30 +40,35 @@ impl Game<'_> {
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
// WitherSkullBlock.checkSpawn: clear the pattern, the wither
|
||||
// appears on the middle of the bottom row, then neighbours update.
|
||||
let cells: Vec<[i32; 3]> = (0..3)
|
||||
.flat_map(|c| (0..3).map(move |row| (c, row)))
|
||||
.map(|(c, row)| at(c, row))
|
||||
.collect();
|
||||
self.clear_pattern_blocks(&cells)?;
|
||||
for row in 0..3 {
|
||||
for c in 0..3 {
|
||||
let p = at(c, row);
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
if !self.res.facts.is_air(state) {
|
||||
self.destroy_particles(p, state);
|
||||
self.world.set(p[0], p[1], p[2], self.world.air)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let base = at(1, 2);
|
||||
let at = [
|
||||
f64::from(base[0]) + 0.5,
|
||||
f64::from(base[1]) + 0.55,
|
||||
f64::from(base[2]) + 0.5,
|
||||
];
|
||||
let yaw = if ax == 1 { 0.0 } else { 90.0 };
|
||||
if let Some(id) = self.create_triggered("minecraft:wither", at, yaw)
|
||||
if let Some(id) = self.spawn_mob("minecraft:wither", at, true)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
m.persistent = true;
|
||||
let yaw = if ax == 1 { 0.0 } else { 90.0 };
|
||||
m.yaw = yaw;
|
||||
m.body_yaw = yaw;
|
||||
m.previous_body_yaw = yaw;
|
||||
m.head_yaw = yaw;
|
||||
horizon_client::mobs::wither::make_invulnerable(m);
|
||||
let view =
|
||||
self.mob_entity(&self.mobs.iter().find(|m| m.id == id).cloned().unwrap());
|
||||
self.trigger_entity("minecraft:summoned_entity", "entity", &view);
|
||||
}
|
||||
self.update_pattern_blocks(&cells)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,58 +62,6 @@ fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A packed atlas image: sprite name, size and RGBA pixels.
|
||||
type PackedImage = (String, u32, u32, Vec<u8>);
|
||||
/// Sprites the atlas definitions (`atlases/*.json`) add beyond the texture
|
||||
/// files already packed: generated ones (`paletted_permutations` such as the
|
||||
/// armour trim sprites, `unstitch` regions) and files listed under another id.
|
||||
fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
|
||||
use crate::atlas_sources::{self, SpriteOrigin};
|
||||
let have: std::collections::HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
|
||||
let textures = atlas_sources::list_textures(root);
|
||||
let load = |texture: &str, directory: &str| {
|
||||
decode(&atlas_sources::texture_path(root, texture, directory)).ok()
|
||||
};
|
||||
let mut out: Vec<PackedImage> = Vec::new();
|
||||
let mut added = std::collections::HashSet::new();
|
||||
for (atlas, sources) in atlas_sources::atlases(root) {
|
||||
// The other atlases' renamed files (GUI sprites, particles...) are
|
||||
// looked up by their texture paths here.
|
||||
let item_models = atlas == "minecraft:blocks" || atlas == "minecraft:items";
|
||||
for (id, origin) in atlas_sources::list(&sources, &textures) {
|
||||
if !item_models && matches!(origin, SpriteOrigin::File(_)) {
|
||||
continue;
|
||||
}
|
||||
let name = id.strip_prefix("minecraft:").unwrap_or(&id).to_owned();
|
||||
if have.contains(name.as_str()) || added.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
let image = match &origin {
|
||||
SpriteOrigin::File(texture) => {
|
||||
let path = texture.strip_prefix("minecraft:").unwrap_or(texture);
|
||||
// Packed under its own name already: only a renamed file is new.
|
||||
if path == name {
|
||||
continue;
|
||||
}
|
||||
load(texture, "textures").map(|(w, h, p)| {
|
||||
// Animated strips keep their first frame, like the packed files.
|
||||
if h > w && (path.starts_with("block/") || path.starts_with("item/")) {
|
||||
(w, w, p[..(w * w * 4) as usize].to_vec())
|
||||
} else {
|
||||
(w, h, p)
|
||||
}
|
||||
})
|
||||
}
|
||||
generated => atlas_sources::generate(generated, &load),
|
||||
};
|
||||
if let Some((w, h, pixels)) = image {
|
||||
added.insert(name.clone());
|
||||
out.push((name, w, h, pixels));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
impl HudAtlas {
|
||||
/// A font glyph's atlas cell (8x8 GUI pixels) and advance.
|
||||
pub fn glyph(&self, ch: char) -> Option<(Rect, f32)> {
|
||||
@@ -188,7 +136,6 @@ impl HudAtlas {
|
||||
.flat_map(|h| h.join().unwrap_or_default())
|
||||
.collect()
|
||||
});
|
||||
images.extend(atlas_sprites(root, &images));
|
||||
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
|
||||
// Map textures (game/maps.rs) are written into these slots at run time.
|
||||
for i in 0..MAP_SLOTS {
|
||||
|
||||
@@ -1,815 +0,0 @@
|
||||
//! Item rendering through the resolved item model pipeline: the
|
||||
//! `ItemModelResolver` of `horizon-assets` fills an [`ItemRenderState`]
|
||||
//! (`ItemStackRenderState`: layers of baked quads or special renderers, each
|
||||
//! with its display transform, local transform, tints and foil), and
|
||||
//! [`ItemRenderState::submit`] turns it into the quads `ItemFeatureRenderer`
|
||||
//! and the special model renderers submit, in pose space. A sink then draws
|
||||
//! them: [`gui`] into the HUD batch with the GUI item lighting, [`WorldSink`]
|
||||
//! into entity vertices with the level's lighting.
|
||||
pub mod gui;
|
||||
mod special;
|
||||
|
||||
use crate::{
|
||||
block_entity_render::{ModelPart, diffuse},
|
||||
hud::Rect,
|
||||
mesher::Vertex,
|
||||
};
|
||||
use glam::{Mat3, Mat4, Vec3};
|
||||
use horizon_assets::{
|
||||
animation::AnimationMetadata,
|
||||
item_bake::{BakedItemModel, SpriteLookup, SpriteMask, bake_item_model},
|
||||
item_model::{
|
||||
ClientItem, Components, Context, DisplayContext, FoilType, ItemEnv, ItemModels,
|
||||
LayerContent, LevelState, OwnerState, SpecialModel, StackView,
|
||||
},
|
||||
model::{CuboidModel, Direction},
|
||||
palette::PalettedPermutations,
|
||||
resolve::{MISSING_MODEL, ModelLibrary},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
/// One quad as submitted for drawing: pose-space corners, atlas UVs, the
|
||||
/// transformed unit normal, ARGB colour (tint, `-1` white) and its glint.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ItemQuad {
|
||||
pub pos: [Vec3; 4],
|
||||
pub uv: [[f32; 2]; 4],
|
||||
pub normal: Vec3,
|
||||
pub color: u32,
|
||||
pub foil: Foil,
|
||||
/// `MaterialInfo.lightEmission` (block light floor).
|
||||
pub light_emission: u8,
|
||||
}
|
||||
/// Which glint render type a quad uses.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Foil {
|
||||
None,
|
||||
/// `GLINT_TEXTURING` over the sprite's UVs (`itemGlintRenderType`,
|
||||
/// `entitySolidGlint`).
|
||||
Standard,
|
||||
/// `itemGlintSpecialRenderType`: compasses and clocks, a sheeted decal.
|
||||
Special,
|
||||
}
|
||||
/// Where submitted quads go.
|
||||
pub trait ItemSink {
|
||||
fn quad(&mut self, quad: ItemQuad);
|
||||
}
|
||||
impl ItemSink for Vec<ItemQuad> {
|
||||
fn quad(&mut self, quad: ItemQuad) {
|
||||
self.push(quad);
|
||||
}
|
||||
}
|
||||
|
||||
/// Data special renderers read from the game (registries, skins).
|
||||
pub trait SpecialHost {
|
||||
/// The banner atlas texture of a banner pattern (`entity/banner/<asset>`).
|
||||
fn banner_texture(&self, pattern: &str) -> Option<String>;
|
||||
/// The shield atlas texture of a banner pattern (`entity/shield/<asset>`).
|
||||
fn shield_texture(&self, pattern: &str) -> Option<String>;
|
||||
/// The skin texture of a `minecraft:profile` component, if loaded.
|
||||
fn player_skin(&self, _profile: &Value) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// A host with no registries: patterns and skins fall back to defaults.
|
||||
pub struct NoHost;
|
||||
impl SpecialHost for NoHost {
|
||||
fn banner_texture(&self, _: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn shield_texture(&self, _: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The geometry of one layer.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum LayerGeometry {
|
||||
Quads(Arc<BakedItemModel>),
|
||||
Special {
|
||||
model: SpecialModel,
|
||||
argument: Value,
|
||||
},
|
||||
}
|
||||
/// `ItemStackRenderState.LayerRenderState`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LayerRender {
|
||||
pub geometry: LayerGeometry,
|
||||
pub tints: Vec<i32>,
|
||||
pub foil: FoilType,
|
||||
/// `applyTransform`: the display transform, then the local transform.
|
||||
pub matrix: Mat4,
|
||||
/// `particleMaterial` (atlas sprite name).
|
||||
pub particle: String,
|
||||
pub uses_block_light: bool,
|
||||
/// Untransformed extents (quad corners or the special model's parts).
|
||||
pub extents: Arc<Vec<Vec3>>,
|
||||
}
|
||||
/// `ItemStackRenderState`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ItemRenderState {
|
||||
pub display: DisplayContext,
|
||||
pub layers: Vec<LayerRender>,
|
||||
pub oversized_in_gui: bool,
|
||||
pub animated: bool,
|
||||
}
|
||||
impl ItemRenderState {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.layers.is_empty()
|
||||
}
|
||||
/// `pickParticleMaterial`: a random layer's particle sprite.
|
||||
pub fn pick_particle(
|
||||
&self,
|
||||
random: &mut impl horizon_core::random::RandomSource,
|
||||
) -> Option<String> {
|
||||
if self.layers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let i = random.next_int_bound(self.layers.len() as i32).unwrap_or(0) as usize;
|
||||
self.layers.get(i).map(|l| l.particle.clone())
|
||||
}
|
||||
/// `usesBlockLight` of the first layer (GUI lighting choice).
|
||||
pub fn uses_block_light(&self) -> bool {
|
||||
self.layers.first().is_some_and(|l| l.uses_block_light)
|
||||
}
|
||||
/// `getModelBoundingBox`: the transformed extents of every layer, as
|
||||
/// (min, max); a zero box at the origin when there are none.
|
||||
pub fn bounding_box(&self) -> (Vec3, Vec3) {
|
||||
let mut lo = Vec3::splat(f32::INFINITY);
|
||||
let mut hi = Vec3::splat(f32::NEG_INFINITY);
|
||||
for layer in &self.layers {
|
||||
for p in layer.extents.iter() {
|
||||
let q = layer.matrix.transform_point3(*p);
|
||||
lo = lo.min(q);
|
||||
hi = hi.max(q);
|
||||
}
|
||||
}
|
||||
if lo.x > hi.x {
|
||||
(Vec3::ZERO, Vec3::ZERO)
|
||||
} else {
|
||||
(lo, hi)
|
||||
}
|
||||
}
|
||||
/// `ItemStackRenderState.submit` under `pose`.
|
||||
pub fn submit(
|
||||
&self,
|
||||
renderer: &ItemRenderer,
|
||||
sprites: &HashMap<String, Rect>,
|
||||
host: &dyn SpecialHost,
|
||||
pose: Mat4,
|
||||
sink: &mut dyn ItemSink,
|
||||
) {
|
||||
for layer in &self.layers {
|
||||
let m = pose * layer.matrix;
|
||||
match &layer.geometry {
|
||||
LayerGeometry::Quads(baked) => {
|
||||
submit_quads(baked, layer, self.display, m, sprites, sink);
|
||||
}
|
||||
LayerGeometry::Special { model, argument } => special::submit(
|
||||
renderer,
|
||||
model,
|
||||
argument,
|
||||
layer.foil != FoilType::None,
|
||||
m,
|
||||
sprites,
|
||||
host,
|
||||
sink,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The atlas name of a sprite identifier (`minecraft:item/apple` -> `item/apple`).
|
||||
pub fn sprite_name(sprite: &str) -> &str {
|
||||
sprite.strip_prefix("minecraft:").unwrap_or(sprite)
|
||||
}
|
||||
/// `Direction.getUnitVec3f`.
|
||||
pub fn direction_vector(d: Direction) -> Vec3 {
|
||||
match d {
|
||||
Direction::Down => Vec3::NEG_Y,
|
||||
Direction::Up => Vec3::Y,
|
||||
Direction::North => Vec3::NEG_Z,
|
||||
Direction::South => Vec3::Z,
|
||||
Direction::West => Vec3::NEG_X,
|
||||
Direction::East => Vec3::X,
|
||||
}
|
||||
}
|
||||
/// `PoseStack.Pose.transformNormal`: the normal matrix, renormalized.
|
||||
pub fn transform_normal(m: Mat4, n: Vec3) -> Vec3 {
|
||||
let normal = Mat3::from_mat4(m).inverse().transpose();
|
||||
(normal * n).normalize_or_zero()
|
||||
}
|
||||
/// An `ARGB` int as the `u32` colour of an [`ItemQuad`].
|
||||
pub fn argb(color: i32) -> u32 {
|
||||
color as u32
|
||||
}
|
||||
|
||||
/// `ItemFeatureRenderer.prepareMainSubmit` for one cuboid layer.
|
||||
fn submit_quads(
|
||||
baked: &BakedItemModel,
|
||||
layer: &LayerRender,
|
||||
display: DisplayContext,
|
||||
m: Mat4,
|
||||
sprites: &HashMap<String, Rect>,
|
||||
sink: &mut dyn ItemSink,
|
||||
) {
|
||||
let foil = match layer.foil {
|
||||
FoilType::None => Foil::None,
|
||||
FoilType::Standard => Foil::Standard,
|
||||
FoilType::Special => Foil::Special,
|
||||
};
|
||||
let _ = display;
|
||||
for q in &baked.quads {
|
||||
let Some(r) = sprites
|
||||
.get(sprite_name(&q.material.sprite))
|
||||
.or_else(|| sprites.get("misc/missingno"))
|
||||
.or_else(|| sprites.get("horizon/missingno"))
|
||||
.copied()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// `getLayerColorSafe`: an untinted quad, or a tint index past the
|
||||
// tints, draws white.
|
||||
let color = if q.tint_index >= 0 {
|
||||
layer
|
||||
.tints
|
||||
.get(q.tint_index as usize)
|
||||
.copied()
|
||||
.unwrap_or(-1)
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
let pos = q.positions.map(|p| m.transform_point3(Vec3::from(p)));
|
||||
let uv =
|
||||
q.uv.map(|[u, v]| [r.u0 + (r.u1 - r.u0) * u, r.v0 + (r.v1 - r.v0) * v]);
|
||||
sink.quad(ItemQuad {
|
||||
pos,
|
||||
uv,
|
||||
normal: transform_normal(m, direction_vector(q.facing)),
|
||||
color: argb(color),
|
||||
foil,
|
||||
light_emission: q.light_emission,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sprites for baking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The alpha masks of item and block sprites, read from the texture files
|
||||
/// (every unique animation frame) and the items atlas's palette sprites.
|
||||
struct FileSprites {
|
||||
root: PathBuf,
|
||||
permutations: Vec<PalettedPermutations>,
|
||||
cache: Mutex<HashMap<String, Option<SpriteMask>>>,
|
||||
}
|
||||
impl SpriteLookup for FileSprites {
|
||||
fn mask(&self, sprite: &str) -> Option<SpriteMask> {
|
||||
if let Ok(cache) = self.cache.lock()
|
||||
&& let Some(hit) = cache.get(sprite)
|
||||
{
|
||||
return hit.clone();
|
||||
}
|
||||
let mask = self.load(sprite);
|
||||
if let Ok(mut cache) = self.cache.lock() {
|
||||
cache.insert(sprite.to_owned(), mask.clone());
|
||||
}
|
||||
mask
|
||||
}
|
||||
}
|
||||
fn argb_pixels(rgba: &[u8]) -> Vec<u32> {
|
||||
rgba.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| u32::from_be_bytes([p[3], p[0], p[1], p[2]]))
|
||||
.collect()
|
||||
}
|
||||
impl FileSprites {
|
||||
fn png(&self, sprite: &str, directory: &str) -> PathBuf {
|
||||
crate::atlas_sources::texture_path(&self.root, sprite, directory)
|
||||
}
|
||||
fn image(&self, sprite: &str, directory: &str) -> Option<(usize, usize, Vec<u32>)> {
|
||||
let (w, h, rgba) = crate::hud::decode(&self.png(sprite, directory)).ok()?;
|
||||
Some((w as usize, h as usize, argb_pixels(&rgba)))
|
||||
}
|
||||
fn load(&self, sprite: &str) -> Option<SpriteMask> {
|
||||
let png = self.png(sprite, "textures");
|
||||
let (iw, ih, argb) = match self.image(sprite, "textures") {
|
||||
Some(image) => image,
|
||||
None => {
|
||||
let p = self.permutations.iter().find_map(|s| s.find(sprite))?;
|
||||
let (w, h, mut pixels) = self.image(p.base, "textures")?;
|
||||
let (_, _, key) = self.image(p.palette_key, "textures/palettes")?;
|
||||
let (_, _, target) = self.image(p.palette, "textures/palettes")?;
|
||||
horizon_assets::palette::apply_palette(&mut pixels, &key, &target)?;
|
||||
(w, h, pixels)
|
||||
}
|
||||
};
|
||||
let alpha: Vec<u8> = argb.iter().map(|p| (p >> 24) as u8).collect();
|
||||
let animation = fs::read_to_string(png.with_extension("png.mcmeta"))
|
||||
.ok()
|
||||
.and_then(|m| serde_json::from_str::<Value>(&m).ok())
|
||||
.and_then(|m| m.get("animation").map(Value::to_string))
|
||||
.and_then(|a| AnimationMetadata::from_json(&a).ok());
|
||||
let (w, h) = match &animation {
|
||||
Some(a) => {
|
||||
let [w, h] = a.frame_size(iw as i32, ih as i32);
|
||||
(w as usize, h as usize)
|
||||
}
|
||||
None => (iw, ih),
|
||||
};
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
let columns = iw / w;
|
||||
let total = columns * (ih / h);
|
||||
let clip = animation.and_then(|a| a.clip(total as u32).ok().flatten());
|
||||
let frames: Vec<usize> = match clip {
|
||||
Some(clip) => {
|
||||
let mut unique = Vec::new();
|
||||
for (index, _) in clip.sequence() {
|
||||
if !unique.contains(&(*index as usize)) {
|
||||
unique.push(*index as usize);
|
||||
}
|
||||
}
|
||||
unique
|
||||
}
|
||||
None => vec![0],
|
||||
};
|
||||
let frames = frames
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let (fx, fy) = (f % columns * w, f / columns * h);
|
||||
(0..h)
|
||||
.flat_map(|y| (0..w).map(move |x| (x, y)))
|
||||
.map(|(x, y)| alpha[(fy + y) * iw + fx + x] == 0)
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
Some(SpriteMask {
|
||||
width: w,
|
||||
height: h,
|
||||
frames,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The baked item models and everything resolution needs.
|
||||
pub struct ItemRenderer {
|
||||
pub models: ItemModels,
|
||||
library: ModelLibrary,
|
||||
sprites: FileSprites,
|
||||
baked: Mutex<HashMap<String, Arc<BakedItemModel>>>,
|
||||
extents: Mutex<HashMap<String, Arc<Vec<Vec3>>>>,
|
||||
/// Entity model layers of the special renderers.
|
||||
parts: HashMap<String, ModelPart>,
|
||||
/// `colormap/grass.png` as ARGB.
|
||||
grass: Vec<i32>,
|
||||
/// `minecraft:item` registry ids (`Item.getId`).
|
||||
item_ids: HashMap<String, i32>,
|
||||
}
|
||||
|
||||
/// Every `models/**.json` of a namespace directory, by identifier.
|
||||
fn load_models(root: &Path) -> BTreeMap<String, CuboidModel> {
|
||||
let mut out = BTreeMap::new();
|
||||
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
|
||||
return out;
|
||||
};
|
||||
for ns in namespaces.flatten() {
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
let base = ns.path().join("models");
|
||||
let mut stack = vec![base.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if path.extension().is_some_and(|x| x == "json")
|
||||
&& let Ok(rel) = path.strip_prefix(&base)
|
||||
&& let Ok(text) = fs::read_to_string(&path)
|
||||
&& let Ok(model) = CuboidModel::from_json(&text)
|
||||
{
|
||||
let rel = rel.with_extension("").to_string_lossy().replace('\\', "/");
|
||||
out.insert(format!("{namespace}:{rel}"), model);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Every `items/*.json` client item definition, by identifier.
|
||||
fn load_definitions(root: &Path) -> BTreeMap<String, ClientItem> {
|
||||
let mut out = BTreeMap::new();
|
||||
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
|
||||
return out;
|
||||
};
|
||||
for ns in namespaces.flatten() {
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
let Ok(files) = fs::read_dir(ns.path().join("items")) else {
|
||||
continue;
|
||||
};
|
||||
for f in files.flatten() {
|
||||
let path = f.path();
|
||||
if path.extension().is_none_or(|e| e != "json") {
|
||||
continue;
|
||||
}
|
||||
let name = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
if let Some(item) = fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|t| ClientItem::from_json(&t).ok())
|
||||
{
|
||||
out.insert(format!("{namespace}:{name}"), item);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl ItemRenderer {
|
||||
/// Load item definitions, item prototypes (`items.json`), every model,
|
||||
/// the special renderers' entity models and the grass colormap.
|
||||
pub fn load(root: &Path) -> crate::Result<Self> {
|
||||
let prototypes: BTreeMap<String, Components> =
|
||||
serde_json::from_slice(&fs::read(root.join("items.json"))?)?;
|
||||
let models = ItemModels {
|
||||
definitions: load_definitions(root),
|
||||
prototypes,
|
||||
};
|
||||
let library = ModelLibrary::with_item_generator(load_models(root))
|
||||
.map_err(|e| format!("item models: {e:?}"))?;
|
||||
let permutations = PalettedPermutations::from_atlas(
|
||||
&fs::read_to_string(root.join("assets/minecraft/atlases/items.json"))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
let parts = special::load_parts(root);
|
||||
let grass = crate::hud::decode(&root.join("assets/minecraft/textures/colormap/grass.png"))
|
||||
.map(|(_, _, rgba)| argb_pixels(&rgba).into_iter().map(|p| p as i32).collect())
|
||||
.unwrap_or_default();
|
||||
let item_ids = horizon_protocol::registry::Builtin::find(root)
|
||||
.and_then(|p| horizon_protocol::registry::Builtin::load(&p).ok())
|
||||
.and_then(|b| {
|
||||
let ids = b.get("minecraft:item")?;
|
||||
let mut out = HashMap::new();
|
||||
for i in 0.. {
|
||||
let Some(name) = ids.name(i) else { break };
|
||||
out.insert(name.to_owned(), i);
|
||||
}
|
||||
Some(out)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(Self {
|
||||
item_ids,
|
||||
models,
|
||||
library,
|
||||
sprites: FileSprites {
|
||||
root: root.to_path_buf(),
|
||||
permutations,
|
||||
cache: Mutex::default(),
|
||||
},
|
||||
baked: Mutex::default(),
|
||||
extents: Mutex::default(),
|
||||
parts,
|
||||
grass,
|
||||
})
|
||||
}
|
||||
/// `ModelBakery` item baking, cached per model.
|
||||
pub fn bake(&self, model: &str) -> Arc<BakedItemModel> {
|
||||
if let Ok(cache) = self.baked.lock()
|
||||
&& let Some(hit) = cache.get(model)
|
||||
{
|
||||
return hit.clone();
|
||||
}
|
||||
let baked = bake_item_model(&self.library, &self.sprites, model)
|
||||
.or_else(|_| bake_item_model(&self.library, &self.sprites, MISSING_MODEL))
|
||||
.map(Arc::new)
|
||||
.unwrap_or_else(|_| {
|
||||
Arc::new(BakedItemModel {
|
||||
quads: Vec::new(),
|
||||
uses_block_light: false,
|
||||
particle: horizon_assets::model::Material {
|
||||
sprite: horizon_assets::item_bake::MISSING_SPRITE.to_owned(),
|
||||
force_translucent: false,
|
||||
},
|
||||
transforms: Default::default(),
|
||||
})
|
||||
});
|
||||
if let Ok(mut cache) = self.baked.lock() {
|
||||
cache.insert(model.to_owned(), baked.clone());
|
||||
}
|
||||
baked
|
||||
}
|
||||
/// `CuboidItemModelWrapper.computeExtents`: the distinct quad corners.
|
||||
fn quad_extents(&self, model: &str, baked: &BakedItemModel) -> Arc<Vec<Vec3>> {
|
||||
if let Ok(cache) = self.extents.lock()
|
||||
&& let Some(hit) = cache.get(model)
|
||||
{
|
||||
return hit.clone();
|
||||
}
|
||||
let mut out: Vec<Vec3> = Vec::new();
|
||||
for q in &baked.quads {
|
||||
for p in q.positions {
|
||||
let p = Vec3::from(p);
|
||||
if !out.contains(&p) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = Arc::new(out);
|
||||
if let Ok(mut cache) = self.extents.lock() {
|
||||
cache.insert(model.to_owned(), out.clone());
|
||||
}
|
||||
out
|
||||
}
|
||||
fn special_extents(&self, model: &SpecialModel) -> Arc<Vec<Vec3>> {
|
||||
let key = model.to_json().to_string();
|
||||
if let Ok(cache) = self.extents.lock()
|
||||
&& let Some(hit) = cache.get(&key)
|
||||
{
|
||||
return hit.clone();
|
||||
}
|
||||
let out = Arc::new(special::extents(self, model));
|
||||
if let Ok(mut cache) = self.extents.lock() {
|
||||
cache.insert(key, out.clone());
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `ItemModelResolver.updateForTopItem`.
|
||||
pub fn resolve(
|
||||
&self,
|
||||
stack: &StackView,
|
||||
display: DisplayContext,
|
||||
owner: Option<&OwnerState>,
|
||||
level: Option<&LevelState>,
|
||||
seed: i32,
|
||||
env: &dyn ItemEnv,
|
||||
) -> ItemRenderState {
|
||||
let ctx = Context {
|
||||
display,
|
||||
owner,
|
||||
level,
|
||||
seed,
|
||||
};
|
||||
let resolved = self.models.resolve(stack, &ctx, env);
|
||||
let mut animated = false;
|
||||
let layers = resolved
|
||||
.layers
|
||||
.into_iter()
|
||||
.map(|layer| {
|
||||
let (geometry, baked, extents) = match layer.content {
|
||||
LayerContent::Model { model } => {
|
||||
let baked = self.bake(&model);
|
||||
let extents = self.quad_extents(&model, &baked);
|
||||
(LayerGeometry::Quads(baked.clone()), baked, extents)
|
||||
}
|
||||
LayerContent::Missing => {
|
||||
let baked = self.bake(MISSING_MODEL);
|
||||
let extents = self.quad_extents(MISSING_MODEL, &baked);
|
||||
(LayerGeometry::Quads(baked.clone()), baked, extents)
|
||||
}
|
||||
LayerContent::Special {
|
||||
base,
|
||||
model,
|
||||
argument,
|
||||
} => {
|
||||
let baked = self.bake(&base);
|
||||
let extents = self.special_extents(&model);
|
||||
(LayerGeometry::Special { model, argument }, baked, extents)
|
||||
}
|
||||
};
|
||||
if layer.foil != FoilType::None {
|
||||
animated = true;
|
||||
}
|
||||
LayerRender {
|
||||
geometry,
|
||||
tints: layer.tints,
|
||||
foil: layer.foil,
|
||||
matrix: glam_matrix(&baked.layer_matrix(display, &layer.transform)),
|
||||
uses_block_light: baked.uses_block_light,
|
||||
particle: sprite_name(&baked.particle.sprite).to_owned(),
|
||||
extents,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
ItemRenderState {
|
||||
display,
|
||||
layers,
|
||||
oversized_in_gui: resolved.oversized_in_gui,
|
||||
animated,
|
||||
}
|
||||
}
|
||||
/// `GrassColor.get` over the loaded colormap.
|
||||
pub fn grass_color(&self, temperature: f64, downfall: f64) -> i32 {
|
||||
let rain = downfall * temperature;
|
||||
let x = ((1.0 - temperature) * 255.0) as i64;
|
||||
let y = ((1.0 - rain) * 255.0) as i64;
|
||||
let index = (y << 8 | x) as usize;
|
||||
self.grass.get(index).copied().unwrap_or(-65281)
|
||||
}
|
||||
/// Submit a special model renderer directly (worn skulls of
|
||||
/// `CustomHeadLayer`, which draw the skull model, not the item).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn submit_special(
|
||||
&self,
|
||||
model: &SpecialModel,
|
||||
argument: &Value,
|
||||
has_foil: bool,
|
||||
pose: Mat4,
|
||||
sprites: &HashMap<String, Rect>,
|
||||
host: &dyn SpecialHost,
|
||||
sink: &mut dyn ItemSink,
|
||||
) {
|
||||
special::submit(self, model, argument, has_foil, pose, sprites, host, sink);
|
||||
}
|
||||
/// `Item.getId` (0 when the registry report is missing).
|
||||
pub fn item_id(&self, item: &str) -> i32 {
|
||||
self.item_ids.get(item).copied().unwrap_or(0)
|
||||
}
|
||||
pub(crate) fn part(&self, key: &str) -> Option<&ModelPart> {
|
||||
self.parts.get(key)
|
||||
}
|
||||
}
|
||||
/// A `horizon-assets` (row-major, JOML-exact) matrix as glam's.
|
||||
pub fn glam_matrix(m: &horizon_assets::math::Mat4) -> Mat4 {
|
||||
Mat4::from_cols_array_2d(&m.rows()).transpose()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stacks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// An NBT value as the JSON its component codec reads.
|
||||
pub fn tag_json(tag: &Tag) -> Value {
|
||||
match tag {
|
||||
Tag::End => Value::Null,
|
||||
Tag::Byte(v) => Value::from(*v),
|
||||
Tag::Short(v) => Value::from(*v),
|
||||
Tag::Int(v) => Value::from(*v),
|
||||
Tag::Long(v) => Value::from(*v),
|
||||
Tag::Float(bits) => serde_json::Number::from_f64(f64::from(f32::from_bits(*bits)))
|
||||
.map_or(Value::Null, Value::Number),
|
||||
Tag::Double(bits) => {
|
||||
serde_json::Number::from_f64(f64::from_bits(*bits)).map_or(Value::Null, Value::Number)
|
||||
}
|
||||
Tag::ByteArray(a) => Value::Array(a.iter().map(|b| Value::from(*b as i8)).collect()),
|
||||
Tag::String(s) => Value::String(s.to_string_lossy()),
|
||||
Tag::List(l) => Value::Array(l.iter().map(tag_json).collect()),
|
||||
Tag::Compound(c) => Value::Object(
|
||||
c.iter()
|
||||
.map(|(k, v)| (k.to_string_lossy(), tag_json(v)))
|
||||
.collect(),
|
||||
),
|
||||
Tag::IntArray(a) => Value::Array(a.iter().map(|v| Value::from(*v)).collect()),
|
||||
Tag::LongArray(a) => Value::Array(a.iter().map(|v| Value::from(*v)).collect()),
|
||||
}
|
||||
}
|
||||
/// The resolver's view of a client stack: its item, count and component patch.
|
||||
pub fn stack_view(stack: &crate::items::ItemStack) -> StackView {
|
||||
let mut view = StackView::new(&stack.item, stack.count as i32);
|
||||
if let Some(Tag::Compound(c)) = crate::item_data::components_tag(stack.damage, stack.data()) {
|
||||
for (k, v) in &c {
|
||||
let key = k.to_string_lossy();
|
||||
match key.strip_prefix('!') {
|
||||
Some(removed) => view.patch.insert(removed.to_owned(), None),
|
||||
None => view.patch.insert(key, Some(tag_json(v))),
|
||||
};
|
||||
}
|
||||
}
|
||||
view
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// World drawing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Entity vertices for submitted quads: the `item`/`entity` shader's
|
||||
/// `minecraft_mix_light` with the level's diffuse lights on the vertex
|
||||
/// colour, the packed lightmap coordinates (raised by light emission) and
|
||||
/// overlay 7 for the glint.
|
||||
pub struct WorldSink<'a> {
|
||||
pub out: &'a mut Vec<Vertex>,
|
||||
/// Packed light (`block_entity_render::vertex_light`).
|
||||
pub light: u32,
|
||||
/// The level's diffuse lights; `None` leaves the colour unshaded.
|
||||
pub lights: Option<(Vec3, Vec3)>,
|
||||
}
|
||||
impl ItemSink for WorldSink<'_> {
|
||||
fn quad(&mut self, q: ItemQuad) {
|
||||
let brightness = self.lights.map_or(1.0, |l| diffuse(q.normal, l));
|
||||
let [a, r, g, b] = q.color.to_be_bytes();
|
||||
let c = |v: u8| (f32::from(v) * brightness) as u32;
|
||||
let color = u32::from(a) << 24 | c(b) << 16 | c(g) << 8 | c(r);
|
||||
// `LightCoordsUtil.lightCoordsWithEmission`: block light at least the emission.
|
||||
let emission = u32::from(q.light_emission) * 16;
|
||||
let light = if (self.light >> 8 & 0xFF) < emission {
|
||||
(self.light & 0xFF) | emission << 8
|
||||
} else {
|
||||
self.light
|
||||
};
|
||||
let overlay = if q.foil == Foil::None { 0 } else { 7 };
|
||||
for (p, uv) in q.pos.iter().zip(q.uv) {
|
||||
self.out.push(Vertex {
|
||||
pos: p.to_array(),
|
||||
uv,
|
||||
layer: overlay,
|
||||
color,
|
||||
light,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ItemEntity.getSpin`.
|
||||
pub fn item_spin(age_in_ticks: f32, bob_offset: f32) -> f32 {
|
||||
age_in_ticks / 20.0 + bob_offset
|
||||
}
|
||||
/// `ItemClusterRenderState.getRenderedAmount`.
|
||||
pub fn rendered_amount(count: u32) -> u32 {
|
||||
match count {
|
||||
0..=1 => 1,
|
||||
2..=16 => 2,
|
||||
17..=32 => 3,
|
||||
33..=48 => 4,
|
||||
_ => 5,
|
||||
}
|
||||
}
|
||||
/// `ItemEntityRenderer.submitMultipleFromCount`: the poses of each copy of
|
||||
/// a stack (`seed` is `ItemClusterRenderState.getSeedForItemStack`).
|
||||
pub fn cluster_poses(state: &ItemRenderState, pose: Mat4, amount: u32, seed: i64) -> Vec<Mat4> {
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
let mut out = Vec::new();
|
||||
if amount == 0 {
|
||||
return out;
|
||||
}
|
||||
let mut random = LegacyRandom::new(seed);
|
||||
let (lo, hi) = state.bounding_box();
|
||||
let depth = hi.z - lo.z;
|
||||
let mut next = || random.next_float() * 2.0 - 1.0;
|
||||
if depth > 0.0625 {
|
||||
out.push(pose);
|
||||
for _ in 1..amount {
|
||||
let (x, y, z) = (next() * 0.15, next() * 0.15, next() * 0.15);
|
||||
out.push(pose * Mat4::from_translation(Vec3::new(x, y, z)));
|
||||
}
|
||||
} else {
|
||||
let offset = depth * 1.5;
|
||||
let mut p = pose
|
||||
* Mat4::from_translation(Vec3::new(0.0, 0.0, -(offset * (amount - 1) as f32 / 2.0)));
|
||||
out.push(p);
|
||||
p *= Mat4::from_translation(Vec3::new(0.0, 0.0, offset));
|
||||
for _ in 1..amount {
|
||||
let (x, y) = (next() * 0.15 * 0.5, next() * 0.15 * 0.5);
|
||||
out.push(p * Mat4::from_translation(Vec3::new(x, y, 0.0)));
|
||||
p *= Mat4::from_translation(Vec3::new(0.0, 0.0, offset));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rendered_amounts_follow_the_count_bands() {
|
||||
let bands: Vec<u32> = [0, 1, 2, 16, 17, 32, 33, 48, 49, 64]
|
||||
.iter()
|
||||
.map(|c| rendered_amount(*c))
|
||||
.collect();
|
||||
assert_eq!(bands, [1, 1, 2, 2, 3, 3, 4, 4, 5, 5]);
|
||||
}
|
||||
#[test]
|
||||
fn nbt_components_read_as_json() {
|
||||
let tag = Tag::compound([
|
||||
("a", Tag::Int(3)),
|
||||
("b", Tag::List(vec![Tag::String("x".into())])),
|
||||
("c", Tag::Float(1.5f32.to_bits())),
|
||||
]);
|
||||
assert_eq!(
|
||||
tag_json(&tag),
|
||||
serde_json::json!({"a": 3, "b": ["x"], "c": 1.5})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn normals_follow_the_inverse_transpose() {
|
||||
let m = Mat4::from_scale(Vec3::new(2.0, 1.0, 1.0));
|
||||
assert_eq!(transform_normal(m, Vec3::X), Vec3::X);
|
||||
let r = Mat4::from_rotation_y(std::f32::consts::FRAC_PI_2);
|
||||
assert!((transform_normal(r, Vec3::Z) - Vec3::X).length() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
//! `GuiGraphics.renderItem` drawn into the HUD batch: the item's layers are
|
||||
//! submitted under the GUI item pose (`translate(x + 8, y + 8)`,
|
||||
//! `scale(16, -16, 16)`), lit with `Lighting.Entry.ITEMS_3D` or `ITEMS_FLAT`
|
||||
//! (`minecraft_mix_light` on the transformed normals), back faces culled,
|
||||
//! drawn far to near, and clipped to the item's 16x16 slot unless the model
|
||||
//! is oversized in the GUI (`GuiItemAtlas` draws each item into a scissored
|
||||
//! 16x16 slot; oversized items get a picture-in-picture of their bounds).
|
||||
use super::{Foil, ItemQuad, ItemRenderState, ItemRenderer, SpecialHost};
|
||||
use crate::hud::{HudBatch, Rect};
|
||||
use glam::{EulerRot, Mat4, Vec2, Vec3};
|
||||
use std::{collections::HashMap, f32::consts::PI};
|
||||
|
||||
/// `Lighting.DIFFUSE_LIGHT_0` and `DIFFUSE_LIGHT_1`.
|
||||
fn diffuse_lights() -> (Vec3, Vec3) {
|
||||
(
|
||||
Vec3::new(0.2, 1.0, -0.7).normalize(),
|
||||
Vec3::new(-0.2, 1.0, 0.7).normalize(),
|
||||
)
|
||||
}
|
||||
/// `Lighting.Entry.ITEMS_FLAT`: `rotationY(-PI/8).rotateX(3PI/4)`.
|
||||
pub fn items_flat_lights() -> (Vec3, Vec3) {
|
||||
let m = Mat4::from_rotation_y(-PI / 8.0) * Mat4::from_rotation_x(PI * 3.0 / 4.0);
|
||||
let (a, b) = diffuse_lights();
|
||||
(m.transform_vector3(a), m.transform_vector3(b))
|
||||
}
|
||||
/// JOML `rotateYXZ(y, x, z)`.
|
||||
fn rotate_yxz(y: f32, x: f32, z: f32) -> Mat4 {
|
||||
Mat4::from_euler(EulerRot::YXZ, y, x, z)
|
||||
}
|
||||
/// `Lighting.Entry.ITEMS_3D`.
|
||||
pub fn items_3d_lights() -> (Vec3, Vec3) {
|
||||
let m = Mat4::from_scale(Vec3::new(1.0, -1.0, 1.0))
|
||||
* rotate_yxz(1.082_104_1, 3.237_585_8, 0.0)
|
||||
* rotate_yxz(-PI / 8.0, PI * 3.0 / 4.0, 0.0);
|
||||
let (a, b) = diffuse_lights();
|
||||
(m.transform_vector3(a), m.transform_vector3(b))
|
||||
}
|
||||
|
||||
/// A GUI-space vertex: position, atlas UV.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Point {
|
||||
p: Vec2,
|
||||
uv: [f32; 2],
|
||||
}
|
||||
fn lerp(a: Point, b: Point, t: f32) -> Point {
|
||||
Point {
|
||||
p: a.p + (b.p - a.p) * t,
|
||||
uv: [
|
||||
a.uv[0] + (b.uv[0] - a.uv[0]) * t,
|
||||
a.uv[1] + (b.uv[1] - a.uv[1]) * t,
|
||||
],
|
||||
}
|
||||
}
|
||||
/// Sutherland-Hodgman against one half plane `dot(p, n) <= limit`.
|
||||
fn clip_edge(poly: &[Point], axis: usize, limit: f32, keep_below: bool) -> Vec<Point> {
|
||||
let inside = |p: &Point| {
|
||||
let v = p.p[axis];
|
||||
if keep_below { v <= limit } else { v >= limit }
|
||||
};
|
||||
let mut out = Vec::with_capacity(poly.len() + 2);
|
||||
for i in 0..poly.len() {
|
||||
let (a, b) = (poly[i], poly[(i + 1) % poly.len()]);
|
||||
let (ia, ib) = (inside(&a), inside(&b));
|
||||
if ia {
|
||||
out.push(a);
|
||||
}
|
||||
if ia != ib {
|
||||
let t = (limit - a.p[axis]) / (b.p[axis] - a.p[axis]);
|
||||
out.push(lerp(a, b, t));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// A triangle clipped to `[x0, x1] x [y0, y1]`, as a fan.
|
||||
fn clip_triangle(tri: [Point; 3], rect: [f32; 4]) -> Vec<Point> {
|
||||
let mut poly = tri.to_vec();
|
||||
for (axis, limit, below) in [
|
||||
(0, rect[0], false),
|
||||
(0, rect[2], true),
|
||||
(1, rect[1], false),
|
||||
(1, rect[3], true),
|
||||
] {
|
||||
if poly.is_empty() {
|
||||
break;
|
||||
}
|
||||
poly = clip_edge(&poly, axis, limit, below);
|
||||
}
|
||||
poly
|
||||
}
|
||||
|
||||
/// RGBA vertex colour of a lit quad: `minecraft_mix_light` on the tint.
|
||||
fn lit_color(q: &ItemQuad, lights: (Vec3, Vec3)) -> u32 {
|
||||
let light = crate::block_entity_render::diffuse(q.normal, lights);
|
||||
let [a, r, g, b] = q.color.to_be_bytes();
|
||||
let c = |v: u8| (f32::from(v) * light) as u8;
|
||||
crate::hud::rgba(c(r), c(g), c(b), a)
|
||||
}
|
||||
/// The glint's moving sheen at a GUI position (a stand-in for the
|
||||
/// `GLINT_TEXTURING`-scrolled `enchanted_glint_item` texture added on top).
|
||||
fn glint_color(p: Vec2, time: f32) -> u32 {
|
||||
let d = ((p.x + p.y) / 32.0 - time * 0.35).rem_euclid(1.0);
|
||||
let band = (1.0 - (d - 0.5).abs() * 6.0).max(0.0);
|
||||
crate::hud::rgba(
|
||||
(170.0 + 70.0 * band) as u8,
|
||||
(90.0 + 110.0 * band) as u8,
|
||||
255,
|
||||
(45.0 + 110.0 * band) as u8,
|
||||
)
|
||||
}
|
||||
|
||||
fn emit(batch: &mut HudBatch, fan: &[Point], color: impl Fn(Vec2) -> u32, glint: bool) {
|
||||
let shift = if glint { 2.0 } else { 0.0 };
|
||||
for i in 1..fan.len().saturating_sub(1) {
|
||||
let (a, b, c) = (fan[0], fan[i], fan[i + 1]);
|
||||
let corners = [a, b, c, c];
|
||||
// `HudBatch.quad` draws (0, 1, 2) and (0, 2, 3): the second is empty.
|
||||
batch.quad(
|
||||
corners.map(|v| v.p.to_array()),
|
||||
corners.map(|v| [v.uv[0] + shift, v.uv[1]]),
|
||||
color(a.p),
|
||||
);
|
||||
if glint {
|
||||
// Per-vertex colours are flat per quad in the HUD batch: use the
|
||||
// fan centre for the sheen.
|
||||
let n = batch.vertices.len();
|
||||
for v in &mut batch.vertices[n - 6..] {
|
||||
v.color = color(Vec2::from(v.pos));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit, light, cull, sort and clip one item's quads into `batch` at GUI
|
||||
/// position `(x, y)`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw(
|
||||
state: &ItemRenderState,
|
||||
renderer: &ItemRenderer,
|
||||
sprites: &HashMap<String, Rect>,
|
||||
host: &dyn SpecialHost,
|
||||
batch: &mut HudBatch,
|
||||
x: f32,
|
||||
y: f32,
|
||||
time: f32,
|
||||
) {
|
||||
if state.is_empty() {
|
||||
return;
|
||||
}
|
||||
let pose = Mat4::from_translation(Vec3::new(x + 8.0, y + 8.0, 0.0))
|
||||
* Mat4::from_scale(Vec3::new(16.0, -16.0, 16.0));
|
||||
let mut quads: Vec<ItemQuad> = Vec::new();
|
||||
state.submit(renderer, sprites, host, pose, &mut quads);
|
||||
let lights = if state.uses_block_light() {
|
||||
items_3d_lights()
|
||||
} else {
|
||||
items_flat_lights()
|
||||
};
|
||||
// Back-face culling: front faces wind counter-clockwise on screen with y up.
|
||||
let mut visible: Vec<(f32, usize)> = quads
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, q)| {
|
||||
let p = q.pos.map(|v| Vec2::new(v.x, -v.y));
|
||||
let area: f32 = (0..4)
|
||||
.map(|i| {
|
||||
let (a, b) = (p[i], p[(i + 1) % 4]);
|
||||
a.x * b.y - b.x * a.y
|
||||
})
|
||||
.sum();
|
||||
area > 0.0
|
||||
})
|
||||
.map(|(i, q)| (q.pos.iter().map(|v| v.z).sum::<f32>() / 4.0, i))
|
||||
.collect();
|
||||
// Far to near; the depth buffer of the item atlas, approximated per quad.
|
||||
visible.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let rect = if state.oversized_in_gui {
|
||||
[
|
||||
f32::NEG_INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
f32::INFINITY,
|
||||
f32::INFINITY,
|
||||
]
|
||||
} else {
|
||||
[x, y, x + 16.0, y + 16.0]
|
||||
};
|
||||
for (_, i) in visible {
|
||||
let q = &quads[i];
|
||||
let points: [Point; 4] = std::array::from_fn(|k| Point {
|
||||
p: Vec2::new(q.pos[k].x, q.pos[k].y),
|
||||
uv: q.uv[k],
|
||||
});
|
||||
let color = lit_color(q, lights);
|
||||
for tri in [
|
||||
[points[0], points[1], points[2]],
|
||||
[points[0], points[2], points[3]],
|
||||
] {
|
||||
let fan = clip_triangle(tri, rect);
|
||||
if fan.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
emit(batch, &fan, |_| color, false);
|
||||
if q.foil != Foil::None {
|
||||
emit(batch, &fan, |p| glint_color(p, time), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn flat_lights_match_joml() {
|
||||
// new Matrix4f().rotationY(-PI/8).rotateX(3PI/4).transformDirection(L0).
|
||||
let (l0, _) = items_flat_lights();
|
||||
let l = Vec3::new(0.2, 1.0, -0.7).normalize();
|
||||
let (s, c) = ((3.0 * PI / 4.0).sin(), (3.0 * PI / 4.0).cos());
|
||||
let rx = Vec3::new(l.x, c * l.y - s * l.z, s * l.y + c * l.z);
|
||||
let (s, c) = ((-PI / 8.0).sin(), (-PI / 8.0).cos());
|
||||
let ry = Vec3::new(c * rx.x + s * rx.z, rx.y, -s * rx.x + c * rx.z);
|
||||
assert!((l0 - ry).length() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn triangles_clip_to_the_slot() {
|
||||
let p = |x: f32, y: f32| Point {
|
||||
p: Vec2::new(x, y),
|
||||
uv: [x, y],
|
||||
};
|
||||
let fan = clip_triangle(
|
||||
[p(-4.0, 0.0), p(8.0, 0.0), p(8.0, 8.0)],
|
||||
[0.0, 0.0, 16.0, 16.0],
|
||||
);
|
||||
assert!(fan.iter().all(|v| v.p.x >= 0.0));
|
||||
// UVs follow the cut affinely.
|
||||
assert!(fan.iter().all(|v| (v.uv[0] - v.p.x).abs() < 1e-6));
|
||||
assert!(
|
||||
clip_triangle(
|
||||
[p(20.0, 0.0), p(30.0, 0.0), p(30.0, 5.0)],
|
||||
[0.0, 0.0, 16.0, 16.0]
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,597 +0,0 @@
|
||||
//! `SpecialModelRenderer`s: items drawn with entity models (chests, shulker
|
||||
//! boxes, banners, heads, shields, tridents, decorated pots, conduits, bells,
|
||||
//! books and copper golem statues). Each submits its model at the layer pose
|
||||
//! as vanilla's `submit` does (no block-entity placement transform) and
|
||||
//! reports `getExtents` over the same posed parts.
|
||||
use super::{Foil, ItemQuad, ItemRenderer, ItemSink, SpecialHost, argb};
|
||||
use crate::{block_entity_render::ModelPart, hud::Rect};
|
||||
use glam::{Mat4, Quat, Vec3};
|
||||
use horizon_assets::item_model::{SpecialKind, SpecialModel};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, f32::consts::PI, path::Path};
|
||||
|
||||
/// Entity model layers the special renderers bake (`ModelLayers`).
|
||||
const LAYERS: [(&str, &str); 22] = [
|
||||
("chest", "minecraft:chest#main"),
|
||||
("double_chest_left", "minecraft:double_chest_left#main"),
|
||||
("double_chest_right", "minecraft:double_chest_right#main"),
|
||||
("shulker_box", "minecraft:shulker_box#main"),
|
||||
("standing_banner", "minecraft:standing_banner#main"),
|
||||
("standing_banner_flag", "minecraft:standing_banner#flag"),
|
||||
("wall_banner", "minecraft:wall_banner#main"),
|
||||
("wall_banner_flag", "minecraft:wall_banner#flag"),
|
||||
("skeleton", "minecraft:skeleton_skull#main"),
|
||||
("wither_skeleton", "minecraft:wither_skeleton_skull#main"),
|
||||
("zombie", "minecraft:zombie_head#main"),
|
||||
("creeper", "minecraft:creeper_head#main"),
|
||||
("dragon", "minecraft:dragon_skull#main"),
|
||||
("piglin", "minecraft:piglin_head#main"),
|
||||
("player", "minecraft:player_head#main"),
|
||||
("bell", "minecraft:bell#main"),
|
||||
("book", "minecraft:book#main"),
|
||||
("conduit_shell", "minecraft:conduit#shell"),
|
||||
("decorated_pot_base", "minecraft:decorated_pot_base#main"),
|
||||
("decorated_pot_sides", "minecraft:decorated_pot_sides#main"),
|
||||
("shield", "minecraft:shield#main"),
|
||||
("trident", "minecraft:trident#main"),
|
||||
];
|
||||
const COPPER_GOLEM_LAYERS: [(&str, &str); 4] = [
|
||||
("copper_golem", "minecraft:copper_golem#main"),
|
||||
(
|
||||
"copper_golem_running",
|
||||
"minecraft:copper_golem_running#main",
|
||||
),
|
||||
(
|
||||
"copper_golem_sitting",
|
||||
"minecraft:copper_golem_sitting#main",
|
||||
),
|
||||
("copper_golem_star", "minecraft:copper_golem_star#main"),
|
||||
];
|
||||
|
||||
pub(super) fn load_parts(root: &Path) -> HashMap<String, ModelPart> {
|
||||
let Ok(all) = std::fs::read(root.join("entity-models.json"))
|
||||
.map_err(|_| ())
|
||||
.and_then(|b| serde_json::from_slice::<Value>(&b).map_err(|_| ()))
|
||||
else {
|
||||
return HashMap::new();
|
||||
};
|
||||
LAYERS
|
||||
.iter()
|
||||
.chain(COPPER_GOLEM_LAYERS.iter())
|
||||
.filter_map(|(key, layer)| {
|
||||
all.get(*layer)
|
||||
.map(|v| ((*key).to_owned(), ModelPart::parse(v)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A pose override for a named part (`setupAnim`).
|
||||
type Adjust<'a> = &'a dyn Fn(&str, &mut [f32; 9]);
|
||||
fn none(_: &str, _: &mut [f32; 9]) {}
|
||||
|
||||
/// `ModelPart.translateAndRotate`.
|
||||
fn part_matrix(parent: Mat4, p: &[f32; 9]) -> Mat4 {
|
||||
parent
|
||||
* Mat4::from_translation(Vec3::new(p[0], p[1], p[2]) / 16.0)
|
||||
* Mat4::from_quat(Quat::from_euler(glam::EulerRot::ZYX, p[5], p[4], p[3]))
|
||||
* Mat4::from_scale(Vec3::new(p[6], p[7], p[8]))
|
||||
}
|
||||
/// `ModelPart.render` of `part` (named `name`) as item quads.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw(
|
||||
part: &ModelPart,
|
||||
name: &str,
|
||||
parent: Mat4,
|
||||
adjust: Adjust,
|
||||
sprite: Rect,
|
||||
color: u32,
|
||||
foil: Foil,
|
||||
sink: &mut dyn ItemSink,
|
||||
) {
|
||||
let mut p = part.pose;
|
||||
adjust(name, &mut p);
|
||||
let m = part_matrix(parent, &p);
|
||||
for poly in &part.polygons {
|
||||
let pos = poly.map(|v| m.transform_point3(Vec3::new(v[0], v[1], v[2]) / 16.0));
|
||||
let normal = (pos[1] - pos[0]).cross(pos[2] - pos[0]).normalize_or_zero();
|
||||
let normal = if normal == Vec3::ZERO {
|
||||
(pos[2] - pos[1])
|
||||
.cross(pos[3] - pos[1])
|
||||
.normalize_or(Vec3::Y)
|
||||
} else {
|
||||
normal
|
||||
};
|
||||
sink.quad(ItemQuad {
|
||||
pos,
|
||||
uv: poly.map(|v| {
|
||||
[
|
||||
sprite.u0 + (sprite.u1 - sprite.u0) * v[3],
|
||||
sprite.v0 + (sprite.v1 - sprite.v0) * v[4],
|
||||
]
|
||||
}),
|
||||
normal,
|
||||
color,
|
||||
foil,
|
||||
light_emission: 0,
|
||||
});
|
||||
}
|
||||
for (child_name, child) in &part.children {
|
||||
draw(child, child_name, m, adjust, sprite, color, foil, sink);
|
||||
}
|
||||
}
|
||||
/// `ModelPart.getExtentsForGui` from an identity pose.
|
||||
fn extents_of(part: &ModelPart, name: &str, parent: Mat4, adjust: Adjust, out: &mut Vec<Vec3>) {
|
||||
if part.polygons.is_empty() && part.children.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut p = part.pose;
|
||||
adjust(name, &mut p);
|
||||
let m = part_matrix(parent, &p);
|
||||
for poly in &part.polygons {
|
||||
for v in poly {
|
||||
let q = m.transform_point3(Vec3::new(v[0], v[1], v[2]) / 16.0);
|
||||
if !out.contains(&q) {
|
||||
out.push(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (child_name, child) in &part.children {
|
||||
extents_of(child, child_name, m, adjust, out);
|
||||
}
|
||||
}
|
||||
fn child<'a>(part: &'a ModelPart, name: &str) -> Option<&'a ModelPart> {
|
||||
part.child(name)
|
||||
}
|
||||
|
||||
/// A texture identifier relative to `textures/` (`minecraft:entity/x` or a
|
||||
/// full `minecraft:textures/entity/x.png`) as its atlas sprite name.
|
||||
fn texture_sprite(id: &str) -> String {
|
||||
let path = id.strip_prefix("minecraft:").unwrap_or(id);
|
||||
let path = path.strip_prefix("textures/").unwrap_or(path);
|
||||
path.strip_suffix(".png").unwrap_or(path).to_owned()
|
||||
}
|
||||
fn mapped(directory: &str, id: &str) -> String {
|
||||
format!(
|
||||
"entity/{directory}/{}",
|
||||
id.strip_prefix("minecraft:").unwrap_or(id)
|
||||
)
|
||||
}
|
||||
/// `DyeColor.getTextureDiffuseColor` as ARGB.
|
||||
fn dye(name: &str) -> u32 {
|
||||
0xFF00_0000 | crate::banner::dye_rgb(name)
|
||||
}
|
||||
|
||||
/// The chest model of a chest type, with `ChestModel.setupAnim(openness)`.
|
||||
fn chest_model(chest_type: &str) -> &'static str {
|
||||
match chest_type {
|
||||
"left" => "double_chest_left",
|
||||
"right" => "double_chest_right",
|
||||
_ => "chest",
|
||||
}
|
||||
}
|
||||
fn skull_layer(kind: &str) -> &'static str {
|
||||
match kind {
|
||||
"skeleton" => "skeleton",
|
||||
"wither_skeleton" => "wither_skeleton",
|
||||
"zombie" => "zombie",
|
||||
"creeper" => "creeper",
|
||||
"dragon" => "dragon",
|
||||
"piglin" => "piglin",
|
||||
_ => "player",
|
||||
}
|
||||
}
|
||||
/// `SkullBlockRenderer.SKIN_BY_TYPE`.
|
||||
fn skull_texture(kind: &str) -> &'static str {
|
||||
match kind {
|
||||
"skeleton" => "entity/skeleton/skeleton",
|
||||
"wither_skeleton" => "entity/skeleton/wither_skeleton",
|
||||
"zombie" => "entity/zombie/zombie",
|
||||
"creeper" => "entity/creeper/creeper",
|
||||
"dragon" => "entity/enderdragon/dragon",
|
||||
"piglin" => "entity/piglin/piglin",
|
||||
_ => "entity/player/wide/steve",
|
||||
}
|
||||
}
|
||||
/// `SkullModelBase.setupAnim` for a head at `animation` (yRot = xRot = 0).
|
||||
fn skull_pose(kind: &str, animation: f32) -> impl Fn(&str, &mut [f32; 9]) + '_ {
|
||||
move |name: &str, p: &mut [f32; 9]| match (kind, name) {
|
||||
(_, "head") => {
|
||||
p[3] = 0.0;
|
||||
p[4] = 0.0;
|
||||
}
|
||||
("dragon", "jaw") => p[3] = (((animation * PI * 0.2) as f64).sin() as f32 + 1.0) * 0.2,
|
||||
("piglin", "left_ear") => {
|
||||
p[5] = -((((animation * PI * 0.2 * 1.2) as f64).cos() + 2.5) as f32) * 0.2;
|
||||
}
|
||||
("piglin", "right_ear") => {
|
||||
p[5] = ((((animation * PI * 0.2) as f64).cos() + 2.5) as f32) * 0.2;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn shulker_pose(progress: f32) -> impl Fn(&str, &mut [f32; 9]) {
|
||||
move |name: &str, p: &mut [f32; 9]| {
|
||||
if name == "lid" {
|
||||
p[0] = 0.0;
|
||||
p[1] = 24.0 - progress * 0.5 * 16.0;
|
||||
p[2] = 0.0;
|
||||
p[4] = 270.0 * progress * (PI / 180.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn chest_pose(open: f32) -> impl Fn(&str, &mut [f32; 9]) {
|
||||
move |name: &str, p: &mut [f32; 9]| {
|
||||
if name == "lid" || name == "lock" {
|
||||
p[3] = -(open * (PI / 2.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `BannerFlagModel.setupAnim(0)`.
|
||||
fn flag_pose(name: &str, p: &mut [f32; 9]) {
|
||||
if name == "flag" {
|
||||
p[3] = (-0.0125 + 0.01 * (2.0 * PI * 0.0f32).cos()) * PI;
|
||||
}
|
||||
}
|
||||
/// `BookModel.setupAnim`.
|
||||
fn book_pose(openness: f32, flip1: f32, flip2: f32) -> impl Fn(&str, &mut [f32; 9]) {
|
||||
move |name: &str, p: &mut [f32; 9]| match name {
|
||||
"left_lid" => p[4] = PI + openness,
|
||||
"right_lid" => p[4] = -openness,
|
||||
"left_pages" => {
|
||||
p[4] = openness;
|
||||
p[0] = openness.sin();
|
||||
}
|
||||
"right_pages" => {
|
||||
p[4] = -openness;
|
||||
p[0] = openness.sin();
|
||||
}
|
||||
"flip_page1" => {
|
||||
p[4] = openness - openness * 2.0 * flip1;
|
||||
p[0] = openness.sin();
|
||||
}
|
||||
"flip_page2" => {
|
||||
p[4] = openness - openness * 2.0 * flip2;
|
||||
p[0] = openness.sin();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `CopperGolemStatueModel.setupAnim`.
|
||||
fn statue_pose(name: &str, p: &mut [f32; 9]) {
|
||||
if name == "root" {
|
||||
p[1] = 0.0;
|
||||
p[5] = PI;
|
||||
}
|
||||
}
|
||||
fn statue_layer(pose: &str) -> &'static str {
|
||||
match pose {
|
||||
"running" => "copper_golem_running",
|
||||
"sitting" => "copper_golem_sitting",
|
||||
"star" => "copper_golem_star",
|
||||
_ => "copper_golem",
|
||||
}
|
||||
}
|
||||
|
||||
/// `SpecialModelRenderer.getExtents`.
|
||||
pub(super) fn extents(renderer: &ItemRenderer, model: &SpecialModel) -> Vec<Vec3> {
|
||||
let mut out = Vec::new();
|
||||
let id = Mat4::IDENTITY;
|
||||
let mut add = |key: &str, adjust: Adjust| {
|
||||
if let Some(part) = renderer.part(key) {
|
||||
extents_of(part, "root", id, adjust, &mut out);
|
||||
}
|
||||
};
|
||||
match &model.kind {
|
||||
SpecialKind::Bell => add("bell", &none),
|
||||
SpecialKind::Banner { .. } => {
|
||||
// `BannerRenderer.getExtents`: the standing models only.
|
||||
add("standing_banner", &none);
|
||||
add("standing_banner_flag", &flag_pose);
|
||||
}
|
||||
SpecialKind::Book {
|
||||
open_angle,
|
||||
page1,
|
||||
page2,
|
||||
} => add(
|
||||
"book",
|
||||
&book_pose(open_angle * (PI / 180.0), *page1, *page2),
|
||||
),
|
||||
SpecialKind::Conduit => add("conduit_shell", &none),
|
||||
SpecialKind::Chest {
|
||||
openness,
|
||||
chest_type,
|
||||
..
|
||||
} => add(chest_model(chest_type), &chest_pose(*openness)),
|
||||
SpecialKind::CopperGolemStatue { pose, .. } => add(statue_layer(pose), &statue_pose),
|
||||
SpecialKind::DecoratedPot => {
|
||||
if let Some(base) = renderer.part("decorated_pot_base") {
|
||||
// `neck`, `top` and `bottom` are posed from an identity stack.
|
||||
for name in ["neck", "top", "bottom"] {
|
||||
if let Some(part) = child(base, name) {
|
||||
extents_of(part, name, id, &none, &mut out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SpecialKind::EndCube { .. } => {}
|
||||
SpecialKind::Head {
|
||||
kind, animation, ..
|
||||
} => add(skull_layer(kind), &skull_pose(kind, *animation)),
|
||||
SpecialKind::PlayerHead => add("player", &none),
|
||||
SpecialKind::ShulkerBox { openness, .. } => add("shulker_box", &shulker_pose(*openness)),
|
||||
SpecialKind::Shield => add("shield", &none),
|
||||
SpecialKind::Trident => add("trident", &none),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Banner pattern layers of a `minecraft:banner_patterns` value: the
|
||||
/// pattern identifier (or inline asset) and the dye.
|
||||
fn pattern_layers(v: &Value) -> Vec<(String, String)> {
|
||||
v.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|layer| {
|
||||
let pattern = match &layer["pattern"] {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Object(o) => format!("#asset:{}", o.get("asset_id")?.as_str()?),
|
||||
_ => return None,
|
||||
};
|
||||
let color = layer["color"].as_str()?.to_owned();
|
||||
Some((pattern, color))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn pattern_texture(host: &dyn SpecialHost, banner: bool, pattern: &str) -> Option<String> {
|
||||
if let Some(asset) = pattern.strip_prefix("#asset:") {
|
||||
let path = asset.strip_prefix("minecraft:").unwrap_or(asset);
|
||||
return Some(format!(
|
||||
"entity/{}/{path}",
|
||||
if banner { "banner" } else { "shield" }
|
||||
));
|
||||
}
|
||||
if banner {
|
||||
host.banner_texture(pattern)
|
||||
} else {
|
||||
host.shield_texture(pattern)
|
||||
}
|
||||
}
|
||||
/// `DecoratedPotPatterns.getPatternFromItem` for one side's sherd item.
|
||||
fn pot_side(item: Option<&str>) -> String {
|
||||
let item = item.map(|i| i.strip_prefix("minecraft:").unwrap_or(i));
|
||||
match item.and_then(|i| i.strip_suffix("_pottery_sherd")) {
|
||||
Some(name) => format!("entity/decorated_pot/{name}_pottery_pattern"),
|
||||
None => "entity/decorated_pot/decorated_pot_side".to_owned(),
|
||||
}
|
||||
}
|
||||
/// `PotDecorations` as [back, left, right, front] item ids, from the list
|
||||
/// codec or a keyed object.
|
||||
fn pot_sides(v: &Value) -> [Option<String>; 4] {
|
||||
let id = |s: &Value| match s {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Object(o) => o.get("id").and_then(Value::as_str).map(str::to_owned),
|
||||
_ => None,
|
||||
};
|
||||
match v {
|
||||
Value::Array(a) => std::array::from_fn(|i| a.get(i).and_then(id)),
|
||||
Value::Object(o) => ["back", "left", "right", "front"].map(|k| o.get(k).and_then(id)),
|
||||
_ => Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `SpecialModelRenderer.submit`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn submit(
|
||||
renderer: &ItemRenderer,
|
||||
model: &SpecialModel,
|
||||
argument: &Value,
|
||||
has_foil: bool,
|
||||
pose: Mat4,
|
||||
sprites: &HashMap<String, Rect>,
|
||||
host: &dyn SpecialHost,
|
||||
sink: &mut dyn ItemSink,
|
||||
) {
|
||||
let white = argb(-1);
|
||||
let mut part = |key: &str, adjust: Adjust, texture: &str, color: u32, foil: Foil| {
|
||||
if let (Some(p), Some(r)) = (renderer.part(key), sprites.get(texture).copied()) {
|
||||
draw(p, "root", pose, adjust, r, color, foil, sink);
|
||||
}
|
||||
};
|
||||
match &model.kind {
|
||||
SpecialKind::Bell => part("bell", &none, "entity/bell/bell_body", white, Foil::None),
|
||||
SpecialKind::Book {
|
||||
open_angle,
|
||||
page1,
|
||||
page2,
|
||||
} => part(
|
||||
"book",
|
||||
&book_pose(open_angle * (PI / 180.0), *page1, *page2),
|
||||
"entity/enchantment/enchanting_table_book",
|
||||
white,
|
||||
Foil::None,
|
||||
),
|
||||
SpecialKind::Conduit => {
|
||||
part(
|
||||
"conduit_shell",
|
||||
&none,
|
||||
"entity/conduit/base",
|
||||
white,
|
||||
Foil::None,
|
||||
);
|
||||
}
|
||||
SpecialKind::Chest {
|
||||
texture,
|
||||
openness,
|
||||
chest_type,
|
||||
} => part(
|
||||
chest_model(chest_type),
|
||||
&chest_pose(*openness),
|
||||
&mapped("chest", texture),
|
||||
white,
|
||||
Foil::None,
|
||||
),
|
||||
SpecialKind::ShulkerBox { texture, openness } => part(
|
||||
"shulker_box",
|
||||
&shulker_pose(*openness),
|
||||
&mapped("shulker", texture),
|
||||
white,
|
||||
Foil::None,
|
||||
),
|
||||
SpecialKind::CopperGolemStatue { texture, pose: p } => part(
|
||||
statue_layer(p),
|
||||
&statue_pose,
|
||||
&texture_sprite(texture),
|
||||
white,
|
||||
Foil::None,
|
||||
),
|
||||
SpecialKind::Head {
|
||||
kind,
|
||||
texture,
|
||||
animation,
|
||||
} => {
|
||||
let skin = texture
|
||||
.as_deref()
|
||||
.map(|t| format!("entity/{}", t.strip_prefix("minecraft:").unwrap_or(t)))
|
||||
.unwrap_or_else(|| skull_texture(kind).to_owned());
|
||||
part(
|
||||
skull_layer(kind),
|
||||
&skull_pose(kind, *animation),
|
||||
&skin,
|
||||
white,
|
||||
Foil::None,
|
||||
);
|
||||
}
|
||||
SpecialKind::PlayerHead => {
|
||||
let skin = (!argument.is_null())
|
||||
.then(|| host.player_skin(argument))
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "entity/player/wide/steve".to_owned());
|
||||
part("player", &none, &skin, white, Foil::None);
|
||||
}
|
||||
SpecialKind::Trident => {
|
||||
let foil = if has_foil { Foil::Standard } else { Foil::None };
|
||||
part("trident", &none, "entity/trident/trident", white, foil);
|
||||
}
|
||||
SpecialKind::Banner { color, wall } => {
|
||||
let (main, flag) = if *wall {
|
||||
("wall_banner", "wall_banner_flag")
|
||||
} else {
|
||||
("standing_banner", "standing_banner_flag")
|
||||
};
|
||||
part(main, &none, "entity/banner/banner_base", white, Foil::None);
|
||||
part(
|
||||
flag,
|
||||
&flag_pose,
|
||||
"entity/banner/banner_base",
|
||||
white,
|
||||
Foil::None,
|
||||
);
|
||||
// `submitPatterns`: the base layer in the banner's colour, then up to 16 layers.
|
||||
part(
|
||||
flag,
|
||||
&flag_pose,
|
||||
"entity/banner/base",
|
||||
dye(color),
|
||||
Foil::None,
|
||||
);
|
||||
for (pattern, color) in pattern_layers(argument).into_iter().take(16) {
|
||||
if let Some(t) = pattern_texture(host, true, &pattern) {
|
||||
part(flag, &flag_pose, &t, dye(&color), Foil::None);
|
||||
}
|
||||
}
|
||||
}
|
||||
SpecialKind::Shield => {
|
||||
let patterns = pattern_layers(&argument["minecraft:banner_patterns"]);
|
||||
let base_color = argument["minecraft:base_color"].as_str();
|
||||
let has_patterns = !patterns.is_empty() || base_color.is_some();
|
||||
let base = if has_patterns {
|
||||
"entity/shield/shield_base"
|
||||
} else {
|
||||
"entity/shield/shield_base_nopattern"
|
||||
};
|
||||
let foil = if has_foil && !has_patterns {
|
||||
Foil::Standard
|
||||
} else {
|
||||
Foil::None
|
||||
};
|
||||
part("shield", &none, base, white, foil);
|
||||
if has_patterns {
|
||||
part(
|
||||
"shield",
|
||||
&none,
|
||||
"entity/shield/base",
|
||||
dye(base_color.unwrap_or("white")),
|
||||
Foil::None,
|
||||
);
|
||||
for (pattern, color) in patterns.into_iter().take(16) {
|
||||
if let Some(t) = pattern_texture(host, false, &pattern) {
|
||||
part("shield", &none, &t, dye(&color), Foil::None);
|
||||
}
|
||||
}
|
||||
if has_foil {
|
||||
// `patternedShieldGlint`: the glint alone over the model.
|
||||
part("shield", &none, base, white, Foil::Standard);
|
||||
}
|
||||
}
|
||||
}
|
||||
SpecialKind::DecoratedPot => {
|
||||
let sides = pot_sides(argument);
|
||||
if let Some(base) = renderer.part("decorated_pot_base")
|
||||
&& let Some(r) = sprites
|
||||
.get("entity/decorated_pot/decorated_pot_base")
|
||||
.copied()
|
||||
{
|
||||
for name in ["neck", "top", "bottom"] {
|
||||
if let Some(p) = child(base, name) {
|
||||
draw(p, name, pose, &none, r, white, Foil::None, sink);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(side_parts) = renderer.part("decorated_pot_sides") {
|
||||
for (i, name) in ["back", "left", "right", "front"].into_iter().enumerate() {
|
||||
let texture = pot_side(sides[i].as_deref());
|
||||
if let (Some(p), Some(r)) = (child(side_parts, name), sprites.get(&texture)) {
|
||||
draw(p, name, pose, &none, *r, white, Foil::None, sink);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// `EndCubeSpecialRenderer` draws the end portal/gateway effect; no
|
||||
// bundled item uses it.
|
||||
SpecialKind::EndCube { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pot_sides_read_both_shapes() {
|
||||
let list = serde_json::json!(["minecraft:brick", "minecraft:angler_pottery_sherd"]);
|
||||
let sides = pot_sides(&list);
|
||||
assert_eq!(
|
||||
pot_side(sides[0].as_deref()),
|
||||
"entity/decorated_pot/decorated_pot_side"
|
||||
);
|
||||
assert_eq!(
|
||||
pot_side(sides[1].as_deref()),
|
||||
"entity/decorated_pot/angler_pottery_pattern"
|
||||
);
|
||||
assert_eq!(
|
||||
pot_side(sides[3].as_deref()),
|
||||
"entity/decorated_pot/decorated_pot_side"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn texture_ids_map_to_sprites() {
|
||||
assert_eq!(
|
||||
texture_sprite("minecraft:textures/entity/copper_golem/copper_golem.png"),
|
||||
"entity/copper_golem/copper_golem"
|
||||
);
|
||||
assert_eq!(
|
||||
mapped("chest", "minecraft:christmas"),
|
||||
"entity/chest/christmas"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -526,27 +526,13 @@ impl Items {
|
||||
pub fn has_loot_table(&self, id: &str) -> bool {
|
||||
self.loot.contains_key(id)
|
||||
}
|
||||
/// The server random sequence the block's loot table draws from.
|
||||
pub fn block_loot_sequence(
|
||||
&self,
|
||||
registry: &BlockRegistry,
|
||||
state: BlockStateId,
|
||||
) -> Option<String> {
|
||||
let block = registry.state(state)?;
|
||||
let name = block.name.trim_start_matches("minecraft:");
|
||||
let standing = name.replacen("_wall_", "_", 1);
|
||||
let standing = standing.strip_prefix("wall_").unwrap_or(&standing);
|
||||
let standing = standing.replacen("wall_", "", 1);
|
||||
self.loot_sequence(&format!("minecraft:blocks/{name}"))
|
||||
.or_else(|| self.loot_sequence(&format!("minecraft:blocks/{standing}")))
|
||||
}
|
||||
/// Evaluate the block's loot table for a player break without explosions.
|
||||
pub fn block_drops(
|
||||
&self,
|
||||
registry: &BlockRegistry,
|
||||
state: BlockStateId,
|
||||
held: Option<&ItemStack>,
|
||||
random: &mut impl RandomSource,
|
||||
random: &mut LegacyRandom,
|
||||
) -> Vec<ItemStack> {
|
||||
let Some(block) = registry.state(state) else {
|
||||
return vec![];
|
||||
@@ -595,34 +581,6 @@ impl Items {
|
||||
};
|
||||
ctx.evaluate(table, random)
|
||||
}
|
||||
/// The server random sequence a loot table draws from (`random_sequence`):
|
||||
/// vanilla's `LootContext.Builder.create` uses it instead of the level random.
|
||||
pub fn loot_sequence(&self, id: &str) -> Option<String> {
|
||||
self.loot.get(id)?["random_sequence"]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
}
|
||||
/// `loot_table` drawing from any random (a random sequence).
|
||||
pub fn loot_table_from(
|
||||
&self,
|
||||
id: &str,
|
||||
killed_by_player: bool,
|
||||
held: Option<&ItemStack>,
|
||||
random: &mut impl RandomSource,
|
||||
) -> Vec<ItemStack> {
|
||||
let Some(table) = self.loot.get(id) else {
|
||||
return vec![];
|
||||
};
|
||||
let empty = BTreeMap::new();
|
||||
let ctx = LootContext {
|
||||
items: self,
|
||||
block: "",
|
||||
properties: &empty,
|
||||
held,
|
||||
killed_by_player,
|
||||
};
|
||||
ctx.evaluate(table, random)
|
||||
}
|
||||
pub fn entity_drops(
|
||||
&self,
|
||||
entity: &str,
|
||||
@@ -646,15 +604,14 @@ impl Items {
|
||||
}
|
||||
}
|
||||
impl LootContext<'_> {
|
||||
fn evaluate(&self, table: &Value, random: &mut impl RandomSource) -> Vec<ItemStack> {
|
||||
fn evaluate(&self, table: &Value, random: &mut LegacyRandom) -> Vec<ItemStack> {
|
||||
let ctx = self;
|
||||
let mut out = Vec::new();
|
||||
for pool in table["pools"].as_array().into_iter().flatten() {
|
||||
if !ctx.conditions(pool, random) {
|
||||
continue;
|
||||
}
|
||||
// LootPool: rolls.getInt (Mth.randomBetweenInclusive for uniform rolls).
|
||||
let rolls = ctx.int_number(&pool["rolls"], random).max(0) as u32;
|
||||
let rolls = ctx.number(&pool["rolls"], random).max(0.0) as u32;
|
||||
for _ in 0..rolls {
|
||||
let mut produced = Vec::new();
|
||||
ctx.choose(
|
||||
@@ -686,7 +643,7 @@ impl LootContext<'_> {
|
||||
fn enchantment_level(&self, id: &str) -> u32 {
|
||||
self.held.map_or(0, |h| h.enchantment_level(id))
|
||||
}
|
||||
fn number(&self, v: &Value, random: &mut impl RandomSource) -> f32 {
|
||||
fn number(&self, v: &Value, random: &mut LegacyRandom) -> f32 {
|
||||
match v {
|
||||
Value::Number(n) => n.as_f64().unwrap_or(0.0) as f32,
|
||||
Value::Object(o) => match o
|
||||
@@ -717,7 +674,7 @@ impl LootContext<'_> {
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
fn int_number(&self, v: &Value, random: &mut impl RandomSource) -> i32 {
|
||||
fn int_number(&self, v: &Value, random: &mut LegacyRandom) -> i32 {
|
||||
if let Value::Object(o) = v
|
||||
&& o.get("type")
|
||||
.and_then(Value::as_str)
|
||||
@@ -734,7 +691,7 @@ impl LootContext<'_> {
|
||||
}
|
||||
self.number(v, random).round() as i32
|
||||
}
|
||||
fn condition(&self, c: &Value, random: &mut impl RandomSource) -> bool {
|
||||
fn condition(&self, c: &Value, random: &mut LegacyRandom) -> bool {
|
||||
let (kind, c) = match c {
|
||||
Value::String(s) => (s.as_str(), &Value::Null),
|
||||
Value::Object(o) => (o.get("type").and_then(Value::as_str).unwrap_or(""), c),
|
||||
@@ -843,7 +800,7 @@ impl LootContext<'_> {
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
fn conditions(&self, o: &Value, random: &mut impl RandomSource) -> bool {
|
||||
fn conditions(&self, o: &Value, random: &mut LegacyRandom) -> bool {
|
||||
if let Some(c) = o.get("condition")
|
||||
&& !self.condition(c, random)
|
||||
{
|
||||
@@ -859,7 +816,7 @@ impl LootContext<'_> {
|
||||
fn expand(
|
||||
&self,
|
||||
e: &Value,
|
||||
random: &mut impl RandomSource,
|
||||
random: &mut LegacyRandom,
|
||||
out: &mut Vec<(i32, ItemStack, Value)>,
|
||||
) -> bool {
|
||||
if !self.conditions(e, random) {
|
||||
@@ -917,12 +874,7 @@ impl LootContext<'_> {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn choose(
|
||||
&self,
|
||||
entries: &[Value],
|
||||
random: &mut impl RandomSource,
|
||||
produced: &mut Vec<ItemStack>,
|
||||
) {
|
||||
fn choose(&self, entries: &[Value], random: &mut LegacyRandom, produced: &mut Vec<ItemStack>) {
|
||||
let mut candidates = Vec::new();
|
||||
for e in entries {
|
||||
self.expand(e, random, &mut candidates);
|
||||
@@ -949,7 +901,7 @@ impl LootContext<'_> {
|
||||
self.modifiers(&modifiers, &mut stack, random);
|
||||
produced.push(stack);
|
||||
}
|
||||
fn modifiers(&self, m: &Value, stack: &mut ItemStack, random: &mut impl RandomSource) {
|
||||
fn modifiers(&self, m: &Value, stack: &mut ItemStack, random: &mut LegacyRandom) {
|
||||
let list: Vec<&Value> = match m {
|
||||
Value::Array(a) => a.iter().collect(),
|
||||
Value::Object(_) => vec![m],
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
//! Argument nodes of vanilla's command tree with a custom suggestion
|
||||
//! provider (path of node names from the root, provider network name).
|
||||
//! Generated by tools/generate_command_suggestions.py from
|
||||
//! VanillaCommandSuggestionsExporter; do not edit by hand.
|
||||
|
||||
pub const SUGGESTIONS: &[(&str, &str)] = &[
|
||||
(
|
||||
"advancement grant targets only advancement criterion",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"advancement revoke targets only advancement criterion",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"attribute target attribute modifier remove id",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"attribute target attribute modifier value get id",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
("bossbar get id", "minecraft:ask_server"),
|
||||
("bossbar remove id", "minecraft:ask_server"),
|
||||
("bossbar set id", "minecraft:ask_server"),
|
||||
("data get storage target", "minecraft:ask_server"),
|
||||
("data merge storage target", "minecraft:ask_server"),
|
||||
(
|
||||
"data modify block target targetPath append from storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify block target targetPath append string storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify block target targetPath insert index from storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify block target targetPath insert index string storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify block target targetPath merge from storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify block target targetPath merge string storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify block target targetPath prepend from storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify block target targetPath prepend string storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify block target targetPath set from storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify block target targetPath set string storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify entity target targetPath append from storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify entity target targetPath append string storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify entity target targetPath insert index from storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify entity target targetPath insert index string storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify entity target targetPath merge from storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify entity target targetPath merge string storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify entity target targetPath prepend from storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify entity target targetPath prepend string storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify entity target targetPath set from storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify entity target targetPath set string storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
("data modify storage target", "minecraft:ask_server"),
|
||||
(
|
||||
"data modify storage target targetPath append from storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify storage target targetPath append string storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify storage target targetPath insert index from storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify storage target targetPath insert index string storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify storage target targetPath merge from storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify storage target targetPath merge string storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify storage target targetPath prepend from storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify storage target targetPath prepend string storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify storage target targetPath set from storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"data modify storage target targetPath set string storage source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
("data remove storage target", "minecraft:ask_server"),
|
||||
("datapack disable name", "minecraft:ask_server"),
|
||||
("datapack enable name", "minecraft:ask_server"),
|
||||
(
|
||||
"datapack enable name after existing",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"datapack enable name before existing",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
("debug function name", "minecraft:ask_server"),
|
||||
("deop targets", "minecraft:ask_server"),
|
||||
("execute if data storage source", "minecraft:ask_server"),
|
||||
("execute if function name", "minecraft:ask_server"),
|
||||
("execute if score target", "minecraft:ask_server"),
|
||||
(
|
||||
"execute if score target targetObjective < source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"execute if score target targetObjective <= source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"execute if score target targetObjective = source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"execute if score target targetObjective > source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"execute if score target targetObjective >= source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
("execute if stopwatch id", "minecraft:ask_server"),
|
||||
("execute store result bossbar id", "minecraft:ask_server"),
|
||||
("execute store result score targets", "minecraft:ask_server"),
|
||||
(
|
||||
"execute store result storage target",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
("execute store success bossbar id", "minecraft:ask_server"),
|
||||
(
|
||||
"execute store success score targets",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"execute store success storage target",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
("execute summon entity", "minecraft:summonable_entities"),
|
||||
("execute unless data storage source", "minecraft:ask_server"),
|
||||
("execute unless function name", "minecraft:ask_server"),
|
||||
("execute unless score target", "minecraft:ask_server"),
|
||||
(
|
||||
"execute unless score target targetObjective < source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"execute unless score target targetObjective <= source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"execute unless score target targetObjective = source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"execute unless score target targetObjective > source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"execute unless score target targetObjective >= source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
("execute unless stopwatch id", "minecraft:ask_server"),
|
||||
("function name", "minecraft:ask_server"),
|
||||
("function name with storage source", "minecraft:ask_server"),
|
||||
("op targets", "minecraft:ask_server"),
|
||||
("pardon targets", "minecraft:ask_server"),
|
||||
("pardon-ip target", "minecraft:ask_server"),
|
||||
("place template template", "minecraft:ask_server"),
|
||||
("playsound sound", "minecraft:available_sounds"),
|
||||
(
|
||||
"posteffect add targets posteffect",
|
||||
"minecraft:post_effects",
|
||||
),
|
||||
(
|
||||
"posteffect remove targets posteffect",
|
||||
"minecraft:post_effects",
|
||||
),
|
||||
("random reset sequence", "minecraft:ask_server"),
|
||||
("random roll range sequence", "minecraft:ask_server"),
|
||||
("random value range sequence", "minecraft:ask_server"),
|
||||
("schedule clear function", "minecraft:ask_server"),
|
||||
("schedule function function", "minecraft:ask_server"),
|
||||
("scoreboard players add targets", "minecraft:ask_server"),
|
||||
(
|
||||
"scoreboard players display name targets",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"scoreboard players display numberformat targets",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
("scoreboard players enable targets", "minecraft:ask_server"),
|
||||
(
|
||||
"scoreboard players enable targets objective",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
("scoreboard players get target", "minecraft:ask_server"),
|
||||
("scoreboard players list target", "minecraft:ask_server"),
|
||||
(
|
||||
"scoreboard players operation targets",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
(
|
||||
"scoreboard players operation targets targetObjective operation source",
|
||||
"minecraft:ask_server",
|
||||
),
|
||||
("scoreboard players remove targets", "minecraft:ask_server"),
|
||||
("scoreboard players reset targets", "minecraft:ask_server"),
|
||||
("scoreboard players set targets", "minecraft:ask_server"),
|
||||
("stopsound targets * sound", "minecraft:available_sounds"),
|
||||
(
|
||||
"stopsound targets ambient sound",
|
||||
"minecraft:available_sounds",
|
||||
),
|
||||
(
|
||||
"stopsound targets block sound",
|
||||
"minecraft:available_sounds",
|
||||
),
|
||||
(
|
||||
"stopsound targets hostile sound",
|
||||
"minecraft:available_sounds",
|
||||
),
|
||||
(
|
||||
"stopsound targets master sound",
|
||||
"minecraft:available_sounds",
|
||||
),
|
||||
(
|
||||
"stopsound targets music sound",
|
||||
"minecraft:available_sounds",
|
||||
),
|
||||
(
|
||||
"stopsound targets neutral sound",
|
||||
"minecraft:available_sounds",
|
||||
),
|
||||
(
|
||||
"stopsound targets player sound",
|
||||
"minecraft:available_sounds",
|
||||
),
|
||||
(
|
||||
"stopsound targets record sound",
|
||||
"minecraft:available_sounds",
|
||||
),
|
||||
("stopsound targets ui sound", "minecraft:available_sounds"),
|
||||
(
|
||||
"stopsound targets voice sound",
|
||||
"minecraft:available_sounds",
|
||||
),
|
||||
(
|
||||
"stopsound targets weather sound",
|
||||
"minecraft:available_sounds",
|
||||
),
|
||||
("stopwatch query id", "minecraft:ask_server"),
|
||||
("stopwatch remove id", "minecraft:ask_server"),
|
||||
("stopwatch restart id", "minecraft:ask_server"),
|
||||
("summon entity", "minecraft:summonable_entities"),
|
||||
("tag targets remove name", "minecraft:ask_server"),
|
||||
("team join team members", "minecraft:ask_server"),
|
||||
("team leave members", "minecraft:ask_server"),
|
||||
("test locate tests", "minecraft:ask_server"),
|
||||
("test run tests", "minecraft:ask_server"),
|
||||
("test runmultiple tests", "minecraft:ask_server"),
|
||||
("test verify tests", "minecraft:ask_server"),
|
||||
("tick rate rate", "minecraft:ask_server"),
|
||||
("tick sprint time", "minecraft:ask_server"),
|
||||
("tick step time", "minecraft:ask_server"),
|
||||
("time of clock query timeline", "minecraft:ask_server"),
|
||||
("time of clock set timemarker", "minecraft:ask_server"),
|
||||
("time query timeline", "minecraft:ask_server"),
|
||||
("time set timemarker", "minecraft:ask_server"),
|
||||
("trigger objective", "minecraft:ask_server"),
|
||||
("whitelist add targets", "minecraft:ask_server"),
|
||||
("whitelist remove targets", "minecraft:ask_server"),
|
||||
];
|
||||
@@ -1,352 +0,0 @@
|
||||
//! The command tree players receive (Commands.sendCommands and
|
||||
//! ClientboundCommandsPacket): vanilla's tree from the data generator's
|
||||
//! commands report, cut down to the nodes the player's permission level may
|
||||
//! use and the commands of the server kind (dedicated servers have `op`,
|
||||
//! `ban`, `stop` ...; an integrated server `publish`), numbered breadth first
|
||||
//! as vanilla does, with the argument parsers' network properties and the
|
||||
//! custom suggestion providers (`command_suggestions`).
|
||||
use super::command_suggestions::SUGGESTIONS;
|
||||
use horizon_protocol::{
|
||||
buf::Writer,
|
||||
packets::play::{CommandNode, Commands},
|
||||
registry::Builtin,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
/// Commands.CommandSelection: registered only on dedicated servers.
|
||||
const DEDICATED_ONLY: &[&str] = &[
|
||||
"ban",
|
||||
"ban-ip",
|
||||
"banlist",
|
||||
"deop",
|
||||
"op",
|
||||
"pardon",
|
||||
"pardon-ip",
|
||||
"perf",
|
||||
"save-all",
|
||||
"save-off",
|
||||
"save-on",
|
||||
"setidletimeout",
|
||||
"stop",
|
||||
"transfer",
|
||||
"whitelist",
|
||||
];
|
||||
/// Registered only on integrated servers.
|
||||
const INTEGRATED_ONLY: &[&str] = &["publish", "unpublish"];
|
||||
/// Commands that check permissions only on dedicated servers
|
||||
/// (`checkPermissions` in SeedCommand and VersionCommand).
|
||||
const OPEN_WHEN_INTEGRATED: &[&str] = &["seed", "version"];
|
||||
/// Game rules of experimental features (minecart improvements), which the
|
||||
/// report lists but a vanilla-features world does not register.
|
||||
const EXPERIMENTAL_RULES: &[&str] = &["max_minecart_speed", "minecraft:max_minecart_speed"];
|
||||
|
||||
pub struct CommandTree {
|
||||
root: Value,
|
||||
}
|
||||
|
||||
/// A node of the filtered tree.
|
||||
struct Node {
|
||||
name: String,
|
||||
kind: u8,
|
||||
executable: bool,
|
||||
restricted: bool,
|
||||
children: Vec<usize>,
|
||||
redirect: Option<Vec<String>>,
|
||||
parser: Option<(String, Value)>,
|
||||
suggestions: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// PermissionLevel names in the report's requirements.
|
||||
fn required_level(node: &Value) -> u8 {
|
||||
match node["permissions"]["permission"]["level"].as_str() {
|
||||
Some("moderators") => 1,
|
||||
Some("gamemasters") => 2,
|
||||
Some("admins") => 3,
|
||||
Some("owners") => 4,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandTree {
|
||||
/// commands.json next to registries.json (the data generator's reports).
|
||||
pub fn load(resources: &Path) -> Option<Self> {
|
||||
let dir = Builtin::find(resources)?.parent()?.to_path_buf();
|
||||
let text = std::fs::read_to_string(dir.join("commands.json")).ok()?;
|
||||
Some(Self {
|
||||
root: serde_json::from_str(&text).ok()?,
|
||||
})
|
||||
}
|
||||
|
||||
/// The ClientboundCommandsPacket for a player with `level` permissions.
|
||||
pub fn packet(&self, level: u8, dedicated: bool, builtin: &Builtin) -> Commands {
|
||||
let mut nodes = vec![Node {
|
||||
name: String::new(),
|
||||
kind: 0,
|
||||
executable: false,
|
||||
restricted: false,
|
||||
children: Vec::new(),
|
||||
redirect: None,
|
||||
parser: None,
|
||||
suggestions: None,
|
||||
}];
|
||||
let suggestions: HashMap<&str, &'static str> = SUGGESTIONS.iter().copied().collect();
|
||||
let mut paths: HashMap<Vec<String>, usize> = HashMap::new();
|
||||
paths.insert(Vec::new(), 0);
|
||||
fill(
|
||||
&self.root,
|
||||
0,
|
||||
&mut Vec::new(),
|
||||
level,
|
||||
dedicated,
|
||||
&suggestions,
|
||||
&mut nodes,
|
||||
&mut paths,
|
||||
);
|
||||
// enumerateNodes: breadth first from the root, each node's children
|
||||
// (in name order) then its redirect target.
|
||||
let mut ids = vec![None; nodes.len()];
|
||||
let mut order = Vec::new();
|
||||
let mut queue = VecDeque::from([0usize]);
|
||||
while let Some(n) = queue.pop_front() {
|
||||
if ids[n].is_some() {
|
||||
continue;
|
||||
}
|
||||
ids[n] = Some(order.len() as i32);
|
||||
order.push(n);
|
||||
queue.extend(nodes[n].children.iter().copied());
|
||||
if let Some(target) = nodes[n].redirect.as_ref().and_then(|p| paths.get(p)) {
|
||||
queue.push_back(*target);
|
||||
}
|
||||
}
|
||||
let argument_types = builtin.get("minecraft:command_argument_type");
|
||||
let out = order
|
||||
.iter()
|
||||
.map(|&n| {
|
||||
let node = &nodes[n];
|
||||
CommandNode {
|
||||
kind: node.kind,
|
||||
executable: node.executable,
|
||||
restricted: node.restricted,
|
||||
children: node.children.iter().filter_map(|c| ids[*c]).collect(),
|
||||
redirect: node
|
||||
.redirect
|
||||
.as_ref()
|
||||
.and_then(|p| paths.get(p))
|
||||
.and_then(|t| ids[*t]),
|
||||
name: node.name.clone(),
|
||||
parser: node.parser.as_ref().map(|(parser, props)| {
|
||||
let id = argument_types.and_then(|t| t.id(parser)).unwrap_or(0);
|
||||
(id, properties(parser, props))
|
||||
}),
|
||||
suggestions: node.suggestions.map(str::to_owned),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Commands {
|
||||
nodes: out,
|
||||
root: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands.fillUsableCommands over the report: the children the source may use.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn fill(
|
||||
source: &Value,
|
||||
target: usize,
|
||||
path: &mut Vec<String>,
|
||||
level: u8,
|
||||
dedicated: bool,
|
||||
suggestions: &HashMap<&str, &'static str>,
|
||||
nodes: &mut Vec<Node>,
|
||||
paths: &mut HashMap<Vec<String>, usize>,
|
||||
) {
|
||||
let Some(children) = source["children"].as_object() else {
|
||||
return;
|
||||
};
|
||||
let mut names: Vec<&String> = children.keys().collect();
|
||||
names.sort();
|
||||
for name in names {
|
||||
let child = &children[name];
|
||||
let required =
|
||||
if path.is_empty() && !dedicated && OPEN_WHEN_INTEGRATED.contains(&name.as_str()) {
|
||||
0
|
||||
} else {
|
||||
required_level(child)
|
||||
};
|
||||
if required > level
|
||||
|| path.len() == 1
|
||||
&& path[0] == "gamerule"
|
||||
&& EXPERIMENTAL_RULES.contains(&name.as_str())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if path.is_empty()
|
||||
&& (dedicated && INTEGRATED_ONLY.contains(&name.as_str())
|
||||
|| !dedicated && DEDICATED_ONLY.contains(&name.as_str()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
path.push(name.clone());
|
||||
let argument = child["type"] == "argument";
|
||||
let index = nodes.len();
|
||||
nodes.push(Node {
|
||||
name: name.clone(),
|
||||
kind: if argument { 2 } else { 1 },
|
||||
executable: child["executable"].as_bool().unwrap_or(false),
|
||||
restricted: required > 0,
|
||||
children: Vec::new(),
|
||||
redirect: redirect(child),
|
||||
parser: argument.then(|| {
|
||||
(
|
||||
child["parser"].as_str().unwrap_or_default().to_owned(),
|
||||
child["properties"].clone(),
|
||||
)
|
||||
}),
|
||||
suggestions: if argument {
|
||||
suggestions.get(path.join(" ").as_str()).copied()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
});
|
||||
nodes[target].children.push(index);
|
||||
paths.insert(path.clone(), index);
|
||||
fill(
|
||||
child,
|
||||
index,
|
||||
path,
|
||||
level,
|
||||
dedicated,
|
||||
suggestions,
|
||||
nodes,
|
||||
paths,
|
||||
);
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// The redirect target's path. The report leaves out redirects to the root
|
||||
/// (`execute ... run`, `return run`): a node that neither runs nor leads
|
||||
/// anywhere forks to the root.
|
||||
fn redirect(node: &Value) -> Option<Vec<String>> {
|
||||
if let Some(path) = node["redirect"].as_array() {
|
||||
return Some(
|
||||
path.iter()
|
||||
.filter_map(|s| s.as_str().map(str::to_owned))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
let dead_end = node["children"].as_object().is_none_or(|c| c.is_empty())
|
||||
&& !node["executable"].as_bool().unwrap_or(false);
|
||||
dead_end.then(Vec::new)
|
||||
}
|
||||
|
||||
/// ArgumentTypeInfo.serializeToNetwork for the report's properties.
|
||||
fn properties(parser: &str, p: &Value) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
let flags = |w: &mut Writer| {
|
||||
w.u8(u8::from(p.get("min").is_some()) | u8::from(p.get("max").is_some()) << 1);
|
||||
};
|
||||
match parser {
|
||||
"brigadier:integer" => {
|
||||
flags(&mut w);
|
||||
for key in ["min", "max"] {
|
||||
if let Some(v) = p[key].as_i64() {
|
||||
w.i32(v as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
"brigadier:long" => {
|
||||
flags(&mut w);
|
||||
for key in ["min", "max"] {
|
||||
if let Some(v) = p[key].as_i64() {
|
||||
w.i64(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
"brigadier:float" => {
|
||||
flags(&mut w);
|
||||
for key in ["min", "max"] {
|
||||
if let Some(v) = p[key].as_f64() {
|
||||
w.f32(v as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
"brigadier:double" => {
|
||||
flags(&mut w);
|
||||
for key in ["min", "max"] {
|
||||
if let Some(v) = p[key].as_f64() {
|
||||
w.f64(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
"brigadier:string" => w.varint(match p["type"].as_str() {
|
||||
Some("phrase") => 1,
|
||||
Some("greedy") => 2,
|
||||
_ => 0,
|
||||
}),
|
||||
"minecraft:entity" => {
|
||||
let single = u8::from(p["amount"] == "single");
|
||||
let players = u8::from(p["type"] == "players");
|
||||
w.u8(single | players << 1);
|
||||
}
|
||||
"minecraft:score_holder" => w.u8(u8::from(p["amount"] == "multiple")),
|
||||
"minecraft:time" => w.i32(p["min"].as_i64().unwrap_or(0) as i32),
|
||||
"minecraft:resource"
|
||||
| "minecraft:resource_key"
|
||||
| "minecraft:resource_or_tag"
|
||||
| "minecraft:resource_or_tag_key"
|
||||
| "minecraft:resource_selector" => w.string(p["registry"].as_str().unwrap_or_default()),
|
||||
_ => {}
|
||||
}
|
||||
w.data
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn integer_bounds_and_entity_flags() {
|
||||
let p: Value = serde_json::from_str(r#"{"min": 1, "max": 1000}"#).unwrap();
|
||||
assert_eq!(
|
||||
properties("brigadier:integer", &p),
|
||||
vec![3, 0, 0, 0, 1, 0, 0, 3, 0xE8]
|
||||
);
|
||||
let p: Value = serde_json::from_str(r#"{"amount": "single", "type": "players"}"#).unwrap();
|
||||
assert_eq!(properties("minecraft:entity", &p), vec![3]);
|
||||
let p: Value = serde_json::from_str(r#"{"type": "greedy"}"#).unwrap();
|
||||
assert_eq!(properties("brigadier:string", &p), vec![2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_filter_and_redirects_resolve() {
|
||||
let report: Value = serde_json::from_str(
|
||||
r#"{"type":"root","children":{
|
||||
"say":{"type":"literal","children":{"message":{"type":"argument","parser":"minecraft:message","executable":true}},
|
||||
"permissions":{"type":"minecraft:require","permission":{"type":"minecraft:command_level","level":"gamemasters"}}},
|
||||
"me":{"type":"literal","children":{"action":{"type":"argument","parser":"minecraft:message","executable":true}}},
|
||||
"stop":{"type":"literal","executable":true},
|
||||
"tell":{"type":"literal","redirect":["me"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let tree = CommandTree { root: report };
|
||||
let builtin = Builtin::default();
|
||||
let guest = tree.packet(0, false, &builtin);
|
||||
let names: Vec<_> = guest.nodes.iter().map(|n| n.name.as_str()).collect();
|
||||
assert_eq!(names, ["", "me", "tell", "action"]);
|
||||
// `tell` redirects to `me` (node 1).
|
||||
assert_eq!(guest.nodes[2].redirect, Some(1));
|
||||
let op = tree.packet(2, true, &builtin);
|
||||
let names: Vec<_> = op.nodes.iter().map(|n| n.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
["", "me", "say", "stop", "tell", "action", "message"]
|
||||
);
|
||||
assert!(op.nodes[2].restricted);
|
||||
}
|
||||
}
|
||||
@@ -64,8 +64,6 @@ pub struct SyncData {
|
||||
registries: Vec<(String, Entries)>,
|
||||
pub tags: UpdateTags,
|
||||
pub features: Vec<String>,
|
||||
/// Vanilla's command tree (the commands report), when found.
|
||||
pub commands: Option<super::commands::CommandTree>,
|
||||
}
|
||||
|
||||
/// Every `*.json` under `dir`, as (path without extension relative to dir, value), sorted.
|
||||
@@ -135,7 +133,6 @@ impl SyncData {
|
||||
registries,
|
||||
tags,
|
||||
features: vec!["minecraft:vanilla".to_owned()],
|
||||
commands: super::commands::CommandTree::load(resources),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
//! Entity tracking (ChunkMap.TrackedEntity / ServerEntity): the game describes
|
||||
//! every entity it simulates as an `EntityState` each tick; a `Tracker`
|
||||
//! remembers what one client has been told and produces the spawn, movement,
|
||||
//! motion, metadata, equipment and removal packets that bring it up to date,
|
||||
//! at the entity type's update interval (`tracking`).
|
||||
use super::tracking;
|
||||
//! Entity tracking (ServerEntity / TrackedEntity): the game describes every
|
||||
//! entity it simulates as an `EntityState` each tick; a `Tracker` remembers
|
||||
//! what one client has been told and produces the spawn, movement, metadata,
|
||||
//! equipment and removal packets that bring it up to date.
|
||||
use horizon_protocol::{
|
||||
buf::pack_degrees,
|
||||
entity_data::Value,
|
||||
item::Stack,
|
||||
packets::play::{
|
||||
self, AddEntity, EntityPositionSync, MoveEntity, MoveEntityPos, MoveEntityPosRot,
|
||||
MoveEntityRot, PositionPath, RotateHead, SetEntityData, SetEntityMotion, SetEquipment,
|
||||
VecDelta,
|
||||
self, AddEntity, MoveEntity, MoveEntityPos, MoveEntityPosRot, MoveEntityRot, RotateHead,
|
||||
SetEntityData, SetEntityMotion, SetEquipment, TeleportEntity, VecDelta,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -33,27 +30,24 @@ pub struct EntityState {
|
||||
/// SynchedEntityData entries that differ from the defaults.
|
||||
pub meta: Vec<(u8, i32, Value)>,
|
||||
/// EquipmentSlot ordinals (0 main hand, 1 off hand, 2 feet, 3 legs,
|
||||
/// 4 chest, 5 head, 6 body, 7 saddle) and their stacks.
|
||||
/// 4 chest, 5 head) and their stacks.
|
||||
pub equipment: Vec<(u8, Option<Stack>)>,
|
||||
/// Whether movement packets should include a velocity (items, projectiles).
|
||||
pub send_velocity: bool,
|
||||
}
|
||||
|
||||
/// What a client was last told about one entity (ServerEntity's fields).
|
||||
/// What a client was last told about one entity.
|
||||
struct Sent {
|
||||
/// VecDeltaCodec's base: the last position sent.
|
||||
base: [f64; 3],
|
||||
/// lastSentYRot / lastSentXRot / lastSentYHeadRot.
|
||||
/// The position in 1/4096 blocks (VecDeltaCodec base).
|
||||
base: [i64; 3],
|
||||
position: [f64; 3],
|
||||
rotation: (i8, i8),
|
||||
head: i8,
|
||||
/// lastSentMovement.
|
||||
velocity: [f64; 3],
|
||||
meta: Vec<(u8, i32, Value)>,
|
||||
equipment: Vec<(u8, Option<Stack>)>,
|
||||
was_on_ground: bool,
|
||||
tick: u32,
|
||||
teleport_delay: u32,
|
||||
/// The type's update interval (0: never) and whether velocity is tracked.
|
||||
interval: u32,
|
||||
track_deltas: bool,
|
||||
on_ground: bool,
|
||||
ticks: u32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -61,16 +55,19 @@ pub struct Tracker {
|
||||
sent: std::collections::HashMap<i32, Sent>,
|
||||
}
|
||||
|
||||
/// VecDeltaCodec.encode.
|
||||
fn encode(v: f64) -> i64 {
|
||||
(v * 4096.0).round() as i64
|
||||
fn quantize(p: [f64; 3]) -> [i64; 3] {
|
||||
[
|
||||
(p[0] * 4096.0).round() as i64,
|
||||
(p[1] * 4096.0).round() as i64,
|
||||
(p[2] * 4096.0).round() as i64,
|
||||
]
|
||||
}
|
||||
|
||||
/// The horizontal range within which a client sees an entity of `kind`
|
||||
/// (TrackedEntity.updatePlayer: the type's range, capped by the view distance).
|
||||
pub fn visible_range(kind: &str, view_distance: i32) -> f64 {
|
||||
let chunks = i32::from(tracking::settings(kind).0);
|
||||
f64::from(chunks.min(view_distance) * 16)
|
||||
fn dequantize(q: [i64; 3]) -> [f64; 3] {
|
||||
[
|
||||
q[0] as f64 / 4096.0,
|
||||
q[1] as f64 / 4096.0,
|
||||
q[2] as f64 / 4096.0,
|
||||
]
|
||||
}
|
||||
|
||||
impl Tracker {
|
||||
@@ -97,7 +94,6 @@ impl Tracker {
|
||||
pub fn spawn(&mut self, e: &EntityState, entity_type: i32) -> Vec<play::Clientbound> {
|
||||
let rotation = (pack_degrees(e.pitch), pack_degrees(e.yaw));
|
||||
let head = pack_degrees(e.head_yaw);
|
||||
let (_, interval, track_deltas) = tracking::settings(&e.kind);
|
||||
let mut out = vec![play::Clientbound::AddEntity(AddEntity {
|
||||
id: e.id,
|
||||
uuid: e.uuid,
|
||||
@@ -124,136 +120,114 @@ impl Tracker {
|
||||
self.sent.insert(
|
||||
e.id,
|
||||
Sent {
|
||||
base: e.position,
|
||||
base: quantize(e.position),
|
||||
position: e.position,
|
||||
rotation,
|
||||
head,
|
||||
velocity: e.velocity,
|
||||
meta: e.meta.clone(),
|
||||
equipment: e.equipment.clone(),
|
||||
was_on_ground: e.on_ground,
|
||||
tick: 0,
|
||||
teleport_delay: 0,
|
||||
interval,
|
||||
track_deltas,
|
||||
on_ground: e.on_ground,
|
||||
ticks: 0,
|
||||
},
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// Changes since the last update (ServerEntity.sendChanges): movement and
|
||||
/// rotation every update interval (positions at least every 60 ticks, a
|
||||
/// full position sync every 400 or when the entity lands or leaves the
|
||||
/// ground), velocity for types that track it, changed data at once.
|
||||
/// Changes since the last update (ServerEntity.sendChanges).
|
||||
pub fn update(&mut self, e: &EntityState) -> Vec<play::Clientbound> {
|
||||
let Some(s) = self.sent.get_mut(&e.id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let dirty = e.meta != s.meta;
|
||||
let due = s.interval != 0 && s.tick.is_multiple_of(s.interval);
|
||||
if due || dirty {
|
||||
let rotation = (pack_degrees(e.pitch), pack_degrees(e.yaw));
|
||||
let turned = |a: i8, b: i8| (i32::from(a) - i32::from(b)).abs() >= 1;
|
||||
let rotate = turned(rotation.1, s.rotation.1) || turned(rotation.0, s.rotation.0);
|
||||
s.teleport_delay += 1;
|
||||
let d: f64 = (0..3).map(|k| (e.position[k] - s.base[k]).powi(2)).sum();
|
||||
let send_position = d >= 7.629_394_5e-6 || s.tick.is_multiple_of(60);
|
||||
let sync = EntityPositionSync {
|
||||
s.ticks += 1;
|
||||
let rotation = (pack_degrees(e.pitch), pack_degrees(e.yaw));
|
||||
let rotated = rotation != s.rotation;
|
||||
let now = quantize(e.position);
|
||||
let delta = [now[0] - s.base[0], now[1] - s.base[1], now[2] - s.base[2]];
|
||||
let moved = delta != [0; 3];
|
||||
let fits = delta.iter().all(|d| i16::try_from(*d).is_ok());
|
||||
if moved && !fits {
|
||||
out.push(play::Clientbound::TeleportEntity(TeleportEntity {
|
||||
id: e.id,
|
||||
path: PositionPath::Linear(e.position),
|
||||
position: e.position,
|
||||
delta: e.velocity,
|
||||
yaw: e.yaw,
|
||||
pitch: e.pitch,
|
||||
relatives: 0,
|
||||
on_ground: e.on_ground,
|
||||
};
|
||||
// ServerEntity.createMovePacket.
|
||||
let packet = if s.teleport_delay > 400 || s.was_on_ground != e.on_ground {
|
||||
s.was_on_ground = e.on_ground;
|
||||
s.teleport_delay = 0;
|
||||
Some(play::Clientbound::EntityPositionSync(sync))
|
||||
} else if send_position {
|
||||
let delta = [0, 1, 2].map(|k| encode(e.position[k]) - encode(s.base[k]));
|
||||
if delta.iter().any(|v| i16::try_from(*v).is_err()) {
|
||||
Some(play::Clientbound::EntityPositionSync(sync))
|
||||
} else {
|
||||
let m = MoveEntity {
|
||||
id: e.id,
|
||||
delta: Some(VecDelta::Linear(delta.map(|v| v as i16))),
|
||||
rotation: rotate.then_some((rotation.1, rotation.0)),
|
||||
on_ground: e.on_ground,
|
||||
};
|
||||
Some(if rotate {
|
||||
play::Clientbound::MoveEntityPosRot(MoveEntityPosRot(m))
|
||||
} else {
|
||||
play::Clientbound::MoveEntityPos(MoveEntityPos(m))
|
||||
})
|
||||
}
|
||||
} else if rotate {
|
||||
Some(play::Clientbound::MoveEntityRot(MoveEntityRot(
|
||||
MoveEntity {
|
||||
id: e.id,
|
||||
delta: None,
|
||||
rotation: Some((rotation.1, rotation.0)),
|
||||
on_ground: e.on_ground,
|
||||
},
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if s.track_deltas {
|
||||
let diff: f64 = (0..3)
|
||||
.map(|k| (e.velocity[k] - s.velocity[k]).powi(2))
|
||||
.sum();
|
||||
let still = e.velocity.iter().all(|v| *v == 0.0);
|
||||
if diff > 1.0e-7 || diff > 0.0 && still {
|
||||
s.velocity = e.velocity;
|
||||
out.push(play::Clientbound::SetEntityMotion(SetEntityMotion {
|
||||
id: e.id,
|
||||
velocity: e.velocity,
|
||||
}));
|
||||
}
|
||||
}
|
||||
if let Some(packet) = packet {
|
||||
let (position, rotated) = match &packet {
|
||||
play::Clientbound::EntityPositionSync(_)
|
||||
| play::Clientbound::MoveEntityPosRot(_) => (true, true),
|
||||
play::Clientbound::MoveEntityPos(_) => (true, false),
|
||||
_ => (false, true),
|
||||
};
|
||||
out.push(packet);
|
||||
if position {
|
||||
s.base = e.position;
|
||||
}
|
||||
if rotated {
|
||||
s.rotation = rotation;
|
||||
}
|
||||
}
|
||||
// ServerEntity.sendDirtyEntityData.
|
||||
if dirty {
|
||||
let changed: Vec<_> = e
|
||||
.meta
|
||||
.iter()
|
||||
.filter(|m| !s.meta.contains(m))
|
||||
.cloned()
|
||||
.collect();
|
||||
if !changed.is_empty() {
|
||||
out.push(play::Clientbound::SetEntityData(SetEntityData {
|
||||
id: e.id,
|
||||
values: changed,
|
||||
}));
|
||||
}
|
||||
s.meta = e.meta.clone();
|
||||
}
|
||||
let head = pack_degrees(e.head_yaw);
|
||||
if turned(head, s.head) {
|
||||
out.push(play::Clientbound::RotateHead(RotateHead {
|
||||
}));
|
||||
s.base = now;
|
||||
} else if moved || rotated || e.on_ground != s.on_ground {
|
||||
let d = VecDelta::Linear([delta[0] as i16, delta[1] as i16, delta[2] as i16]);
|
||||
let rot = Some((rotation.1, rotation.0));
|
||||
let packet = if moved && rotated {
|
||||
play::Clientbound::MoveEntityPosRot(MoveEntityPosRot(MoveEntity {
|
||||
id: e.id,
|
||||
head_yaw: head,
|
||||
}));
|
||||
s.head = head;
|
||||
delta: Some(d),
|
||||
rotation: rot,
|
||||
on_ground: e.on_ground,
|
||||
}))
|
||||
} else if moved {
|
||||
play::Clientbound::MoveEntityPos(MoveEntityPos(MoveEntity {
|
||||
id: e.id,
|
||||
delta: Some(d),
|
||||
rotation: None,
|
||||
on_ground: e.on_ground,
|
||||
}))
|
||||
} else {
|
||||
play::Clientbound::MoveEntityRot(MoveEntityRot(MoveEntity {
|
||||
id: e.id,
|
||||
delta: None,
|
||||
rotation: rot,
|
||||
on_ground: e.on_ground,
|
||||
}))
|
||||
};
|
||||
out.push(packet);
|
||||
if moved {
|
||||
s.base = now;
|
||||
}
|
||||
}
|
||||
s.tick += 1;
|
||||
// LivingEntity.detectEquipmentUpdates: changed slots every tick.
|
||||
if moved || rotated || e.on_ground != s.on_ground {
|
||||
s.rotation = rotation;
|
||||
s.on_ground = e.on_ground;
|
||||
}
|
||||
s.position = dequantize(s.base);
|
||||
let head = pack_degrees(e.head_yaw);
|
||||
if head != s.head {
|
||||
out.push(play::Clientbound::RotateHead(RotateHead {
|
||||
id: e.id,
|
||||
head_yaw: head,
|
||||
}));
|
||||
s.head = head;
|
||||
}
|
||||
if e.send_velocity {
|
||||
let dv: f64 = (0..3)
|
||||
.map(|i| (e.velocity[i] - s.velocity[i]).powi(2))
|
||||
.sum();
|
||||
if dv > 1.0e-7 || (e.velocity == [0.0; 3] && s.velocity != [0.0; 3]) {
|
||||
out.push(play::Clientbound::SetEntityMotion(SetEntityMotion {
|
||||
id: e.id,
|
||||
velocity: e.velocity,
|
||||
}));
|
||||
s.velocity = e.velocity;
|
||||
}
|
||||
}
|
||||
if e.meta != s.meta {
|
||||
let changed: Vec<_> = e
|
||||
.meta
|
||||
.iter()
|
||||
.filter(|m| !s.meta.contains(m))
|
||||
.cloned()
|
||||
.collect();
|
||||
if !changed.is_empty() {
|
||||
out.push(play::Clientbound::SetEntityData(SetEntityData {
|
||||
id: e.id,
|
||||
values: changed,
|
||||
}));
|
||||
}
|
||||
s.meta = e.meta.clone();
|
||||
}
|
||||
if e.equipment != s.equipment {
|
||||
// Cleared slots are sent as empty stacks.
|
||||
let mut slots: Vec<(u8, Option<Stack>)> = e
|
||||
@@ -297,46 +271,20 @@ mod tests {
|
||||
data: 0,
|
||||
meta: Vec::new(),
|
||||
equipment: Vec::new(),
|
||||
send_velocity: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_go_out_every_update_interval() {
|
||||
fn small_moves_are_relative_and_large_ones_teleport() {
|
||||
let mut t = Tracker::default();
|
||||
let spawn = t.spawn(&state(0.0), 100);
|
||||
assert!(matches!(spawn[0], play::Clientbound::AddEntity(_)));
|
||||
// Tick 0 is due and a multiple of 60: the position goes out unchanged.
|
||||
assert!(matches!(
|
||||
t.update(&state(0.0))[..],
|
||||
[play::Clientbound::MoveEntityPos(_)]
|
||||
));
|
||||
// A zombie updates every third tick.
|
||||
assert!(t.update(&state(0.5)).is_empty());
|
||||
assert!(t.update(&state(0.5)).is_empty());
|
||||
assert!(matches!(
|
||||
t.update(&state(0.5))[..],
|
||||
[play::Clientbound::MoveEntityPos(_)]
|
||||
));
|
||||
for _ in 0..2 {
|
||||
t.update(&state(40.0));
|
||||
}
|
||||
assert!(t.update(&state(0.0)).is_empty());
|
||||
let step = t.update(&state(0.5));
|
||||
assert!(matches!(step[0], play::Clientbound::MoveEntityPos(_)));
|
||||
let jump = t.update(&state(40.0));
|
||||
assert!(matches!(jump[0], play::Clientbound::EntityPositionSync(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn landing_sends_a_position_sync() {
|
||||
let mut t = Tracker::default();
|
||||
let mut e = state(0.0);
|
||||
t.spawn(&e, 100);
|
||||
t.update(&e);
|
||||
e.on_ground = false;
|
||||
t.update(&e);
|
||||
t.update(&e);
|
||||
assert!(matches!(
|
||||
t.update(&e)[..],
|
||||
[play::Clientbound::EntityPositionSync(_)]
|
||||
));
|
||||
assert!(matches!(jump[0], play::Clientbound::TeleportEntity(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -344,7 +292,6 @@ mod tests {
|
||||
let mut t = Tracker::default();
|
||||
let mut e = state(0.0);
|
||||
t.spawn(&e, 100);
|
||||
t.update(&e);
|
||||
e.meta.push((0, 0, Value::Byte(1)));
|
||||
e.equipment.push((0, Some(Stack::new(5, 1))));
|
||||
let out = t.update(&e);
|
||||
|
||||
@@ -36,8 +36,6 @@ pub struct Guest {
|
||||
pub head_yaw: f32,
|
||||
pub on_ground: bool,
|
||||
pub game_mode: i32,
|
||||
/// The permission level (0 none, 2 game masters, 4 owners).
|
||||
pub permission: u8,
|
||||
pub sneaking: bool,
|
||||
pub sprinting: bool,
|
||||
pub fall_flying: bool,
|
||||
@@ -112,7 +110,6 @@ impl Guest {
|
||||
head_yaw: 0.0,
|
||||
on_ground: false,
|
||||
game_mode: SURVIVAL,
|
||||
permission: 0,
|
||||
sneaking: false,
|
||||
sprinting: false,
|
||||
fall_flying: false,
|
||||
|
||||
@@ -12,7 +12,6 @@ use super::{
|
||||
guest::{Guest, stack_to_network},
|
||||
};
|
||||
use crate::{items::ItemStack, world::World};
|
||||
use horizon_protocol::ids::play::clientbound as ids;
|
||||
use horizon_protocol::{
|
||||
entity_data::{Value, serializer},
|
||||
packets::play::{self, Clientbound as C, Serverbound as S},
|
||||
@@ -42,8 +41,6 @@ pub struct HubConfig {
|
||||
pub dimension: String,
|
||||
pub seed: i64,
|
||||
pub max_players: usize,
|
||||
/// A dedicated server: operators from `ops`, the dedicated-only commands.
|
||||
pub dedicated: bool,
|
||||
}
|
||||
|
||||
/// The host player as the other players see them.
|
||||
@@ -67,8 +64,7 @@ pub struct HostState {
|
||||
/// Everything the hub needs from the game for one tick.
|
||||
pub struct TickCtx<'a> {
|
||||
pub world: &'a mut World,
|
||||
/// The player playing on the host (none on a dedicated server).
|
||||
pub host: Option<&'a HostState>,
|
||||
pub host: &'a HostState,
|
||||
pub entities: &'a [EntityState],
|
||||
pub next_entity: &'a mut u32,
|
||||
/// Where new players appear.
|
||||
@@ -77,9 +73,6 @@ pub struct TickCtx<'a> {
|
||||
pub difficulty_locked: bool,
|
||||
/// The overworld clock's speed (0 when the day does not advance).
|
||||
pub clock_rate: f32,
|
||||
/// TickRateManager: the tick rate and whether the game is frozen.
|
||||
pub tick_rate: f32,
|
||||
pub tick_frozen: bool,
|
||||
}
|
||||
|
||||
/// What the gameplay code has to do for a guest.
|
||||
@@ -101,13 +94,7 @@ pub struct Hub {
|
||||
pub(super) sync: Arc<SyncData>,
|
||||
pub ticks: u64,
|
||||
last_clock: i64,
|
||||
last_rate: f32,
|
||||
ticking: (f32, bool),
|
||||
weather: (bool, f32, f32),
|
||||
/// ClientboundUpdateRecipesPacket, when the game provides it.
|
||||
pub recipes: Option<Vec<u8>>,
|
||||
/// Operators and their permission levels (dedicated servers).
|
||||
pub ops: std::collections::HashMap<u128, u8>,
|
||||
chat_type: i32,
|
||||
dimension_type: i32,
|
||||
clock_ids: Vec<i32>,
|
||||
@@ -143,10 +130,6 @@ impl Hub {
|
||||
sync,
|
||||
ticks: 0,
|
||||
last_clock: i64::MIN,
|
||||
last_rate: 0.0,
|
||||
ticking: (20.0, false),
|
||||
recipes: None,
|
||||
ops: Default::default(),
|
||||
weather: (false, 0.0, 0.0),
|
||||
chat_type,
|
||||
dimension_type,
|
||||
@@ -163,11 +146,6 @@ impl Hub {
|
||||
self.guests.iter_mut().find(|g| g.conn == conn)
|
||||
}
|
||||
|
||||
/// The network id of a name in a built-in or synchronized registry.
|
||||
pub fn registry_id(&self, registry: &str, name: &str) -> Option<i32> {
|
||||
self.sync.id(registry, name)
|
||||
}
|
||||
|
||||
pub fn item_id(&mut self, item: &Arc<str>) -> i32 {
|
||||
if let Some(id) = self.item_ids.get(item) {
|
||||
return *id;
|
||||
@@ -245,13 +223,13 @@ impl Hub {
|
||||
game_mode: Some(mode),
|
||||
listed: Some(true),
|
||||
latency: Some(latency),
|
||||
display_name: Some(None),
|
||||
display_name: None,
|
||||
list_order: Some(0),
|
||||
show_hat: Some(true),
|
||||
}
|
||||
}
|
||||
/// ClientboundPlayerInfoUpdatePacket.createPlayerInitializing: every action.
|
||||
const INFO_ACTIONS: u8 = 0xFF;
|
||||
/// ClientboundPlayerInfoUpdatePacket.createPlayerInitializing actions.
|
||||
const INFO_ACTIONS: u8 = 1 | 4 | 8 | 16 | 64 | 128;
|
||||
|
||||
// ---- joining -------------------------------------------------------
|
||||
|
||||
@@ -276,7 +254,6 @@ impl Hub {
|
||||
let conn = j.conn;
|
||||
let mut guest = Guest::new(j, entity_id, ctx.spawn, self.config.view_distance);
|
||||
guest.game_mode = self.config.default_game_mode;
|
||||
guest.permission = self.default_permission(guest.uuid);
|
||||
guest.joined_tick = self.ticks;
|
||||
guest.position = ctx.spawn;
|
||||
guest.previous_position = ctx.spawn;
|
||||
@@ -284,41 +261,33 @@ impl Hub {
|
||||
conn
|
||||
}
|
||||
|
||||
/// The permission level a player gets: an integrated server gives every
|
||||
/// guest the game masters' level when commands are allowed
|
||||
/// (IntegratedServer.setGuestCommandAccess); a dedicated server its
|
||||
/// operators' level (`ops`).
|
||||
fn default_permission(&self, uuid: u128) -> u8 {
|
||||
if self.config.dedicated {
|
||||
self.ops.get(&uuid).copied().unwrap_or(0)
|
||||
} else if self.config.guest_commands {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// The levels of a server, in MinecraftServer.levelKeys order.
|
||||
fn level_keys() -> Vec<String> {
|
||||
["overworld", "the_end", "the_nether"]
|
||||
.map(|l| format!("minecraft:{l}"))
|
||||
.to_vec()
|
||||
}
|
||||
|
||||
/// PlayerList.placeNewPlayer for a guest whose saved state is loaded.
|
||||
pub fn place_new_player(&mut self, conn: u32, ctx: &mut TickCtx) {
|
||||
let Some(i) = self.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let (name, uuid, entity_id, mode) = {
|
||||
let (name, uuid, entity_id, mode, view) = {
|
||||
let g = &self.guests[i];
|
||||
(g.name.clone(), g.uuid, g.entity_id, g.game_mode)
|
||||
(
|
||||
g.name.clone(),
|
||||
g.uuid,
|
||||
g.entity_id,
|
||||
g.game_mode,
|
||||
g.view_distance,
|
||||
)
|
||||
};
|
||||
let _ = view;
|
||||
let levels = vec![
|
||||
"minecraft:overworld".to_owned(),
|
||||
"minecraft:the_nether".to_owned(),
|
||||
"minecraft:the_end".to_owned(),
|
||||
];
|
||||
self.guests[i].placed = true;
|
||||
let g = &self.guests[i];
|
||||
g.send(&C::Login(play::Login {
|
||||
player_id: entity_id,
|
||||
hardcore: self.config.hardcore,
|
||||
levels: Self::level_keys(),
|
||||
levels,
|
||||
max_players: self.config.max_players as i32,
|
||||
chunk_radius: self.config.view_distance,
|
||||
simulation_distance: self.config.simulation_distance,
|
||||
@@ -341,38 +310,13 @@ impl Hub {
|
||||
g.send(&C::SetHeldSlot(play::SetHeldSlot {
|
||||
slot: g.selected as i32,
|
||||
}));
|
||||
if let Some(recipes) = &self.recipes {
|
||||
g.sender.send(recipes.clone());
|
||||
}
|
||||
// PlayerList.sendPlayerPermissionLevel, then the recipe book.
|
||||
self.send_permission_level(i);
|
||||
let g = &self.guests[i];
|
||||
g.send(&raw(
|
||||
ids::RECIPE_BOOK_SETTINGS,
|
||||
vec![0; 8], // four books, each closed and unfiltered
|
||||
));
|
||||
g.send(&raw(ids::RECIPE_BOOK_ADD, vec![0, 1])); // no entries, replace
|
||||
// The others hear of the newcomer before it joins the list.
|
||||
self.broadcast_except(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: yellow_translation("multiplayer.player.joined", &[&name]),
|
||||
overlay: false,
|
||||
}),
|
||||
);
|
||||
self.guests[i].placed = true;
|
||||
self.send_commands(i);
|
||||
self.system_message(yellow_translation("multiplayer.player.joined", &[&name]));
|
||||
self.teleport(i, None);
|
||||
let g = &self.guests[i];
|
||||
g.send(&C::ServerData(play::ServerData {
|
||||
motd: literal(&self.lan.shared.motd()),
|
||||
icon: None,
|
||||
}));
|
||||
// The player list: everyone already here, then the newcomer to all.
|
||||
let mut entries = Vec::new();
|
||||
if let Some(host) = ctx.host {
|
||||
entries.push(self.player_info(host.uuid, &host.name, host.game_mode, 0));
|
||||
}
|
||||
for o in self.guests.iter().filter(|o| o.conn != conn && o.placed) {
|
||||
let mut entries =
|
||||
vec![self.player_info(ctx.host.uuid, &ctx.host.name, ctx.host.game_mode, 0)];
|
||||
for o in self.guests.iter().filter(|o| o.conn != conn) {
|
||||
entries.push(self.player_info(
|
||||
o.uuid,
|
||||
&o.name,
|
||||
@@ -380,40 +324,26 @@ impl Hub {
|
||||
o.latency.load(Ordering::Relaxed),
|
||||
));
|
||||
}
|
||||
self.guests[i].send(&C::PlayerInfoUpdate(play::PlayerInfoUpdate {
|
||||
entries.push(self.player_info(uuid, &name, mode, 0));
|
||||
let own = play::PlayerInfoUpdate {
|
||||
actions: Self::INFO_ACTIONS,
|
||||
entries,
|
||||
}));
|
||||
};
|
||||
self.guests[i].send(&C::PlayerInfoUpdate(own));
|
||||
let newcomer = play::PlayerInfoUpdate {
|
||||
actions: Self::INFO_ACTIONS,
|
||||
entries: vec![self.player_info(uuid, &name, mode, 0)],
|
||||
};
|
||||
self.broadcast(&C::PlayerInfoUpdate(newcomer));
|
||||
self.broadcast_except(conn, &C::PlayerInfoUpdate(newcomer));
|
||||
self.send_level_info(i, ctx);
|
||||
// ServerPlayer.sendPostEffects and initInventoryMenu.
|
||||
self.guests[i].send(&raw(ids::POST_EFFECTS, vec![0]));
|
||||
self.send_inventory(i);
|
||||
}
|
||||
|
||||
/// PlayerList.sendPlayerPermissionLevel: the op level as an entity event
|
||||
/// (24 + level), then the commands that level may use.
|
||||
fn send_permission_level(&self, i: usize) {
|
||||
let g = &self.guests[i];
|
||||
g.send(&C::EntityEvent(play::EntityEvent {
|
||||
id: g.entity_id,
|
||||
event: 24 + g.permission.min(4) as i8,
|
||||
}));
|
||||
self.send_commands(i);
|
||||
}
|
||||
|
||||
/// The permissions changed: tell everyone their level and commands again.
|
||||
pub fn resend_commands(&mut self) {
|
||||
/// Send everyone the command tree again (the permissions changed).
|
||||
pub fn resend_commands(&self) {
|
||||
for i in 0..self.guests.len() {
|
||||
if !self.config.dedicated {
|
||||
self.guests[i].permission = self.default_permission(self.guests[i].uuid);
|
||||
}
|
||||
if self.guests[i].placed {
|
||||
self.send_permission_level(i);
|
||||
self.send_commands(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,23 +351,51 @@ impl Hub {
|
||||
/// Commands.sendCommands: the command tree for this player.
|
||||
fn send_commands(&self, i: usize) {
|
||||
let g = &self.guests[i];
|
||||
let packet = match &self.sync.commands {
|
||||
Some(tree) => tree.packet(g.permission, self.config.dedicated, &self.sync.builtin),
|
||||
None => play::Commands {
|
||||
nodes: vec![play::CommandNode {
|
||||
kind: 0,
|
||||
executable: false,
|
||||
// A root with every command taking free-form arguments.
|
||||
let mut nodes = vec![play::CommandNode {
|
||||
kind: 0,
|
||||
executable: false,
|
||||
restricted: false,
|
||||
children: Vec::new(),
|
||||
redirect: None,
|
||||
name: String::new(),
|
||||
parser: None,
|
||||
suggestions: None,
|
||||
}];
|
||||
if self.config.guest_commands {
|
||||
let argument = |name: &str| play::CommandNode {
|
||||
kind: 2,
|
||||
executable: true,
|
||||
restricted: false,
|
||||
children: Vec::new(),
|
||||
redirect: None,
|
||||
name: name.to_owned(),
|
||||
// brigadier:string with the greedy phrase type.
|
||||
parser: self
|
||||
.sync
|
||||
.builtin
|
||||
.get("minecraft:command_argument_type")
|
||||
.and_then(|ids| ids.id("brigadier:string"))
|
||||
.map(|id| (id, vec![2])),
|
||||
suggestions: None,
|
||||
};
|
||||
for name in COMMANDS {
|
||||
nodes.push(play::CommandNode {
|
||||
kind: 1,
|
||||
executable: true,
|
||||
restricted: false,
|
||||
children: Vec::new(),
|
||||
children: vec![nodes.len() as i32 + 1],
|
||||
redirect: None,
|
||||
name: String::new(),
|
||||
name: (*name).to_owned(),
|
||||
parser: None,
|
||||
suggestions: None,
|
||||
}],
|
||||
root: 0,
|
||||
},
|
||||
};
|
||||
g.send(&C::Commands(packet));
|
||||
});
|
||||
nodes.push(argument("args"));
|
||||
let literal_index = nodes.len() as i32 - 2;
|
||||
nodes[0].children.push(literal_index);
|
||||
}
|
||||
}
|
||||
g.send(&C::Commands(play::Commands { nodes, root: 0 }));
|
||||
}
|
||||
|
||||
/// ServerGamePacketListenerImpl.teleport: move the client and wait for its confirmation.
|
||||
@@ -472,8 +430,7 @@ impl Hub {
|
||||
lerp_time: 0,
|
||||
absolute_max_size: 29_999_984,
|
||||
warning_blocks: 5,
|
||||
// WorldBorder.DEFAULT_WARNING_TIME, in ticks.
|
||||
warning_time: 300,
|
||||
warning_time: 15,
|
||||
}));
|
||||
g.send(&self.time_packet(ctx.world, ctx.clock_rate));
|
||||
g.send(&C::SetDefaultSpawnPosition(play::SetDefaultSpawnPosition {
|
||||
@@ -507,14 +464,8 @@ impl Hub {
|
||||
event: game_event::LEVEL_CHUNKS_LOAD_START,
|
||||
param: 0.0,
|
||||
}));
|
||||
// TickRateManager.updateJoiningPlayer.
|
||||
g.send(&ticking_state(ctx.tick_rate, ctx.tick_frozen));
|
||||
g.send(&raw(ids::TICKING_STEP, vec![0]));
|
||||
}
|
||||
|
||||
/// ServerClockManager.createFullSyncPacket: every clock. Horizon keeps the
|
||||
/// overworld clock (the day time); the End's clock runs with the game
|
||||
/// time. Clocks do not advance without the advance_time rule.
|
||||
fn time_packet(&self, world: &World, rate: f32) -> C {
|
||||
let overworld = self
|
||||
.sync
|
||||
@@ -525,22 +476,11 @@ impl Hub {
|
||||
clocks: self
|
||||
.clock_ids
|
||||
.iter()
|
||||
.map(|&id| {
|
||||
if id == overworld {
|
||||
play::ClockState {
|
||||
clock: id,
|
||||
total_ticks: world.day_time,
|
||||
partial_tick: 0.0,
|
||||
rate,
|
||||
}
|
||||
} else {
|
||||
play::ClockState {
|
||||
clock: id,
|
||||
total_ticks: world.game_time,
|
||||
partial_tick: 0.0,
|
||||
rate: if rate > 0.0 { 1.0 } else { 0.0 },
|
||||
}
|
||||
}
|
||||
.map(|&id| play::ClockState {
|
||||
clock: id,
|
||||
total_ticks: if id == overworld { world.day_time } else { 0 },
|
||||
partial_tick: 0.0,
|
||||
rate: if id == overworld { rate } else { 0.0 },
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
@@ -659,40 +599,9 @@ impl Hub {
|
||||
/// Time and weather (ServerLevel.tickTime / advanceWeatherCycle broadcasts).
|
||||
fn sync_world(&mut self, ctx: &TickCtx) {
|
||||
let world = &*ctx.world;
|
||||
// MinecraftServer.tickServer: the game time every 20 ticks
|
||||
// (forceGameTimeSynchronization, no clocks).
|
||||
if self.ticks.is_multiple_of(20) {
|
||||
self.broadcast(&C::SetTime(play::SetTime {
|
||||
game_time: world.game_time,
|
||||
clocks: Vec::new(),
|
||||
}));
|
||||
}
|
||||
// ServerClockManager.modifyClock: a day that jumped (`time set`, `time
|
||||
// add`) or a new rate updates the overworld clock.
|
||||
let (day, rate) = (world.day_time, ctx.clock_rate);
|
||||
let step = i64::from(rate.ceil() as i32).max(1);
|
||||
if self.last_clock != i64::MIN
|
||||
&& ((day - self.last_clock).abs() > step || rate != self.last_rate)
|
||||
{
|
||||
let overworld = self
|
||||
.sync
|
||||
.id("minecraft:world_clock", "minecraft:overworld")
|
||||
.unwrap_or(0);
|
||||
self.broadcast(&C::SetTime(play::SetTime {
|
||||
game_time: world.game_time,
|
||||
clocks: vec![play::ClockState {
|
||||
clock: overworld,
|
||||
total_ticks: day,
|
||||
partial_tick: 0.0,
|
||||
rate,
|
||||
}],
|
||||
}));
|
||||
}
|
||||
self.last_clock = day;
|
||||
self.last_rate = rate;
|
||||
if (ctx.tick_rate, ctx.tick_frozen) != self.ticking {
|
||||
self.ticking = (ctx.tick_rate, ctx.tick_frozen);
|
||||
self.broadcast(&ticking_state(ctx.tick_rate, ctx.tick_frozen));
|
||||
if self.ticks.is_multiple_of(20) || self.last_clock == i64::MIN {
|
||||
self.broadcast(&self.time_packet(world, ctx.clock_rate));
|
||||
self.last_clock = world.day_time;
|
||||
}
|
||||
let w = &world.weather;
|
||||
let (raining, rain, thunder) = self.weather;
|
||||
@@ -955,6 +864,7 @@ impl Hub {
|
||||
data: 0,
|
||||
meta,
|
||||
equipment,
|
||||
send_velocity: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -986,23 +896,22 @@ impl Hub {
|
||||
fn track_entities(&mut self, ctx: &mut TickCtx) {
|
||||
let mut all: Vec<EntityState> =
|
||||
Vec::with_capacity(ctx.entities.len() + self.guests.len() + 1);
|
||||
if let Some(h) = ctx.host {
|
||||
let equipment = {
|
||||
let e = &h.equipment;
|
||||
self.equipment_of(&e[0], &e[1], [&e[5], &e[4], &e[3], &e[2]])
|
||||
};
|
||||
all.push(self.player_state(
|
||||
h.entity_id,
|
||||
h.uuid,
|
||||
h.position,
|
||||
(h.yaw, h.pitch, h.head_yaw),
|
||||
h.on_ground,
|
||||
(h.sneaking, h.sprinting, false, false),
|
||||
0x7F,
|
||||
h.health,
|
||||
equipment,
|
||||
));
|
||||
}
|
||||
let h = ctx.host;
|
||||
let equipment = {
|
||||
let e = &h.equipment;
|
||||
self.equipment_of(&e[0], &e[1], [&e[5], &e[4], &e[3], &e[2]])
|
||||
};
|
||||
all.push(self.player_state(
|
||||
h.entity_id,
|
||||
h.uuid,
|
||||
h.position,
|
||||
(h.yaw, h.pitch, h.head_yaw),
|
||||
h.on_ground,
|
||||
(h.sneaking, h.sprinting, false, false),
|
||||
0x7F,
|
||||
h.health,
|
||||
equipment,
|
||||
));
|
||||
let placed: Vec<usize> = (0..self.guests.len())
|
||||
.filter(|&i| self.guests[i].placed)
|
||||
.collect();
|
||||
@@ -1041,26 +950,20 @@ impl Hub {
|
||||
if !self.guests[gi].placed {
|
||||
continue;
|
||||
}
|
||||
let view = self.guests[gi].view_distance;
|
||||
let range = f64::from(self.guests[gi].view_distance * 16).min(128.0);
|
||||
let me = self.guests[gi].entity_id;
|
||||
let origin = self.guests[gi].position;
|
||||
// TrackedEntity.updatePlayer: within the type's range (capped by the
|
||||
// view distance) horizontally, in a chunk the client has.
|
||||
let visible = |g: &Guest, e: &EntityState| {
|
||||
let range = super::entity::visible_range(&e.kind, view);
|
||||
let (dx, dz) = (origin[0] - e.position[0], origin[2] - e.position[2]);
|
||||
let chunk = (
|
||||
(e.position[0].floor() as i32) >> 4,
|
||||
(e.position[2].floor() as i32) >> 4,
|
||||
);
|
||||
dx * dx + dz * dz <= range * range && g.sent_chunks.contains(&chunk)
|
||||
};
|
||||
// Forget what disappeared or left range.
|
||||
let mut removed = Vec::new();
|
||||
for id in self.guests[gi].tracker.ids() {
|
||||
match by_id.get(&id) {
|
||||
Some(&ei) if visible(&self.guests[gi], &all[ei]) => {}
|
||||
_ => removed.push(id),
|
||||
None => removed.push(id),
|
||||
Some(&ei) => {
|
||||
let e = &all[ei];
|
||||
if !in_range(origin, e.position, range + 8.0) {
|
||||
removed.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !removed.is_empty() {
|
||||
@@ -1076,12 +979,12 @@ impl Hub {
|
||||
if self.guests[gi].tracker.is_tracking(e.id) {
|
||||
let packets = self.guests[gi].tracker.update(e);
|
||||
self.guests[gi].send_all(&packets);
|
||||
} else if visible(&self.guests[gi], e) {
|
||||
} else if in_range(origin, e.position, range) {
|
||||
let Some(kind) = self.entity_type(&e.kind) else {
|
||||
continue;
|
||||
};
|
||||
// Players need their list entry first; guests got it at join.
|
||||
let packets = self.guests[gi].tracker.spawn(e, kind);
|
||||
// Players need their list entry first; guests got it at join.
|
||||
self.guests[gi].send_all(&packets);
|
||||
}
|
||||
}
|
||||
@@ -1206,6 +1109,11 @@ impl Hub {
|
||||
events.push(HostEvent::Chat(format!("<{name}> {text}")));
|
||||
}
|
||||
}
|
||||
S::Unknown(id, ref payload) => {
|
||||
// Packets Horizon does not model: ignore the ones that only
|
||||
// matter to vanilla's chat signing.
|
||||
let _ = (id, payload);
|
||||
}
|
||||
other => events.push(HostEvent::Packet(conn, Box::new(other))),
|
||||
}
|
||||
}
|
||||
@@ -1299,6 +1207,48 @@ impl Hub {
|
||||
}
|
||||
}
|
||||
|
||||
fn in_range(a: [f64; 3], b: [f64; 3], range: f64) -> bool {
|
||||
let (dx, dz) = (a[0] - b[0], a[2] - b[2]);
|
||||
dx * dx + dz * dz <= range * range && (a[1] - b[1]).abs() <= 400.0
|
||||
}
|
||||
|
||||
/// The commands guests may type when the host allows them.
|
||||
const COMMANDS: &[&str] = &[
|
||||
"advancement",
|
||||
"attribute",
|
||||
"clear",
|
||||
"clone",
|
||||
"damage",
|
||||
"data",
|
||||
"difficulty",
|
||||
"effect",
|
||||
"enchant",
|
||||
"experience",
|
||||
"fill",
|
||||
"gamemode",
|
||||
"gamerule",
|
||||
"give",
|
||||
"kill",
|
||||
"me",
|
||||
"msg",
|
||||
"particle",
|
||||
"playsound",
|
||||
"say",
|
||||
"scoreboard",
|
||||
"seed",
|
||||
"setblock",
|
||||
"spawnpoint",
|
||||
"summon",
|
||||
"tag",
|
||||
"teleport",
|
||||
"tell",
|
||||
"time",
|
||||
"title",
|
||||
"tp",
|
||||
"weather",
|
||||
"xp",
|
||||
];
|
||||
|
||||
impl Hub {
|
||||
pub fn item_name(&self, id: i32) -> Option<std::sync::Arc<str>> {
|
||||
self.sync
|
||||
@@ -1334,16 +1284,3 @@ impl Hub {
|
||||
self.send_inventory(i);
|
||||
}
|
||||
}
|
||||
|
||||
/// A packet Horizon sends as raw bytes (no fields worth a type).
|
||||
fn raw(id: i32, payload: Vec<u8>) -> C {
|
||||
C::Unknown(id, payload)
|
||||
}
|
||||
|
||||
/// ClientboundTickingStatePacket: the tick rate and whether the game is frozen.
|
||||
fn ticking_state(rate: f32, frozen: bool) -> C {
|
||||
let mut w = horizon_protocol::buf::Writer::new();
|
||||
w.f32(rate);
|
||||
w.bool(frozen);
|
||||
raw(ids::TICKING_STATE, w.data)
|
||||
}
|
||||
@@ -5,8 +5,6 @@
|
||||
//! ever exchanges decoded packets with these threads through channels, so a
|
||||
//! world that is not open to the network pays nothing.
|
||||
pub mod chunks;
|
||||
pub mod command_suggestions;
|
||||
pub mod commands;
|
||||
pub mod conn;
|
||||
pub mod containers;
|
||||
pub mod data;
|
||||
@@ -16,7 +14,6 @@ pub mod guest;
|
||||
pub mod hub;
|
||||
pub mod menu;
|
||||
pub mod playerdata;
|
||||
pub mod tracking;
|
||||
|
||||
pub use conn::PlayerSender;
|
||||
use horizon_protocol::packets::play;
|
||||
@@ -90,9 +87,6 @@ impl Shared {
|
||||
online.insert(uuid);
|
||||
None
|
||||
}
|
||||
pub fn motd(&self) -> String {
|
||||
self.motd.lock().unwrap().clone()
|
||||
}
|
||||
pub fn release(&self, uuid: u128) {
|
||||
self.online.lock().unwrap().remove(&uuid);
|
||||
}
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
//! Entity tracking settings per entity type (EntityType.Builder
|
||||
//! clientTrackingRange in chunks, updateInterval in ticks with 0 for never,
|
||||
//! trackDeltas). Generated by tools/generate_entity_tracking.py from the
|
||||
//! decompiled EntityTypes.java; do not edit by hand.
|
||||
|
||||
/// (entity type, tracking range in chunks, update interval, track deltas), sorted.
|
||||
pub const TRACKING: &[(&str, u8, u32, bool)] = &[
|
||||
("minecraft:acacia_boat", 10, 3, true),
|
||||
("minecraft:acacia_chest_boat", 10, 3, true),
|
||||
("minecraft:allay", 8, 2, true),
|
||||
("minecraft:area_effect_cloud", 10, 0, true),
|
||||
("minecraft:armadillo", 10, 3, true),
|
||||
("minecraft:armor_stand", 10, 3, true),
|
||||
("minecraft:arrow", 4, 20, true),
|
||||
("minecraft:axolotl", 10, 3, true),
|
||||
("minecraft:bamboo_chest_raft", 10, 3, true),
|
||||
("minecraft:bamboo_raft", 10, 3, true),
|
||||
("minecraft:bat", 5, 3, false),
|
||||
("minecraft:bee", 8, 3, true),
|
||||
("minecraft:birch_boat", 10, 3, true),
|
||||
("minecraft:birch_chest_boat", 10, 3, true),
|
||||
("minecraft:blaze", 8, 3, true),
|
||||
("minecraft:block_display", 10, 1, true),
|
||||
("minecraft:bogged", 8, 3, true),
|
||||
("minecraft:breeze", 10, 3, true),
|
||||
("minecraft:breeze_wind_charge", 4, 10, true),
|
||||
("minecraft:camel", 10, 3, true),
|
||||
("minecraft:camel_husk", 10, 3, true),
|
||||
("minecraft:cat", 8, 3, true),
|
||||
("minecraft:cave_spider", 8, 3, true),
|
||||
("minecraft:cherry_boat", 10, 3, true),
|
||||
("minecraft:cherry_chest_boat", 10, 3, true),
|
||||
("minecraft:chest_minecart", 8, 3, true),
|
||||
("minecraft:chicken", 10, 3, true),
|
||||
("minecraft:cod", 4, 3, true),
|
||||
("minecraft:command_block_minecart", 8, 3, true),
|
||||
("minecraft:copper_golem", 10, 3, true),
|
||||
("minecraft:cow", 10, 3, true),
|
||||
("minecraft:creaking", 8, 3, true),
|
||||
("minecraft:creeper", 8, 3, true),
|
||||
("minecraft:cushion", 10, 0, false),
|
||||
("minecraft:dark_oak_boat", 10, 3, true),
|
||||
("minecraft:dark_oak_chest_boat", 10, 3, true),
|
||||
("minecraft:dolphin", 5, 3, true),
|
||||
("minecraft:donkey", 10, 3, true),
|
||||
("minecraft:dragon_fireball", 4, 10, true),
|
||||
("minecraft:drowned", 8, 3, true),
|
||||
("minecraft:egg", 4, 10, true),
|
||||
("minecraft:elder_guardian", 10, 3, true),
|
||||
("minecraft:end_crystal", 16, 0, false),
|
||||
("minecraft:ender_dragon", 10, 3, true),
|
||||
("minecraft:ender_pearl", 4, 10, true),
|
||||
("minecraft:enderman", 8, 3, true),
|
||||
("minecraft:endermite", 8, 3, true),
|
||||
("minecraft:evoker", 8, 3, true),
|
||||
("minecraft:evoker_fangs", 6, 2, false),
|
||||
("minecraft:experience_bottle", 4, 10, true),
|
||||
("minecraft:experience_orb", 6, 20, true),
|
||||
("minecraft:eye_of_ender", 4, 4, true),
|
||||
("minecraft:falling_block", 10, 20, true),
|
||||
("minecraft:fireball", 4, 10, true),
|
||||
("minecraft:firework_rocket", 4, 10, true),
|
||||
("minecraft:fishing_bobber", 4, 5, true),
|
||||
("minecraft:fox", 8, 3, true),
|
||||
("minecraft:frog", 10, 3, true),
|
||||
("minecraft:furnace_minecart", 8, 3, true),
|
||||
("minecraft:ghast", 10, 3, true),
|
||||
("minecraft:giant", 10, 3, true),
|
||||
("minecraft:glow_item_frame", 10, 0, false),
|
||||
("minecraft:glow_squid", 10, 3, true),
|
||||
("minecraft:goat", 10, 3, true),
|
||||
("minecraft:guardian", 8, 3, true),
|
||||
("minecraft:happy_ghast", 10, 3, true),
|
||||
("minecraft:hoglin", 8, 3, true),
|
||||
("minecraft:hopper_minecart", 8, 3, true),
|
||||
("minecraft:horse", 10, 3, true),
|
||||
("minecraft:husk", 8, 3, true),
|
||||
("minecraft:illusioner", 8, 3, true),
|
||||
("minecraft:interaction", 10, 3, true),
|
||||
("minecraft:iron_golem", 10, 3, true),
|
||||
("minecraft:item", 6, 20, true),
|
||||
("minecraft:item_display", 10, 1, true),
|
||||
("minecraft:item_frame", 10, 0, false),
|
||||
("minecraft:jungle_boat", 10, 3, true),
|
||||
("minecraft:jungle_chest_boat", 10, 3, true),
|
||||
("minecraft:leash_knot", 10, 0, false),
|
||||
("minecraft:lightning_bolt", 16, 0, true),
|
||||
("minecraft:lingering_potion", 4, 10, true),
|
||||
("minecraft:llama", 10, 3, true),
|
||||
("minecraft:llama_spit", 4, 10, false),
|
||||
("minecraft:magma_cube", 8, 3, true),
|
||||
("minecraft:mangrove_boat", 10, 3, true),
|
||||
("minecraft:mangrove_chest_boat", 10, 3, true),
|
||||
("minecraft:mannequin", 32, 2, true),
|
||||
("minecraft:marker", 0, 3, true),
|
||||
("minecraft:minecart", 8, 3, true),
|
||||
("minecraft:mooshroom", 10, 3, true),
|
||||
("minecraft:mule", 8, 3, true),
|
||||
("minecraft:nautilus", 10, 3, true),
|
||||
("minecraft:oak_boat", 10, 3, true),
|
||||
("minecraft:oak_chest_boat", 10, 3, true),
|
||||
("minecraft:ocelot", 10, 3, true),
|
||||
("minecraft:ominous_item_spawner", 8, 3, true),
|
||||
("minecraft:painting", 10, 0, false),
|
||||
("minecraft:pale_oak_boat", 10, 3, true),
|
||||
("minecraft:pale_oak_chest_boat", 10, 3, true),
|
||||
("minecraft:panda", 10, 3, true),
|
||||
("minecraft:parched", 8, 3, true),
|
||||
("minecraft:parrot", 8, 3, true),
|
||||
("minecraft:phantom", 8, 3, true),
|
||||
("minecraft:pig", 10, 3, true),
|
||||
("minecraft:piglin", 8, 3, true),
|
||||
("minecraft:piglin_brute", 8, 3, true),
|
||||
("minecraft:pillager", 8, 3, true),
|
||||
("minecraft:player", 32, 2, false),
|
||||
("minecraft:polar_bear", 10, 3, true),
|
||||
("minecraft:poplar_boat", 10, 3, true),
|
||||
("minecraft:poplar_chest_boat", 10, 3, true),
|
||||
("minecraft:pufferfish", 4, 3, true),
|
||||
("minecraft:rabbit", 8, 3, true),
|
||||
("minecraft:ravager", 10, 3, true),
|
||||
("minecraft:salmon", 4, 3, true),
|
||||
("minecraft:sheep", 10, 3, true),
|
||||
("minecraft:shulker", 10, 3, true),
|
||||
("minecraft:shulker_bullet", 8, 3, true),
|
||||
("minecraft:silverfish", 8, 3, true),
|
||||
("minecraft:skeleton", 8, 3, true),
|
||||
("minecraft:skeleton_horse", 10, 3, true),
|
||||
("minecraft:slime", 10, 3, true),
|
||||
("minecraft:small_fireball", 4, 10, true),
|
||||
("minecraft:sniffer", 10, 3, true),
|
||||
("minecraft:snow_golem", 8, 3, true),
|
||||
("minecraft:snowball", 4, 10, true),
|
||||
("minecraft:spawner_minecart", 8, 3, true),
|
||||
("minecraft:spectral_arrow", 4, 20, true),
|
||||
("minecraft:spider", 8, 3, true),
|
||||
("minecraft:splash_potion", 4, 10, true),
|
||||
("minecraft:spruce_boat", 10, 3, true),
|
||||
("minecraft:spruce_chest_boat", 10, 3, true),
|
||||
("minecraft:squid", 8, 3, true),
|
||||
("minecraft:stray", 8, 3, true),
|
||||
("minecraft:strider", 10, 3, true),
|
||||
("minecraft:sulfur_cube", 10, 3, true),
|
||||
("minecraft:tadpole", 10, 3, true),
|
||||
("minecraft:text_display", 10, 1, true),
|
||||
("minecraft:tnt", 10, 10, true),
|
||||
("minecraft:tnt_minecart", 8, 3, true),
|
||||
("minecraft:trader_llama", 10, 3, true),
|
||||
("minecraft:trident", 4, 20, true),
|
||||
("minecraft:tropical_fish", 4, 3, true),
|
||||
("minecraft:turtle", 10, 3, true),
|
||||
("minecraft:vex", 8, 3, true),
|
||||
("minecraft:villager", 10, 3, true),
|
||||
("minecraft:vindicator", 8, 3, true),
|
||||
("minecraft:wandering_trader", 10, 3, true),
|
||||
("minecraft:warden", 16, 3, true),
|
||||
("minecraft:wind_charge", 4, 10, true),
|
||||
("minecraft:witch", 8, 3, true),
|
||||
("minecraft:wither", 10, 3, false),
|
||||
("minecraft:wither_skeleton", 8, 3, true),
|
||||
("minecraft:wither_skull", 4, 10, true),
|
||||
("minecraft:wolf", 10, 3, true),
|
||||
("minecraft:zoglin", 8, 3, true),
|
||||
("minecraft:zombie", 8, 3, true),
|
||||
("minecraft:zombie_horse", 10, 3, true),
|
||||
("minecraft:zombie_nautilus", 10, 3, true),
|
||||
("minecraft:zombie_villager", 8, 3, true),
|
||||
("minecraft:zombified_piglin", 8, 3, true),
|
||||
];
|
||||
|
||||
/// The settings of one entity type; unknown types get the builder defaults.
|
||||
pub fn settings(kind: &str) -> (u8, u32, bool) {
|
||||
TRACKING
|
||||
.binary_search_by(|row| row.0.cmp(kind))
|
||||
.map_or((5, 3, true), |i| {
|
||||
(TRACKING[i].1, TRACKING[i].2, TRACKING[i].3)
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,6 @@ pub mod advancements;
|
||||
pub mod ambience;
|
||||
pub mod animate_tick;
|
||||
pub mod assets;
|
||||
pub mod atlas_sources;
|
||||
pub mod attribute_updates;
|
||||
pub mod audio;
|
||||
pub mod auto_jump;
|
||||
@@ -16,8 +15,8 @@ pub mod block_entity_render;
|
||||
pub mod block_transform;
|
||||
pub mod blocks;
|
||||
pub mod bossbar;
|
||||
pub mod brewing;
|
||||
pub mod camera;
|
||||
pub mod brewing;
|
||||
pub mod cauldron;
|
||||
pub mod chat;
|
||||
pub mod chunk;
|
||||
@@ -48,7 +47,6 @@ pub mod hud_layout;
|
||||
pub mod hunger;
|
||||
pub mod input;
|
||||
pub mod item_data;
|
||||
pub mod item_render;
|
||||
pub mod items;
|
||||
pub mod java_text;
|
||||
pub mod key_names;
|
||||
@@ -88,8 +86,6 @@ pub mod settings;
|
||||
pub mod shadows;
|
||||
pub mod signs;
|
||||
pub mod snbt;
|
||||
pub mod sound_engine;
|
||||
pub mod sound_events;
|
||||
pub mod sprint;
|
||||
pub mod sprites;
|
||||
pub mod stations;
|
||||
|
||||
@@ -18,7 +18,6 @@ pub mod control;
|
||||
pub mod creaking;
|
||||
pub mod cube;
|
||||
pub mod data;
|
||||
pub mod drops;
|
||||
pub mod enderman;
|
||||
pub mod equine;
|
||||
pub mod farm;
|
||||
@@ -46,8 +45,6 @@ pub mod render;
|
||||
pub mod riders;
|
||||
pub mod shulker;
|
||||
pub mod sniffer;
|
||||
pub mod spawn_rules;
|
||||
pub mod spawn_selection;
|
||||
pub mod spawning;
|
||||
pub mod species;
|
||||
pub mod sulfur_cube;
|
||||
@@ -422,22 +419,6 @@ impl Damage {
|
||||
DamageKind::OnFire | DamageKind::InFire | DamageKind::Lava | DamageKind::Fireball
|
||||
)
|
||||
}
|
||||
/// DamageTypeTags.NO_KNOCKBACK
|
||||
pub fn no_knockback(&self) -> bool {
|
||||
matches!(
|
||||
self.kind,
|
||||
DamageKind::Explosion
|
||||
| DamageKind::Fall
|
||||
| DamageKind::OnFire
|
||||
| DamageKind::InFire
|
||||
| DamageKind::Lava
|
||||
| DamageKind::Drown
|
||||
| DamageKind::Magic
|
||||
| DamageKind::Cactus
|
||||
| DamageKind::Generic
|
||||
| DamageKind::InWall
|
||||
)
|
||||
}
|
||||
pub fn is_projectile(&self) -> bool {
|
||||
matches!(
|
||||
self.kind,
|
||||
@@ -547,9 +528,6 @@ pub struct Extra {
|
||||
pub trap_time: i32,
|
||||
/// Enchantments on the equipment above.
|
||||
pub gear: Option<Box<gear::Gear>>,
|
||||
/// `Mob.dropChances` by `EquipmentSlot` (`drops::SLOTS` order) when set
|
||||
/// explicitly; None uses the defaults of `drops::drop_chance`.
|
||||
pub drop_chances: Option<Box<[f32; 8]>>,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Mob {
|
||||
@@ -611,10 +589,6 @@ pub struct Mob {
|
||||
pub attrs: Attrs,
|
||||
/// The player riding this mob and its input.
|
||||
pub rider: Option<equine::Rider>,
|
||||
/// Events raised outside a tick (hurt and death sounds of damage taken
|
||||
/// from the game), handed over with the next tick's events or drained
|
||||
/// by the game.
|
||||
pub outbox: Vec<MobEvent>,
|
||||
}
|
||||
/// Events a mob tick asks the game to act on.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -925,10 +899,6 @@ pub struct MobWorld<'a> {
|
||||
pub mob_griefing: bool,
|
||||
/// `getCurrentDifficultyAt` near the player.
|
||||
pub difficulty: spawning::LocalDifficulty,
|
||||
/// The End (its dimension type's monster spawn light rules).
|
||||
pub end: bool,
|
||||
/// The world seed (slime chunks).
|
||||
pub world_seed: i64,
|
||||
}
|
||||
impl Mob {
|
||||
pub fn new(id: u32, kind: Arc<MobType>, position: Vec3, yaw: f32) -> Self {
|
||||
@@ -964,8 +934,7 @@ impl Mob {
|
||||
previous_limb_speed: 0.0,
|
||||
swell: 0,
|
||||
previous_swell: 0,
|
||||
// Mob.ambientSoundTime starts at 0.
|
||||
ambient_timer: 0,
|
||||
ambient_timer: -kind.ambient_interval,
|
||||
fall_distance: 0.0,
|
||||
persistent: false,
|
||||
random,
|
||||
@@ -986,7 +955,6 @@ impl Mob {
|
||||
name_visible: false,
|
||||
anim: anim::AnimState::default(),
|
||||
rider: None,
|
||||
outbox: Vec::new(),
|
||||
attrs: Attrs {
|
||||
speed: kind.speed,
|
||||
attack_damage: kind.attack_damage,
|
||||
@@ -995,18 +963,6 @@ impl Mob {
|
||||
},
|
||||
kind,
|
||||
};
|
||||
// setCanPickUpLoot(true) in the Villager, AbstractPiglin, Allay,
|
||||
// Dolphin, Fox and Panda constructors.
|
||||
mob.extra.pick_up_loot = matches!(
|
||||
mob.species(),
|
||||
Species::Villager
|
||||
| Species::Piglin
|
||||
| Species::PiglinBrute
|
||||
| Species::Allay
|
||||
| Species::Dolphin
|
||||
| Species::Fox
|
||||
| Species::Panda
|
||||
);
|
||||
ai::register(&mut mob);
|
||||
mob
|
||||
}
|
||||
@@ -1107,35 +1063,17 @@ impl Mob {
|
||||
self.ai.last_hurt_by_player = 100;
|
||||
}
|
||||
}
|
||||
// dealDefaultKnockback, unless the damage type is tagged no_knockback;
|
||||
// a source without a position knocks in a random direction.
|
||||
if knock && !source.no_knockback() {
|
||||
let (dx, dz) = source.from.map_or((0.0, 0.0), |f| {
|
||||
(f[0] - self.position.x, f[2] - self.position.z)
|
||||
});
|
||||
if knock
|
||||
&& let Some(f) = source.from
|
||||
&& !matches!(source.kind, DamageKind::Explosion)
|
||||
{
|
||||
let (dx, dz) = (f[0] - self.position.x, f[2] - self.position.z);
|
||||
self.knockback(0.4, dx, dz);
|
||||
}
|
||||
ai::after_hurt(self, &source);
|
||||
if self.health <= 0.0 {
|
||||
self.health = 0.0;
|
||||
}
|
||||
if knock {
|
||||
// playHurtSound (Mob: also resets the ambient sound clock) or the
|
||||
// death sound: makeSound draws the voice pitch whenever there is a sound.
|
||||
let dead = !self.alive();
|
||||
let sound = ai::voice_sounds(self)[if dead { 2 } else { 1 }];
|
||||
if !dead {
|
||||
self.ambient_timer = -self.kind.ambient_interval;
|
||||
}
|
||||
if !sound.is_empty() {
|
||||
let pitch = self.voice_pitch();
|
||||
if !self.silent {
|
||||
let at = [self.position.x, self.position.y, self.position.z];
|
||||
self.outbox
|
||||
.push(MobEvent::Sound(sound, at, ai::sound_volume(self), pitch));
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// LivingEntity.knockback(power, xd, zd) towards the negative direction.
|
||||
@@ -1144,7 +1082,7 @@ impl Mob {
|
||||
if power <= 0.0 {
|
||||
return;
|
||||
}
|
||||
while xd * xd + zd * zd < f64::from(1.0e-5_f32) {
|
||||
while xd * xd + zd * zd < 1.0e-5 {
|
||||
xd = (self.random.next_double() - self.random.next_double()) * 0.01;
|
||||
zd = (self.random.next_double() - self.random.next_double()) * 0.01;
|
||||
}
|
||||
@@ -1220,16 +1158,11 @@ impl Mob {
|
||||
let at = [self.position.x, self.eye_y(), self.position.z];
|
||||
events.push(MobEvent::Sound(event, at, 1.0, 1.0));
|
||||
}
|
||||
/// LivingEntity.getVoicePitch.
|
||||
pub fn voice_pitch(&mut self) -> f32 {
|
||||
(self.random.next_float() - self.random.next_float()) * 0.2
|
||||
+ if self.is_baby() { 1.5 } else { 1.0 }
|
||||
}
|
||||
/// Mob.playSound with LivingEntity.getVoicePitch.
|
||||
pub fn voice(&mut self, event: &'static str, events: &mut Vec<MobEvent>) {
|
||||
let pitch = self.voice_pitch();
|
||||
// Entity.playSound: at the entity's feet.
|
||||
let at = [self.position.x, self.position.y, self.position.z];
|
||||
let pitch = (self.random.next_float() - self.random.next_float()) * 0.2
|
||||
+ if self.is_baby() { 1.5 } else { 1.0 };
|
||||
let at = [self.position.x, self.eye_y(), self.position.z];
|
||||
events.push(MobEvent::Sound(event, at, ai::sound_volume(self), pitch));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +47,6 @@ pub struct Ai {
|
||||
/// Swing animation progress (attackAnim) and ticks.
|
||||
pub swing_time: i32,
|
||||
pub swinging: bool,
|
||||
/// Walked distance and the next footstep.
|
||||
pub steps: super::living::Steps,
|
||||
}
|
||||
impl Mob {
|
||||
/// LivingEntity.swing
|
||||
@@ -268,11 +266,6 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
|
||||
if m.removed {
|
||||
return false;
|
||||
}
|
||||
// ServerLevel.tick checks despawning before ticking the entity.
|
||||
if !check_despawn(m, w) {
|
||||
m.removed = true;
|
||||
return false;
|
||||
}
|
||||
m.previous = m.position;
|
||||
m.previous_yaw = m.yaw;
|
||||
m.previous_body_yaw = m.body_yaw;
|
||||
@@ -305,10 +298,16 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
|
||||
if m.alive() {
|
||||
m.body_rotation_tick();
|
||||
}
|
||||
events.append(&mut m.outbox);
|
||||
m.update_walk_animation();
|
||||
m.anim.end_tick(m.ai.swinging);
|
||||
tick_swing(m);
|
||||
if !m.alive() {
|
||||
return true;
|
||||
}
|
||||
if !check_despawn(m, w) {
|
||||
m.removed = true;
|
||||
return false;
|
||||
}
|
||||
m.position.y > -128.0
|
||||
}
|
||||
/// LivingEntity.updateSwingTime (6-tick swing duration).
|
||||
@@ -327,9 +326,6 @@ fn tick_swing(m: &mut Mob) {
|
||||
/// Entity.baseTick + LivingEntity.baseTick + Mob.baseTick.
|
||||
fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
m.fluids = w.fluids(m.bounds(), m.eye_y());
|
||||
if m.fluids.in_water {
|
||||
m.fall_distance = 0.0;
|
||||
}
|
||||
// Fire: water extinguishes, lava ignites, burning hurts every second.
|
||||
if m.fluids.in_water && m.fire_ticks > 0 {
|
||||
m.fire_ticks = 0;
|
||||
@@ -405,8 +401,6 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
if !m.alive() {
|
||||
m.death_time += 1;
|
||||
}
|
||||
// Hurt sounds of the damage above come before the ambient sound.
|
||||
events.append(&mut m.outbox);
|
||||
// Mob.baseTick: ambient sounds.
|
||||
if m.alive() {
|
||||
let roll = m.random.next_int_bound(1000).unwrap_or(999);
|
||||
@@ -420,10 +414,6 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
// Entity.baseTick ends with Leashable.tickLeash.
|
||||
super::leash::tick(m, w, events);
|
||||
}
|
||||
/// `Mob.playAmbientSound` on demand: a spawn egg or a mob bucket just placed `m`.
|
||||
pub fn ambient_sound(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
play_ambient(m, w, events);
|
||||
}
|
||||
fn play_ambient(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
let sound = match m.species() {
|
||||
// Warden.getAmbientSound: by anger, silent while roaring or digging.
|
||||
@@ -685,39 +675,42 @@ fn species_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// Mob.checkDespawn, before the tick (players are the only despawn
|
||||
/// reference). Returns false when the mob is discarded.
|
||||
/// Mob.checkDespawn (players are the only despawn reference).
|
||||
fn check_despawn(m: &mut Mob, w: &MobWorld) -> bool {
|
||||
let creature = m.kind.category == "creature";
|
||||
// Warden.removeWhenFarAway is false; a jockey's chicken despawns like a monster.
|
||||
if m.persistent
|
||||
|| m.species() == Species::Warden
|
||||
|| creature && m.kind.flags.animal && !m.extra.jockey
|
||||
{
|
||||
m.ai.no_action_time = 0;
|
||||
return true;
|
||||
}
|
||||
let Some(p) = &w.player else { return true };
|
||||
let d = m.distance_sqr(p.position);
|
||||
// requiresCustomPersistence: raiders in a raid, a tame nautilus, a sulfur
|
||||
// cube holding a block or let out of a bucket.
|
||||
let persistent = m.persistent
|
||||
// Patrols and raiders (`removeWhenFarAway`, `requiresCustomPersistence`).
|
||||
if super::raider::keeps_alive(m, d)
|
||||
|| super::zombie_villager::keeps_alive(m)
|
||||
|| super::riders::keeps_alive(m)
|
||||
// requiresCustomPersistence: a tame nautilus, a sulfur cube holding a
|
||||
// block or let out of a bucket.
|
||||
|| super::nautilus::is_nautilus(m.species()) && m.extra.tame
|
||||
|| m.species() == Species::SulfurCube
|
||||
&& (super::sulfur_cube::has_body(m) || m.extra.scratch[3] != 0);
|
||||
// removeWhenFarAway: animals, wardens, patrols and raiders, zombie
|
||||
// villagers being cured and ridden mobs stay.
|
||||
let creature = m.kind.category == "creature";
|
||||
let removable = !(m.species() == Species::Warden
|
||||
|| creature && m.kind.flags.animal && !m.extra.jockey
|
||||
|| super::raider::keeps_alive(m, d)
|
||||
|| super::zombie_villager::keeps_alive(m)
|
||||
|| super::riders::keeps_alive(m));
|
||||
// MobCategory.getDespawnDistance / getNoDespawnDistance
|
||||
&& (super::sulfur_cube::has_body(m) || m.extra.scratch[3] != 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let despawn = if m.kind.category == "water_ambient" {
|
||||
64.0
|
||||
} else {
|
||||
128.0
|
||||
};
|
||||
if !persistent && d > despawn * despawn && removable {
|
||||
if d > despawn * despawn {
|
||||
return false;
|
||||
}
|
||||
if !persistent
|
||||
&& m.ai.no_action_time > 600
|
||||
if m.ai.no_action_time > 600
|
||||
&& m.random.next_int_bound(800).unwrap_or(1) == 0
|
||||
&& d > 32.0 * 32.0
|
||||
&& removable
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -759,15 +752,17 @@ pub fn sound_volume(m: &Mob) -> f32 {
|
||||
Species::Slime | Species::MagmaCube | Species::SulfurCube => {
|
||||
0.4 * m.extra.size.max(1) as f32
|
||||
}
|
||||
Species::Bat => 0.1,
|
||||
Species::Squid
|
||||
| Species::GlowSquid
|
||||
| Species::Cow
|
||||
| Species::Mooshroom
|
||||
| Species::Bee
|
||||
| Species::Allay
|
||||
| Species::Wolf => 0.4,
|
||||
s if super::equine::is_equine(s) => 0.8,
|
||||
Species::Cod
|
||||
| Species::Salmon
|
||||
| Species::TropicalFish
|
||||
| Species::Pufferfish
|
||||
| Species::Bat => 0.1,
|
||||
Species::Squid | Species::GlowSquid => 0.4,
|
||||
Species::Horse
|
||||
| Species::Donkey
|
||||
| Species::Mule
|
||||
| Species::Llama
|
||||
| Species::TraderLlama => 0.8,
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
@@ -977,127 +972,3 @@ fn tick_effects(m: &mut Mob) {
|
||||
effects.retain(|e| e.1 > 0);
|
||||
m.extra.effects = effects;
|
||||
}
|
||||
|
||||
/// `Entity.getMovementEmission` is not NONE (silverfish, endermites,
|
||||
/// shulkers, guardians and breezes emit only events, no step sounds).
|
||||
pub fn emits_movement(m: &Mob) -> bool {
|
||||
!matches!(m.species(), Species::Shulker)
|
||||
}
|
||||
/// The movement emission includes sounds.
|
||||
pub fn movement_sounds(m: &Mob) -> bool {
|
||||
!matches!(
|
||||
m.species(),
|
||||
Species::Silverfish
|
||||
| Species::Endermite
|
||||
| Species::Guardian
|
||||
| Species::ElderGuardian
|
||||
| Species::Breeze
|
||||
)
|
||||
}
|
||||
/// `playStepSound`: the species' footstep, or the block's step sound at 15%.
|
||||
pub fn step_sound(
|
||||
m: &Mob,
|
||||
w: &MobWorld,
|
||||
pos: [i32; 3],
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
) -> Option<(&'static str, f32, f32)> {
|
||||
use Species as S;
|
||||
let baby = m.is_baby();
|
||||
let quiet = |s: &'static str| Some((s, 0.15, 1.0));
|
||||
match m.species() {
|
||||
// Silent footsteps, or only movement events.
|
||||
S::Allay
|
||||
| S::Bee
|
||||
| S::Cod
|
||||
| S::Salmon
|
||||
| S::TropicalFish
|
||||
| S::Pufferfish
|
||||
| S::Tadpole
|
||||
| S::HappyGhast
|
||||
| S::Nautilus
|
||||
| S::ZombieNautilus
|
||||
| S::Silverfish
|
||||
| S::Endermite
|
||||
| S::Guardian
|
||||
| S::ElderGuardian
|
||||
| S::Breeze => None,
|
||||
S::Armadillo => quiet("entity.armadillo.step"),
|
||||
S::Chicken if baby => quiet("entity.baby_chicken.step"),
|
||||
S::Chicken => quiet("entity.chicken.step"),
|
||||
S::Cow | S::Mooshroom => quiet("entity.cow.step"),
|
||||
S::Pig if baby => quiet("entity.baby_pig.step"),
|
||||
S::Pig => quiet("entity.pig.step"),
|
||||
S::Wolf => quiet("entity.wolf.step"),
|
||||
S::Llama | S::TraderLlama => quiet("entity.llama.step"),
|
||||
S::Frog => quiet("entity.frog.step"),
|
||||
S::Goat => quiet("entity.goat.step"),
|
||||
S::Panda => quiet("entity.panda.step"),
|
||||
S::Parrot => quiet("entity.parrot.step"),
|
||||
S::PolarBear => quiet("entity.polar_bear.step"),
|
||||
S::Sheep => quiet("entity.sheep.step"),
|
||||
S::Sniffer => quiet("entity.sniffer.step"),
|
||||
S::Turtle if baby => quiet("entity.turtle.shamble_baby"),
|
||||
S::Turtle => quiet("entity.turtle.shamble"),
|
||||
S::Creaking => quiet("entity.creaking.step"),
|
||||
S::Hoglin => quiet("entity.hoglin.step"),
|
||||
S::Zoglin => quiet("entity.zoglin.step"),
|
||||
S::Piglin => quiet("entity.piglin.step"),
|
||||
S::PiglinBrute => quiet("entity.piglin_brute.step"),
|
||||
S::Ravager => quiet("entity.ravager.step"),
|
||||
S::Skeleton => quiet("entity.skeleton.step"),
|
||||
S::Stray => quiet("entity.stray.step"),
|
||||
S::Bogged => quiet("entity.bogged.step"),
|
||||
S::Parched => quiet("entity.parched.step"),
|
||||
S::WitherSkeleton => quiet("entity.wither_skeleton.step"),
|
||||
S::Spider | S::CaveSpider => quiet("entity.spider.step"),
|
||||
S::Zombie | S::ZombifiedPiglin => quiet("entity.zombie.step"),
|
||||
S::Husk => quiet("entity.husk.step"),
|
||||
S::Drowned => quiet("entity.drowned.step"),
|
||||
S::ZombieVillager => quiet("entity.zombie_villager.step"),
|
||||
S::CamelHusk => Some(("entity.camel_husk.step", 0.4, 1.0)),
|
||||
S::Camel => Some(("entity.camel.step", 1.0, 1.0)),
|
||||
S::IronGolem => Some(("entity.iron_golem.step", 1.0, 1.0)),
|
||||
S::Strider if m.fluids.in_lava => Some(("entity.strider.step_lava", 1.0, 1.0)),
|
||||
S::Strider => Some(("entity.strider.step", 1.0, 1.0)),
|
||||
S::Warden => Some(("entity.warden.step", 10.0, 1.0)),
|
||||
_ => {
|
||||
// Entity.playStepSound: the block's sound type.
|
||||
let _ = pos;
|
||||
let sounds = &w.facts.get(state).sounds;
|
||||
let step: &'static str = super::ai::intern_sound(&sounds.step);
|
||||
Some((step, sounds.volume * 0.15, sounds.pitch))
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A block sound id as a static string (the block facts intern them).
|
||||
pub fn intern_sound(s: &std::sync::Arc<str>) -> &'static str {
|
||||
use std::{collections::HashSet, sync::Mutex};
|
||||
static NAMES: Mutex<Option<HashSet<&'static str>>> = Mutex::new(None);
|
||||
let mut names = NAMES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let set = names.get_or_insert_with(HashSet::new);
|
||||
let key = s.strip_prefix("minecraft:").unwrap_or(s);
|
||||
if let Some(found) = set.get(key) {
|
||||
return found;
|
||||
}
|
||||
let leaked: &'static str = Box::leak(key.to_owned().into_boxed_str());
|
||||
set.insert(leaked);
|
||||
leaked
|
||||
}
|
||||
/// `getSwimSound`.
|
||||
pub fn swim_sound(m: &Mob) -> &'static str {
|
||||
match m.species() {
|
||||
Species::Drowned => "entity.drowned.swim",
|
||||
Species::Axolotl => "entity.axolotl.swim",
|
||||
Species::Dolphin => "entity.dolphin.swim",
|
||||
Species::Cod
|
||||
| Species::Salmon
|
||||
| Species::TropicalFish
|
||||
| Species::Pufferfish
|
||||
| Species::Tadpole => "entity.fish.swim",
|
||||
Species::Nautilus if m.is_baby() => "entity.baby_nautilus.swim",
|
||||
Species::Nautilus => "entity.nautilus.swim",
|
||||
Species::ZombieNautilus => "entity.zombie_nautilus.swim",
|
||||
Species::Turtle => "entity.turtle.swim",
|
||||
_ => "entity.generic.swim",
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,6 @@ impl AnimState {
|
||||
pub fn end_tick(&mut self, _swinging: bool) {}
|
||||
}
|
||||
/// Part poses for one draw, keyed by part name.
|
||||
#[derive(Clone)]
|
||||
pub struct Poses {
|
||||
map: HashMap<String, [f32; 9]>,
|
||||
dummy: [f32; 9],
|
||||
|
||||
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user