Compare commits

..
Author SHA1 Message Date
Ludovic Marie 84a7405bb5 Merge branch 'parity-base' into parity/input 2026-10-10 22:21:42 +02:00
182 changed files with 4151 additions and 24350 deletions

No files matched your search

-5
View File
@@ -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
+4 -4
View File
@@ -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(),
};
+12 -60
View File
@@ -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]
+10 -49
View File
@@ -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: &current,
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();
+1 -4
View File
@@ -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(&region, &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(())
}
+254 -266
View File
@@ -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));
}
}
-464
View File
@@ -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
View File
@@ -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))
}
}
+3 -11
View File
@@ -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]
+26 -20
View File
@@ -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,
});
}
}
}
+11 -18
View File
@@ -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);
-105
View File
@@ -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)
}
}
+23 -63
View File
@@ -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")
};
+6 -3
View File
@@ -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() {
+7 -3
View File
@@ -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)
})
}
+13 -23
View File
@@ -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> {
+15 -53
View File
@@ -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));
+3 -7
View File
@@ -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 &reg.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();
+6 -6
View File
@@ -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)
}
+15 -57
View File
@@ -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.
+5 -24
View File
@@ -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",
+2 -10
View File
@@ -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);
+2 -10
View File
@@ -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 {
+1 -9
View File
@@ -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()));
+25 -55
View File
@@ -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(
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,
})
}
+159
View File
@@ -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") {
+17 -96
View File
@@ -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;
+7 -5
View File
@@ -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) => {
+1 -1
View File
@@ -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(),
+417 -183
View File
@@ -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
+4 -9
View File
@@ -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)
+88 -25
View File
@@ -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);
+18 -62
View File
@@ -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
-373
View File
@@ -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));
}
}
+20 -7
View File
@@ -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);
}
}
}
+60 -226
View File
@@ -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 {
+7 -3
View File
@@ -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.
+1 -6
View File
@@ -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;
+3 -29
View File
@@ -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 => {}
}
}
+51 -70
View File
@@ -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;
}
}
}
}
}
}
+2 -2
View File
@@ -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;
+42 -105
View File
@@ -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);
}
}
+2 -4
View File
@@ -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"
+11 -38
View File
@@ -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);
}
+7 -13
View File
@@ -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).
-21
View File
@@ -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> {
+15
View File
@@ -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(
+5 -10
View File
@@ -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;
}
}
-8
View File
@@ -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;
}
+129
View File
@@ -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
+80 -1
View File
@@ -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()
}
}
+1 -5
View File
@@ -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,
);
+16 -11
View File
@@ -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(());
}
}
-53
View File
@@ -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 {
-815
View File
@@ -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"
);
}
}
+10 -58
View File
@@ -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"),
];
-352
View File
@@ -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);
}
}
-3
View File
@@ -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),
})
}
+119 -172
View File
@@ -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);
-3
View File
@@ -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,
+157 -220
View File
@@ -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)
}
-6
View File
@@ -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);
}
-178
View File
@@ -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)
})
}
+1 -5
View File
@@ -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;
+10 -77
View File
@@ -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));
}
}
+40 -169
View File
@@ -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",
}
}
-1
View File
@@ -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