mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
237
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
089a7c1242 | ||
|
|
6cfb512e1e | ||
|
|
6f6042da38 | ||
|
|
345a66c710 | ||
|
|
2cc12be695 | ||
|
|
8126b95fb6 | ||
|
|
a36ce4ef76 | ||
|
|
d53d7bf02a | ||
|
|
e42f12db85 | ||
|
|
988f7d1ade | ||
|
|
8e3cade985 | ||
|
|
8ca4e1d985 | ||
|
|
b5c12d0f76 | ||
|
|
2251cfb716 | ||
|
|
e5b2d2ea71 | ||
|
|
c1a7b0d35e | ||
|
|
b502fe41ac | ||
|
|
a8c5f833e4 | ||
|
|
8ca4e05005 | ||
|
|
adcc59b920 | ||
|
|
f42833e146 | ||
|
|
1bd640668a | ||
|
|
cc5f7ff239 | ||
|
|
f7a5a3809f | ||
|
|
1faeccc439 | ||
|
|
57512281ba | ||
|
|
ebafaf775a | ||
|
|
2f890f7df9 | ||
|
|
62a7855892 | ||
|
|
cc3714e6ad | ||
|
|
d08bf63777 | ||
|
|
92dbf8406e | ||
|
|
77ac54a216 | ||
|
|
8815f97746 | ||
|
|
7e74759055 | ||
|
|
6c0d2781e6 | ||
|
|
45a678e309 | ||
|
|
efaa2a0e3f | ||
|
|
ed5ac7df1f | ||
|
|
3e92f5e6fd | ||
|
|
a0e3c383a2 | ||
|
|
1c9379c29e | ||
|
|
46e88c47b1 | ||
|
|
8671d460bf | ||
|
|
9f282e42ea | ||
|
|
7b0e4fee81 | ||
|
|
c76e42d88d | ||
|
|
fe8b0c622f | ||
|
|
c8137fbec9 | ||
|
|
a4d6ba2781 | ||
|
|
84cc6b5d64 | ||
|
|
5258de5549 | ||
|
|
c939630a79 | ||
|
|
932d351e11 | ||
|
|
8504efabb8 | ||
|
|
63db515796 | ||
|
|
d629a4f6f6 | ||
|
|
77f2a09212 | ||
|
|
aa1a7c0c42 | ||
|
|
36e6c2dc3f | ||
|
|
aca06a277b | ||
|
|
fedc1c7313 | ||
|
|
df6139520e | ||
|
|
1a57f3660d | ||
|
|
1e030fd397 | ||
|
|
4c1c81539f | ||
|
|
bf6bb5d470 | ||
|
|
f4cc8528ae | ||
|
|
c12ad4d2cf | ||
|
|
b8dccdaa55 | ||
|
|
50ac3abcda | ||
|
|
f4eb3234a8 | ||
|
|
a02fdabe6f | ||
|
|
d16b78e715 | ||
|
|
e5cc020034 | ||
|
|
799fcccba4 | ||
|
|
54feca80d1 | ||
|
|
c1f8fee513 | ||
|
|
8ca476e489 | ||
|
|
efabb913d0 | ||
|
|
bed239a2cd | ||
|
|
63ddd76a15 | ||
|
|
208f1c010f | ||
|
|
935e3927ab | ||
|
|
412594e68d | ||
|
|
63d25cbd63 | ||
|
|
43aba8dbe5 | ||
|
|
9ee9bb4d0e | ||
|
|
4d5f0a965f | ||
|
|
585e28d1fb | ||
|
|
966a63278e | ||
|
|
cf8a5a51b4 | ||
|
|
22462182e3 | ||
|
|
c8c4a29367 | ||
|
|
f24cc569db | ||
|
|
c3140e1bae | ||
|
|
061648308e | ||
|
|
b6e947c9a4 | ||
|
|
8b33877963 | ||
|
|
3b56859e04 | ||
|
|
a4586852b1 | ||
|
|
f2a63474b9 | ||
|
|
24224f4728 | ||
|
|
4e580b2732 | ||
|
|
0a14bac24c | ||
|
|
4eb7a27e58 | ||
|
|
8be5c59fce | ||
|
|
3aaff8da93 | ||
|
|
a8107ee025 | ||
|
|
625f6ca1a5 | ||
|
|
d12a260a53 | ||
|
|
0d70c87082 | ||
|
|
b32520088e | ||
|
|
460ecfce99 | ||
|
|
58d5f3b519 | ||
|
|
d3678c9112 | ||
|
|
7cc9057fa6 | ||
|
|
8dd47b5ef2 | ||
|
|
97d414cc4d | ||
|
|
3f11bff706 | ||
|
|
27b110085e | ||
|
|
5bb149a282 | ||
|
|
4b3116c54f | ||
|
|
1d13a300fa | ||
|
|
54642a5e80 | ||
|
|
5905a4977f | ||
|
|
98d37c0887 | ||
|
|
be24d83d97 | ||
|
|
561f797c04 | ||
|
|
cb65d4f80d | ||
|
|
04116c33c6 | ||
|
|
9734a8a255 | ||
|
|
cd7d6a3dcc | ||
|
|
b73ef3faa3 | ||
|
|
868e494b0c | ||
|
|
576c5942f1 | ||
|
|
575fadbeeb | ||
|
|
6df80522aa | ||
|
|
f16012e26c | ||
|
|
5a92cfb6b1 | ||
|
|
22807a3f3a | ||
|
|
b27965b4ca | ||
|
|
9c5982d8e0 | ||
|
|
1665d177c6 | ||
|
|
36dac313c8 | ||
|
|
9fb4fde63b | ||
|
|
d2cd07f1ac | ||
|
|
dec4b523f3 | ||
|
|
3236b57773 | ||
|
|
c572bb7f88 | ||
|
|
9329e154cb | ||
|
|
72a1b13eae | ||
|
|
2ea59c309b | ||
|
|
cf92894885 | ||
|
|
0e05b77d95 | ||
|
|
4fea4cfd63 | ||
|
|
61e96ef269 | ||
|
|
9ee63c0510 | ||
|
|
9794dd7ca9 | ||
|
|
5d76ff0225 | ||
|
|
caa5c31dd0 | ||
|
|
4465421885 | ||
|
|
1d38cdeba7 | ||
|
|
249341e7cc | ||
|
|
85c277b5e2 | ||
|
|
309ddb48ed | ||
|
|
87a0ccc90c | ||
|
|
1e36a5da1f | ||
|
|
6ad03dd306 | ||
|
|
837ec11a1a | ||
|
|
a26ffbb007 | ||
|
|
eb6d139e34 | ||
|
|
fc6f53a337 | ||
|
|
16d11c6bb2 | ||
|
|
b248489d15 | ||
|
|
6165c33ebe | ||
|
|
404cf600c1 | ||
|
|
b66525f609 | ||
|
|
1b8b51978d | ||
|
|
dcffd6cae9 | ||
|
|
694d953496 | ||
|
|
3f782b89ee | ||
|
|
0e33837c78 | ||
|
|
53aad00102 | ||
|
|
3c794c2825 | ||
|
|
29662d963c | ||
|
|
c69d03f96e | ||
|
|
de4029c1df | ||
|
|
62a47df275 | ||
|
|
232b0e85aa | ||
|
|
05bc471106 | ||
|
|
45af2b23b2 | ||
|
|
de7d92e134 | ||
|
|
8c6933bda4 | ||
|
|
4ea8baade5 | ||
|
|
15047ffac4 | ||
|
|
b20ab60d01 | ||
|
|
c0e2f62b35 | ||
|
|
817c37749f | ||
|
|
4f49676a37 | ||
|
|
24987d6660 | ||
|
|
391ce2975d | ||
|
|
ce92f7e45a | ||
|
|
a7f6ecbdfb | ||
|
|
31c1bd1f8b | ||
|
|
7e5063a4d5 | ||
|
|
185908fd38 | ||
|
|
dd0bffd667 | ||
|
|
c8f1559eb0 | ||
|
|
1f031f6bd3 | ||
|
|
2e7afbfe64 | ||
|
|
5a3dc52e1f | ||
|
|
bb4d24e909 | ||
|
|
513312eb8b | ||
|
|
6c44743c21 | ||
|
|
7cead764e8 | ||
|
|
ef05a384d5 | ||
|
|
d4a2f07032 | ||
|
|
0801940eeb | ||
|
|
5cede4ff4e | ||
|
|
574b20a662 | ||
|
|
2a767017b9 | ||
|
|
a8ea55f39a | ||
|
|
9fcd6133ed | ||
|
|
812702d809 | ||
|
|
bfc26f944a | ||
|
|
7658a24435 | ||
|
|
394db9011a | ||
|
|
b1896906d1 | ||
|
|
debd86ea59 | ||
|
|
f9a93d080a | ||
|
|
caeaa41bdc | ||
|
|
15f071ff3b | ||
|
|
4985864055 | ||
|
|
9d9617fe29 | ||
|
|
d9d4d19a1d | ||
|
|
2f9c963068 |
No files matched your search
@@ -394,7 +394,7 @@ pub fn apply_deprecated(map: &mut HashMap<String, String>, json: &[u8]) {
|
||||
/// characters in strings, `=` or `=>` and `;`), which vanilla reads lang files with (expected
|
||||
/// from `Language.loadFromJson`, Gson). A file that is not JSON even so adds nothing; a value
|
||||
/// that is not a string, number or boolean (`GsonHelper.convertToString`) ends the file there.
|
||||
fn read_lang_file(bytes: &[u8], map: &mut HashMap<String, String>) {
|
||||
pub fn read_lang_file(bytes: &[u8], map: &mut HashMap<String, String>) {
|
||||
use horizon_world::datafix::json::{Json, parse_lenient};
|
||||
if let Ok(entries) = serde_json::from_slice::<HashMap<String, String>>(bytes) {
|
||||
map.extend(entries);
|
||||
|
||||
@@ -81,7 +81,7 @@ fn elements(tag: &Tag) -> Option<Vec<Tag>> {
|
||||
}
|
||||
/// `DoubleTag.intValue` / `FloatTag.intValue` floor before the i32 conversion;
|
||||
/// integral tags use their intValue narrowing. Byte arrays then keep the low byte.
|
||||
fn int_value(t: &Tag) -> Option<i32> {
|
||||
pub fn int_value(t: &Tag) -> Option<i32> {
|
||||
Some(match t {
|
||||
Tag::Byte(v) => i32::from(*v),
|
||||
Tag::Short(v) => i32::from(*v),
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//! 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 OpenAL Soft's stereo panning.
|
||||
//! (streams on a worker thread) and OpenAL Soft's mixing: mono sources panned
|
||||
//! and attenuated, multi-channel buffers (stereo music and records) played
|
||||
//! direct as OpenAL does.
|
||||
use crate::Result;
|
||||
use crate::sound_engine::{
|
||||
Attenuation, Backend, Bound, ChannelId, InstanceId, PlayResult, SoundEngine, SoundInstance,
|
||||
@@ -29,7 +31,7 @@ enum State {
|
||||
}
|
||||
/// An OpenAL source.
|
||||
struct Channel {
|
||||
samples: Option<(Arc<Vec<f32>>, u32)>,
|
||||
samples: Option<(Arc<Vec<f32>>, u32, u16)>,
|
||||
position: f64,
|
||||
state: State,
|
||||
gain: f32,
|
||||
@@ -135,8 +137,8 @@ impl Drop for MixerBackend {
|
||||
static SHARED_CHANNELS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
/// Two players' copies of one sound: started within this of each other.
|
||||
const SAME_SOUND: std::time::Duration = std::time::Duration::from_millis(150);
|
||||
/// Decoded samples and their rate (`None`: the file did not load).
|
||||
type Samples = Option<(Arc<Vec<f32>>, u32)>;
|
||||
/// Decoded interleaved samples, their rate and channel count (`None`: the file did not load).
|
||||
type Samples = Option<(Arc<Vec<f32>>, u32, u16)>;
|
||||
/// Decoded static sounds (`SoundBufferLibrary`'s buffers), dropped least
|
||||
/// recently used first past `CACHE_BYTES`: kept forever, every sound heard in
|
||||
/// a session stayed (up to 1.4 GiB of samples, villages playing hundreds).
|
||||
@@ -177,7 +179,7 @@ impl SoundCache {
|
||||
fn size(samples: &Samples) -> usize {
|
||||
samples
|
||||
.as_ref()
|
||||
.map_or(0, |(s, _)| s.len() * std::mem::size_of::<f32>())
|
||||
.map_or(0, |(s, _, _)| s.len() * std::mem::size_of::<f32>())
|
||||
}
|
||||
}
|
||||
/// A static sound to decode for a channel waiting on it.
|
||||
@@ -260,7 +262,7 @@ pub struct Audio {
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: u32,
|
||||
}
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32, u16)> {
|
||||
use symphonia::core::{
|
||||
audio::SampleBuffer, codecs::DecoderOptions, formats::FormatOptions, io::MediaSourceStream,
|
||||
meta::MetadataOptions, probe::Hint,
|
||||
@@ -284,7 +286,8 @@ fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.ok()?;
|
||||
let mut mono = Vec::new();
|
||||
let mut samples = Vec::new();
|
||||
let mut channels = 0u16;
|
||||
while let Ok(packet) = format.next_packet() {
|
||||
if packet.track_id() != id {
|
||||
continue;
|
||||
@@ -293,14 +296,34 @@ fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
continue;
|
||||
};
|
||||
let spec = *buffer.spec();
|
||||
let channels = spec.channels.count().max(1);
|
||||
let mut samples = SampleBuffer::<f32>::new(buffer.capacity() as u64, spec);
|
||||
samples.copy_interleaved_ref(buffer);
|
||||
for frame in samples.samples().chunks(channels) {
|
||||
mono.push(frame.iter().sum::<f32>() / channels as f32);
|
||||
let frame_channels = spec.channels.count().max(1);
|
||||
let mut decoded = SampleBuffer::<f32>::new(buffer.capacity() as u64, spec);
|
||||
decoded.copy_interleaved_ref(buffer);
|
||||
if channels == 0 {
|
||||
channels = frame_channels.min(2) as u16;
|
||||
}
|
||||
if frame_channels <= 2 {
|
||||
samples.extend_from_slice(decoded.samples());
|
||||
} else {
|
||||
// OpenAL plays multi-channel buffers direct; a stereo output hears the odd/even split.
|
||||
for frame in decoded.samples().chunks(frame_channels) {
|
||||
let (mut left, mut right) = (0.0, 0.0);
|
||||
let (mut lefts, mut rights) = (0.0f32, 0.0f32);
|
||||
for (i, sample) in frame.iter().enumerate() {
|
||||
if i % 2 == 0 {
|
||||
left += sample;
|
||||
lefts += 1.0;
|
||||
} else {
|
||||
right += sample;
|
||||
rights += 1.0;
|
||||
}
|
||||
}
|
||||
samples.push(left / lefts.max(1.0));
|
||||
samples.push(right / rights.max(1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((mono, rate))
|
||||
(channels != 0).then_some((samples, rate, channels))
|
||||
}
|
||||
/// A sound file without its variant number (`mob/creeper/say2.ogg` is `mob/creeper/say`): each
|
||||
/// player's game picks its own variant of the same event.
|
||||
@@ -427,7 +450,7 @@ impl Backend for MixerBackend {
|
||||
// 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));
|
||||
let samples = decode(&file).map(|(s, r, c)| (Arc::new(s), r, c));
|
||||
attach(&mixer, channel, samples, looping);
|
||||
});
|
||||
return;
|
||||
@@ -448,7 +471,7 @@ impl Backend for MixerBackend {
|
||||
// Queued twice before the first decode finished.
|
||||
let cached = cache.lock().ok().and_then(|mut c| c.get(&job.key));
|
||||
let samples = cached.unwrap_or_else(|| {
|
||||
let samples = decode(&job.file).map(|(s, r)| (Arc::new(s), r));
|
||||
let samples = decode(&job.file).map(|(s, r, c)| (Arc::new(s), r, c));
|
||||
if let Ok(mut c) = cache.lock() {
|
||||
c.insert(job.key, samples.clone());
|
||||
}
|
||||
@@ -584,9 +607,13 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
let mut voices = std::mem::take(&mut mixer.voices);
|
||||
voices.clear();
|
||||
for (id, c) in &mixer.channels {
|
||||
let Some((_, source_rate)) = &c.samples else {
|
||||
let Some((_, source_rate, source_channels)) = &c.samples else {
|
||||
continue;
|
||||
};
|
||||
// OpenAL plays a multi-channel buffer direct: no panning and no distance attenuation
|
||||
// (`CalcVoiceParams`' non-spatialized path, vanilla's HRTF-off stereo context). Vanilla's
|
||||
// music and records are stereo. A mono buffer is the positional one.
|
||||
let direct = *source_channels > 1;
|
||||
if c.state != State::Playing {
|
||||
continue;
|
||||
}
|
||||
@@ -611,24 +638,33 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
let d = if c.relative { at } else { away(listener) };
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
|
||||
let gain = if camera.is_some() {
|
||||
c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance)
|
||||
c.gain.clamp(0.0, 1.0)
|
||||
* if direct {
|
||||
1.0
|
||||
} else {
|
||||
distance_gain(c.linear, distance)
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Relative sources are already in OpenAL's listener space (-Z ahead).
|
||||
let [mut left, mut right] = if c.relative {
|
||||
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
|
||||
let [left, right] = if direct {
|
||||
[gain, gain]
|
||||
} else {
|
||||
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
|
||||
// Relative sources are already in OpenAL's listener space (-Z ahead).
|
||||
let [mut left, mut right] = if c.relative {
|
||||
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
|
||||
} else {
|
||||
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
|
||||
};
|
||||
if shared {
|
||||
// Several players on the speakers: half the stereo width, as left and right are
|
||||
// nobody's in particular.
|
||||
let middle = (left + right) / 2.0;
|
||||
left = middle + (left - middle) / 2.0;
|
||||
right = middle + (right - middle) / 2.0;
|
||||
}
|
||||
[left * gain, right * gain]
|
||||
};
|
||||
if shared {
|
||||
// Several players on the speakers: half the stereo width, as left and right are
|
||||
// nobody's in particular.
|
||||
let middle = (left + right) / 2.0;
|
||||
left = middle + (left - middle) / 2.0;
|
||||
right = middle + (right - middle) / 2.0;
|
||||
}
|
||||
let [left, right] = [left, right].map(|pan| gain * pan);
|
||||
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
|
||||
voices.push((*id, [left, right, gain], step));
|
||||
}
|
||||
@@ -636,13 +672,15 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
let Some(c) = mixer.channels.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
let Some((samples, _)) = c.samples.clone() else {
|
||||
let Some((samples, _, source_channels)) = c.samples.clone() else {
|
||||
continue;
|
||||
};
|
||||
let source_channels = source_channels as usize;
|
||||
let frames = samples.len() / source_channels;
|
||||
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 {
|
||||
if i + 1 >= frames {
|
||||
if c.looping && frames > 1 {
|
||||
c.position = 0.0;
|
||||
i = 0;
|
||||
} else {
|
||||
@@ -651,13 +689,24 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
}
|
||||
}
|
||||
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;
|
||||
if source_channels > 1 {
|
||||
let l = samples[i * 2] * (1.0 - t) + samples[(i + 1) * 2] * t;
|
||||
let r = samples[i * 2 + 1] * (1.0 - t) + samples[(i + 1) * 2 + 1] * t;
|
||||
if channels >= 2 {
|
||||
frame[0] += l * left;
|
||||
frame[1] += r * right;
|
||||
} else {
|
||||
frame[0] += (l + r) * 0.5 * gain;
|
||||
}
|
||||
} else {
|
||||
// A mono output has no panning: the source's own gain.
|
||||
frame[0] += s * gain;
|
||||
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
|
||||
if channels >= 2 {
|
||||
frame[0] += s * left;
|
||||
frame[1] += s * right;
|
||||
} else {
|
||||
// A mono output has no panning: the source's own gain.
|
||||
frame[0] += s * gain;
|
||||
}
|
||||
}
|
||||
c.position += step;
|
||||
}
|
||||
@@ -1687,7 +1736,7 @@ mod tests {
|
||||
|
||||
fn channel(samples: Vec<f32>, linear: Option<f32>, at: [f32; 3], looping: bool) -> Channel {
|
||||
Channel {
|
||||
samples: Some((Arc::new(samples), 48_000)),
|
||||
samples: Some((Arc::new(samples), 48_000, 1)),
|
||||
position: 0.0,
|
||||
state: State::Playing,
|
||||
gain: 1.0,
|
||||
@@ -1702,11 +1751,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A channel whose buffer is interleaved stereo.
|
||||
fn stereo_channel(
|
||||
samples: Vec<f32>,
|
||||
linear: Option<f32>,
|
||||
at: [f32; 3],
|
||||
looping: bool,
|
||||
) -> Channel {
|
||||
let mut c = channel(Vec::new(), linear, at, looping);
|
||||
c.samples = Some((Arc::new(samples), 48_000, 2));
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_sounds_stay_within_the_budget_least_recently_used_out() {
|
||||
let mut cache = SoundCache::default();
|
||||
let quarter = CACHE_BYTES / 4 / std::mem::size_of::<f32>();
|
||||
let sound = || Some((Arc::new(vec![0.0f32; quarter]), 44100));
|
||||
let sound = || Some((Arc::new(vec![0.0f32; quarter]), 44100, 1));
|
||||
for key in ["a", "b", "c", "d"] {
|
||||
cache.insert(key.into(), sound());
|
||||
}
|
||||
@@ -1833,6 +1894,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_sounds_play_direct_without_panning_or_distance() {
|
||||
// OpenAL Soft plays a multi-channel buffer direct (its non-spatialized path), so vanilla's
|
||||
// stereo music and records keep their own left/right mix at full volume whichever way the
|
||||
// camera faces and however far it is. Only mono buffers are panned and attenuated.
|
||||
let wave = [0.1, -0.2, 0.3, -0.4, 0.5, -0.6]; // three stereo frames
|
||||
let expected = [wave[0], wave[1], wave[2], wave[3]];
|
||||
for (yaw, at, linear, listener) in [
|
||||
(0.0, [100.0, 0.0, 0.0], Some(16.0), [0.0; 3]),
|
||||
(180.0, [0.0, 0.0, -100.0], Some(16.0), [0.0; 3]),
|
||||
(90.0, [2.0, 1.0, -3.0], Some(16.0), [5.0, 60.0, 5.0]),
|
||||
(0.0, [0.0, 0.0, 0.0], None, [0.0; 3]),
|
||||
] {
|
||||
let mut m = Mixer {
|
||||
listener,
|
||||
listener_yaw: yaw,
|
||||
..Mixer::default()
|
||||
};
|
||||
m.channels
|
||||
.insert(1, stereo_channel(wave.to_vec(), linear, at, false));
|
||||
let mut data = [0.0; 4];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
assert_eq!(data, expected, "yaw {yaw}, source {at:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panning_follows_the_camera_rotation() {
|
||||
let gains = |yaw: f32, pitch: f32, at: [f32; 3]| {
|
||||
|
||||
@@ -896,6 +896,13 @@ pub fn worldgen_overrides(
|
||||
let mut warnings = Vec::new();
|
||||
for pack in packs {
|
||||
for file in pack.list_kinds(&overrides::PACK_KINDS) {
|
||||
if let Some(id) = overrides::template_id(&file) {
|
||||
match pack.read(&file) {
|
||||
Some(nbt) => out.set_template(&id, nbt),
|
||||
None => warnings.push(format!("{}: {file}: unreadable", pack.id)),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let read = || String::from_utf8(pack.read(&file)?).ok();
|
||||
match out.add_file(&pack.id, &file, read) {
|
||||
PackFile::Read | PackFile::Other => {}
|
||||
@@ -956,6 +963,7 @@ mod tests {
|
||||
for pack in ["a", "b", "off"] {
|
||||
write(pack, "pack.mcmeta", "{}");
|
||||
write(pack, "data/minecraft/worldgen/noise/cave.json", pack);
|
||||
write(pack, "data/p/structure/room/a.nbt", pack);
|
||||
write(
|
||||
pack,
|
||||
"data/minecraft/tags/worldgen/biome/is_hill.json",
|
||||
@@ -992,6 +1000,8 @@ mod tests {
|
||||
o.entries("noises").collect::<Vec<_>>(),
|
||||
[("minecraft:cave", "a")]
|
||||
);
|
||||
assert_eq!(o.templates().len(), 1);
|
||||
assert_eq!(&*o.templates()["p:room/a"], b"a");
|
||||
assert_eq!(
|
||||
o.entries("biomes").collect::<Vec<_>>(),
|
||||
[("minecraft:plains", "{}")]
|
||||
|
||||
@@ -6634,17 +6634,11 @@ impl Renderer {
|
||||
if let Some(atlas) = &mut atlas {
|
||||
atlas.separate_ao = self.separate_ao;
|
||||
}
|
||||
self.pack_material_atlases = atlas.as_ref().and_then(|_| {
|
||||
self.pack_material_atlases = atlas.as_ref().and_then(|colour| {
|
||||
let maps = self.pbr_maps.as_ref()?;
|
||||
let make = |kind: usize, encoder: &mut wgpu::CommandEncoder| {
|
||||
let arrays: Vec<_> = maps.iter().map(|pair| pair[kind].clone()).collect();
|
||||
pack_atlas::PackAtlas::material(
|
||||
&self.device,
|
||||
encoder,
|
||||
&arrays,
|
||||
self.sprite_split,
|
||||
layers,
|
||||
)
|
||||
colour.material_for(&self.device, encoder, &arrays, layers)
|
||||
};
|
||||
Some([make(0, &mut encoder)?, make(1, &mut encoder)?])
|
||||
});
|
||||
|
||||
@@ -220,7 +220,7 @@ impl Cull {
|
||||
/// write storage.
|
||||
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> Option<DepthTest> {
|
||||
let depth_test = match adapter.get_info().backend {
|
||||
wgpu::Backend::Vulkan
|
||||
wgpu::Backend::Vulkan | wgpu::Backend::Gl
|
||||
if device
|
||||
.features()
|
||||
.contains(wgpu::Features::SHADER_EARLY_DEPTH_TEST) =>
|
||||
|
||||
@@ -30,6 +30,7 @@ struct Stitched {
|
||||
/// Each layer's sprite at the first level: its top left corner and its side.
|
||||
rects: Vec<[u32; 3]>,
|
||||
padding: u32,
|
||||
material: bool,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// One per sprite array.
|
||||
groups: Vec<wgpu::BindGroup>,
|
||||
@@ -66,10 +67,19 @@ fn vs(@builtin(vertex_index) v: u32, @location(0) rect: vec4<i32>, @location(1)
|
||||
out.source = source.xy;
|
||||
return out;
|
||||
}
|
||||
fn sprite_texel(in: Out) -> vec2<i32> {
|
||||
return clamp(vec2<i32>(floor(in.position.xy)) - in.rect.xy, vec2(0), vec2(in.rect.z - 1));
|
||||
}
|
||||
@fragment
|
||||
fn fs(in: Out) -> @location(0) vec4<f32> {
|
||||
let texel = clamp(vec2<i32>(floor(in.position.xy)) - in.rect.xy, vec2(0), vec2(in.rect.z - 1));
|
||||
return textureLoad(sprites, texel, i32(in.source.x), i32(in.source.y));
|
||||
return textureLoad(sprites, sprite_texel(in), i32(in.source.x), i32(in.source.y));
|
||||
}
|
||||
@fragment
|
||||
fn fs_material(in: Out) -> @location(0) vec4<f32> {
|
||||
let texel = sprite_texel(in);
|
||||
let edge = i32(textureDimensions(sprites, 0).x);
|
||||
let sampled = min((texel * 2 + 1) * edge / (2 * in.rect.z), vec2(edge - 1));
|
||||
return textureLoad(sprites, sampled, i32(in.source.x), i32(in.source.y));
|
||||
}
|
||||
"#;
|
||||
|
||||
@@ -210,14 +220,72 @@ impl PackAtlas {
|
||||
stitch_mip_level(&sides, mip_option).min(array_levels.saturating_sub(deepest + 1));
|
||||
let max = device.limits().max_texture_dimension_2d;
|
||||
let layout = stitch::stitch(&sides, mip, anisotropy_bit, max)?;
|
||||
let rects = layout
|
||||
.holders
|
||||
.iter()
|
||||
.zip(&sides)
|
||||
.map(|(&[x, y], &side)| [x + layout.padding, y + layout.padding, side])
|
||||
.collect();
|
||||
Some(Self::with_rects(
|
||||
device,
|
||||
encoder,
|
||||
arrays,
|
||||
split,
|
||||
sprite,
|
||||
[layout.width.max(1), layout.height.max(1)],
|
||||
rects,
|
||||
layout.padding,
|
||||
mip + 1,
|
||||
false,
|
||||
))
|
||||
}
|
||||
|
||||
/// Companion maps use the colour atlas's coordinates, not a second packing.
|
||||
pub fn material_for(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
layers: u32,
|
||||
) -> Option<Self> {
|
||||
let Some(stitched) = &self.stitched else {
|
||||
return Self::material(device, encoder, arrays, self.split, layers);
|
||||
};
|
||||
Some(Self::with_rects(
|
||||
device,
|
||||
encoder,
|
||||
arrays,
|
||||
self.split,
|
||||
arrays[0].width(),
|
||||
self.size(),
|
||||
stitched.rects.clone(),
|
||||
stitched.padding,
|
||||
1,
|
||||
true,
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn with_rects(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
split: u32,
|
||||
sprite: u32,
|
||||
size: [u32; 2],
|
||||
rects: Vec<[u32; 3]>,
|
||||
padding: u32,
|
||||
levels: u32,
|
||||
material: bool,
|
||||
) -> Self {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack block atlas"),
|
||||
size: wgpu::Extent3d {
|
||||
width: layout.width.max(1),
|
||||
height: layout.height.max(1),
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: mip + 1,
|
||||
mip_level_count: levels,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
@@ -268,7 +336,7 @@ impl PackAtlas {
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
entry_point: Some(if material { "fs_material" } else { "fs" }),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
|
||||
}),
|
||||
@@ -293,12 +361,6 @@ impl PackAtlas {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let rects = layout
|
||||
.holders
|
||||
.iter()
|
||||
.zip(&sides)
|
||||
.map(|(&[x, y], &side)| [x + layout.padding, y + layout.padding, side])
|
||||
.collect::<Vec<_>>();
|
||||
let layers = rects.len() as u32;
|
||||
let atlas = Self {
|
||||
texture,
|
||||
@@ -309,13 +371,14 @@ impl PackAtlas {
|
||||
separate_ao: false,
|
||||
stitched: Some(Stitched {
|
||||
rects,
|
||||
padding: layout.padding,
|
||||
padding,
|
||||
material,
|
||||
pipeline,
|
||||
groups,
|
||||
}),
|
||||
};
|
||||
atlas.copy(device, encoder, arrays, 0..layers);
|
||||
Some(atlas)
|
||||
atlas
|
||||
}
|
||||
|
||||
/// Whether the sprites keep their own sizes ([`PackAtlas::stitched`]).
|
||||
@@ -425,7 +488,11 @@ impl PackAtlas {
|
||||
rect: [x, y, side, stitched.padding].map(|v| (v >> level) as i32),
|
||||
source: [
|
||||
layer % self.split,
|
||||
(self.sprite / side).ilog2() + level,
|
||||
if stitched.material {
|
||||
0
|
||||
} else {
|
||||
(self.sprite / side).ilog2() + level
|
||||
},
|
||||
size[0],
|
||||
size[1],
|
||||
],
|
||||
@@ -563,7 +630,7 @@ impl PackAtlas {
|
||||
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
|
||||
layout(set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };\n",
|
||||
);
|
||||
// Of each sprite layer, `mc_Entity` (id, then 1 for fluids or -1) and
|
||||
// Of each sprite layer, `mc_Entity` (id, then 1 for fluids or 0) and
|
||||
// `at_midBlock.w` (its block's light), the game's table; and the arena page's
|
||||
// vertices (`TerrainVertex`, six words each).
|
||||
declarations.push_str(
|
||||
@@ -641,7 +708,7 @@ impl PackAtlas {
|
||||
let entity = if flat {
|
||||
"vec4(float(hz_block_ids[3u * hz_layer]), float(hz_block_ids[3u * hz_layer + 1u]), 0.0, 0.0)"
|
||||
} else {
|
||||
"vec4(-1.0, -1.0, 0.0, 0.0)"
|
||||
"vec4(-1.0, 0.0, 0.0, 0.0)"
|
||||
};
|
||||
// The block a face belongs to: half a block behind its centre (the quad's diagonal);
|
||||
// w, the light it gives (Iris's `BLOCK_EMISSION_ATTRIBUTE`, 0 to 15).
|
||||
@@ -894,6 +961,144 @@ mod tests {
|
||||
assert_eq!([at(9, 5), at(8, 4), at(10, 6)], [[100, 0, 0, 255]; 3]);
|
||||
}
|
||||
|
||||
fn material_fixture(animated: bool) {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
// No adapter (a CI runner without a GPU): skip, as the module's other readback tests do.
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let colour_array = upscaled_array(&device, &queue, &[2, 4, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let colour = PackAtlas::stitched(
|
||||
&device,
|
||||
&mut encoder,
|
||||
&[colour_array],
|
||||
3,
|
||||
4,
|
||||
&[2, 4, 1],
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
for kind in 0..2 {
|
||||
// Different source resolution, with encoded channel boundaries and defaults.
|
||||
let markers = if kind == 0 {
|
||||
[
|
||||
[128, 126, 255, 19],
|
||||
crate::sprites::FLAT_NORMAL,
|
||||
[130, 125, 254, 21],
|
||||
]
|
||||
} else {
|
||||
[
|
||||
[17, 240, 65, 255],
|
||||
[18, 0, 66, 0],
|
||||
crate::sprites::NO_SPECULAR,
|
||||
]
|
||||
};
|
||||
let pixels: Vec<_> = markers.iter().flat_map(|m| m.repeat(4)).collect();
|
||||
let array = wgpu::util::DeviceExt::create_texture_with_data(
|
||||
&device,
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 2,
|
||||
height: 2,
|
||||
depth_or_array_layers: 3,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&pixels,
|
||||
);
|
||||
let arrays = [array];
|
||||
let material = colour
|
||||
.material_for(&device, &mut encoder, &arrays, 3)
|
||||
.unwrap();
|
||||
assert_eq!(material.size(), [16, 8]);
|
||||
assert_eq!(material.rects_table(), colour.rects_table());
|
||||
assert_eq!(material.texture.mip_level_count(), 1);
|
||||
queue.submit([encoder.finish()]);
|
||||
encoder = device.create_command_encoder(&Default::default());
|
||||
let changed = [243, 1, 229, 0];
|
||||
if animated {
|
||||
// Alter every source layer, but copy only the animated layer.
|
||||
queue.write_texture(
|
||||
arrays[0].as_image_copy(),
|
||||
&changed.repeat(12),
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
bytes_per_row: Some(8),
|
||||
rows_per_image: Some(2),
|
||||
offset: 0,
|
||||
},
|
||||
arrays[0].size(),
|
||||
);
|
||||
material.copy(&device, &mut encoder, &arrays, [0]);
|
||||
}
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256 * 8,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
material.texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
bytes_per_row: Some(256),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
material.texture.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
encoder = device.create_command_encoder(&Default::default());
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = read.slice(..).get_mapped_range().unwrap();
|
||||
// Independent, hand-worked colour rectangles, including all padding pixels.
|
||||
for (layer, [x, y, side]) in [[9usize, 1, 2], [1, 1, 4], [9, 5, 1]]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let expected = if animated && layer == 0 {
|
||||
changed
|
||||
} else {
|
||||
markers[layer]
|
||||
};
|
||||
for py in y - 1..=y + side {
|
||||
for px in x - 1..=x + side {
|
||||
let at = py * 256 + px * 4;
|
||||
assert_eq!(
|
||||
&data[at..at + 4],
|
||||
&expected,
|
||||
"kind={kind}, layer={layer}, ({px},{py})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
queue.submit([encoder.finish()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stitched_material_maps_share_colour_rectangles() {
|
||||
material_fixture(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animated_stitched_material_updates_only_its_rectangle() {
|
||||
material_fixture(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terrain_inputs_respects_separate_ao() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
|
||||
@@ -40,6 +40,9 @@ mod higher_shadowcolor_tests;
|
||||
#[cfg(test)]
|
||||
mod single_side_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod undrawn_terrain_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod shadow_faces_tests;
|
||||
|
||||
@@ -55,6 +58,9 @@ mod custom_type_tests;
|
||||
#[cfg(test)]
|
||||
mod sky_depth_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod translucent_color_tests;
|
||||
|
||||
/// The texture behind a pack sampler name.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Source {
|
||||
@@ -477,7 +483,7 @@ pub(super) struct World<'a> {
|
||||
pub lightmap: wgpu::TextureView,
|
||||
/// Terrain's geometry, when the pack draws it.
|
||||
pub terrain: Option<Geometry<'a>>,
|
||||
/// Of each block sprite layer, `mc_Entity` (id, then 1 for fluids or -1) and `at_midBlock.w`
|
||||
/// Of each block sprite layer, `mc_Entity` (id, then 1 for fluids or 0) and `at_midBlock.w`
|
||||
/// (its block's light): three values a layer.
|
||||
pub block_ids: &'a [i32],
|
||||
/// The atlas of the sun and moon's sprites.
|
||||
@@ -555,6 +561,10 @@ pub struct PackPost {
|
||||
terrain_cutout: Option<Pass>,
|
||||
/// What draws translucent terrain (`gbuffers_water` or its fallback), after `deferred`.
|
||||
water: Option<Pass>,
|
||||
/// The pack has no program for solid and cutout terrain, or for translucent terrain:
|
||||
/// nothing draws it (`horizon_shaders::Post::terrain_undrawn`).
|
||||
terrain_undrawn: bool,
|
||||
water_undrawn: bool,
|
||||
/// What draws the sky (`gbuffers_skybasic` or its fallback), before terrain, and its vertices.
|
||||
sky: Option<Sky>,
|
||||
/// What draws entities and block entities, after terrain, and their vertices.
|
||||
@@ -1273,8 +1283,9 @@ fn bindings(
|
||||
let mut reads = 0u16;
|
||||
let mut shadow_reads = 0u16;
|
||||
let watershadow = interface.samplers.iter().any(|s| s.name == "watershadow");
|
||||
// A gbuffers pass draws into its buffers and the depth buffer: reading them as well would
|
||||
// be a feedback loop (undefined in GL, refused by wgpu), so those read a blank texture.
|
||||
// Opaque gbuffers draws cannot sample their own attachments. Translucent draws can
|
||||
// read colortex4 and above from the preceding deferred stage; those reads use a copy
|
||||
// taken before the draw, so the sampled texture is never also an attachment.
|
||||
let in_place = match geometry {
|
||||
Some(_) => buffers.iter().fold(0u16, |m, &b| m | 1 << b),
|
||||
None => 0,
|
||||
@@ -1384,7 +1395,9 @@ fn bindings(
|
||||
Source::Depth => geometry.is_some(),
|
||||
// Translucent terrain reads the copy; opaque terrain would read itself.
|
||||
Source::Depth1 | Source::Depth2 => group == Group::Gbuffers,
|
||||
Source::Color(n) => in_place & 1 << n != 0,
|
||||
Source::Color(n) => {
|
||||
(group != Group::Translucent || n < 4) && in_place & 1 << n != 0
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let mut sample_type = match src {
|
||||
@@ -1865,7 +1878,13 @@ fn build_pass(
|
||||
ids: geometry.is_some_and(|g| g.ids.is_some()),
|
||||
groups: HashMap::new(),
|
||||
portal_groups: HashMap::new(),
|
||||
copies: image_buffers(&images) & reads,
|
||||
copies: (image_buffers(&images)
|
||||
| if p.group == Group::Translucent {
|
||||
written
|
||||
} else {
|
||||
0
|
||||
})
|
||||
& reads,
|
||||
reads: reads | image_buffers(&images),
|
||||
buffers: !interface.buffers.is_empty(),
|
||||
images,
|
||||
@@ -2288,6 +2307,7 @@ impl PackPost {
|
||||
cull: g.cull,
|
||||
});
|
||||
let split = post.terrain.is_some() && post.terrain_cutout.is_some();
|
||||
let (terrain_undrawn, water_undrawn) = (post.terrain_undrawn, post.water_undrawn);
|
||||
let mut terrain = post
|
||||
.terrain
|
||||
.zip(terrain_geometry.as_ref())
|
||||
@@ -2834,6 +2854,8 @@ impl PackPost {
|
||||
terrain,
|
||||
terrain_cutout,
|
||||
water,
|
||||
terrain_undrawn,
|
||||
water_undrawn,
|
||||
sky,
|
||||
entities,
|
||||
clouds,
|
||||
@@ -4061,9 +4083,9 @@ impl PackPost {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the pack draws terrain itself.
|
||||
/// Whether the pack draws terrain itself, or has nothing draw it.
|
||||
pub fn has_terrain(&self) -> bool {
|
||||
self.terrain.is_some() || self.terrain_cutout.is_some()
|
||||
self.terrain.is_some() || self.terrain_cutout.is_some() || self.terrain_undrawn
|
||||
}
|
||||
|
||||
/// Whether the pack's shadow pass draws terrain (`shadow_view`).
|
||||
@@ -4091,7 +4113,7 @@ impl PackPost {
|
||||
|
||||
/// Whether the pack draws translucent terrain itself.
|
||||
pub fn has_water(&self) -> bool {
|
||||
self.water.is_some()
|
||||
self.water.is_some() || self.water_undrawn
|
||||
}
|
||||
|
||||
/// The names of the programs that run, in order.
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! A translucent draw reads the preceding scene even when it writes the same color buffer.
|
||||
//! Original programs and independently calculated colors; no shader-pack assets.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
use wgpu::{TextureFormat as T, util::DeviceExt};
|
||||
|
||||
#[test]
|
||||
fn translucent_draws_sample_the_color_written_by_deferred() {
|
||||
let Some(gpu) = Gpu::new() else { return };
|
||||
let positions: [[f32; 3]; 6] = [
|
||||
[-1.0, -1.0, 0.0],
|
||||
[1.0, -1.0, 0.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
[-1.0, -1.0, 0.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
[-1.0, 1.0, 0.0],
|
||||
];
|
||||
let vertices = gpu
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Original translucent quad"),
|
||||
contents: bytemuck::cast_slice(&positions),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
|
||||
let layouts = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 12,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &ATTRS,
|
||||
})];
|
||||
// Odd and even numbers of deferred passes leave the current scene on different sides.
|
||||
// Exercise two buffer numbers and the legacy sampler aliases too.
|
||||
for (buffer, sampler) in [(4, "colortex4"), (7, "gaux4")] {
|
||||
for stages in [1, 2] {
|
||||
for blended in [false, true] {
|
||||
let mut files = vec![
|
||||
("deferred.vsh".into(), VERTEX.into()),
|
||||
(
|
||||
"deferred.fsh".into(),
|
||||
format!(
|
||||
"#version 120\n/* RENDERTARGETS: {buffer} */\nvoid main(){{gl_FragData[0]=vec4(0.25,0.5,0.75,1.0);}}"
|
||||
),
|
||||
),
|
||||
(
|
||||
"gbuffers_water.vsh".into(),
|
||||
"#version 120\nvoid main(){gl_Position=gl_Vertex;}".into(),
|
||||
),
|
||||
(
|
||||
"gbuffers_water.fsh".into(),
|
||||
format!(
|
||||
"#version 120\n/* RENDERTARGETS: {buffer} */\nuniform sampler2D {sampler};\nvoid main(){{gl_FragData[0]=vec4(texture2D({sampler},vec2(0.5)).rgb+vec3(0.125,0.0,0.0),0.5);}}"
|
||||
),
|
||||
),
|
||||
("final.vsh".into(), VERTEX.into()),
|
||||
(
|
||||
"final.fsh".into(),
|
||||
format!(
|
||||
"#version 120\nuniform sampler2D colortex{buffer};varying vec2 uv;\nvoid main(){{gl_FragColor=vec4(texture2D(colortex{buffer},uv).rgb,1.0);}}"
|
||||
),
|
||||
),
|
||||
(
|
||||
"shaders.properties".into(),
|
||||
format!(
|
||||
"blend.gbuffers_water={}\n",
|
||||
if blended {
|
||||
"SRC_ALPHA ONE_MINUS_SRC_ALPHA ONE ONE_MINUS_SRC_ALPHA"
|
||||
} else {
|
||||
"off"
|
||||
}
|
||||
),
|
||||
),
|
||||
];
|
||||
if stages == 2 {
|
||||
files.extend([
|
||||
("deferred1.vsh".into(), VERTEX.into()),
|
||||
("deferred1.fsh".into(), format!(
|
||||
"#version 120\n/* RENDERTARGETS: {buffer} */\nvoid main(){{gl_FragData[0]=vec4(0.5,0.75,0.25,1.0);}}"
|
||||
)),
|
||||
]);
|
||||
}
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: Some(Geometry {
|
||||
buffers: &layouts,
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
}),
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
horizon_shaders::GameInputs {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
|
||||
body: "hz_Vertex=vec4(fixture_position,1.0);\n".into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(post.has_water(), "{:?}", post.notes);
|
||||
let mut expected = if stages == 1 {
|
||||
[0.25f32, 0.5, 0.75]
|
||||
} else {
|
||||
[0.5, 0.75, 0.25]
|
||||
};
|
||||
expected[0] += if blended { 0.0625 } else { 0.125 };
|
||||
let expected = expected.map(|c| (c * 255.0).round() as u8);
|
||||
let hud = HudBatch::new(SIZE);
|
||||
for frame in 0..2 {
|
||||
let pixels = gpu.render(
|
||||
&mut post,
|
||||
&color,
|
||||
&depth,
|
||||
&fixture_frame(&hud),
|
||||
&mut |rp, list, _, _| {
|
||||
if list == TerrainList::Translucent {
|
||||
rp.set_vertex_buffer(0, vertices.slice(..));
|
||||
rp.draw(0..6, 0..1);
|
||||
}
|
||||
},
|
||||
);
|
||||
for p in pixels {
|
||||
assert!(
|
||||
p[..3]
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.all(|(&a, b)| a.abs_diff(b) <= 2),
|
||||
"buffer {buffer}, {stages} deferred stages, blended {blended}, frame {frame}: {p:?}, expected {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! A pack with no program for terrain draws none (#10455): Iris 1.11.7 drew no terrain, solid
|
||||
//! or translucent, into any buffer or depth with probe packs of ours having no gbuffers
|
||||
//! programs. The game hands the pack its terrain, so it doesn't draw it either. The programs
|
||||
//! here are original and cut down.
|
||||
use super::destination_tests::Fixture;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvoid main() { gl_Position = ftransform(); }\n";
|
||||
const WHITE: &str =
|
||||
"#version 120\n/* DRAWBUFFERS:0 */\nvoid main() { gl_FragData[0] = vec4(1.0); }\n";
|
||||
|
||||
/// A pack with `programs` (each drawing white into colortex0) loaded for a game that can hand it
|
||||
/// terrain: whether it takes solid and translucent terrain from the game.
|
||||
fn takes_terrain(programs: &[&str]) -> Option<(bool, bool)> {
|
||||
let gpu = Fixture::new()?;
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for name in programs {
|
||||
for (ext, source) in [("vsh", VERTEX), ("fsh", WHITE)] {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}.{ext}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
}
|
||||
}
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"undrawn-terrain",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain: Some(Default::default()),
|
||||
..Default::default()
|
||||
};
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &inputs);
|
||||
let post = gpu.try_build(post).ok()?;
|
||||
Some((post.has_terrain(), post.has_water()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_without_terrain_programs_takes_terrain_from_the_game() {
|
||||
let Some(taken) = takes_terrain(&["composite"]) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(taken, (true, true));
|
||||
}
|
||||
@@ -2709,6 +2709,41 @@ impl OptionsUi {
|
||||
}
|
||||
rows
|
||||
}
|
||||
/// The row that reaches the Zoom mod that replaced the extra: its switch and settings page
|
||||
/// when it is built in, else a note saying where it is installed (Options > Mods). Zoom
|
||||
/// became an official native mod, so Extras has no setting of its own for it (#8713).
|
||||
fn zoom_mod_row(s: &Settings) -> Row {
|
||||
let Some(i) = s
|
||||
.mods
|
||||
.installed
|
||||
.iter()
|
||||
.position(|m| m.id == "zoom" && m.native.is_some())
|
||||
else {
|
||||
// A note, not a button: Options > Mods already has the one way into the Mods page
|
||||
// (`every_setting_and_page_is_reached_from_one_place`).
|
||||
return Row::Note(lang::get("horizon.extras.zoom_mod_install"));
|
||||
};
|
||||
let label = || Text::option_name_value(tr("horizon.extras.zoom"), on_off(s.mods.is_on(i)));
|
||||
let toggle = |width| button(0.0, 0.0, width, label(), Act::ToggleMod(i));
|
||||
let settings = s.mods.installed[i]
|
||||
.native
|
||||
.as_ref()
|
||||
.is_some_and(|page| !page.keys.is_empty() || !page.buttons.is_empty());
|
||||
if settings {
|
||||
Row::Buttons(
|
||||
toggle(150.0),
|
||||
Some(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
tr("horizon.mods.settings"),
|
||||
Act::ModSettings(i),
|
||||
)),
|
||||
)
|
||||
} else {
|
||||
Row::Buttons(toggle(310.0), None)
|
||||
}
|
||||
}
|
||||
/// A native mod's page: its name, its keys as Key Binds rows (the
|
||||
/// player's choice, else the default), then its buttons.
|
||||
fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
|
||||
@@ -2781,6 +2816,11 @@ impl OptionsUi {
|
||||
// (Horizon Settings has it).
|
||||
let preset = button(0.0, 0.0, 150.0, tr("horizon.pvp.preset"), Act::PvpPreset);
|
||||
let mut rows = Vec::new();
|
||||
if self.page() == Page::Extras {
|
||||
// Zoom, the FPS counter, Keystrokes and the Minimap are official mods now;
|
||||
// Extras keeps a way to the one players look for here (#8713).
|
||||
rows.push(Self::zoom_mod_row(s));
|
||||
}
|
||||
if self.page() == Page::Pvp {
|
||||
// The editor moves what the game shows: only from a world.
|
||||
let edit = button(0.0, 0.0, 150.0, tr("horizon.pvp.editor"), Act::HudEditor);
|
||||
@@ -6648,6 +6688,60 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extras_reaches_the_zoom_mod_that_replaced_the_extra() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Extras);
|
||||
let english = || lang::Language::load(&[], "en_us");
|
||||
// Without the mod, Extras says where it is installed (a note, not a second way into
|
||||
// Options > Mods).
|
||||
let (widgets, _, _, _) = lang::scoped(english(), || ui.layout(&s, 480.0, 4000.0)).0;
|
||||
assert!(
|
||||
widgets.iter().any(|w| matches!(
|
||||
&w.kind,
|
||||
WKind::Text { text, .. } if text.plain() == "Zoom: install from Mods"
|
||||
)),
|
||||
"Extras has no hint for the Zoom mod"
|
||||
);
|
||||
// Built in, Zoom's settings live on its page: Extras opens that page.
|
||||
s.mods.installed.push(crate::mod_list::Installed {
|
||||
id: "zoom".into(),
|
||||
title: "Zoom 2.0.0".into(),
|
||||
description: String::new(),
|
||||
problem: None,
|
||||
dev: false,
|
||||
native: Some(crate::mod_list::NativePage {
|
||||
keys: vec![["zoom".into(), "Zoom".into(), "key.keyboard.z".into()]],
|
||||
buttons: Vec::new(),
|
||||
}),
|
||||
});
|
||||
let (widgets, _, _, _) = lang::scoped(english(), || ui.layout(&s, 480.0, 4000.0)).0;
|
||||
assert!(
|
||||
widgets.iter().any(|w| matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::ModSettings(0),
|
||||
label,
|
||||
..
|
||||
} if label.plain() == "Settings"
|
||||
)),
|
||||
"Extras doesn't open the Zoom mod's settings"
|
||||
);
|
||||
// The mod's switch is on the same row, so Extras keeps the Zoom setting it had.
|
||||
assert!(
|
||||
widgets.iter().any(|w| matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::ToggleMod(0),
|
||||
label,
|
||||
..
|
||||
} if label.plain().starts_with("Zoom")
|
||||
)),
|
||||
"Extras doesn't switch the Zoom mod"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusive_fullscreen_and_its_tooltips_follow_the_option() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -899,9 +899,24 @@ fn merge(kind: Merge, layers: &[Vec<u8>]) -> Option<Vec<u8>> {
|
||||
.iter()
|
||||
// SoundManager skips a sounds.json Gson rejects, whole.
|
||||
.filter(|b| kind != Merge::Sounds || crate::sound_events::is_valid_file(b))
|
||||
.filter_map(|b| match serde_json::from_slice(b) {
|
||||
Ok(Value::Object(m)) => Some(m),
|
||||
_ => None,
|
||||
.filter_map(|b| {
|
||||
match serde_json::from_slice(b) {
|
||||
Ok(Value::Object(m)) => Some(m),
|
||||
// `Language.loadFromJson` reads with Gson's lenient reader, so
|
||||
// a lang file with `#` comments still counts; a file that
|
||||
// strict JSON accepts keeps its bytes as they are.
|
||||
_ if kind == Merge::Lang => {
|
||||
let mut strings = std::collections::HashMap::new();
|
||||
horizon_client_core::lang::read_lang_file(b, &mut strings);
|
||||
Some(
|
||||
strings
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, Value::String(v)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let list = |key: &str, layers: &mut dyn Iterator<Item = &Map<String, Value>>| {
|
||||
@@ -1424,6 +1439,18 @@ mod tests {
|
||||
parse(lang.unwrap()),
|
||||
serde_json::json!({"a":"vanilla a","b":"pack b"})
|
||||
);
|
||||
// A layer with `#` comments is read leniently, not dropped.
|
||||
let lenient = merge(
|
||||
Merge::Lang,
|
||||
&[
|
||||
json(r#"{"a":"vanilla a"}"#),
|
||||
json("# a pack comment\n{\"b\":\"pack b\", # trailing\n\"a\":\"pack a\"}"),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
parse(lenient.unwrap()),
|
||||
serde_json::json!({"a":"pack a","b":"pack b"})
|
||||
);
|
||||
// SoundManager: sounds add up; `replace` starts the event over.
|
||||
let sounds = merge(
|
||||
Merge::Sounds,
|
||||
|
||||
@@ -975,11 +975,32 @@ impl TextField {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ScreenRectangle.intersection`: the part of both rectangles, or
|
||||
/// `ScreenRectangle.empty()` (`0, 0, 0, 0`, which clips everything) when they
|
||||
/// do not overlap.
|
||||
fn intersect(a: [i32; 4], b: [i32; 4]) -> [i32; 4] {
|
||||
let rect = [
|
||||
a[0].max(b[0]),
|
||||
a[1].max(b[1]),
|
||||
a[2].min(b[2]),
|
||||
a[3].min(b[3]),
|
||||
];
|
||||
if rect[0] < rect[2] && rect[1] < rect[3] {
|
||||
rect
|
||||
} else {
|
||||
[0, 0, 0, 0]
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the calls with the HUD batch: sprites nine-sliced, textures from
|
||||
/// the atlas (`textures/gui/x.png` is `gui/x`; tiled textures from their
|
||||
/// corner), scissor clipping, and the tooltip at the mouse.
|
||||
pub fn paint(batch: &mut HudBatch, atlas: &HudAtlas, draws: &[Draw]) {
|
||||
let mut clip: Option<(usize, [i32; 4])> = None;
|
||||
// `GuiGraphicsExtractor.ScissorStack`: a scissor clips what is drawn since
|
||||
// it opened, cut to the screen while none is open and to the one around it
|
||||
// otherwise; closing it puts the enclosing one back.
|
||||
let screen = [0, 0, batch.width as i32, batch.height as i32];
|
||||
let mut clips: Vec<(usize, [i32; 4])> = Vec::new();
|
||||
let mut tooltip = None;
|
||||
for d in draws {
|
||||
match d {
|
||||
@@ -1083,10 +1104,11 @@ pub fn paint(batch: &mut HudBatch, atlas: &HudAtlas, draws: &[Draw]) {
|
||||
);
|
||||
}
|
||||
Draw::Scissor { x0, y0, x1, y1 } => {
|
||||
clip = Some((batch.vertices.len(), [*x0, *y0, *x1, *y1]))
|
||||
let outer = clips.last().map_or(screen, |&(_, rect)| rect);
|
||||
clips.push((batch.vertices.len(), intersect(outer, [*x0, *y0, *x1, *y1])));
|
||||
}
|
||||
Draw::NoScissor => {
|
||||
if let Some((start, [x0, y0, x1, y1])) = clip.take() {
|
||||
if let Some((start, [x0, y0, x1, y1])) = clips.pop() {
|
||||
batch.clip_since(start, x0 as f32, y0 as f32, x1 as f32, y1 as f32);
|
||||
}
|
||||
}
|
||||
@@ -1282,3 +1304,126 @@ mod button_label_tests {
|
||||
batch.vertices.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod scissor_tests {
|
||||
use super::*;
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
/// The bounds of every quad's six vertices, in draw order.
|
||||
fn quads(batch: &HudBatch) -> Vec<[f32; 4]> {
|
||||
batch
|
||||
.vertices
|
||||
.as_chunks::<6>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let (mut x0, mut y0) = (f32::MAX, f32::MAX);
|
||||
let (mut x1, mut y1) = (f32::MIN, f32::MIN);
|
||||
for v in q {
|
||||
x0 = x0.min(v.pos[0]);
|
||||
y0 = y0.min(v.pos[1]);
|
||||
x1 = x1.max(v.pos[0]);
|
||||
y1 = y1.max(v.pos[1]);
|
||||
}
|
||||
[x0, y0, x1, y1]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fill(x0: i32, y0: i32, x1: i32, y1: i32) -> Draw {
|
||||
Draw::Fill {
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1,
|
||||
color: -1,
|
||||
}
|
||||
}
|
||||
|
||||
fn scissor(x0: i32, y0: i32, x1: i32, y1: i32) -> Draw {
|
||||
Draw::Scissor { x0, y0, x1, y1 }
|
||||
}
|
||||
|
||||
/// `GuiGraphicsExtractor.ScissorStack.push`: a scissor inside another is
|
||||
/// intersected with it (`ScreenRectangle.intersection`), and the outer one
|
||||
/// still clips what is drawn after the inner one closes.
|
||||
#[test]
|
||||
fn a_nested_scissor_is_intersected_with_the_one_around_it() {
|
||||
let atlas = atlas();
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
paint(
|
||||
&mut batch,
|
||||
&atlas,
|
||||
&[
|
||||
scissor(10, 10, 100, 100),
|
||||
fill(0, 0, 200, 200),
|
||||
scissor(50, 50, 300, 300),
|
||||
fill(0, 0, 400, 400),
|
||||
Draw::NoScissor,
|
||||
fill(0, 0, 200, 200),
|
||||
Draw::NoScissor,
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
quads(&batch),
|
||||
[
|
||||
[10.0, 10.0, 100.0, 100.0],
|
||||
[50.0, 50.0, 100.0, 100.0],
|
||||
[10.0, 10.0, 100.0, 100.0],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// A scissor with no scissor around it is cut to the screen, as
|
||||
/// `ScissorStack.push` intersects with the screen while the stack is empty.
|
||||
#[test]
|
||||
fn a_scissor_wider_than_the_screen_is_cut_to_it() {
|
||||
let atlas = atlas();
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
paint(
|
||||
&mut batch,
|
||||
&atlas,
|
||||
&[
|
||||
scissor(-50, -50, 500, 500),
|
||||
fill(-100, -100, 600, 600),
|
||||
Draw::NoScissor,
|
||||
],
|
||||
);
|
||||
assert_eq!(quads(&batch), [[0.0, 0.0, 320.0, 240.0]]);
|
||||
}
|
||||
|
||||
/// A scissor that misses the one around it intersects to
|
||||
/// `ScreenRectangle.empty()`: nothing inside it is drawn, and the outer
|
||||
/// scissor takes over again when it closes.
|
||||
#[test]
|
||||
fn a_scissor_outside_the_one_around_it_draws_nothing() {
|
||||
let atlas = atlas();
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
paint(
|
||||
&mut batch,
|
||||
&atlas,
|
||||
&[
|
||||
scissor(10, 10, 50, 50),
|
||||
fill(0, 0, 200, 200),
|
||||
scissor(60, 60, 90, 90),
|
||||
fill(0, 0, 200, 200),
|
||||
Draw::NoScissor,
|
||||
fill(0, 0, 200, 200),
|
||||
Draw::NoScissor,
|
||||
fill(0, 0, 200, 200),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
quads(&batch),
|
||||
[
|
||||
[10.0, 10.0, 50.0, 50.0],
|
||||
[10.0, 10.0, 50.0, 50.0],
|
||||
[0.0, 0.0, 200.0, 200.0],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -237,6 +237,7 @@ pub struct Resolver {
|
||||
done: std::sync::mpsc::Receiver<(NameOrId, Option<ResolvedProfile>)>,
|
||||
send: std::sync::mpsc::Sender<(NameOrId, Option<ResolvedProfile>)>,
|
||||
last_sweep: std::time::Instant,
|
||||
heads: HeadProfiles,
|
||||
}
|
||||
struct Entry {
|
||||
touched: std::time::Instant,
|
||||
@@ -304,6 +305,7 @@ impl Resolver {
|
||||
done,
|
||||
send,
|
||||
last_sweep: std::time::Instant::now(),
|
||||
heads: HeadProfiles::default(),
|
||||
}
|
||||
}
|
||||
/// `CacheBuilder.expireAfterAccess` with `maximumSize`.
|
||||
@@ -465,6 +467,64 @@ pub fn dynamic_nbt(profile: &horizon_world::nbt::Tag) -> Option<NameOrId> {
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_HEAD_PROFILES: usize = 4096;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct HeadProfile {
|
||||
info: ProfileInfo,
|
||||
skin: Option<(String, String)>,
|
||||
}
|
||||
|
||||
fn prepare_head(profile: &serde_json::Value) -> HeadProfile {
|
||||
let info = parse_profile(profile);
|
||||
let skin = info.textures.as_ref().and_then(|(value, signature)| {
|
||||
let url = unpack(value, *signature, &TEXTURE_URIS).skin?;
|
||||
let location = skin_location(&texture_hash(&url)?);
|
||||
Some((url, location))
|
||||
});
|
||||
HeadProfile { info, skin }
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct HeadProfiles {
|
||||
entries: std::collections::HashMap<serde_json::Value, std::sync::Arc<HeadProfile>>,
|
||||
nbt: std::collections::HashMap<horizon_world::nbt::Tag, std::sync::Arc<serde_json::Value>>,
|
||||
resolved: [std::collections::HashMap<String, std::sync::Arc<HeadProfile>>; 2],
|
||||
}
|
||||
|
||||
impl HeadProfiles {
|
||||
fn get(&mut self, profile: &serde_json::Value) -> std::sync::Arc<HeadProfile> {
|
||||
if let Some(parsed) = self.entries.get(profile) {
|
||||
return parsed.clone();
|
||||
}
|
||||
if self.entries.len() >= MAX_HEAD_PROFILES {
|
||||
self.entries.clear();
|
||||
}
|
||||
let parsed = std::sync::Arc::new(prepare_head(profile));
|
||||
self.entries.insert(profile.clone(), parsed.clone());
|
||||
parsed
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolver {
|
||||
/// Cache by NBT value rather than address: equipment can be replaced or
|
||||
/// mutated in place, and neither must leave a stale profile behind.
|
||||
pub fn head_profile_nbt(
|
||||
&mut self,
|
||||
profile: &horizon_world::nbt::Tag,
|
||||
) -> std::sync::Arc<serde_json::Value> {
|
||||
if let Some(json) = self.heads.nbt.get(profile) {
|
||||
return json.clone();
|
||||
}
|
||||
if self.heads.nbt.len() >= MAX_HEAD_PROFILES {
|
||||
self.heads.nbt.clear();
|
||||
}
|
||||
let json = std::sync::Arc::new(crate::scoreboard::tag_to_json(profile));
|
||||
self.heads.nbt.insert(profile.clone(), json.clone());
|
||||
json
|
||||
}
|
||||
}
|
||||
|
||||
/// `PlayerSkinRenderCache.getOrDefault` for a head: the resolved skin once
|
||||
/// its texture is in the atlas (`atlas`), else `partialProfile`'s default.
|
||||
pub fn head_skin(
|
||||
@@ -478,32 +538,54 @@ pub fn head_skin(
|
||||
{
|
||||
return sprite;
|
||||
}
|
||||
let p = parse_profile(profile);
|
||||
let property = p.textures.or_else(|| {
|
||||
let key = p.dynamic.as_ref()?;
|
||||
let p = resolver.heads.get(profile);
|
||||
// Dynamic resolution is deliberately not cached: pending answers and the
|
||||
// resolver's expiring service results must still advance on every draw.
|
||||
let resolved = p.info.dynamic.as_ref().and_then(|key| {
|
||||
if p.info.textures.is_some() {
|
||||
return None;
|
||||
}
|
||||
match resolver.state(key) {
|
||||
Resolve::Done(Some(profile)) => profile.textures(),
|
||||
Resolve::Done(Some(profile)) => {
|
||||
let (_, value, signature) = profile
|
||||
.properties
|
||||
.iter()
|
||||
.find(|(n, _, _)| n == "textures")?;
|
||||
let cache = &mut resolver.heads.resolved[usize::from(signature.is_some())];
|
||||
if let Some(parsed) = cache.get(value) {
|
||||
return Some(parsed.clone());
|
||||
}
|
||||
if cache.len() >= MAX_HEAD_PROFILES {
|
||||
cache.clear();
|
||||
}
|
||||
let property = if signature.is_some() {
|
||||
serde_json::json!({"name": "textures", "value": value, "signature": ""})
|
||||
} else {
|
||||
serde_json::json!({"name": "textures", "value": value})
|
||||
};
|
||||
let parsed = std::sync::Arc::new(prepare_head(
|
||||
&serde_json::json!({"properties": [property]}),
|
||||
));
|
||||
cache.insert(value.clone(), parsed.clone());
|
||||
Some(parsed)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
if let Some((value, signature)) = property
|
||||
&& let Some(url) = unpack(&value, signature, &TEXTURE_URIS).skin
|
||||
&& let Some(hash) = texture_hash(&url)
|
||||
{
|
||||
let location = skin_location(&hash);
|
||||
match downloads.state(Kind::Skin, &url) {
|
||||
Download::Ready if atlas(&location) => {
|
||||
downloads.drawn(&location);
|
||||
return location;
|
||||
if let Some((url, location)) = resolved.as_deref().unwrap_or(&p).skin.as_ref() {
|
||||
match downloads.state(Kind::Skin, url) {
|
||||
Download::Ready if atlas(location) => {
|
||||
downloads.drawn(location);
|
||||
return location.clone();
|
||||
}
|
||||
// Its slot was evicted: offer the image again.
|
||||
Download::Ready => {
|
||||
downloads.again(&location);
|
||||
downloads.again(location);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
crate::tab_list::default_skin(p.id).to_owned()
|
||||
crate::tab_list::default_skin(p.info.id).to_owned()
|
||||
}
|
||||
|
||||
/// `PlayerSkin.Patch.apply` for a head: the sprite of a profile's `texture`
|
||||
@@ -1137,6 +1219,80 @@ pub fn sha1(data: &[u8]) -> [u8; 20] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn head_profile_cache_matches_uncached_and_is_bounded() {
|
||||
use base64::Engine;
|
||||
let mut cache = super::HeadProfiles::default();
|
||||
for (url, slim) in [
|
||||
("https://textures.minecraft.net/texture/abc", false),
|
||||
("https://textures.minecraft.net/texture/abc", true),
|
||||
("https://evil.example/texture/abc", false),
|
||||
] {
|
||||
let payload = serde_json::json!({"textures": {
|
||||
"SKIN": {"url": url, "metadata": {"model": if slim { "slim" } else { "classic" }}},
|
||||
"CAPE": {"url": "http://textures.minecraft.net/texture/cape"}
|
||||
}});
|
||||
let value = base64::engine::general_purpose::STANDARD.encode(payload.to_string());
|
||||
for signed in [false, true] {
|
||||
let mut property = serde_json::json!({"name": "textures", "value": value});
|
||||
if signed {
|
||||
property["signature"] = serde_json::Value::Null;
|
||||
}
|
||||
let profile = serde_json::json!({"properties": [property]});
|
||||
let parsed = cache.get(&profile);
|
||||
assert_eq!(*parsed, super::prepare_head(&profile));
|
||||
let textures = super::unpack(
|
||||
&value,
|
||||
if signed {
|
||||
super::Signature::Signed
|
||||
} else {
|
||||
super::Signature::Unsigned
|
||||
},
|
||||
&super::TEXTURE_URIS,
|
||||
);
|
||||
assert_eq!(textures.slim, slim && !url.contains("evil"));
|
||||
assert_eq!(textures.cape.is_some(), !url.contains("evil"));
|
||||
assert_eq!(parsed.skin.is_some(), !url.contains("evil"));
|
||||
assert!(std::sync::Arc::ptr_eq(&parsed, &cache.get(&profile)));
|
||||
}
|
||||
}
|
||||
for profile in [
|
||||
serde_json::json!({"properties": [{"name": "textures", "value": "!bad"}]}),
|
||||
serde_json::json!({"name": "Notch"}),
|
||||
serde_json::json!({}),
|
||||
] {
|
||||
let parsed = cache.get(&profile);
|
||||
assert_eq!(*parsed, super::prepare_head(&profile));
|
||||
assert!(parsed.skin.is_none());
|
||||
assert!(std::sync::Arc::ptr_eq(&parsed, &cache.get(&profile)));
|
||||
}
|
||||
cache.entries.clear();
|
||||
for i in 0..super::MAX_HEAD_PROFILES {
|
||||
cache.get(&serde_json::json!({"id": i}));
|
||||
}
|
||||
assert_eq!(cache.entries.len(), super::MAX_HEAD_PROFILES);
|
||||
cache.get(&serde_json::json!({}));
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_profile_nbt_cache_tracks_value_changes() {
|
||||
let mut resolver = super::Resolver::new();
|
||||
let mut tag = horizon_world::nbt::Tag::String("Notch".into());
|
||||
let first = resolver.head_profile_nbt(&tag);
|
||||
assert_eq!(*first, crate::scoreboard::tag_to_json(&tag));
|
||||
assert!(std::sync::Arc::ptr_eq(
|
||||
&first,
|
||||
&resolver.head_profile_nbt(&tag)
|
||||
));
|
||||
tag = horizon_world::nbt::Tag::String("Alex".into());
|
||||
assert_eq!(*resolver.head_profile_nbt(&tag), serde_json::json!("Alex"));
|
||||
for i in 0..super::MAX_HEAD_PROFILES {
|
||||
resolver.head_profile_nbt(&horizon_world::nbt::Tag::Int(i as i32));
|
||||
}
|
||||
assert!(resolver.heads.nbt.len() < super::MAX_HEAD_PROFILES);
|
||||
}
|
||||
|
||||
/// A profile's `texture` names a pack texture, never a file outside the
|
||||
/// packs: bad characters and `..` segments draw nothing.
|
||||
#[test]
|
||||
|
||||
@@ -544,6 +544,12 @@ pub struct Streaming {
|
||||
/// Chunks come from a server (`insert_remote`/`forget`): nothing is
|
||||
/// generated and columns are only unloaded when the server says so.
|
||||
pub remote: bool,
|
||||
/// This server sends bulk block edits as whole columns whose light is
|
||||
/// from before its relight (a Horizon server): a resent resident column
|
||||
/// is lit here. Any other server's packet carries the light vanilla
|
||||
/// applies, so it is not lit again (a proxy's next server resends its
|
||||
/// whole world at the same coordinates).
|
||||
pub relight_resent: bool,
|
||||
/// More areas to keep loaded (centre chunk, radius), e.g. around the
|
||||
/// players of a server. Each is a square, like vanilla's ticket levels:
|
||||
/// chunks finish out to `radius + 1` (Chebyshev) and generation reaches
|
||||
@@ -1108,6 +1114,7 @@ impl Streaming {
|
||||
generated_total: 0,
|
||||
meshed_total: 0,
|
||||
remote: false,
|
||||
relight_resent: false,
|
||||
extra: Vec::new(),
|
||||
cover: None,
|
||||
mesh: true,
|
||||
@@ -1154,14 +1161,15 @@ impl Streaming {
|
||||
}
|
||||
/// A resident column a server sent again whole (it changed it in bulk):
|
||||
/// its new blocks taken, the sections that did not change kept as they
|
||||
/// were, and its light and its neighbours' worked out again here, off
|
||||
/// the game thread, as light is for the blocks a server changes one at a
|
||||
/// time; the sections that changed remesh (with theirs) once lit. Until
|
||||
/// then the sections that changed show the light the packet carried (a
|
||||
/// Horizon server sends the light from before its relight, then the
|
||||
/// new light; another server's is already right, which matters where
|
||||
/// the neighbours this light needs aren't all here).
|
||||
pub fn replace_remote(&mut self, world: &mut World, mut chunk: Chunk) {
|
||||
/// were, and (for a Horizon server's bulk edit) its light and its
|
||||
/// neighbours' worked out again here, off the game thread, as light is for
|
||||
/// the blocks a server changes one at a time; the sections that changed
|
||||
/// remesh (with theirs) once lit. Until then the sections that changed
|
||||
/// show the light the packet carried (a Horizon server sends the light
|
||||
/// from before its relight, then the new light; another server's is already
|
||||
/// right, which matters where the neighbours this light needs aren't all
|
||||
/// here).
|
||||
pub fn replace_remote(&mut self, world: &mut World, chunk: Chunk) {
|
||||
let key = (chunk.x, chunk.z);
|
||||
let Some(old) = world.chunks.get(&key).cloned() else {
|
||||
return self.insert_remote(world, chunk);
|
||||
@@ -1169,34 +1177,7 @@ impl Streaming {
|
||||
if !self.lit.contains(&key) {
|
||||
return self.insert_remote(world, chunk);
|
||||
}
|
||||
// The cells whose blocks changed, by section: they remesh with the neighbours within a block of them (as
|
||||
// vanilla's `setBlocksDirty`), not every section round each section changed (which, for the few blocks a
|
||||
// section often changes in a bulk edit, is mostly sections that did not).
|
||||
let mut cells = crate::light::LightChanges::default();
|
||||
cells.keyed(world.layout());
|
||||
let kept = keep_unchanged_sections(&old, &mut chunk, &mut cells);
|
||||
chunk.light_loaded = old.light_loaded;
|
||||
chunk.lit = true;
|
||||
world.chunks.insert(key, Arc::new(chunk));
|
||||
world.revision = world.revision.wrapping_add(1);
|
||||
// A section with new biomes and the same blocks remeshes now (its tint), with its neighbours, whose tint
|
||||
// blends across the border: above and below too, as a block's tint can sample the biome quart of the
|
||||
// section next to it (`BiomeManager.getBiome`'s zoom reaches a quart beyond the block).
|
||||
Self::light_updated(world, key.0, key.1, &kept.biomes);
|
||||
// A section with the same blocks and new light takes the packet's light (vanilla's
|
||||
// `handleLevelChunkWithLight` always applies it) and remeshes as a light update would.
|
||||
Self::light_updated(world, key.0, key.1, &kept.light);
|
||||
if !kept.blocks {
|
||||
return;
|
||||
}
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
world.dirty_light.insert((key.0 + dx, key.1 + dz));
|
||||
}
|
||||
}
|
||||
cells.dirty_sections(|section| {
|
||||
world.dirty_sections.insert(section);
|
||||
});
|
||||
take_resent_column(world, key, &old, chunk, self.relight_resent);
|
||||
}
|
||||
/// A server's light update for a resident column: the sections whose
|
||||
/// light changed and their neighbours remesh
|
||||
@@ -3046,6 +3027,51 @@ fn keep_unchanged_sections(
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Takes the server's new `chunk` over the resident column `old`: the sections
|
||||
/// whose blocks did not change keep `old`'s (with `chunk`'s biomes and light),
|
||||
/// and the sections whose blocks changed remesh. `relight` says the packet's
|
||||
/// light is from before the server's relight (a Horizon server's bulk edit), so
|
||||
/// the column and its neighbours are lit here; another server's packet already
|
||||
/// carries the light vanilla applies, so no light job is queued for it.
|
||||
fn take_resent_column(
|
||||
world: &mut World,
|
||||
key: (i32, i32),
|
||||
old: &Chunk,
|
||||
mut chunk: Chunk,
|
||||
relight: bool,
|
||||
) {
|
||||
// The cells whose blocks changed, by section: they remesh with the neighbours within a block of them (as
|
||||
// vanilla's `setBlocksDirty`), not every section round each section changed (which, for the few blocks a
|
||||
// section often changes in a bulk edit, is mostly sections that did not).
|
||||
let mut cells = crate::light::LightChanges::default();
|
||||
cells.keyed(world.layout());
|
||||
let kept = keep_unchanged_sections(old, &mut chunk, &mut cells);
|
||||
chunk.light_loaded = old.light_loaded;
|
||||
chunk.lit = true;
|
||||
world.chunks.insert(key, Arc::new(chunk));
|
||||
world.revision = world.revision.wrapping_add(1);
|
||||
// A section with new biomes and the same blocks remeshes now (its tint), with its neighbours, whose tint
|
||||
// blends across the border: above and below too, as a block's tint can sample the biome quart of the
|
||||
// section next to it (`BiomeManager.getBiome`'s zoom reaches a quart beyond the block).
|
||||
Streaming::light_updated(world, key.0, key.1, &kept.biomes);
|
||||
// A section with the same blocks and new light takes the packet's light (vanilla's
|
||||
// `handleLevelChunkWithLight` always applies it) and remeshes as a light update would.
|
||||
Streaming::light_updated(world, key.0, key.1, &kept.light);
|
||||
if !kept.blocks {
|
||||
return;
|
||||
}
|
||||
if relight {
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
world.dirty_light.insert((key.0 + dx, key.1 + dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
cells.dirty_sections(|section| {
|
||||
world.dirty_sections.insert(section);
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether two sections' packed light (4,096 cells) holds the same values.
|
||||
fn same_light(a: &crate::chunk::Light, b: &crate::chunk::Light) -> bool {
|
||||
a.same_as(b)
|
||||
@@ -4215,6 +4241,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn resident_column(world: &mut World, light: u8) -> Chunk {
|
||||
let mut chunk = Chunk::empty(2, -3);
|
||||
chunk.set(1, 10, 1, 1);
|
||||
Arc::make_mut(&mut chunk.sections[0]).light = crate::chunk::Light::Uniform(light);
|
||||
chunk.lit = true;
|
||||
world.chunks.insert((2, -3), Arc::new(chunk.clone()));
|
||||
chunk
|
||||
}
|
||||
|
||||
/// A server's resent column uses the light in its packet, as vanilla's
|
||||
/// `handleLevelChunkWithLight` does: only a Horizon server's bulk edit,
|
||||
/// whose packet light is from before its relight, is relit here. A proxy's
|
||||
/// next server resends its whole world at the same coordinates, and
|
||||
/// relighting every column and its neighbours is work that world never
|
||||
/// asked for (#10273).
|
||||
#[test]
|
||||
fn a_resent_column_is_relit_only_for_a_horizon_servers_bulk_edit() {
|
||||
for relight in [false, true] {
|
||||
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
|
||||
let old = resident_column(&mut world, 0x50);
|
||||
let mut new = old.clone();
|
||||
new.set(1, 10, 1, 2);
|
||||
Arc::make_mut(&mut new.sections[0]).light = crate::chunk::Light::Uniform(0xF0);
|
||||
take_resent_column(&mut world, (2, -3), &old, new, relight);
|
||||
let chunk = &world.chunks[&(2, -3)];
|
||||
assert_eq!(chunk.block(1, 10, 1).0, 2);
|
||||
// The packet's light is what the column shows.
|
||||
assert_eq!(chunk.sections[0].light.get(0), 0xF0);
|
||||
// The changed section remeshes either way.
|
||||
assert!(world.dirty_sections.contains(&(2, 0, -3)));
|
||||
// Only a Horizon bulk edit queues light work, for the column and
|
||||
// its eight neighbours.
|
||||
assert_eq!(world.dirty_light.len(), if relight { 9 } else { 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -124,10 +124,18 @@ pub fn face(
|
||||
}
|
||||
}
|
||||
|
||||
/// `Hud.extractTabList`: whether the list shows. A single-player world
|
||||
/// shows it only with another listed player or a `list` objective.
|
||||
pub fn visible(key_down: bool, local_server: bool, listed: usize, objective: bool) -> bool {
|
||||
key_down && !(local_server && listed <= 1 && !objective)
|
||||
/// `Hud.extractTabList`: whether the list shows. `Hud.extractRenderState`
|
||||
/// only calls it inside `if (!isHidden)`, so F1 takes the list away whatever
|
||||
/// else holds it up. A single-player world shows it only with another listed
|
||||
/// player or a `list` objective.
|
||||
pub fn visible(
|
||||
hud_shown: bool,
|
||||
key_down: bool,
|
||||
local_server: bool,
|
||||
listed: usize,
|
||||
objective: bool,
|
||||
) -> bool {
|
||||
hud_shown && key_down && !(local_server && listed <= 1 && !objective)
|
||||
}
|
||||
|
||||
/// `Options.getBackgroundColor(553648127)`: the row background.
|
||||
@@ -702,8 +710,11 @@ mod tests {
|
||||
assert_eq!(ping_sprite(1000), "icon/ping_1");
|
||||
// UUID.hashCode of 0 is 0: the first default skin.
|
||||
assert_eq!(default_skin(0), "entity/player/slim/alex");
|
||||
assert!(!visible(true, true, 1, false));
|
||||
assert!(visible(true, true, 1, true));
|
||||
assert!(visible(true, false, 1, false));
|
||||
assert!(!visible(true, true, true, 1, false));
|
||||
assert!(visible(true, true, true, 1, true));
|
||||
assert!(visible(true, true, false, 1, false));
|
||||
// F1 (`Hud.isHidden`) takes the list away however it was held up.
|
||||
assert!(!visible(false, true, false, 2, false));
|
||||
assert!(!visible(false, true, true, 1, true));
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,8 @@
|
||||
"horizon.extras.no_ai": "Keine KI",
|
||||
"horizon.extras.agent_control": "Agentensteuerung",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.zoom": "Zoom",
|
||||
"horizon.extras.zoom_mod_install": "Zoom: über Mods installieren",
|
||||
"horizon.extras.fullbright.tooltip": "Zeigt Blöcke und Objekte bei vollem Licht, selbst an dunklen Orten.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Fackeln und andere Lichtquellen, die gehalten, fallen gelassen werden oder brennen, beleuchten die Blöcke um sich herum.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Zeigt deinen Namen in der dritten Person über deinem Kopf an, so wie andere Spieler ihn sehen. Versteckt, wenn der Server die Namen deines Teams verbirgt oder du unsichtbar bist.",
|
||||
|
||||
@@ -322,6 +322,8 @@
|
||||
"horizon.extras.no_ai": "No AI",
|
||||
"horizon.extras.agent_control": "Agent Control",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.zoom": "Zoom",
|
||||
"horizon.extras.zoom_mod_install": "Zoom: install from Mods",
|
||||
"horizon.extras.splitscreen": "Splitscreen",
|
||||
"horizon.extras.chat_animation": "Smooth chat",
|
||||
"horizon.extras.chat_indicator": "Message indicator",
|
||||
|
||||
@@ -242,6 +242,8 @@
|
||||
"horizon.extras.no_ai": "Bez AI",
|
||||
"horizon.extras.agent_control": "Sterowanie agentem",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.zoom": "Zoom",
|
||||
"horizon.extras.zoom_mod_install": "Zoom: zainstaluj w Modach",
|
||||
"horizon.extras.fullbright.tooltip": "Pokazuje bloki i byty w pełnym oświetleniu, nawet w ciemnych miejscach.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Pochodnie i inne źródła światła trzymane w dłoni, upuszczone lub płonące oświetlają otaczające bloki.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Pokazuje twój nick nad głową w widoku z trzeciej osoby, tak jak widzą go inni gracze. Ukryty, gdy serwer ukrywa nazwy twojej drużyny lub jesteś niewidzialny.",
|
||||
|
||||
@@ -220,6 +220,7 @@ fn goal_name(m: &Mob, name: &str) -> String {
|
||||
"RandomSwimming" => "RandomSwimmingGoal",
|
||||
"LookAtPlayer" => "LookAtPlayerGoal",
|
||||
"RandomLookAround" => "RandomLookAroundGoal",
|
||||
"SpearUse" => "SpearUseGoal",
|
||||
"HurtByTarget" => "HurtByTargetGoal",
|
||||
"NearestTarget" => "NearestAttackableTargetGoal",
|
||||
"Panic" => "PanicGoal",
|
||||
@@ -1449,7 +1450,13 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let tag = horizon_client::entity_nbt::mob_to_nbt(&mobs[i], [0; 4], None, None);
|
||||
let tag = horizon_client::entity_nbt::mob_to_nbt(
|
||||
&mobs[i],
|
||||
[0; 4],
|
||||
None,
|
||||
None,
|
||||
|_| None,
|
||||
);
|
||||
let flower = horizon_client::mobs::bee::get(&mobs[i]).and_then(|b| b.flower);
|
||||
let mut honey = Vec::new();
|
||||
let stored = {
|
||||
|
||||
@@ -355,7 +355,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
let mob =
|
||||
entity_nbt::mob_from_nbt(&tag, &types, 1, None).ok_or("unreadable villager")?;
|
||||
let back = entity_nbt::mob_to_nbt(&mob, [0; 4], None, None);
|
||||
let back = entity_nbt::mob_to_nbt(&mob, [0; 4], None, None, |_| None);
|
||||
writeln!(out, "{name} snbt {text}")?;
|
||||
let mut kept = horizon_world::nbt::Compound::new();
|
||||
for key in [
|
||||
|
||||
@@ -370,6 +370,8 @@ impl ArrowScene<'_, '_> {
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
// CollisionContext.of(arrow): scaffolding is open from below.
|
||||
let context = horizon_world::collision_context::CollisionContext::position(from.y);
|
||||
let hit = horizon_core::geometry::traverse_blocks(from, to, 1024, |p: BlockPos| {
|
||||
if Some(p) == ignored {
|
||||
return None;
|
||||
@@ -380,7 +382,7 @@ impl ArrowScene<'_, '_> {
|
||||
}
|
||||
let shape = self
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.collision_shape_with_context(&mut scene, state, p, &context)
|
||||
.ok()?;
|
||||
shape.clip(from, to, p)
|
||||
});
|
||||
@@ -447,13 +449,15 @@ pub fn clip_block_hit(
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
// CollisionContext.of(projectile): scaffolding is open from below.
|
||||
let context = horizon_world::collision_context::CollisionContext::position(from.y);
|
||||
let hit = horizon_core::geometry::traverse_blocks(from, to, 1024, |p: BlockPos| {
|
||||
let state = world.block(p.x, p.y, p.z);
|
||||
if facts.is_air(state) {
|
||||
return None;
|
||||
}
|
||||
let shape = collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.collision_shape_with_context(&mut scene, state, p, &context)
|
||||
.ok()?;
|
||||
shape.clip(from, to, p)
|
||||
})
|
||||
|
||||
@@ -467,6 +467,11 @@ impl Creative {
|
||||
pub fn tab(&self) -> &Tab {
|
||||
&self.tabs[self.selected.min(self.tabs.len() - 1)]
|
||||
}
|
||||
/// The search tab's index (`CreativeModeTabs.searchTab`), the tab the chat
|
||||
/// key opens while another one is shown.
|
||||
pub fn search_tab(&self) -> Option<usize> {
|
||||
self.tabs.iter().position(|t| t.search)
|
||||
}
|
||||
/// selectTab: a different tab empties the search box; the scrollbar goes to the top.
|
||||
/// A tab on another page shows that page.
|
||||
pub fn select(&mut self, tab: usize) {
|
||||
@@ -480,10 +485,13 @@ impl Creative {
|
||||
self.page = t.page;
|
||||
}
|
||||
self.scroll = 0.0;
|
||||
if self.tab().search {
|
||||
self.search.focused = true;
|
||||
// The search box is shown, focused and impossible to unfocus only on
|
||||
// its own tab; another tab hides it and takes the focus away.
|
||||
let search = self.tab().search;
|
||||
if search {
|
||||
self.search.reset_blink();
|
||||
}
|
||||
self.search.focused = search;
|
||||
}
|
||||
/// How many pages of tabs there are (1 without mods' tabs).
|
||||
pub fn pages(&self) -> u32 {
|
||||
@@ -1438,6 +1446,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.keyPressed`: from a tab that is not the
|
||||
/// search, the chat key selects `CreativeModeTabs.searchTab`, which focuses
|
||||
/// the search box and empties the query the other tab left.
|
||||
#[test]
|
||||
fn the_chat_key_opens_the_search_tab() {
|
||||
let mut c = creative(0);
|
||||
c.search.set_value("anvil");
|
||||
let tab = c.search_tab().expect("the fixture has a search tab");
|
||||
assert!(c.tabs[tab].search);
|
||||
assert!(!c.tab().search);
|
||||
c.select(tab);
|
||||
assert!(c.tab().search);
|
||||
assert!(c.search.focused);
|
||||
assert_eq!(c.search.value, "", "another tab empties the query");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_vanilla_trees() {
|
||||
let c = creative(0);
|
||||
@@ -1536,6 +1560,26 @@ mod tests {
|
||||
assert!(!c.scrolling);
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.selectTab`: the search box is focused on
|
||||
/// its own tab and loses the focus when another tab is shown (and, hidden,
|
||||
/// is no longer the field a key types into).
|
||||
#[test]
|
||||
fn the_search_box_loses_the_focus_with_its_tab() {
|
||||
let mut c = creative(0);
|
||||
let search = c
|
||||
.tabs
|
||||
.iter()
|
||||
.position(|t| t.search)
|
||||
.expect("the fixture has a search tab");
|
||||
c.select(search);
|
||||
assert!(c.search.focused);
|
||||
c.select(0);
|
||||
assert!(!c.search.focused, "another tab takes the focus away");
|
||||
assert!(!c.tab().search);
|
||||
c.select(search);
|
||||
assert!(c.search.focused, "and the search tab takes it back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creative_tab_titles_have_vanilla_keys() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -120,7 +120,13 @@ mod drop_motion_tests {
|
||||
[
|
||||
-4627867408251048793,
|
||||
4597245257657090048,
|
||||
4597448471913462163,
|
||||
// Vanilla's `Math.sin` is HotSpot's intrinsic, which rounds this
|
||||
// one a bit lower on AArch64 than on x86-64 (`jmath`).
|
||||
if cfg!(target_arch = "aarch64") {
|
||||
4597448471913462162
|
||||
} else {
|
||||
4597448471913462163
|
||||
},
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -409,6 +415,29 @@ impl ItemEntity {
|
||||
}
|
||||
impl ItemEntity {
|
||||
pub const WIDTH: f64 = 0.25;
|
||||
/// `EntityType.ITEM` declares `eyeHeight(0.2125)`: an item is lit from the
|
||||
/// block its eye is in, most of a block above the one its base rests in.
|
||||
pub const EYE_HEIGHT: f64 = 0.2125;
|
||||
/// `Entity.getLightProbePosition(partialTick)` for a dropped item: the eye
|
||||
/// position (`getEyePosition`) over the position drawn this frame
|
||||
/// (`getPosition(partialTick)`), not this tick's own.
|
||||
pub fn light_probe(&self, partial: f64) -> [f64; 3] {
|
||||
[
|
||||
self.previous.x + (self.position.x - self.previous.x) * partial,
|
||||
self.previous.y + (self.position.y - self.previous.y) * partial + Self::EYE_HEIGHT,
|
||||
self.previous.z + (self.position.z - self.previous.z) * partial,
|
||||
]
|
||||
}
|
||||
/// `EntityRenderer.getPackedLightCoords` for a dropped item: the level's
|
||||
/// sky light over the block light of the cell it is lit from, which
|
||||
/// `EntityRenderer.getBlockLightLevel` puts at 15 while the item
|
||||
/// `isOnFire`. `light` is the level's packed light (sky high, block low);
|
||||
/// `environment_tick` clears the fire ticks of a fire-immune stack, as
|
||||
/// `ItemEntity.fireImmune` makes it never on fire.
|
||||
pub fn render_light(&self, light: u8) -> u32 {
|
||||
let block = if self.fire_ticks > 0 { 15 } else { light & 15 };
|
||||
(u32::from(light >> 4) * 16) | ((u32::from(block) * 16) << 8)
|
||||
}
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
let h = Self::WIDTH / 2.0;
|
||||
Aabb::new(
|
||||
@@ -1896,6 +1925,84 @@ impl ArmorStand {
|
||||
pub fn render_position(&self, alpha: f64) -> Vec3 {
|
||||
self.previous.lerp(self.position, alpha)
|
||||
}
|
||||
/// `ArmorStand.getDimensionsMarker(false)`: the width, height and eye
|
||||
/// height of the box a stand has without the marker flag, which is the box
|
||||
/// a marker's light probe walks (`MARKER_DIMENSIONS` itself is empty, and
|
||||
/// `isBaby()` is `isSmall()`).
|
||||
pub fn probe_dimensions(&self) -> (f64, f64, f64) {
|
||||
if self.small {
|
||||
(0.25, 0.9875, 0.9875)
|
||||
} else {
|
||||
(0.5, 1.975, 1.7775)
|
||||
}
|
||||
}
|
||||
/// `ArmorStand.getLightProbePosition` as the cell the renderer reads
|
||||
/// (`BlockPos.containing` of the position it returns): a marker walks
|
||||
/// `BlockPos.betweenClosed` of its non-marker box at `position()` - it
|
||||
/// takes no partial tick - and returns the first cell whose brightness
|
||||
/// `max(block, sky)` is 15, else the brightest, ties going to the first
|
||||
/// cell walked; every other stand is lit from its eye
|
||||
/// (`Entity.getLightProbePosition` is `getEyePosition(partialTick)`).
|
||||
/// `light` is the level's packed light.
|
||||
pub fn light_probe(
|
||||
&self,
|
||||
partial: f64,
|
||||
mut light: impl FnMut(i32, i32, i32) -> u8,
|
||||
) -> [i32; 3] {
|
||||
let (width, height, eye) = self.probe_dimensions();
|
||||
if !self.marker {
|
||||
let at = self.render_position(partial);
|
||||
return [
|
||||
at.x.floor() as i32,
|
||||
(at.y + eye).floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
];
|
||||
}
|
||||
let half = width / 2.0;
|
||||
let (lo_x, hi_x) = (
|
||||
(self.position.x - half).floor() as i32,
|
||||
(self.position.x + half).floor() as i32,
|
||||
);
|
||||
let (lo_z, hi_z) = (
|
||||
(self.position.z - half).floor() as i32,
|
||||
(self.position.z + half).floor() as i32,
|
||||
);
|
||||
let (lo_y, hi_y) = (
|
||||
self.position.y.floor() as i32,
|
||||
(self.position.y + height).floor() as i32,
|
||||
);
|
||||
// `BlockPos.betweenClosed` walks x fastest, then y, then z; the stand's
|
||||
// own cell is what it falls back to when the box is empty.
|
||||
let mut best = [
|
||||
self.position.x.floor() as i32,
|
||||
self.position.y.floor() as i32,
|
||||
self.position.z.floor() as i32,
|
||||
];
|
||||
let mut brightest = i32::MIN;
|
||||
for cell_z in lo_z..=hi_z {
|
||||
for cell_y in lo_y..=hi_y {
|
||||
for cell_x in lo_x..=hi_x {
|
||||
let level = light(cell_x, cell_y, cell_z);
|
||||
let brightness = i32::from((level & 15).max(level >> 4));
|
||||
if brightness == 15 {
|
||||
return [cell_x, cell_y, cell_z];
|
||||
}
|
||||
if brightness > brightest {
|
||||
brightest = brightness;
|
||||
best = [cell_x, cell_y, cell_z];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
/// `EntityRenderer.getPackedLightCoords` for the cell `light_probe` chose:
|
||||
/// the level's sky light over the cell's block light, at 15 block light
|
||||
/// while the stand `isOnFire`. `light` is the level's packed light.
|
||||
pub fn render_light(&self, light: u8) -> u32 {
|
||||
let block = if self.fire_ticks > 0 { 15 } else { light & 15 };
|
||||
(u32::from(light >> 4) * 16) | ((u32::from(block) * 16) << 8)
|
||||
}
|
||||
/// The body's yaw `alpha` of the way through the tick
|
||||
/// (`LivingEntityRenderer.solveBodyRot`: `Mth.rotLerp(partialTick,
|
||||
/// yBodyRotO, yBodyRot)`).
|
||||
@@ -2573,6 +2680,113 @@ mod armor_stand_render_tests {
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod armor_stand_light_tests {
|
||||
use super::*;
|
||||
|
||||
fn stand(x: f64, y: f64, z: f64) -> ArmorStand {
|
||||
ArmorStand::new(1, Vec3::new(x, y, z), 0.0)
|
||||
}
|
||||
|
||||
/// `ArmorStand.getLightProbePosition` for a stand that is not a marker is
|
||||
/// `Entity.getEyePosition`: `EntityType.ARMOR_STAND` declares
|
||||
/// `eyeHeight(1.7775)`, and a small stand (`isBaby()`) is the same type
|
||||
/// scaled by 0.5 with `withEyeHeight(0.9875)`. Horizon read
|
||||
/// `floor(base + 0.5)` for every stand, so a stand standing on a lit floor
|
||||
/// took the floor's light instead of the light at its head.
|
||||
#[test]
|
||||
fn a_stand_is_lit_from_the_block_its_eye_is_in() {
|
||||
let dark = |_: i32, _: i32, _: i32| 0u8;
|
||||
// Full stand on y = 64: eye 65.7775, cell 65 (the old 64.5 was 64).
|
||||
assert_eq!(stand(0.5, 64.0, 0.5).light_probe(1.0, dark), [0, 65, 0]);
|
||||
assert_eq!((64.0_f64 + 0.5).floor() as i32, 64);
|
||||
// Small stand: eye 0.9875 above its base, so y = 64.2 is 65.1875.
|
||||
let mut small = stand(0.5, 64.2, 0.5);
|
||||
small.small = true;
|
||||
assert_eq!(small.light_probe(1.0, dark), [0, 65, 0]);
|
||||
assert_eq!((64.2_f64 + 0.5).floor() as i32, 64);
|
||||
// A small stand at y = 64.0 stays in its own cell: eye 64.9875.
|
||||
let mut low = stand(0.5, 64.0, 0.5);
|
||||
low.small = true;
|
||||
assert_eq!(low.light_probe(1.0, dark), [0, 64, 0]);
|
||||
}
|
||||
|
||||
/// `getEyePosition(partialTick)` interpolates: a stand falling from 70 to
|
||||
/// 69 is probed 0.5 down at the middle of the frame, 69.5 + 1.7775 in the
|
||||
/// cell at 71, while its feet's cell is 69.
|
||||
#[test]
|
||||
fn a_stand_probe_follows_the_frame() {
|
||||
let dark = |_: i32, _: i32, _: i32| 0u8;
|
||||
let mut falling = stand(0.5, 70.0, 0.5);
|
||||
falling.interpolate(Some((Vec3::new(0.5, 69.0, 0.5), 0.0)));
|
||||
assert_eq!(falling.light_probe(0.0, dark), [0, 71, 0]);
|
||||
assert_eq!(falling.light_probe(0.5, dark), [0, 71, 0]);
|
||||
assert_eq!(falling.light_probe(1.0, dark), [0, 70, 0]);
|
||||
assert_eq!(falling.position.y.floor() as i32, 69);
|
||||
}
|
||||
|
||||
/// A marker walks `BlockPos.betweenClosed` of its own box - the size it has
|
||||
/// without the marker flag, 0.5 by 1.975 - from `position()`, taking the
|
||||
/// first cell at brightness 15 and else the brightest. Horizon used the
|
||||
/// stand's own cell, so a marker (a hologram on a dark ceiling) was lit by
|
||||
/// its feet's block.
|
||||
#[test]
|
||||
fn a_marker_scans_the_cells_of_its_own_box() {
|
||||
let at = |want: [i32; 3], level: u8| {
|
||||
move |x: i32, y: i32, z: i32| {
|
||||
if [x, y, z] == want { level } else { 0 }
|
||||
}
|
||||
};
|
||||
let mut marker = stand(0.5, 64.0, 0.5);
|
||||
marker.marker = true;
|
||||
// The box is x/z 0.25..0.75 - the cell at 0 - and y 64..65.975.
|
||||
assert_eq!(marker.light_probe(1.0, at([0, 65, 0], 15)), [0, 65, 0]);
|
||||
assert_eq!(marker.light_probe(1.0, at([0, 65, 0], 9)), [0, 65, 0]);
|
||||
// Nothing lit: the first cell walked, x fastest then y then z.
|
||||
assert_eq!(marker.light_probe(1.0, |_, _, _| 0u8), [0, 64, 0]);
|
||||
// The cell above the box is not scanned, so its 15 does not win.
|
||||
assert_eq!(marker.light_probe(1.0, at([0, 66, 0], 15)), [0, 64, 0]);
|
||||
// 15 wins over a brighter-looking neighbour, and y is walked before z:
|
||||
// (0, 65, 0) comes before (0, 64, 1) though both are at 15.
|
||||
let mut wide = stand(0.5, 64.0, 0.9);
|
||||
wide.marker = true;
|
||||
let two = |x: i32, y: i32, z: i32| {
|
||||
if (x, y, z) == (0, 65, 0) || (x, y, z) == (0, 64, 1) {
|
||||
15
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
assert_eq!(wide.light_probe(1.0, two), [0, 65, 0]);
|
||||
// `getLightProbePosition`'s marker branch reads `position()`, not the
|
||||
// partial-tick position: falling does not move the scan.
|
||||
let mut moving = stand(0.5, 64.0, 0.5);
|
||||
moving.marker = true;
|
||||
moving.interpolate(Some((Vec3::new(0.5, 70.0, 0.5), 0.0)));
|
||||
assert_eq!(moving.light_probe(0.0, |_, _, _| 0u8), [0, 70, 0]);
|
||||
assert_eq!(moving.light_probe(0.5, |_, _, _| 0u8), [0, 70, 0]);
|
||||
}
|
||||
|
||||
/// `EntityRenderer.getBlockLightLevel`: a stand `isOnFire` is drawn at 15
|
||||
/// block light whatever the level says, and the sky light is the probe
|
||||
/// cell's own.
|
||||
#[test]
|
||||
fn a_burning_stand_is_drawn_at_full_block_light() {
|
||||
let light = |sky: u8, block: u8| (sky << 4) | block;
|
||||
let cold = stand(0.5, 64.0, 0.5);
|
||||
assert_eq!(cold.render_light(light(15, 0)), (15 * 16) as u32);
|
||||
assert_eq!(cold.render_light(light(0, 9)), ((9 * 16) << 8) as u32);
|
||||
let burning = ArmorStand {
|
||||
fire_ticks: 100,
|
||||
..stand(0.5, 64.0, 0.5)
|
||||
};
|
||||
assert_eq!(
|
||||
burning.render_light(light(15, 0)),
|
||||
((15 * 16) | ((15 * 16) << 8)) as u32
|
||||
);
|
||||
assert_eq!(burning.render_light(light(0, 0)), ((15 * 16) << 8) as u32);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod merge_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -4247,6 +4461,104 @@ mod render_light_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dropped_item_light_tests {
|
||||
use super::*;
|
||||
|
||||
fn item(previous_y: f64, y: f64) -> ItemEntity {
|
||||
ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 1,
|
||||
thrower: None,
|
||||
target: None,
|
||||
stack: ItemStack {
|
||||
item: std::sync::Arc::from("minecraft:stone"),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
},
|
||||
health: 5,
|
||||
fire_ticks: 0,
|
||||
position: Vec3::new(0.5, y, 0.5),
|
||||
previous: Vec3::new(0.5, previous_y, 0.5),
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
ticks: 0,
|
||||
needs_sync: false,
|
||||
stuck: Vec3::ZERO,
|
||||
was_touching_water: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.getLightProbePosition` is the eye position, and `EntityType.ITEM`
|
||||
/// declares `eyeHeight(0.2125)`. The item used to be lit from the cell of
|
||||
/// `base + 0.1`, so a falling item at y = 64.85 read the cell at 64 (block
|
||||
/// light of the floor) while its eye at 65.0625 is in the cell at 65.
|
||||
#[test]
|
||||
fn a_dropped_item_is_lit_from_the_block_its_eye_is_in() {
|
||||
let falling = item(64.85, 64.85);
|
||||
let probe = falling.light_probe(1.0);
|
||||
assert!((probe[1] - 65.062_5).abs() < 1e-9);
|
||||
assert_eq!(probe[1].floor(), 65.0);
|
||||
// The old probe, `(y + 0.1).floor()`, and the base cell itself.
|
||||
assert_eq!((64.85_f64 + 0.1).floor(), 64.0);
|
||||
assert_eq!(64.85_f64.floor(), 64.0);
|
||||
// Resting on a block top the two agree: the eye is 0.2125 up.
|
||||
let resting = item(64.0, 64.0);
|
||||
assert_eq!(resting.light_probe(1.0)[1].floor(), 64.0);
|
||||
assert!((resting.light_probe(1.0)[1] - 64.212_5).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// `Entity.getLightProbePosition(partialTick)` follows `getPosition(partialTick)`:
|
||||
/// an item falling a block in one tick is probed halfway between the two
|
||||
/// positions in the middle of the frame. The old code read the tick's own
|
||||
/// position, so the light jumped a frame early.
|
||||
#[test]
|
||||
fn an_item_probe_follows_the_frame() {
|
||||
let falling = item(64.0, 65.0);
|
||||
let mid = falling.light_probe(0.5);
|
||||
assert!((mid[1] - 64.712_5).abs() < 1e-9);
|
||||
assert_eq!(mid[1].floor(), 64.0);
|
||||
assert_eq!(falling.light_probe(1.0)[1].floor(), 65.0);
|
||||
// Across x and z too, not just down.
|
||||
let sliding = ItemEntity {
|
||||
position: Vec3::new(1.5, 64.0, 0.5),
|
||||
previous: Vec3::new(0.5, 64.0, 0.5),
|
||||
..item(64.0, 64.0)
|
||||
};
|
||||
assert!((sliding.light_probe(0.5)[0] - 1.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// `EntityRenderer.getBlockLightLevel` is `isOnFire() ? 15 : the level's
|
||||
/// block light`, and the sky light is never overridden. A dropped item was
|
||||
/// drawn at the level's block light while it burned.
|
||||
#[test]
|
||||
fn a_burning_item_is_drawn_at_full_block_light() {
|
||||
let light = |sky: u8, block: u8| (sky << 4) | block;
|
||||
let cold = item(64.0, 64.0);
|
||||
assert_eq!(cold.render_light(light(15, 0)), (15 * 16) as u32);
|
||||
assert_eq!(cold.render_light(light(0, 9)), ((9 * 16) << 8) as u32);
|
||||
let burning = ItemEntity {
|
||||
fire_ticks: 160,
|
||||
..item(64.0, 64.0)
|
||||
};
|
||||
// Daylight, dark block: the block light goes to 15, the sky stays.
|
||||
assert_eq!(
|
||||
burning.render_light(light(15, 0)),
|
||||
((15 * 16) | ((15 * 16) << 8)) as u32
|
||||
);
|
||||
// A dark cave: 15 block light, still no invented sky light.
|
||||
assert_eq!(burning.render_light(light(0, 0)), ((15 * 16) << 8) as u32);
|
||||
}
|
||||
}
|
||||
crate::impl_joined!(ItemEntity, ExperienceOrb, ArmorStand);
|
||||
#[cfg(test)]
|
||||
mod orb_lava_tests {
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::{
|
||||
arrows::Pickup,
|
||||
entities::{ArmorStand, Cushion, ExperienceOrb, ItemEntity, ItemFrame, Painting},
|
||||
mobs::{Mob, MobType, Who},
|
||||
nbt_path::int_value,
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
save::{
|
||||
doubles, float, floats, item_from_nbt, item_to_nbt, number, put, read_doubles, text,
|
||||
@@ -170,6 +171,39 @@ fn player_uuid() -> [i32; 4] {
|
||||
u as u32 as i32,
|
||||
]
|
||||
}
|
||||
/// The UUID a save writes for a reference to `who`. A mob is only an id here:
|
||||
/// its UUID lives in the game layer (`EntityMemory.uuids`), which is what
|
||||
/// `uuid_of` answers from. When it answers `None` (the mob is no longer in the
|
||||
/// level) nothing is written, rather than a made-up UUID that would name nothing
|
||||
/// when the save is read back. The reference comes back with [`who_by_uuid`].
|
||||
fn who_uuid(who: Who, uuid_of: &mut impl FnMut(u32) -> Option<[i32; 4]>) -> Option<[i32; 4]> {
|
||||
match who {
|
||||
Who::Player => Some(player_uuid()),
|
||||
Who::Mob(id) => uuid_of(id),
|
||||
}
|
||||
}
|
||||
/// The entity a stored UUID names, while it is in the level: `player` (the
|
||||
/// player's own UUID, when one is in the level), or the mob `uuids` gives that
|
||||
/// UUID to. `uuids` is the game layer's id -> UUID map, so this is the read half
|
||||
/// of the pair [`who_uuid`] and `mob_to_nbt`'s `uuid_of` form. A UUID nothing
|
||||
/// loaded has, or one naming a kind `Who` cannot carry (an item, a projectile, a
|
||||
/// guest), is `None` - never some other entity as a stand-in.
|
||||
pub fn who_by_uuid(
|
||||
uuids: &HashMap<u32, [i32; 4]>,
|
||||
mobs: &[Mob],
|
||||
player: Option<[i32; 4]>,
|
||||
uuid: [i32; 4],
|
||||
) -> Option<Who> {
|
||||
if player == Some(uuid) {
|
||||
return Some(Who::Player);
|
||||
}
|
||||
// Only a loaded mob: the map also holds projectiles, vehicles and items,
|
||||
// whose ids mean nothing to `Who`.
|
||||
uuids
|
||||
.iter()
|
||||
.find(|(id, u)| **u == uuid && mobs.iter().any(|m| m.id == **id))
|
||||
.map(|(id, _)| Who::Mob(*id))
|
||||
}
|
||||
/// `ResolvableProfile.CODEC`: the name, id and properties of a profile, then the
|
||||
/// skin's texture ids and model (`PlayerSkin.Patch`: "texture", "cape", "elytra", "model").
|
||||
fn profile_nbt(p: &horizon_protocol::entity_data::Profile) -> Tag {
|
||||
@@ -343,11 +377,17 @@ fn is_air(registry: &BlockRegistry, state: BlockStateId) -> bool {
|
||||
/// state (an enderman's `carriedBlockState`): without one, a caller that has no
|
||||
/// resources (unit tests, structure templates) keeps whatever the loaded
|
||||
/// compound had for it.
|
||||
///
|
||||
/// `uuid_of` is how a field that names another entity by UUID (a mob's
|
||||
/// `angry_at`) is written: it answers the UUID of a mob by its id, from the game
|
||||
/// layer's map, and `None` for one the level no longer has. A caller with no
|
||||
/// such map passes `|_| None` and writes no reference.
|
||||
pub fn mob_to_nbt(
|
||||
m: &Mob,
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&Tag>,
|
||||
registry: Option<&BlockRegistry>,
|
||||
mut uuid_of: impl FnMut(u32) -> Option<[i32; 4]>,
|
||||
) -> Tag {
|
||||
use crate::mobs::Species as S;
|
||||
let base = Base {
|
||||
@@ -429,8 +469,8 @@ pub fn mob_to_nbt(
|
||||
-1
|
||||
}),
|
||||
);
|
||||
if m.extra.angry_at == Some(crate::mobs::Who::Player) {
|
||||
put(&mut tag, "angry_at", Tag::IntArray(player_uuid().to_vec()));
|
||||
if let Some(uuid) = m.extra.angry_at.and_then(|who| who_uuid(who, &mut uuid_of)) {
|
||||
put(&mut tag, "angry_at", Tag::IntArray(uuid.to_vec()));
|
||||
}
|
||||
}
|
||||
// `Mannequin.addAdditionalSaveData`: the profile whose skin it wears, whether it
|
||||
@@ -1223,6 +1263,11 @@ pub fn mob_to_nbt(
|
||||
map.insert("drop_chances".into(), Tag::compound(set));
|
||||
}
|
||||
}
|
||||
// SpellcasterIllager.addAdditionalSaveData: save the live casting timer,
|
||||
// including zero and negative values, rather than stale loaded NBT.
|
||||
if matches!(m.species(), S::Evoker | S::Illusioner) {
|
||||
map.insert("SpellTicks".into(), Tag::Int(m.extra.counter));
|
||||
}
|
||||
crate::mobs::raider::write_nbt(m, map);
|
||||
crate::mobs::riders::write_nbt(m, map);
|
||||
// LivingEntity equipment: what the mob holds now.
|
||||
@@ -1700,12 +1745,12 @@ pub fn mob_from_nbt(
|
||||
m.extra.drop_chances = Some(Box::new(chances));
|
||||
}
|
||||
if saves_age(m.species()) {
|
||||
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
|
||||
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
|
||||
m.extra.age = tag.get("Age").and_then(int_value).unwrap_or(0);
|
||||
m.extra.forced_age = tag.get("ForcedAge").and_then(int_value).unwrap_or(0);
|
||||
m.extra.age_locked = flag(tag, "AgeLocked");
|
||||
}
|
||||
if is_animal(&m.kind.id) {
|
||||
m.extra.in_love = number(tag.get("InLove")).unwrap_or(0) as i32;
|
||||
m.extra.in_love = tag.get("InLove").and_then(int_value).unwrap_or(0);
|
||||
// Animal.readAdditionalSaveData: the player who fed it into love.
|
||||
m.extra.love_cause = crate::save::uuid_from_tag(tag.get("LoveCause"));
|
||||
}
|
||||
@@ -2194,6 +2239,14 @@ pub fn mob_from_nbt(
|
||||
// one spent on the next tick.
|
||||
m.extra.counter2 = if time == -1 { 0 } else { time.max(0) + 1 };
|
||||
}
|
||||
// SpellcasterIllager.readAdditionalSaveData: this is the same timer used
|
||||
// by casting goals and the existing customServerAiStep countdown.
|
||||
if matches!(
|
||||
m.species(),
|
||||
crate::mobs::Species::Evoker | crate::mobs::Species::Illusioner
|
||||
) {
|
||||
m.extra.counter = number(tag.get("SpellTicks")).unwrap_or(0) as i32;
|
||||
}
|
||||
crate::mobs::raider::read_nbt(&mut m, tag);
|
||||
crate::mobs::riders::read_nbt(&mut m, tag);
|
||||
// A mob saved mid-death finishes dying; one saved dead is dropped.
|
||||
@@ -2259,7 +2312,9 @@ pub fn tnt_power(tag: &Tag) -> f32 {
|
||||
}
|
||||
|
||||
/// `PrimedTnt.addAdditionalSaveData`: its `fuse` and, when it is not the
|
||||
/// default, its `explosion_power`, and the encoded live block state.
|
||||
/// default, its `explosion_power`, the encoded live block state, and its
|
||||
/// `owner` (`EntityReference.store`). With no live owner, a loaded `owner`
|
||||
/// stays as the raw compound has it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn tnt_to_nbt(
|
||||
position: Vec3,
|
||||
@@ -2270,6 +2325,7 @@ pub fn tnt_to_nbt(
|
||||
block_state: Tag,
|
||||
rotation: [f32; 2],
|
||||
uuid: [i32; 4],
|
||||
owner: Option<[i32; 4]>,
|
||||
raw: Option<&Tag>,
|
||||
) -> Tag {
|
||||
let mut tag = Base {
|
||||
@@ -2287,8 +2343,15 @@ pub fn tnt_to_nbt(
|
||||
put(&mut tag, "explosion_power", Tag::Float(power.to_bits()));
|
||||
}
|
||||
put(&mut tag, "block_state", block_state);
|
||||
if let Some(owner) = owner {
|
||||
put(&mut tag, "owner", Tag::IntArray(owner.to_vec()));
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// `PrimedTnt.readAdditionalSaveData`: `EntityReference.read(input, "owner")`.
|
||||
pub fn tnt_owner(tag: &Tag) -> Option<[i32; 4]> {
|
||||
uuid_from_tag(tag.get("owner"))
|
||||
}
|
||||
/// `EvokerFangs.addAdditionalSaveData`: always save the live warmup counter.
|
||||
/// Known Owner UUIDs use `EntityReference.store` encoding; unresolved
|
||||
/// loaded Owner data remains in the raw compound.
|
||||
@@ -3228,14 +3291,18 @@ pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Ta
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// `ItemFrame.readAdditionalSaveData`: an absent Facing defaults to Direction.DOWN.
|
||||
pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option<ItemFrame> {
|
||||
let pos = attached_block_pos(tag)?;
|
||||
Some(ItemFrame {
|
||||
id,
|
||||
pos,
|
||||
facing: number(tag.get("Facing")).unwrap_or(2).clamp(0, 5) as u8,
|
||||
facing: number(tag.get("Facing")).unwrap_or(0).clamp(0, 5) as u8,
|
||||
glow: kind(tag).as_deref() == Some("minecraft:glow_item_frame"),
|
||||
item: tag.get("Item").and_then(item_from_nbt),
|
||||
item: tag
|
||||
.get("Item")
|
||||
.and_then(item_from_nbt)
|
||||
.map(|s| crate::items::ItemStack { count: 1, ..s }),
|
||||
rotation: (number(tag.get("ItemRotation")).unwrap_or(0) & 7) as u8,
|
||||
drop_chance: float(tag.get("ItemDropChance")).map_or(1.0, |v| v as f32),
|
||||
fixed: flag(tag, "Fixed"),
|
||||
@@ -3661,6 +3728,7 @@ mod tnt_power_tests {
|
||||
[0.0, 0.0],
|
||||
[1, 2, 3, 4],
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(tnt_power(&saved), 10.0);
|
||||
let plain = tnt_to_nbt(
|
||||
@@ -3673,9 +3741,108 @@ mod tnt_power_tests {
|
||||
[0.0, 0.0],
|
||||
[1, 2, 3, 4],
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(plain.get("explosion_power").is_none());
|
||||
}
|
||||
|
||||
/// `EntityReference.store(owner, output, "owner")`: the igniter's UUID as
|
||||
/// an int array, read back by `EntityReference.read`; an owner the save
|
||||
/// could not name keeps the loaded one from the raw compound.
|
||||
#[test]
|
||||
fn the_owner_round_trips_as_a_uuid() {
|
||||
let save = |owner, raw: Option<&Tag>| {
|
||||
let t = string("minecraft:tnt");
|
||||
tnt_to_nbt(
|
||||
Vec3::ZERO,
|
||||
Vec3::ZERO,
|
||||
false,
|
||||
80,
|
||||
4.0,
|
||||
t,
|
||||
[0.0; 2],
|
||||
[1; 4],
|
||||
owner,
|
||||
raw,
|
||||
)
|
||||
};
|
||||
let saved = save(Some([5, 6, 7, 8]), None);
|
||||
assert_eq!(saved.get("owner"), Some(&Tag::IntArray(vec![5, 6, 7, 8])));
|
||||
assert_eq!(tnt_owner(&saved), Some([5, 6, 7, 8]));
|
||||
assert_eq!(tnt_owner(&save(None, None)), None);
|
||||
assert_eq!(tnt_owner(&save(None, Some(&saved))), Some([5, 6, 7, 8]));
|
||||
assert_eq!(tnt_owner(&save(Some([9; 4]), Some(&saved))), Some([9; 4]));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod spell_ticks_nbt_tests {
|
||||
use super::*;
|
||||
use crate::mobs::Species;
|
||||
|
||||
fn caster(species: Species, name: &str) -> Mob {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
ty.species = species;
|
||||
ty.id = format!("minecraft:{name}");
|
||||
Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0)
|
||||
}
|
||||
|
||||
/// `SpellcasterIllager.readAdditionalSaveData`: bind loaded values to the
|
||||
/// counter already used by casting goals and customServerAiStep.
|
||||
#[test]
|
||||
fn spell_ticks_load_into_the_live_counter() {
|
||||
for (species, name) in [
|
||||
(Species::Evoker, "evoker"),
|
||||
(Species::Illusioner, "illusioner"),
|
||||
] {
|
||||
let m = caster(species, name);
|
||||
let types = HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
|
||||
for value in [None, Some(30), Some(0), Some(-2)] {
|
||||
let mut raw = Tag::compound([
|
||||
("id", string(&m.kind.id)),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
]);
|
||||
if let Some(value) = value {
|
||||
put(&mut raw, "SpellTicks", Tag::Int(value));
|
||||
}
|
||||
let loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
|
||||
assert_eq!(
|
||||
loaded.extra.counter,
|
||||
value.unwrap_or(0),
|
||||
"{name}: {value:?}"
|
||||
);
|
||||
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], None, None);
|
||||
assert_eq!(saved.get("SpellTicks"), Some(&Tag::Int(value.unwrap_or(0))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `SpellcasterIllager.addAdditionalSaveData`: live state wins even when
|
||||
/// no raw input exists or the original raw counter has become stale.
|
||||
#[test]
|
||||
fn spell_ticks_save_the_mutated_model_without_a_raw_fallback() {
|
||||
for (species, name) in [
|
||||
(Species::Evoker, "evoker"),
|
||||
(Species::Illusioner, "illusioner"),
|
||||
] {
|
||||
let mut m = caster(species, name);
|
||||
let raw = Tag::compound([
|
||||
("SpellTicks", Tag::Int(30)),
|
||||
("audit:unrelated", Tag::Int(7)),
|
||||
]);
|
||||
for value in [29, 27, 20, 0, -2] {
|
||||
m.extra.counter = value;
|
||||
for raw in [None, Some(&raw)] {
|
||||
let saved = mob_to_nbt(&m, [1, 2, 3, 4], raw, None);
|
||||
assert_eq!(saved.get("SpellTicks"), Some(&Tag::Int(value)), "{name}");
|
||||
if raw.is_some() {
|
||||
assert_eq!(saved.get("audit:unrelated"), Some(&Tag::Int(7)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -3734,11 +3901,11 @@ mod tests {
|
||||
{
|
||||
map.insert("Age".into(), Tag::Int(age));
|
||||
}
|
||||
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None);
|
||||
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None, |_| None);
|
||||
assert_eq!(saved.get("Age"), expected.as_ref(), "{species:?}, {age:?}");
|
||||
assert_eq!(saved.get("h228_control"), Some(&Tag::Int(42)));
|
||||
}
|
||||
let saved = mob_to_nbt(&mob, [0; 4], None, None);
|
||||
let saved = mob_to_nbt(&mob, [0; 4], None, None, |_| None);
|
||||
assert_eq!(
|
||||
saved.get("Age"),
|
||||
expected.as_ref(),
|
||||
@@ -3769,7 +3936,7 @@ mod tests {
|
||||
("audit:unrelated", Tag::Int(7)),
|
||||
]);
|
||||
for base in [None, Some(&raw)] {
|
||||
let saved = mob_to_nbt(&mob, [0; 4], base, None);
|
||||
let saved = mob_to_nbt(&mob, [0; 4], base, None, |_| None);
|
||||
for (key, expected) in [
|
||||
("Age", Tag::Int(-123)),
|
||||
("ForcedAge", Tag::Int(19)),
|
||||
@@ -3829,7 +3996,7 @@ mod tests {
|
||||
Some("minecraft:diamond_horse_armor")
|
||||
);
|
||||
assert_eq!(m.extra.head.as_deref(), Some("minecraft:iron_helmet"));
|
||||
let saved = mob_to_nbt(&m, [0; 4], Some(&raw), None);
|
||||
let saved = mob_to_nbt(&m, [0; 4], Some(&raw), None, |_| None);
|
||||
let eq = saved.get("equipment").unwrap();
|
||||
let id = |key: &str| {
|
||||
eq.get(key)
|
||||
@@ -3923,7 +4090,7 @@ mod tests {
|
||||
1
|
||||
);
|
||||
mob.max_swell = 7;
|
||||
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None);
|
||||
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None, |_| None);
|
||||
assert_eq!(saved.get("Fuse"), Some(&Tag::Short(7)));
|
||||
}
|
||||
}
|
||||
@@ -4350,6 +4517,81 @@ mod tests {
|
||||
let cleared = item_entity_to_nbt(&cleared, [1, 2, 3, 4], Some(&tag));
|
||||
assert!(cleared.get("Thrower").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn animal_integer_loads_save_modeled_values_without_raw_source() {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let def = Arc::make_mut(&mut kind);
|
||||
def.id = "minecraft:pig".into();
|
||||
def.species = crate::mobs::Species::Pig;
|
||||
let types = [("minecraft:pig".into(), kind)].into();
|
||||
for (input, expected) in [
|
||||
(None, 0),
|
||||
(Some(Tag::Int(0)), 0),
|
||||
(Some(Tag::Byte(-7)), -7),
|
||||
(Some(Tag::Short(13)), 13),
|
||||
(Some(Tag::Int(i32::MIN)), i32::MIN),
|
||||
(Some(Tag::Int(i32::MAX)), i32::MAX),
|
||||
(Some(Tag::Long(2147483648)), i32::MIN),
|
||||
(Some(Tag::Long(-2147483649)), i32::MAX),
|
||||
(Some(Tag::Long(4294967296)), 0),
|
||||
(Some(Tag::Long(i64::MAX)), -1),
|
||||
(Some(Tag::Long(i64::MIN)), 0),
|
||||
(Some(Tag::Float(2.5f32.to_bits())), 2),
|
||||
(Some(Tag::Float((-2.5f32).to_bits())), -3),
|
||||
(Some(Tag::Double(2.5f64.to_bits())), 2),
|
||||
(Some(Tag::Double((-2.5f64).to_bits())), -3),
|
||||
(Some(Tag::Float(2147483520f32.to_bits())), 2147483520),
|
||||
(Some(Tag::Float(2147483648f32.to_bits())), i32::MAX),
|
||||
(Some(Tag::Float((-2147483648f32).to_bits())), i32::MIN),
|
||||
(Some(Tag::Float((-2147483904f32).to_bits())), i32::MIN),
|
||||
(Some(Tag::Double(2147483647.75f64.to_bits())), i32::MAX),
|
||||
(Some(Tag::Double(2147483648f64.to_bits())), i32::MAX),
|
||||
(Some(Tag::Double((-2147483647.5f64).to_bits())), i32::MIN),
|
||||
(Some(Tag::Double((-2147483648.5f64).to_bits())), i32::MIN),
|
||||
(Some(Tag::Double(1e20f64.to_bits())), i32::MAX),
|
||||
(Some(Tag::Double((-1e20f64).to_bits())), i32::MIN),
|
||||
(Some(Tag::Float(1e20f32.to_bits())), i32::MAX),
|
||||
(Some(Tag::Float((-1e20f32).to_bits())), i32::MIN),
|
||||
] {
|
||||
let mut raw = Tag::compound([
|
||||
("id", string("minecraft:pig")),
|
||||
("Pos", doubles(&[4.5, 200.0, 4.5])),
|
||||
("NoAI", Tag::Byte(1)),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
("AgeLocked", Tag::Byte(1)),
|
||||
("Unrelated", string("keep")),
|
||||
]);
|
||||
if let Some(value) = input.as_ref() {
|
||||
for key in ["Age", "ForcedAge", "InLove"] {
|
||||
put(&mut raw, key, value.clone());
|
||||
}
|
||||
}
|
||||
let mob = mob_from_nbt(&raw, &types, 1, None).unwrap();
|
||||
assert_eq!(
|
||||
(mob.extra.age, mob.extra.forced_age, mob.extra.in_love),
|
||||
(expected, expected, expected),
|
||||
"{input:?}"
|
||||
);
|
||||
assert!(mob.no_ai && mob.no_gravity && mob.extra.age_locked);
|
||||
for source in [None, Some(&raw)] {
|
||||
let saved = mob_to_nbt(&mob, [0, 0, 0, 55], source, None);
|
||||
for key in ["Age", "ForcedAge", "InLove"] {
|
||||
assert_eq!(
|
||||
saved.get(key),
|
||||
Some(&Tag::Int(expected)),
|
||||
"{key}: {input:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(saved.get("NoAI"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(saved.get("AgeLocked"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(
|
||||
saved.get("Unrelated"),
|
||||
source.and_then(|tag| tag.get("Unrelated"))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animal_nbt_preserves_signed_integer_in_love_and_defaults_after_removal() {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
@@ -4381,14 +4623,14 @@ mod tests {
|
||||
// Query snapshots and saves both write the live field as an int,
|
||||
// regardless of whether there is an old compound to preserve.
|
||||
for old in [None, Some(&raw)] {
|
||||
let mut saved = mob_to_nbt(&mob, [0, 0, 0, 1], old, None);
|
||||
let mut saved = mob_to_nbt(&mob, [0, 0, 0, 1], old, None, |_| None);
|
||||
assert_eq!(saved.get("InLove"), Some(&Tag::Int(expected)));
|
||||
if let Tag::Compound(map) = &mut saved {
|
||||
map.remove(&"InLove".into());
|
||||
}
|
||||
let cleared = mob_from_nbt(&saved, &types, 2, None).unwrap();
|
||||
assert_eq!(cleared.extra.in_love, 0);
|
||||
let saved = mob_to_nbt(&cleared, [0, 0, 0, 1], Some(&saved), None);
|
||||
let saved = mob_to_nbt(&cleared, [0, 0, 0, 1], Some(&saved), None, |_| None);
|
||||
assert_eq!(saved.get("InLove"), Some(&Tag::Int(0)));
|
||||
}
|
||||
}
|
||||
@@ -4424,6 +4666,7 @@ mod tests {
|
||||
[90.0, 10.0],
|
||||
[1; 4],
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(rotation(&tnt), [90.0, 10.0]);
|
||||
let mut stand = ArmorStand::new(1, Vec3::ZERO, 0.0);
|
||||
@@ -4969,7 +5212,7 @@ mod axolotl_nbt_tests {
|
||||
ty.species = crate::mobs::Species::Axolotl;
|
||||
let mut mob = Mob::new(1, kind, Vec3::ZERO, 0.0);
|
||||
restore_axolotl_memories(&mut mob, &raw);
|
||||
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None);
|
||||
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None, |_| None);
|
||||
let entry = saved
|
||||
.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
@@ -5019,7 +5262,7 @@ mod axolotl_nbt_tests {
|
||||
let m = mob_from_nbt(&input, &types, 1, None).unwrap();
|
||||
let expected = kind == "tadpole" || value.unwrap_or(false);
|
||||
for raw in [None, Some(&input)] {
|
||||
let saved = mob_to_nbt(&m, [1; 4], raw, None);
|
||||
let saved = mob_to_nbt(&m, [1; 4], raw, None, |_| None);
|
||||
assert_eq!(
|
||||
saved.get("FromBucket"),
|
||||
Some(&Tag::Byte(i8::from(expected))),
|
||||
@@ -5031,7 +5274,7 @@ mod axolotl_nbt_tests {
|
||||
}
|
||||
let pig = Mob::new(1, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0);
|
||||
assert!(
|
||||
mob_to_nbt(&pig, [1; 4], None, None)
|
||||
mob_to_nbt(&pig, [1; 4], None, None, |_| None)
|
||||
.get("FromBucket")
|
||||
.is_none()
|
||||
);
|
||||
@@ -5073,7 +5316,7 @@ mod axolotl_nbt_tests {
|
||||
Tag::compound([("test:unrelated", Tag::compound([("value", Tag::Int(9))]))]),
|
||||
)]),
|
||||
)]);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw), None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw), None, |_| None);
|
||||
let memories = tag.get("Brain").unwrap().get("memories").unwrap();
|
||||
assert!(memories.get("test:unrelated").is_some());
|
||||
assert_eq!(
|
||||
@@ -5191,7 +5434,7 @@ mod axolotl_nbt_tests {
|
||||
|
||||
crate::mobs::animal_brain::set_hunting_cooldown(&mut m, 0);
|
||||
assert!(bucket_data(&m).get("HuntingCooldown").is_none());
|
||||
let saved = mob_to_nbt(&m, [1, 2, 3, 4], Some(&tag), None);
|
||||
let saved = mob_to_nbt(&m, [1, 2, 3, 4], Some(&tag), None, |_| None);
|
||||
assert!(
|
||||
saved
|
||||
.get("Brain")
|
||||
@@ -5229,7 +5472,7 @@ mod sheep_nbt_tests {
|
||||
// Summoned white, then dyed red and sheared: the save has it now.
|
||||
let raw = Tag::compound([("Color", Tag::Byte(0)), ("Sheared", Tag::Byte(0))]);
|
||||
(m.extra.color, m.extra.sheared) = (14, true);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw), None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw), None, |_| None);
|
||||
assert_eq!(
|
||||
(number(tag.get("Color")), number(tag.get("Sheared"))),
|
||||
(Some(14), Some(1))
|
||||
@@ -5268,13 +5511,13 @@ mod sheep_nbt_tests {
|
||||
let protected = mob_from_nbt(&raw, &types, 1, None).unwrap();
|
||||
assert!(protected.extra.cannot_be_hunted);
|
||||
assert!(!protected.seen(None).huntable);
|
||||
let saved = mob_to_nbt(&protected, [0; 4], Some(&raw), None);
|
||||
let saved = mob_to_nbt(&protected, [0; 4], Some(&raw), None, |_| None);
|
||||
assert_eq!(saved.get("CannotBeHunted"), Some(&Tag::Byte(1)));
|
||||
for raw in [hoglin(None), hoglin(Some(0))] {
|
||||
let prey = mob_from_nbt(&raw, &types, 2, None).unwrap();
|
||||
assert!(!prey.extra.cannot_be_hunted);
|
||||
assert!(prey.seen(None).huntable);
|
||||
let saved = mob_to_nbt(&prey, [0; 4], Some(&raw), None);
|
||||
let saved = mob_to_nbt(&prey, [0; 4], Some(&raw), None, |_| None);
|
||||
assert_eq!(saved.get("CannotBeHunted"), Some(&Tag::Byte(0)));
|
||||
}
|
||||
}
|
||||
@@ -5307,7 +5550,7 @@ mod sheep_nbt_tests {
|
||||
Some("mymod:entities/boss")
|
||||
);
|
||||
assert_eq!(m.extra.death_loot_seed, 42);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
|
||||
assert_eq!(
|
||||
text(tag.get("DeathLootTable")).as_deref(),
|
||||
Some("mymod:entities/boss")
|
||||
@@ -5319,7 +5562,7 @@ mod sheep_nbt_tests {
|
||||
("Pos", doubles(&[0.0, 64.0, 0.0])),
|
||||
]);
|
||||
let plain = mob_from_nbt(&plain, &types, 3, None).unwrap();
|
||||
let tag = mob_to_nbt(&plain, [1, 2, 3, 4], None, None);
|
||||
let tag = mob_to_nbt(&plain, [1, 2, 3, 4], None, None, |_| None);
|
||||
assert!(tag.get("DeathLootTable").is_none() && tag.get("DeathLootTableSeed").is_none());
|
||||
}
|
||||
|
||||
@@ -5345,7 +5588,7 @@ mod sheep_nbt_tests {
|
||||
0.0,
|
||||
);
|
||||
m.extra.more_carrot_ticks = 33;
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
|
||||
assert_eq!(number(tag.get("MoreCarrotTicks")), Some(33));
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert_eq!(back.extra.more_carrot_ticks, 33);
|
||||
@@ -5372,10 +5615,10 @@ mod sheep_nbt_tests {
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
|
||||
assert_eq!(number(tag.get("Trusting")), Some(0));
|
||||
m.extra.trusting = true;
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
|
||||
assert_eq!(number(tag.get("Trusting")), Some(1));
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert!(back.extra.trusting);
|
||||
@@ -5404,7 +5647,7 @@ mod sheep_nbt_tests {
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
let save = |m: &Mob| mob_to_nbt(m, [1, 2, 3, 4], None, None);
|
||||
let save = |m: &Mob| mob_to_nbt(m, [1, 2, 3, 4], None, None, |_| None);
|
||||
// The default description is not written; it comes back.
|
||||
let tag = save(&m);
|
||||
assert!(tag.get("description").is_none() && tag.get("hide_description").is_none());
|
||||
@@ -5455,7 +5698,7 @@ mod sheep_nbt_tests {
|
||||
)]);
|
||||
let mut m = Mob::new(1, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0);
|
||||
m.add_effect(crate::brewing::effect("testfx:glow").unwrap().id, 200, 2);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
|
||||
let Some(Tag::List(saved)) = tag.get("active_effects") else {
|
||||
panic!("no active_effects");
|
||||
};
|
||||
@@ -5486,7 +5729,7 @@ mod sheep_nbt_tests {
|
||||
200,
|
||||
2,
|
||||
);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
|
||||
let Some(Tag::List(saved)) = tag.get("active_effects") else {
|
||||
panic!("no active_effects");
|
||||
};
|
||||
@@ -5517,7 +5760,7 @@ mod sheep_nbt_tests {
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
|
||||
assert_eq!(
|
||||
text(tag.get("variant")).as_deref(),
|
||||
Some("minecraft:temperate")
|
||||
@@ -5528,7 +5771,7 @@ mod sheep_nbt_tests {
|
||||
);
|
||||
// Spawned in a warm biome with the mini sounds.
|
||||
(m.extra.variant, m.extra.sound_variant) = (1, Some(2));
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
|
||||
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:warm"));
|
||||
assert_eq!(
|
||||
text(tag.get("sound_variant")).as_deref(),
|
||||
@@ -5543,7 +5786,7 @@ mod sheep_nbt_tests {
|
||||
0.0,
|
||||
);
|
||||
(hen.extra.variant, hen.extra.egg_time) = (2, 7795);
|
||||
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None, None);
|
||||
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None, None, |_| None);
|
||||
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:cold"));
|
||||
let back = mob_from_nbt(&tag, &types, 4, None).unwrap();
|
||||
assert_eq!((back.extra.variant, back.extra.egg_time), (2, 7795));
|
||||
@@ -5592,7 +5835,7 @@ mod sheep_nbt_tests {
|
||||
Some(Tag::compound([("Age", Tag::Int(-100))])),
|
||||
Some(Tag::compound([("Age", Tag::Int(100))])),
|
||||
] {
|
||||
let saved = mob_to_nbt(&mob, [0; 4], raw.as_ref(), None);
|
||||
let saved = mob_to_nbt(&mob, [0; 4], raw.as_ref(), None, |_| None);
|
||||
assert_eq!(
|
||||
saved.get("Age"),
|
||||
expected.as_ref(),
|
||||
@@ -5623,7 +5866,7 @@ mod sheep_nbt_tests {
|
||||
]);
|
||||
let m = mob_from_nbt(&saved, &types, 1, None).unwrap();
|
||||
assert_eq!(m.extra.age, -21359);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
|
||||
assert_eq!(number(tag.get("Age")), Some(-21359));
|
||||
assert_eq!(number(tag.get("AgeLocked")), Some(0));
|
||||
assert_eq!(number(tag.get("VillagerDataFinalized")), Some(1));
|
||||
@@ -5656,7 +5899,7 @@ mod sheep_nbt_tests {
|
||||
assert_eq!(m.extra.counter, 2400);
|
||||
m.extra.flag = true;
|
||||
m.extra.counter = 123;
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
|
||||
assert_eq!(number(tag.get("Moistness")), Some(123));
|
||||
assert_eq!(number(tag.get("GotFish")), Some(1));
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
@@ -5697,7 +5940,7 @@ mod sheep_nbt_tests {
|
||||
);
|
||||
m.extra.ints[5] = 48000;
|
||||
m.extra.point = Some([12.0, 65.0, -30.0]);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
|
||||
assert_eq!(number(tag.get("DespawnDelay")), Some(48000));
|
||||
let Some(Tag::IntArray(target)) = tag.get("wander_target") else {
|
||||
panic!("wander_target missing");
|
||||
@@ -5735,7 +5978,7 @@ mod sheep_nbt_tests {
|
||||
.unwrap();
|
||||
let new = |kind: &str| Mob::new(1, types[kind].clone(), Vec3::new(0.0, 4.0, 0.0), 0.0);
|
||||
let round_trip = |m: &Mob| {
|
||||
let tag = mob_to_nbt(m, [1, 2, 3, 4], None, Some(®istry));
|
||||
let tag = mob_to_nbt(m, [1, 2, 3, 4], None, Some(®istry), |_| None);
|
||||
(mob_from_nbt(&tag, &types, 2, Some(®istry)).unwrap(), tag)
|
||||
};
|
||||
// A save with nothing but where the mob stands.
|
||||
@@ -6065,7 +6308,7 @@ mod item_component_tests {
|
||||
);
|
||||
// The save keeps the packed `Variant` int and gains no `variant` name,
|
||||
// which only `VariantUtils.writeVariant` species store.
|
||||
let tag = mob_to_nbt(&horse, [1, 2, 3, 4], None, None);
|
||||
let tag = mob_to_nbt(&horse, [1, 2, 3, 4], None, None, |_| None);
|
||||
assert_eq!(tag.get("variant"), None);
|
||||
assert!(
|
||||
matches!(tag.get("Variant"), Some(Tag::Int(v)) if *v == 6 | 2 << 8),
|
||||
@@ -6123,7 +6366,8 @@ mod item_component_tests {
|
||||
else {
|
||||
return m;
|
||||
};
|
||||
let tag = crate::data_cmd::merged(&mob_to_nbt(&m, [1, 2, 3, 4], None, None), data);
|
||||
let tag =
|
||||
crate::data_cmd::merged(&mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None), data);
|
||||
mob_from_nbt(&tag, &types, 2, None).unwrap()
|
||||
};
|
||||
// `entity_data={NoAI:1b}`: no id, so the egg's own type holds the data.
|
||||
@@ -6381,6 +6625,54 @@ mod fireball_nbt_tests {
|
||||
assert_eq!(item_frame_from_nbt(&frame, 1).unwrap().pos, [-1, 80, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_missing_facing_loads_down_and_valid_directions_round_trip() {
|
||||
// ItemFrame.readAdditionalSaveData: LEGACY_ID_CODEC, orElse(DOWN).
|
||||
for kind in ["minecraft:item_frame", "minecraft:glow_item_frame"] {
|
||||
// Explicit controls run before the missing-field assertion.
|
||||
for facing in [Some(0), Some(1), Some(2), Some(3), Some(4), Some(5), None] {
|
||||
let mut tag = Tag::compound([
|
||||
("id", string(kind)),
|
||||
("block_pos", Tag::IntArray(vec![4, 102, 6])),
|
||||
("Fixed", Tag::Byte(1)),
|
||||
("ItemRotation", Tag::Byte(5)),
|
||||
("ItemDropChance", Tag::Float(0.25_f32.to_bits())),
|
||||
(
|
||||
"Item",
|
||||
item_to_nbt(&crate::items::ItemStack::new("minecraft:diamond", 1)),
|
||||
),
|
||||
]);
|
||||
if let Some(direction) = facing {
|
||||
put(&mut tag, "Facing", Tag::Byte(direction));
|
||||
}
|
||||
let frame = item_frame_from_nbt(&tag, 17).unwrap();
|
||||
assert_eq!(frame.kind(), kind);
|
||||
assert_eq!(frame.id, 17);
|
||||
assert!(frame.fixed);
|
||||
assert_eq!(
|
||||
frame.item.as_ref().unwrap().item.as_ref(),
|
||||
"minecraft:diamond"
|
||||
);
|
||||
for raw in [None, Some(&tag)] {
|
||||
let saved = item_frame_to_nbt(&frame, [1, 2, 3, 4], raw);
|
||||
assert_eq!(
|
||||
saved.get("Facing"),
|
||||
Some(&Tag::Byte(facing.unwrap_or(0))),
|
||||
"{kind}"
|
||||
);
|
||||
assert_eq!(saved.get("Item"), tag.get("Item"));
|
||||
assert_eq!(saved.get("ItemRotation"), Some(&Tag::Byte(5)));
|
||||
assert_eq!(saved.get("ItemDropChance"), tag.get("ItemDropChance"));
|
||||
assert_eq!(saved.get("Fixed"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(saved.get("block_pos"), tag.get("block_pos"));
|
||||
if facing.is_none() {
|
||||
assert_eq!(saved.get("Pos"), Some(&doubles(&[4.5, 102.96875, 6.5])));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_frame_drops_with_its_custom_name() {
|
||||
// `ItemFrame.getFrameItemStackWithData`: the frame item, then
|
||||
@@ -6559,7 +6851,7 @@ mod neutral_anger_tests {
|
||||
m.extra.anger = 400;
|
||||
m.extra.anger_end = 3000;
|
||||
m.extra.angry_at = Some(crate::mobs::Who::Player);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
|
||||
assert_eq!(number(tag.get("anger_end_time")), Some(3000));
|
||||
assert!(tag.get("angry_at").is_some());
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
@@ -6567,7 +6859,7 @@ mod neutral_anger_tests {
|
||||
assert_eq!(back.extra.angry_at, Some(crate::mobs::Who::Player));
|
||||
// A mob that is no NeutralMob writes no anger.
|
||||
let pig = Mob::new(3, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0);
|
||||
let tag = mob_to_nbt(&pig, [1, 2, 3, 5], None, None);
|
||||
let tag = mob_to_nbt(&pig, [1, 2, 3, 5], None, None, |_| None);
|
||||
assert!(tag.get("anger_end_time").is_none());
|
||||
// The anger that is left when the mob comes back.
|
||||
assert_eq!(crate::mobs::anger_left(3000, 1000), 2000);
|
||||
@@ -6575,6 +6867,118 @@ mod neutral_anger_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod uuid_bridge_tests {
|
||||
use super::*;
|
||||
use crate::mobs::{Species, sensor_scan_tests::kind};
|
||||
|
||||
/// A wolf, whose anger is `NeutralMob`'s, so its save carries `angry_at`.
|
||||
/// No resources are needed: only the species decides that.
|
||||
fn wolf(id: u32) -> Mob {
|
||||
let mut kind = (*kind()).clone();
|
||||
kind.id = "minecraft:wolf".into();
|
||||
kind.species = Species::Wolf;
|
||||
let mut m = Mob::new(id, Arc::new(kind), Vec3::ZERO, 0.0);
|
||||
m.extra.anger = 400;
|
||||
m.extra.anger_end = 3000;
|
||||
m
|
||||
}
|
||||
|
||||
/// A mob's anger at another mob is saved as that mob's UUID, which the game
|
||||
/// layer answers for (`Who` itself carries none).
|
||||
#[test]
|
||||
fn a_mob_anger_target_is_saved_as_its_uuid() {
|
||||
let mut m = wolf(1);
|
||||
m.extra.angry_at = Some(Who::Mob(7));
|
||||
let target = [7, 0, 0, 3];
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |id| {
|
||||
(id == 7).then_some(target)
|
||||
});
|
||||
assert_eq!(tag.get("angry_at"), Some(&Tag::IntArray(target.to_vec())));
|
||||
assert_eq!(number(tag.get("anger_end_time")), Some(3000));
|
||||
}
|
||||
|
||||
/// A target the map does not know (a mob that left the level) is not named:
|
||||
/// nothing is written rather than a made-up UUID no entity answers for.
|
||||
#[test]
|
||||
fn an_unknown_anger_target_writes_no_angry_at() {
|
||||
let mut m = wolf(1);
|
||||
m.extra.angry_at = Some(Who::Mob(7));
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
|
||||
assert_eq!(tag.get("angry_at"), None);
|
||||
assert_eq!(number(tag.get("anger_end_time")), Some(3000));
|
||||
}
|
||||
|
||||
/// The player is not an id: its UUID needs no map from the caller.
|
||||
#[test]
|
||||
fn a_player_anger_target_needs_no_map() {
|
||||
let mut m = wolf(1);
|
||||
m.extra.angry_at = Some(Who::Player);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
|
||||
assert_eq!(
|
||||
tag.get("angry_at"),
|
||||
Some(&Tag::IntArray(player_uuid().to_vec()))
|
||||
);
|
||||
}
|
||||
|
||||
/// The round trip both deferred fixes need: a mob's UUID written through the
|
||||
/// save seam reads back as that mob, not as another one.
|
||||
#[test]
|
||||
fn a_saved_mob_uuid_resolves_back_to_it() {
|
||||
let mut wolf = wolf(1);
|
||||
let pig = {
|
||||
let mut kind = (*kind()).clone();
|
||||
kind.id = "minecraft:pig".into();
|
||||
kind.species = Species::Pig;
|
||||
Mob::new(9, Arc::new(kind), Vec3::ZERO, 0.0)
|
||||
};
|
||||
let (wolf_uuid, pig_uuid) = ([1, 2, 3, 4], [9, 0, 0, 1]);
|
||||
let uuids: HashMap<u32, [i32; 4]> = HashMap::from([(1, wolf_uuid), (9, pig_uuid)]);
|
||||
wolf.extra.angry_at = Some(Who::Mob(pig.id));
|
||||
let tag = mob_to_nbt(&wolf, wolf_uuid, None, None, |id| uuids.get(&id).copied());
|
||||
let Some(Tag::IntArray(stored)) = tag.get("angry_at") else {
|
||||
panic!("anger at a mob is saved as that mob's UUID");
|
||||
};
|
||||
assert_eq!(stored, &pig_uuid.to_vec());
|
||||
let mobs = [wolf, pig];
|
||||
assert_eq!(
|
||||
who_by_uuid(&uuids, &mobs, None, pig_uuid),
|
||||
Some(Who::Mob(9))
|
||||
);
|
||||
// The other loaded mob is not mistaken for it.
|
||||
assert_eq!(
|
||||
who_by_uuid(&uuids, &mobs, None, wolf_uuid),
|
||||
Some(Who::Mob(1))
|
||||
);
|
||||
// The player is the other half of `Who`, kept apart from the id map.
|
||||
let player = [5, 5, 5, 5];
|
||||
assert_eq!(
|
||||
who_by_uuid(&uuids, &mobs, Some(player), player),
|
||||
Some(Who::Player)
|
||||
);
|
||||
}
|
||||
|
||||
/// A UUID nothing loaded has is no entity at all: not another mob, and not
|
||||
/// the projectile whose id shares the map.
|
||||
#[test]
|
||||
fn a_uuid_no_loaded_mob_has_resolves_to_nothing() {
|
||||
let wolf = wolf(1);
|
||||
let uuids: HashMap<u32, [i32; 4]> =
|
||||
HashMap::from([(1, [1, 2, 3, 4]), (1000, [5, 0, 0, 1])]);
|
||||
let mobs = [wolf];
|
||||
// Never loaded (it left, or the save is another world's).
|
||||
assert_eq!(who_by_uuid(&uuids, &mobs, None, [9, 9, 9, 9]), None);
|
||||
// Loaded, but not a mob: `Who::Mob` would mean an arrow's id.
|
||||
assert_eq!(who_by_uuid(&uuids, &mobs, None, [5, 0, 0, 1]), None);
|
||||
// Nothing loaded at all resolves to nothing either.
|
||||
assert_eq!(who_by_uuid(&uuids, &[], None, [1, 2, 3, 4]), None);
|
||||
assert_eq!(
|
||||
who_by_uuid(&uuids, &mobs, None, [1, 2, 3, 4]),
|
||||
Some(Who::Mob(1))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod held_item_name_tests {
|
||||
use super::*;
|
||||
@@ -6598,7 +7002,7 @@ mod held_item_name_tests {
|
||||
helmet.damage = 40;
|
||||
helmet.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Hat".into()));
|
||||
crate::mobs::finalize::set_item_stack(&mut mob, crate::mobs::finalize::HEAD, helmet);
|
||||
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None);
|
||||
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None, |_| None);
|
||||
let loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
|
||||
let worn = crate::mobs::finalize::item_stack(&loaded, crate::mobs::finalize::HEAD).unwrap();
|
||||
assert_eq!(worn.damage, 40);
|
||||
@@ -6634,7 +7038,7 @@ mod held_item_name_tests {
|
||||
let mut sword = crate::items::ItemStack::new("minecraft:diamond_sword", 1);
|
||||
sword.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Old Sword".into()));
|
||||
mob.extra.set_main_hand_stack(Some(sword));
|
||||
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None);
|
||||
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None, |_| None);
|
||||
let mut loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
|
||||
assert_eq!(
|
||||
loaded
|
||||
@@ -6648,7 +7052,7 @@ mod held_item_name_tests {
|
||||
loaded
|
||||
.extra
|
||||
.set_main_hand(Some("minecraft:diamond_sword".into()));
|
||||
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], Some(&raw), None);
|
||||
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], Some(&raw), None, |_| None);
|
||||
let restored = mob_from_nbt(&saved, &types, 3, None).unwrap();
|
||||
assert_eq!(
|
||||
restored.extra.main_hand.as_deref(),
|
||||
@@ -6805,7 +7209,7 @@ mod axolotl_memory_ttl_tests {
|
||||
ty.species = crate::mobs::Species::Axolotl;
|
||||
let mut mob = Mob::new(1, kind, Vec3::ZERO, 0.0);
|
||||
restore_axolotl_memories(&mut mob, &raw);
|
||||
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None);
|
||||
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None, |_| None);
|
||||
let entry = memory(&saved, key).expect("loaded value must be present");
|
||||
assert_eq!(entry.get("value"), Some(&Tag::Int(100)));
|
||||
let expected = ttl.filter(|v| *v != i64::MAX).map(Tag::Long);
|
||||
@@ -6818,7 +7222,7 @@ mod axolotl_memory_ttl_tests {
|
||||
} else {
|
||||
brain.mem.temptation_cooldown_ticks.set(99);
|
||||
}
|
||||
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None);
|
||||
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None, |_| None);
|
||||
let entry = memory(&saved, key).unwrap();
|
||||
assert_eq!(entry.get("value"), Some(&Tag::Int(99)));
|
||||
assert!(entry.get("ttl").is_none(), "stale raw TTL must not return");
|
||||
@@ -6854,7 +7258,7 @@ mod generic_home_nbt_tests {
|
||||
for raw in [None, Some(&raw)] {
|
||||
for radius in [0, 5, -2, -1] {
|
||||
m.ai.home = Some(([4, 5, 6], radius));
|
||||
let saved = mob_to_nbt(&m, [0; 4], raw, None);
|
||||
let saved = mob_to_nbt(&m, [0; 4], raw, None, |_| None);
|
||||
if radius == -1 {
|
||||
assert!(saved.get("home_radius").is_none());
|
||||
assert!(saved.get("home_pos").is_none());
|
||||
@@ -6864,7 +7268,7 @@ mod generic_home_nbt_tests {
|
||||
}
|
||||
}
|
||||
m.ai.home = None;
|
||||
let saved = mob_to_nbt(&m, [0; 4], raw, None);
|
||||
let saved = mob_to_nbt(&m, [0; 4], raw, None, |_| None);
|
||||
assert!(saved.get("home_radius").is_none());
|
||||
assert!(saved.get("home_pos").is_none());
|
||||
if raw.is_some() {
|
||||
@@ -6957,7 +7361,7 @@ mod generic_home_nbt_tests {
|
||||
for raw in [None, Some(&raw)] {
|
||||
for home in [None, Some(([7, 8, 9], 5))] {
|
||||
m.ai.home = home;
|
||||
let saved = mob_to_nbt(&m, [0; 4], raw, None);
|
||||
let saved = mob_to_nbt(&m, [0; 4], raw, None, |_| None);
|
||||
assert_eq!(saved.get("home_pos"), Some(&Tag::IntArray(vec![4, 5, 6])));
|
||||
assert_eq!(
|
||||
saved.get("home_radius"),
|
||||
@@ -7024,7 +7428,7 @@ mod piglin_live_anger_memory_tests {
|
||||
for remaining in [600, 520] {
|
||||
m.extra.anger = remaining;
|
||||
for base in [None, Some(&raw)] {
|
||||
let saved = mob_to_nbt(&m, [42, 43, 44, 45], base, None);
|
||||
let saved = mob_to_nbt(&m, [42, 43, 44, 45], base, None, |_| None);
|
||||
assert_eq!(
|
||||
memory(&saved),
|
||||
Some(&Tag::compound([
|
||||
@@ -7046,7 +7450,7 @@ mod piglin_live_anger_memory_tests {
|
||||
crate::mobs::piglin::set_guarded_anger_target(&mut m, Some([1, 2, 3, 4]));
|
||||
m.extra.anger = 0;
|
||||
m.ai.target = None;
|
||||
let saved = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw()), None);
|
||||
let saved = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw()), None, |_| None);
|
||||
assert!(memory(&saved).is_none());
|
||||
assert_unrelated(&saved);
|
||||
}
|
||||
@@ -7054,15 +7458,15 @@ mod piglin_live_anger_memory_tests {
|
||||
#[test]
|
||||
fn calm_loaded_and_missing_identity_states_are_distinct() {
|
||||
let mut m = mob();
|
||||
assert!(memory(&mob_to_nbt(&m, [42, 43, 44, 45], None, None)).is_none());
|
||||
assert!(memory(&mob_to_nbt(&m, [42, 43, 44, 45], None, None, |_| None)).is_none());
|
||||
let raw = raw();
|
||||
// Loaded Brain semantics stay outside this live event projection.
|
||||
let untouched = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw), None);
|
||||
let untouched = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw), None, |_| None);
|
||||
assert_eq!(memory(&untouched), memory(&raw));
|
||||
crate::mobs::piglin::set_guarded_anger_target(&mut m, None);
|
||||
assert_eq!(m.extra.anger, 600);
|
||||
assert_eq!(m.ai.target, Some(Who::Player));
|
||||
let saved = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw), None);
|
||||
let saved = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw), None, |_| None);
|
||||
assert!(memory(&saved).is_none());
|
||||
assert_unrelated(&saved);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,47 @@
|
||||
use crate::{hud::Rect, mesher::Vertex};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Test the flame's own envelope, which is taller and wider than the entity's box.
|
||||
/// This only rejects camera-invisible geometry; callers keep other-view shader/mod paths.
|
||||
pub fn in_view(
|
||||
view: &crate::gfx::Frustum,
|
||||
camera: glam::DVec3,
|
||||
position: [f64; 3],
|
||||
width: f32,
|
||||
height: f32,
|
||||
) -> bool {
|
||||
let origin = (glam::DVec3::from(position) - camera).as_vec3();
|
||||
bounds(origin, width, height).is_none_or(|(lo, hi)| view.intersects(lo, hi))
|
||||
}
|
||||
|
||||
fn bounds(origin: glam::Vec3, width: f32, height: f32) -> Option<(glam::Vec3, glam::Vec3)> {
|
||||
let scale = width * 1.4;
|
||||
let remaining = height / scale;
|
||||
// Unusual/invalid dimensions keep the original path. The ordinary aspect ratios also
|
||||
// bound accumulated float error in the 0.45-step loop well below the spare 0.1 scale.
|
||||
if !origin.is_finite()
|
||||
|| !scale.is_finite()
|
||||
|| !remaining.is_finite()
|
||||
|| scale <= 0.0
|
||||
|| !(0.0..=64.0).contains(&remaining)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// half starts at 0.5 and shrinks. z starts at 0.3 - trunc(remaining)*0.02 and
|
||||
// recedes by 0.03 per layer. Two extra steps cover float rounding at layer boundaries.
|
||||
let layers = (remaining / 0.45).ceil() + 2.0;
|
||||
let z = 0.3 - remaining.trunc() * 0.02;
|
||||
let depth = z.abs().max((z - layers * 0.03).abs());
|
||||
// |rotated x/z| <= |x| + |z|, for every billboard yaw. The last layer starts
|
||||
// below height and reaches 1.4*scale above its start; keep extra numerical slack.
|
||||
let radius = (0.5 + depth) * scale;
|
||||
let padding = glam::Vec3::splat(0.05 + origin.abs().max_element() * f32::EPSILON * 4.0);
|
||||
Some((
|
||||
origin - glam::Vec3::new(radius, 0.0, radius) - padding,
|
||||
origin + glam::Vec3::new(radius, height + 1.5 * scale, radius) + padding,
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn flames(
|
||||
position: [f64; 3],
|
||||
@@ -62,6 +103,74 @@ pub fn flames(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn culling_envelope_contains_emitted_flames_at_every_billboard_yaw() {
|
||||
let rect = super::Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
let sprites =
|
||||
super::HashMap::from([("block/fire_0".into(), rect), ("block/fire_1".into(), rect)]);
|
||||
for (width, height) in [
|
||||
(0.25, 0.25),
|
||||
(0.5, 1.975),
|
||||
(0.25, 0.9875),
|
||||
(0.6, 1.8),
|
||||
(0.9, 1.4),
|
||||
(2.0, 1.9),
|
||||
(5.0, 448.0),
|
||||
] {
|
||||
for scale in [0.0625, 1.0, 16.0] {
|
||||
let (width, height) = (width * scale, height * scale);
|
||||
for camera in [
|
||||
glam::DVec3::ZERO,
|
||||
glam::DVec3::new(30_000_000.0, -64.0, -30_000_000.0),
|
||||
] {
|
||||
let position = camera + glam::DVec3::new(7.25, -2.5, -12.0);
|
||||
let (lo, hi) =
|
||||
super::bounds((position - camera).as_vec3(), width, height).unwrap();
|
||||
for yaw in (0..360).step_by(15) {
|
||||
let mut out = Vec::new();
|
||||
super::flames(
|
||||
position.to_array(),
|
||||
width,
|
||||
height,
|
||||
camera,
|
||||
yaw as f32,
|
||||
&sprites,
|
||||
0,
|
||||
&mut out,
|
||||
);
|
||||
assert!(!out.is_empty());
|
||||
for v in out {
|
||||
let p = glam::Vec3::from_array(v.pos);
|
||||
assert!(
|
||||
p.cmpge(lo).all() && p.cmple(hi).all(),
|
||||
"{width}x{height}, yaw {yaw}: {p:?} outside {lo:?}..{hi:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_dimensions_keep_the_unculled_path() {
|
||||
for (width, height) in [
|
||||
(0.0, 1.0),
|
||||
(f32::NAN, 1.0),
|
||||
(1.0, f32::INFINITY),
|
||||
(0.001, 100.0),
|
||||
] {
|
||||
assert!(super::bounds(glam::Vec3::ZERO, width, height).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flames_cover_entity_height_and_alternate_animated_sprites() {
|
||||
let rect = |u0| super::Rect {
|
||||
|
||||
@@ -85,7 +85,8 @@ impl FurnaceCooking for Furnace {
|
||||
let can_burn = match &self.items[2] {
|
||||
None => true,
|
||||
Some(out) => {
|
||||
out.item == recipe.result.item && out.count + recipe.result.count <= max
|
||||
out.same_item_same_components(&recipe.result)
|
||||
&& out.count + recipe.result.count <= max
|
||||
}
|
||||
};
|
||||
if can_burn {
|
||||
@@ -249,6 +250,66 @@ mod tests {
|
||||
(items, recipes, providers)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooking_requires_output_components_and_capacity() {
|
||||
let (items, recipes, providers) = data("output-components");
|
||||
let mut different = ItemStack::new("test:ingot", 1);
|
||||
different.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Cold".into()));
|
||||
for (output, eligible) in [
|
||||
(Some(different), false),
|
||||
(None, true),
|
||||
(Some(ItemStack::new("test:ingot", 1)), true),
|
||||
(Some(ItemStack::new("test:ingot", 64)), false),
|
||||
(Some(ItemStack::new("test:ingot", 63)), true),
|
||||
] {
|
||||
let before = output.clone();
|
||||
let mut f = Furnace {
|
||||
kind: FurnaceKind::Furnace,
|
||||
items: [
|
||||
Some(ItemStack::new("test:ore", 3)),
|
||||
Some(ItemStack::new("test:coal", 1)),
|
||||
output,
|
||||
],
|
||||
lit_remaining: 0,
|
||||
lit_total: 0,
|
||||
cooking_timer: 0,
|
||||
cooking_total: 200,
|
||||
speed: 1.0,
|
||||
recipes_used: HashMap::new(),
|
||||
};
|
||||
let recipe = recipes
|
||||
.cook(f.kind.recipe_type(), f.items[0].as_ref().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(&*recipe.result.item, recipe.result.count, recipe.time),
|
||||
("test:ingot", 1, 200)
|
||||
);
|
||||
let mut drops = Vec::new();
|
||||
assert_eq!(
|
||||
f.tick(&recipes, &items, &providers, &mut drops),
|
||||
eligible.then_some(true),
|
||||
"output {before:?}"
|
||||
);
|
||||
assert!(drops.is_empty());
|
||||
if eligible {
|
||||
assert!(f.items[1].is_none());
|
||||
for _ in 1..200 {
|
||||
f.tick(&recipes, &items, &providers, &mut drops);
|
||||
}
|
||||
assert_eq!(f.items[0].as_ref().unwrap().count, 2);
|
||||
assert_eq!(
|
||||
f.items[2].as_ref().unwrap().count,
|
||||
before.as_ref().map_or(0, |s| s.count) + 1
|
||||
);
|
||||
} else {
|
||||
assert_eq!(f.items[0].as_ref().unwrap().count, 3);
|
||||
assert_eq!(f.items[1].as_ref().unwrap().count, 1);
|
||||
assert_eq!(f.items[2], before);
|
||||
assert_eq!((f.lit_remaining, f.cooking_timer), (0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The furnace sleeps while `idle`, so its tick must change nothing then: run the
|
||||
/// real tick on every idle case and compare, and check a furnace that can smelt
|
||||
/// isn't idle and does change.
|
||||
|
||||
@@ -71,6 +71,7 @@ mod boarding;
|
||||
mod boat_authority_fixture;
|
||||
mod boat_leads;
|
||||
mod boat_passengers;
|
||||
mod boat_rider_fixture;
|
||||
mod boats;
|
||||
mod books;
|
||||
mod bossbars;
|
||||
@@ -2955,6 +2956,7 @@ struct Game<'a> {
|
||||
dragon_model: Option<horizon_client::mobs::Part>,
|
||||
/// Map textures waiting for the next HUD batch to upload them.
|
||||
map_patches: Vec<horizon_client::font::Patch>,
|
||||
name_tag_layouts: names::NameTagLayouts,
|
||||
text_displays: std::collections::HashMap<i32, text_displays::RemoteTextDisplay>,
|
||||
interactions: std::collections::HashMap<i32, interactions::RemoteInteraction>,
|
||||
/// The fence knots a server tracks to this client, by entity id, at their
|
||||
@@ -3041,6 +3043,9 @@ struct Game<'a> {
|
||||
/// Where item entities are, while the block entities tick (hoppers).
|
||||
item_columns: Option<machines::ItemColumns>,
|
||||
creative_screen: Creative,
|
||||
/// `CreativeModeInventoryScreen.ignoreTextInput`: the chat key opened the
|
||||
/// search tab, so the letter it carries is not typed into the box.
|
||||
creative_ignore_text: bool,
|
||||
control: Option<horizon_client::control::Server>,
|
||||
/// The Agent Control setting the socket was last opened or closed for (`agent_settings`).
|
||||
agent_control_on: bool,
|
||||
@@ -3172,6 +3177,10 @@ struct Game<'a> {
|
||||
pause_menu: bool,
|
||||
pause_focus: Option<u8>,
|
||||
death_focus: Option<u8>,
|
||||
/// The screen ([`Game::key_screen`]) the menu focus belongs to. Another
|
||||
/// screen in front drops it, as `Gui.setScreen` drops the focus of the
|
||||
/// screen it leaves (`Screen.clearFocus`).
|
||||
focus_screen: u64,
|
||||
/// MultiPlayerGameMode.previousLocalPlayerMode (F3+N goes back to it).
|
||||
previous_mode: Option<horizon_client::command::args::GameMode>,
|
||||
gamemode_switcher: Option<horizon_client::game_mode_switcher::GameModeSwitcher>,
|
||||
@@ -7529,12 +7538,13 @@ impl Game<'_> {
|
||||
/// game event from the player, at its feet.
|
||||
pub(super) fn stop_using_item(&mut self, stack: &ItemStack) {
|
||||
self.using = None;
|
||||
self.use_vibration(stack);
|
||||
self.use_vibration(stack, "minecraft:item_interact_finish");
|
||||
}
|
||||
/// `ItemStack.causeUseVibration` for `stack` used by the player: an
|
||||
/// `ITEM_INTERACT_FINISH` game event from the player, at its feet, when the
|
||||
/// stack's use effects ask for interaction vibrations (the default).
|
||||
pub(super) fn use_vibration(&mut self, stack: &ItemStack) {
|
||||
/// `ItemStack.causeUseVibration` for `stack` used by the player: the
|
||||
/// `event` game event (`ITEM_INTERACT_START` when the use begins,
|
||||
/// `ITEM_INTERACT_FINISH` when it ends) from the player, at its feet, when
|
||||
/// the stack's use effects ask for interaction vibrations (the default).
|
||||
pub(super) fn use_vibration(&mut self, stack: &ItemStack, event: &'static str) {
|
||||
if !self
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
@@ -7546,7 +7556,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let ctx = self.player_context(None);
|
||||
self.post_game_event("minecraft:item_interact_finish", [p.x, p.y, p.z], ctx);
|
||||
self.post_game_event(event, [p.x, p.y, p.z], ctx);
|
||||
}
|
||||
/// `ServerPlayerGameMode.tick` while a player digs: level event 2020 every
|
||||
/// fourth tick since the START (the hit sound), else 2019 (the crack only),
|
||||
@@ -7600,10 +7610,8 @@ impl Game<'_> {
|
||||
ticks: 0,
|
||||
});
|
||||
}
|
||||
let block_break_speed = self.block_break_speed();
|
||||
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let facts = self.res.facts().get(state);
|
||||
if facts.hardness < 0.0 {
|
||||
if self.res.facts().get(state).hardness < 0.0 {
|
||||
// The server still counts its destroy ticks on an unbreakable block.
|
||||
let m = self.mining.as_mut().ok_or("mining")?;
|
||||
m.ticks += 1;
|
||||
@@ -7611,36 +7619,7 @@ impl Game<'_> {
|
||||
self.destroy_progress_event(pos, hit.face, ticks);
|
||||
return Ok(());
|
||||
}
|
||||
// Player.getDestroySpeed / BlockBehaviour.getDestroyProgress.
|
||||
let held = self.held();
|
||||
let mut speed = self.items().mining_speed(held, &facts.name);
|
||||
let hardness = facts.hardness;
|
||||
let harvest = !facts.requires_tool || self.items().correct_for_drops(held, &facts.name);
|
||||
speed = self.modify_destroy_speed(speed);
|
||||
speed *= block_break_speed;
|
||||
let player = self.player.as_ref().ok_or("no player")?;
|
||||
if !player.ground.on_ground {
|
||||
speed /= 5.0;
|
||||
}
|
||||
let eye = self.eye_position().unwrap_or_default();
|
||||
if self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(
|
||||
eye[0].floor() as i32,
|
||||
eye[1].floor() as i32,
|
||||
eye[2].floor() as i32,
|
||||
))
|
||||
.fluid
|
||||
== Fluid::Water
|
||||
{
|
||||
speed *= self.submerged_mining_speed();
|
||||
}
|
||||
let progress = if hardness == 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
speed / hardness / if harvest { 30.0 } else { 100.0 }
|
||||
};
|
||||
let progress = self.destroy_progress(hit.pos);
|
||||
self.swing_arm();
|
||||
let m = self.mining.as_mut().ok_or("mining")?;
|
||||
m.progress += progress;
|
||||
@@ -7817,18 +7796,29 @@ impl Game<'_> {
|
||||
/// farmland breaks. The local player's and every server player's landings.
|
||||
/// The level events they send go out too: a turtle egg's break particles
|
||||
/// and sound (`decreaseEggs`' 2001, `Level.destroyBlock`'s for the last).
|
||||
fn landing_block_updates(&mut self, event: &horizon_client::landing::FallEvent) {
|
||||
///
|
||||
/// A mod hears the change first (`native_entity_change_block_v2`, Bukkit's
|
||||
/// `EntityChangeBlockEvent`): cancelling puts the block back and skips the
|
||||
/// updates and the events, so nothing the change would have caused happens -
|
||||
/// the crop on trampled farmland stays, because breaking it is the
|
||||
/// neighbour update below and not a write of its own.
|
||||
fn landing_block_updates(
|
||||
&mut self,
|
||||
player: Option<u128>,
|
||||
event: &horizon_client::landing::FallEvent,
|
||||
) {
|
||||
use horizon_client::landing::FallEvent;
|
||||
let (position, flags) = match *event {
|
||||
// The block the landing changed, what it replaced, and what it became.
|
||||
let (position, from, to, flags) = match *event {
|
||||
FallEvent::Write {
|
||||
position, flags, ..
|
||||
} => (position, flags as i32),
|
||||
position,
|
||||
from,
|
||||
state,
|
||||
flags,
|
||||
} => (position, from, state, flags as i32),
|
||||
FallEvent::Destroy {
|
||||
position, state, ..
|
||||
} => {
|
||||
self.level_event_at(2001, [position.x, position.y, position.z], state.0 as i32);
|
||||
(position, level_flags::UPDATE_ALL)
|
||||
}
|
||||
} => (position, state, self.air_state(), level_flags::UPDATE_ALL),
|
||||
FallEvent::LevelEvent {
|
||||
event,
|
||||
position,
|
||||
@@ -7839,8 +7829,49 @@ impl Game<'_> {
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
if self.landing_change_cancelled(player, position, from, to) {
|
||||
self.world
|
||||
.set(position.x, position.y, position.z, from)
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
if matches!(event, FallEvent::Destroy { .. }) {
|
||||
self.level_event_at(2001, [position.x, position.y, position.z], from.0 as i32);
|
||||
}
|
||||
self.with_level(|b, level, ticks| b.notify_written(level, ticks, position, flags));
|
||||
}
|
||||
/// `Blocks.AIR.defaultBlockState`: what a landing's destroy leaves.
|
||||
fn air_state(&self) -> BlockStateId {
|
||||
self.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:air")
|
||||
.unwrap_or(BlockStateId(0))
|
||||
}
|
||||
/// Whether a mod cancelled a landing's block change
|
||||
/// (`native_entity_change_block_v2`): asked with the two blocks' names and
|
||||
/// the player whose landing it was (`None` when no player did it).
|
||||
fn landing_change_cancelled(
|
||||
&mut self,
|
||||
player: Option<u128>,
|
||||
position: BlockPos,
|
||||
from: BlockStateId,
|
||||
to: BlockStateId,
|
||||
) -> bool {
|
||||
let name = |s: BlockStateId| self.res.facts().get(s).name.to_string();
|
||||
let (from, to) = (name(from), name(to));
|
||||
let mut cancel = false;
|
||||
self.native_entity_change_block_v2(
|
||||
player,
|
||||
None,
|
||||
"minecraft:player",
|
||||
[position.x, position.y, position.z],
|
||||
&from,
|
||||
&to,
|
||||
&mut cancel,
|
||||
);
|
||||
cancel
|
||||
}
|
||||
/// The ticks the frame shows (animated textures, the HUD, a shader pack's clock): the
|
||||
/// client's, or under `HORIZON_STILL` (`FixedStep::still`) the world's game time, which
|
||||
/// moves only while the world runs.
|
||||
@@ -8080,8 +8111,6 @@ impl Game<'_> {
|
||||
if let Some(log) = self.world.block_log.as_mut() {
|
||||
log.extend(held);
|
||||
}
|
||||
// Everything that has joined has had its spawn packet.
|
||||
self.ordered.announced = horizon_client::join_seq::JoinSeq::upcoming();
|
||||
self.tick_friends();
|
||||
horizon_client::spans::time("tick_hand", || self.tick_hand());
|
||||
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
|
||||
@@ -8100,6 +8129,10 @@ impl Game<'_> {
|
||||
self.run_ordered_ticks(horizon_client::join_seq::JoinSeq::MAX, true)?;
|
||||
self.apply_late_wind_pushes();
|
||||
}
|
||||
// Everything that has joined has had its spawn packet and, if it joined
|
||||
// before the entity passes (TNT a piston or repeater primed), its first
|
||||
// tick in the late pass above (#10063).
|
||||
self.ordered.announced = horizon_client::join_seq::JoinSeq::upcoming();
|
||||
if running {
|
||||
horizon_client::spans::time("tick_moving_pistons", || self.tick_moving_pistons())?;
|
||||
horizon_client::spans::time("tick_block_entities", || self.tick_block_entities())?;
|
||||
@@ -8650,7 +8683,7 @@ impl Game<'_> {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
fn draw_item(&self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) {
|
||||
pub(super) fn draw_item(&self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) {
|
||||
// `GuiGraphics.renderItem`: the resolved item model (tints, foil and
|
||||
// special renderers included), then the decorations.
|
||||
self.draw_item_model(batch, stack, x, y);
|
||||
@@ -8740,7 +8773,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `GuiGraphicsExtractor.itemDecorations`: the count (or `count_text`)
|
||||
/// and damage bar.
|
||||
fn draw_item_decorations(
|
||||
pub(super) fn draw_item_decorations(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
stack: &ItemStack,
|
||||
@@ -10826,6 +10859,10 @@ impl Game<'_> {
|
||||
pub(super) fn has_control_down(&self) -> bool {
|
||||
self.modifiers.control_key()
|
||||
}
|
||||
/// The same live word for Alt (`InputWithModifiers.hasAltDown`).
|
||||
pub(super) fn has_alt_down(&self) -> bool {
|
||||
self.modifiers.alt_key()
|
||||
}
|
||||
/// Real events word the modifiers by their own `ModifiersChanged`; a
|
||||
/// control socket's `key` is synthesized, so it words the bit directly
|
||||
/// and every reader can ask the one live state.
|
||||
@@ -11275,12 +11312,27 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.yaw
|
||||
};
|
||||
// Packs may move vertices or draw from another camera; TAA jitters the
|
||||
// projection beyond this unjittered frustum. Keep their original geometry path.
|
||||
let fire_view = self.view_frustum.as_ref().filter(|_| {
|
||||
self.pack_ids.is_none()
|
||||
&& self.native_shaders.picked.is_empty()
|
||||
&& !self.settings.horizon.taa
|
||||
});
|
||||
let fire_visible = |position, width, height| {
|
||||
fire_view.is_none_or(|view| {
|
||||
horizon_client::fire_render::in_view(view, camera, position, width, height)
|
||||
})
|
||||
};
|
||||
laps.lap("eg_prep");
|
||||
for m in &self.mobs {
|
||||
// Blaze.isOnFire: a charged blaze burns.
|
||||
let blazing = m.species() == horizon_client::mobs::Species::Blaze && m.extra.flag;
|
||||
if (m.is_on_fire() || blazing) && !m.removed {
|
||||
let at = m.previous.lerp(m.position, alpha);
|
||||
if !fire_visible([at.x, at.y, at.z], m.width(), m.height()) {
|
||||
continue;
|
||||
}
|
||||
horizon_client::fire_render::flames(
|
||||
[at.x, at.y, at.z],
|
||||
m.width(),
|
||||
@@ -11305,20 +11357,26 @@ impl Game<'_> {
|
||||
let feet = self
|
||||
.previous_position
|
||||
.lerp(p.position, self.clock.camera_fraction());
|
||||
horizon_client::fire_render::flames(
|
||||
if fire_visible(
|
||||
[feet.x, feet.y, feet.z],
|
||||
p.dimensions.width,
|
||||
p.dimensions.height,
|
||||
camera,
|
||||
fire_yaw,
|
||||
&self.gui.atlas.sprites,
|
||||
self.world.light(
|
||||
feet.x.floor() as i32,
|
||||
feet.y.floor() as i32,
|
||||
feet.z.floor() as i32,
|
||||
),
|
||||
&mut out,
|
||||
);
|
||||
) {
|
||||
horizon_client::fire_render::flames(
|
||||
[feet.x, feet.y, feet.z],
|
||||
p.dimensions.width,
|
||||
p.dimensions.height,
|
||||
camera,
|
||||
fire_yaw,
|
||||
&self.gui.atlas.sprites,
|
||||
self.world.light(
|
||||
feet.x.floor() as i32,
|
||||
feet.y.floor() as i32,
|
||||
feet.z.floor() as i32,
|
||||
),
|
||||
&mut out,
|
||||
);
|
||||
}
|
||||
}
|
||||
laps.lap("eg_fire_mobs");
|
||||
for item in &self.dropped {
|
||||
@@ -11329,6 +11387,9 @@ impl Game<'_> {
|
||||
.resists_fire(&item.stack, "minecraft:in_fire")
|
||||
{
|
||||
let at = item.previous.lerp(item.position, alpha);
|
||||
if !fire_visible([at.x, at.y, at.z], 0.25, 0.25) {
|
||||
continue;
|
||||
}
|
||||
horizon_client::fire_render::flames(
|
||||
[at.x, at.y, at.z],
|
||||
0.25,
|
||||
@@ -11350,6 +11411,9 @@ impl Game<'_> {
|
||||
if stand.fire_ticks > 0 && !stand.marker {
|
||||
let scale = if stand.small { 0.5 } else { 1.0 };
|
||||
let at = stand.render_position(alpha);
|
||||
if !fire_visible([at.x, at.y, at.z], 0.5 * scale, 1.975 * scale) {
|
||||
continue;
|
||||
}
|
||||
horizon_client::fire_render::flames(
|
||||
[at.x, at.y, at.z],
|
||||
0.5 * scale,
|
||||
@@ -11453,7 +11517,17 @@ impl Game<'_> {
|
||||
);
|
||||
// Text, block and item displays (DisplayRenderer's shadow radius).
|
||||
casters.extend(self.text_displays.values().filter_map(|d| d.shadow(a)));
|
||||
// Packs may move vertices or draw from another view.
|
||||
let shadow_view = self
|
||||
.view_frustum
|
||||
.as_ref()
|
||||
.filter(|_| self.pack_ids.is_none() && self.native_shaders.picked.is_empty());
|
||||
for (pos, radius, strength) in casters {
|
||||
if let Some(view) = shadow_view
|
||||
&& !horizon_client::shadows::in_view(view, camera, pos, radius, strength)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
horizon_client::shadows::shadow(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
@@ -13864,7 +13938,7 @@ impl Game<'_> {
|
||||
return Ok(UseResult::Pass);
|
||||
};
|
||||
// `BucketItem.use`: `level.mayInteract` and `mayUseItemAt`.
|
||||
if !self.may_build() {
|
||||
if !self.may_build() && !self.can_place_on_in_adventure(hit.pos) {
|
||||
return Ok(UseResult::Fail);
|
||||
}
|
||||
let mut canceled = false;
|
||||
@@ -13915,7 +13989,7 @@ impl Game<'_> {
|
||||
};
|
||||
let clicked = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
if let Some((content, sound, mob)) = bucket {
|
||||
if !self.may_build() {
|
||||
if !self.may_build() && !self.can_place_on_in_adventure(hit.pos) {
|
||||
return Ok(UseResult::Fail);
|
||||
}
|
||||
let target = self.bucket_target(content, &hit);
|
||||
@@ -14031,7 +14105,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if item == "flint_and_steel" || item == "fire_charge" {
|
||||
if !self.may_build() {
|
||||
if !self.may_build() && !self.can_place_on_in_adventure(hit.pos) {
|
||||
return Ok(UseResult::Fail);
|
||||
}
|
||||
let name = self.res.facts().get(clicked).name.clone();
|
||||
@@ -16166,6 +16240,8 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
fn render(&mut self) -> Result<bool> {
|
||||
let prepare = spans::guard("prepare");
|
||||
let mut pr = spans::laps();
|
||||
// The window's GPU holds the atlas: its copy in memory is kept
|
||||
// compressed (a no-op once it is).
|
||||
if self.renderer.is_some() {
|
||||
@@ -16183,6 +16259,9 @@ impl Game<'_> {
|
||||
self.apply_ui_settings();
|
||||
}
|
||||
self.observe_screen();
|
||||
// `Gui.setScreen` clears the focus of the screen it leaves: a menu
|
||||
// that comes back starts with no widget highlighted.
|
||||
self.drop_left_screen_focus();
|
||||
// Minecraft.doWorldLoad: the LevelLoadingScreen while single player's
|
||||
// server starts, with the server's chunk map and progress.
|
||||
if let Some(starting) = &self.integrated_starting {
|
||||
@@ -16204,6 +16283,8 @@ impl Game<'_> {
|
||||
upgrade,
|
||||
self.cursor,
|
||||
);
|
||||
pr.lap("pr_loading");
|
||||
drop(prepare);
|
||||
return renderer.render(&loading_frame(&hud));
|
||||
}
|
||||
return Ok(false);
|
||||
@@ -16243,6 +16324,8 @@ impl Game<'_> {
|
||||
Some(&status),
|
||||
progress,
|
||||
);
|
||||
pr.lap("pr_loading");
|
||||
drop(prepare);
|
||||
return renderer.render(&loading_frame(&hud));
|
||||
}
|
||||
return Ok(false);
|
||||
@@ -16259,6 +16342,8 @@ impl Game<'_> {
|
||||
loader.progress.line(),
|
||||
loader.progress.fraction(),
|
||||
);
|
||||
pr.lap("pr_loading");
|
||||
drop(prepare);
|
||||
return renderer.render(&loading_frame(&hud));
|
||||
}
|
||||
return Ok(false);
|
||||
@@ -16275,6 +16360,8 @@ impl Game<'_> {
|
||||
reload.progress.fraction(),
|
||||
self.settings.file.bool("darkMojangStudiosBackground"),
|
||||
);
|
||||
pr.lap("pr_loading");
|
||||
drop(prepare);
|
||||
return renderer.render(&loading_frame(&hud));
|
||||
}
|
||||
return Ok(false);
|
||||
@@ -16286,6 +16373,7 @@ impl Game<'_> {
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
pr.lap("pr_setup");
|
||||
let alpha = self.clock.fraction();
|
||||
// Camera.getCameraEntityPartialTicks: the view, the hand and the
|
||||
// player's own model stay smooth while the level is frozen.
|
||||
@@ -16325,8 +16413,10 @@ impl Game<'_> {
|
||||
self.pitch,
|
||||
),
|
||||
};
|
||||
pr.lap("pr_camera");
|
||||
self.audio
|
||||
.set_listener([camera.x, camera.y, camera.z], view_yaw, view_pitch);
|
||||
pr.lap("pr_listener");
|
||||
let block = [
|
||||
camera.x.floor() as i32,
|
||||
camera.y.floor() as i32,
|
||||
@@ -16348,6 +16438,7 @@ impl Game<'_> {
|
||||
renderer.start_occlusion(camera, (self.streaming.radius * 16) as f32)
|
||||
});
|
||||
}
|
||||
pr.lap("pr_occlusion");
|
||||
let biome = self
|
||||
.world
|
||||
.chunks
|
||||
@@ -16395,12 +16486,14 @@ impl Game<'_> {
|
||||
std::array::from_fn(|i| sky.sky_color[i] + (flash[i] - sky.sky_color[i]) * 0.22);
|
||||
sky.sky_light_factor = 1.0;
|
||||
}
|
||||
pr.lap("pr_environment");
|
||||
// FishingHookRenderer's line, then DebugRenderer: chunk borders and
|
||||
// hitboxes (F3+G, F3+B).
|
||||
let mut lines = self.fishing_line(alpha, size);
|
||||
let g = self.debug_gizmos(alpha as f32, camera.to_array());
|
||||
lines.extend(g.lines);
|
||||
let on_top = g.on_top;
|
||||
pr.lap("pr_debug");
|
||||
let colored_lights = if self.title.is_none() {
|
||||
self.native_frame_visuals(camera, alpha, &mut lines)
|
||||
} else {
|
||||
@@ -16418,12 +16511,14 @@ impl Game<'_> {
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
pr.lap("pr_native_visuals");
|
||||
let view = horizon_client::camera::view_matrix(view_yaw, view_pitch);
|
||||
let world_primitives = if self.title.is_none() {
|
||||
self.world_primitive_geometry(camera, alpha, view)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
pr.lap("pr_primitives");
|
||||
let at = camera.to_array();
|
||||
// GameRenderer.render3dHud: the debug crosshair, first person with the HUD shown.
|
||||
let mut crosshair = horizon_client::gizmos::Gizmos::default();
|
||||
@@ -16503,6 +16598,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
pr.lap("pr_outline");
|
||||
let title = self.shows_panorama();
|
||||
let (yaw, pitch) = if title {
|
||||
// PanoramaRenderer: the title cube spins at 0.1 degrees per tick, pitched 10 degrees.
|
||||
@@ -16561,11 +16657,13 @@ impl Game<'_> {
|
||||
dimension: self.dim.id().to_string(),
|
||||
}
|
||||
});
|
||||
pr.lap("pr_projection");
|
||||
// GUI hooks project against this frame's camera, including its bob/spin and FOV.
|
||||
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
|
||||
self.flush_ime(&mut hud);
|
||||
self.native_gui_render_post(&mut hud, self.ui.hide_gui, self.focused);
|
||||
self.resolve_inline_profiles(&hud.inline_profiles);
|
||||
pr.lap("pr_hud");
|
||||
self.locator_camera =
|
||||
(!title).then_some((camera.to_array(), view_yaw, view_pitch, fov_degrees));
|
||||
let requested_post_effects = self.requested_post_effects();
|
||||
@@ -16582,6 +16680,7 @@ impl Game<'_> {
|
||||
self.see_through_vertices.clear();
|
||||
self.glowing_vertices.get_mut().clear();
|
||||
self.shadow_only_vertices.get_mut().clear();
|
||||
pr.lap("pr_entity_setup");
|
||||
if !title {
|
||||
let (built, static_sections, visible_static_sections, static_stats) =
|
||||
spans::time("entity_geometry", || {
|
||||
@@ -16598,6 +16697,7 @@ impl Game<'_> {
|
||||
// be drawn over the title (#8192). Its models stay stored.
|
||||
self.skin_frame.get_mut().clear_draws();
|
||||
}
|
||||
pr.lap("pr_entities");
|
||||
let bolts = if title {
|
||||
Vec::new()
|
||||
} else {
|
||||
@@ -16612,6 +16712,7 @@ impl Game<'_> {
|
||||
self.dragon_fireball_geometry(camera, alpha, &mut dragon);
|
||||
entities.extend(dragon);
|
||||
}
|
||||
pr.lap("pr_dragon_bolts");
|
||||
let (celestial, stars) = if title {
|
||||
(Vec::new(), None)
|
||||
} else if self.dim == Dim::Overworld
|
||||
@@ -16625,6 +16726,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
(Vec::new(), None)
|
||||
};
|
||||
pr.lap("pr_celestial");
|
||||
let (mut hand, hand_items) = if title || self.scoping() {
|
||||
Default::default()
|
||||
} else {
|
||||
@@ -16637,6 +16739,7 @@ impl Game<'_> {
|
||||
let aspect = size[0] as f32 / size[1].max(1) as f32;
|
||||
self.item_activation_geometry(camera_alpha as f32, view, aspect, &mut hand);
|
||||
}
|
||||
pr.lap("pr_hand");
|
||||
let screen = if title {
|
||||
Vec::new()
|
||||
} else {
|
||||
@@ -16651,6 +16754,7 @@ impl Game<'_> {
|
||||
self.end_geometry(camera, &mut entities);
|
||||
}
|
||||
self.outline_fill(&outline, camera, &mut entities);
|
||||
pr.lap("pr_screen_end");
|
||||
let weather = if !title && self.can_have_weather() && self.settings.horizon.weather {
|
||||
horizon_client::spans::time("weather", || self.weather_geometry(camera, alpha))
|
||||
} else {
|
||||
@@ -16660,16 +16764,19 @@ impl Game<'_> {
|
||||
.then(|| self.border_draw(camera.to_array(), alpha as f32))
|
||||
.flatten();
|
||||
let see_through = std::mem::take(&mut self.see_through_vertices);
|
||||
pr.lap("pr_weather_border");
|
||||
if title {
|
||||
self.dynamic_lights.clear();
|
||||
} else {
|
||||
self.gather_dynamic_lights(camera, alpha);
|
||||
}
|
||||
pr.lap("pr_lights");
|
||||
let pack_inputs = if title {
|
||||
Default::default()
|
||||
} else {
|
||||
self.shader_pack_inputs(camera, alpha as f32, &sky)
|
||||
};
|
||||
pr.lap("pr_shader_inputs");
|
||||
// Map textures and font glyphs changed while building this frame's
|
||||
// geometry (game/maps.rs, world text) upload with this batch; draining
|
||||
// map_patches any earlier leaves them a frame behind.
|
||||
@@ -16836,6 +16943,9 @@ impl Game<'_> {
|
||||
},
|
||||
chunk_fade: self.settings.chunk_fade,
|
||||
};
|
||||
pr.lap("pr_frame");
|
||||
// Renderer encoding and GPU waiting belong to render, not prepare.
|
||||
drop(prepare);
|
||||
let result = match self.renderer.as_mut() {
|
||||
Some(renderer) => {
|
||||
renderer.set_pass_timing(self.debug || self.gpu_passes);
|
||||
@@ -16843,6 +16953,7 @@ impl Game<'_> {
|
||||
}
|
||||
None => Err("renderer".into()),
|
||||
};
|
||||
let _cleanup = spans::guard("pr_cleanup");
|
||||
// A frame the surface refused (occluded, lost, timed out) is `Ok(false)`.
|
||||
self.frame_drawn = matches!(result, Ok(true));
|
||||
// Restore storage even when rendering fails (for example, a lost surface).
|
||||
@@ -16992,6 +17103,9 @@ impl Game<'_> {
|
||||
/// `Screen.keyPressed` hears the raw one, and only a raw key settles what
|
||||
/// the container's hotbar keys are now (`checkHotbarKeyPressed`).
|
||||
fn key_pressed(&mut self, key: KeyCode, raw: KeyCode) -> Result<()> {
|
||||
// `CreativeModeInventoryScreen.keyPressed` clears `ignoreTextInput` on
|
||||
// every key, before it decides anything.
|
||||
self.creative_ignore_text = false;
|
||||
if self.sleeping.is_some()
|
||||
&& self.screen == Screen::Playing
|
||||
&& !matches!(
|
||||
@@ -17095,6 +17209,17 @@ impl Game<'_> {
|
||||
&& self.search_tab_hotbar_key(key) => {}
|
||||
KeyCode::KeyT if self.screen == Screen::Inventory => self.recipe_focus_search(),
|
||||
KeyCode::KeyT if self.screen == Screen::Playing => self.open_chat_method(""),
|
||||
// CreativeModeInventoryScreen.keyPressed: the chat key opens the
|
||||
// search tab from another tab, and `ignoreTextInput` drops the
|
||||
// letter it would have typed.
|
||||
KeyCode::KeyT
|
||||
if self.screen == Screen::Creative && !self.creative_screen.tab().search =>
|
||||
{
|
||||
if let Some(tab) = self.creative_screen.search_tab() {
|
||||
select_creative_tab(&mut self.creative_screen, &mut self.inv_clicks, tab);
|
||||
self.creative_ignore_text = true;
|
||||
}
|
||||
}
|
||||
// key.advancements.
|
||||
KeyCode::KeyL if self.screen == Screen::Playing => {
|
||||
self.open_advancements(false);
|
||||
@@ -19263,6 +19388,8 @@ impl Game<'_> {
|
||||
let steps = self.clock.advance(elapsed);
|
||||
for _ in 0..steps {
|
||||
let started = Instant::now();
|
||||
// Minecraft.tick: ClientTickEvent.Pre before any of it.
|
||||
self.native_client_tick_pre();
|
||||
if self.title.is_none() && !self.pause {
|
||||
if self.tick_script.armed() {
|
||||
self.tick_script_step(event_loop);
|
||||
@@ -19285,6 +19412,8 @@ impl Game<'_> {
|
||||
self.tick_chat_queue();
|
||||
self.tick_music();
|
||||
self.tick_sound_engine();
|
||||
// ClientTickEvent.Post, the tick's work done.
|
||||
self.native_client_tick_post();
|
||||
self.clock.record(started.elapsed());
|
||||
if let Some(report) = self.clock.take_sprint_report() {
|
||||
self.sprint_report(&report);
|
||||
@@ -19305,7 +19434,7 @@ impl Game<'_> {
|
||||
}
|
||||
result => result?,
|
||||
};
|
||||
self.apply_cursor_icon();
|
||||
horizon_client::spans::time("pr_cursor", || self.apply_cursor_icon());
|
||||
// The frame's spans, taken every frame they are recorded so they never
|
||||
// pile up: the slow-frame line's, and the session's (`perf.frame`).
|
||||
let frame_spans = if horizon_client::spans::recording_here() {
|
||||
@@ -19969,6 +20098,7 @@ impl<'a> Game<'a> {
|
||||
breath_clouds: Vec::new(),
|
||||
dragon_model: prepared.dragon_model,
|
||||
map_patches: Vec::new(),
|
||||
name_tag_layouts: Default::default(),
|
||||
text_displays: Default::default(),
|
||||
interactions: Default::default(),
|
||||
remote_knots: Default::default(),
|
||||
@@ -20022,6 +20152,7 @@ impl<'a> Game<'a> {
|
||||
mod_chunk_tickets: HashMap::new(),
|
||||
item_columns: None,
|
||||
creative_screen: prepared.creative_screen,
|
||||
creative_ignore_text: false,
|
||||
control: None,
|
||||
agent_control_on: false,
|
||||
agent_last: None,
|
||||
@@ -20093,6 +20224,7 @@ impl<'a> Game<'a> {
|
||||
pause_menu: true,
|
||||
pause_focus: None,
|
||||
death_focus: None,
|
||||
focus_screen: 0,
|
||||
previous_mode: None,
|
||||
gamemode_switcher: None,
|
||||
clipboard: Default::default(),
|
||||
@@ -21807,6 +21939,10 @@ fn start_game<'a>(
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some(directory) = std::env::var_os("HORIZON_BOAT_RIDER_FIXTURE") {
|
||||
game.run_boat_rider_fixture(std::path::Path::new(&directory))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(directory) = std::env::var_os("HORIZON_BOAT_AUTHORITY_FIXTURE") {
|
||||
game.run_boat_authority_fixture(std::path::Path::new(&directory))?;
|
||||
return Ok(None);
|
||||
@@ -22497,6 +22633,8 @@ pub fn run_with() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = horizon_client::health::HURT_SERVER.set(native_hooks::hurt_server);
|
||||
let _ = horizon_client::health::DAMAGE_PRE.set(native_hooks::damage_pre);
|
||||
let _ = horizon_client::health::DAMAGE_POST.set(native_hooks::damage_post);
|
||||
let _ = horizon_client::block_contact::HOOK.set(native_hooks::block_contact);
|
||||
let _ = horizon_client::health::KNOCKBACK.set(native_hooks::knockback);
|
||||
// Before any worker starts: on Linux they go behind the server thread
|
||||
|
||||
@@ -692,7 +692,9 @@ impl Game<'_> {
|
||||
let uuid = self.persist.uuids.get(&m.id).copied().unwrap_or_default();
|
||||
let raw = self.persist.raw.get(&m.id);
|
||||
let registry = Some(self.pack.assets.registry);
|
||||
let mut tag = horizon_client::entity_nbt::mob_to_nbt(m, uuid, raw, registry);
|
||||
let mut tag = horizon_client::entity_nbt::mob_to_nbt(m, uuid, raw, registry, |other| {
|
||||
self.persist.uuid_of(other)
|
||||
});
|
||||
let riders = passengers.iter().filter_map(|r| r.nbt.clone()).collect();
|
||||
super::persist::add_passengers(&mut tag, riders);
|
||||
tag
|
||||
|
||||
@@ -202,16 +202,27 @@ impl Game<'_> {
|
||||
let host = self
|
||||
.player_view()
|
||||
.filter(|p| !p.spectator && self.level.player_uuid.unwrap_or([0, 0, 0, 1]) == uuid)
|
||||
.map(|p| p.position);
|
||||
.map(|p| (p.position, p.eye));
|
||||
let found = host.or_else(|| {
|
||||
self.lan_present()
|
||||
.into_iter()
|
||||
.find(|p| p.uuid == uuid && !p.spectator)
|
||||
.map(|p| {
|
||||
horizon_core::geometry::Vec3::new(p.position[0], p.position[1], p.position[2])
|
||||
(
|
||||
horizon_core::geometry::Vec3::new(
|
||||
p.position[0],
|
||||
p.position[1],
|
||||
p.position[2],
|
||||
),
|
||||
p.eye,
|
||||
)
|
||||
})
|
||||
});
|
||||
found.filter(|p| p.distance_squared(m.position) < 64.0 * 64.0)
|
||||
// The 64-block search uses the feet; the deposit point is the eyes
|
||||
// (`new EntityTracker(player, true)`).
|
||||
found
|
||||
.filter(|(feet, _)| feet.distance_squared(m.position) < 64.0 * 64.0)
|
||||
.map(|(_, eye)| horizon_core::geometry::Vec3::new(eye[0], eye[1], eye[2]))
|
||||
}
|
||||
/// `GoAndGiveItemsToTarget` / `AllayAi.throwItem`: within 3 blocks of
|
||||
/// the deposit, one item a tick, each restarting the three-second pause
|
||||
@@ -249,13 +260,14 @@ impl Game<'_> {
|
||||
};
|
||||
let from = [
|
||||
m.position.x,
|
||||
m.position.y + m.eye_height() - 0.3,
|
||||
m.position.y + m.eye_height() - 0.2,
|
||||
m.position.z,
|
||||
];
|
||||
// BehaviorUtils.throwItem: the vector runs from the allay's feet.
|
||||
let d = [
|
||||
target.x - from[0],
|
||||
target.y + 1.0 - from[1],
|
||||
target.z - from[2],
|
||||
target.x - m.position.x,
|
||||
target.y + 1.0 - m.position.y,
|
||||
target.z - m.position.z,
|
||||
];
|
||||
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt().max(1e-6);
|
||||
let v = [d[0] / len * 0.2, d[1] / len * 0.3, d[2] / len * 0.2];
|
||||
|
||||
@@ -1110,12 +1110,13 @@ impl Game<'_> {
|
||||
)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0));
|
||||
let light = self.world.light(
|
||||
at.x.floor() as i32,
|
||||
(at.y + 0.5).floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
// `EntityRenderer.getPackedLightCoords`: the block
|
||||
// `ArmorStand.getLightProbePosition` gives - the brightest cell of a
|
||||
// marker's own box, else the stand's eye - at 15 block light while
|
||||
// it burns.
|
||||
let probe = a.light_probe(alpha, |x, y, z| self.world.light(x, y, z));
|
||||
let light = self.world.light(probe[0], probe[1], probe[2]);
|
||||
let packed = a.render_light(light);
|
||||
let skinned = if skinning {
|
||||
let mut models = self.skin_models.borrow_mut();
|
||||
let mut frame = self.skin_frame.borrow_mut();
|
||||
@@ -1629,8 +1630,8 @@ impl Game<'_> {
|
||||
.extra
|
||||
.as_deref()
|
||||
.and_then(|d| d.other.iter().find(|(k, _)| k == "minecraft:profile"))
|
||||
.map(|(_, t)| horizon_client::scoreboard::tag_to_json(t))
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
.map(|(_, t)| self.skin_profiles.borrow_mut().head_profile_nbt(t))
|
||||
.unwrap_or_else(|| std::sync::Arc::new(serde_json::Value::Null));
|
||||
self.pack.item_renderer.submit_special(
|
||||
&special,
|
||||
&profile,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Headless passenger-order regression through the real remote client paths.
|
||||
use super::{Game, Keys, boats::Boat};
|
||||
use horizon_client::{Result, held::Source, motion::PlayerMotion};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::{connection::Pipe, packets::play::Serverbound};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Case {
|
||||
name: String,
|
||||
first: Vec<bool>,
|
||||
velocity: [f64; 3],
|
||||
spin: f32,
|
||||
keys: [bool; 4],
|
||||
event: i8,
|
||||
}
|
||||
impl Game<'_> {
|
||||
pub(super) fn run_boat_rider_fixture(&mut self, directory: &Path) -> Result<()> {
|
||||
let cases: Vec<Case> =
|
||||
serde_json::from_str(&std::fs::read_to_string(directory.join("inputs.json"))?)?;
|
||||
let (client, peer) = Pipe::pair();
|
||||
self.connect_local(client, "BoatSeatFixture")?;
|
||||
self.remote.as_mut().unwrap().entity_id = 1;
|
||||
self.player = Some(PlayerMotion::new(self.motion, Vec3::new(0.0, 100.0, 0.0))?);
|
||||
|
||||
let mut output = Vec::new();
|
||||
for case in cases {
|
||||
self.boats.clear();
|
||||
let mut b = Boat::new(3, "oak".into(), Vec3::new(0.0, 100.0, 0.0), 0.0);
|
||||
let [x, y, z] = case.velocity;
|
||||
b.velocity = Vec3::new(x, y, z);
|
||||
b.delta_rotation = case.spin;
|
||||
self.keys.release_source(Source::Agent);
|
||||
for (key, held) in [KeyCode::KeyA, KeyCode::KeyD, KeyCode::KeyW, KeyCode::KeyS]
|
||||
.into_iter()
|
||||
.zip(case.keys)
|
||||
{
|
||||
if held {
|
||||
self.keys.press(key, Source::Agent);
|
||||
}
|
||||
}
|
||||
self.boats.push(b);
|
||||
let mut steps = Vec::new();
|
||||
for first in case.first {
|
||||
self.remote_set_passengers(3, if first { &[1, 2] } else { &[2, 1] });
|
||||
let mut accepts_server_moves = false;
|
||||
self.tick_remote_objects(
|
||||
|_, p| {
|
||||
accepts_server_moves = true;
|
||||
p
|
||||
},
|
||||
|_, yaw| yaw,
|
||||
|_| false,
|
||||
);
|
||||
self.remote_ride(
|
||||
Keys {
|
||||
left: case.keys[0],
|
||||
right: case.keys[1],
|
||||
forward: case.keys[2],
|
||||
backward: case.keys[3],
|
||||
..Keys::default()
|
||||
},
|
||||
7,
|
||||
)?;
|
||||
let packets = self.remote_vehicle_packets();
|
||||
if case.event != 0 {
|
||||
self.remote_boat_event(3, case.event);
|
||||
}
|
||||
let boat = &self.boats[0];
|
||||
steps.push(json!({
|
||||
"controlled": self.remote.as_ref().unwrap().first_passenger,
|
||||
"accepts_server_moves": accepts_server_moves,
|
||||
"hands_busy": self.hands_busy(),
|
||||
"moves": packets.iter().filter(|p| matches!(p, Serverbound::MoveVehicle(_))).count(),
|
||||
"paddles": packets.iter().filter(|p| matches!(p, Serverbound::PaddleBoat(_))).count(),
|
||||
"spin": format!("{:08x}", boat.delta_rotation.to_bits()),
|
||||
"velocity": boat.velocity.components().map(|v| format!("{:016x}", v.to_bits())),
|
||||
}));
|
||||
}
|
||||
output.push(json!({"name": case.name, "steps": steps}));
|
||||
}
|
||||
peer.shutdown();
|
||||
self.remote = None;
|
||||
std::fs::write(directory.join("rust.json"), serde_json::to_vec(&output)?)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -934,7 +934,7 @@ impl Game<'_> {
|
||||
/// client moves only the boat its player drives.
|
||||
fn boat_can_simulate_movement(&self, id: u32) -> bool {
|
||||
if self.remote.is_some() {
|
||||
self.riding == Some(id)
|
||||
self.controls_boat(id)
|
||||
} else {
|
||||
!self.guest_vehicles().contains(&id)
|
||||
}
|
||||
|
||||
@@ -294,6 +294,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
"raid_state" => {
|
||||
let state = self.harness_owner(|g| g.raid_fixture_state(&op))?;
|
||||
let row = json!({"cmd":format!("#raid_state {} {}",op["label"].as_str().unwrap_or(""),op["id"]),
|
||||
"messages":[],"results":[],"state":state});
|
||||
out.push_str(&row.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
"mob_anger" => {
|
||||
// Metadata-state equality through the production writer, not packet delivery.
|
||||
let uuid = u128::from_str_radix(
|
||||
|
||||
@@ -2799,6 +2799,18 @@ impl Game<'_> {
|
||||
horizon_client::save::consume_container_loot(&mut extras, key);
|
||||
chunk.saved = Some(std::sync::Arc::new(extras));
|
||||
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
|
||||
// `ChestBlock.getMenuProvider` unpacks both halves of a double chest:
|
||||
// the other half's table, if it still holds one, rolls too. The own roll
|
||||
// is consumed above, so the partner's call does not come back here.
|
||||
if let Some((other, _)) = self.chest_partner(key) {
|
||||
let other_name = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(other.0, other.1, other.2))
|
||||
.name
|
||||
.to_string();
|
||||
self.unpack_opened_loot(other, &other_name);
|
||||
}
|
||||
}
|
||||
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
|
||||
/// container (`setblock`, `data`): an empty container with a `LootTable`
|
||||
|
||||
@@ -201,19 +201,8 @@ impl Game<'_> {
|
||||
crate::game::persist::with_raw(tag, self.persist.raw.get(&id))
|
||||
}
|
||||
Target::Tnt(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let t = self.tnts.iter().find(|t| t.id == id)?;
|
||||
entity_nbt::tnt_to_nbt(
|
||||
t.position,
|
||||
t.velocity,
|
||||
t.on_ground,
|
||||
t.fuse,
|
||||
t.power,
|
||||
t.block_state_nbt(self.pack.assets.registry),
|
||||
t.rotation,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
)
|
||||
let i = self.tnts.iter().position(|t| t.id == id)?;
|
||||
self.tnt_nbt(i)
|
||||
}
|
||||
Target::Falling(id) => {
|
||||
let f = self.falling.iter().find(|f| f.id == id)?.clone();
|
||||
|
||||
@@ -635,9 +635,9 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
Slot::Frame(id) => match self.item_frames.iter_mut().find(|f| f.id == *id) {
|
||||
Some(f) => {
|
||||
f.item = stack;
|
||||
Slot::Frame(id) => match self.item_frames.iter().position(|f| f.id == *id) {
|
||||
Some(i) => {
|
||||
self.set_frame_item(i, stack);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
|
||||
@@ -30,26 +30,30 @@ impl Game<'_> {
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
/// A template's tree by id, as `StructureTemplateManager` reads it from
|
||||
/// disk.
|
||||
/// A template's tree by id, as `StructureTemplateManager` reads it: the
|
||||
/// world's saved structures, the enabled data packs from the highest, and
|
||||
/// the game's own.
|
||||
pub(in crate::game) fn read_template_tag(&self, id: &str) -> Option<Tag> {
|
||||
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
let generated = self
|
||||
.world_root
|
||||
.join("generated")
|
||||
.join(ns)
|
||||
.join("structures")
|
||||
.join(format!("{path}.nbt"));
|
||||
let in_pack = format!("data/{ns}/structure/{path}.nbt");
|
||||
let prepared = self
|
||||
.options
|
||||
.resources
|
||||
.join("worldgen/structure")
|
||||
.join(ns)
|
||||
.join(format!("{path}.nbt"));
|
||||
let candidates = [
|
||||
self.world_root
|
||||
.join("generated")
|
||||
.join(ns)
|
||||
.join("structures")
|
||||
.join(format!("{path}.nbt")),
|
||||
self.options
|
||||
.resources
|
||||
.join("worldgen/structure")
|
||||
.join(ns)
|
||||
.join(format!("{path}.nbt")),
|
||||
std::fs::read(&generated).ok(),
|
||||
self.fns.pack_file(&in_pack),
|
||||
std::fs::read(&prepared).ok(),
|
||||
];
|
||||
for file in candidates {
|
||||
let Ok(bytes) = std::fs::read(&file) else {
|
||||
continue;
|
||||
};
|
||||
for bytes in candidates.into_iter().flatten() {
|
||||
let Ok(raw) = Compression::Gzip.decode(&bytes, 1 << 28) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -294,6 +294,11 @@ impl Game<'_> {
|
||||
// The anvil's rename box, while it has an input to name.
|
||||
Screen::Inventory if self.station_typing() => self.station_text(Some(text), false),
|
||||
Screen::Creative if self.creative_screen.tab().search => {
|
||||
// CreativeModeInventoryScreen.charTyped: the chat key that
|
||||
// opened the tab does not type its letter.
|
||||
if std::mem::take(&mut self.creative_ignore_text) {
|
||||
return Ok(());
|
||||
}
|
||||
// As typed: replaces a selected query, keeps the length limit.
|
||||
self.creative_search_edit(Some(text))
|
||||
}
|
||||
|
||||
@@ -144,15 +144,18 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
binding: self.w.collision,
|
||||
subject: None,
|
||||
};
|
||||
let context = horizon_world::collision_context::CollisionContext::position(from.y);
|
||||
let r = traverse_blocks(from, to, 256, |p: BlockPos| {
|
||||
let state = self.w.world.block(p.x, p.y, p.z);
|
||||
if self.w.facts.is_air(state) {
|
||||
return None;
|
||||
}
|
||||
// ClipContext hands CollisionContext.of(projectile): scaffolding
|
||||
// is open for a projectile below it.
|
||||
let shape = self
|
||||
.w
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.collision_shape_with_context(&mut scene, state, p, &context)
|
||||
.ok()?;
|
||||
shape.clip(from, to, p)
|
||||
});
|
||||
@@ -2119,11 +2122,15 @@ impl Game<'_> {
|
||||
None,
|
||||
);
|
||||
}
|
||||
MobEvent::Sound(name, at, volume, pitch)
|
||||
| MobEvent::LocalSound(name, at, volume, pitch) => {
|
||||
MobEvent::Sound(name, at, volume, pitch) => {
|
||||
self.audio
|
||||
.play(&format!("minecraft:{name}"), Some(at), volume, pitch)
|
||||
}
|
||||
MobEvent::LocalSound(name, at, volume, pitch) => {
|
||||
// Level.playLocalSound: play locally without broadcasting.
|
||||
self.audio
|
||||
.play_unshared(&format!("minecraft:{name}"), Some(at), volume, pitch)
|
||||
}
|
||||
MobEvent::LevelEventSound {
|
||||
sound,
|
||||
at,
|
||||
@@ -2161,7 +2168,7 @@ impl Game<'_> {
|
||||
// A mob's own `gameEvent(..)` calls: Armadillo.rollUp/rollOut's
|
||||
// ENTITY_ACTION, Creeper.tick's PRIME_FUSE, Mob.ate's EAT, the
|
||||
// shulker's lid (Shulker.setRawPeekAmount), an evoker's vexes
|
||||
// (ENTITY_PLACE).
|
||||
// (ENTITY_PLACE), Enderman.teleport's TELEPORT where it was.
|
||||
MobEvent::GameEvent {
|
||||
event, at, source, ..
|
||||
} if matches!(
|
||||
@@ -2172,6 +2179,7 @@ impl Game<'_> {
|
||||
| "minecraft:container_open"
|
||||
| "minecraft:container_close"
|
||||
| "minecraft:entity_place"
|
||||
| "minecraft:teleport"
|
||||
) =>
|
||||
{
|
||||
let ctx = self.who_context(Some(Who::Mob(source)), None);
|
||||
@@ -7137,11 +7145,11 @@ impl Game<'_> {
|
||||
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
|
||||
self.mark_entity(entity_start, &mob.kind.id);
|
||||
self.rendered_mobs.set(self.rendered_mobs.get() + 1);
|
||||
let light = self.world.light(
|
||||
mob.position.x.floor() as i32,
|
||||
(mob.position.y + mob.eye_height()).floor() as i32,
|
||||
mob.position.z.floor() as i32,
|
||||
);
|
||||
// `EntityRenderer.getPackedLightCoords`: the block the mob's light
|
||||
// probe (its eye) is in, at the position this frame draws rather
|
||||
// than this tick's.
|
||||
let probe = mob.light_probe(alpha);
|
||||
let light = self.world.light(probe[0], probe[1], probe[2]);
|
||||
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
// EntityRenderer.getBlockLightLevel: a burning mob has 15 block
|
||||
// light, and so do the Allay, Blaze, Magma Cube, Vex and Wither
|
||||
@@ -7398,11 +7406,12 @@ impl Game<'_> {
|
||||
let p = glam::DVec3::from_array(at) - camera;
|
||||
let c = p.as_vec3();
|
||||
// EntityRenderer.getPackedLightCoords: the block its light probe
|
||||
// (the eye) is in, not the one under its feet.
|
||||
// (the eye) is in, at the position this frame draws rather than
|
||||
// this tick's, and not the one under its feet.
|
||||
let light = self.world.light(
|
||||
a.position.x.floor() as i32,
|
||||
a.light_probe_y().floor() as i32,
|
||||
a.position.z.floor() as i32,
|
||||
at[0].floor() as i32,
|
||||
a.light_probe_y(alpha).floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
);
|
||||
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
// EntityRenderer.getBlockLightLevel: a shulker bullet and anything
|
||||
@@ -7936,6 +7945,86 @@ fn mob_effect_instance(
|
||||
Some(instance)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod local_sound_event_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::mobs::MobEvent;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn local_mob_events_play_without_entering_the_shared_capture() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.game_dir =
|
||||
Some(std::env::temp_dir().join(format!("horizon-h069-unit-{}", std::process::id())));
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.audio.capture = Some(Vec::new());
|
||||
game.handle_mob_events(vec![
|
||||
MobEvent::Sound("entity.cow.hurt", [1.0, 100.0, 2.0], 0.5, 1.0),
|
||||
MobEvent::LocalSound("entity.breeze.whirl", [3.0, 100.0, 4.0], 0.8, 0.7),
|
||||
MobEvent::Sound("entity.zombie.hurt", [5.0, 100.0, 6.0], 1.0, 0.9),
|
||||
])
|
||||
.unwrap();
|
||||
// A positive LocalSound must still take the playback path. An absent
|
||||
// producer cannot satisfy this actual Game adapter regression.
|
||||
assert_eq!(
|
||||
game.audio.heard.get("minecraft:entity.breeze.whirl"),
|
||||
Some(&1)
|
||||
);
|
||||
assert_eq!(game.audio.heard.get("minecraft:entity.cow.hurt"), Some(&1));
|
||||
assert_eq!(
|
||||
game.audio.heard.get("minecraft:entity.zombie.hurt"),
|
||||
Some(&1)
|
||||
);
|
||||
let shared = game.audio.capture.as_ref().unwrap();
|
||||
assert_eq!(shared.len(), 2, "LocalSound must not become a shared sound");
|
||||
assert_eq!(shared[0].event, "minecraft:entity.cow.hurt");
|
||||
assert_eq!(shared[1].event, "minecraft:entity.zombie.hurt");
|
||||
assert_eq!(
|
||||
(
|
||||
shared[0].position,
|
||||
shared[0].volume,
|
||||
shared[0].pitch,
|
||||
shared[0].source
|
||||
),
|
||||
([1.0, 100.0, 2.0], 0.5, 1.0, 6)
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
shared[1].position,
|
||||
shared[1].volume,
|
||||
shared[1].pitch,
|
||||
shared[1].source
|
||||
),
|
||||
([5.0, 100.0, 6.0], 1.0, 0.9, 5)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::perspective::CameraType;
|
||||
|
||||
@@ -408,6 +408,19 @@ pub(super) fn preflight(options: &mut Options) -> Result<bool> {
|
||||
horizon_client::command::vanilla::set_horizon_commands(true);
|
||||
}
|
||||
props.store(&settings)?;
|
||||
// `RegionFileVersion.configure`: the compression every region file this
|
||||
// server writes uses, from `region-file-compression`. An unknown name is
|
||||
// logged with the ones there are, and the default (deflate) stays.
|
||||
if !horizon_world::compression::Compression::configure(&props.region_file_compression) {
|
||||
log(
|
||||
"ERROR",
|
||||
&format!(
|
||||
"Invalid `region-file-compression` value `{}` in server.properties. Please use one of: {}",
|
||||
props.region_file_compression,
|
||||
horizon_world::compression::REGION_FILE_NAMES.join(",")
|
||||
),
|
||||
);
|
||||
}
|
||||
let eula_file = PathBuf::from("eula.txt");
|
||||
let agreed = match eula::agreed(&eula_file) {
|
||||
Ok(a) => a,
|
||||
|
||||
@@ -657,13 +657,13 @@ impl Dragon {
|
||||
if damage <= self.last_hurt {
|
||||
return Hurt::Landed { full: false };
|
||||
}
|
||||
self.health -= damage - self.last_hurt;
|
||||
self.hurt_health(damage - self.last_hurt, source);
|
||||
self.last_hurt = damage;
|
||||
false
|
||||
} else {
|
||||
self.last_hurt = damage;
|
||||
self.invulnerable = 20;
|
||||
self.health -= damage;
|
||||
self.hurt_health(damage, source);
|
||||
self.hurt_time = 10;
|
||||
true
|
||||
};
|
||||
@@ -705,6 +705,16 @@ impl Dragon {
|
||||
}
|
||||
Hurt::Landed { full }
|
||||
}
|
||||
/// `LivingEntity.actuallyHurt` for the dragon, which has no armour or
|
||||
/// absorption: a native mod's `LivingDamageEvent.Pre` may change what
|
||||
/// comes off the health, and `.Post` hears what did.
|
||||
fn hurt_health(&mut self, damage: f32, source: HurtSource<'_>) {
|
||||
use horizon_client::health::{Hurting, damage_post, damage_pre};
|
||||
let mut damage = damage;
|
||||
damage_pre(Hurting::Dragon(self.id as i32, source), &mut damage);
|
||||
self.health -= damage;
|
||||
damage_post(Hurting::Dragon(self.id as i32, source), damage);
|
||||
}
|
||||
/// `EnderDragon.knockback`: `LivingEntity.knockback` only when the phase
|
||||
/// is not sitting. `(xd, zd)` points at the hit (`dealDefaultKnockback`:
|
||||
/// the source position minus this position). Knockback resistance is the
|
||||
@@ -5137,6 +5147,69 @@ mod tests {
|
||||
assert!(matches!(d.phase, Phase::Dying { .. }));
|
||||
}
|
||||
|
||||
/// What the `LivingDamageEvent` hooks of these tests were handed: the
|
||||
/// dragon, Post (true) or Pre, and the amount.
|
||||
static TAKEN: std::sync::Mutex<Vec<(i32, bool, f32)>> = std::sync::Mutex::new(Vec::new());
|
||||
/// Sets the native `LivingDamageEvent.Pre` and `.Post` hooks for dragons
|
||||
/// 9201 and 9202 (global, as `set_hook`'s): they record what they are
|
||||
/// handed, and Pre halves 9202's amount.
|
||||
fn set_damage_hooks() {
|
||||
use horizon_client::health::{DAMAGE_POST, DAMAGE_PRE, Hurting};
|
||||
fn pre(hurting: Hurting<'_>, amount: &mut f32) {
|
||||
let Hurting::Dragon(entity @ 9201..=9202, _) = hurting else {
|
||||
return;
|
||||
};
|
||||
TAKEN.lock().unwrap().push((entity, false, *amount));
|
||||
if entity == 9202 {
|
||||
*amount /= 2.0;
|
||||
}
|
||||
}
|
||||
fn post(hurting: Hurting<'_>, amount: f32) {
|
||||
if let Hurting::Dragon(entity @ 9201..=9202, _) = hurting {
|
||||
TAKEN.lock().unwrap().push((entity, true, amount));
|
||||
}
|
||||
}
|
||||
let installed = (
|
||||
*DAMAGE_PRE.get_or_init(|| pre),
|
||||
*DAMAGE_POST.get_or_init(|| post),
|
||||
);
|
||||
assert!(std::ptr::fn_addr_eq(
|
||||
installed.0,
|
||||
pre as fn(Hurting<'_>, &mut f32)
|
||||
));
|
||||
assert!(std::ptr::fn_addr_eq(
|
||||
installed.1,
|
||||
post as fn(Hurting<'_>, f32)
|
||||
));
|
||||
}
|
||||
fn taken(entity: i32) -> Vec<(bool, f32)> {
|
||||
let taken = TAKEN.lock().unwrap();
|
||||
taken
|
||||
.iter()
|
||||
.filter(|t| t.0 == entity)
|
||||
.map(|t| (t.1, t.2))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_native_damage_hooks_see_and_change_what_a_dragons_health_takes() {
|
||||
set_damage_hooks();
|
||||
// The hit after the part's cut (20 / 4 + 1), then, within the
|
||||
// cooldown, what a stronger one exceeds it by.
|
||||
let mut d = dragon_as(9201);
|
||||
assert_eq!(d.hurt(2, 20.0, HIT, false), Hurt::Landed { full: true });
|
||||
assert_eq!(d.hurt(0, 10.0, HIT, false), Hurt::Landed { full: false });
|
||||
assert_eq!(
|
||||
taken(9201),
|
||||
[(false, 6.0), (true, 6.0), (false, 4.0), (true, 4.0)]
|
||||
);
|
||||
assert_eq!(d.health, MAX_HEALTH - 10.0);
|
||||
let mut d = dragon_as(9202);
|
||||
assert_eq!(d.hurt(0, 9.0, HIT, false), Hurt::Landed { full: true });
|
||||
assert_eq!(taken(9202), [(false, 9.0), (true, 4.5)]);
|
||||
assert_eq!(d.health, MAX_HEALTH - 4.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hit_names_the_player_behind_it() {
|
||||
let source = |kind, by, direct| {
|
||||
|
||||
@@ -94,6 +94,8 @@ impl Game<'_> {
|
||||
/// Fills `dynamic_lights` for the frame from [`Self::dynamic_light_sources`], the nearest
|
||||
/// [`dynamic_light::MAX`] of them.
|
||||
pub(super) fn gather_dynamic_lights(&mut self, camera: DVec3, alpha: f64) {
|
||||
let _span = horizon_client::spans::guard("pr_light_gather");
|
||||
let mut laps = horizon_client::spans::laps();
|
||||
let mut out = std::mem::take(&mut self.dynamic_lights);
|
||||
out.clear();
|
||||
let sources: Vec<Source> = self
|
||||
@@ -101,7 +103,9 @@ impl Game<'_> {
|
||||
.into_iter()
|
||||
.map(|(s, _)| s)
|
||||
.collect();
|
||||
laps.lap("pr_light_sources");
|
||||
dynamic_light::select(&sources, camera, &mut out);
|
||||
self.dynamic_lights = out;
|
||||
laps.lap("pr_light_select");
|
||||
}
|
||||
}
|
||||
@@ -379,6 +379,21 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `ItemFrame.setItem(stack, true)`: keeps one item, plays the add sound
|
||||
/// for a non-empty stack and updates the comparators.
|
||||
pub(super) fn set_frame_item(&mut self, i: usize, stack: Option<ItemStack>) {
|
||||
let f = &mut self.item_frames[i];
|
||||
f.item = stack.map(|s| ItemStack { count: 1, ..s });
|
||||
let (pos, filled) = (f.pos, f.item.is_some());
|
||||
if filled {
|
||||
let (name, at) = (
|
||||
sound(&self.item_frames[i], "add_item"),
|
||||
center(&self.item_frames[i]),
|
||||
);
|
||||
self.audio.play(&name, Some(at), 1.0, 1.0);
|
||||
}
|
||||
self.frame_output_changed(pos);
|
||||
}
|
||||
/// `ItemFrame.setItem` / `setRotation`: `updateNeighbourForOutputSignal`, so
|
||||
/// the comparators reading the frame take its new signal.
|
||||
fn frame_output_changed(&mut self, pos: [i32; 3]) {
|
||||
|
||||
@@ -24,6 +24,16 @@ pub(super) struct Scheduled {
|
||||
pub time: i64,
|
||||
pub seq: u64,
|
||||
}
|
||||
impl FnState {
|
||||
/// The file `path` of the highest enabled pack that has it.
|
||||
pub(super) fn pack_file(&self, path: &str) -> Option<Vec<u8>> {
|
||||
self.enabled
|
||||
.iter()
|
||||
.rev()
|
||||
.filter_map(|id| self.packs.iter().find(|p| &p.id == id))
|
||||
.find_map(|pack| pack.read(path))
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct FnState {
|
||||
pub store: DataStore,
|
||||
|
||||
@@ -383,7 +383,9 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed`.
|
||||
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed`, whose
|
||||
/// end asks the mods' `native_break_speed`. Every dig's progress comes from
|
||||
/// here: single player's, a host's for each guest, a remote client's own.
|
||||
pub(super) fn destroy_progress(&mut self, pos: [i32; 3]) -> f32 {
|
||||
let block_break_speed = self.block_break_speed();
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
@@ -414,6 +416,8 @@ impl Game<'_> {
|
||||
if self.player.as_ref().is_some_and(|p| !p.ground.on_ground) {
|
||||
speed /= 5.0;
|
||||
}
|
||||
// NeoForge's PlayerEvent.BreakSpeed, where Player.getDestroySpeed ends.
|
||||
self.native_break_speed(pos, state, speed, &mut speed);
|
||||
speed / hardness / if harvest { 30.0 } else { 100.0 }
|
||||
}
|
||||
/// A key for the main-hand stack (`destroyingItem`): its item and components.
|
||||
@@ -439,7 +443,7 @@ impl Game<'_> {
|
||||
/// `LocalPlayer.isHandsBusy`: paddling a boat.
|
||||
pub(super) fn hands_busy(&self) -> bool {
|
||||
self.riding
|
||||
.is_some_and(|id| self.boats.iter().any(|b| b.id == id))
|
||||
.is_some_and(|id| self.controls_boat(id) && self.boats.iter().any(|b| b.id == id))
|
||||
&& [
|
||||
winit::keyboard::KeyCode::KeyW,
|
||||
winit::keyboard::KeyCode::KeyS,
|
||||
|
||||
@@ -620,11 +620,24 @@ impl Game<'_> {
|
||||
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,
|
||||
);
|
||||
// `EntityRenderer.getPackedLightCoords`: an item entity is lit from
|
||||
// the block its light probe (its eye, 0.2125 up) is in, at 15 block
|
||||
// light while it burns. A pickup particle is not an entity and keeps
|
||||
// the cell it is drawn in.
|
||||
let packed = if entity {
|
||||
let probe = item.light_probe(alpha);
|
||||
item.render_light(self.world.light(
|
||||
probe[0].floor() as i32,
|
||||
probe[1].floor() as i32,
|
||||
probe[2].floor() as i32,
|
||||
))
|
||||
} else {
|
||||
vertex_light(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.pack.item_renderer.item_id(&item.stack.item))
|
||||
+ i64::from(item.stack.damage);
|
||||
let copies = item_render::cluster_poses(
|
||||
@@ -664,11 +677,11 @@ impl Game<'_> {
|
||||
let outline = self.thing_outline(item.id, "minecraft:item");
|
||||
// Posed on the GPU when every layer can be, its silhouette too when it glows.
|
||||
let skinned = skinning
|
||||
.then(|| self.submit_item_skinned(&state, &copies, vertex_light(packed), lights))
|
||||
.then(|| self.submit_item_skinned(&state, &copies, packed, lights))
|
||||
.flatten();
|
||||
if skinned.is_none() {
|
||||
for © in &copies {
|
||||
self.submit_item_world(&state, copy, vertex_light(packed), out);
|
||||
self.submit_item_world(&state, copy, packed, out);
|
||||
}
|
||||
}
|
||||
if let Some(rgb) = outline {
|
||||
|
||||
@@ -32,6 +32,9 @@ impl Game<'_> {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
self.use_duration = duration;
|
||||
self.use_ticks_set = ticks_set;
|
||||
// `LivingEntity.startUsingItem`: `causeUseVibration(ITEM_INTERACT_START)`
|
||||
// when the use effects of the stack ask for interaction vibrations.
|
||||
self.use_vibration(&stack, "minecraft:item_interact_start");
|
||||
// A new charge hears the crossbow's loading sounds again
|
||||
// (`game/crossbow.rs`).
|
||||
self.crossbow_sounds = [false; 2];
|
||||
@@ -100,7 +103,7 @@ impl Game<'_> {
|
||||
// a potion is drunk (no last tick of its own: a mod's tick ran it out).
|
||||
if self.finish_drinking(slot, stack) {
|
||||
self.use_item_finished(stack);
|
||||
self.use_vibration(stack);
|
||||
self.use_vibration(stack, "minecraft:item_interact_finish");
|
||||
return;
|
||||
}
|
||||
self.using = None;
|
||||
@@ -113,7 +116,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.use_item_finished(stack);
|
||||
// `LivingEntity.completeUsingItem` ends with `stopUsingItem`.
|
||||
self.use_vibration(stack);
|
||||
self.use_vibration(stack, "minecraft:item_interact_finish");
|
||||
}
|
||||
/// `LivingEntityUseItemEvent.Finish` after `finishUsingItem` of `stack`
|
||||
/// (no ticks left): the hand holds what a mod made the result.
|
||||
@@ -449,3 +452,87 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod interact_vibration_tests {
|
||||
use super::*;
|
||||
|
||||
/// Runs `f` on a windowless game built from the prepared resources with
|
||||
/// `held` in the main hand and returns the game events it posted;
|
||||
/// `None` without the resources.
|
||||
fn with_game(held: &str, f: impl FnOnce(&mut Game<'_>)) -> Option<Vec<String>> {
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES")?);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.ok()?;
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(
|
||||
game.motion,
|
||||
horizon_core::geometry::Vec3::new(10.5, 64.0, -3.25),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
*game.held_mut() = Some(ItemStack::new(held, 1));
|
||||
crate::game::dispense_fixture::start_recording();
|
||||
f(&mut game);
|
||||
Some(crate::game::dispense_fixture::stop_recording())
|
||||
}
|
||||
|
||||
/// `LivingEntity.startUsingItem` ends with
|
||||
/// `this.useItem.causeUseVibration(this, GameEvent.ITEM_INTERACT_START)`,
|
||||
/// and `ItemStack.causeUseVibration` posts only when the stack's use effects
|
||||
/// ask for interaction vibrations (the default). `stopUsingItem` posts the
|
||||
/// finish the same way, which Horizon already did, so a use is a pair of
|
||||
/// vibrations and an item that turns them off is neither.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_use_start_is_an_item_interact_start_vibration() {
|
||||
let bow = || ItemStack::new("minecraft:bow", 1);
|
||||
let Some(lines) = with_game("minecraft:bow", |game| {
|
||||
assert!(game.start_using_item(), "the bow's use starts");
|
||||
game.stop_using_item(&bow());
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
"G 10,64,-4 minecraft:item_interact_start".to_owned(),
|
||||
"G 10,64,-4 minecraft:item_interact_finish".to_owned(),
|
||||
]
|
||||
);
|
||||
// A spear's use effects set `interact_vibrations` false: no vibration.
|
||||
let spear = || ItemStack::new("minecraft:iron_spear", 1);
|
||||
let Some(lines) = with_game("minecraft:iron_spear", |game| {
|
||||
assert!(game.start_using_item(), "the spear's use starts");
|
||||
game.stop_using_item(&spear());
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
assert!(lines.is_empty(), "{lines:?}");
|
||||
}
|
||||
}
|
||||
@@ -325,6 +325,11 @@ impl Game<'_> {
|
||||
self.start_song(pos, &song);
|
||||
}
|
||||
if ticks % 20 == 0 {
|
||||
// `JukeboxSongPlayer.tick`: `gameEvent(JUKEBOX_PLAY, pos,
|
||||
// Context.of(state))` every twenty ticks while a song plays, for
|
||||
// the sculk listeners and the allays around it.
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.post_block_game_event("minecraft:jukebox_play", pos, None, Some(state));
|
||||
let color = f64::from(self.world.random.next_int_bound(4).unwrap_or(0)) / 24.0;
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
@@ -431,3 +436,100 @@ mod tests {
|
||||
assert!(!keeps_song(true, false, false));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod jukebox_play_tests {
|
||||
use super::*;
|
||||
|
||||
/// A windowless game from the prepared resources with a jukebox at
|
||||
/// (0, 64, 0), a disc in hand and its song started, ticked `ticks` times:
|
||||
/// the game events it posted, in order. `None` without the resources.
|
||||
fn playing(ticks: i32) -> Option<Vec<String>> {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES")?);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.ok()?;
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
// `World::set` writes into a resident chunk only.
|
||||
game.world.chunks.insert(
|
||||
(0, 0),
|
||||
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
|
||||
);
|
||||
let jukebox = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:jukebox")?;
|
||||
game.world.set(0, 64, 0, jukebox).unwrap();
|
||||
let disc = ItemStack::new("minecraft:music_disc_cat", 1);
|
||||
crate::game::dispense_fixture::start_recording();
|
||||
assert!(
|
||||
game.use_jukebox([0, 64, 0], Some(&disc)),
|
||||
"the disc goes in"
|
||||
);
|
||||
for _ in 0..ticks {
|
||||
game.tick_jukebox((0, 64, 0));
|
||||
}
|
||||
Some(crate::game::dispense_fixture::stop_recording())
|
||||
}
|
||||
|
||||
/// `JukeboxSongPlayer.tick` posts `JUKEBOX_PLAY` at the jukebox every
|
||||
/// `PLAY_EVENT_INTERVAL_TICKS` (20) ticks of a song, next to the note
|
||||
/// particle it already drew. Horizon drew the particle and kept the
|
||||
/// listeners deaf: a sculk sensor beside a playing jukebox never clicked.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_playing_jukebox_posts_jukebox_play_every_twenty_ticks() {
|
||||
let Some(lines) = playing(41) else { return };
|
||||
let plays: Vec<&String> = lines
|
||||
.iter()
|
||||
.filter(|l| l.ends_with("minecraft:jukebox_play"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
plays,
|
||||
vec![
|
||||
&"G 0,64,0 minecraft:jukebox_play".to_owned(),
|
||||
&"G 0,64,0 minecraft:jukebox_play".to_owned(),
|
||||
&"G 0,64,0 minecraft:jukebox_play".to_owned(),
|
||||
],
|
||||
"{lines:?}"
|
||||
);
|
||||
// The song is still playing after 41 ticks.
|
||||
assert!(
|
||||
lines.iter().all(|l| l.ends_with("minecraft:jukebox_play")),
|
||||
"{lines:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A jukebox whose song is stopped posts no play event: only the twenty
|
||||
/// ticks of a playing song do.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_stopped_jukebox_posts_no_play_event() {
|
||||
let Some(lines) = playing(0) else { return };
|
||||
assert!(lines.is_empty(), "{lines:?}");
|
||||
}
|
||||
}
|
||||
@@ -1278,6 +1278,17 @@ impl Game<'_> {
|
||||
if pos.1 == super::minecarts::CART_KEY_Y {
|
||||
return self.lan_open_vehicle_container(host, conn, pos);
|
||||
}
|
||||
// `RandomizableContainer.unpackLootTable`: a guest opening a block
|
||||
// container rolls the table it still holds, as the host's own open does
|
||||
// (`unpack_opened_loot` no-ops when there is no roll), and the stat
|
||||
// fires for the acting guest.
|
||||
let opened = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(pos.0, pos.1, pos.2))
|
||||
.name
|
||||
.to_string();
|
||||
self.unpack_opened_loot(pos, &opened);
|
||||
let name = &self
|
||||
.res
|
||||
.facts()
|
||||
@@ -1499,6 +1510,17 @@ impl Game<'_> {
|
||||
// loot table is still to roll, and the table stays.
|
||||
let spectator = host.hub.guest(conn).is_some_and(|g| g.spectator());
|
||||
if spectator && self.boat_loot_pending(self.boats[i].id) {
|
||||
// ServerPlayer.openMenu: the refused spectator's action bar, red.
|
||||
let message =
|
||||
horizon_client::text::Text::translate("container.spectatorCantOpen", &[])
|
||||
.with_color(horizon_client::text::RED);
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: horizon_client::lan::data::component_nbt(&message.component()),
|
||||
overlay: true,
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
self.unpack_boat_loot(i);
|
||||
@@ -3688,12 +3710,10 @@ impl Game<'_> {
|
||||
let world = self
|
||||
.level
|
||||
.world_spawn
|
||||
.map(|[x, y, z]| [f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5]);
|
||||
let spawn = host.hub.guests[i]
|
||||
.spawn
|
||||
.or(world)
|
||||
.map(|[x, y, z]| [f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5])
|
||||
.or(self.spawn.map(|s| [s.x, s.y, s.z]))
|
||||
.unwrap_or([0.5, 80.0, 0.5]);
|
||||
let spawn = host.hub.guests[i].spawn.unwrap_or(world);
|
||||
if !host.hub.guests[i].dead {
|
||||
return;
|
||||
}
|
||||
@@ -3788,7 +3808,9 @@ impl Game<'_> {
|
||||
world: &mut self.world,
|
||||
entities: &[],
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
// PlayerList.respawn / sendLevelInfo send level respawn data,
|
||||
// independently of the personal destination passed to Hub::respawn.
|
||||
spawn: world,
|
||||
difficulty: i32::from(self.ui.difficulty),
|
||||
difficulty_locked: self.level.difficulty_locked,
|
||||
clock_rate,
|
||||
@@ -4726,8 +4748,10 @@ impl Game<'_> {
|
||||
let mut events = std::mem::take(&mut w.player.events);
|
||||
// LayeredCauldronBlock.handleEntityOnFireInside along those moves.
|
||||
for event in &events {
|
||||
if let MotionEvent::CauldronExtinguish { pos } = event {
|
||||
game.cauldron_extinguish(*pos);
|
||||
match event {
|
||||
MotionEvent::CauldronExtinguish { pos } => game.cauldron_extinguish(*pos),
|
||||
MotionEvent::PowderSnowMelt { pos } => game.powder_snow_melt(*pos),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Entity.baseTick: rain puts out a burning player.
|
||||
@@ -4881,7 +4905,10 @@ impl Game<'_> {
|
||||
host.hub.entity_event(id, event as i8, at);
|
||||
}
|
||||
}
|
||||
MotionEvent::Fall(event) => self.landing_block_updates(&event),
|
||||
MotionEvent::Fall(event) => {
|
||||
let player = host.hub.guest(conn).map(|g| g.uuid);
|
||||
self.landing_block_updates(player, &event)
|
||||
}
|
||||
// PowderSnowBlock.entityInside: every player around sees the
|
||||
// snowflake (`ServerLevel.sendParticles`).
|
||||
MotionEvent::Snowflake { position, velocity } => {
|
||||
@@ -4900,6 +4927,11 @@ impl Game<'_> {
|
||||
}
|
||||
self.drown_particles(at, [0.0; 3]);
|
||||
}
|
||||
// ServerPlayer.onInsideBlock: the enter_block trigger, for the
|
||||
// player who moved.
|
||||
MotionEvent::EnterBlock { state } => {
|
||||
self.as_guest(host, conn, |game| game.trigger_enter_block(state));
|
||||
}
|
||||
// HoneyBlock.maybeDoSlideEffects: the sound reaches the
|
||||
// others (Player.playSound excludes the player, whose client
|
||||
// plays it), the entity event 53 everyone around.
|
||||
@@ -6010,3 +6042,149 @@ mod spin_target_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod respawn_world_point_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::packets::{configuration::ClientInformation, play::Clientbound};
|
||||
use std::sync::{Arc, atomic::AtomicI32, mpsc};
|
||||
|
||||
/// PlayerList.respawn sends world respawn data, independently of the personal landing.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn guest_respawn_keeps_world_information_distinct_from_personal_landing() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let scratch = std::env::temp_dir().join(format!(
|
||||
"horizon-respawn-point-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&scratch).unwrap();
|
||||
game.world_root = scratch.clone();
|
||||
let world = [0, 100, 0];
|
||||
let personal = [5.5, 101.0, 5.5];
|
||||
game.level.world_spawn = Some(world);
|
||||
game.spawn = Some(Vec3::new(0.5, 100.0, 0.5));
|
||||
game.lan_host(
|
||||
super::super::lan::LanSettings {
|
||||
port: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
},
|
||||
NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::loopback(0, "respawn-test".into(), 2)
|
||||
},
|
||||
HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 2,
|
||||
simulation_distance: 2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 2,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut guest = Guest::new(
|
||||
Joined {
|
||||
conn: 2,
|
||||
uuid: 330,
|
||||
name: "RespawnGuest".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(2),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
},
|
||||
2,
|
||||
[0.5, 100.0, 0.5],
|
||||
2,
|
||||
);
|
||||
guest.dimension = "minecraft:overworld".into();
|
||||
guest.placed = true;
|
||||
guest.loaded = true;
|
||||
guest.spawn = Some(personal);
|
||||
guest.health = 0.0;
|
||||
guest.dead = true;
|
||||
let mut host = game.lan.take().unwrap();
|
||||
host.hub.guests.push(guest);
|
||||
game.guest_respawn(&mut host, 2);
|
||||
let guest = &host.hub.guests[0];
|
||||
assert_eq!(guest.uuid, 330);
|
||||
assert_eq!(guest.position, personal);
|
||||
assert_eq!(guest.health, 20.0);
|
||||
assert!(!guest.dead);
|
||||
let mut packets = Vec::new();
|
||||
for out in rx.try_iter() {
|
||||
match out {
|
||||
Out::Packet(body) => packets.push(body),
|
||||
Out::Packets(bodies) => packets.extend(bodies),
|
||||
Out::Disconnect(_) => panic!("unexpected disconnect"),
|
||||
}
|
||||
}
|
||||
let mut sequence = Vec::new();
|
||||
let mut points = Vec::new();
|
||||
for body in packets {
|
||||
let mut r = horizon_protocol::buf::Reader::new(&body);
|
||||
let id = r.varint().unwrap();
|
||||
match Clientbound::decode(id, r.rest()).unwrap() {
|
||||
Clientbound::Respawn(_) => sequence.push(84),
|
||||
Clientbound::SetDefaultSpawnPosition(p) => {
|
||||
sequence.push(99);
|
||||
points.push((
|
||||
p.pos.dimension,
|
||||
p.pos.pos,
|
||||
p.yaw.to_bits(),
|
||||
p.pitch.to_bits(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(sequence, [84, 99, 99]);
|
||||
assert_eq!(points, vec![("minecraft:overworld".into(), world, 0, 0); 2]);
|
||||
drop(host);
|
||||
drop(game);
|
||||
std::fs::remove_dir_all(scratch).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -492,9 +492,9 @@ impl Game<'_> {
|
||||
}
|
||||
gs.last_sound = self.ticks;
|
||||
let at = gs.pos.map(|v| f64::from(v) + 0.5);
|
||||
for sound in sounds {
|
||||
self.audio.play(sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
// The menu's `lastSoundTime` lets one sound through per tick, however
|
||||
// many results a shift-click took.
|
||||
self.audio.play(sounds[0], Some(at), 1.0, 1.0);
|
||||
}
|
||||
|
||||
/// A click on the guest's station window; false for other windows.
|
||||
|
||||
@@ -1275,6 +1275,8 @@ impl Game<'_> {
|
||||
/// `MinecartHopper.tick`'s `tryConsumeItems` (`suckInItems`) for an enabled
|
||||
/// hopper minecart: one item from the container above, else the item
|
||||
/// entities over it or touching it, each tick without a cooldown.
|
||||
/// `consumedItemThisFrame` in 26.3 caps a tick at one item: the tick tries
|
||||
/// once, and `makeStepAlongTrack` may try again, but only one succeeds.
|
||||
pub(super) fn hopper_cart_suck(&mut self, cart: &mut super::minecarts::Minecart) {
|
||||
// The cart's slots are edited as a container under the placeholder key
|
||||
// its menu uses; when that menu is open the placeholder is the live copy.
|
||||
@@ -1702,3 +1704,80 @@ mod mod_container_tests {
|
||||
assert_eq!(view.slots_for_face(None), [0, 1, 2]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hopper_cart_transfer_tests {
|
||||
use super::super::{
|
||||
minecarts::{CartKind, Minecart},
|
||||
native_machines::tests::server_game,
|
||||
};
|
||||
use super::Game;
|
||||
use horizon_client::{containers::BlockEntity, items::ItemStack};
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
/// Issue #8668: a hopper minecart takes one item a tick from the container
|
||||
/// above. `MinecartHopper.tick` has no transfer cooldown in 26.3: it calls
|
||||
/// `tryConsumeItems` every tick, and `consumedItemThisFrame` caps a tick at
|
||||
/// one item even though `makeStepAlongTrack` may try too. A whole stack a
|
||||
/// tick (the old behaviour) or a block hopper's eight-tick cooldown both
|
||||
/// fail this.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn hopper_cart_takes_one_item_a_tick() {
|
||||
let mut game = server_game();
|
||||
let rail = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:rail")
|
||||
.unwrap();
|
||||
let chest = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:chest")
|
||||
.unwrap();
|
||||
// A rail holds the cart still; the chest sits in the block the cart
|
||||
// pulls from (`getLevelY` is y + 0.5, so the block above is y + 1.5).
|
||||
game.world.set(8, 64, 8, rail).unwrap();
|
||||
game.world.set(8, 65, 8, chest).unwrap();
|
||||
game.world.block_entities.insert(
|
||||
(8, 65, 8),
|
||||
BlockEntity::Chest(vec![Some(ItemStack::new("minecraft:stone", 64)); 27]),
|
||||
);
|
||||
game.next_entity += 1;
|
||||
let id = game.next_entity;
|
||||
let mut cart = Minecart::new(id, CartKind::Hopper, Vec3::new(8.5, 64.0625, 8.5));
|
||||
cart.enabled = true;
|
||||
game.carts.push(cart);
|
||||
let count = |game: &Game<'_>| {
|
||||
let in_chest: u32 = match game.world.block_entities.get(&(8, 65, 8)) {
|
||||
Some(BlockEntity::Chest(s)) => s.iter().flatten().map(|s| s.count).sum(),
|
||||
_ => 0,
|
||||
};
|
||||
let in_cart: u32 = game
|
||||
.carts
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.unwrap()
|
||||
.items
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|s| s.count)
|
||||
.sum();
|
||||
(in_chest, in_cart)
|
||||
};
|
||||
let (before_chest, before_cart) = count(&game);
|
||||
for _ in 0..20 {
|
||||
game.tick_carts();
|
||||
}
|
||||
let (after_chest, after_cart) = count(&game);
|
||||
assert_eq!(before_chest, 27 * 64);
|
||||
assert_eq!(before_cart, 0);
|
||||
assert_eq!(before_chest - after_chest, after_cart - before_cart);
|
||||
assert_eq!(
|
||||
after_cart, 20,
|
||||
"one item a tick, not a stack nor eight ticks"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -674,7 +674,8 @@ mod tests {
|
||||
Mob::new(2, ghoul.clone(), at, 0.0),
|
||||
);
|
||||
assert_eq!(ghoul.health, 40.0);
|
||||
let saved = |m: &Mob| horizon_client::entity_nbt::mob_to_nbt(m, [0, 0, 0, 2], None, None);
|
||||
let saved =
|
||||
|m: &Mob| horizon_client::entity_nbt::mob_to_nbt(m, [0, 0, 0, 2], None, None, |_| None);
|
||||
let keys = |tag: &horizon_world::nbt::Tag| -> Vec<String> {
|
||||
let horizon_world::nbt::Tag::Compound(map) = tag else {
|
||||
panic!("a mob saves a compound");
|
||||
@@ -725,7 +726,8 @@ mod tests {
|
||||
let trader = Mob::new(1, types["minecraft:wandering_trader"].clone(), at, 0.0);
|
||||
let merchant = Mob::new(2, merchant.clone(), at, 0.0);
|
||||
assert!(merchant.ai.goals.has("Float") && trader.ai.goals.has("Float"));
|
||||
let saved = |m: &Mob| horizon_client::entity_nbt::mob_to_nbt(m, [0, 0, 0, 2], None, None);
|
||||
let saved =
|
||||
|m: &Mob| horizon_client::entity_nbt::mob_to_nbt(m, [0, 0, 0, 2], None, None, |_| None);
|
||||
let keys = |tag: &Tag| -> Vec<String> {
|
||||
let Tag::Compound(map) = tag else {
|
||||
panic!("a mob saves a compound");
|
||||
|
||||
@@ -8,13 +8,112 @@
|
||||
//! and the player's own in third person with the `ownNameTag` extra.
|
||||
use super::{Game, view_vector};
|
||||
use horizon_client::{
|
||||
block_entity_render::vertex_light, hud::HudBatch, mesher::Vertex, mobs::Mob, mod_api,
|
||||
save::uuid_text, scoreboard as sb, text::Text,
|
||||
block_entity_render::vertex_light,
|
||||
hud::{HudAtlas, HudBatch, HudVertex},
|
||||
mesher::Vertex,
|
||||
mobs::Mob,
|
||||
mod_api,
|
||||
save::uuid_text,
|
||||
scoreboard as sb,
|
||||
text::Text,
|
||||
};
|
||||
|
||||
/// The world size of one text pixel in a name tag: `Matrix4f.scale(0.025F, -0.025F, 0.025F)`.
|
||||
pub(super) const NAME_TAG_UNIT: f32 = 0.025;
|
||||
|
||||
/// Local glyph quads only: camera transforms, lighting and cosmetic marks stay per frame.
|
||||
/// Slots follow submission order; comparing the whole styled text also handles entity reorder,
|
||||
/// team/score changes and native name hooks without keeping entity IDs alive.
|
||||
#[derive(Default)]
|
||||
pub(super) struct NameTagLayouts {
|
||||
epoch: (u64, u64, bool),
|
||||
next: usize,
|
||||
lines: Vec<Option<NameTagLine>>,
|
||||
}
|
||||
|
||||
struct NameTagLine {
|
||||
text: Text,
|
||||
alpha: u32,
|
||||
background: bool,
|
||||
y: f32,
|
||||
behind: usize,
|
||||
vertices: Vec<HudVertex>,
|
||||
}
|
||||
|
||||
impl NameTagLayouts {
|
||||
fn begin_frame(&mut self, atlas: &HudAtlas) {
|
||||
let epoch = (
|
||||
atlas.revision,
|
||||
atlas.font.epoch(),
|
||||
horizon_client::lang::bidirectional(),
|
||||
);
|
||||
if self.epoch != epoch {
|
||||
self.lines.clear();
|
||||
self.epoch = epoch;
|
||||
}
|
||||
self.next = 0;
|
||||
}
|
||||
|
||||
fn end_frame(&mut self) {
|
||||
self.lines.truncate(self.next);
|
||||
}
|
||||
|
||||
fn name_tag(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
text: &Text,
|
||||
y: f32,
|
||||
alpha: u32,
|
||||
background: bool,
|
||||
) -> usize {
|
||||
// At most 512 * 512 HUD vertices (~10 MiB), plus short text keys. Longer tags and
|
||||
// excess lines still render normally. Animated/random glyphs and live inline skins
|
||||
// must go through the font each time, including legacy formatting supplied by hooks.
|
||||
const MAX_LINES: usize = 512;
|
||||
const MAX_VERTICES: usize = 512;
|
||||
let slot = self.next;
|
||||
self.next += 1;
|
||||
let stable =
|
||||
text.resolved().spans.iter().all(|span| {
|
||||
span.style.obfuscated != Some(true) && span.style.inline_object.is_none()
|
||||
}) && text.spans.iter().map(|s| s.text.len()).sum::<usize>() <= 1024;
|
||||
if slot < MAX_LINES
|
||||
&& stable
|
||||
&& let Some(Some(line)) = self.lines.get(slot)
|
||||
&& line.text == *text
|
||||
&& line.alpha == alpha
|
||||
&& line.background == background
|
||||
&& line.y == y
|
||||
{
|
||||
batch.vertices.extend_from_slice(&line.vertices);
|
||||
return line.behind;
|
||||
}
|
||||
let behind = batch.name_tag(
|
||||
atlas,
|
||||
text,
|
||||
y,
|
||||
alpha << 24 | 0xFF_FFFF,
|
||||
if background { 0x3F00_0000 } else { 0 },
|
||||
);
|
||||
if slot < MAX_LINES {
|
||||
if self.lines.len() <= slot {
|
||||
self.lines.resize_with(slot + 1, || None);
|
||||
}
|
||||
self.lines[slot] =
|
||||
(stable && batch.vertices.len() <= MAX_VERTICES).then(|| NameTagLine {
|
||||
text: text.clone(),
|
||||
alpha,
|
||||
background,
|
||||
y,
|
||||
behind,
|
||||
vertices: batch.vertices.clone(),
|
||||
});
|
||||
}
|
||||
behind
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A named name tag names the mob under the crosshair.
|
||||
pub(super) fn name_mob(&mut self, index: usize) -> bool {
|
||||
@@ -469,6 +568,8 @@ impl Game<'_> {
|
||||
// max((0.25 + 0.75) / 2, 0.5), as ARGB.color (the floor of 255 times it).
|
||||
const FADED: u32 = 0x7F;
|
||||
let mut patches = Vec::new();
|
||||
let mut layouts = std::mem::take(&mut self.name_tag_layouts);
|
||||
layouts.begin_frame(&self.gui.atlas);
|
||||
for tag in self.name_tags(camera, alpha) {
|
||||
let o = tag.at - camera;
|
||||
// `gbuffers_entities` draws name tag text (2.3); a sneaking player's faded tag, on its
|
||||
@@ -486,6 +587,7 @@ impl Game<'_> {
|
||||
NAME_TAG_UNIT,
|
||||
out,
|
||||
tag.marks,
|
||||
Some(&mut layouts),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
@@ -503,6 +605,7 @@ impl Game<'_> {
|
||||
NAME_TAG_UNIT,
|
||||
out,
|
||||
tag.marks,
|
||||
Some(&mut layouts),
|
||||
));
|
||||
// RenderPipelines.TEXT_SEE_THROUGH: no depth test, no lightmap.
|
||||
patches.extend(self.styled_name_tag_quads(
|
||||
@@ -515,8 +618,11 @@ impl Game<'_> {
|
||||
NAME_TAG_UNIT,
|
||||
see_through,
|
||||
tag.marks,
|
||||
Some(&mut layouts),
|
||||
));
|
||||
}
|
||||
layouts.end_frame();
|
||||
self.name_tag_layouts = layouts;
|
||||
self.map_patches.extend(patches);
|
||||
}
|
||||
/// The lines of one name tag, `o` from the camera: the text at
|
||||
@@ -545,6 +651,7 @@ impl Game<'_> {
|
||||
unit,
|
||||
out,
|
||||
Marks::default(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -563,6 +670,7 @@ impl Game<'_> {
|
||||
unit: f32,
|
||||
out: &mut Vec<Vertex>,
|
||||
marks: Marks,
|
||||
mut layouts: Option<&mut NameTagLayouts>,
|
||||
) -> Vec<horizon_client::font::Patch> {
|
||||
// Options.getBackgroundOpacity(0.25) black, as ARGB.color.
|
||||
const BACKGROUND: u32 = 0x3F00_0000;
|
||||
@@ -595,13 +703,23 @@ impl Game<'_> {
|
||||
batch.vertices.clear();
|
||||
// Font.prepareText, as any text: bold, italic, fonts and each
|
||||
// glyph's own box (accented.png's are 12 pixels tall).
|
||||
let behind = batch.name_tag(
|
||||
&self.gui.atlas,
|
||||
text,
|
||||
y0,
|
||||
text_alpha << 24 | 0xFF_FFFF,
|
||||
if background { BACKGROUND } else { 0 },
|
||||
);
|
||||
let behind = match layouts.as_deref_mut() {
|
||||
Some(layouts) => layouts.name_tag(
|
||||
&mut batch,
|
||||
&self.gui.atlas,
|
||||
text,
|
||||
y0,
|
||||
text_alpha,
|
||||
background,
|
||||
),
|
||||
None => batch.name_tag(
|
||||
&self.gui.atlas,
|
||||
text,
|
||||
y0,
|
||||
text_alpha << 24 | 0xFF_FFFF,
|
||||
if background { BACKGROUND } else { 0 },
|
||||
),
|
||||
};
|
||||
if n == last && marks.horizon {
|
||||
let x = -self.gui.atlas.font.width_text(text) / 2.0 - 10.0;
|
||||
// 8 text pixels square, a pixel left of the background.
|
||||
@@ -777,6 +895,135 @@ mod tests {
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn tag_font() -> HudAtlas {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-tag-cache-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
));
|
||||
let textures = root.join("assets/minecraft/textures/font");
|
||||
std::fs::create_dir_all(root.join("assets/minecraft/font")).unwrap();
|
||||
std::fs::create_dir_all(&textures).unwrap();
|
||||
std::fs::write(root.join("assets/minecraft/font/default.json"),
|
||||
r#"{"providers":[{"type":"bitmap","file":"minecraft:font/cache.png","ascent":7,"chars":["ab"]}]}"#).unwrap();
|
||||
let file = std::fs::File::create(textures.join("cache.png")).unwrap();
|
||||
let mut encoder = png::Encoder::new(file, 16, 8);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
let pixels: Vec<u8> = (0..128)
|
||||
.flat_map(|i| [255, 255, 255, if i % 8 < 4 { 255 } else { 0 }])
|
||||
.collect();
|
||||
writer.write_image_data(&pixels).unwrap();
|
||||
drop(writer);
|
||||
let mut atlas = HudAtlas::boot();
|
||||
atlas.font = horizon_client::font::Font::load(
|
||||
&root,
|
||||
&Default::default(),
|
||||
&[],
|
||||
(1024, 1024),
|
||||
(512, 512),
|
||||
atlas.sprites["horizon/white"],
|
||||
);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
atlas
|
||||
}
|
||||
|
||||
fn tag_bytes(
|
||||
cache: &mut NameTagLayouts,
|
||||
atlas: &HudAtlas,
|
||||
text: &Text,
|
||||
alpha: u32,
|
||||
background: bool,
|
||||
) -> Vec<u8> {
|
||||
cache.begin_frame(atlas);
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
let behind = cache.name_tag(&mut batch, atlas, text, 0.0, alpha, background);
|
||||
cache.end_frame();
|
||||
let mut fresh = HudBatch::new([1, 1]);
|
||||
assert_eq!(
|
||||
behind,
|
||||
fresh.name_tag(
|
||||
atlas,
|
||||
text,
|
||||
0.0,
|
||||
alpha << 24 | 0xFF_FFFF,
|
||||
if background { 0x3F00_0000 } else { 0 }
|
||||
)
|
||||
);
|
||||
let bytes: &[u8] = bytemuck::cast_slice(&batch.vertices);
|
||||
assert_eq!(bytes, bytemuck::cast_slice::<_, u8>(&fresh.vertices));
|
||||
bytes.to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_tag_quads_follow_text_style_pass_and_font_changes() {
|
||||
let mut atlas = tag_font();
|
||||
let mut cache = NameTagLayouts::default();
|
||||
let a = Text::literal("a");
|
||||
let first = tag_bytes(&mut cache, &atlas, &a, 255, false);
|
||||
assert!(!first.is_empty());
|
||||
assert_eq!(tag_bytes(&mut cache, &atlas, &a, 255, false), first);
|
||||
for text in [
|
||||
Text::literal("b"),
|
||||
a.clone().bold(),
|
||||
Text::colored("a", 0x12AB34),
|
||||
] {
|
||||
assert_ne!(tag_bytes(&mut cache, &atlas, &text, 255, false), first);
|
||||
tag_bytes(&mut cache, &atlas, &text, 255, false);
|
||||
}
|
||||
assert_ne!(tag_bytes(&mut cache, &atlas, &a, 127, true), first);
|
||||
tag_bytes(&mut cache, &atlas, &a, 127, true);
|
||||
tag_bytes(&mut cache, &atlas, &a, 255, false);
|
||||
// Reset and occupy the first glyph slot with b: a's old UV is now wrong.
|
||||
atlas.font.reset_cache();
|
||||
atlas.font.glyph('b');
|
||||
assert_ne!(tag_bytes(&mut cache, &atlas, &a, 255, false), first);
|
||||
// Font options may change without replacing the atlas.
|
||||
atlas.font.uniform = true;
|
||||
tag_bytes(&mut cache, &atlas, &a, 255, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obfuscated_tag_glyphs_keep_animating_after_cache_warmup() {
|
||||
let atlas = tag_font();
|
||||
let mut cache = NameTagLayouts::default();
|
||||
let text = Text::legacy("§kaaaaaaaaaaaaaaaa");
|
||||
let mut images = std::collections::HashSet::new();
|
||||
for seed in 0..8 {
|
||||
cache.begin_frame(&atlas);
|
||||
atlas.font.reseed(seed);
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
cache.name_tag(&mut batch, &atlas, &text, 0.0, 255, false);
|
||||
cache.end_frame();
|
||||
let bytes = bytemuck::cast_slice::<_, u8>(&batch.vertices).to_vec();
|
||||
atlas.font.reseed(seed);
|
||||
let mut fresh = HudBatch::new([1, 1]);
|
||||
fresh.name_tag(&atlas, &text, 0.0, u32::MAX, 0);
|
||||
assert_eq!(bytes, bytemuck::cast_slice::<_, u8>(&fresh.vertices));
|
||||
images.insert(bytes);
|
||||
}
|
||||
assert!(images.len() > 1, "the fixture must actually animate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_tag_objects_follow_live_sprite_updates() {
|
||||
let mut atlas = HudAtlas::boot();
|
||||
let mut sprite = atlas.sprites["horizon/white"];
|
||||
atlas.sprites.insert("item/probe".into(), sprite);
|
||||
let text = horizon_client::component::to_text(
|
||||
&serde_json::json!({"object":"atlas", "sprite":"minecraft:item/probe"}),
|
||||
&mut |_| Text::empty(),
|
||||
);
|
||||
let mut cache = NameTagLayouts::default();
|
||||
let first = tag_bytes(&mut cache, &atlas, &text, 255, false);
|
||||
assert!(!first.is_empty());
|
||||
sprite.u0 += 0.1;
|
||||
atlas.sprites.insert("item/probe".into(), sprite);
|
||||
assert_ne!(tag_bytes(&mut cache, &atlas, &text, 255, false), first);
|
||||
}
|
||||
|
||||
/// The player model `perspective::player_type` builds from the prepared
|
||||
/// resources, without them: its box only needs the id and the pointer.
|
||||
fn player_mob() -> Mob {
|
||||
|
||||
@@ -4,9 +4,11 @@ use horizon_client::{
|
||||
collisions::SceneCollisions,
|
||||
hud::{HudBatch, rgba},
|
||||
hud_layout::GuiDraw,
|
||||
items::ItemStack,
|
||||
mod_api::Text,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// This window's state; unavailable on a server or before a window is attached.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -291,4 +293,40 @@ impl Game<'_> {
|
||||
self.titles.set_overlay(text, animate);
|
||||
}
|
||||
}
|
||||
/// Draw an item stack with its default decorations at `at` in GUI units.
|
||||
pub(super) fn native_gui_draw_item(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
stack: &ItemStack,
|
||||
at: [f32; 2],
|
||||
) {
|
||||
self.draw_item(batch, stack, at[0], at[1]);
|
||||
}
|
||||
/// Draw an item stack with custom count/decoration text (or None) at `at` in GUI units.
|
||||
pub(super) fn native_gui_draw_item_with(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
stack: &ItemStack,
|
||||
at: [f32; 2],
|
||||
text: Option<&str>,
|
||||
) {
|
||||
self.draw_item_model(batch, stack, at[0], at[1]);
|
||||
self.draw_item_decorations(batch, stack, at[0], at[1], text.map(str::to_owned));
|
||||
}
|
||||
/// Whether Shift is pressed (live modifier state).
|
||||
pub(super) fn native_has_shift_down(&self) -> bool {
|
||||
self.has_shift_down()
|
||||
}
|
||||
/// Whether Control is pressed (live modifier state).
|
||||
pub(super) fn native_has_control_down(&self) -> bool {
|
||||
self.has_control_down()
|
||||
}
|
||||
/// Whether Alt is pressed (live modifier state).
|
||||
pub(super) fn native_has_alt_down(&self) -> bool {
|
||||
self.has_alt_down()
|
||||
}
|
||||
/// Whether a specific key is currently held down.
|
||||
pub(super) fn native_is_key_down(&self, key: KeyCode) -> bool {
|
||||
self.physical_keys.contains(&key)
|
||||
}
|
||||
}
|
||||
@@ -2194,9 +2194,25 @@ impl Game<'_> {
|
||||
mod_api::dispatch_client_start(self, mod_api::mods());
|
||||
}
|
||||
/// Before an actual connection is discarded, not on a dimension change.
|
||||
/// NeoForge's `ClientPlayerNetworkEvent.LoggingOut`, told here after
|
||||
/// `native_client_level_unload` and only when a connection ends.
|
||||
pub(super) fn native_client_disconnecting(&self) {
|
||||
mod_api::dispatch_client_disconnecting(self, mod_api::mods());
|
||||
}
|
||||
/// The window's player logs in to a server (NeoForge's
|
||||
/// `ClientPlayerNetworkEvent.LoggingIn`, in
|
||||
/// `ClientPacketListener.handleLogin`), single player's own server or a
|
||||
/// remote one: once per login packet, after the new client level
|
||||
/// (`native_client_level_load`). Respawns and dimension changes don't log
|
||||
/// in again; a server that sends its player back through configuration
|
||||
/// does, without a `native_client_disconnecting` between. Horizon makes
|
||||
/// the player with the server's first position, so `native_player` is
|
||||
/// None here. A mod may send the server its own packets
|
||||
/// (`native_send_packet`). Client only.
|
||||
/// API @API:client_player_network_logging_in@.
|
||||
pub(super) fn native_client_player_network_logging_in(&mut self) {
|
||||
mod_api::dispatch_client_player_network_logging_in(self, mod_api::mods());
|
||||
}
|
||||
/// Once before this client's final save/exit. A mod can flush and join its worker here.
|
||||
pub(super) fn native_client_stopping(&self) {
|
||||
mod_api::dispatch_client_stopping(self, mod_api::mods());
|
||||
@@ -2501,6 +2517,21 @@ impl Game<'_> {
|
||||
pub(super) fn native_level_tick_post(&mut self, dimension: &str) {
|
||||
mod_api::dispatch_level_tick_post(self, mod_api::mods(), dimension);
|
||||
}
|
||||
/// A client tick of the window starts (NeoForge's `ClientTickEvent.Pre`,
|
||||
/// at the top of `Minecraft.tick`), before any of its work. The window
|
||||
/// ticks 20 times a second on the title screen, while paused and in a
|
||||
/// world alike, and runs the ticks a slow frame owes back to back. Never
|
||||
/// on a server, single player's own included: it ticks on its own thread.
|
||||
/// Client only. API @API:client_tick@.
|
||||
pub(super) fn native_client_tick_pre(&mut self) {
|
||||
mod_api::dispatch_client_tick_pre(self, mod_api::mods());
|
||||
}
|
||||
/// The same client tick ends (`ClientTickEvent.Post`): the world's tick,
|
||||
/// the chat queue, music and sounds are done. A tick that errors ends
|
||||
/// before it. Client only. API @API:client_tick@.
|
||||
pub(super) fn native_client_tick_post(&mut self) {
|
||||
mod_api::dispatch_client_tick_post(self, mod_api::mods());
|
||||
}
|
||||
/// The HUD (not drawn under F1), after the minimap: place a box with
|
||||
/// `corners.place` (boxes stack away from their edge) and draw it with
|
||||
/// `native_fill` and `native_text`. Skip a corner `native_corner_shown`
|
||||
@@ -2587,6 +2618,30 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_left_click_block(self, mod_api::mods(), pos, face, state, canceled);
|
||||
}
|
||||
/// NeoForge's `PlayerEvent.BreakSpeed`: how fast the player digs the block
|
||||
/// `state` at `pos`, where `Player.getDestroySpeed` ends. `speed` starts as
|
||||
/// vanilla's, `original` (the tool, mining efficiency, Haste and Mining
|
||||
/// Fatigue, the block break speed attribute, the slowdowns under water and
|
||||
/// off the ground), and a mod may change it; every mod is called in turn,
|
||||
/// and `original` stays vanilla's. `BlockBehaviour.getDestroyProgress` then
|
||||
/// divides the speed by the block's hardness and by 30, or 100 without the
|
||||
/// right tool, for each tick of the dig: a speed of 0 makes no progress, so
|
||||
/// the block isn't broken, not even one that breaks at once. Not asked for
|
||||
/// an unbreakable block (hardness -1) nor in creative mode. Fires on the
|
||||
/// `Game` that works out the dig: single player's, a LAN or dedicated
|
||||
/// host's for each guest (the helpers read that player), and a remote
|
||||
/// client's own prediction, so a mod changes the speed alike on both sides
|
||||
/// to keep the crack in step with the server. It is asked every tick of a
|
||||
/// dig, on each side, so it should only compute. API @API:break_speed@.
|
||||
pub(super) fn native_break_speed(
|
||||
&self,
|
||||
pos: [i32; 3],
|
||||
state: BlockStateId,
|
||||
original: f32,
|
||||
speed: &mut f32,
|
||||
) {
|
||||
mod_api::dispatch_break_speed(self, mod_api::mods(), pos, state, original, speed);
|
||||
}
|
||||
/// A living player's server tick ends (`ServerPlayer.tick`): once a tick
|
||||
/// for every player in the world, after their digging, item use,
|
||||
/// cooldowns, statistics and advancements. The helpers read that player.
|
||||
@@ -6281,6 +6336,36 @@ impl Game<'_> {
|
||||
mod_api::dispatch_entity_break_door(self, mod_api::mods(), entity, kind, pos);
|
||||
}
|
||||
|
||||
/// A mob or a player is about to change the block at `pos`: a landing's
|
||||
/// trampling (farmland to dirt, a turtle egg breaking), before its
|
||||
/// neighbour updates and events. `player` is the player whose landing it
|
||||
/// was, `entity` and `kind` the mob for a mob's landing (`None` and the
|
||||
/// player's type here). `from` and `to` are the block names. Set `cancel`
|
||||
/// and the block stays as it was and nothing the change would have caused
|
||||
/// happens, as Bukkit's `EntityChangeBlockEvent` (@API:entity_change_block_v2@).
|
||||
pub(super) fn native_entity_change_block_v2(
|
||||
&mut self,
|
||||
player: Option<u128>,
|
||||
entity: Option<u32>,
|
||||
kind: &str,
|
||||
pos: [i32; 3],
|
||||
from: &str,
|
||||
to: &str,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
mod_api::dispatch_entity_change_block_v2(
|
||||
self,
|
||||
mod_api::mods(),
|
||||
player,
|
||||
entity,
|
||||
kind,
|
||||
pos,
|
||||
from,
|
||||
to,
|
||||
cancel,
|
||||
);
|
||||
}
|
||||
|
||||
/// A mob changed a block at `pos`, after the new state was applied.
|
||||
/// 214.
|
||||
pub(super) fn native_entity_change_block(
|
||||
@@ -6590,6 +6675,18 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_tooltip_component_draw(self, mod_api::mods(), stack, batch, origin, size);
|
||||
}
|
||||
/// Intercept item tooltip rendering before geometry calculation and drawing.
|
||||
/// Setting `cancel` to true suppresses vanilla tooltip rendering.
|
||||
pub(super) fn native_tooltip_render_pre(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
batch: &mut HudBatch,
|
||||
x: f32,
|
||||
y: f32,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
mod_api::dispatch_tooltip_render_pre(self, mod_api::mods(), stack, batch, x, y, cancel);
|
||||
}
|
||||
|
||||
/// The small bar `GuiGraphicsExtractor.itemDecorations` draws under an
|
||||
/// item, wherever the game shows one: `bar` arrives holding vanilla's value
|
||||
@@ -7867,6 +7964,63 @@ impl Game<'_> {
|
||||
canceled,
|
||||
);
|
||||
}
|
||||
/// `LivingEntity.actuallyHurt` is about to take `amount` from entity
|
||||
/// `entity`'s health (NeoForge's `LivingDamageEvent.Pre`), once armour
|
||||
/// (already worn down by the hit), enchantment protection, effects
|
||||
/// (Resistance) and absorption have taken their part. The other arguments
|
||||
/// are `native_living_incoming_damage`'s. Change `amount` to change what
|
||||
/// the health loses: a negative or NaN amount takes nothing, an infinite
|
||||
/// one `Float.MAX_VALUE`. The hit can't be canceled here (that is
|
||||
/// `native_living_incoming_damage`): its invulnerable time, hurt sound and
|
||||
/// knockback follow whatever the amount. Not told when no hit reaches
|
||||
/// `actuallyHurt` (refused, canceled, or within the cooldown and no
|
||||
/// stronger than the last hit) nor when a wolf's armour takes it.
|
||||
/// Players, mobs and the ender dragon. It runs on the thread that ticks
|
||||
/// the world (the server thread): state shared with render thread hooks
|
||||
/// needs synchronising. No `self`: the game is in the middle of the hit.
|
||||
/// API @API:living_damage@.
|
||||
pub(super) fn native_living_damage_pre(
|
||||
entity: i32,
|
||||
kind: &str,
|
||||
damage_type: &str,
|
||||
causing: i32,
|
||||
direct: i32,
|
||||
amount: &mut f32,
|
||||
) {
|
||||
mod_api::dispatch_living_damage_pre(
|
||||
mod_api::mods(),
|
||||
entity,
|
||||
kind,
|
||||
damage_type,
|
||||
causing,
|
||||
direct,
|
||||
amount,
|
||||
);
|
||||
}
|
||||
/// The end of `LivingEntity.actuallyHurt` (NeoForge's
|
||||
/// `LivingDamageEvent.Post`): `amount` came off entity `entity`'s health,
|
||||
/// 0 when nothing did (absorption took it all, or a mod made it 0), after
|
||||
/// the combat tracker. The arguments are
|
||||
/// `native_living_damage_pre`'s. It only hears, on the server thread, with
|
||||
/// no `self`. API @API:living_damage@.
|
||||
pub(super) fn native_living_damage_post(
|
||||
entity: i32,
|
||||
kind: &str,
|
||||
damage_type: &str,
|
||||
causing: i32,
|
||||
direct: i32,
|
||||
amount: f32,
|
||||
) {
|
||||
mod_api::dispatch_living_damage_post(
|
||||
mod_api::mods(),
|
||||
entity,
|
||||
kind,
|
||||
damage_type,
|
||||
causing,
|
||||
direct,
|
||||
amount,
|
||||
);
|
||||
}
|
||||
/// A player's weapon hit on a mob, before `Mob::damage_with` borrows the
|
||||
/// victim: melee, sweep and spear hits, including LAN guests. Unlike v1,
|
||||
/// this has a host and snapshots of the weapon, victim position/view and
|
||||
@@ -8246,7 +8400,33 @@ pub(super) fn hurt_server(hurting: Hurting<'_>, amount: &mut f32) -> bool {
|
||||
);
|
||||
canceled
|
||||
}
|
||||
/// `native_living_incoming_damage`'s entity, entity type and source for a hit.
|
||||
/// `native_living_damage_pre` for the damage code (`health::DAMAGE_PRE`). Its
|
||||
/// one caller.
|
||||
pub(super) fn damage_pre(hurting: Hurting<'_>, amount: &mut f32) {
|
||||
let (entity, kind, source) = hurt_server_args(hurting);
|
||||
Game::native_living_damage_pre(
|
||||
entity,
|
||||
kind,
|
||||
source.damage_type,
|
||||
source.causing,
|
||||
source.direct,
|
||||
amount,
|
||||
);
|
||||
}
|
||||
/// `native_living_damage_post` for the damage code (`health::DAMAGE_POST`).
|
||||
/// Its one caller.
|
||||
pub(super) fn damage_post(hurting: Hurting<'_>, amount: f32) {
|
||||
let (entity, kind, source) = hurt_server_args(hurting);
|
||||
Game::native_living_damage_post(
|
||||
entity,
|
||||
kind,
|
||||
source.damage_type,
|
||||
source.causing,
|
||||
source.direct,
|
||||
amount,
|
||||
);
|
||||
}
|
||||
/// The damage hooks' entity, entity type and source for a hit.
|
||||
fn hurt_server_args(hurting: Hurting<'_>) -> (i32, &str, HurtSource<'_>) {
|
||||
match hurting {
|
||||
Hurting::Player(entity, source) => (entity, "minecraft:player", source),
|
||||
|
||||
@@ -262,7 +262,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::super::{
|
||||
Audio, Options, Progress, Settings,
|
||||
boats::Boat,
|
||||
@@ -278,7 +278,7 @@ mod tests {
|
||||
|
||||
/// A server's game on the prepared resources: nobody is playing, and the
|
||||
/// chunk at the origin is loaded.
|
||||
fn server_game() -> Game<'static> {
|
||||
pub(crate) fn server_game() -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
|
||||
@@ -844,8 +844,10 @@ impl<'a> Game<'a> {
|
||||
/// `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, finalized with the
|
||||
/// region's random as generation adds them (mobs are not generated in
|
||||
/// the End).
|
||||
/// region's random as generation adds them. The settings' own
|
||||
/// `disable_mob_generation` skips the pass, which is how the End's preset
|
||||
/// keeps its chunks empty (the dimension is also checked, as before, so a
|
||||
/// preset that fails to load cannot start filling 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;
|
||||
@@ -857,6 +859,11 @@ impl<'a> Game<'a> {
|
||||
if terrain.is_flat() {
|
||||
return;
|
||||
}
|
||||
// `NoiseGeneratorSettings.disableMobGeneration`: the shipped End preset
|
||||
// asks for no packs at generation, and so would any dimension using it.
|
||||
if terrain.disable_mob_generation() {
|
||||
return;
|
||||
}
|
||||
let mut region = terrain.region_random(cx, cz);
|
||||
let any = region.as_any();
|
||||
let added = if let Some(r) = any.downcast_mut::<LegacyRandom>() {
|
||||
|
||||
@@ -967,19 +967,7 @@ impl Game<'_> {
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
let uuid = self.uuid_for(id);
|
||||
let t = &self.tnts[i];
|
||||
let tag = horizon_client::entity_nbt::tnt_to_nbt(
|
||||
t.position,
|
||||
t.velocity,
|
||||
t.on_ground,
|
||||
t.fuse,
|
||||
t.power,
|
||||
t.block_state_nbt(self.pack.assets.registry),
|
||||
t.rotation,
|
||||
uuid,
|
||||
self.persist_raw(id),
|
||||
);
|
||||
let tag = self.tnt_nbt(i);
|
||||
self.tnts.remove(i);
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
|
||||
@@ -114,7 +114,8 @@ impl Game<'_> {
|
||||
let at = block(s.position);
|
||||
let center = at.map(|v| f64::from(v) + 0.5);
|
||||
if s.tick_count == s.spawn_item_after - ABOUT_TO_SPAWN {
|
||||
self.audio.play(
|
||||
self.audio.play_on(
|
||||
6, // SoundSource.NEUTRAL, as OminousItemSpawner.tickServer selects.
|
||||
"minecraft:block.trial_spawner.about_to_spawn_item",
|
||||
Some(center),
|
||||
1.0,
|
||||
@@ -237,3 +238,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod warning_tests;
|
||||
@@ -0,0 +1,112 @@
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn warning_dispatch_uses_neutral_at_tick_four() {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::motion::PlayerMotion;
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let scratch = std::env::temp_dir().join(format!(
|
||||
"horizon-ominous-sound-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&scratch).unwrap();
|
||||
game.world_root = scratch.clone();
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(4.5, 100.0, 4.5)).unwrap());
|
||||
|
||||
const WARNING: &str = "minecraft:block.trial_spawner.about_to_spawn_item";
|
||||
let center = [6.5, 102.5, 4.5];
|
||||
game.audio.capture = Some(Vec::new());
|
||||
game.audio
|
||||
.play("minecraft:block.stone.break", Some(center), 1.0, 1.0);
|
||||
game.audio.play_on(
|
||||
6,
|
||||
"minecraft:block.note_block.pling",
|
||||
Some(center),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
assert_eq!(
|
||||
game.audio
|
||||
.capture
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|s| s.source)
|
||||
.collect::<Vec<_>>(),
|
||||
[4, 6]
|
||||
);
|
||||
game.audio.capture.as_mut().unwrap().clear();
|
||||
let (tag, _) = horizon_world::nbt::parse_snbt(r#"{id:"minecraft:ominous_item_spawner",UUID:[I;128,0,0,1],Pos:[6.25d,102.75d,4.875d],item:{id:"minecraft:diamond",count:1},spawn_item_after_ticks:40L}"#).unwrap();
|
||||
game.load_ominous_item_spawner(tag);
|
||||
assert_eq!(game.ominous_spawners.len(), 1);
|
||||
let s = &game.ominous_spawners[0];
|
||||
assert_eq!(s.uuid, [128, 0, 0, 1]);
|
||||
assert_eq!(s.position, [6.25, 102.75, 4.875]);
|
||||
assert_eq!(&*s.item.item, "minecraft:diamond");
|
||||
assert_eq!(s.item.count, 1);
|
||||
assert_eq!(s.spawn_item_after, 40);
|
||||
assert_eq!(s.tick_count, 0);
|
||||
for tick in 1..=5 {
|
||||
game.tick_ominous_item_spawners();
|
||||
assert_eq!(game.ominous_spawners[0].tick_count, tick);
|
||||
let warning: Vec<_> = game
|
||||
.audio
|
||||
.capture
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|s| s.event == WARNING)
|
||||
.collect();
|
||||
assert_eq!(warning.len(), if tick >= 4 { 1 } else { 0 });
|
||||
if tick == 4 {
|
||||
let sound = warning[0];
|
||||
assert_eq!(sound.position, center);
|
||||
assert_eq!(sound.volume.to_bits(), 1.0f32.to_bits());
|
||||
assert_eq!(sound.pitch.to_bits(), 1.0f32.to_bits());
|
||||
assert_eq!(
|
||||
sound.source, 6,
|
||||
"OminousItemSpawner.tickServer explicitly selects NEUTRAL"
|
||||
);
|
||||
}
|
||||
}
|
||||
game.audio
|
||||
.play("minecraft:block.stone.break", Some(center), 1.0, 1.0);
|
||||
assert_eq!(
|
||||
game.audio.capture.as_ref().unwrap().last().unwrap().source,
|
||||
4,
|
||||
"the explicit warning source must not change later event-family inference"
|
||||
);
|
||||
drop(game);
|
||||
std::fs::remove_dir_all(scratch).unwrap();
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use serde_json::{Value, json};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
mod face_buttons;
|
||||
mod hid_layout;
|
||||
mod triggers;
|
||||
|
||||
/// The controllers gilrs sees and the DualSense's output.
|
||||
@@ -261,7 +262,11 @@ fn state_of(pad: &gilrs::Gamepad) -> PadState {
|
||||
sdl_mapped,
|
||||
);
|
||||
state.left = left;
|
||||
state.right = right;
|
||||
state.right = if hid_layout::right_stick_on_z(pad) {
|
||||
hid_layout::right_stick([axis(A::LeftZ), axis(A::RightZ)])
|
||||
} else {
|
||||
right
|
||||
};
|
||||
state.triggers = triggers::triggers(pad);
|
||||
state
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Linux's codes for X and Y don't say where those buttons sit.
|
||||
use super::hid_layout;
|
||||
use gilrs::{Button, Gamepad, MappingSource};
|
||||
use horizon_client::gamepad::GlyphStyle;
|
||||
|
||||
@@ -18,14 +19,20 @@ pub(super) fn crossed(pad: &Gamepad) -> bool {
|
||||
Button::Z,
|
||||
]
|
||||
.map(|button| pad.button_code(button).is_some()),
|
||||
hid_layout::numbered_buttons(pad),
|
||||
GlyphStyle::of_device(pad.vendor_id(), pad.product_id()),
|
||||
)
|
||||
}
|
||||
|
||||
fn crossed_layout(source: MappingSource, face_buttons: [bool; 6], style: GlyphStyle) -> bool {
|
||||
fn crossed_layout(
|
||||
source: MappingSource,
|
||||
face_buttons: [bool; 6],
|
||||
numbered_buttons: bool,
|
||||
style: GlyphStyle,
|
||||
) -> bool {
|
||||
source == MappingSource::Driver
|
||||
// The Xbox-style layout has no C or Z.
|
||||
&& face_buttons == [true, true, true, true, false, false]
|
||||
// The Xbox-style layout has no C or Z, unless HID's numbered buttons name them unused.
|
||||
&& (face_buttons == [true, true, true, true, false, false] || numbered_buttons)
|
||||
// These drivers send the same codes for what is really on the north and the west.
|
||||
&& !matches!(style, GlyphStyle::PlayStation | GlyphStyle::Nintendo)
|
||||
}
|
||||
@@ -49,7 +56,12 @@ mod tests {
|
||||
fn an_unmapped_xbox_style_pad_is_crossed() {
|
||||
// The GameSir Nova Lite 2 of #7323: `3537:1098`, no SDL row, X sends BTN_NORTH.
|
||||
let style = GlyphStyle::of_device(Some(0x3537), Some(0x1098));
|
||||
assert!(crossed_layout(MappingSource::Driver, XBOX_BUTTONS, style));
|
||||
assert!(crossed_layout(
|
||||
MappingSource::Driver,
|
||||
XBOX_BUTTONS,
|
||||
false,
|
||||
style
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -57,6 +69,7 @@ mod tests {
|
||||
assert!(!crossed_layout(
|
||||
MappingSource::SdlMappings,
|
||||
XBOX_BUTTONS,
|
||||
false,
|
||||
GlyphStyle::Xbox
|
||||
));
|
||||
}
|
||||
@@ -65,7 +78,12 @@ mod tests {
|
||||
fn a_sony_or_nintendo_pad_means_what_its_codes_say() {
|
||||
for vendor in [0x054c, 0x057e] {
|
||||
let style = GlyphStyle::of_device(Some(vendor), None);
|
||||
assert!(!crossed_layout(MappingSource::Driver, XBOX_BUTTONS, style));
|
||||
assert!(!crossed_layout(
|
||||
MappingSource::Driver,
|
||||
XBOX_BUTTONS,
|
||||
false,
|
||||
style
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,11 +96,49 @@ mod tests {
|
||||
assert!(!crossed_layout(
|
||||
MappingSource::Driver,
|
||||
different,
|
||||
false,
|
||||
GlyphStyle::Xbox
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_xbox_pad_over_bluetooth_is_crossed_though_it_lists_c_and_z() {
|
||||
// `045e:0b13` on hid_microsoft reports BTN_SOUTH to BTN_THUMBR, with no SDL row for
|
||||
// firmware 0x0520: X sends BTN_NORTH.
|
||||
let style = GlyphStyle::of_device(Some(0x045e), Some(0x0b13));
|
||||
assert!(crossed_layout(
|
||||
MappingSource::Driver,
|
||||
[true; 6],
|
||||
true,
|
||||
style
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbered_buttons_on_a_sony_or_nintendo_pad_are_left_alone() {
|
||||
for vendor in [0x054c, 0x057e] {
|
||||
let style = GlyphStyle::of_device(Some(vendor), None);
|
||||
assert!(!crossed_layout(
|
||||
MappingSource::Driver,
|
||||
[true; 6],
|
||||
true,
|
||||
style
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn six_real_buttons_are_not_crossed() {
|
||||
// A pad with C and Z and its stick elsewhere: they may be real buttons, so X is X.
|
||||
assert!(!crossed_layout(
|
||||
MappingSource::Driver,
|
||||
[true; 6],
|
||||
false,
|
||||
GlyphStyle::Xbox
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_x_and_y_change_places() {
|
||||
assert_eq!(named(Button::West, true), Button::North);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! A pad the kernel's generic HID driver describes, which an SDL row would otherwise explain.
|
||||
//! SDL rows match one firmware version exactly, so a new firmware of a known pad lands here too.
|
||||
use gilrs::{Axis, Button, Gamepad, MappingSource};
|
||||
|
||||
/// Whether this pad has its right stick on `ABS_Z`/`ABS_RZ`, as an Xbox pad over Bluetooth does.
|
||||
pub(super) fn right_stick_on_z(pad: &Gamepad) -> bool {
|
||||
cfg!(target_os = "linux")
|
||||
&& stick_on_z(
|
||||
pad.mapping_source(),
|
||||
[
|
||||
Axis::RightStickX,
|
||||
Axis::RightStickY,
|
||||
Axis::LeftZ,
|
||||
Axis::RightZ,
|
||||
]
|
||||
.map(|axis| pad.axis_code(axis).is_some()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether HID's numbered buttons reach us as A, B, C, X, Y, Z: this layout's X and Y are the
|
||||
/// fourth and fifth buttons, and C and Z are the third and sixth, which nothing presses.
|
||||
pub(super) fn numbered_buttons(pad: &Gamepad) -> bool {
|
||||
right_stick_on_z(pad)
|
||||
&& [Button::C, Button::Z]
|
||||
.iter()
|
||||
.all(|&button| pad.button_code(button).is_some())
|
||||
}
|
||||
|
||||
/// `[RightStickX, RightStickY, LeftZ, RightZ]` present. With `RightStickX` there, Z is a trigger
|
||||
/// or belongs to a model `gamepad::stick_axes` knows.
|
||||
fn stick_on_z(source: MappingSource, axes: [bool; 4]) -> bool {
|
||||
source == MappingSource::Driver && axes == [false, false, true, true]
|
||||
}
|
||||
|
||||
/// The right stick from those axes. gilrs reverses Y only on the axes it calls Y, so RZ, which
|
||||
/// grows downward like every Linux Y axis, is turned over here to match them.
|
||||
pub(super) fn right_stick([z, rz]: [f32; 2]) -> [f32; 2] {
|
||||
[z, -rz]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `045e:0b13` firmware 0x0520 over Bluetooth: X, Y, Z, RZ, GAS, BRAKE and hats.
|
||||
const XBOX_BLUETOOTH: [bool; 4] = [false, false, true, true];
|
||||
|
||||
#[test]
|
||||
fn a_pad_without_rx_or_ry_has_its_right_stick_on_z() {
|
||||
assert!(stick_on_z(MappingSource::Driver, XBOX_BLUETOOTH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_sdl_row_already_says_where_the_stick_is() {
|
||||
assert!(!stick_on_z(MappingSource::SdlMappings, XBOX_BLUETOOTH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pad_with_rx_and_ry_keeps_them() {
|
||||
// xpad: Z and RZ are its triggers.
|
||||
assert!(!stick_on_z(MappingSource::Driver, [true, true, true, true]));
|
||||
// The 8BitDo Ultimate 2 has RX too, and stick_axes reads it.
|
||||
assert!(!stick_on_z(
|
||||
MappingSource::Driver,
|
||||
[true, false, true, true]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pad_missing_either_axis_has_no_stick_there() {
|
||||
assert!(!stick_on_z(
|
||||
MappingSource::Driver,
|
||||
[false, false, true, false]
|
||||
));
|
||||
assert!(!stick_on_z(
|
||||
MappingSource::Driver,
|
||||
[false, false, false, true]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pushing_the_stick_up_reads_up() {
|
||||
// Up is the low end of ABS_RZ, and up is positive on every other stick axis.
|
||||
assert_eq!(right_stick([0.0, -1.0]), [0.0, 1.0]);
|
||||
assert_eq!(right_stick([-0.5, 0.25]), [-0.5, -0.25]);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
//! Read triggers from mapped buttons, or the conventional unmapped Linux axes.
|
||||
use super::hid_layout;
|
||||
use gilrs::{Axis, Button, Gamepad, MappingSource};
|
||||
use horizon_client::gamepad::BITDO_ULTIMATE2_VID_PID;
|
||||
|
||||
/// Linux's event type for absolute axes, and its codes for an Xbox Bluetooth pad's triggers.
|
||||
const EV_ABS: u16 = 0x03;
|
||||
const ABS_GAS: u16 = 0x09;
|
||||
const ABS_BRAKE: u16 = 0x0a;
|
||||
|
||||
pub(super) fn triggers(pad: &Gamepad) -> [f32; 2] {
|
||||
let axes = cfg!(target_os = "linux")
|
||||
&& axis_triggers(
|
||||
@@ -27,11 +33,15 @@ pub(super) fn triggers(pad: &Gamepad) -> [f32; 2] {
|
||||
.map(|button| pad.button_code(button).is_some()),
|
||||
(pad.vendor_id(), pad.product_id()),
|
||||
);
|
||||
let on_gas_and_brake = hid_layout::right_stick_on_z(pad);
|
||||
[
|
||||
(Button::LeftTrigger2, Axis::LeftZ),
|
||||
(Button::RightTrigger2, Axis::RightZ),
|
||||
(Button::LeftTrigger2, Axis::LeftZ, ABS_BRAKE),
|
||||
(Button::RightTrigger2, Axis::RightZ, ABS_GAS),
|
||||
]
|
||||
.map(|(button, axis)| {
|
||||
.map(|(button, axis, absolute)| {
|
||||
if on_gas_and_brake && let Some(value) = unnamed_axis(pad, absolute) {
|
||||
return trigger_value(false, None, false, Some(value));
|
||||
}
|
||||
trigger_value(
|
||||
pad.button_code(button).is_some(),
|
||||
pad.button_data(button).map(|data| data.value()),
|
||||
@@ -42,6 +52,16 @@ pub(super) fn triggers(pad: &Gamepad) -> [f32; 2] {
|
||||
})
|
||||
}
|
||||
|
||||
/// gilrs' default mapping has no name for these, and those pads also list trigger buttons that
|
||||
/// never fire, so an axis that has moved wins.
|
||||
fn unnamed_axis(pad: &Gamepad, absolute: u16) -> Option<f32> {
|
||||
let code = (u32::from(EV_ABS) << 16) | u32::from(absolute);
|
||||
pad.state()
|
||||
.axes()
|
||||
.find(|(axis, _)| axis.into_u32() == code)
|
||||
.map(|(_, data)| data.value())
|
||||
}
|
||||
|
||||
fn axis_triggers(
|
||||
source: MappingSource,
|
||||
six_axes: bool,
|
||||
|
||||
@@ -1142,22 +1142,33 @@ impl Game<'_> {
|
||||
|
||||
/// Vanilla's arrow navigation moves a screen's focus off the widget a
|
||||
/// click focused: the screens that draw that click focus themselves drop
|
||||
/// it, so only the pad's focus shows.
|
||||
fn clear_screen_click_focus(&mut self) {
|
||||
/// it, so only the pad's focus shows. The screen it clears need not be
|
||||
/// the one in front (a screen that just opened leaves the old focus
|
||||
/// behind it), so every menu's click focus goes.
|
||||
pub(super) fn clear_screen_click_focus(&mut self) {
|
||||
if let Some(title) = self.title.as_mut() {
|
||||
title.focused_button = None;
|
||||
}
|
||||
match self.screen {
|
||||
Screen::Paused => self.pause_focus = None,
|
||||
Screen::Dead => self.death_focus = None,
|
||||
Screen::Options => self.ui.options.clear_click_focus(),
|
||||
_ => {}
|
||||
}
|
||||
self.pause_focus = None;
|
||||
self.death_focus = None;
|
||||
self.ui.options.clear_click_focus();
|
||||
if let Some(options) = self.world_options.as_mut() {
|
||||
options.clear_click_focus();
|
||||
}
|
||||
}
|
||||
|
||||
/// `Gui.setScreen` clears the focus of the screen it leaves
|
||||
/// (`Screen.clearFocus`): the screen that opens starts with none, so the
|
||||
/// button a click focused is not still highlighted
|
||||
/// (`AbstractWidget.isHoveredOrFocused`) when the player comes back to it.
|
||||
/// The frame after another screen is front, `key_screen` names a new one.
|
||||
pub(super) fn drop_left_screen_focus(&mut self) {
|
||||
let screen = self.key_screen();
|
||||
if another_screen_is_front(&mut self.focus_screen, screen) {
|
||||
self.clear_screen_click_focus();
|
||||
}
|
||||
}
|
||||
|
||||
/// The controller's focus ring in focus mode, for the screen's narration:
|
||||
/// the focused widget.
|
||||
pub(super) fn pad_focus_rect(&self) -> Option<[f32; 4]> {
|
||||
@@ -2376,6 +2387,13 @@ fn field_at(targets: &[Target], at: (f32, f32)) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `front` is another screen than the one `shown` names yet, and
|
||||
/// records it: the menu focus belongs to the screen that showed it
|
||||
/// (`Gui.setScreen` clears the focus of the screen it leaves).
|
||||
pub(super) fn another_screen_is_front(shown: &mut u64, front: u64) -> bool {
|
||||
std::mem::replace(shown, front) != front
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2434,6 +2452,15 @@ mod tests {
|
||||
assert_eq!(named_target("reset", &labels, &[first, second]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_screen_is_a_new_place_for_the_focus() {
|
||||
let mut shown = 7;
|
||||
assert!(!another_screen_is_front(&mut shown, 7), "the same screen");
|
||||
assert!(another_screen_is_front(&mut shown, 8), "another screen");
|
||||
assert!(!another_screen_is_front(&mut shown, 8), "still that one");
|
||||
assert_eq!(shown, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scrollbar_tells_how_far_rows_moved_when_rectangles_cannot() {
|
||||
// A whole row a notch: the same rectangles, so only the amount shows it.
|
||||
|
||||
@@ -52,6 +52,15 @@ pub struct EntityMemory {
|
||||
/// Only filled while a mod is loaded.
|
||||
pub(super) live: Vec<(u32, String, bool)>,
|
||||
}
|
||||
impl EntityMemory {
|
||||
/// The UUID entity `id` was loaded or saved with: what a save writes for a
|
||||
/// field naming that entity (`entity_nbt::mob_to_nbt`'s `uuid_of`). `None`
|
||||
/// for one that has none yet, so no reference is written rather than a
|
||||
/// made-up UUID nothing would answer for.
|
||||
pub(super) fn uuid_of(&self, id: u32) -> Option<[i32; 4]> {
|
||||
self.uuids.get(&id).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// The key that marks a compound queued with the chunk loads as a fresh add
|
||||
/// (`PersistentEntitySectionManager.addNewEntity`: `/summon`, a portal
|
||||
@@ -1154,6 +1163,7 @@ impl Game<'_> {
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
Some(self.pack.assets.registry),
|
||||
|other| self.persist.uuid_of(other),
|
||||
);
|
||||
if horizon_client::mobs::camel::is_camel(self.mobs[i].species())
|
||||
&& let Tag::Compound(c) = &mut tag
|
||||
@@ -1275,13 +1285,24 @@ impl Game<'_> {
|
||||
join_saved_team(&mut self.score.board, &name, tag);
|
||||
}
|
||||
}
|
||||
/// The entity a UUID read back from a save names, while it is in the level:
|
||||
/// the player, or a mob loaded or saved with that UUID. `None` for nothing
|
||||
/// loaded (an entity that is not here, or one `Who` cannot carry) - never
|
||||
/// some other entity as a stand-in.
|
||||
pub(super) fn who_by_uuid(&self, uuid: [i32; 4]) -> Option<Who> {
|
||||
let player = if self.player.is_some() {
|
||||
self.level.player_uuid
|
||||
} else {
|
||||
None
|
||||
};
|
||||
entity_nbt::who_by_uuid(&self.persist.uuids, &self.mobs, player, uuid)
|
||||
}
|
||||
/// The mob that was loaded or saved with a UUID.
|
||||
pub(super) fn mob_by_uuid(&self, uuid: [i32; 4]) -> Option<u32> {
|
||||
self.persist
|
||||
.uuids
|
||||
.iter()
|
||||
.find(|(id, u)| **u == uuid && self.mobs.iter().any(|m| m.id == **id))
|
||||
.map(|(id, _)| *id)
|
||||
match self.who_by_uuid(uuid) {
|
||||
Some(Who::Mob(id)) => Some(id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// A mob's UUID, made up for new ones.
|
||||
pub(super) fn mob_uuid(&mut self, id: u32) -> [i32; 4] {
|
||||
@@ -1799,23 +1820,9 @@ impl Game<'_> {
|
||||
i: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
let (id, at) = (self.tnts[i].id, self.tnts[i].position);
|
||||
keys.contains(&chunk_of(at)).then(|| {
|
||||
let uuid = self.uuid_for(id);
|
||||
let t = &self.tnts[i];
|
||||
let tag = entity_nbt::tnt_to_nbt(
|
||||
t.position,
|
||||
t.velocity,
|
||||
t.on_ground,
|
||||
t.fuse,
|
||||
t.power,
|
||||
t.block_state_nbt(self.pack.assets.registry),
|
||||
t.rotation,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
);
|
||||
(chunk_of(at), tag)
|
||||
})
|
||||
let at = self.tnts[i].position;
|
||||
keys.contains(&chunk_of(at))
|
||||
.then(|| (chunk_of(at), self.tnt_nbt(i)))
|
||||
}
|
||||
pub(super) fn save_boat(
|
||||
&mut self,
|
||||
|
||||
@@ -1987,7 +1987,14 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let m = &mut self.mobs[i];
|
||||
if harm {
|
||||
if harm && m.species() == horizon_client::mobs::Species::Enderman {
|
||||
// `Enderman.hurtServer` for a thrown potion: only water hurts
|
||||
// (`hurtWithCleanWater`), then `repeatedlyTryToTeleport`, which
|
||||
// the AI step runs and which does nothing while it rides.
|
||||
if m.alive() && !m.extra.passenger {
|
||||
m.extra.counter2 = 64;
|
||||
}
|
||||
} else if harm {
|
||||
// HealOrHarmMobEffect.applyInstantaneousEffect: indirectMagic(potion, owner).
|
||||
let harm = (intensity * f64::from(6 << amp) + 0.5) as i32 as f32;
|
||||
let source = Damage::indirect_magic(owner.who(), [at.x, at.y, at.z]);
|
||||
|
||||
@@ -34,6 +34,18 @@ impl Game<'_> {
|
||||
}
|
||||
self.extinguish_sound([at.x, at.y, at.z]);
|
||||
}
|
||||
/// `PowderSnowBlock.entityInside` (runBefore EXTINGUISH) for a burning
|
||||
/// player: `Level.destroyBlock(pos, false)`. A client of a remote server
|
||||
/// leaves it to the server.
|
||||
pub(super) fn powder_snow_melt(&mut self, pos: BlockPos) {
|
||||
let at = [pos.x, pos.y, pos.z];
|
||||
if self.remote.is_some() || !self.is_powder_snow(at) {
|
||||
return;
|
||||
}
|
||||
self.with_level(|b, level, ticks| b.destroy_block(level, ticks, pos, false));
|
||||
let _ = self.process_level_events();
|
||||
self.world.modified.insert((at[0] >> 4, at[2] >> 4));
|
||||
}
|
||||
/// Whether the block at `pos` is powder snow.
|
||||
pub(super) fn is_powder_snow(&self, pos: [i32; 3]) -> bool {
|
||||
&*self
|
||||
|
||||
@@ -1221,14 +1221,19 @@ impl Game<'_> {
|
||||
/// bed or bell a villager has taken): `PoiManager.isVillageCenter`.
|
||||
pub(super) fn village_centers(&self) -> HashSet<(i32, i32, i32)> {
|
||||
let mut out = HashSet::new();
|
||||
let world = self.mob_world_base(&[]);
|
||||
for m in &self.mobs {
|
||||
if m.species() != Species::Villager || !m.alive() {
|
||||
continue;
|
||||
}
|
||||
for p in [m.extra.home, m.extra.bed, m.extra.meeting]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
// A remembered HOME is no longer an occupied village POI once its
|
||||
// loaded bed head disappears. An unavailable chunk remains unknown.
|
||||
let bed = m.extra.bed.filter(|&[x, y, z]| {
|
||||
!self.world.loaded(x, z)
|
||||
|| horizon_client::mobs::villager_brain::poi_at(&world, x, y, z)
|
||||
== Some(horizon_client::mobs::villager_brain::Poi::Home)
|
||||
});
|
||||
for p in [m.extra.home, bed, m.extra.meeting].into_iter().flatten() {
|
||||
out.insert(section(p));
|
||||
}
|
||||
}
|
||||
@@ -1520,3 +1525,94 @@ mod tests {
|
||||
assert_eq!(section([-1, -17, 15]), (-1, -2, 0));
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Read-only command-harness observation of the existing raid state and inputs.
|
||||
pub(in crate::game) fn raid_fixture_state(
|
||||
&self,
|
||||
op: &serde_json::Value,
|
||||
) -> horizon_client::Result<serde_json::Value> {
|
||||
use serde_json::json;
|
||||
let id = op["id"].as_i64().ok_or("raid observation: missing id")? as i32;
|
||||
let mut state = json!({"id":id,"present":self.raiding.raids.get(id).is_some()});
|
||||
if let Some(r) = self.raiding.raids.get(id) {
|
||||
state["status"] = json!(r.status.name());
|
||||
state["started"] = json!(r.started);
|
||||
state["active"] = json!(r.active);
|
||||
state["groups_spawned"] = json!(r.groups_spawned);
|
||||
state["center"] = json!(r.center);
|
||||
// Observe the production answer; never substitute a corrected POI test here.
|
||||
state["village"] = json!(Self::is_village(&self.village_centers(), r.center));
|
||||
if op["members"].as_bool() == Some(true) {
|
||||
state["member_present"] = json!(self.raid_members(id).next().is_some());
|
||||
}
|
||||
}
|
||||
let observe = |m: &Mob| -> horizon_client::Result<serde_json::Value> {
|
||||
let uuid = self
|
||||
.persist
|
||||
.uuids
|
||||
.get(&m.id)
|
||||
.copied()
|
||||
.ok_or("raid observation: actor UUID is not registered")?;
|
||||
let tag = horizon_client::entity_nbt::mob_to_nbt(
|
||||
m,
|
||||
uuid,
|
||||
self.persist.raw.get(&m.id),
|
||||
Some(self.pack.assets.registry),
|
||||
);
|
||||
Ok(json!({"uuid":uuid,"type":m.kind.id,"health":m.health,
|
||||
"pos":[m.position.x,m.position.y,m.position.z],"noai":m.no_ai,
|
||||
"home":m.extra.bed.map(|p| p.to_vec()).unwrap_or_default(),
|
||||
"raid_id":raider::state(m).and_then(|s|s.raid_id).unwrap_or(-1),
|
||||
"nbt":horizon_client::snbt::to_snbt(&tag)}))
|
||||
};
|
||||
let actors = self
|
||||
.raid_members(id)
|
||||
.map(observe)
|
||||
.collect::<horizon_client::Result<Vec<_>>>()?;
|
||||
let mut villagers = Vec::new();
|
||||
for uuid in op["villagers"]
|
||||
.as_array()
|
||||
.ok_or("raid observation: missing villagers")?
|
||||
{
|
||||
let uuid = u128::from_str_radix(
|
||||
&uuid
|
||||
.as_str()
|
||||
.ok_or("raid observation: invalid UUID")?
|
||||
.replace('-', ""),
|
||||
16,
|
||||
)?;
|
||||
let Some(super::commands::Target::Mob(id)) = self.target_by_uuid(uuid) else {
|
||||
return Err("raid observation: villager UUID missing/wrong type".into());
|
||||
};
|
||||
let m = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && m.species() == Species::Villager)
|
||||
.ok_or("raid observation: actor is not a villager")?;
|
||||
villagers.push(observe(m)?);
|
||||
}
|
||||
let pos: [i32; 3] =
|
||||
std::array::from_fn(|i| op["poi"][i].as_i64().unwrap_or(i64::MIN) as i32);
|
||||
let loaded = self.world.loaded(pos[0], pos[2]);
|
||||
let live_poi = loaded.then(|| {
|
||||
let w = self.mob_world_base(&[]);
|
||||
format!(
|
||||
"{:?}",
|
||||
horizon_client::mobs::villager_brain::poi_at(&w, pos[0], pos[1], pos[2])
|
||||
)
|
||||
});
|
||||
eprintln!(
|
||||
"raid input raw: {}",
|
||||
json!({"label":op["label"],"id":id,"loaded":loaded,
|
||||
"live_poi":live_poi,"claimed_pois":self.village_pois(),
|
||||
"raid_nbt":horizon_client::snbt::to_snbt(&self.raiding.raids.to_nbt()),
|
||||
"actors":actors,"villagers":villagers})
|
||||
);
|
||||
Ok(state)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "raids/tests/bed_loss.rs"]
|
||||
mod bed_loss_tests;
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Real Game raid creation/wave dispatch with a loaded claimed-bed precondition.
|
||||
use super::super::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::{chunk::Chunk, mobs::Mob, raid::Status};
|
||||
use horizon_core::{geometry::Vec3, position::ChunkPos};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn admitted_raid_loses_removed_loaded_bed_without_erasing_memory() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = super::super::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.world_root =
|
||||
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap()).join("raid-unit-world");
|
||||
for x in -5..5 {
|
||||
for z in -5..5 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(x, z))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
let registry = game.pack.assets.registry;
|
||||
let air = registry.default_state("minecraft:air").unwrap();
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
for x in -63..=63 {
|
||||
for z in -63..=63 {
|
||||
game.world.set(x, 199, z, stone).unwrap();
|
||||
}
|
||||
}
|
||||
let foot = registry.default_state("minecraft:white_bed").unwrap();
|
||||
let mut head_state = registry.state(foot).unwrap().clone();
|
||||
head_state.properties.insert("part".into(), "head".into());
|
||||
let head = registry.id(&head_state).unwrap();
|
||||
game.world.set(2, 200, 2, foot).unwrap();
|
||||
game.world.set(2, 200, 1, head).unwrap();
|
||||
let kind = game
|
||||
.res
|
||||
.mob_types()
|
||||
.get("minecraft:villager")
|
||||
.unwrap()
|
||||
.clone();
|
||||
let mut villager = Mob::new(162, kind, Vec3::new(3.5, 200.0, 2.5), 0.0);
|
||||
villager.no_ai = true;
|
||||
// The full Game/Java fixture proves real AI acquisition; this unit starts
|
||||
// from that claimed-bed state and exercises production raid/wave dispatch.
|
||||
villager.extra.bed = Some([2, 200, 1]);
|
||||
game.mobs.push(villager);
|
||||
assert!(game.raid_command_create([8, 200, 8], 1));
|
||||
let id = game.raiding.raids.ids()[0];
|
||||
for _ in 0..400 {
|
||||
game.world.game_time += 1;
|
||||
game.tick_raids();
|
||||
if game.raiding.raids.get(id).unwrap().groups_spawned == 1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let raid = game.raiding.raids.get(id).unwrap();
|
||||
assert!(raid.started);
|
||||
assert_eq!(raid.groups_spawned, 1, "real first wave must be admitted");
|
||||
assert!(
|
||||
game.raid_members(id).next().is_some(),
|
||||
"real wave members required"
|
||||
);
|
||||
for _ in 0..20 {
|
||||
game.tick_raids();
|
||||
}
|
||||
assert_eq!(game.raiding.raids.get(id).unwrap().status, Status::Ongoing);
|
||||
// Unavailable claims remain unknown rather than becoming known air.
|
||||
let chunk = game.world.chunks.remove(&(0, 0)).unwrap();
|
||||
assert!(Game::is_village(&game.village_centers(), [2, 200, 1]));
|
||||
game.world.chunks.insert((0, 0), chunk);
|
||||
game.level_set([2, 200, 1], air).unwrap();
|
||||
game.level_set([2, 200, 2], air).unwrap();
|
||||
assert_eq!(game.world.block(2, 200, 1), air);
|
||||
assert_eq!(game.world.block(2, 200, 2), air);
|
||||
game.tick_raids();
|
||||
assert_eq!(game.raiding.raids.get(id).unwrap().status, Status::Loss);
|
||||
assert_eq!(
|
||||
game.mobs.iter().find(|m| m.id == 162).unwrap().extra.bed,
|
||||
Some([2, 200, 1])
|
||||
);
|
||||
}
|
||||
@@ -2013,9 +2013,12 @@ impl Game<'_> {
|
||||
self.send_chat_acknowledgement();
|
||||
self.close_dialogs();
|
||||
// The server configuring us now (a proxy's next one)
|
||||
// offers its own far terrain, or none.
|
||||
// offers its own far terrain, or none, and announces its
|
||||
// own features again (a Horizon server's quiet commands).
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.far_terrain = None;
|
||||
remote.quiet_commands = false;
|
||||
remote.mod_net = false;
|
||||
}
|
||||
self.clear_lod();
|
||||
if let Some(remote) = &self.remote {
|
||||
@@ -2253,6 +2256,9 @@ impl Game<'_> {
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
// A Horizon server's bulk edit sends whole columns with the light
|
||||
// from before its relight; any other server's packet light is final.
|
||||
let relight_resent = r.quiet_commands;
|
||||
let dimension = r.registries.dimension(spawn.dimension_type);
|
||||
// The server's dimension type gives the level's shape, and its
|
||||
// chunks the shape's column.
|
||||
@@ -2358,6 +2364,7 @@ impl Game<'_> {
|
||||
self.native_client_level_unload(&old);
|
||||
}
|
||||
}
|
||||
self.streaming.relight_resent = relight_resent;
|
||||
let has_sky = self
|
||||
.remote
|
||||
.as_ref()
|
||||
@@ -3303,6 +3310,8 @@ impl Game<'_> {
|
||||
false,
|
||||
horizon_client::level_loading::Reason::Other,
|
||||
);
|
||||
// handleLogin: ClientPlayerNetworkEvent.LoggingIn, the new level made.
|
||||
self.native_client_player_network_logging_in();
|
||||
// No ClientInformation here: the configuration phase sent it,
|
||||
// and ClientPacketListener.broadcastClientInformation sends it
|
||||
// again only when an option changes it.
|
||||
@@ -6411,6 +6420,9 @@ impl Game<'_> {
|
||||
// NetherPortalBlock.entityInside on the client's own level:
|
||||
// the portal overlay and its trigger sound (the server moves
|
||||
// the player).
|
||||
horizon_client::motion::MotionEvent::PowderSnowMelt { pos } => {
|
||||
self.powder_snow_melt(pos);
|
||||
}
|
||||
horizon_client::motion::MotionEvent::InsidePortal { .. } => {
|
||||
self.client_inside_nether_portal();
|
||||
}
|
||||
@@ -7489,15 +7501,12 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let facts = self.res.facts().get(state).clone();
|
||||
// The server's own getDestroyProgress: under water, off the ground,
|
||||
// with haste, fatigue and efficiency, so the client stops digging
|
||||
// when the server is done too (#1254).
|
||||
let progress = if facts.hardness == 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
self.destroy_progress(hit.pos)
|
||||
};
|
||||
// with haste, fatigue, efficiency and the mods' native_break_speed, so
|
||||
// the client stops digging when the server is done too (#1254). A
|
||||
// block of hardness 0 gives infinity, breaking at once, unless a mod
|
||||
// made the speed 0.
|
||||
let progress = self.destroy_progress(hit.pos);
|
||||
// sameDestroyTarget: the same block and still the item the break began with.
|
||||
let held = self.main_hand_key();
|
||||
let same = self.remote.as_ref().is_some_and(|r| {
|
||||
@@ -8415,8 +8424,16 @@ impl Game<'_> {
|
||||
stack: &ItemStack,
|
||||
) -> (bool, Option<DryChanges>, Vec<DrySound>) {
|
||||
// `BucketItem.use`: `level.mayInteract` and `mayUseItemAt`.
|
||||
// `Player.mayUseItemAt` tests the clicked block against `can_place_on`.
|
||||
if !self.may_build() {
|
||||
return (false, None, Vec::new());
|
||||
let aim = if &*stack.item == "minecraft:bucket" {
|
||||
self.bucket_hit()
|
||||
} else {
|
||||
self.pick()
|
||||
};
|
||||
if !aim.is_some_and(|hit| self.can_place_on_in_adventure(hit.pos)) {
|
||||
return (false, None, Vec::new());
|
||||
}
|
||||
}
|
||||
if &*stack.item == "minecraft:bucket" {
|
||||
let Some(hit) = self.bucket_hit() else {
|
||||
@@ -9396,6 +9413,8 @@ impl Game<'_> {
|
||||
/// MultiPlayerGameMode.dropItem (key.drop): predict the removal, then send
|
||||
/// the action; swing only if a nonempty stack was removed.
|
||||
pub(super) fn remote_drop(&mut self, all: bool) {
|
||||
// ensureHasSentCarriedItem: the server drops from the slot just chosen.
|
||||
self.remote_send_carried();
|
||||
let dropped =
|
||||
horizon_client::items::take_held_drop(&mut self.inventory[self.selected], all, false);
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
|
||||
@@ -332,7 +332,7 @@ impl Game<'_> {
|
||||
pub(super) fn turn_remote_object(&mut self, id: i32, yaw: Option<f32>, on_ground: bool) {
|
||||
let id = id as u32;
|
||||
// The boat the player drives turns here (isLocalInstanceAuthoritative).
|
||||
let driven = self.riding;
|
||||
let driven = self.riding.filter(|&id| self.controls_boat(id));
|
||||
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
|
||||
if Some(id) == driven {
|
||||
return;
|
||||
@@ -390,7 +390,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// The boat the player drives moves here, not where the server says.
|
||||
let driven = self.riding;
|
||||
let driven = self.riding.filter(|&id| self.controls_boat(id));
|
||||
for b in &mut self.boats {
|
||||
if Some(b.id) == driven {
|
||||
continue;
|
||||
|
||||
@@ -254,6 +254,12 @@ impl Game<'_> {
|
||||
Some(position)
|
||||
}
|
||||
|
||||
/// AbstractBoat.getControllingPassenger: only the first player steers a boat.
|
||||
/// A non-remote world retains its existing local movement ownership.
|
||||
pub(super) fn controls_boat(&self, id: u32) -> bool {
|
||||
self.riding == Some(id) && self.remote.as_ref().is_none_or(|r| r.first_passenger)
|
||||
}
|
||||
|
||||
/// The local player's tick in its vehicle, in place of its own movement:
|
||||
/// false when it rides nothing.
|
||||
pub(super) fn remote_ride(&mut self, keys: Keys, sprint_window: i32) -> Result<bool> {
|
||||
@@ -287,8 +293,11 @@ impl Game<'_> {
|
||||
// It steers with the input `LocalPlayer.rideTick` latched on the
|
||||
// previous tick, so the paddle packet describes the motion the
|
||||
// server is sent; this tick's keys are latched after it ticked.
|
||||
let mut b = self.tick_boat(self.boats[i].clone(), true);
|
||||
b.input = [keys.left, keys.right, keys.forward, keys.backward];
|
||||
let mut b = self.boats[i].clone();
|
||||
if self.controls_boat(id) {
|
||||
b = self.tick_boat(b, true);
|
||||
b.input = [keys.left, keys.right, keys.forward, keys.backward];
|
||||
}
|
||||
// positionRider: the seat turns with the boat; clampRotation.
|
||||
self.yaw += b.delta_rotation;
|
||||
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
@@ -349,6 +358,7 @@ impl Game<'_> {
|
||||
}
|
||||
let Some(b) = self
|
||||
.riding
|
||||
.filter(|&id| self.controls_boat(id))
|
||||
.and_then(|id| self.boats.iter().find(|b| b.id == id))
|
||||
else {
|
||||
return Vec::new();
|
||||
@@ -383,6 +393,7 @@ impl Game<'_> {
|
||||
}
|
||||
let Some(b) = self
|
||||
.riding
|
||||
.filter(|&id| self.controls_boat(id))
|
||||
.and_then(|id| self.boats.iter_mut().find(|b| b.id == id))
|
||||
else {
|
||||
return;
|
||||
@@ -414,7 +425,7 @@ impl Game<'_> {
|
||||
/// the synched bubble time, followed by the oars on boats this client does not drive.
|
||||
pub(super) fn tick_remote_boat_bubbles(&mut self) {
|
||||
for i in 0..self.boats.len() {
|
||||
let driven = self.riding == Some(self.boats[i].id);
|
||||
let driven = self.controls_boat(self.boats[i].id);
|
||||
if !driven {
|
||||
// `AbstractBoat.tick`: the non-authoritative copy carries no
|
||||
// cached motion into the next time this player drives it.
|
||||
|
||||
@@ -326,7 +326,8 @@ impl Game<'_> {
|
||||
let tags = self.tags_of(t);
|
||||
for (tag, negated) in &s.tags {
|
||||
let has = if tag.is_empty() {
|
||||
!tags.is_empty()
|
||||
// EntitySelectorOptions' empty-name predicate (lambda$bootStrap$43).
|
||||
tags.is_empty()
|
||||
} else {
|
||||
tags.contains(tag)
|
||||
};
|
||||
@@ -835,6 +836,8 @@ impl Game<'_> {
|
||||
};
|
||||
let has_objective = self.score.board.display_objective(sb::SLOT_LIST).is_some();
|
||||
let shown = tab_list::visible(
|
||||
// `Hud.extractRenderState` draws the list inside `if (!isHidden)`.
|
||||
!self.ui.hide_gui,
|
||||
key,
|
||||
self.has_singleplayer_server(),
|
||||
players.len(),
|
||||
@@ -993,7 +996,7 @@ mod tab_list_tests {
|
||||
let mut guests = vec![guest(2, "Alex")];
|
||||
let players = local_tab_players(me.clone(), &guests);
|
||||
assert_eq!(players.as_slice(), std::slice::from_ref(&me));
|
||||
assert!(!tab_list::visible(true, true, players.len(), false));
|
||||
assert!(!tab_list::visible(true, true, true, players.len(), false));
|
||||
|
||||
guests[0].placed = true;
|
||||
let players = local_tab_players(me.clone(), &guests);
|
||||
@@ -1001,14 +1004,18 @@ mod tab_list_tests {
|
||||
assert_eq!(players[0], me);
|
||||
assert_eq!(players[1].uuid, 2);
|
||||
assert_eq!(players[1].name, "Alex");
|
||||
assert!(tab_list::visible(true, true, players.len(), false));
|
||||
assert!(!tab_list::visible(false, true, players.len(), false));
|
||||
assert!(tab_list::visible(true, true, true, players.len(), false));
|
||||
assert!(!tab_list::visible(true, false, true, players.len(), false));
|
||||
|
||||
guests.clear();
|
||||
let players = local_tab_players(me, &guests);
|
||||
assert_eq!(players.len(), 1);
|
||||
assert!(!tab_list::visible(true, true, players.len(), false));
|
||||
assert!(tab_list::visible(true, true, players.len(), true));
|
||||
assert!(!tab_list::visible(true, true, true, players.len(), false));
|
||||
assert!(tab_list::visible(true, true, true, players.len(), true));
|
||||
// F1 (`Hud.isHidden`) takes the list away: `extractRenderState` draws
|
||||
// it inside `if (!isHidden)`, the key and the objective aside.
|
||||
assert!(!tab_list::visible(false, true, true, players.len(), true));
|
||||
assert!(!tab_list::visible(false, true, false, 2, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1122,3 +1129,6 @@ mod team_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod empty_tag_tests;
|
||||
@@ -0,0 +1,104 @@
|
||||
use super::*;
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn empty_tag_selectors_follow_actual_tag_edits() {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::motion::PlayerMotion;
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let scratch = std::env::temp_dir().join(format!(
|
||||
"horizon-empty-tags-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&scratch).unwrap();
|
||||
game.world_root = scratch.clone();
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(10.5, 64.0, 0.5)).unwrap());
|
||||
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
for (n, x, tags) in [(1, 4.5, ""), (2, 8.5, ",Tags:[\"audit\"]")] {
|
||||
assert!(game.run_command(&format!(
|
||||
"summon pig {x} 64 4.5 {{UUID:[I;267,0,0,{n}],NoAI:1b,NoGravity:1b{tags}}}"
|
||||
)));
|
||||
}
|
||||
let a = game.target_with_uuid([267, 0, 0, 1]).unwrap();
|
||||
let b = game.target_with_uuid([267, 0, 0, 2]).unwrap();
|
||||
assert_ne!(a, b);
|
||||
let select = |g: &mut Game<'_>, tag: &str| {
|
||||
let text = format!("@e[type=minecraft:pig,tag={tag}]");
|
||||
let selector = horizon_client::command::selector::parse(
|
||||
&mut horizon_client::command::Reader::new(&text),
|
||||
&g.ui.registries,
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
g.select(&selector).into_iter().collect::<HashSet<_>>()
|
||||
};
|
||||
for (phase, tagged_a) in [false, true, false].into_iter().enumerate() {
|
||||
if phase != 0 {
|
||||
for (n, tagged) in [(1, tagged_a), (2, !tagged_a)] {
|
||||
assert!(game.run_command(&format!(
|
||||
"tag 0000010b-0000-0000-0000-{n:012} {} audit",
|
||||
if tagged { "add" } else { "remove" }
|
||||
)));
|
||||
}
|
||||
}
|
||||
assert_eq!(game.target_with_uuid([267, 0, 0, 1]), Some(a));
|
||||
assert_eq!(game.target_with_uuid([267, 0, 0, 2]), Some(b));
|
||||
assert_eq!(
|
||||
game.tags_of(a),
|
||||
if tagged_a {
|
||||
vec!["audit".to_owned()]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
game.tags_of(b),
|
||||
if tagged_a {
|
||||
vec![]
|
||||
} else {
|
||||
vec!["audit".to_owned()]
|
||||
}
|
||||
);
|
||||
let (empty, tagged) = if tagged_a { (b, a) } else { (a, b) };
|
||||
// Named-tag admissions pass before the old empty-tag assertion fails.
|
||||
assert_eq!(select(&mut game, "audit"), HashSet::from([tagged]));
|
||||
assert_eq!(select(&mut game, "!audit"), HashSet::from([empty]));
|
||||
assert_eq!(select(&mut game, ""), HashSet::from([empty]));
|
||||
assert_eq!(select(&mut game, "!"), HashSet::from([tagged]));
|
||||
}
|
||||
drop(game);
|
||||
std::fs::remove_dir_all(scratch).unwrap();
|
||||
}
|
||||
@@ -796,7 +796,7 @@ mod block_id_tests {
|
||||
/// their sprite layer, not their block. A layer takes the id of the block named like its sprite
|
||||
/// (`block/oak_leaves` for `oak_leaves`, and still/flow sprites for their fluid), else the id
|
||||
/// most blocks drawing it share (ties: the lowest); -1 when no block drawing it has one.
|
||||
/// y is 1 for the fluid sprites, -1 otherwise.
|
||||
/// y is 1 for the fluid sprites, 0 otherwise (`fluid_pairs`).
|
||||
pub(super) fn block_id_table(pack: &super::PackResources, ids: &BlockIds) -> Vec<i32> {
|
||||
let layers = pack
|
||||
.render
|
||||
@@ -804,13 +804,15 @@ pub(super) fn block_id_table(pack: &super::PackResources, ids: &BlockIds) -> Vec
|
||||
.values()
|
||||
.max()
|
||||
.map_or(0, |l| l + 1) as usize;
|
||||
let mut table = vec![-1; layers * 2];
|
||||
let sprite = |name: &str| pack.render.sprite_layers.get(name).copied();
|
||||
let fluids = ["water_still", "water_flow", "lava_still", "lava_flow"]
|
||||
.map(|f| sprite(&format!("minecraft:block/{f}")));
|
||||
let mut table = fluid_pairs(layers, fluids.into_iter().flatten());
|
||||
if ids.is_empty() {
|
||||
return table;
|
||||
}
|
||||
let registry = pack.assets.registry;
|
||||
let catalog = &pack.render.model_catalog;
|
||||
let sprite = |name: &str| pack.render.sprite_layers.get(name).copied();
|
||||
// (layer, id) -> (a block named like the sprite, the blocks).
|
||||
let mut votes = BlockIdVotes::default();
|
||||
for i in 0..registry.len() {
|
||||
@@ -849,10 +851,17 @@ pub(super) fn block_id_table(pack: &super::PackResources, ids: &BlockIds) -> Vec
|
||||
}
|
||||
}
|
||||
votes.write(&mut table);
|
||||
for f in ["water_still", "water_flow", "lava_still", "lava_flow"] {
|
||||
if let Some(l) = sprite(&format!("minecraft:block/{f}"))
|
||||
&& let Some(slot) = table.get_mut(l as usize * 2 + 1)
|
||||
{
|
||||
table
|
||||
}
|
||||
|
||||
/// `layers` (id, fluid) pairs before the ids are known: id -1, and `mc_Entity.y` 1 for the
|
||||
/// `fluids` layers, 0 for the others. Iris 1.11.7, with probe packs of ours (#10452), gave
|
||||
/// terrain y 1 on water and lava, and 0 on every other block, whether `block.properties`
|
||||
/// maps it or not.
|
||||
fn fluid_pairs(layers: usize, fluids: impl IntoIterator<Item = u32>) -> Vec<i32> {
|
||||
let mut table: Vec<i32> = std::iter::repeat_n([-1, 0], layers).flatten().collect();
|
||||
for l in fluids {
|
||||
if let Some(slot) = table.get_mut(l as usize * 2 + 1) {
|
||||
*slot = 1;
|
||||
}
|
||||
}
|
||||
@@ -950,6 +959,20 @@ fn terrain_table(ids: &[i32], emission: &[i32]) -> Vec<i32> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fluid_pair_tests {
|
||||
use super::fluid_pairs;
|
||||
|
||||
/// Iris 1.11.7's `mc_Entity.y` in terrain (probe packs of ours, #10452): 1 on water and
|
||||
/// lava, 0 on every other block; the ids start at -1 (no id) until `block.properties` gives
|
||||
/// them.
|
||||
#[test]
|
||||
fn terrain_reads_y_1_on_fluids_and_0_elsewhere() {
|
||||
assert_eq!(fluid_pairs(4, [1, 3]), [-1, 0, -1, 1, -1, 0, -1, 1]);
|
||||
assert_eq!(fluid_pairs(2, []), [-1, 0, -1, 0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod emission_tests {
|
||||
use super::{layer_lights, terrain_table};
|
||||
|
||||
@@ -45,6 +45,10 @@ pub(super) struct StartupMenu {
|
||||
key_synthetic: bool,
|
||||
/// The screen the keyboard's focus is on (`key_screen`).
|
||||
key_screen: u64,
|
||||
/// The screen (`Self::key_screen`) the title's click focus belongs to:
|
||||
/// another one in front drops it, as `Gui.setScreen` drops the focus of
|
||||
/// the screen it leaves (`Screen.clearFocus`).
|
||||
focus_screen: u64,
|
||||
pub pending: Option<PendingWorldEntry>,
|
||||
pub reload_packs: bool,
|
||||
/// Browse mods' worker (`mod_browser`).
|
||||
@@ -1195,6 +1199,16 @@ impl Loading {
|
||||
horizon_client::ui_theme::draw_boot(&self.boot, batch, &logs);
|
||||
return;
|
||||
};
|
||||
// `Gui.setScreen` clears the focus of the screen it leaves
|
||||
// (`Screen.clearFocus`): the title behind the options, and the one
|
||||
// that comes back, start with no button highlighted.
|
||||
let screen = self.menu.key_screen();
|
||||
if super::pad_menu::another_screen_is_front(&mut self.menu.focus_screen, screen) {
|
||||
self.menu.options.clear_click_focus();
|
||||
if let Some(title) = self.menu.title.as_mut() {
|
||||
title.focused_button = None;
|
||||
}
|
||||
}
|
||||
if self.menu.pending.is_some() {
|
||||
horizon_client::level_loading::draw_loading_overlay(
|
||||
batch,
|
||||
|
||||
@@ -2732,6 +2732,11 @@ impl Game<'_> {
|
||||
y: f32,
|
||||
after_name: Vec<(horizon_client::text::Text, u32)>,
|
||||
) {
|
||||
let mut cancel = false;
|
||||
self.native_tooltip_render_pre(stack, batch, x, y, &mut cancel);
|
||||
if cancel {
|
||||
return;
|
||||
}
|
||||
let mut lines = self.stack_tooltip_lines(stack, false, creative);
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
|
||||
@@ -51,6 +51,7 @@ impl Game<'_> {
|
||||
}
|
||||
// BaseContainerBlockEntity.canOpen: a spectator opens no container that still
|
||||
// has a loot table to unpack (it clears while it rolls, on first open).
|
||||
// ServerPlayer.openMenu sends the refused spectator a red action bar.
|
||||
if self
|
||||
.world
|
||||
.chunks
|
||||
@@ -58,6 +59,10 @@ impl Game<'_> {
|
||||
.and_then(|c| c.saved.as_ref())
|
||||
.is_some_and(|s| s.rolled_loot.iter().any(|r| r.pos == key))
|
||||
{
|
||||
self.send_overlay_message(
|
||||
Text::translate("container.spectatorCantOpen", &[])
|
||||
.with_color(horizon_client::text::RED),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(entity) = BlockEntity::for_block(&name) else {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! (flint and steel / fire charge).
|
||||
use super::{Game, entity::Entity};
|
||||
use horizon_client::entity_rules::EntityRules;
|
||||
use horizon_client::lan::playerdata::uuid_ints;
|
||||
use horizon_client::{
|
||||
Result, collisions::SceneCollisions, items::ItemStack, mesher::Vertex, mobs::Who, text::Text,
|
||||
};
|
||||
@@ -282,7 +283,13 @@ impl Game<'_> {
|
||||
t.position.z,
|
||||
];
|
||||
let bounds = t.bounds();
|
||||
let (owner, guest, power) = (t.owner, t.guest, t.power);
|
||||
let (id, owner, guest, power) = (t.id, t.owner, t.guest, t.power);
|
||||
let (owner, guest) = match owner {
|
||||
Some(_) => (owner, guest),
|
||||
None => self
|
||||
.loaded_tnt_owner(id)
|
||||
.map_or((None, None), |(w, g)| (Some(w), g)),
|
||||
};
|
||||
let in_water = self.bounds_in_water(bounds);
|
||||
self.tnts.remove(i);
|
||||
// PrimedTnt.tick: with `tnt_explodes` off it goes away without exploding.
|
||||
@@ -313,6 +320,57 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The saved compound of primed TNT `i`: chunk saves, `/data get`, portals.
|
||||
pub(super) fn tnt_nbt(&mut self, i: usize) -> horizon_world::nbt::Tag {
|
||||
let id = self.tnts[i].id;
|
||||
let uuid = self.uuid_for(id);
|
||||
let owner = self.tnt_owner_uuid(self.tnts[i].owner, self.tnts[i].guest);
|
||||
let t = &self.tnts[i];
|
||||
horizon_client::entity_nbt::tnt_to_nbt(
|
||||
t.position,
|
||||
t.velocity,
|
||||
t.on_ground,
|
||||
t.fuse,
|
||||
t.power,
|
||||
t.block_state_nbt(self.pack.assets.registry),
|
||||
t.rotation,
|
||||
uuid,
|
||||
owner,
|
||||
self.persist.raw.get(&id),
|
||||
)
|
||||
}
|
||||
/// `EntityReference.of(owner)`: the UUID of the player or mob that lit it.
|
||||
fn tnt_owner_uuid(&mut self, owner: Option<Who>, guest: Option<u32>) -> Option<[i32; 4]> {
|
||||
match owner? {
|
||||
Who::Player => match guest {
|
||||
Some(conn) => self
|
||||
.lan
|
||||
.as_ref()?
|
||||
.hub
|
||||
.guest(conn)
|
||||
.map(|g| uuid_ints(g.uuid)),
|
||||
None => self.level.player_uuid,
|
||||
},
|
||||
Who::Mob(id) if self.mobs.iter().any(|m| m.id == id) => Some(self.uuid_for(id)),
|
||||
Who::Mob(id) => self.persist.uuids.get(&id).copied(),
|
||||
}
|
||||
}
|
||||
/// `PrimedTnt.getOwner` for TNT loaded with an `owner`: looked up when
|
||||
/// asked, so a player who joined or a mob loaded since is found.
|
||||
fn loaded_tnt_owner(&self, id: u32) -> Option<(Who, Option<u32>)> {
|
||||
let uuid = horizon_client::entity_nbt::tnt_owner(self.persist.raw.get(&id)?)?;
|
||||
let guest = self.lan.as_ref().and_then(|host| {
|
||||
let mut guests = host.hub.guests.iter();
|
||||
guests.find(|g| uuid_ints(g.uuid) == uuid).map(|g| g.conn)
|
||||
});
|
||||
if guest.is_some() {
|
||||
return Some((Who::Player, guest));
|
||||
}
|
||||
if self.level.player_uuid == Some(uuid) {
|
||||
return Some((Who::Player, None));
|
||||
}
|
||||
self.mob_by_uuid(uuid).map(|m| (Who::Mob(m), None))
|
||||
}
|
||||
/// `TntBlock.useItemOn`: flint and steel or a fire charge primes TNT.
|
||||
pub(super) fn ignite_tnt(&mut self, stack: &ItemStack) -> Result<bool> {
|
||||
let item = &*stack.item;
|
||||
@@ -594,6 +652,7 @@ mod tests {
|
||||
tnt.block_state_nbt(®istry),
|
||||
[0.0, 0.0],
|
||||
[221, 0, 0, 1],
|
||||
None,
|
||||
Some(&raw),
|
||||
);
|
||||
assert_eq!(saved.get("block_state"), Some(&expected));
|
||||
@@ -1021,5 +1080,125 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A server's game on the prepared resources with the chunk at the origin
|
||||
/// loaded, as `native_machines`' tests make one; None without them.
|
||||
fn server_game() -> Option<Game<'static>> {
|
||||
use crate::game::*;
|
||||
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
});
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return None;
|
||||
}
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.options.dedicated = Some(Vec::new());
|
||||
game.world.chunks.insert(
|
||||
(0, 0),
|
||||
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
|
||||
);
|
||||
game.streaming.forced.insert((0, 0));
|
||||
Some(game)
|
||||
}
|
||||
|
||||
/// #10063: TNT lit between level ticks (flint and steel, a command) has its
|
||||
/// first tick in the next level tick, so its fuse reads 79 after it, and it
|
||||
/// explodes on the 80th (`PrimedTnt.tick`). Horizon held every TNT that
|
||||
/// joined during a tick back one more: the late pass compared its join
|
||||
/// against an `announced` already raised past it.
|
||||
#[test]
|
||||
fn lit_tnt_ticks_on_the_next_level_tick_and_explodes_on_the_80th() {
|
||||
let Some(mut game) = server_game() else {
|
||||
return;
|
||||
};
|
||||
game.add_primed_tnt(BlockPos::new(8, 64, 8), 0.0, 80, None, None);
|
||||
// Hang it in the empty chunk, so it neither falls out nor lands.
|
||||
game.tnts[0].no_gravity = true;
|
||||
game.tnts[0].velocity = Vec3::ZERO;
|
||||
game.tick_world(true).unwrap();
|
||||
assert_eq!(game.tnts.len(), 1);
|
||||
assert_eq!(
|
||||
game.tnts[0].fuse, 79,
|
||||
"its first tick is the next level tick"
|
||||
);
|
||||
let mut exploded_on = None;
|
||||
for tick in 2..=90 {
|
||||
game.tick_world(true).unwrap();
|
||||
if game.tnts.is_empty() {
|
||||
exploded_on = Some(tick);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(exploded_on, Some(80), "explodes on the 80th tick");
|
||||
}
|
||||
/// TNT a blast primes joins while the entities tick (vanilla's
|
||||
/// `EntityTickList` loop) and waits for the next tick. The fix for #10063
|
||||
/// must not tick it in the tick it joined.
|
||||
#[test]
|
||||
fn tnt_primed_by_a_blast_waits_for_the_next_tick() {
|
||||
let Some(mut game) = server_game() else {
|
||||
return;
|
||||
};
|
||||
let block = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:tnt")
|
||||
.unwrap();
|
||||
game.world.set(9, 64, 8, block).unwrap();
|
||||
game.add_primed_tnt(BlockPos::new(8, 64, 8), 0.0, 1, None, None);
|
||||
game.tnts[0].no_gravity = true;
|
||||
game.tnts[0].velocity = Vec3::ZERO;
|
||||
let first = game.tnts[0].id;
|
||||
for _ in 0..3 {
|
||||
game.tick_world(true).unwrap();
|
||||
if game.tnts.iter().all(|t| t.id != first) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
game.tnts.iter().all(|t| t.id != first),
|
||||
"the first TNT went off"
|
||||
);
|
||||
assert_eq!(game.tnts.len(), 1, "its blast primed the TNT block");
|
||||
let primed = &game.tnts[0];
|
||||
assert_eq!(
|
||||
primed.position, primed.previous,
|
||||
"not ticked in the tick the blast primed it"
|
||||
);
|
||||
game.tick_world(true).unwrap();
|
||||
let primed = &game.tnts[0];
|
||||
assert_ne!(primed.position, primed.previous, "ticked on the next tick");
|
||||
}
|
||||
}
|
||||
horizon_client::impl_joined!(PrimedTnt);
|
||||
@@ -56,6 +56,37 @@ pub static HURT_SERVER: OnceLock<fn(Hurting<'_>, &mut f32) -> bool> = OnceLock::
|
||||
pub fn hurt_server_canceled(hurting: Hurting<'_>, amount: &mut f32) -> bool {
|
||||
HURT_SERVER.get().is_some_and(|hook| hook(hurting, amount))
|
||||
}
|
||||
/// The game's `LivingDamageEvent.Pre` hook (`native_living_damage_pre`), set
|
||||
/// once by the game: `LivingEntity.actuallyHurt` once armour, enchantments,
|
||||
/// effects and absorption took their part, before the health. It may change
|
||||
/// the amount.
|
||||
pub static DAMAGE_PRE: OnceLock<fn(Hurting<'_>, &mut f32)> = OnceLock::new();
|
||||
/// The game's `LivingDamageEvent.Post` hook (`native_living_damage_post`), set
|
||||
/// once by the game: the end of `actuallyHurt`, with what came off the health.
|
||||
pub static DAMAGE_POST: OnceLock<fn(Hurting<'_>, f32)> = OnceLock::new();
|
||||
/// [`DAMAGE_PRE`] on what is about to come off the health (nothing while it is
|
||||
/// unset). An amount it made negative or NaN takes nothing, an infinite one
|
||||
/// `Float.MAX_VALUE`.
|
||||
pub fn damage_pre(hurting: Hurting<'_>, amount: &mut f32) {
|
||||
let Some(hook) = DAMAGE_PRE.get() else {
|
||||
return;
|
||||
};
|
||||
let before = amount.to_bits();
|
||||
hook(hurting, amount);
|
||||
if amount.to_bits() != before {
|
||||
*amount = if amount.is_nan() || *amount < 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
amount.min(f32::MAX)
|
||||
};
|
||||
}
|
||||
}
|
||||
/// [`DAMAGE_POST`] with what came off the health (nothing while it is unset).
|
||||
pub fn damage_post(hurting: Hurting<'_>, amount: f32) {
|
||||
if let Some(hook) = DAMAGE_POST.get() {
|
||||
hook(hurting, amount);
|
||||
}
|
||||
}
|
||||
/// A knockback's strength and direction, as `LivingEntity.knockback` has them
|
||||
/// before knockback resistance is applied. The entity is pushed away from
|
||||
/// the direction (`x`, `z`), which vanilla points at the source of the hit.
|
||||
@@ -679,6 +710,14 @@ impl horizon_world::blocking_rules::TypeMatchWorld for MatchedSets {
|
||||
}
|
||||
}
|
||||
impl Environment<'_> {
|
||||
/// The hit's `DamageSource`, for the native damage hooks.
|
||||
fn hurt_source(&self) -> HurtSource<'_> {
|
||||
HurtSource {
|
||||
damage_type: &self.source.definition().name,
|
||||
causing: self.causing,
|
||||
direct: 0,
|
||||
}
|
||||
}
|
||||
fn event(&mut self, event: HealthEvent) {
|
||||
self.events.push(MotionEvent::Health(event));
|
||||
}
|
||||
@@ -779,6 +818,14 @@ impl ServerDamageEnvironment<Attributes> for Environment<'_> {
|
||||
fn damage_event(&mut self) -> io::Result<()> {
|
||||
self.phase("entity-damage")
|
||||
}
|
||||
fn damage_pre(&mut self, damage: &mut f32) -> io::Result<()> {
|
||||
damage_pre(Hurting::Player(self.entity, self.hurt_source()), damage);
|
||||
Ok(())
|
||||
}
|
||||
fn damage_post(&mut self, damage: f32) -> io::Result<()> {
|
||||
damage_post(Hurting::Player(self.entity, self.hurt_source()), damage);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl CombatEnvironment<Attributes> for Environment<'_> {
|
||||
fn dead_flag(&self) -> bool {
|
||||
@@ -854,11 +901,7 @@ impl HurtWorld for Environment<'_> {
|
||||
Ok(self.status.fire_resistance)
|
||||
}
|
||||
fn hurt_start(&mut self, _: &mut State, damage: &mut f32) -> io::Result<bool> {
|
||||
let source = HurtSource {
|
||||
damage_type: &self.source.definition().name,
|
||||
causing: self.causing,
|
||||
direct: 0,
|
||||
};
|
||||
let source = self.hurt_source();
|
||||
let canceled = hurt_server_canceled(Hurting::Player(self.entity, source), damage);
|
||||
self.canceled |= canceled;
|
||||
Ok(canceled)
|
||||
@@ -1297,6 +1340,136 @@ mod tests {
|
||||
assert_eq!(seen(9013), [("minecraft:arrow 44".to_owned(), 5.0)]);
|
||||
}
|
||||
|
||||
/// What the `LivingDamageEvent` hooks of these tests were handed: the
|
||||
/// entity, Post (true) or Pre, and the amount.
|
||||
static TAKEN: std::sync::Mutex<Vec<(i32, bool, f32)>> = std::sync::Mutex::new(Vec::new());
|
||||
/// Sets the native `LivingDamageEvent.Pre` and `.Post` hooks of these
|
||||
/// tests (global: they stay set for the process), for entities 9201 to
|
||||
/// 9205 and 9211 to 9212 only: they record what each is handed, and Pre
|
||||
/// halves x2's amount and makes 9203's -5, 9204's NaN and 9205's infinite.
|
||||
fn set_damage_hooks() -> fn(i32) -> Vec<(bool, f32)> {
|
||||
use super::Hurting;
|
||||
fn entity(hurting: &Hurting<'_>) -> Option<i32> {
|
||||
let entity = match hurting {
|
||||
Hurting::Player(e, _) | Hurting::Dragon(e, _) | Hurting::ArmorStand(e, _) => *e,
|
||||
Hurting::Mob(mob, _) => mob.id as i32,
|
||||
};
|
||||
matches!(entity, 9201..=9205 | 9211..=9212).then_some(entity)
|
||||
}
|
||||
fn pre(hurting: Hurting<'_>, amount: &mut f32) {
|
||||
let Some(entity) = entity(&hurting) else {
|
||||
return;
|
||||
};
|
||||
TAKEN.lock().unwrap().push((entity, false, *amount));
|
||||
match entity {
|
||||
9202 | 9212 => *amount /= 2.0,
|
||||
9203 => *amount = -5.0,
|
||||
9204 => *amount = f32::NAN,
|
||||
9205 => *amount = f32::INFINITY,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn post(hurting: Hurting<'_>, amount: f32) {
|
||||
if let Some(entity) = entity(&hurting) {
|
||||
TAKEN.lock().unwrap().push((entity, true, amount));
|
||||
}
|
||||
}
|
||||
let installed = (
|
||||
*super::DAMAGE_PRE.get_or_init(|| pre),
|
||||
*super::DAMAGE_POST.get_or_init(|| post),
|
||||
);
|
||||
assert!(std::ptr::fn_addr_eq(
|
||||
installed.0,
|
||||
pre as fn(Hurting<'_>, &mut f32)
|
||||
));
|
||||
assert!(std::ptr::fn_addr_eq(
|
||||
installed.1,
|
||||
post as fn(Hurting<'_>, f32)
|
||||
));
|
||||
|entity| {
|
||||
let taken = TAKEN.lock().unwrap();
|
||||
taken
|
||||
.iter()
|
||||
.filter(|t| t.0 == entity)
|
||||
.map(|t| (t.1, t.2))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_native_damage_hooks_see_and_change_what_a_mobs_health_takes() {
|
||||
use crate::mobs::{Damage, DamageKind, Mob};
|
||||
let taken = set_damage_hooks();
|
||||
let mob = |id| {
|
||||
Mob::new(
|
||||
id,
|
||||
crate::mobs::sensor_scan_tests::kind(),
|
||||
Default::default(),
|
||||
0.0,
|
||||
)
|
||||
};
|
||||
let hit = Damage {
|
||||
kind: DamageKind::PlayerAttack,
|
||||
attacker: None,
|
||||
from: Some([3.0, 0.0, 0.0]),
|
||||
attacker_species: None,
|
||||
};
|
||||
let mut plain = mob(9201);
|
||||
assert!(plain.damage(2.0, hit));
|
||||
// Within the cooldown only what exceeds the last hit reaches the health.
|
||||
assert!(plain.damage(5.0, hit));
|
||||
assert_eq!(plain.health, 5.0);
|
||||
assert_eq!(
|
||||
taken(9201),
|
||||
[(false, 2.0), (true, 2.0), (false, 3.0), (true, 3.0)]
|
||||
);
|
||||
let mut halved = mob(9202);
|
||||
assert!(halved.damage(2.0, hit));
|
||||
assert_eq!(halved.health, 9.0);
|
||||
assert_eq!(taken(9202), [(false, 2.0), (true, 1.0)]);
|
||||
// A negative or NaN amount takes nothing and an infinite one
|
||||
// Float.MAX_VALUE; the hit lands all the same.
|
||||
let (mut negative, mut nan, mut infinite) = (mob(9203), mob(9204), mob(9205));
|
||||
assert!(negative.damage(2.0, hit) && nan.damage(2.0, hit) && infinite.damage(2.0, hit));
|
||||
assert_eq!(
|
||||
(negative.health, nan.health, negative.hurt_time),
|
||||
(10.0, 10.0, 10)
|
||||
);
|
||||
assert!(infinite.health <= 0.0);
|
||||
assert_eq!(taken(9204), [(false, 2.0), (true, 0.0)]);
|
||||
assert_eq!(taken(9205), [(false, 2.0), (true, f32::MAX)]);
|
||||
}
|
||||
|
||||
/// Needs the prepared data in `.local/horizon-client/26.3`; skipped
|
||||
/// without it.
|
||||
#[test]
|
||||
fn the_native_damage_hooks_see_and_change_what_a_players_health_takes() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let taken = set_damage_hooks();
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let resources = crate::motion::MotionResources::load(&root, &assets).unwrap();
|
||||
let (health, rules) = (&resources.health, super::HealthRules::default());
|
||||
let id = health.damage.id("minecraft:arrow").unwrap();
|
||||
let player = |entity| {
|
||||
let mut p = crate::motion::PlayerMotion::new(&resources, Default::default()).unwrap();
|
||||
p.entity_id = entity;
|
||||
p
|
||||
};
|
||||
let mut plain = player(9211);
|
||||
assert!(plain.hurt(health, rules, id, 5.0).unwrap());
|
||||
assert_eq!(plain.health.health(), 15.0);
|
||||
assert_eq!(taken(9211), [(false, 5.0), (true, 5.0)]);
|
||||
let mut halved = player(9212);
|
||||
assert!(halved.hurt(health, rules, id, 5.0).unwrap());
|
||||
assert_eq!(halved.health.health(), 17.5);
|
||||
assert_eq!(taken(9212), [(false, 5.0), (true, 2.5)]);
|
||||
}
|
||||
|
||||
/// What the knockback hook of these tests saw: the entity, the hit that
|
||||
/// causes it (a mob's damage kind) and the push it was handed.
|
||||
static PUSHES: std::sync::Mutex<Vec<(i32, String, super::Knock)>> =
|
||||
|
||||
@@ -164,6 +164,9 @@ pub enum FallEvent {
|
||||
Broadcast(u8),
|
||||
Write {
|
||||
position: BlockPos,
|
||||
/// The block it replaced, so a mod that cancels the change
|
||||
/// (`native_entity_change_block_v2`) can have it put back.
|
||||
from: BlockStateId,
|
||||
state: BlockStateId,
|
||||
flags: u32,
|
||||
},
|
||||
@@ -241,6 +244,7 @@ impl FallEvent {
|
||||
position,
|
||||
state,
|
||||
flags,
|
||||
..
|
||||
} => format!("write {} {} {}", p(position), state.0, flags),
|
||||
Self::Destroy {
|
||||
position, drops, ..
|
||||
@@ -587,9 +591,11 @@ impl BlockLandingWorld for MotionWorld<'_, '_, '_> {
|
||||
Ok(true)
|
||||
}
|
||||
fn set_block(&mut self, position: BlockPos, state: BlockStateId, flags: u32) -> Result<bool> {
|
||||
let from = self.scene.block(position.x, position.y, position.z);
|
||||
self.scene.set(position.x, position.y, position.z, state)?;
|
||||
self.fall_event(FallEvent::Write {
|
||||
position,
|
||||
from,
|
||||
state,
|
||||
flags,
|
||||
});
|
||||
|
||||
@@ -1768,6 +1768,14 @@ impl Menu {
|
||||
self.containers[RESULT][0] = result;
|
||||
}
|
||||
|
||||
/// A station's take sound, gated by its menu's `lastSoundTime`: once per
|
||||
/// tick, so a shift-click that takes many results plays it once.
|
||||
fn take_sound(&mut self, sound: &'static str) {
|
||||
if !self.sounds.contains(&sound) {
|
||||
self.sounds.push(sound);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ResultSlot.onTake` and the other `Slot.onTake` overrides.
|
||||
fn on_take(&mut self, env: &Env, s: usize, taken: &ItemStack) {
|
||||
if self.slots[s].role == Role::Potion {
|
||||
@@ -1796,7 +1804,7 @@ impl Menu {
|
||||
if self.remove(env, 0, 1).is_some() {
|
||||
self.stone_setup_result(env, self.data[0]);
|
||||
}
|
||||
self.sounds.push("minecraft:ui.stonecutter.take_result");
|
||||
self.take_sound("minecraft:ui.stonecutter.take_result");
|
||||
return;
|
||||
}
|
||||
if self.slots[s].role == Role::GrindResult {
|
||||
@@ -1851,8 +1859,7 @@ impl Menu {
|
||||
// onCraftedBy is the caller's (map_crafted).
|
||||
self.remove(env, 0, 1);
|
||||
self.remove(env, 1, 1);
|
||||
self.sounds
|
||||
.push("minecraft:ui.cartography_table.take_result");
|
||||
self.take_sound("minecraft:ui.cartography_table.take_result");
|
||||
self.slot_changed(env, s);
|
||||
return;
|
||||
}
|
||||
@@ -1863,7 +1870,7 @@ impl Menu {
|
||||
if self.containers[MAIN][0].is_none() || self.containers[MAIN][1].is_none() {
|
||||
self.data[0] = -1;
|
||||
}
|
||||
self.sounds.push("minecraft:ui.loom.take_result");
|
||||
self.take_sound("minecraft:ui.loom.take_result");
|
||||
return;
|
||||
}
|
||||
if matches!(self.kind, Kind::CrafterBlock) && self.slots[s].container == MAIN {
|
||||
@@ -3467,6 +3474,34 @@ pub(crate) mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// `StonecutterMenu`'s `lastSoundTime`: a shift-click crafting a whole
|
||||
/// stack plays the take sound once, not once per item.
|
||||
#[test]
|
||||
fn a_stonecutter_shift_click_plays_the_take_sound_once() {
|
||||
with_menu(Kind::Stonecutter, |env, menu| {
|
||||
menu.creative = false;
|
||||
menu.set_slot(env, 0, stack("granite", 60));
|
||||
let polished = menu
|
||||
.stone
|
||||
.recipes
|
||||
.iter()
|
||||
.position(|&r| {
|
||||
&*env.station_recipes.stonecutting[r].result.item
|
||||
== "minecraft:polished_granite"
|
||||
})
|
||||
.expect("granite cuts into polished granite");
|
||||
assert!(menu.button(env, polished as i32));
|
||||
menu.click(env, 1, 0, Input::QuickMove).unwrap();
|
||||
assert_eq!(menu.slot(0), None);
|
||||
let made: u32 = (2..menu.slot_count())
|
||||
.filter_map(|i| menu.slot(i))
|
||||
.map(|s| s.count)
|
||||
.sum();
|
||||
assert_eq!(made, 60);
|
||||
assert_eq!(menu.sounds, ["minecraft:ui.stonecutter.take_result"]);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_move_landing_routes_plain_containers() {
|
||||
for (kind, size) in [
|
||||
|
||||
@@ -3326,6 +3326,25 @@ impl Mob {
|
||||
source: Damage,
|
||||
armor_effectiveness: Option<&mut dyn FnMut(f32) -> f32>,
|
||||
player_uuid: Option<[i32; 4]>,
|
||||
) -> bool {
|
||||
// Enderman.hurtServer: past `isInvulnerableTo`, the teleport draw
|
||||
// follows `super.hurtServer` whatever it answered (a hurt cooldown, a
|
||||
// dead enderman), its hurt sound's pitch included.
|
||||
let enderman = self.species() == Species::Enderman
|
||||
&& !self.removed
|
||||
&& !(self.invulnerable_flag && !source.kind.bypasses_invulnerability());
|
||||
let hurt = self.hurt_server(amount, source, armor_effectiveness, player_uuid);
|
||||
if enderman {
|
||||
enderman::after_hurt(self, &source);
|
||||
}
|
||||
hurt
|
||||
}
|
||||
fn hurt_server(
|
||||
&mut self,
|
||||
amount: f32,
|
||||
source: Damage,
|
||||
armor_effectiveness: Option<&mut dyn FnMut(f32) -> f32>,
|
||||
player_uuid: Option<[i32; 4]>,
|
||||
) -> bool {
|
||||
axolotl::before_damage(self, amount, &source);
|
||||
raider::note_hurt(self);
|
||||
@@ -3457,6 +3476,8 @@ impl Mob {
|
||||
// Wolf.actuallyHurt: wolf armor takes the damage instead of the wolf.
|
||||
let absorbed = self.wolf_armor_absorbs(&source);
|
||||
let mut knock = true;
|
||||
// What came off the health, for `LivingDamageEvent.Post`.
|
||||
let mut taken = 0.0;
|
||||
let dealt = if self.invulnerable as f32 > 10.0 {
|
||||
if amount <= self.last_hurt {
|
||||
return false;
|
||||
@@ -3465,8 +3486,8 @@ impl Mob {
|
||||
if absorbed {
|
||||
self.hurt_wolf_armor(dealt);
|
||||
} else {
|
||||
let dealt = self.soak_hit(dealt, &source);
|
||||
self.health -= dealt;
|
||||
let through = self.soak_hit(dealt, &source);
|
||||
taken = self.hurt_health(through, &source);
|
||||
}
|
||||
self.last_hurt = amount;
|
||||
knock = false;
|
||||
@@ -3478,7 +3499,7 @@ impl Mob {
|
||||
self.hurt_wolf_armor(amount);
|
||||
} else {
|
||||
let through = self.soak_hit(amount, &source);
|
||||
self.health -= through;
|
||||
taken = self.hurt_health(through, &source);
|
||||
}
|
||||
self.hurt_time = 10;
|
||||
amount
|
||||
@@ -3489,6 +3510,11 @@ impl Mob {
|
||||
if !absorbed && dealt != 0.0 {
|
||||
self.record_damage(source, dealt);
|
||||
}
|
||||
// The end of `actuallyHurt`: a native mod's `LivingDamageEvent.Post`.
|
||||
// `Wolf.actuallyHurt` never gets there when the armour takes the hit.
|
||||
if !absorbed {
|
||||
crate::health::damage_post(crate::health::Hurting::Mob(self, &source), taken);
|
||||
}
|
||||
// LivingEntity.hurtArmor: `Horse` is the only mob that overrides it, and
|
||||
// it hands `doHurtEquipment` the body slot alone. A wolf's armour has
|
||||
// its own rule (`Wolf.actuallyHurt`, `hurt_wolf_armor`) and a player's
|
||||
@@ -3587,12 +3613,24 @@ impl Mob {
|
||||
if self.species() == Species::GlowSquid && self.ai.last_hurt_by.is_some() {
|
||||
aquatic::hurt_server(self);
|
||||
}
|
||||
true
|
||||
// Squid.hurtServer (also GlowSquid's result): parent health damage has
|
||||
// happened, but without a last living attacker the subclass returns false.
|
||||
!matches!(self.species(), Species::Squid | Species::GlowSquid)
|
||||
|| self.ai.last_hurt_by.is_some()
|
||||
}
|
||||
/// `damage` by a source whose direct entity is `direct` (an entity type id), which `source`
|
||||
/// cannot carry: `DamageSources.indirectMagic(potion or cloud, owner)`.
|
||||
pub fn damage_by(&mut self, amount: f32, source: Damage, direct: &'static str) -> bool {
|
||||
// Enderman.hurtServer: only a thrown potion skips the teleport draw;
|
||||
// a lingering cloud's `indirect_magic` goes through it.
|
||||
let cloud = self.species() == Species::Enderman
|
||||
&& direct == "minecraft:area_effect_cloud"
|
||||
&& !self.removed
|
||||
&& !(self.invulnerable_flag && !source.kind.bypasses_invulnerability());
|
||||
let hurt = self.damage(amount, source);
|
||||
if cloud {
|
||||
enderman::teleport_chance(self, &source);
|
||||
}
|
||||
if hurt {
|
||||
self.last_direct = Some(direct);
|
||||
}
|
||||
@@ -3841,6 +3879,15 @@ impl Mob {
|
||||
|| present_ambient != self.extra.ambient_effects.contains(&effect)
|
||||
|| was_visible != visible)
|
||||
}
|
||||
/// `LivingEntity.actuallyHurt`'s last step: what got past absorption goes to
|
||||
/// a native mod's `LivingDamageEvent.Pre`, which may change it, then comes
|
||||
/// off the health. What came off.
|
||||
fn hurt_health(&mut self, through: f32, source: &Damage) -> f32 {
|
||||
let mut through = through;
|
||||
crate::health::damage_pre(crate::health::Hurting::Mob(self, source), &mut through);
|
||||
self.health -= through;
|
||||
through
|
||||
}
|
||||
/// [`Mob::soak_absorption`] for a hit from `source`: `LivingEntity.actuallyHurt`
|
||||
/// credits a `ServerPlayer` attacker with the absorbed damage in tenths.
|
||||
fn soak_hit(&mut self, damage: f32, source: &Damage) -> f32 {
|
||||
@@ -5542,6 +5589,70 @@ mod effect_refusal_tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn check_squid_damage_result(id: &str) {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let types = MobTypes::load_for_server(&root).unwrap();
|
||||
let mut attacker = mob(&types, "minecraft:cow");
|
||||
attacker.id = 2;
|
||||
attacker.position = Vec3::new(4.0, 64.0, 4.0);
|
||||
assert!(attacker.alive());
|
||||
let by_cow = Damage {
|
||||
kind: DamageKind::Generic,
|
||||
..Damage::mob(attacker.id, [4.0, 64.0, 4.0]).with_species(Species::Cow)
|
||||
};
|
||||
// An unrelated parent's generic acceptance remains true.
|
||||
let mut cow = mob(&types, "minecraft:cow");
|
||||
assert!(cow.damage(1.0, Damage::of(DamageKind::Generic)));
|
||||
assert_eq!(cow.health, 9.0);
|
||||
// A parent's existing rejection never reaches the subclass aftermath.
|
||||
let mut protected = mob(&types, id);
|
||||
protected.invulnerable_flag = true;
|
||||
assert!(!protected.damage(1.0, by_cow));
|
||||
assert_eq!(protected.health, 10.0);
|
||||
if protected.species() == Species::GlowSquid {
|
||||
assert_eq!(aquatic::dark_ticks(&protected), 0);
|
||||
}
|
||||
for no_ai in [false, true] {
|
||||
for attributed in [true, false] {
|
||||
let mut victim = mob(&types, id);
|
||||
victim.no_ai = no_ai;
|
||||
assert_eq!(victim.health, 10.0);
|
||||
assert!(victim.ai.last_hurt_by.is_none());
|
||||
let source = if attributed {
|
||||
by_cow
|
||||
} else {
|
||||
Damage::of(DamageKind::Generic)
|
||||
};
|
||||
let accepted = victim.damage(1.0, source);
|
||||
// Check the parent's state first: false must not roll back damage.
|
||||
assert_eq!(victim.health, 9.0);
|
||||
assert_eq!(
|
||||
victim.ai.last_hurt_by,
|
||||
attributed.then_some(Who::Mob(attacker.id))
|
||||
);
|
||||
if victim.species() == Species::GlowSquid {
|
||||
assert_eq!(
|
||||
aquatic::dark_ticks(&victim),
|
||||
if attributed { 100 } else { 0 }
|
||||
);
|
||||
}
|
||||
assert_eq!(accepted, attributed, "{id} NoAI={no_ai}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared entity types in HORIZON_TEST_RESOURCES"]
|
||||
fn glow_squid_damage_result_keeps_parent_health_side_effect() {
|
||||
check_squid_damage_result("minecraft:glow_squid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared entity types in HORIZON_TEST_RESOURCES"]
|
||||
fn squid_damage_result_keeps_parent_health_side_effect() {
|
||||
check_squid_damage_result("minecraft:squid");
|
||||
}
|
||||
|
||||
/// Squid.hurtServer reports a hit only when a living entity dealt it, so
|
||||
/// environmental damage leaves a glow squid glowing; a mob's blow dims it.
|
||||
#[test]
|
||||
@@ -5555,11 +5666,13 @@ mod effect_refusal_tests {
|
||||
let types = MobTypes::load_for_server(&root).unwrap();
|
||||
let mut burned = mob(&types, "minecraft:glow_squid");
|
||||
burned.health = 10.0;
|
||||
assert!(burned.damage(1.0, Damage::of(DamageKind::Campfire)));
|
||||
assert!(!burned.damage(1.0, Damage::of(DamageKind::Campfire)));
|
||||
assert_eq!(burned.health, 9.0);
|
||||
assert_eq!(aquatic::dark_ticks(&burned), 0);
|
||||
let mut struck = mob(&types, "minecraft:glow_squid");
|
||||
struck.health = 10.0;
|
||||
assert!(struck.damage(1.0, Damage::mob(2, [0.0, 64.0, 0.0])));
|
||||
assert_eq!(struck.health, 9.0);
|
||||
assert_eq!(aquatic::dark_ticks(&struck), 100);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,6 +711,10 @@ pub fn tick(
|
||||
if horse {
|
||||
super::equine::ai_step(m, w, events);
|
||||
}
|
||||
// LivingEntity.aiStep ends in the water's hurt with or without AI;
|
||||
// a NoAI enderman's hurts still teleport it (Enderman.hurtServer).
|
||||
hurt_by_water(m, w, events);
|
||||
enderman_hurt_teleports(m, w, events);
|
||||
// Ravager.aiStep runs outside serverAiStep: NoAI still advances
|
||||
// attack/stun/roar, including the stun-to-roar transition.
|
||||
if m.species() == Species::Ravager {
|
||||
@@ -720,6 +724,9 @@ pub fn tick(
|
||||
// Pufferfish.aiStep's mob contacts run even when serverAiStep is skipped.
|
||||
aquatic::sting_touching_mobs(m, w, events);
|
||||
}
|
||||
if m.species() == Species::GlowSquid {
|
||||
aquatic::glow_squid_dark_tick(m);
|
||||
}
|
||||
} else {
|
||||
ai_step(m, w, level_random, events);
|
||||
}
|
||||
@@ -766,6 +773,10 @@ pub fn tick(
|
||||
super::panda::tick(m, w, events);
|
||||
}
|
||||
aquatic::school_tick(m, w, level_random);
|
||||
// Dolphin.tick: after super.tick, NoAI restores the species maximum.
|
||||
if m.no_ai && m.species() == Species::Dolphin {
|
||||
m.air = m.max_air_supply();
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `LivingEntity.aiStep` -> `Entity.applyEffectsFromBlocks` for the blocks
|
||||
@@ -840,6 +851,17 @@ fn effects_from_blocks(
|
||||
},
|
||||
));
|
||||
visited.push(p);
|
||||
// PowderSnowBlock.entityInside: a burning mob destroys the
|
||||
// block (with `mob_griefing`), then EXTINGUISH puts it out.
|
||||
if w.block_name(x, y, z) == "minecraft:powder_snow" {
|
||||
if m.is_on_fire() && w.mob_griefing {
|
||||
events.push(MobEvent::BreakBlock {
|
||||
pos: p,
|
||||
drop: false,
|
||||
});
|
||||
}
|
||||
m.extinguish_fire();
|
||||
}
|
||||
match w.block_name(x, y, z) {
|
||||
"minecraft:cactus" => {
|
||||
m.damage(1.0, Damage::of(DamageKind::Cactus));
|
||||
@@ -1230,10 +1252,6 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.kind.flags.water_sensitive && (m.fluids.in_water || in_rain(m, w)) {
|
||||
m.damage(1.0, Damage::of(DamageKind::Drown));
|
||||
}
|
||||
freeze_tick(m, w);
|
||||
}
|
||||
if m.species() == Species::Illusioner {
|
||||
@@ -1255,6 +1273,8 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
// lava ignites to the full 160 / 300 ticks and its hit lands after the
|
||||
// invulnerable time of that tick has already dropped.
|
||||
block_contact_damage(m, w, fire);
|
||||
// Enderman.hurtServer teleports inside the hurt (fire, lava, a block).
|
||||
enderman_hurt_teleports(m, w, events);
|
||||
if !m.alive() {
|
||||
m.death_time += 1;
|
||||
}
|
||||
@@ -1619,10 +1639,13 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
|
||||
} else if !(m.species() == Species::Mannequin && m.extra.immovable) {
|
||||
// Mannequin.isEffectiveAi: an immovable one never travels.
|
||||
m.travel(w, input, events);
|
||||
// A fall's hurt (checkFallDamage) teleports at once.
|
||||
enderman_hurt_teleports(m, w, events);
|
||||
}
|
||||
// LivingEntity.aiStep: applyEffectsFromBlocks follows travel.
|
||||
m.apply_effects_from_blocks(w, events);
|
||||
push_entities(m, w, events);
|
||||
hurt_by_water(m, w, events);
|
||||
// Spider.tick: climbing while pressed against a wall.
|
||||
if m.kind.flags.climber {
|
||||
m.climbing = m.horizontal_collision;
|
||||
@@ -1632,6 +1655,24 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
|
||||
burn_undead(m, w, events);
|
||||
species_ai_step(m, w, level_random, events);
|
||||
}
|
||||
/// The end of `LivingEntity.aiStep`: a mob that water hurts (an enderman, a
|
||||
/// blaze, a snow golem) takes 1 drowning damage in water or rain
|
||||
/// (`isSensitiveToWater`, `isInWaterOrRain`). An enderman's teleport is part of
|
||||
/// that hurt (`Enderman.hurtServer`), so it happens now, not next tick.
|
||||
fn hurt_by_water(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
if m.kind.flags.water_sensitive && (m.fluids.in_water || in_rain(m, w)) {
|
||||
m.damage(1.0, Damage::of(DamageKind::Drown));
|
||||
enderman_hurt_teleports(m, w, events);
|
||||
}
|
||||
}
|
||||
/// The teleport `Enderman.hurtServer` makes inside a hurt, after the hurt's
|
||||
/// sound; with or without AI.
|
||||
fn enderman_hurt_teleports(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
if m.species() == Species::Enderman && m.extra.counter2 > 0 {
|
||||
events.append(&mut m.outbox);
|
||||
super::enderman::run_pending_teleports(m, w, events);
|
||||
}
|
||||
}
|
||||
impl Mob {
|
||||
/// LivingEntity.isPushable (and the push it takes from `Entity.push(Entity)`):
|
||||
/// bats and shulkers are never moved by others, nor is a mob on a climbable
|
||||
@@ -1996,7 +2037,9 @@ fn species_ai_step(
|
||||
super::cube::ai_step(m, w, events);
|
||||
super::sulfur_cube::ai_step(m, w, events);
|
||||
}
|
||||
Species::Nautilus | Species::ZombieNautilus => super::nautilus::ai_step(m, w, events),
|
||||
Species::Nautilus | Species::ZombieNautilus => {
|
||||
super::nautilus::ai_step(m, w, level_random, events)
|
||||
}
|
||||
Species::Chicken => farm::chicken_ai_step(m, w, events),
|
||||
Species::Parrot => super::parrot::calculate_flapping(m),
|
||||
Species::Armadillo => super::armadillo::tick(m),
|
||||
@@ -2551,7 +2594,6 @@ pub fn accepts_damage(m: &mut Mob, source: &Damage, _amount: f32) -> bool {
|
||||
pub fn after_hurt(m: &mut Mob, source: &Damage) {
|
||||
m.ai.no_action_time = 0;
|
||||
match m.species() {
|
||||
Species::Enderman => super::enderman::after_hurt(m, source),
|
||||
Species::Armadillo => super::armadillo::after_hurt(m, source),
|
||||
Species::Shulker => super::shulker::after_hurt(m),
|
||||
Species::Warden => super::warden::hurt(m, source),
|
||||
@@ -5055,3 +5097,74 @@ mod flight_effect_fall_tests {
|
||||
assert!(ran, "prepared resources must run the integration fixture");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dolphin_noai_air_tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn check(water: bool, no_ai: bool, initial: i32, expected: [i32; 2]) {
|
||||
let build = |world: &mut crate::world::World,
|
||||
registry: &horizon_world::registry::BlockRegistry| {
|
||||
world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(crate::chunk::Chunk::empty(0, 0)));
|
||||
if water {
|
||||
let water = registry.default_state("minecraft:water").unwrap();
|
||||
for x in 0..=4 {
|
||||
for z in 0..=4 {
|
||||
for y in 0..=8 {
|
||||
world.set(x, y, z, water).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let ran = crate::mobs::world::with_built_test_world(&[], build, |w, types| {
|
||||
let kind = Arc::new(types.types["minecraft:dolphin"].clone());
|
||||
let mut mob = Mob::new(1, kind, Vec3::new(2.5, 3.0, 2.5), 0.0);
|
||||
mob.no_ai = no_ai;
|
||||
mob.no_gravity = true;
|
||||
mob.persistent = true;
|
||||
mob.attrs.speed = 0.0;
|
||||
mob.air = initial;
|
||||
let mut random = LegacyRandom::new(0);
|
||||
let mut events = Vec::new();
|
||||
assert_eq!(mob.health, 10.0);
|
||||
assert_eq!(mob.max_air_supply(), 4800);
|
||||
for (index, expected) in expected.into_iter().enumerate() {
|
||||
assert!(tick(&mut mob, w, &mut random, &mut events));
|
||||
assert_eq!(mob.age, (index + 1) as u32);
|
||||
assert_eq!(mob.fluids.eye_in_water, water);
|
||||
assert_eq!(mob.health, 10.0);
|
||||
assert_eq!(mob.no_ai, no_ai);
|
||||
assert_eq!(
|
||||
mob.air,
|
||||
expected,
|
||||
"water={water}, NoAI={no_ai}, initial={initial}, tick={}",
|
||||
index + 1
|
||||
);
|
||||
}
|
||||
});
|
||||
if !ran {
|
||||
eprintln!("Prepared mob data unavailable; Dolphin full-tick coverage did not run");
|
||||
}
|
||||
}
|
||||
|
||||
/// `Dolphin.tick` restores max air after super.tick for a NoAI dolphin,
|
||||
/// even when that superclass tick refilled or consumed loaded air.
|
||||
#[test]
|
||||
fn noai_dolphins_restore_air_after_the_full_mob_tick() {
|
||||
for water in [false, true] {
|
||||
for initial in [4800, 100] {
|
||||
check(water, true, initial, [4800, 4800]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The NoAI exception must not suppress normal underwater consumption.
|
||||
#[test]
|
||||
fn normal_dolphins_keep_their_underwater_countdown() {
|
||||
check(true, false, 4800, [4799, 4798]);
|
||||
}
|
||||
}
|
||||
@@ -463,16 +463,22 @@ pub fn hurt_server(m: &mut Mob) {
|
||||
set_dark_ticks(m, 100);
|
||||
}
|
||||
|
||||
/// `GlowSquid.aiStep`: its positive dark timer runs after the parent step,
|
||||
/// including when NoAI skips server AI. This upkeep consumes no random draws.
|
||||
pub(super) fn glow_squid_dark_tick(m: &mut Mob) {
|
||||
let ticks = dark_ticks(m);
|
||||
if ticks > 0 {
|
||||
set_dark_ticks(m, ticks - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `GlowSquid.aiStep` after `super.aiStep()`: the dark timer counts down, and
|
||||
/// a `glow` particle appears at `getRandomX(0.6)`, `getRandomY()`,
|
||||
/// `getRandomZ(0.6)` (drawn in that order). A window that simulates the world
|
||||
/// and a client of a server both draw it, so the server's own `ai_step` pushes
|
||||
/// the event as well (as the nautilus bubbles do).
|
||||
fn glow_squid_ai_step(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
let ticks = dark_ticks(m);
|
||||
if ticks > 0 {
|
||||
set_dark_ticks(m, ticks - 1);
|
||||
}
|
||||
glow_squid_dark_tick(m);
|
||||
let (w, h) = (f64::from(m.width()), f64::from(m.height()));
|
||||
let r = &mut m.random;
|
||||
let x = m.position.x + w * ((2.0 * r.next_double() - 1.0) * 0.6);
|
||||
@@ -1605,3 +1611,6 @@ mod pufferfish_sting_tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod countdown_tests;
|
||||
@@ -0,0 +1,69 @@
|
||||
use super::*;
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared vanilla resources in .local/horizon-client"]
|
||||
fn mob_tick_keeps_glow_squid_countdowns_with_no_ai() {
|
||||
let ran = crate::mobs::world::with_built_test_world(
|
||||
&[],
|
||||
|world, registry| {
|
||||
world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(crate::chunk::Chunk::empty(0, 0)));
|
||||
let water = registry.default_state("minecraft:water").unwrap();
|
||||
for x in 0..16 {
|
||||
for y in 60..69 {
|
||||
for z in 0..16 {
|
||||
world.set(x, y, z, water).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|world, types| {
|
||||
// Normal and negative controls run before the old NoAI positive failure.
|
||||
for no_ai in [false, true] {
|
||||
for initial in [-2, 40] {
|
||||
let kind = Arc::new(types.types["minecraft:glow_squid"].clone());
|
||||
let mut squid = Mob::new(1, kind, Vec3::new(8.5, 64.0, 8.5), 0.0);
|
||||
squid.no_ai = no_ai;
|
||||
squid.no_gravity = true;
|
||||
squid.persistent = true;
|
||||
set_dark_ticks(&mut squid, initial);
|
||||
let mut random = LegacyRandom::new(0);
|
||||
let mut events = Vec::new();
|
||||
for elapsed in 1..=10 {
|
||||
assert!(squid.tick(world, &mut random, &mut events));
|
||||
assert_eq!(squid.health, 10.0);
|
||||
let expected = if initial > 0 {
|
||||
initial - elapsed
|
||||
} else {
|
||||
initial
|
||||
};
|
||||
assert_eq!(
|
||||
dark_ticks(&squid),
|
||||
expected,
|
||||
"NoAI={no_ai}, initial={initial}, elapsed={elapsed}"
|
||||
);
|
||||
}
|
||||
let glows = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
matches!(event,
|
||||
MobEvent::ParticleAt { kind, .. } if *kind == ParticleType::Glow.name())
|
||||
})
|
||||
.count();
|
||||
assert_eq!(
|
||||
glows,
|
||||
if no_ai { 0 } else { 10 },
|
||||
"the timer-only NoAI path must preserve existing particle dispatch"
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
ran,
|
||||
"prepared vanilla resources are required; do not silently skip this regression"
|
||||
);
|
||||
}
|
||||
@@ -272,6 +272,8 @@ pub fn environmental(kind: DamageKind) -> bool {
|
||||
| DamageKind::Lava
|
||||
| DamageKind::LightningBolt
|
||||
| DamageKind::HotFloor
|
||||
| DamageKind::Freeze
|
||||
| DamageKind::SulfurCubeHot
|
||||
)
|
||||
}
|
||||
/// The hurt sound: a ball's is muffled, and it has no ambient sound
|
||||
@@ -806,3 +808,44 @@ mod tests {
|
||||
assert!(!environmental(DamageKind::Campfire));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod panic_environmental_tests {
|
||||
use super::*;
|
||||
|
||||
/// `#panic_environmental_causes` from the vanilla data: every type in it
|
||||
/// panics the armadillo (`Armadillo.actuallyHurt` rolls out).
|
||||
#[test]
|
||||
fn the_environmental_causes_are_vanillas_tag() {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
.join("data/minecraft/tags/damage_type/panic_environmental_causes.json");
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
return;
|
||||
};
|
||||
let json: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||
let mut ids: Vec<&str> = json["values"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap())
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
let kinds = [
|
||||
("minecraft:cactus", DamageKind::Cactus),
|
||||
("minecraft:freeze", DamageKind::Freeze),
|
||||
("minecraft:hot_floor", DamageKind::HotFloor),
|
||||
("minecraft:in_fire", DamageKind::InFire),
|
||||
("minecraft:lava", DamageKind::Lava),
|
||||
("minecraft:lightning_bolt", DamageKind::LightningBolt),
|
||||
("minecraft:on_fire", DamageKind::OnFire),
|
||||
("minecraft:sulfur_cube_hot", DamageKind::SulfurCubeHot),
|
||||
];
|
||||
assert_eq!(ids, kinds.map(|(id, _)| id).to_vec());
|
||||
for (id, kind) in kinds {
|
||||
assert!(environmental(kind), "{id}");
|
||||
}
|
||||
assert!(!environmental(DamageKind::Drown));
|
||||
}
|
||||
}
|
||||
@@ -246,12 +246,8 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
// Player.touch: entities within the player's box grown by (1, 0.5, 1).
|
||||
&& m.bounds().intersects(p.bounds.inflate(Vec3::new(1.0, 0.5, 1.0)))
|
||||
{
|
||||
let reach = 2.04f32.sqrt() as f64 - 0.6;
|
||||
if m.bounds()
|
||||
.inflate(Vec3::new(reach, 0.0, reach))
|
||||
.intersects(p.bounds)
|
||||
&& m.sees(w, Who::Player)
|
||||
{
|
||||
// isWithinMeleeAttackRange, with what the cube holds.
|
||||
if m.melee_reaches(p.bounds) && m.sees(w, Who::Player) {
|
||||
events.push(MobEvent::CubeHurtPlayer {
|
||||
cube: m.id,
|
||||
amount: attack_damage(m),
|
||||
@@ -274,7 +270,8 @@ fn attack_damage(m: &Mob) -> f32 {
|
||||
/// `AbstractCubeMob.push(entity)`: an iron golem whose `pushEntities` takes
|
||||
/// this cube (its box overlaps the cube's, `EntitySelector.pushableBy`) gets
|
||||
/// `dealDamage`: alive, `doTeamsAllowDamage`, `isWithinMeleeAttackRange`
|
||||
/// (always true for overlapping boxes) and `hasLineOfSight`, then a
|
||||
/// (true for overlapping boxes unless a held spear's minimum range refuses
|
||||
/// them) and `hasLineOfSight`, then a
|
||||
/// `mobAttack` hit for `getAttackDamage`.
|
||||
fn hurt_touching_golems(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
// canDealDamage also needs Mob.isEffectiveAi: a NoAI cube never hurts.
|
||||
@@ -292,6 +289,7 @@ fn hurt_touching_golems(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
&& o.species == Species::IronGolem
|
||||
&& o.alive
|
||||
&& o.bounds.intersects(bounds)
|
||||
&& m.melee_reaches(o.bounds)
|
||||
&& crate::scoreboard::pushable_by(theirs, own, pushable)
|
||||
&& crate::scoreboard::teams_allow_damage(own, theirs)
|
||||
})
|
||||
|
||||
@@ -71,8 +71,12 @@ pub fn stared_at(m: &Mob, w: &MobWorld) -> bool {
|
||||
Vec3::new(m.position.x, m.eye_y(), m.position.z),
|
||||
)
|
||||
}
|
||||
/// Enderman.teleport(x, y, z): never while riding something; drop to standing
|
||||
/// ground, require free space without liquid.
|
||||
/// `Enderman.teleport(x, y, z)`: never while riding something, then
|
||||
/// `LivingEntity.randomTeleport(x, y, z, true, #enderman_does_not_teleport_to)`:
|
||||
/// the first `#entities_can_teleport_to` block below the spot is the ground,
|
||||
/// and the enderman lands at the spot's own height less the blocks it went
|
||||
/// down (`checkPositionAndTeleport`), unless the ground or a block in its new
|
||||
/// box is one it does not teleport to, the box collides or holds a liquid.
|
||||
pub fn teleport_to(
|
||||
m: &mut Mob,
|
||||
w: &MobWorld,
|
||||
@@ -84,8 +88,8 @@ pub fn teleport_to(
|
||||
if m.extra.passenger {
|
||||
return false;
|
||||
}
|
||||
// LivingEntity.randomTeleport: WorldBorder.clampVec3ToBound keeps the
|
||||
// target inside the border (Mth.clamp, 1.0E-5F inside the far edges).
|
||||
// WorldBorder.clampVec3ToBound keeps the target inside the border
|
||||
// (Mth.clamp, 1.0E-5F inside the far edges).
|
||||
let (x, z) = match w.border {
|
||||
Some(border) => {
|
||||
let [min_x, min_z, max_x, max_z] = border.bounds(0.0);
|
||||
@@ -101,40 +105,71 @@ pub fn teleport_to(
|
||||
if !w.world.loaded(bx, bz) {
|
||||
return false;
|
||||
}
|
||||
let refused = |name: &str| w.has_tag("minecraft:enderman_does_not_teleport_to", name);
|
||||
let mut by = y.floor() as i32;
|
||||
while by > w.world.bottom_y() && !w.facts.get(w.world.block(bx, by, bz)).full_collision {
|
||||
let mut ny = y;
|
||||
let ground = loop {
|
||||
if by <= w.world.bottom_y() {
|
||||
return false;
|
||||
}
|
||||
by -= 1;
|
||||
}
|
||||
let ground = w.facts.get(w.world.block(bx, by, bz));
|
||||
if !ground.full_collision || ground.fluid == Fluid::Water {
|
||||
let name = w.block_name(bx, by, bz);
|
||||
if w.has_tag("minecraft:entities_can_teleport_to", name) {
|
||||
break name;
|
||||
}
|
||||
ny -= 1.0;
|
||||
};
|
||||
if refused(ground) {
|
||||
return false;
|
||||
}
|
||||
let ny = f64::from(by + 1);
|
||||
let half = f64::from(m.width()) / 2.0;
|
||||
let b = Aabb::new(
|
||||
Vec3::new(x - half, ny, z - half),
|
||||
Vec3::new(x + half, ny + f64::from(m.height()), z + half),
|
||||
);
|
||||
if !w.no_collision(b) || w.fluids(b, ny + m.eye_height()).in_water || w.fluids(b, ny).in_lava {
|
||||
if !w.no_collision(b) {
|
||||
return false;
|
||||
}
|
||||
let old = m.center();
|
||||
// LevelReader.containsAnyLiquid and findBlocksIn: the same closed range of
|
||||
// cells, from BlockPos.containing(min) to BlockPos.containing(max).
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
for cx in lo.x.floor() as i32..=hi.x.floor() as i32 {
|
||||
for cy in lo.y.floor() as i32..=hi.y.floor() as i32 {
|
||||
for cz in lo.z.floor() as i32..=hi.z.floor() as i32 {
|
||||
if w.fluid(cx, cy, cz) != Fluid::None || refused(w.block_name(cx, cy, cz)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Enderman.canRandomlyTeleportTo: not onto ground that holds water
|
||||
// (a waterlogged slab, leaves or roots).
|
||||
if w.fluid(bx, by, bz) == Fluid::Water {
|
||||
return false;
|
||||
}
|
||||
let old = m.position;
|
||||
let old_block = [
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
];
|
||||
// Entity.teleportTo -> snapTo: the old position moves too, the motion stays.
|
||||
m.position = Vec3::new(x, ny, z);
|
||||
m.previous = m.position;
|
||||
m.velocity = Vec3::ZERO;
|
||||
m.ai.nav.stop();
|
||||
// `LivingEntity.randomTeleport(..., true)` broadcasts entity event 46
|
||||
// before the trail and the teleport sounds.
|
||||
// `randomTeleport(..., true)` broadcasts entity event 46.
|
||||
events.push(MobEvent::EntityEvent {
|
||||
mob: m.id,
|
||||
event: 46,
|
||||
at: [m.position.x, m.position.y, m.position.z],
|
||||
});
|
||||
// Enderman.teleport: GameEvent.TELEPORT where it was, silent or not.
|
||||
events.push(MobEvent::GameEvent {
|
||||
event: "minecraft:teleport",
|
||||
at: [old.x, old.y, old.z],
|
||||
source: m.id,
|
||||
state: horizon_world::registry::BlockStateId(0),
|
||||
});
|
||||
if !m.silent {
|
||||
// Level event 2018: the portal trail, from the old block to the new
|
||||
// one, packed as `BlockUtil.clampedPackDifferenceInPosition` does.
|
||||
@@ -145,14 +180,12 @@ pub fn teleport_to(
|
||||
pos: old_block,
|
||||
data: packed(0) << 16 | packed(1) << 8 | packed(2),
|
||||
});
|
||||
// Both sounds are at the new feet: `xo` was moved by snapTo, and
|
||||
// Entity.playSound plays at the position.
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
events.push(MobEvent::Sound("entity.enderman.teleport", at, 1.0, 1.0));
|
||||
events.push(MobEvent::Sound("entity.enderman.teleport", at, 1.0, 1.0));
|
||||
}
|
||||
events.push(MobEvent::Sound("entity.enderman.teleport", old, 1.0, 1.0));
|
||||
events.push(MobEvent::Sound(
|
||||
"entity.enderman.teleport",
|
||||
m.center(),
|
||||
1.0,
|
||||
1.0,
|
||||
));
|
||||
true
|
||||
}
|
||||
pub fn teleport_random(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
|
||||
@@ -226,20 +259,29 @@ pub fn on_damage(m: &mut Mob, source: &Damage) -> bool {
|
||||
true
|
||||
}
|
||||
pub fn after_hurt(m: &mut Mob, source: &Damage) {
|
||||
// A thrown potion's hit (`indirect_magic`, which only a splash potion
|
||||
// deals to a mob) leaves `Enderman.hurtServer` before the random chance:
|
||||
// A thrown potion's hit (`indirect_magic` with the potion as the direct
|
||||
// entity, `Mob::damage_by`) and a projectile it dodges leave
|
||||
// `Enderman.hurtServer` before the random chance:
|
||||
// `repeatedlyTryToTeleport` replaces it.
|
||||
if source.kind != super::DamageKind::IndirectMagic
|
||||
&& source.attacker.is_none()
|
||||
&& m.random.next_int_bound(10).unwrap_or(0) != 0
|
||||
{
|
||||
if !dodges(m, source) && source.kind != super::DamageKind::IndirectMagic {
|
||||
teleport_chance(m, source);
|
||||
}
|
||||
}
|
||||
/// `Enderman.hurtServer`'s `!(source.getEntity() instanceof LivingEntity) &&
|
||||
/// random.nextInt(10) != 0`: a teleport after the hurt.
|
||||
pub fn teleport_chance(m: &mut Mob, source: &Damage) {
|
||||
if source.attacker.is_none() && m.random.next_int_bound(10).unwrap_or(0) != 0 {
|
||||
m.extra.counter2 = 1;
|
||||
}
|
||||
}
|
||||
/// Pending teleports (repeatedlyTryToTeleport: up to 64 tries).
|
||||
/// Enderman.aiStep: never jumps; the teleports a hurt left run.
|
||||
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
// Enderman.aiStep: never jumps.
|
||||
m.ai.jumping = false;
|
||||
run_pending_teleports(m, w, events);
|
||||
}
|
||||
/// The teleports `hurtServer` asked for (`teleport()`, or
|
||||
/// `repeatedlyTryToTeleport`: up to 64 tries).
|
||||
pub fn run_pending_teleports(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
if m.extra.counter2 > 0 {
|
||||
let tries = m.extra.counter2;
|
||||
m.extra.counter2 = 0;
|
||||
@@ -256,10 +298,6 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (m.fluids.in_water || super::ai::in_rain(m, w)) && m.alive() {
|
||||
// Hurt by water: teleport away.
|
||||
m.extra.counter2 = m.extra.counter2.max(1);
|
||||
}
|
||||
}
|
||||
/// EndermanModel.setupAnim: half-speed clamped limbs, held-block arms, creepy head.
|
||||
/// Humanoid riding pose runs first (`isPassenger`); only xRot is halved/clamped after.
|
||||
|
||||
@@ -655,11 +655,63 @@ impl Mob {
|
||||
self.ai.seen_cache.push((who, v));
|
||||
v
|
||||
}
|
||||
/// Mob.isWithinMeleeAttackRange: the box inflated by sqrt(2.04) - 0.6.
|
||||
/// Mob.isWithinMeleeAttackRange: the box inflated by sqrt(2.04) - 0.6, or
|
||||
/// for an active item with `attack_range` (the spears) by its
|
||||
/// `effectiveMaxRange` for a mob (`max_reach * mob_factor`), and not
|
||||
/// within its `effectiveMinRange` (`min_reach * mob_factor`).
|
||||
pub fn within_melee_range(&self, t: &TargetInfo) -> bool {
|
||||
let reach = 2.04f32.sqrt() as f64 - 0.6;
|
||||
let b = self.bounds().inflate(Vec3::new(reach, 0.0, reach));
|
||||
b.intersects(t.bounds)
|
||||
self.melee_reaches(t.bounds)
|
||||
}
|
||||
/// [`Self::within_melee_range`] for a target's box (`getHitbox`).
|
||||
pub fn melee_reaches(&self, target: Aabb) -> bool {
|
||||
let (max, min) = match self.active_attack_range() {
|
||||
Some((max, min)) => (f64::from(max), f64::from(min)),
|
||||
None => (2.04f32.sqrt() as f64 - 0.6, 0.0),
|
||||
};
|
||||
let reaches = |range: f64| {
|
||||
self.bounds()
|
||||
.inflate(Vec3::new(range, 0.0, range))
|
||||
.intersects(target)
|
||||
};
|
||||
reaches(max) && (min <= 0.0 || !reaches(min))
|
||||
}
|
||||
/// The active item's `attack_range` for a mob: `effectiveMaxRange` and
|
||||
/// `effectiveMinRange` (`max_reach` and `min_reach` times `mob_factor`).
|
||||
/// A patch on the held stack wins (`!minecraft:attack_range` removes it;
|
||||
/// the codec's defaults are 0, 3 and a factor of 1); else the spears'.
|
||||
fn active_attack_range(&self) -> Option<(f32, f32)> {
|
||||
let item = self.active_item()?;
|
||||
let patch = (self.extra.main_hand.as_deref() == Some(item))
|
||||
.then(|| self.extra.main_hand_stack().and_then(|s| s.data()))
|
||||
.flatten();
|
||||
if patch.is_some_and(|d| d.other("!minecraft:attack_range").is_some()) {
|
||||
return None;
|
||||
}
|
||||
if let Some(range) = patch.and_then(|d| d.other("minecraft:attack_range")) {
|
||||
let float = |key: &str, default: f32| match range.get(key) {
|
||||
Some(horizon_world::nbt::Tag::Float(bits)) => f32::from_bits(*bits),
|
||||
Some(horizon_world::nbt::Tag::Double(bits)) => f64::from_bits(*bits) as f32,
|
||||
Some(horizon_world::nbt::Tag::Int(v)) => *v as f32,
|
||||
_ => default,
|
||||
};
|
||||
let factor = float("mob_factor", 1.0);
|
||||
return Some((
|
||||
float("max_reach", 3.0) * factor,
|
||||
float("min_reach", 0.0) * factor,
|
||||
));
|
||||
}
|
||||
crate::spear::Kinetic::of(item).map(|_| {
|
||||
let r = &crate::spear::REACH;
|
||||
(r.max * r.mob_factor, r.min * r.mob_factor)
|
||||
})
|
||||
}
|
||||
/// `LivingEntity.getActiveItem`: the item being used, else the main hand's.
|
||||
pub fn active_item(&self) -> Option<&str> {
|
||||
if super::spear_use::is_using_item(self) && self.extra.using_off_hand {
|
||||
self.extra.off_hand.as_deref()
|
||||
} else {
|
||||
self.extra.main_hand.as_deref()
|
||||
}
|
||||
}
|
||||
/// Whether the species is a `TamableAnimal` (not the horses and llamas,
|
||||
/// which are tamed another way).
|
||||
@@ -3319,3 +3371,63 @@ mod breed_xp_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod melee_range_tests {
|
||||
use super::*;
|
||||
|
||||
fn mob_holding(item: &str, components: Option<&str>) -> Mob {
|
||||
let kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
|
||||
let mut m = Mob::new(1, Arc::new(kind), Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
let mut stack = crate::items::ItemStack::new(item, 1);
|
||||
if let Some(text) = components {
|
||||
let tag = horizon_world::nbt::parse_snbt(text).unwrap().0;
|
||||
(stack.damage, stack.extra) = crate::item_data::decode_components(Some(&tag));
|
||||
}
|
||||
m.extra.set_main_hand_stack(Some(stack));
|
||||
m
|
||||
}
|
||||
/// A one-block target whose near face is `gap` blocks east of the mob's box.
|
||||
fn target(m: &Mob, gap: f64) -> Aabb {
|
||||
let x = m.bounds().max_position().x + gap;
|
||||
Aabb::new(Vec3::new(x, 64.0, 0.0), Vec3::new(x + 1.0, 65.0, 1.0))
|
||||
}
|
||||
|
||||
/// `Mob.isWithinMeleeAttackRange` with `getActiveItem().get(ATTACK_RANGE)`:
|
||||
/// no component reaches sqrt(2.04) - 0.6 (about 0.83); a spear 4.5 and 2.0
|
||||
/// times its mob factor 0.5; a patched component with the codec's
|
||||
/// defaults (`mob_factor` 1, `min_reach` 0); a removed one the default.
|
||||
#[test]
|
||||
fn the_active_items_attack_range_sets_the_reach() {
|
||||
let sword = mob_holding("minecraft:iron_sword", None);
|
||||
assert!(sword.melee_reaches(target(&sword, 0.8)));
|
||||
assert!(!sword.melee_reaches(target(&sword, 0.9)));
|
||||
|
||||
let spear = mob_holding("minecraft:iron_spear", None);
|
||||
assert!(spear.melee_reaches(target(&spear, 2.2)));
|
||||
assert!(!spear.melee_reaches(target(&spear, 2.3)));
|
||||
assert!(
|
||||
!spear.melee_reaches(target(&spear, 0.5)),
|
||||
"inside the minimum range"
|
||||
);
|
||||
assert!(spear.melee_reaches(target(&spear, 1.1)));
|
||||
|
||||
let long = mob_holding(
|
||||
"minecraft:iron_sword",
|
||||
Some(r#"{"minecraft:attack_range":{max_reach:6.0f}}"#),
|
||||
);
|
||||
assert!(long.melee_reaches(target(&long, 5.9)));
|
||||
assert!(
|
||||
long.melee_reaches(target(&long, 0.0)),
|
||||
"no minimum by default"
|
||||
);
|
||||
assert!(!long.melee_reaches(target(&long, 6.1)));
|
||||
|
||||
let blunt = mob_holding(
|
||||
"minecraft:iron_spear",
|
||||
Some(r#"{"!minecraft:attack_range":{}}"#),
|
||||
);
|
||||
assert!(blunt.melee_reaches(target(&blunt, 0.5)));
|
||||
assert!(!blunt.melee_reaches(target(&blunt, 2.0)));
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,17 @@ pub fn elastic_pull(
|
||||
holder: &HolderState,
|
||||
motion: Vec3,
|
||||
quad: bool,
|
||||
) -> Option<([f64; 3], f64)> {
|
||||
elastic_pull_with_distance(position, yaw, size, holder, motion, quad, ELASTIC)
|
||||
}
|
||||
fn elastic_pull_with_distance(
|
||||
position: Vec3,
|
||||
yaw: f32,
|
||||
size: (f64, f64),
|
||||
holder: &HolderState,
|
||||
motion: Vec3,
|
||||
quad: bool,
|
||||
elastic_distance: f64,
|
||||
) -> Option<([f64; 3], f64)> {
|
||||
// SHARED_QUAD_ATTACHMENT_POINTS, ENTITY_ATTACHMENT_POINT, LEASHER_ATTACHMENT_POINT.
|
||||
const SHARED_QUAD: [[f64; 3]; 4] = [
|
||||
@@ -243,7 +254,7 @@ pub fn elastic_pull(
|
||||
pivot[2] - object[2],
|
||||
];
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
if distance < ELASTIC {
|
||||
if distance < elastic_distance {
|
||||
continue;
|
||||
}
|
||||
// Vec3.normalize, then scale(distance - slack).
|
||||
@@ -253,7 +264,7 @@ pub fn elastic_pull(
|
||||
} else {
|
||||
[d[0] / length, d[1] / length, d[2] / length]
|
||||
};
|
||||
let pull = distance - ELASTIC;
|
||||
let pull = distance - elastic_distance;
|
||||
let mut f = [unit[0] * pull, unit[1] * pull, unit[2] * pull];
|
||||
let t = lever[2] * f[0] - lever[0] * f[2];
|
||||
if motion.x * f[0] + motion.y * f[1] + motion.z * f[2] >= 0.0 {
|
||||
@@ -360,10 +371,10 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
};
|
||||
// PathfinderMob.whenLeashedTo: home is the holder's block, within the
|
||||
// elastic distance less one.
|
||||
let elastic = if m.species() == super::Species::HappyGhast {
|
||||
10.0
|
||||
let (elastic, snap) = if m.species() == Species::HappyGhast {
|
||||
(10.0, 16.0)
|
||||
} else {
|
||||
ELASTIC
|
||||
(ELASTIC, SNAP)
|
||||
};
|
||||
m.ai.home = Some((
|
||||
[
|
||||
@@ -390,7 +401,7 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
+ (state.center.y - center.y).powi(2)
|
||||
+ (state.center.z - center.z).powi(2))
|
||||
.sqrt();
|
||||
if distance > SNAP {
|
||||
if distance > snap {
|
||||
events.push(MobEvent::LeashBreak {
|
||||
mob: m.id,
|
||||
drop: true,
|
||||
@@ -398,7 +409,7 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
let stretched = distance > ELASTIC - state.width - f64::from(m.width());
|
||||
let stretched = distance > elastic - state.width - f64::from(m.width());
|
||||
// Leashable.checkElasticInteractions: both ends must support the shared
|
||||
// quad attachment points. The live holder was resolved and checked above;
|
||||
// these physics points are independent of the rendered rope offsets.
|
||||
@@ -406,13 +417,14 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
&& matches!(holder, Holder::Mob(id) if w.seen(id).is_some_and(|s|
|
||||
matches!(s.species, Species::Ghast | Species::HappyGhast)));
|
||||
let pulled = stretched
|
||||
&& elastic_pull(
|
||||
&& elastic_pull_with_distance(
|
||||
m.position,
|
||||
m.yaw,
|
||||
(f64::from(m.width()), f64::from(m.height())),
|
||||
&state,
|
||||
m.velocity,
|
||||
quad,
|
||||
elastic,
|
||||
)
|
||||
.is_some_and(|(force, torque)| {
|
||||
m.extra.leash_spin += 10.0 * torque;
|
||||
@@ -428,6 +440,11 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
);
|
||||
check_fall_distance(m);
|
||||
super::equine::on_elastic_leash_pull(m, w, events);
|
||||
// HappyGhast.onElasticLeashPull: let the rope move it without
|
||||
// competing with the current flight target.
|
||||
if m.species() == Species::HappyGhast {
|
||||
m.ai.mov.operation = super::control::Operation::Wait;
|
||||
}
|
||||
true
|
||||
});
|
||||
if !pulled {
|
||||
@@ -472,7 +489,8 @@ pub fn remove(m: &mut Mob) -> Option<Holder> {
|
||||
/// `PathfinderMob.closeRangeLeashBehaviour`: walk to within two blocks of
|
||||
/// the holder unless panicking.
|
||||
fn close_range(m: &mut Mob, w: &MobWorld, holder: &HolderState) {
|
||||
if m.ai.panicking {
|
||||
// HappyGhast.shouldStayCloseToLeashHolder is false at either age.
|
||||
if m.ai.panicking || m.species() == Species::HappyGhast {
|
||||
return;
|
||||
}
|
||||
let d = Vec3::new(
|
||||
@@ -668,6 +686,110 @@ pub fn quad_holder_offsets(m: &Mob) -> Option<[[f64; 3]; 4]> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires HORIZON_TEST_RESOURCES and the vanilla Happy Ghast leash reference"]
|
||||
fn happy_ghast_leash_dispatch_matches_vanilla() {
|
||||
use super::super::{
|
||||
control::Operation,
|
||||
path::{Path, PathNode, PathType},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let reference = std::env::var_os("HORIZON_HAPPY_GHAST_LEASH_REFERENCE").unwrap();
|
||||
let rows: Vec<serde_json::Value> =
|
||||
serde_json::from_slice(&std::fs::read(reference).unwrap()).unwrap();
|
||||
assert_eq!(rows.len(), 36, "missing leash cases");
|
||||
let ran = crate::mobs::world::with_resource_test_world(
|
||||
&root,
|
||||
&[],
|
||||
true,
|
||||
true,
|
||||
|world, registry| {
|
||||
world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(crate::chunk::Chunk::empty(0, 0)));
|
||||
world
|
||||
.set(
|
||||
0,
|
||||
64,
|
||||
0,
|
||||
registry.default_state("minecraft:oak_fence").unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
},
|
||||
|world, types| {
|
||||
assert!(
|
||||
holder_state(world, Holder::Knot([0, 64, 0])).is_some(),
|
||||
"missing fence holder"
|
||||
);
|
||||
let mut failures = Vec::new();
|
||||
for row in rows {
|
||||
let kind = row["kind"].as_str().unwrap();
|
||||
let position = &row["position"];
|
||||
let id = if kind == "cow" {
|
||||
"minecraft:cow"
|
||||
} else {
|
||||
"minecraft:happy_ghast"
|
||||
};
|
||||
let mut mob = Mob::new(
|
||||
1,
|
||||
Arc::new(types.types[id].clone()),
|
||||
Vec3::new(
|
||||
position[0].as_f64().unwrap(),
|
||||
position[1].as_f64().unwrap(),
|
||||
position[2].as_f64().unwrap(),
|
||||
),
|
||||
0.0,
|
||||
);
|
||||
if kind != "cow" {
|
||||
mob.extra.age = if kind == "baby" { -24000 } else { 0 };
|
||||
super::super::happy_ghast::ensure_setup(&mut mob);
|
||||
}
|
||||
mob.on_ground = false;
|
||||
mob.ai.nav.path = Some(Path {
|
||||
nodes: vec![PathNode {
|
||||
x: 8,
|
||||
y: 64,
|
||||
z: 8,
|
||||
kind: PathType::Open,
|
||||
}],
|
||||
next: 0,
|
||||
target: [8, 64, 8],
|
||||
dist_to_target: 0.0,
|
||||
reached: true,
|
||||
});
|
||||
mob.ai.nav.target_pos = Some([8, 64, 8]);
|
||||
mob.ai.mov.set_wanted(20.0, 70.0, 20.0, 1.0);
|
||||
mob.extra.leash = Some(Holder::Knot([0, 64, 0]));
|
||||
let mut events = Vec::new();
|
||||
tick(&mut mob, world, &mut events);
|
||||
let actual = serde_json::json!({
|
||||
"kind": kind, "distance": row["distance"], "phase": row["phase"],
|
||||
"position": position,
|
||||
"snapped": events.iter().any(|event| matches!(event, MobEvent::LeashBreak { .. })),
|
||||
"wanted": mob.ai.mov.operation == Operation::MoveTo,
|
||||
"path": mob.ai.nav.path.as_ref().map(|path| path.target),
|
||||
"velocity": [mob.velocity.x, mob.velocity.y, mob.velocity.z],
|
||||
});
|
||||
let states_match = ["kind", "distance", "phase", "snapped", "wanted", "path"]
|
||||
.into_iter()
|
||||
.all(|key| row[key] == actual[key]);
|
||||
let velocity_matches = (0..3).all(|i| {
|
||||
(row["velocity"][i].as_f64().unwrap()
|
||||
- actual["velocity"][i].as_f64().unwrap())
|
||||
.abs()
|
||||
<= 1e-10
|
||||
});
|
||||
if !states_match || !velocity_matches {
|
||||
failures.push((row, actual));
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "leash dispatch differs: {failures:?}");
|
||||
},
|
||||
);
|
||||
assert!(ran, "prepared resources must execute the fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mob_tick_selects_quad_only_when_both_live_ends_support_it() {
|
||||
let ran = crate::mobs::world::with_test_world(&[], |world, types| {
|
||||
|
||||
@@ -558,6 +558,15 @@ impl Mob {
|
||||
pub fn eye_height(&self) -> f64 {
|
||||
self.dims().2
|
||||
}
|
||||
/// `EntityRenderer.getPackedLightCoords` reads the block
|
||||
/// `Entity.getLightProbePosition(partialTick)` is in, and a living entity
|
||||
/// leaves that as `getEyePosition`: the eye height over
|
||||
/// `getPosition(partialTick)`, the position the frame draws, not this
|
||||
/// tick's. A mob walking across a cell boundary is drawn lit by the cell it
|
||||
/// is drawn in.
|
||||
pub fn light_probe(&self, partial: f64) -> [i32; 3] {
|
||||
eye_probe_cell(self.previous, self.position, self.eye_height(), partial)
|
||||
}
|
||||
pub fn width(&self) -> f32 {
|
||||
self.dims().0 as f32
|
||||
}
|
||||
@@ -1786,6 +1795,19 @@ impl horizon_world::water_splash::WaterSplashWorld for Splash<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.getEyePosition(partialTick)` as the cell the renderer lights an
|
||||
/// entity from (`Entity.getLightProbePosition` is that position and
|
||||
/// `EntityRenderer.getPackedLightCoords` takes `BlockPos.containing` of it):
|
||||
/// `Mth.lerp(partialTick, xo, x)` on each axis, plus `getEyeHeight()` on y.
|
||||
pub fn eye_probe_cell(previous: Vec3, position: Vec3, eye_height: f64, partial: f64) -> [i32; 3] {
|
||||
let at = previous.lerp(position, partial);
|
||||
[
|
||||
at.x.floor() as i32,
|
||||
(at.y + eye_height).floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
]
|
||||
}
|
||||
|
||||
/// `Entity.doWaterSplashEffect` for a mob this window is drawing.
|
||||
/// `Level.addParticle` runs on the client. `ClientLevel.playSeededSound`
|
||||
/// ignores `Entity.playSound` (`except` is null), and the SPLASH game event
|
||||
@@ -1901,6 +1923,30 @@ impl RestitutionWorld for MobRestitution<'_, '_> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `Entity.getLightProbePosition(partialTick)` for a living entity is
|
||||
/// `getEyePosition`: `getEyeHeight()` over `Mth.lerp(partialTick, xo, x)`.
|
||||
/// Horizon probed this tick's own position, so a mob walking into the next
|
||||
/// cell was drawn lit by that cell for the whole frame before it got there,
|
||||
/// and a falling one for a frame after it left.
|
||||
#[test]
|
||||
fn a_mob_probe_follows_the_frame() {
|
||||
let eye = 1.62;
|
||||
// Walking from x = 0.9 to 1.1: the eye is in the cell at 0 until the
|
||||
// frame reaches the boundary, and in the cell at 1 from there on.
|
||||
let (previous, position) = (Vec3::new(0.9, 64.0, 0.5), Vec3::new(1.1, 64.0, 0.5));
|
||||
assert_eq!(eye_probe_cell(previous, position, eye, 0.0), [0, 65, 0]);
|
||||
assert_eq!(eye_probe_cell(previous, position, eye, 0.5), [1, 65, 0]);
|
||||
assert_eq!(eye_probe_cell(previous, position, eye, 1.0), [1, 65, 0]);
|
||||
// The old probe was the tick's own position on every frame.
|
||||
assert_eq!(position.x.floor() as i32, 1);
|
||||
// Falling a block: the eye is at 71.62 at the start of the frame and
|
||||
// 70.62 at its end.
|
||||
let (previous, position) = (Vec3::new(0.5, 70.0, 0.5), Vec3::new(0.5, 69.0, 0.5));
|
||||
assert_eq!(eye_probe_cell(previous, position, eye, 0.0), [0, 71, 0]);
|
||||
assert_eq!(eye_probe_cell(previous, position, eye, 0.5), [0, 71, 0]);
|
||||
assert_eq!(eye_probe_cell(previous, position, eye, 1.0), [0, 70, 0]);
|
||||
}
|
||||
/// A pig dropped hard onto a block bounces by that block's restitution
|
||||
/// (`Entity.getBlockBounciness`): landing on a slime block sends it back
|
||||
/// up, landing on stone stops it dead. The same 8-block fall then costs the
|
||||
|
||||
@@ -276,7 +276,12 @@ fn check_restriction(m: &mut Mob) {
|
||||
/// `AbstractNautilus.tick` and the brains' memories: the rider's breath, the
|
||||
/// dash clock, bubbles, and choosing whom to charge at
|
||||
/// (`NautilusAi.findNearestValidAttackTarget`).
|
||||
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
pub fn ai_step(
|
||||
m: &mut Mob,
|
||||
w: &MobWorld,
|
||||
level_random: &mut horizon_core::random::LegacyRandom,
|
||||
events: &mut Vec<MobEvent>,
|
||||
) {
|
||||
// customServerAiStep: the home is kept before the rest of the step.
|
||||
check_restriction(m);
|
||||
if m.extra.scratch[1] != 0 {
|
||||
@@ -321,7 +326,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
m.extra.anger -= 1;
|
||||
}
|
||||
if m.ai.target.is_none() && m.alive() && !m.no_ai {
|
||||
m.ai.target = find_target(m, w);
|
||||
m.ai.target = find_target(m, w, level_random);
|
||||
}
|
||||
}
|
||||
/// `spawnBubbles`: more as it swims faster, from behind its mouth.
|
||||
@@ -375,7 +380,11 @@ pub fn aggravated(m: &Mob) -> bool {
|
||||
}
|
||||
/// `NautilusAi.findNearestValidAttackTarget`: whoever it is angry at, else
|
||||
/// now and then (a coin flip every 2400 to 3600 ticks) the nearest pufferfish.
|
||||
fn find_target(m: &mut Mob, w: &MobWorld) -> Option<Who> {
|
||||
fn find_target(
|
||||
m: &mut Mob,
|
||||
w: &MobWorld,
|
||||
level_random: &mut horizon_core::random::LegacyRandom,
|
||||
) -> Option<Who> {
|
||||
if m.extra.in_love > 0 || !m.fluids.in_water || m.is_baby() || m.extra.tame {
|
||||
return None;
|
||||
}
|
||||
@@ -391,15 +400,26 @@ fn find_target(m: &mut Mob, w: &MobWorld) -> Option<Who> {
|
||||
if m.extra.counter > 0 {
|
||||
return None;
|
||||
}
|
||||
m.extra.counter = 2400 + m.random.next_int_bound(1201).unwrap_or(0);
|
||||
if m.random.next_float() < 0.5 {
|
||||
// The cooldown and the coin come from the level's random (`level.getRandom()`).
|
||||
m.extra.counter = 2400 + level_random.next_int_bound(1201).unwrap_or(0);
|
||||
if level_random.next_float() < 0.5 {
|
||||
return None;
|
||||
}
|
||||
w.nearby(m.position, 16.0, |s| {
|
||||
s.species == Species::Pufferfish && in_water_at(w, s.position)
|
||||
})
|
||||
.next()
|
||||
.map(|s| Who::Mob(s.id))
|
||||
// NEAREST_VISIBLE_LIVING_ENTITIES.findClosest(isHostileTarget): the
|
||||
// nearest #nautilus_hostiles in water that the nautilus can see
|
||||
// (`Sensor.isEntityTargetable`: line of sight).
|
||||
let mut near: Vec<(f64, u32)> = w
|
||||
.nearby(m.position, 16.0, |s| {
|
||||
s.species == Species::Pufferfish && in_water_at(w, s.position)
|
||||
})
|
||||
.map(|s| (s.position.distance_squared(m.position), s.id))
|
||||
// TargetingConditions.range(16): feet to feet.
|
||||
.filter(|&(d, _)| d <= 16.0 * 16.0)
|
||||
.collect();
|
||||
near.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
near.into_iter()
|
||||
.map(|(_, id)| Who::Mob(id))
|
||||
.find(|&who| m.sees(w, who))
|
||||
}
|
||||
/// `hurtServer`: `NautilusAi.setAngerTarget` for 400 ticks.
|
||||
pub fn after_hurt(m: &mut Mob, source: &super::Damage) {
|
||||
@@ -681,11 +701,13 @@ mod tests {
|
||||
assert_eq!(m.extra.owner, owner);
|
||||
assert_eq!(m.extra.sitting, expected, "{id} {owner:?} {sitting:?}");
|
||||
// No raw compound: this must come from the modeled flag.
|
||||
let saved = crate::entity_nbt::mob_to_nbt(&m, [5, 6, 7, 8], None, None);
|
||||
let saved =
|
||||
crate::entity_nbt::mob_to_nbt(&m, [5, 6, 7, 8], None, None, |_| None);
|
||||
assert_eq!(saved.get("Sitting"), Some(&Tag::Byte(i8::from(expected))));
|
||||
// A stale raw flag must not override a later model change.
|
||||
m.extra.sitting = !expected;
|
||||
let saved = crate::entity_nbt::mob_to_nbt(&m, [5, 6, 7, 8], Some(&raw), None);
|
||||
let saved =
|
||||
crate::entity_nbt::mob_to_nbt(&m, [5, 6, 7, 8], Some(&raw), None, |_| None);
|
||||
assert_eq!(saved.get("Sitting"), Some(&Tag::Byte(i8::from(!expected))));
|
||||
}
|
||||
}
|
||||
@@ -790,3 +812,83 @@ mod tests {
|
||||
assert_eq!(m.rider.expect("the rider stays on").jump, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hostile_target_tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A pool from x -8 to 8, with a stone wall at x = 3 when `wall`.
|
||||
fn pool(
|
||||
wall: bool,
|
||||
) -> impl FnOnce(&mut crate::world::World, &horizon_world::registry::BlockRegistry) {
|
||||
move |world, registry| {
|
||||
let water = registry.default_state("minecraft:water").unwrap();
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
for cx in -1..2 {
|
||||
for cz in -1..1 {
|
||||
world
|
||||
.chunks
|
||||
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
|
||||
}
|
||||
}
|
||||
for x in -8..=18 {
|
||||
for y in 60..=66 {
|
||||
for z in -4..=4 {
|
||||
let block = if wall && x == 3 { stone } else { water };
|
||||
world.set(x, y, z, block).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `NautilusAi.findNearestValidAttackTarget`: past the cooldown and the
|
||||
/// level random's 1 in 2, the target is the closest #nautilus_hostiles in
|
||||
/// water among NEAREST_VISIBLE_LIVING_ENTITIES, so not a pufferfish behind
|
||||
/// stone. Each seed is a fresh level random.
|
||||
fn targets(wall: bool, fish_at: Vec3) -> Option<Vec<Option<Who>>> {
|
||||
let mut out = None;
|
||||
let mut fish = None;
|
||||
crate::mobs::world::with_built_test_world(
|
||||
&[],
|
||||
|_, _| {},
|
||||
|_, types| {
|
||||
let kind = Arc::new(types.types["minecraft:pufferfish"].clone());
|
||||
fish = Some(Mob::new(2, kind, fish_at, 0.0).seen(None));
|
||||
},
|
||||
);
|
||||
let fish = fish?;
|
||||
crate::mobs::world::with_built_test_world(&[fish], pool(wall), |w, types| {
|
||||
let kind = Arc::new(types.types["minecraft:nautilus"].clone());
|
||||
let picks = (0..16)
|
||||
.map(|seed| {
|
||||
let mut m = Mob::new(1, kind.clone(), Vec3::new(0.5, 62.0, 0.5), 270.0);
|
||||
m.fluids.in_water = true;
|
||||
let mut level = horizon_core::random::LegacyRandom::new(seed);
|
||||
find_target(&mut m, w, &mut level)
|
||||
})
|
||||
.collect();
|
||||
out = Some(picks);
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nautilus_only_targets_a_pufferfish_it_sees() {
|
||||
let near = Vec3::new(6.5, 62.0, 0.5);
|
||||
// 16 out on x and 3 on z: inside the sensor's box, past range(16).
|
||||
let far = Vec3::new(16.5, 62.0, 3.5);
|
||||
let (Some(open), Some(walled), Some(beyond)) = (
|
||||
targets(false, near),
|
||||
targets(true, near),
|
||||
targets(false, far),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
assert!(open.contains(&Some(Who::Mob(2))), "{open:?}");
|
||||
assert!(open.iter().all(|t| t.is_none() || *t == Some(Who::Mob(2))));
|
||||
assert!(walled.iter().all(Option::is_none), "{walled:?}");
|
||||
assert!(beyond.iter().all(Option::is_none), "{beyond:?}");
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
//! (`MAIN_GENE_ID`, `HIDDEN_GENE_ID`); the rest is [`State`], whose `flags`,
|
||||
//! `unhappy`, `sneeze` and `eat` are the synched data a server sends.
|
||||
use super::{
|
||||
DamageKind, Mob, MobEvent, MobWorld, Species, Who,
|
||||
Mob, MobEvent, MobWorld, Species, Who,
|
||||
body_anim::Panda as Pose,
|
||||
finalize::{self, Attr},
|
||||
goals::{
|
||||
@@ -708,19 +708,11 @@ impl Goal for PandaPanic {
|
||||
self.0.stop(m, cx)
|
||||
}
|
||||
}
|
||||
/// `DamageTypeTags.PANIC_ENVIRONMENTAL_CAUSES`, or burning.
|
||||
/// `DamageTypeTags.PANIC_ENVIRONMENTAL_CAUSES` (`armadillo::environmental`), or burning.
|
||||
fn environmental(m: &Mob) -> bool {
|
||||
m.fire_ticks > 0 && !m.kind.flags.fire_immune
|
||||
|| m.last_damage.is_some_and(|(d, t)| {
|
||||
matches!(
|
||||
d.kind,
|
||||
DamageKind::OnFire
|
||||
| DamageKind::InFire
|
||||
| DamageKind::Lava
|
||||
| DamageKind::Cactus
|
||||
| DamageKind::LightningBolt
|
||||
| DamageKind::HotFloor
|
||||
) && i64::from(m.age) - t <= 40
|
||||
super::armadillo::environmental(d.kind) && i64::from(m.age) - t <= 40
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1095,3 +1087,27 @@ pub fn register(m: &mut Mob) {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod freeze_panic_tests {
|
||||
use super::*;
|
||||
|
||||
/// `#panic_environmental_causes` holds freeze and sulfur_cube_hot too.
|
||||
#[test]
|
||||
fn freezing_and_a_hot_sulfur_cube_are_environmental() {
|
||||
let kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
|
||||
for cause in [
|
||||
super::super::DamageKind::Freeze,
|
||||
super::super::DamageKind::SulfurCubeHot,
|
||||
] {
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
std::sync::Arc::new(kind.clone()),
|
||||
horizon_core::geometry::Vec3::ZERO,
|
||||
0.0,
|
||||
);
|
||||
m.last_damage = Some((super::super::Damage::of(cause), i64::from(m.age)));
|
||||
assert!(environmental(&m), "{cause:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
|
||||
use super::{
|
||||
DamageKind, Mob, MobWorld, Species, Who,
|
||||
Mob, MobWorld, Species, Who,
|
||||
goals::{
|
||||
Cx, Float, FollowParent, Goal, HurtByTarget, LookAtPlayer, MOVE, MeleeAttack,
|
||||
NearestTarget, Panic, RandomLookAround, Stroll, goal_clone,
|
||||
@@ -88,20 +88,12 @@ impl Goal for PolarBearPanic {
|
||||
self.0.stop(m, cx)
|
||||
}
|
||||
}
|
||||
/// `DamageTypeTags.PANIC_ENVIRONMENTAL_CAUSES` (fire, lava, cactus), or
|
||||
/// `DamageTypeTags.PANIC_ENVIRONMENTAL_CAUSES` (`armadillo::environmental`), or
|
||||
/// burning.
|
||||
fn environmental_panic(m: &Mob) -> bool {
|
||||
m.fire_ticks > 0 && !m.kind.flags.fire_immune
|
||||
|| m.last_damage.is_some_and(|(d, t)| {
|
||||
matches!(
|
||||
d.kind,
|
||||
DamageKind::OnFire
|
||||
| DamageKind::InFire
|
||||
| DamageKind::Lava
|
||||
| DamageKind::Cactus
|
||||
| DamageKind::LightningBolt
|
||||
| DamageKind::HotFloor
|
||||
) && i64::from(m.age) - t <= 40
|
||||
super::armadillo::environmental(d.kind) && i64::from(m.age) - t <= 40
|
||||
})
|
||||
}
|
||||
|
||||
@@ -276,3 +268,27 @@ mod tests {
|
||||
assert_eq!(m.dims().1, f64::from(base_h * (1.0 + 1.0 / STAND_TICKS)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod freeze_panic_tests {
|
||||
use super::*;
|
||||
|
||||
/// `#panic_environmental_causes` holds freeze and sulfur_cube_hot too.
|
||||
#[test]
|
||||
fn freezing_and_a_hot_sulfur_cube_are_environmental() {
|
||||
let kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
|
||||
for cause in [
|
||||
super::super::DamageKind::Freeze,
|
||||
super::super::DamageKind::SulfurCubeHot,
|
||||
] {
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
std::sync::Arc::new(kind.clone()),
|
||||
horizon_core::geometry::Vec3::ZERO,
|
||||
0.0,
|
||||
);
|
||||
m.last_damage = Some((super::super::Damage::of(cause), i64::from(m.age)));
|
||||
assert!(environmental_panic(&m), "{cause:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -566,7 +566,7 @@ mod tests {
|
||||
// Animal.addAdditionalSaveData / readAdditionalSaveData: "InLove" (#1396).
|
||||
let mut m = rabbit();
|
||||
m.extra.in_love = 570;
|
||||
let tag = crate::entity_nbt::mob_to_nbt(&m, [0, 0, 0, 1], None, None);
|
||||
let tag = crate::entity_nbt::mob_to_nbt(&m, [0, 0, 0, 1], None, None, |_| None);
|
||||
assert_eq!(tag.get("InLove"), Some(&horizon_world::nbt::Tag::Int(570)));
|
||||
let types = HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
|
||||
let back = crate::entity_nbt::mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
|
||||
@@ -757,7 +757,7 @@ mod tests {
|
||||
.is_some_and(|d| d.ominous_banner)
|
||||
);
|
||||
assert!(is_captain(&m));
|
||||
let saved = crate::entity_nbt::mob_to_nbt(&m, [0; 4], None, None);
|
||||
let saved = crate::entity_nbt::mob_to_nbt(&m, [0; 4], None, None, |_| None);
|
||||
let chances = saved.get("drop_chances").unwrap();
|
||||
assert_eq!(
|
||||
chances.get("head"),
|
||||
|
||||
@@ -1003,7 +1003,7 @@ mod tests {
|
||||
assert_eq!(map.get(&"sleeping_pos".into()), expected.as_ref());
|
||||
assert_eq!(map.get(&"h232_control".into()), Some(&Tag::Int(42)));
|
||||
}
|
||||
let saved = crate::entity_nbt::mob_to_nbt(&m, [0; 4], None, None);
|
||||
let saved = crate::entity_nbt::mob_to_nbt(&m, [0; 4], None, None, |_| None);
|
||||
assert_eq!(saved.get("sleeping_pos"), expected.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 333 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user