mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
285ecb6e89 | ||
|
|
5509fe8a5a | ||
|
|
547ea7cdef | ||
|
|
372b701542 | ||
|
|
abd2d1742a | ||
|
|
b423fa09d2 | ||
|
|
f352418364 | ||
|
|
1e57b381ef | ||
|
|
0eba0b37b8 | ||
|
|
390794b454 | ||
|
|
54e56ca215 | ||
|
|
a185086f24 | ||
|
|
d026f1eb37 | ||
|
|
cdefed7a19 | ||
|
|
29d24f48df | ||
|
|
21ba38e633 | ||
|
|
a278572906 | ||
|
|
859b6be783 | ||
|
|
a1542cb263 | ||
|
|
f049a08e48 | ||
|
|
f5f5532c19 | ||
|
|
7711bfcdc5 | ||
|
|
2ac08efcb2 | ||
|
|
559b83626f | ||
|
|
c2915208fd | ||
|
|
04bc30606d | ||
|
|
6893bcff88 | ||
|
|
aae98d3db5 | ||
|
|
41f60562fd | ||
|
|
136b352e4f | ||
|
|
ff1b01616f | ||
|
|
3246732087 | ||
|
|
c215a48c91 | ||
|
|
6aabf3f618 | ||
|
|
36ba5c65a6 | ||
|
|
22054eee90 | ||
|
|
1a80673d50 | ||
|
|
75267d2fc6 | ||
|
|
9b88df32d3 |
No files matched your search
@@ -574,6 +574,9 @@ pub struct Renderer {
|
||||
pub screenshot: Option<PathBuf>,
|
||||
/// Outcome of the last screenshot: frame size, or the error.
|
||||
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
|
||||
/// Write the next frame's centre square as the world's 64x64 `icon.png`
|
||||
/// (GameRenderer.takeAutoScreenshot).
|
||||
pub world_icon: Option<PathBuf>,
|
||||
/// Brightness option (lightmap gamma), 0..1.
|
||||
pub gamma: f32,
|
||||
/// Optional full-light lightmap (Horizon Extras).
|
||||
@@ -2669,6 +2672,7 @@ impl Renderer {
|
||||
started: Instant::now(),
|
||||
screenshot: None,
|
||||
screenshot_result: None,
|
||||
world_icon: None,
|
||||
gamma: 0.5,
|
||||
fullbright: false,
|
||||
animated_textures: true,
|
||||
@@ -5113,6 +5117,18 @@ impl Renderer {
|
||||
pass.draw_indexed(0..6, (i * 4) as i32, 0..1);
|
||||
}
|
||||
}
|
||||
// GameRenderer.tryTakeScreenshotIfNeeded runs after the level (hand and
|
||||
// screen effects included) and before the entity outline and the post
|
||||
// chain: the icon is the plain scene, with no GUI either.
|
||||
let eight_bit = self.config.format.block_copy_size(None) == Some(4);
|
||||
let icon = self.world_icon.take().and_then(|path| {
|
||||
if eight_bit {
|
||||
Some((path, self.copy_frame(&mut encoder, &target_texture)))
|
||||
} else {
|
||||
log::warn!("Couldn't save auto screenshot: no 32-bit surface format");
|
||||
None
|
||||
}
|
||||
});
|
||||
// GameRenderer.render3dHud: the debug crosshair after the hand and
|
||||
// the screen effects.
|
||||
if crosshair > 0 {
|
||||
@@ -5192,7 +5208,7 @@ impl Renderer {
|
||||
let readback = self.screenshot.take().and_then(|path| {
|
||||
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
|
||||
// the row stride too small for the copy and abort in validation.
|
||||
if self.config.format.block_copy_size(None) == Some(4) {
|
||||
if eight_bit {
|
||||
Some((path, self.copy_frame(&mut encoder, &target_texture)))
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(format!(
|
||||
@@ -5233,6 +5249,11 @@ impl Renderer {
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
}
|
||||
if let Some((path, buffer)) = icon
|
||||
&& let Err(e) = self.write_world_icon(&path, &buffer)
|
||||
{
|
||||
log::warn!("Couldn't save auto screenshot: {e}");
|
||||
}
|
||||
let present = Instant::now();
|
||||
if let Some(surface_frame) = surface_frame {
|
||||
// On Wayland this ties RedrawRequested to the compositor's frame
|
||||
@@ -5313,6 +5334,16 @@ impl Renderer {
|
||||
buffer
|
||||
}
|
||||
fn write_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<[u32; 2]> {
|
||||
let (width, height, pixels) = self.read_frame(buffer)?;
|
||||
write_png(path, width, height, &pixels)?;
|
||||
Ok([width, height])
|
||||
}
|
||||
/// GameRenderer.takeAutoScreenshot: the centre square, scaled to 64x64.
|
||||
fn write_world_icon(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
|
||||
let (width, height, pixels) = self.read_frame(buffer)?;
|
||||
write_png(path, 64, 64, &world_icon_pixels(&pixels, width, height))
|
||||
}
|
||||
fn read_frame(&self, buffer: &wgpu::Buffer) -> Result<(u32, u32, Vec<u8>)> {
|
||||
if !self.config.usage.contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
return Err("this surface does not support frame readback".into());
|
||||
}
|
||||
@@ -5350,17 +5381,51 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(path)?),
|
||||
width,
|
||||
height,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder.write_header()?.write_image_data(&pixels)?;
|
||||
Ok([width, height])
|
||||
Ok((width, height, pixels))
|
||||
}
|
||||
}
|
||||
fn write_png(path: &std::path::Path, width: u32, height: u32, pixels: &[u8]) -> Result<()> {
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(path)?),
|
||||
width,
|
||||
height,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder.write_header()?.write_image_data(pixels)?;
|
||||
Ok(())
|
||||
}
|
||||
/// `NativeImage.resizeSubRectTo` from the screen's centre to 64x64. Vanilla
|
||||
/// resamples with STB; this averages each destination texel's source block,
|
||||
/// which differs by a texel where a block does not divide evenly.
|
||||
fn world_icon_pixels(pixels: &[u8], width: u32, height: u32) -> Vec<u8> {
|
||||
let side = width.min(height) as usize;
|
||||
if side == 0 {
|
||||
return vec![0; 64 * 64 * 4];
|
||||
}
|
||||
let (left, top) = ((width as usize - side) / 2, (height as usize - side) / 2);
|
||||
let mut out = Vec::with_capacity(64 * 64 * 4);
|
||||
for y in 0..64 {
|
||||
let y0 = y * side / 64;
|
||||
let y1 = ((y + 1) * side).div_ceil(64).max(y0 + 1).min(side);
|
||||
for x in 0..64 {
|
||||
let x0 = x * side / 64;
|
||||
let x1 = ((x + 1) * side).div_ceil(64).max(x0 + 1).min(side);
|
||||
let mut sum = [0u32; 4];
|
||||
for sy in y0..y1 {
|
||||
for sx in x0..x1 {
|
||||
let i = ((top + sy) * width as usize + left + sx) * 4;
|
||||
for c in 0..4 {
|
||||
sum[c] += u32::from(pixels[i + c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = ((y1 - y0) * (x1 - x0)) as u32;
|
||||
out.extend(sum.map(|c| (c / n) as u8));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Camera-relative clip planes (`Frustum`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Frustum {
|
||||
@@ -5452,6 +5517,32 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn world_icon_crops_the_centre_and_scales_to_64() {
|
||||
use super::world_icon_pixels;
|
||||
// 128x64, red left half and blue right half: the centre square is
|
||||
// 64x64 at x=32, so it keeps half of each colour.
|
||||
let mut pixels = Vec::with_capacity(128 * 64 * 4);
|
||||
for _ in 0..128 * 64 {
|
||||
pixels.extend_from_slice(&[255, 0, 0, 255]);
|
||||
}
|
||||
for y in 0..64 {
|
||||
for x in 64..128 {
|
||||
let i = (y * 128 + x) * 4;
|
||||
pixels[i..i + 4].copy_from_slice(&[0, 0, 255, 255]);
|
||||
}
|
||||
}
|
||||
let icon = world_icon_pixels(&pixels, 128, 64);
|
||||
assert_eq!(icon.len(), 64 * 64 * 4);
|
||||
assert_eq!(&icon[..4], [255, 0, 0, 255]);
|
||||
let last = (63 * 64 + 63) * 4;
|
||||
assert_eq!(&icon[last..last + 4], [0, 0, 255, 255]);
|
||||
// A source smaller than the icon repeats its texels rather than
|
||||
// indexing past them.
|
||||
let tiny = world_icon_pixels(&[1, 2, 3, 255], 1, 1);
|
||||
assert!(tiny.as_chunks::<4>().0.iter().all(|p| p == &[1, 2, 3, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_is_8_bit_and_not_srgb() {
|
||||
use super::surface_format;
|
||||
|
||||
@@ -103,10 +103,11 @@ impl SpriteAnimator {
|
||||
let mut counts: Vec<usize> = Vec::new();
|
||||
let mut sprites = Vec::with_capacity(animations.len());
|
||||
for animation in animations {
|
||||
let chains: Vec<_> = (0..animation.frame_count())
|
||||
.map(|frame| source_chain(animation.pixels(frame), size, settings, animation.layer))
|
||||
let pixels: Vec<_> = (0..animation.frame_count())
|
||||
.map(|frame| animation.pixels(frame))
|
||||
.collect();
|
||||
let bytes: u64 = chains.iter().map(|(chain, _)| chain.len() as u64).sum();
|
||||
let (chains, edge) = source_chains(&pixels, size, settings, animation.layer);
|
||||
let bytes: u64 = chains.iter().map(|chain| chain.len() as u64).sum();
|
||||
if bytes > limit {
|
||||
log::warn!(
|
||||
"Horizon: layer {}'s animation ({bytes} bytes) does not fit in a storage buffer; it stays still",
|
||||
@@ -124,7 +125,7 @@ impl SpriteAnimator {
|
||||
let data = &mut frames[buffer];
|
||||
let offsets = chains
|
||||
.iter()
|
||||
.map(|(chain, _)| {
|
||||
.map(|chain| {
|
||||
let offset = (data.len() / 4) as u32;
|
||||
data.extend_from_slice(chain);
|
||||
offset
|
||||
@@ -142,7 +143,7 @@ impl SpriteAnimator {
|
||||
shown: None,
|
||||
layer: animation.layer % split,
|
||||
frames: offsets,
|
||||
edge: chains[0].1,
|
||||
edge,
|
||||
group,
|
||||
});
|
||||
}
|
||||
@@ -424,15 +425,63 @@ fn mix_level(frames: &[u8], [_, current, next, millis, edge]: [u32; 5], width: u
|
||||
/// array's size from its own size's chain, its first level repeated into the
|
||||
/// larger levels: that chain, which the shader repeats. Any other sprite: its
|
||||
/// chain at the array's size.
|
||||
#[cfg(test)]
|
||||
fn source_chain(pixels: &[u8], size: u32, settings: &[Settings], layer: u32) -> (Vec<u8>, u32) {
|
||||
let native = settings.get(layer as usize).map_or(0, |s| s.native_size);
|
||||
let edge = if native.is_power_of_two() && native < size {
|
||||
native
|
||||
let (mut chains, edge) = source_chains(&[pixels], size, settings, layer);
|
||||
(chains.swap_remove(0), edge)
|
||||
}
|
||||
|
||||
/// Every frame's chain, laid out as one frame's `source_chain`, but mipmapped as
|
||||
/// vanilla's `SpriteContents.increaseMipLevel` does: over the whole animation
|
||||
/// sheet (the frames stacked), so `solidify` and the alpha-to-coverage scale
|
||||
/// of each level are the sheet's, not each frame's. A frame alone has a
|
||||
/// single texel square at its 2x2 level, and its coverage scale could round
|
||||
/// a whole far frame transparent (kelp blinking out at distance).
|
||||
fn source_chains(
|
||||
frames: &[&[u8]],
|
||||
size: u32,
|
||||
settings: &[Settings],
|
||||
layer: u32,
|
||||
) -> (Vec<Vec<u8>>, u32) {
|
||||
let s = settings.get(layer as usize).copied().unwrap_or_default();
|
||||
let edge = if s.native_size.is_power_of_two() && s.native_size < size {
|
||||
s.native_size
|
||||
} else {
|
||||
size
|
||||
};
|
||||
let frame = crate::sprites::resample(pixels, size, edge);
|
||||
(super::mip_chain(&frame, edge, settings, layer, 1).0, edge)
|
||||
let mut sheet = crate::mipmap::Image {
|
||||
width: edge,
|
||||
height: edge * frames.len() as u32,
|
||||
pixels: Vec::with_capacity((edge * edge) as usize * frames.len()),
|
||||
};
|
||||
for frame in frames {
|
||||
let frame = crate::sprites::resample(frame, size, edge);
|
||||
sheet.pixels.extend_from_slice(frame.as_chunks::<4>().0);
|
||||
}
|
||||
let has_transparent = s.has_transparent || sheet.pixels.iter().any(|p| p[3] == 0);
|
||||
let levels = crate::mipmap::generate(
|
||||
sheet,
|
||||
edge.trailing_zeros(),
|
||||
Settings {
|
||||
has_transparent,
|
||||
..s
|
||||
},
|
||||
);
|
||||
let chains = (0..frames.len())
|
||||
.map(|frame| {
|
||||
let mut chain = Vec::new();
|
||||
for level in &levels {
|
||||
let texels = (level.width * level.width) as usize;
|
||||
chain.extend(
|
||||
level.pixels[frame * texels..(frame + 1) * texels]
|
||||
.iter()
|
||||
.flatten(),
|
||||
);
|
||||
}
|
||||
chain
|
||||
})
|
||||
.collect();
|
||||
(chains, edge)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -572,6 +621,63 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kelp_frames_share_the_sheet_mips() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root
|
||||
.join("assets/minecraft/textures/block/kelp.png")
|
||||
.exists()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut sprites = crate::sprites::Sprites::default();
|
||||
for name in ["kelp", "kelp_plant", "seagrass", "tall_seagrass_top"] {
|
||||
sprites
|
||||
.insert(&root, &format!("minecraft:block/{name}"))
|
||||
.unwrap();
|
||||
}
|
||||
sprites.finish();
|
||||
// A level drawn nowhere: no texel passes the cutout's alpha test.
|
||||
let blank = |chain: &[u8], edge: u32| {
|
||||
let mut start = 0;
|
||||
let mut level = edge;
|
||||
let mut blank = Vec::new();
|
||||
while level > 0 {
|
||||
let bytes = (level * level * 4) as usize;
|
||||
let texels = &chain[start..start + bytes];
|
||||
blank.push(texels.as_chunks::<4>().0.iter().all(|p| p[3] < 128));
|
||||
start += bytes;
|
||||
level >>= 1;
|
||||
}
|
||||
blank
|
||||
};
|
||||
let mut blinked = 0;
|
||||
for animation in &sprites.animations {
|
||||
let size = sprites.size;
|
||||
let pixels: Vec<_> = (0..animation.frame_count())
|
||||
.map(|f| animation.pixels(f))
|
||||
.collect();
|
||||
let (chains, edge) = source_chains(&pixels, size, &sprites.mips, animation.layer);
|
||||
for (frame, chain) in chains.iter().enumerate() {
|
||||
// The 1x1 level has no texel square to measure coverage on
|
||||
// (vanilla leaves it unscaled); every larger level shows.
|
||||
let levels = blank(chain, edge);
|
||||
assert!(
|
||||
!levels[..levels.len() - 1].contains(&true),
|
||||
"layer {} frame {frame}: {levels:?}",
|
||||
animation.layer
|
||||
);
|
||||
let (alone, _) = source_chain(pixels[frame], size, &sprites.mips, animation.layer);
|
||||
let alone = blank(&alone, edge);
|
||||
blinked += usize::from(alone[..alone.len() - 1].contains(&true));
|
||||
}
|
||||
}
|
||||
// Mipmapped frame by frame, some kelp frames vanished at the 2x2 level.
|
||||
assert!(blinked > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounding_matches_f32_mix_but_for_exact_halves() {
|
||||
// The CPU blend this replaced: f32 mix, then rounded. They differ
|
||||
@@ -730,8 +836,11 @@ mod tests {
|
||||
queue.submit([encoder.finish()]);
|
||||
for animation in &animations {
|
||||
let blend = animation.blend(tick);
|
||||
let chain = |f| source_chain(animation.pixels(f), size, &mips, animation.layer);
|
||||
let ((current, edge), (next, _)) = (chain(blend.frame), chain(blend.next));
|
||||
let pixels: Vec<_> = (0..animation.frame_count())
|
||||
.map(|f| animation.pixels(f))
|
||||
.collect();
|
||||
let (chains, edge) = source_chains(&pixels, size, &mips, animation.layer);
|
||||
let (current, next) = (&chains[blend.frame], &chains[blend.next]);
|
||||
let layers = read_back(
|
||||
&device,
|
||||
&queue,
|
||||
@@ -739,7 +848,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
layers[(animation.layer % split) as usize],
|
||||
reference(¤t, &next, blend.millis, edge, size),
|
||||
reference(current, next, blend.millis, edge, size),
|
||||
"layer {} at tick {tick}",
|
||||
animation.layer
|
||||
);
|
||||
|
||||
@@ -417,6 +417,22 @@ fn build_overlay_uncached(
|
||||
files.insert(name, (i, provided));
|
||||
}
|
||||
}
|
||||
// FallbackResourceManager: a file's `.mcmeta` counts only from the pack that
|
||||
// supplies the file or one above it; vanilla's own sits below every pack.
|
||||
let hidden: BTreeSet<String> = files
|
||||
.iter()
|
||||
.filter(|(name, _)| !name.ends_with(".mcmeta"))
|
||||
.filter_map(|(name, (owner, _))| {
|
||||
let meta = format!("{name}.mcmeta");
|
||||
match files.get(&meta) {
|
||||
Some((meta_owner, _)) if meta_owner >= owner => None,
|
||||
_ => Some(meta),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for name in &hidden {
|
||||
files.remove(name);
|
||||
}
|
||||
// Vanilla sits below every pack, so filters hide its files too.
|
||||
let filters: Vec<&Filter> = packs.iter().flat_map(|p| &p.filter).collect();
|
||||
let touched_dirs: BTreeSet<String> = files
|
||||
@@ -468,6 +484,7 @@ fn build_overlay_uncached(
|
||||
vanilla: &Path,
|
||||
out: &Path,
|
||||
touched: &BTreeSet<String>,
|
||||
hidden: &BTreeSet<String>,
|
||||
files: &BTreeMap<String, (usize, Provided)>,
|
||||
merged: &BTreeMap<String, Vec<u8>>,
|
||||
filters: &[&Filter],
|
||||
@@ -504,6 +521,9 @@ fn build_overlay_uncached(
|
||||
}
|
||||
for name in names {
|
||||
let child = format!("{prefix}{name}");
|
||||
if hidden.contains(&child) {
|
||||
continue;
|
||||
}
|
||||
if let Some(bytes) = merged.get(&child) {
|
||||
fs::write(out.join(&child), bytes)?;
|
||||
} else if let Some((owner, (path, inner))) = files.get(&child) {
|
||||
@@ -528,6 +548,7 @@ fn build_overlay_uncached(
|
||||
vanilla,
|
||||
out,
|
||||
touched,
|
||||
hidden,
|
||||
files,
|
||||
merged,
|
||||
filters,
|
||||
@@ -551,6 +572,7 @@ fn build_overlay_uncached(
|
||||
vanilla,
|
||||
out,
|
||||
&touched_dirs,
|
||||
&hidden,
|
||||
&files,
|
||||
&merged,
|
||||
&filters,
|
||||
@@ -741,7 +763,9 @@ impl SelectionModel {
|
||||
/// `available` in any order; `selected` as options.txt lists them,
|
||||
/// bottom first.
|
||||
pub fn new(mut available: Vec<Selectable>, selected: &[String]) -> Self {
|
||||
// PackRepository.available is a TreeMap by id (String.compareTo).
|
||||
// PackRepository.discoverAvailable: every source's packs go into one
|
||||
// TreeMap by id (String.compareTo), so `file/` packs sort among the
|
||||
// built-in ones.
|
||||
available.sort_by(|a, b| a.id.encode_utf16().cmp(b.id.encode_utf16()));
|
||||
available.dedup_by(|a, b| a.id == b.id);
|
||||
let mut m = Self {
|
||||
|
||||
@@ -49,6 +49,9 @@ pub fn sound_source(event: &str) -> usize {
|
||||
index("ambient")
|
||||
} else if e.starts_with("music_disc.") {
|
||||
index("record")
|
||||
} else if e.starts_with("item.goat_horn.") {
|
||||
// InstrumentItem.play: SoundSource.RECORDS.
|
||||
index("record")
|
||||
} else if e.starts_with("music.") {
|
||||
index("music")
|
||||
} else if e.starts_with("weather.") || e.starts_with("entity.lightning_bolt") {
|
||||
|
||||
@@ -142,8 +142,8 @@ const GENES: [&str; 7] = [
|
||||
"aggressive",
|
||||
];
|
||||
const MEMORIES: [&str; 7] = [
|
||||
"long_jump_cooldown",
|
||||
"ram_cooldown",
|
||||
"long_jump_cooling_down",
|
||||
"ram_cooldown_ticks",
|
||||
"attack_target_cooldown",
|
||||
"hunted_recently",
|
||||
"dig_cooldown",
|
||||
@@ -506,6 +506,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
difficulty,
|
||||
data: &data,
|
||||
created: Vec::new(),
|
||||
jockey_rider: None,
|
||||
};
|
||||
group = finalize::finalize(&mut cx, &mut mob, reason, group);
|
||||
let created = std::mem::take(&mut cx.created);
|
||||
|
||||
@@ -795,7 +795,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
chunk,
|
||||
&mut gen_random,
|
||||
&mut rules,
|
||||
&mut spawn,
|
||||
&mut |l, r, _| spawn(l, r).is_some(),
|
||||
&mut |e| match e {
|
||||
GenEvent::Column(x, z) => log.push(format!("col {x} {z}")),
|
||||
GenEvent::Spawn(t, p, yaw) => spawns.push(format!(
|
||||
|
||||
@@ -37,9 +37,11 @@ pub enum Kind {
|
||||
columns: i32,
|
||||
button_width: i32,
|
||||
},
|
||||
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs).
|
||||
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs),
|
||||
/// and one button per dialog they resolve to (`set_list`).
|
||||
DialogList {
|
||||
dialogs: Tag,
|
||||
entries: Vec<Button>,
|
||||
exit: Option<Button>,
|
||||
columns: i32,
|
||||
button_width: i32,
|
||||
@@ -325,29 +327,36 @@ impl Control {
|
||||
fn parse(t: &Tag) -> Option<Self> {
|
||||
let label = || t.get("label").map(text);
|
||||
Some(match path(&string(t.get("type"))?) {
|
||||
"text" => Self::Text {
|
||||
width: int(t, "width", 200),
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
initial: string(t.get("initial")).unwrap_or_default(),
|
||||
max_length: int(t, "max_length", 32),
|
||||
multiline: t.get("multiline").map(|m| {
|
||||
let get = |key| number(m.get(key)).map(|v| v as i32);
|
||||
(get("max_lines"), get("height"))
|
||||
}),
|
||||
},
|
||||
"text" => {
|
||||
let initial = string(t.get("initial")).unwrap_or_default();
|
||||
let max_length = int(t, "max_length", 32);
|
||||
// TextInput: the default text may not be longer than the length.
|
||||
if initial.encode_utf16().count() > max_length.max(0) as usize {
|
||||
return None;
|
||||
}
|
||||
Self::Text {
|
||||
width: int(t, "width", 200),
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
initial,
|
||||
max_length,
|
||||
multiline: t.get("multiline").map(|m| {
|
||||
let get = |key| number(m.get(key)).map(|v| v as i32);
|
||||
(get("max_lines"), get("height"))
|
||||
}),
|
||||
}
|
||||
}
|
||||
"boolean" => Self::Boolean {
|
||||
label: label()?,
|
||||
initial: boolean(t, "initial", false),
|
||||
on_true: string(t.get("on_true")).unwrap_or_else(|| "true".into()),
|
||||
on_false: string(t.get("on_false")).unwrap_or_else(|| "false".into()),
|
||||
},
|
||||
"single_option" => Self::SingleOption {
|
||||
width: int(t, "width", 200),
|
||||
"single_option" => {
|
||||
// SingleOptionInput.Entry.CODEC: a full entry or just its id.
|
||||
options: list(t.get("options"))
|
||||
let options: Vec<_> = list(t.get("options"))
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
.map(|o| match o {
|
||||
Tag::String(id) => Some((id.to_string_lossy(), None, false)),
|
||||
o => Some((
|
||||
string(o.get("id"))?,
|
||||
@@ -355,10 +364,18 @@ impl Control {
|
||||
boolean(o, "initial", false),
|
||||
)),
|
||||
})
|
||||
.collect(),
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
},
|
||||
.collect::<Option<_>>()?;
|
||||
// A non-empty list with at most one initial entry.
|
||||
if options.is_empty() || options.iter().filter(|o| o.2).count() > 1 {
|
||||
return None;
|
||||
}
|
||||
Self::SingleOption {
|
||||
width: int(t, "width", 200),
|
||||
options,
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
}
|
||||
}
|
||||
"number_range" => {
|
||||
let float = |key| number(t.get(key)).map(|v| v as f32);
|
||||
Self::NumberRange {
|
||||
@@ -395,6 +412,44 @@ impl Control {
|
||||
)),
|
||||
}
|
||||
}
|
||||
/// The text on a cycle button or slider holding `value`
|
||||
/// (`CycleButton.createFullName`, `NumberRangeInput.computeLabel`).
|
||||
pub fn message(&self, value: &InputValue) -> Option<Text> {
|
||||
match (self, value) {
|
||||
(
|
||||
Self::SingleOption {
|
||||
options,
|
||||
label,
|
||||
label_visible,
|
||||
..
|
||||
},
|
||||
InputValue::Option(i),
|
||||
) => {
|
||||
let (id, display, _) = options.get(*i)?;
|
||||
let shown = display.clone().unwrap_or_else(|| Text::literal(id));
|
||||
Some(if *label_visible {
|
||||
Text::translate("options.generic_value", &[label.clone(), shown])
|
||||
} else {
|
||||
shown
|
||||
})
|
||||
}
|
||||
(
|
||||
Self::NumberRange {
|
||||
label,
|
||||
label_format,
|
||||
..
|
||||
},
|
||||
InputValue::Slider(_),
|
||||
) => {
|
||||
let shown = number_string(self.scaled(value)?);
|
||||
Some(Text::translate(
|
||||
label_format,
|
||||
&[label.clone(), Text::literal(shown)],
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `ValueGetter.asTemplateSubstitution`.
|
||||
pub fn substitution(&self, value: &InputValue) -> String {
|
||||
match (self, value) {
|
||||
@@ -536,6 +591,7 @@ impl Dialog {
|
||||
},
|
||||
"dialog_list" => Kind::DialogList {
|
||||
dialogs: t.get("dialogs")?.clone(),
|
||||
entries: Vec::new(),
|
||||
exit: button("exit_action"),
|
||||
columns: int(t, "columns", 2),
|
||||
button_width: int(t, "button_width", 150),
|
||||
@@ -543,20 +599,21 @@ impl Dialog {
|
||||
_ => return None,
|
||||
};
|
||||
// DialogBody.COMPACT_LIST_CODEC: one body, or a list of them.
|
||||
// A body or input it cannot read fails the whole dialog.
|
||||
let body = match t.get("body") {
|
||||
Some(Tag::List(items)) => items.iter().filter_map(Body::parse).collect(),
|
||||
Some(one) => Body::parse(one).into_iter().collect(),
|
||||
Some(Tag::List(items)) => items.iter().map(Body::parse).collect::<Option<_>>()?,
|
||||
Some(one) => vec![Body::parse(one)?],
|
||||
None => Vec::new(),
|
||||
};
|
||||
let inputs = list(t.get("inputs"))
|
||||
.iter()
|
||||
.filter_map(|i| {
|
||||
.map(|i| {
|
||||
Some(Input {
|
||||
key: string(i.get("key"))?,
|
||||
control: Control::parse(i)?,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
.collect::<Option<_>>()?;
|
||||
Some(Self {
|
||||
kind,
|
||||
title: text(t.get("title")?),
|
||||
@@ -588,7 +645,51 @@ impl Dialog {
|
||||
Kind::Notice(b) => vec![b],
|
||||
Kind::Confirmation { yes, no } => vec![yes, no],
|
||||
Kind::MultiAction { actions, exit, .. } => actions.iter().chain(exit).collect(),
|
||||
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => exit.iter().collect(),
|
||||
Kind::ServerLinks { exit, .. } => exit.iter().collect(),
|
||||
Kind::DialogList { entries, exit, .. } => entries.iter().chain(exit).collect(),
|
||||
}
|
||||
}
|
||||
/// `DialogListDialogScreen.createListActions`: a button for each of `dialogs`
|
||||
/// (what the list names), labelled by its external title, opening it.
|
||||
pub fn set_list(&mut self, dialogs: &[Tag]) {
|
||||
let Kind::DialogList {
|
||||
entries,
|
||||
button_width,
|
||||
..
|
||||
} = &mut self.kind
|
||||
else {
|
||||
return;
|
||||
};
|
||||
*entries = dialogs
|
||||
.iter()
|
||||
.filter_map(|tag| {
|
||||
let sub = Dialog::parse(tag)?;
|
||||
Some(Button {
|
||||
label: sub.external_title.unwrap_or(sub.title),
|
||||
tooltip: None,
|
||||
width: *button_width,
|
||||
action: Some(Action::Static(Click::ShowDialog(tag.clone()))),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
/// The dialogs a `dialog_list`'s `dialogs` names: the entries of a `#tag`
|
||||
/// (by `tag`), registry ids (by `registry`) and dialogs written inline.
|
||||
pub fn list_entries(
|
||||
dialogs: &Tag,
|
||||
tag: &dyn Fn(&str) -> Vec<Tag>,
|
||||
registry: &dyn Fn(&str) -> Option<Tag>,
|
||||
) -> Vec<Tag> {
|
||||
let one = |t: &Tag| match t {
|
||||
Tag::String(id) => registry(&id.to_string_lossy()),
|
||||
inline => Some(inline.clone()),
|
||||
};
|
||||
match dialogs {
|
||||
Tag::String(id) if id.to_string_lossy().starts_with('#') => {
|
||||
tag(&id.to_string_lossy()[1..])
|
||||
}
|
||||
Tag::List(items) => items.iter().filter_map(one).collect(),
|
||||
single => one(single).into_iter().collect(),
|
||||
}
|
||||
}
|
||||
/// What `status` reports of it.
|
||||
|
||||
@@ -31,6 +31,44 @@ pub enum Part {
|
||||
Check { input: usize, rect: [f32; 4] },
|
||||
/// A button of `Dialog::buttons`.
|
||||
Button { index: usize, rect: [f32; 4] },
|
||||
/// A single option input's cycle button.
|
||||
Cycle { input: usize, rect: [f32; 4] },
|
||||
/// A number range input's slider.
|
||||
Slider { input: usize, rect: [f32; 4] },
|
||||
/// An item body's widget; `body` indexes `Dialog::body`.
|
||||
Item { body: usize, rect: [f32; 4] },
|
||||
}
|
||||
impl Part {
|
||||
/// The part moved by `(dx, dy)`.
|
||||
fn moved(self, dx: f32, dy: f32) -> Self {
|
||||
match self {
|
||||
Self::Line(t, x, y) => Self::Line(t, x + dx, y + dy),
|
||||
Self::Field { input, rect } => Self::Field {
|
||||
input,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
Self::Check { input, rect } => Self::Check {
|
||||
input,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
Self::Button { index, rect } => Self::Button {
|
||||
index,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
Self::Cycle { input, rect } => Self::Cycle {
|
||||
input,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
Self::Slider { input, rect } => Self::Slider {
|
||||
input,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
Self::Item { body, rect } => Self::Item {
|
||||
body,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -55,21 +93,7 @@ struct Element {
|
||||
impl Element {
|
||||
/// Its parts moved to `(x, y)`.
|
||||
fn at(self, x: f32, y: f32) -> impl Iterator<Item = Part> {
|
||||
self.parts.into_iter().map(move |p| match p {
|
||||
Part::Line(t, px, py) => Part::Line(t, px + x, py + y),
|
||||
Part::Field { input, rect } => Part::Field {
|
||||
input,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
Part::Check { input, rect } => Part::Check {
|
||||
input,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
Part::Button { index, rect } => Part::Button {
|
||||
index,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
})
|
||||
self.parts.into_iter().map(move |p| p.moved(x, y))
|
||||
}
|
||||
}
|
||||
fn shift([x, y, w, h]: [f32; 4], dx: f32, dy: f32) -> [f32; 4] {
|
||||
@@ -100,9 +124,43 @@ fn message(font: &Font, contents: &Text, width: f32) -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
/// `InputControlHandlers`: the control for input `i`, if Horizon has it.
|
||||
fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
|
||||
Some(match control {
|
||||
/// `DialogBodyHandlers.ItemHandler`: the item widget, with its description 2
|
||||
/// to the right of it, both centred on each other vertically.
|
||||
fn item(
|
||||
font: &Font,
|
||||
body: usize,
|
||||
description: Option<&(Text, i32)>,
|
||||
(w, h): (f32, f32),
|
||||
) -> Element {
|
||||
let widget = |rect_h: f32, y: f32| Part::Item {
|
||||
body,
|
||||
rect: [0.0, y, w, rect_h],
|
||||
};
|
||||
let Some((contents, width)) = description else {
|
||||
return Element {
|
||||
w,
|
||||
h,
|
||||
parts: vec![widget(h, 0.0)],
|
||||
};
|
||||
};
|
||||
let text = message(font, contents, *width as f32);
|
||||
let height = h.max(text.h);
|
||||
let mut parts = vec![widget(h, centre(height, h))];
|
||||
parts.extend(
|
||||
text.parts
|
||||
.into_iter()
|
||||
.map(|p| p.moved(w + 2.0, centre(height, text.h))),
|
||||
);
|
||||
Element {
|
||||
w: w + 2.0 + text.w,
|
||||
h: height,
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
/// `InputControlHandlers`: the control for input `i`.
|
||||
fn input(font: &Font, i: usize, control: &Control) -> Element {
|
||||
match control {
|
||||
Control::Text {
|
||||
width,
|
||||
label,
|
||||
@@ -111,14 +169,14 @@ fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
|
||||
} => {
|
||||
let w = *width as f32;
|
||||
if !label_visible {
|
||||
return Some(Element {
|
||||
return Element {
|
||||
w,
|
||||
h: 20.0,
|
||||
parts: vec![Part::Field {
|
||||
input: i,
|
||||
rect: [0.0, 0.0, w, 20.0],
|
||||
}],
|
||||
});
|
||||
};
|
||||
}
|
||||
// CommonLayouts.labeledElement: the label, 4 below it the box,
|
||||
// both on the left.
|
||||
@@ -145,8 +203,25 @@ fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
|
||||
}],
|
||||
}
|
||||
}
|
||||
Control::SingleOption { .. } | Control::NumberRange { .. } => return None,
|
||||
})
|
||||
// CycleButton and AbstractSliderButton: one 20 high button whose
|
||||
// message holds the label.
|
||||
Control::SingleOption { width, .. } => Element {
|
||||
w: *width as f32,
|
||||
h: 20.0,
|
||||
parts: vec![Part::Cycle {
|
||||
input: i,
|
||||
rect: [0.0, 0.0, *width as f32, 20.0],
|
||||
}],
|
||||
},
|
||||
Control::NumberRange { width, .. } => Element {
|
||||
w: *width as f32,
|
||||
h: 20.0,
|
||||
parts: vec![Part::Slider {
|
||||
input: i,
|
||||
rect: [0.0, 0.0, *width as f32, 20.0],
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `DialogScreen.packControlsIntoColumns`: full rows in a grid 2 apart, what
|
||||
@@ -210,9 +285,20 @@ impl Layout {
|
||||
let mut elements: Vec<Element> = dialog
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
Body::Message { contents, width } => Some(message(font, contents, *width as f32)),
|
||||
Body::Item { .. } => None,
|
||||
.enumerate()
|
||||
.map(|(i, b)| match b {
|
||||
Body::Message { contents, width } => message(font, contents, *width as f32),
|
||||
Body::Item {
|
||||
description,
|
||||
width,
|
||||
height,
|
||||
..
|
||||
} => item(
|
||||
font,
|
||||
i,
|
||||
description.as_ref(),
|
||||
(*width as f32, *height as f32),
|
||||
),
|
||||
})
|
||||
.collect();
|
||||
elements.extend(
|
||||
@@ -220,26 +306,24 @@ impl Layout {
|
||||
.inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, input_)| input(font, i, &input_.control)),
|
||||
.map(|(i, input_)| input(font, i, &input_.control)),
|
||||
);
|
||||
let buttons = dialog.buttons();
|
||||
let (listed, footer_buttons, footer_h) = match &dialog.kind {
|
||||
Kind::MultiAction {
|
||||
actions,
|
||||
exit,
|
||||
columns: n,
|
||||
exit, columns: n, ..
|
||||
}
|
||||
| Kind::DialogList {
|
||||
exit, columns: n, ..
|
||||
} => {
|
||||
let widths: Vec<f32> = actions.iter().map(|b| b.width as f32).collect();
|
||||
let listed = buttons.len() - usize::from(exit.is_some());
|
||||
let widths: Vec<f32> = buttons[..listed].iter().map(|b| b.width as f32).collect();
|
||||
elements.push(columns(&widths, *n as usize, 0));
|
||||
// ButtonListDialogScreen: the exit button in the footer, else a 5 high one.
|
||||
let footer = exit.as_ref().map(|_| actions.len()).into_iter().collect();
|
||||
(
|
||||
actions.len(),
|
||||
footer,
|
||||
if exit.is_some() { BAR } else { 5.0 },
|
||||
)
|
||||
let footer = exit.as_ref().map(|_| listed).into_iter().collect();
|
||||
(listed, footer, if exit.is_some() { BAR } else { 5.0 })
|
||||
}
|
||||
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => {
|
||||
Kind::ServerLinks { exit, .. } => {
|
||||
let footer = exit.as_ref().map(|_| 0).into_iter().collect();
|
||||
(0, footer, if exit.is_some() { BAR } else { 5.0 })
|
||||
}
|
||||
@@ -303,24 +387,10 @@ impl Layout {
|
||||
/// whether it is in the body (clipped to its area).
|
||||
pub fn placed(&self, scroll: f32) -> impl Iterator<Item = (Part, bool)> + '_ {
|
||||
let dy = self.area[1] - scroll;
|
||||
let body = self.body.iter().map(move |p| {
|
||||
let p = match p.clone() {
|
||||
Part::Line(t, x, y) => Part::Line(t, x, y + dy),
|
||||
Part::Field { input, rect } => Part::Field {
|
||||
input,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
Part::Check { input, rect } => Part::Check {
|
||||
input,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
Part::Button { index, rect } => Part::Button {
|
||||
index,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
};
|
||||
(p, true)
|
||||
});
|
||||
let body = self
|
||||
.body
|
||||
.iter()
|
||||
.map(move |p| (p.clone().moved(0.0, dy), true));
|
||||
body.chain(self.footer.iter().map(|p| (p.clone(), false)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,11 @@ impl Base<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The mobs that save `Age` and `AgeLocked`.
|
||||
fn saves_age(s: crate::mobs::Species) -> bool {
|
||||
use crate::mobs::{Species, drops::can_be_baby, finalize::ageable};
|
||||
s == Species::Tadpole || (ageable(s) && can_be_baby(s))
|
||||
}
|
||||
/// `LivingEntity` + `Mob` (+ `AgeableMob`, creeper and skeleton specifics).
|
||||
/// The mobs whose class extends vanilla's `Animal` (and so save `InLove`):
|
||||
/// not allays, villagers, golems, dolphins or fish.
|
||||
@@ -286,19 +291,14 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
let id = m.kind.id.as_str();
|
||||
// Only breedable animals have an age; a loaded creature that was saved without
|
||||
// one (a frog) stays without.
|
||||
let aged = raw.is_none_or(|r| r.get("Age").is_some());
|
||||
if (m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin) && aged {
|
||||
// AgeableMob.addAdditionalSaveData, only if canBeABaby (not a frog or a
|
||||
// parrot); a tadpole saves its own age.
|
||||
let species = m.species();
|
||||
if saves_age(species) {
|
||||
map.insert("Age".into(), Tag::Int(m.extra.age));
|
||||
map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked)));
|
||||
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
|
||||
for (key, value) in [
|
||||
("Age", Tag::Int(0)),
|
||||
("ForcedAge", Tag::Int(0)),
|
||||
("AgeLocked", Tag::Byte(0)),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
if species != crate::mobs::Species::Tadpole {
|
||||
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
|
||||
}
|
||||
}
|
||||
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
|
||||
@@ -531,6 +531,8 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
"minecraft:villager" => {
|
||||
map.insert("VillagerData".into(), villager_data(m));
|
||||
// A loaded villager with VillagerData is finalized.
|
||||
map.insert("VillagerDataFinalized".into(), Tag::Byte(1));
|
||||
let t = m.extra.trade.as_deref().cloned().unwrap_or_default();
|
||||
map.insert("Xp".into(), Tag::Int(t.xp));
|
||||
map.insert("LastRestock".into(), Tag::Long(t.last_restock));
|
||||
@@ -728,9 +730,64 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
) {
|
||||
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
|
||||
}
|
||||
// NeutralMob.addPersistentAngerSaveData; the bee saves its own.
|
||||
if matches!(
|
||||
id,
|
||||
"minecraft:iron_golem"
|
||||
| "minecraft:wolf"
|
||||
| "minecraft:polar_bear"
|
||||
| "minecraft:zombified_piglin"
|
||||
| "minecraft:enderman"
|
||||
) {
|
||||
map.entry("anger_end_time".into()).or_insert(Tag::Long(-1));
|
||||
}
|
||||
if let Some(a) = m.extra.attributes.as_deref() {
|
||||
put_attributes(map, m, a);
|
||||
}
|
||||
// The attribute their load makes, which AttributeMap.pack then saves:
|
||||
// Zombie and Piglin.setBaby (even for an adult) the speed, Rabbit.setVariant
|
||||
// the attack damage.
|
||||
use crate::mobs::finalize::Attr;
|
||||
let made = if crate::mobs::doors::is_zombie(species) || species == crate::mobs::Species::Piglin
|
||||
{
|
||||
Some(Attr::MovementSpeed)
|
||||
} else if species == crate::mobs::Species::Rabbit {
|
||||
Some(Attr::AttackDamage)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(attr) = made {
|
||||
let base = crate::mobs::finalize::base_value(m, attr);
|
||||
let mut list = match map.get(&"attributes".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let attr_id = format!("minecraft:{}", attr.name());
|
||||
if !list
|
||||
.iter()
|
||||
.any(|t| text(t.get("id")).as_deref() == Some(attr_id.as_str()))
|
||||
{
|
||||
list.push(Tag::compound([
|
||||
("id", string(&attr_id)),
|
||||
("base", Tag::Double(base.to_bits())),
|
||||
]));
|
||||
map.insert("attributes".into(), Tag::List(list));
|
||||
}
|
||||
}
|
||||
// Mob.addAdditionalSaveData: the chances that are not the default.
|
||||
if let Some(chances) = &m.extra.drop_chances {
|
||||
let set: Vec<(&str, Tag)> = crate::mobs::drops::SLOTS
|
||||
.iter()
|
||||
.zip(chances.iter())
|
||||
.filter(|(_, c)| **c != crate::mobs::drops::DEFAULT_DROP_CHANCE)
|
||||
.map(|(slot, c)| (*slot, Tag::Float(c.to_bits())))
|
||||
.collect();
|
||||
if set.is_empty() {
|
||||
map.remove(&"drop_chances".into());
|
||||
} else {
|
||||
map.insert("drop_chances".into(), Tag::compound(set));
|
||||
}
|
||||
}
|
||||
crate::mobs::raider::write_nbt(m, map);
|
||||
crate::mobs::riders::write_nbt(m, map);
|
||||
// LivingEntity equipment: what the mob holds now.
|
||||
@@ -1027,7 +1084,16 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.custom_name = custom_name(tag);
|
||||
m.custom_name_text = tag.get("CustomName").map(name_text);
|
||||
m.extra.left_handed = flag(tag, "LeftHanded");
|
||||
if m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin {
|
||||
if let Some(Tag::Compound(c)) = tag.get("drop_chances") {
|
||||
let mut chances = [crate::mobs::drops::DEFAULT_DROP_CHANCE; 8];
|
||||
for (chance, slot) in chances.iter_mut().zip(crate::mobs::drops::SLOTS) {
|
||||
if let Some(Tag::Float(v)) = c.get(&slot.into()) {
|
||||
*chance = f32::from_bits(*v);
|
||||
}
|
||||
}
|
||||
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_locked = flag(tag, "AgeLocked");
|
||||
@@ -2960,6 +3026,33 @@ mod sheep_nbt_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baby_villager_keeps_its_age() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let saved = Tag::compound([
|
||||
("id", string("minecraft:villager")),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
("Age", Tag::Int(-21359)),
|
||||
]);
|
||||
let m = mob_from_nbt(&saved, &types, 1).unwrap();
|
||||
assert_eq!(m.extra.age, -21359);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], 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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foxes_rabbits_and_goats_keep_their_kind() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -1684,6 +1684,11 @@ struct Game<'a> {
|
||||
sleep_seen: (i64, i64),
|
||||
dim: Dim,
|
||||
world_root: PathBuf,
|
||||
/// The world's auto `icon.png` until it exists (GameRenderer's
|
||||
/// tryTakeScreenshotIfNeeded). Single player only.
|
||||
world_icon: Option<PathBuf>,
|
||||
/// When a world icon was last attempted; at most once a second.
|
||||
world_icon_attempt: Option<Instant>,
|
||||
/// The world is superflat (`ServerLevel.isFlat`, for every dimension).
|
||||
world_flat: bool,
|
||||
/// A player carried through a portal, re-placed once the destination loads.
|
||||
@@ -4286,9 +4291,13 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let group = horizon_client::cooldowns::group(stack, default.as_ref());
|
||||
self.cooldowns.add(Arc::clone(&group), component.ticks());
|
||||
self.add_cooldown(group, component.ticks());
|
||||
}
|
||||
/// `ItemCooldowns.addCooldown` (a guest also gets the packet).
|
||||
pub(super) fn add_cooldown(&mut self, group: Arc<str>, ticks: i32) {
|
||||
self.cooldowns.add(Arc::clone(&group), ticks);
|
||||
if self.lan_acting.is_some() {
|
||||
self.guest_cooldowns.push((group, component.ticks()));
|
||||
self.guest_cooldowns.push((group, ticks));
|
||||
}
|
||||
}
|
||||
/// One hand's turn in `startUseItem`: `useItemOn` the block aimed at,
|
||||
@@ -4508,6 +4517,10 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if !on_block && &*stack.item == "minecraft:goat_horn" {
|
||||
self.use_instrument(&stack);
|
||||
return Ok(true);
|
||||
}
|
||||
if !on_block && &*stack.item == "minecraft:bow" {
|
||||
if self.using.is_none()
|
||||
&& (self.creative || self.projectile_slot("minecraft:bow").is_some())
|
||||
@@ -4657,6 +4670,34 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `InstrumentItem.use` for a goat horn: the player holds it up for the
|
||||
/// instrument's `use_duration`, its sound plays at the player
|
||||
/// (`SoundSource.RECORDS`, volume `range / 16`), `INSTRUMENT_PLAY` is
|
||||
/// posted, the used statistic counts and the horn is on cooldown as long.
|
||||
/// A horn whose instrument is unknown fails, which still ends the click.
|
||||
fn use_instrument(&mut self, stack: &ItemStack) {
|
||||
let Some(instrument) = self.res.items().instrument(stack) else {
|
||||
return;
|
||||
};
|
||||
if self.using.is_none() {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
}
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
self.audio
|
||||
.play(&instrument.sound, at, instrument.range / 16.0, 1.0);
|
||||
self.post_entity_event(
|
||||
"minecraft:instrument_play",
|
||||
horizon_client::mobs::Who::Player,
|
||||
);
|
||||
if instrument.use_duration > 0.0 {
|
||||
let group = horizon_client::cooldowns::group(stack, None);
|
||||
self.add_cooldown(group, instrument.use_ticks());
|
||||
}
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
}
|
||||
fn apply_consume_effect(&mut self, effect: &horizon_client::items::ConsumeEffect) {
|
||||
use horizon_client::items::ConsumeEffect;
|
||||
match effect {
|
||||
@@ -4788,6 +4829,16 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(duration) = self
|
||||
.res
|
||||
.items()
|
||||
.instrument(&stack)
|
||||
.map(|i| i.use_ticks().max(0) as u32)
|
||||
{
|
||||
// InstrumentItem.getUseDuration: held up that long, nothing more.
|
||||
self.using = (ticks + 1 < duration).then_some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
if &*stack.item == "minecraft:brush" {
|
||||
self.tick_brush(slot, ticks);
|
||||
return;
|
||||
@@ -9097,6 +9148,13 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
self.join_world_rest()?;
|
||||
// A single-player world takes its icon from its first good frame.
|
||||
let icon = std::env::current_dir()?
|
||||
.join("saves")
|
||||
.join(folder)
|
||||
.join("icon.png");
|
||||
self.world_icon = (!icon.is_file()).then_some(icon);
|
||||
self.world_icon_attempt = None;
|
||||
if self
|
||||
.options
|
||||
.execution_role
|
||||
@@ -9565,6 +9623,8 @@ impl Game<'_> {
|
||||
self.streaming.paused = true;
|
||||
self.world = World::new(self.res.facts().air, 0);
|
||||
self.player = None;
|
||||
self.world_icon = None;
|
||||
self.world_icon_attempt = None;
|
||||
self.sleeping = None;
|
||||
self.sleep_timer = 0;
|
||||
self.player_beds.clear();
|
||||
@@ -11673,6 +11733,36 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `GameRenderer.tryTakeScreenshotIfNeeded`: the first frame drawn in a
|
||||
/// single-player world after its sections are meshed becomes `icon.png`.
|
||||
fn try_take_world_icon(&mut self) {
|
||||
let Some(path) = self.world_icon.clone() else {
|
||||
return;
|
||||
};
|
||||
if self.title.is_some() || self.player.is_none() {
|
||||
return;
|
||||
}
|
||||
let now = Instant::now();
|
||||
if self
|
||||
.world_icon_attempt
|
||||
.is_some_and(|last| now.duration_since(last) < Duration::from_secs(1))
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.world_icon_attempt = Some(now);
|
||||
if path.is_file() {
|
||||
self.world_icon = None;
|
||||
return;
|
||||
}
|
||||
let busy = self.streaming.busy();
|
||||
let Some(renderer) = &mut self.renderer else {
|
||||
return;
|
||||
};
|
||||
if renderer.world_icon.is_some() || renderer.drawn_sections <= 10 || busy {
|
||||
return;
|
||||
}
|
||||
renderer.world_icon = Some(path);
|
||||
}
|
||||
fn render(&mut self) -> Result<bool> {
|
||||
// Minecraft.doWorldLoad: the LevelLoadingScreen while single player's
|
||||
// server starts. Its chunk map and progress are the server's, which
|
||||
@@ -11747,6 +11837,7 @@ impl Game<'_> {
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
self.try_take_world_icon();
|
||||
let alpha = self.clock.fraction();
|
||||
// Camera.getCameraEntityPartialTicks: the view, the hand and the
|
||||
// player's own model stay smooth while the level is frozen.
|
||||
@@ -12624,6 +12715,7 @@ impl Game<'_> {
|
||||
let (uploads, unloaded) =
|
||||
horizon_client::spans::time("streaming", || self.streaming.update(&mut self.world));
|
||||
self.streaming.extra.truncate(areas);
|
||||
self.spawn_fresh_chunks();
|
||||
if let Some(lod) = &mut self.lod {
|
||||
self.lod_observe
|
||||
.extend(uploads.iter().map(|(key, _)| (key.0, key.2)));
|
||||
@@ -12974,6 +13066,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
if let Some(r) = &self.renderer {
|
||||
let b = HudBatch::new([r.config.width, r.config.height]);
|
||||
self.cursor = (position.x as f32 / b.scale, position.y as f32 / b.scale);
|
||||
if self.dialog_open() {
|
||||
self.dialog_drag();
|
||||
}
|
||||
if self.screen == Screen::Advancements {
|
||||
self.advancements_drag();
|
||||
}
|
||||
@@ -13111,8 +13206,12 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.dialog_open() => {
|
||||
if state == ElementState::Pressed && button == MouseButton::Left {
|
||||
self.dialog_click()?;
|
||||
if button == MouseButton::Left {
|
||||
if state == ElementState::Pressed {
|
||||
self.dialog_click()?;
|
||||
} else {
|
||||
self.dialog_release();
|
||||
}
|
||||
}
|
||||
}
|
||||
// ConnectScreen's Cancel, under the prompts and dialogs above.
|
||||
@@ -14097,6 +14196,8 @@ impl<'a> Game<'a> {
|
||||
sleep_seen: (0, 0),
|
||||
dim: Dim::Overworld,
|
||||
world_root: PathBuf::new(),
|
||||
world_icon: None,
|
||||
world_icon_attempt: None,
|
||||
world_flat: false,
|
||||
travelling: None,
|
||||
server_trips: Vec::new(),
|
||||
|
||||
@@ -925,24 +925,7 @@ impl Game<'_> {
|
||||
Ok(1)
|
||||
}
|
||||
"say" | "me" | "msg" => self.cmd_say(id, ctx),
|
||||
"list" => {
|
||||
let names = if ctx.has("uuids") {
|
||||
Text::translate(
|
||||
"commands.list.nameAndId",
|
||||
&[
|
||||
Text::literal(PLAYER_NAME),
|
||||
Text::literal(self.player_uuid_string()),
|
||||
],
|
||||
)
|
||||
} else {
|
||||
Text::literal(PLAYER_NAME)
|
||||
};
|
||||
self.system_message(Text::translate(
|
||||
"commands.list.players",
|
||||
&[Text::literal("1"), Text::literal("8"), names],
|
||||
));
|
||||
Ok(1)
|
||||
}
|
||||
"list" => self.cmd_list(ctx),
|
||||
_ => Err(err("command.failed", &[])),
|
||||
}
|
||||
}
|
||||
@@ -3096,6 +3079,66 @@ impl Game<'_> {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// ListPlayersCommand: every online player, the host of a hosted world
|
||||
/// first and then the guests in join order, with the count and the max.
|
||||
fn cmd_list(&mut self, ctx: &Context) -> R<i32> {
|
||||
// A single-player owner is a placed guest of the in-process server, so
|
||||
// it is the host and needs no entry of its own.
|
||||
let owner_listed = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.is_some_and(|h| h.hub.singleplayer_owner.is_some());
|
||||
// The host is online unless this is a dedicated server, which has none.
|
||||
let host = (self.options.dedicated.is_none() && !owner_listed).then(|| match &self.lan {
|
||||
Some(h) => (h.host_uuid, h.host_name.clone()),
|
||||
None => (
|
||||
horizon_protocol::crypto::offline_uuid(PLAYER_NAME),
|
||||
PLAYER_NAME.to_owned(),
|
||||
),
|
||||
});
|
||||
let guests = self
|
||||
.lan
|
||||
.iter()
|
||||
.flat_map(|h| h.hub.guests.iter())
|
||||
.filter(|g| g.placed)
|
||||
.map(|g| (g.uuid, g.name.clone()));
|
||||
let players: Vec<(u128, String)> = host.into_iter().chain(guests).collect();
|
||||
let max = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.map_or(horizon_client::lan::MAX_PLAYERS, |h| {
|
||||
h.hub.lan.shared.max_players()
|
||||
});
|
||||
let uuids = ctx.has("uuids");
|
||||
let mut names = Text::empty();
|
||||
for (k, (uuid, name)) in players.iter().enumerate() {
|
||||
if k > 0 {
|
||||
// ComponentUtils.formatList: gray commas.
|
||||
names.push(Text::colored(", ", text::GRAY));
|
||||
}
|
||||
if uuids {
|
||||
names.push(Text::translate(
|
||||
"commands.list.nameAndId",
|
||||
&[
|
||||
Text::literal(name.as_str()),
|
||||
Text::literal(horizon_protocol::crypto::uuid_string(*uuid)),
|
||||
],
|
||||
));
|
||||
} else {
|
||||
names.push(self.player_text(name, *uuid));
|
||||
}
|
||||
}
|
||||
let count = players.len();
|
||||
self.system_message(Text::translate(
|
||||
"commands.list.players",
|
||||
&[
|
||||
Text::literal(count.to_string()),
|
||||
Text::literal(max.to_string()),
|
||||
names,
|
||||
],
|
||||
));
|
||||
Ok(count as i32)
|
||||
}
|
||||
fn cmd_say(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let raw = match ctx.get(if id == "me" { "action" } else { "message" }) {
|
||||
Some(Arg::Str(s)) => s.clone(),
|
||||
|
||||
@@ -1117,6 +1117,7 @@ impl Game<'_> {
|
||||
impl Game<'_> {
|
||||
/// The player's profile id as `UUID.toString()`: the saved id, else the
|
||||
/// offline id of the player's name.
|
||||
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
|
||||
pub(in crate::game) fn player_uuid_string(&self) -> String {
|
||||
let id = self.level.player_uuid.map_or_else(
|
||||
|| horizon_protocol::crypto::offline_uuid(super::super::chat::PLAYER_NAME),
|
||||
|
||||
@@ -6,7 +6,7 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
impl Game<'_> {
|
||||
/// ComponentArgument with selectors resolved for the source
|
||||
/// (ComponentUtils.updateForEntity: names joined by ", ").
|
||||
fn component_arg(&mut self, ctx: &Context, name: &str) -> R<Text> {
|
||||
pub(in crate::game) fn component_arg(&mut self, ctx: &Context, name: &str) -> R<Text> {
|
||||
let Some(Arg::Str(json)) = ctx.get(name) else {
|
||||
return Ok(Text::empty());
|
||||
};
|
||||
|
||||
@@ -124,6 +124,21 @@ impl Game<'_> {
|
||||
"freeze" => DamageKind::Freeze,
|
||||
"in_wall" => DamageKind::InWall,
|
||||
"thorns" => DamageKind::Thorns,
|
||||
"cramming" => DamageKind::Cramming,
|
||||
"falling_anvil" => DamageKind::FallingAnvil,
|
||||
"falling_block" => DamageKind::FallingBlock,
|
||||
"fireworks" => DamageKind::Fireworks,
|
||||
"generic_kill" => DamageKind::GenericKill,
|
||||
"lightning_bolt" => DamageKind::LightningBolt,
|
||||
"out_of_world" => DamageKind::OutOfWorld,
|
||||
"outside_border" => DamageKind::OutsideBorder,
|
||||
"sonic_boom" => DamageKind::SonicBoom,
|
||||
"spit" => DamageKind::Spit,
|
||||
"stalagmite" => DamageKind::Stalagmite,
|
||||
"starve" => DamageKind::Starve,
|
||||
"sulfur_cube_hot" => DamageKind::SulfurCubeHot,
|
||||
"wind_charge" => DamageKind::WindCharge,
|
||||
"wither" => DamageKind::Wither,
|
||||
_ => DamageKind::Generic,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,9 +514,9 @@ impl Game<'_> {
|
||||
};
|
||||
spawning::LocalDifficulty::new(base, self.world.day_time, inhabited, moon)
|
||||
}
|
||||
/// Advance every mob one tick, then apply their events.
|
||||
pub(super) fn tick_creatures(&mut self) -> Result<()> {
|
||||
self.attack_ticks = self.attack_ticks.saturating_add(1);
|
||||
/// Generation's animals for the chunks generated since, once lit: chunk
|
||||
/// generation places them whether the level ticks or not.
|
||||
pub(super) fn spawn_fresh_chunks(&mut self) {
|
||||
let fresh: Vec<(i32, i32)> = self
|
||||
.world
|
||||
.chunks
|
||||
@@ -530,6 +530,10 @@ impl Game<'_> {
|
||||
}
|
||||
self.spawn_generation_creatures(key.0, key.1);
|
||||
}
|
||||
}
|
||||
/// Advance every mob one tick, then apply their events.
|
||||
pub(super) fn tick_creatures(&mut self) -> Result<()> {
|
||||
self.attack_ticks = self.attack_ticks.saturating_add(1);
|
||||
self.tick_leads();
|
||||
self.spawn_natural();
|
||||
// ServerChunkCache.tickChunks: the custom spawners tick only with spawn_mobs.
|
||||
@@ -4880,6 +4884,17 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
|
||||
K::Freeze => "minecraft:freeze",
|
||||
K::OutOfWorld => "minecraft:out_of_world",
|
||||
K::Cramming => "minecraft:cramming",
|
||||
K::FallingAnvil => "minecraft:falling_anvil",
|
||||
K::Fireworks => "minecraft:fireworks",
|
||||
K::GenericKill => "minecraft:generic_kill",
|
||||
K::LightningBolt => "minecraft:lightning_bolt",
|
||||
K::OutsideBorder => "minecraft:outside_border",
|
||||
K::Spit => "minecraft:spit",
|
||||
K::Stalagmite => "minecraft:stalagmite",
|
||||
K::Starve => "minecraft:starve",
|
||||
K::SulfurCubeHot => "minecraft:sulfur_cube_hot",
|
||||
K::WindCharge => "minecraft:wind_charge",
|
||||
K::Wither => "minecraft:wither",
|
||||
}
|
||||
}
|
||||
/// A Horizon item stack as a loot stack (id, count, damage, enchantments).
|
||||
|
||||
@@ -8,7 +8,7 @@ use horizon_client::{
|
||||
Result,
|
||||
clipboard::EditShortcut,
|
||||
confirm_screen::Confirm,
|
||||
dialog::{AfterAction, Click, Control, Dialog, InputValue},
|
||||
dialog::{AfterAction, Body, Click, Control, Dialog, InputValue, Kind},
|
||||
dialog_screen::{BOX, Layout, Part, WARNING_SPRITES, waiting_button},
|
||||
hud::{HudBatch, rgba},
|
||||
text::Text,
|
||||
@@ -26,6 +26,8 @@ struct Open {
|
||||
fields: Vec<Option<ui::EditBox>>,
|
||||
focus: Option<usize>,
|
||||
scroll: ui::Scroll,
|
||||
/// The number range input whose slider is held.
|
||||
dragging: Option<usize>,
|
||||
}
|
||||
|
||||
/// The dialog screens a server can open.
|
||||
@@ -38,6 +40,17 @@ pub(super) struct DialogState {
|
||||
warning: bool,
|
||||
}
|
||||
|
||||
/// `CycleButton.cycleValue`: the next option (`delta` 1) or the one before
|
||||
/// it, wrapping.
|
||||
fn cycle(open: &mut Open, input: usize, delta: i32) {
|
||||
let Control::SingleOption { options, .. } = &open.dialog.inputs[input].control else {
|
||||
return;
|
||||
};
|
||||
if let Some(InputValue::Option(i)) = open.values.get_mut(input) {
|
||||
*i = (*i as i32 + delta).rem_euclid(options.len().max(1) as i32) as usize;
|
||||
}
|
||||
}
|
||||
|
||||
fn warning_texts() -> (Text, Text, [Text; 2]) {
|
||||
let t = |key: &str| Text::translate(key, &[]);
|
||||
(
|
||||
@@ -57,10 +70,35 @@ impl Game<'_> {
|
||||
/// `showDialog`: the dialog replaces an open one (and a waiting screen);
|
||||
/// under the warning screen, it is what Back returns to.
|
||||
pub(super) fn show_dialog(&mut self, tag: &Tag) {
|
||||
let Some(dialog) = Dialog::parse(tag) else {
|
||||
let Some(mut dialog) = Dialog::parse(tag) else {
|
||||
log::warn!("Horizon: failed to show dialog for data {tag:?}");
|
||||
return;
|
||||
};
|
||||
if let Kind::DialogList { dialogs, .. } = &dialog.kind {
|
||||
let entries = self.remote.as_ref().map(|r| {
|
||||
let list = r.registries.registries.get("minecraft:dialog");
|
||||
let by_id = |id: &str| {
|
||||
let name = if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
};
|
||||
list?.iter().find(|(n, _)| *n == name)?.1.clone()
|
||||
};
|
||||
let by_tag = |name: &str| {
|
||||
let name = if name.contains(':') {
|
||||
name.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{name}")
|
||||
};
|
||||
let ids = r.registries.dialog_tags.get(&name).into_iter().flatten();
|
||||
ids.filter_map(|&i| list?.get(usize::try_from(i).ok()?)?.1.clone())
|
||||
.collect()
|
||||
};
|
||||
Dialog::list_entries(dialogs, &by_tag, &by_id)
|
||||
});
|
||||
dialog.set_list(&entries.unwrap_or_default());
|
||||
}
|
||||
let values = dialog.inputs.iter().map(|i| i.control.initial()).collect();
|
||||
let fields = dialog
|
||||
.inputs
|
||||
@@ -87,6 +125,7 @@ impl Game<'_> {
|
||||
fields,
|
||||
focus: None,
|
||||
scroll: ui::Scroll::default(),
|
||||
dragging: None,
|
||||
});
|
||||
self.dialogs.waiting = None;
|
||||
self.capture(false);
|
||||
@@ -123,6 +162,17 @@ impl Game<'_> {
|
||||
/// `DialogScreen.runAction`: the click event, then the screen the dialog's
|
||||
/// `after_action` leaves.
|
||||
fn run_dialog_action(&mut self, click: Option<Click>, after: AfterAction) -> Result<()> {
|
||||
// defaultHandleClickEvent: an open_url never sets the screen itself, so
|
||||
// the dialog stays unless the confirmation takes over (Web Links off,
|
||||
// or Prompt on Links off, opens nothing or the browser behind it).
|
||||
if let Some(Click::OpenUrl(url)) = &click
|
||||
&& !(self.settings.chat_links && self.settings.file.bool("chatLinksPrompt"))
|
||||
{
|
||||
if self.settings.chat_links {
|
||||
super::packs::open_uri(url);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
match after {
|
||||
AfterAction::None => {}
|
||||
AfterAction::Close => self.dialogs.open = None,
|
||||
@@ -153,7 +203,11 @@ impl Game<'_> {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&value);
|
||||
}
|
||||
Some(other) => log::info!("Horizon: dialog action {other:?} is not supported yet"),
|
||||
// ConfirmLinkScreen over the screen the dialog leaves.
|
||||
Some(Click::OpenUrl(url)) => self.click_url(url),
|
||||
// Screen.insertText: the screen after the dialog takes no text.
|
||||
Some(Click::SuggestCommand(_)) => {}
|
||||
Some(other) => log::error!("Don't know how to handle {other:?}"),
|
||||
None => {}
|
||||
}
|
||||
if !self.dialog_open() {
|
||||
@@ -262,7 +316,8 @@ impl Game<'_> {
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return;
|
||||
};
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
// Out of `self` while it draws: an item body draws through `self`.
|
||||
let Some(mut open) = self.dialogs.open.take() else {
|
||||
return;
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
@@ -291,6 +346,7 @@ impl Game<'_> {
|
||||
let buttons = open.dialog.buttons();
|
||||
let in_area = |y: f32| y >= ay && y < ay + ah;
|
||||
let mut tooltip = None;
|
||||
let mut item_tip = None;
|
||||
let mut start = None;
|
||||
for (part, clipped) in layout.placed(open.scroll.amount) {
|
||||
if clipped && start.is_none() {
|
||||
@@ -333,6 +389,64 @@ impl Game<'_> {
|
||||
true,
|
||||
);
|
||||
}
|
||||
Part::Cycle { input, rect } => {
|
||||
let [x, y, cw, ch] = rect;
|
||||
let hover = in_area(cursor.1) && ui::hovered(cursor, x, y, cw, ch);
|
||||
let Some(label) = open
|
||||
.values
|
||||
.get(input)
|
||||
.and_then(|v| open.dialog.inputs[input].control.message(v))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
ui::button(batch, atlas, x, y, cw, ch, &label, true, hover);
|
||||
}
|
||||
Part::Slider { input, rect } => {
|
||||
let [x, y, sw, sh] = rect;
|
||||
let hover = in_area(cursor.1) && ui::hovered(cursor, x, y, sw, sh);
|
||||
let Some((InputValue::Slider(position), Some(label))) = open
|
||||
.values
|
||||
.get(input)
|
||||
.map(|v| (v.clone(), open.dialog.inputs[input].control.message(v)))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let held = open.dragging == Some(input);
|
||||
ui::slider(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
y,
|
||||
sw,
|
||||
&label,
|
||||
position,
|
||||
hover || held,
|
||||
true,
|
||||
);
|
||||
}
|
||||
Part::Item { body, rect } => {
|
||||
let [x, y, iw, ih] = rect;
|
||||
let Some(Body::Item {
|
||||
item,
|
||||
show_decorations,
|
||||
show_tooltip,
|
||||
..
|
||||
}) = open.dialog.body.get(body)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(stack) = horizon_items::item_nbt::item_from_nbt(item) else {
|
||||
continue;
|
||||
};
|
||||
// ItemDisplayWidget: the item at its corner, then its decorations.
|
||||
self.draw_item_model(batch, &stack, x, y);
|
||||
if *show_decorations {
|
||||
self.draw_item_decorations(batch, &stack, x, y, None);
|
||||
}
|
||||
if *show_tooltip && in_area(cursor.1) && ui::hovered(cursor, x, y, iw, ih) {
|
||||
item_tip = Some(stack);
|
||||
}
|
||||
}
|
||||
Part::Button { index, rect } => {
|
||||
let [x, y, bw, bh] = rect;
|
||||
let hover =
|
||||
@@ -371,6 +485,10 @@ impl Game<'_> {
|
||||
.collect();
|
||||
ui::tooltip(batch, atlas, &lines, cursor);
|
||||
}
|
||||
self.dialogs.open = Some(open);
|
||||
if let Some(stack) = item_tip {
|
||||
self.draw_stack_tooltip(batch, &stack, cursor.0, cursor.1);
|
||||
}
|
||||
}
|
||||
|
||||
/// A left click on a dialog screen.
|
||||
@@ -420,6 +538,7 @@ impl Game<'_> {
|
||||
self.dialogs.warning = true;
|
||||
return Ok(());
|
||||
}
|
||||
let shift = self.modifiers.shift_key();
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -450,6 +569,23 @@ impl Game<'_> {
|
||||
}
|
||||
action = Some(None);
|
||||
}
|
||||
Part::Cycle {
|
||||
input,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => {
|
||||
// CycleButton.onPress: Shift goes back.
|
||||
cycle(open, input, if shift { -1 } else { 1 });
|
||||
action = Some(None);
|
||||
}
|
||||
Part::Slider {
|
||||
input,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => {
|
||||
open.dragging = Some(input);
|
||||
if let Some(v) = open.values.get_mut(input) {
|
||||
*v = InputValue::Slider(ui::slider_value(x, w, cursor.0));
|
||||
}
|
||||
}
|
||||
Part::Button {
|
||||
index,
|
||||
rect: [x, y, w, h],
|
||||
@@ -479,17 +615,65 @@ impl Game<'_> {
|
||||
self.run_dialog_action(click, after)
|
||||
}
|
||||
|
||||
/// The mouse wheel over a dialog: its body scrolls 10 a step.
|
||||
/// The mouse wheel over a dialog: a cycle button under it takes the
|
||||
/// step, else the body scrolls 10 a step.
|
||||
pub(super) fn dialog_scroll(&mut self, delta: f32) {
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return;
|
||||
};
|
||||
let cursor = self.cursor;
|
||||
if let Some(open) = self.dialogs.open.as_mut() {
|
||||
let [_, ay, _, ah] = layout.area;
|
||||
if delta != 0.0 && cursor.1 >= ay && cursor.1 < ay + ah {
|
||||
let hovered = layout
|
||||
.placed(open.scroll.amount)
|
||||
.find_map(|(part, _)| match part {
|
||||
Part::Cycle {
|
||||
input,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => Some(input),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(input) = hovered {
|
||||
// CycleButton.mouseScrolled: up goes back.
|
||||
cycle(open, input, if delta > 0.0 { -1 } else { 1 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
open.scroll.wheel(delta, 10.0, ay, ay + ah, layout.content);
|
||||
}
|
||||
}
|
||||
|
||||
/// The pointer moved with a slider held (`AbstractSliderButton.onDrag`).
|
||||
pub(super) fn dialog_drag(&mut self) {
|
||||
let Some(input) = self.dialogs.open.as_ref().and_then(|o| o.dragging) else {
|
||||
return;
|
||||
};
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return;
|
||||
};
|
||||
let x = self.cursor.0;
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let slider = layout.body.iter().find_map(|p| match p {
|
||||
Part::Slider { input: i, rect } if *i == input => Some(*rect),
|
||||
_ => None,
|
||||
});
|
||||
if let (Some([sx, _, sw, _]), Some(value)) = (slider, open.values.get_mut(input)) {
|
||||
*value = InputValue::Slider(ui::slider_value(sx, sw, x));
|
||||
}
|
||||
}
|
||||
|
||||
/// The left button released: a held slider lets go and clicks.
|
||||
pub(super) fn dialog_release(&mut self) {
|
||||
if let Some(open) = self.dialogs.open.as_mut()
|
||||
&& open.dragging.take().is_some()
|
||||
{
|
||||
self.audio.play_button_click();
|
||||
}
|
||||
}
|
||||
|
||||
/// A key pressed on a dialog screen: Escape closes what allows it, Tab
|
||||
/// moves between the text inputs, the rest goes to the focused one
|
||||
/// (`shortcut`: the press as an edit shortcut; `text`: what it types).
|
||||
|
||||
@@ -572,6 +572,75 @@ impl Game<'_> {
|
||||
.chain(self.fns.enabled.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
/// `FileUtil.isPathPartPortable`: no trailing dot and no Windows device
|
||||
/// name (CON, PRN, AUX, NUL, COM and LPT with a digit from 1 to 9).
|
||||
fn is_portable_name(name: &str) -> bool {
|
||||
let stem = name.split('.').next().unwrap_or_default();
|
||||
let device = match stem.len() {
|
||||
3 => matches!(stem, "com" | "con" | "prn" | "aux" | "nul"),
|
||||
4 => matches!(&stem[..3], "com" | "lpt") && matches!(stem.as_bytes()[3], b'1'..=b'9'),
|
||||
_ => false,
|
||||
};
|
||||
!device && !name.ends_with('.')
|
||||
}
|
||||
/// `DataPackCommand.createPack`: a pack folder with its `data` folder and a
|
||||
/// `pack.mcmeta` in the world's datapacks folder.
|
||||
fn create_pack(&mut self, ctx: &Context) -> R<i32> {
|
||||
let id = match ctx.get("id") {
|
||||
Some(Arg::Str(s)) => s.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let description = self.component_arg(ctx, "description")?;
|
||||
// FileUtil.isValidPathSegment: lowercase letters, digits, '-', '.' and '_'.
|
||||
let valid = !id.is_empty()
|
||||
&& id != "."
|
||||
&& id != ".."
|
||||
&& id
|
||||
.chars()
|
||||
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-' | '.' | '_'));
|
||||
if !valid {
|
||||
return Err(err("commands.datapack.create.invalid_name", &[&id]));
|
||||
}
|
||||
if !Self::is_portable_name(&id) {
|
||||
return Err(err("commands.datapack.create.invalid_full_name", &[&id]));
|
||||
}
|
||||
// Without a world there is no datapacks folder to write to.
|
||||
if self.world_root.as_os_str().is_empty() {
|
||||
return Err(err("commands.datapack.create.io_failure", &[&id]));
|
||||
}
|
||||
let datapacks = self.world_root.join("datapacks");
|
||||
let pack = datapacks.join(&id);
|
||||
if pack.exists() {
|
||||
return Err(err("commands.datapack.create.already_exists", &[&id]));
|
||||
}
|
||||
// The data pack version (SharedConstants), written as a bare major
|
||||
// number for both bounds, as vanilla does.
|
||||
let pack_format = horizon_core::version::DATA_PACK_FORMAT.0;
|
||||
let mcmeta = serde_json::json!({
|
||||
"pack": {
|
||||
"description": description.component(),
|
||||
"min_format": pack_format,
|
||||
"max_format": pack_format,
|
||||
}
|
||||
});
|
||||
let written = std::fs::create_dir_all(&datapacks)
|
||||
.and_then(|()| std::fs::create_dir(&pack))
|
||||
.and_then(|()| std::fs::create_dir(pack.join("data")))
|
||||
.and_then(|()| {
|
||||
std::fs::write(
|
||||
pack.join("pack.mcmeta"),
|
||||
serde_json::to_string_pretty(&mcmeta).unwrap_or_default(),
|
||||
)
|
||||
});
|
||||
if written.is_err() {
|
||||
return Err(err("commands.datapack.create.io_failure", &[&id]));
|
||||
}
|
||||
self.feedback(Text::translate(
|
||||
"commands.datapack.create.success",
|
||||
&[Text::literal(id)],
|
||||
));
|
||||
Ok(1)
|
||||
}
|
||||
pub(super) fn cmd_datapack(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
self.ensure_datapacks();
|
||||
let name = match ctx.get("name") {
|
||||
@@ -590,6 +659,7 @@ impl Game<'_> {
|
||||
|| g.fns.packs.iter().any(|p| p.id == n)
|
||||
};
|
||||
match id {
|
||||
"datapack.create" => self.create_pack(ctx),
|
||||
"datapack.list" => {
|
||||
let want_enabled = ctx.has("enabled") || (!ctx.has("available"));
|
||||
let want_available = ctx.has("available") || (!ctx.has("enabled"));
|
||||
|
||||
@@ -1259,15 +1259,26 @@ impl Game<'_> {
|
||||
.moves
|
||||
.last()
|
||||
.map_or(Vec3::ZERO, |m| Vec3::new(m.0[0], m.0[1], m.0[2]));
|
||||
let swung = self.as_guest(host, conn, |g| {
|
||||
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
|
||||
let used = self.as_guest(host, conn, |g| {
|
||||
g.known_movement = movement;
|
||||
// A horn's sound, inline instruments included.
|
||||
let horn = g.with_hand(hand, |g| {
|
||||
let held = g.held()?;
|
||||
g.res.items().instrument(held).map(|i| i.sound)
|
||||
});
|
||||
g.guest_use = Some(GuestUse::Item(hand));
|
||||
let swings = g.swings_started;
|
||||
let _ = g.use_item();
|
||||
g.guest_use = None;
|
||||
g.swings_started != swings
|
||||
(g.swings_started != swings, horn)
|
||||
});
|
||||
let (swung, horn) = match used {
|
||||
Some((swung, horn)) => (Some(swung), horn),
|
||||
None => (None, None),
|
||||
};
|
||||
self.lan_swing_back(host, conn, swung);
|
||||
self.guest_instrument_sound(host, conn, heard, horn);
|
||||
}
|
||||
S::Attack(p) => self.guest_attack(host, conn, p.id),
|
||||
S::Interact(p) => {
|
||||
@@ -1888,8 +1899,11 @@ impl Game<'_> {
|
||||
// ListPlayersCommand needs no permission and lists every player.
|
||||
let words: Vec<&str> = command.trim_start_matches('/').split(' ').collect();
|
||||
if words[0] == "list" && (words.len() == 1 || words[1..] == ["uuids"]) {
|
||||
let me = (self.options.dedicated.is_none() && self.player.is_some())
|
||||
.then_some((host.host_uuid, host.host_name.as_str()));
|
||||
// A single-player owner is a placed guest already: no host entry.
|
||||
let me = (self.options.dedicated.is_none()
|
||||
&& self.player.is_some()
|
||||
&& host.hub.singleplayer_owner.is_none())
|
||||
.then_some((host.host_uuid, host.host_name.as_str()));
|
||||
let content = host.hub.list_reply(me, words.len() == 2);
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
@@ -2043,8 +2057,11 @@ impl Game<'_> {
|
||||
let d = vanilla::game_dispatcher();
|
||||
let level = std::mem::replace(&mut self.ui.registries.permission, permission);
|
||||
let parse = d.parse(command, &self.ui.registries);
|
||||
let ((start, end), suggestions) = d.suggestions(&parse, command.len(), &self.ui.registries);
|
||||
let ((start, end), mut suggestions) =
|
||||
d.suggestions(&parse, command.len(), &self.ui.registries);
|
||||
self.ui.registries.permission = level;
|
||||
// ServerCommandSuggestionsProvider.limitSuggestionCount.
|
||||
suggestions.truncate(1000);
|
||||
let utf16 = |byte: usize| command[..byte.min(command.len())].encode_utf16().count() as i32;
|
||||
let mut w = horizon_protocol::buf::Writer::new();
|
||||
w.varint(id);
|
||||
@@ -3252,6 +3269,30 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `InstrumentItem.play`'s `level.playSound(player, ...)`: the others hear
|
||||
/// a guest's horn now, the guest's own client played it as it used it.
|
||||
fn guest_instrument_sound(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
heard: usize,
|
||||
horn: Option<String>,
|
||||
) {
|
||||
let (Some(horn), Some(capture)) = (horn, self.audio.capture.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
let mut i = heard.min(capture.len());
|
||||
while i < capture.len() {
|
||||
if capture[i].0 == horn {
|
||||
let (name, at, volume, pitch) = capture.remove(i);
|
||||
let source = horizon_client::settings::sound_source(&name) as i32;
|
||||
host.hub.sound(&name, at, volume, pitch, source, Some(conn));
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Positional sounds and level events of the tick, for the guests.
|
||||
pub(super) fn lan_broadcast_effects(&mut self, host: &mut LanHost) {
|
||||
let sounds = self.audio.capture.take().unwrap_or_default();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! it reads, the spawning chunks and caps of `ServerChunkCache.tickChunks`,
|
||||
//! and the mobs it adds (finalized with the game's `finalizeSpawn`).
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::vanillagen::VanillaTerrain;
|
||||
use horizon_client::{
|
||||
biome::{BiomeInfo, SpawnEntry},
|
||||
blocks::BlockFacts,
|
||||
@@ -19,9 +20,12 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::{LegacyRandom, RandomSource, WorldgenRandom},
|
||||
random::{LegacyRandom, RandomSource, WorldgenRandom, XoroshiroRandom},
|
||||
};
|
||||
use horizon_world::{
|
||||
nbt::{Compound, Tag},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Reused buffers for one pass over the spawning chunks: the candidate chunks
|
||||
@@ -157,6 +161,16 @@ impl GameLevel<'_, '_> {
|
||||
let chunk = self.game.world.chunks.get(&(x >> 4, z >> 4))?;
|
||||
Some(self.game.res.biomes().get(chunk.biome(x, y, z)))
|
||||
}
|
||||
/// The block whose noise cell `BiomeManager.getBiome` reads.
|
||||
fn zoomed(&self, x: i32, y: i32, z: i32) -> [i32; 3] {
|
||||
let zoom = self.game.streaming.biome_zoom;
|
||||
let [qx, qy, qz] = zoom.quart_at_block(horizon_core::position::BlockPos::new(x, y, z));
|
||||
[qx << 2, qy << 2, qz << 2]
|
||||
}
|
||||
fn zoomed_biome(&self, x: i32, y: i32, z: i32) -> Option<&BiomeInfo> {
|
||||
let [x, y, z] = self.zoomed(x, y, z);
|
||||
self.biome(x, y, z)
|
||||
}
|
||||
}
|
||||
impl SpawnLevel for GameLevel<'_, '_> {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
@@ -171,7 +185,9 @@ impl SpawnLevel for GameLevel<'_, '_> {
|
||||
fn block_tag(&self, tag: &str, block: &str) -> bool {
|
||||
self.w.block_tag(tag, block)
|
||||
}
|
||||
// Level.getBiome: the zoomed biome.
|
||||
fn biome_tag(&self, tag: &str, x: i32, y: i32, z: i32) -> bool {
|
||||
let [x, y, z] = self.zoomed(x, y, z);
|
||||
self.w.biome_tag(tag, x, y, z)
|
||||
}
|
||||
fn sky_light(&self, x: i32, y: i32, z: i32) -> i32 {
|
||||
@@ -237,11 +253,13 @@ impl NaturalLevel for GameLevel<'_, '_> {
|
||||
Category::from_name(&t.category),
|
||||
))
|
||||
}
|
||||
// The `natural_mob_spawns` attribute reads the zoomed biome
|
||||
// (fullResolutionBiomes), the spawn probability the noise cell.
|
||||
fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap<String, Vec<SpawnEntry>>> {
|
||||
self.biome(x, y, z).map(|b| &b.spawns)
|
||||
self.zoomed_biome(x, y, z).map(|b| &b.spawns)
|
||||
}
|
||||
fn spawn_cost(&self, entity: &str, x: i32, y: i32, z: i32) -> Option<SpawnCost> {
|
||||
self.biome(x, y, z)?.spawn_costs.get(entity).copied()
|
||||
self.zoomed_biome(x, y, z)?.spawn_costs.get(entity).copied()
|
||||
}
|
||||
fn creature_probability(&self, x: i32, y: i32, z: i32) -> f32 {
|
||||
self.biome(x, y, z).map_or(0.1, |b| b.creature_probability)
|
||||
@@ -573,37 +591,68 @@ 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 (mobs are not generated in
|
||||
/// with the chunk's decoration-seeded random, finalized with the
|
||||
/// region's random as generation adds them (mobs are not generated in
|
||||
/// the End).
|
||||
pub(super) fn spawn_generation_creatures(&mut self, cx: i32, cz: i32) {
|
||||
if !self.world.chunks.contains_key(&(cx, cz)) || self.dim == Dim::End {
|
||||
return;
|
||||
}
|
||||
let Some(terrain) = self.end.locator() else {
|
||||
return;
|
||||
};
|
||||
let mut region = terrain.region_random(cx, cz);
|
||||
let any = region.as_any();
|
||||
let added = if let Some(r) = any.downcast_mut::<LegacyRandom>() {
|
||||
self.generation_creatures(cx, cz, &terrain, r)
|
||||
} else if let Some(r) = any.downcast_mut::<XoroshiroRandom>() {
|
||||
self.generation_creatures(cx, cz, &terrain, r)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
for nbt in added {
|
||||
self.load_mob(Tag::Compound(nbt), None);
|
||||
}
|
||||
}
|
||||
fn generation_creatures<Q: RandomSource + 'static>(
|
||||
&self,
|
||||
cx: i32,
|
||||
cz: i32,
|
||||
terrain: &VanillaTerrain,
|
||||
region: &mut Q,
|
||||
) -> Vec<Compound> {
|
||||
let mut random = WorldgenRandom::new(LegacyRandom::new(0));
|
||||
random.set_decoration_seed(self.level.seed, cx * 16, cz * 16);
|
||||
// The region's own random: no creature's spawn predicate draws from it.
|
||||
let mut rules = LegacyRandom::new(self.level.seed ^ (i64::from(cx) << 32 | i64::from(cz)));
|
||||
let mut pending = Vec::new();
|
||||
{
|
||||
let level = self.natural_level();
|
||||
let source = [cx * 16, level.max_y(), cz * 16];
|
||||
let mut spawn = |l: &GameLevel, r: &SpawnRequest| {
|
||||
let spawned = l.game.finalize_spawned(r, &mut pending);
|
||||
if let Some(s) = &spawned {
|
||||
l.pending.borrow_mut().push(s.bounds);
|
||||
let mut added = Vec::new();
|
||||
let mut group = None;
|
||||
let level = self.natural_level();
|
||||
let source = [cx * 16, level.max_y(), cz * 16];
|
||||
let mut spawn = |l: &GameLevel, r: &SpawnRequest, region: &mut Q| {
|
||||
// Each pack starts without group data.
|
||||
if r.group_index == 0 {
|
||||
group = None;
|
||||
}
|
||||
let [x, y, z] = r.position;
|
||||
let (bx, by, bz) = (x.floor() as i32, y.floor() as i32, z.floor() as i32);
|
||||
let biome = l.zoomed_biome(bx, by, bz).map_or("", |b| b.name.as_str());
|
||||
let at = Vec3::new(x, y, z);
|
||||
match terrain.spawn_generated(r.entity, at, r.yaw, biome, region, &mut group) {
|
||||
Some(nbt) => {
|
||||
added.push(nbt);
|
||||
true
|
||||
}
|
||||
spawned
|
||||
};
|
||||
spawn_selection::spawn_mobs_for_chunk_generation(
|
||||
&level,
|
||||
source,
|
||||
(cx, cz),
|
||||
&mut random,
|
||||
&mut rules,
|
||||
&mut spawn,
|
||||
&mut |_| {},
|
||||
);
|
||||
}
|
||||
self.add_spawned(pending, true);
|
||||
None => false,
|
||||
}
|
||||
};
|
||||
spawn_selection::spawn_mobs_for_chunk_generation(
|
||||
&level,
|
||||
source,
|
||||
(cx, cz),
|
||||
&mut random,
|
||||
region,
|
||||
&mut spawn,
|
||||
&mut |_| {},
|
||||
);
|
||||
added
|
||||
}
|
||||
}
|
||||
@@ -353,6 +353,9 @@ impl Game<'_> {
|
||||
let lang = Arc::new(horizon_client::net::load_language(&root));
|
||||
let view = client_view_distance(self.settings.render_distance);
|
||||
if local.is_none() {
|
||||
// Only a single-player world gets an auto icon.
|
||||
self.world_icon = None;
|
||||
self.world_icon_attempt = None;
|
||||
horizon_client::crash::set_detail(
|
||||
"Server",
|
||||
horizon_client::net::shown_address(address),
|
||||
@@ -573,6 +576,8 @@ impl Game<'_> {
|
||||
self.streaming.paused = true;
|
||||
self.world = World::new(self.res.facts().air, 0);
|
||||
self.player = None;
|
||||
self.world_icon = None;
|
||||
self.world_icon_attempt = None;
|
||||
// Out of the server's bed: InBedChatScreen goes with the level.
|
||||
self.sleeping = None;
|
||||
self.sleep_timer = 0;
|
||||
@@ -5092,6 +5097,12 @@ impl Game<'_> {
|
||||
}
|
||||
// SpyglassItem.use (its sound is the server's).
|
||||
"minecraft:spyglass" => true,
|
||||
// InstrumentItem.use: the horn plays here too (`playSound(player, ...)`
|
||||
// skips the player's own client), the server's copy tells the others.
|
||||
"minecraft:goat_horn" => {
|
||||
self.remote_use_instrument(held);
|
||||
return;
|
||||
}
|
||||
// Item.use: a kinetic weapon starts being used.
|
||||
id if horizon_client::spear::Kinetic::of(id).is_some() => true,
|
||||
// ShieldItem.use: anything that blocks attacks, off cooldown.
|
||||
@@ -5104,6 +5115,27 @@ impl Game<'_> {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
}
|
||||
}
|
||||
/// `InstrumentItem.use` on this client: unless the horn is on cooldown,
|
||||
/// it is held up, plays its sound at the player and starts its cooldown.
|
||||
fn remote_use_instrument(&mut self, held: &ItemStack) {
|
||||
let Some(instrument) = self.res.items().instrument(held) else {
|
||||
return;
|
||||
};
|
||||
let group = horizon_client::cooldowns::group(held, None);
|
||||
if self.cooldowns.is_on_cooldown(&group) {
|
||||
return;
|
||||
}
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
self.audio
|
||||
.play(&instrument.sound, at, instrument.range / 16.0, 1.0);
|
||||
if instrument.use_duration > 0.0 {
|
||||
self.add_cooldown(group, instrument.use_ticks());
|
||||
}
|
||||
}
|
||||
/// Whether `stack` stays in use until the button lets go (bows,
|
||||
/// crossbows, tridents, spyglasses, shields): its use only counts ticks here.
|
||||
fn held_until_released(&self, stack: &ItemStack) -> bool {
|
||||
@@ -5197,6 +5229,15 @@ impl Game<'_> {
|
||||
self.using = in_hand.then_some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
// A goat horn is held up for its instrument's use duration.
|
||||
if let Some(duration) = stack
|
||||
.as_ref()
|
||||
.and_then(|s| self.res.items().instrument(s))
|
||||
.map(|i| i.use_ticks().max(0) as u32)
|
||||
{
|
||||
self.using = (in_hand && ticks + 1 < duration).then_some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
let consumable = stack
|
||||
.as_ref()
|
||||
.and_then(|s| self.res.items().get(&s.item))
|
||||
|
||||
@@ -447,6 +447,7 @@ mod tests {
|
||||
};
|
||||
let dynamic = Dynamic {
|
||||
registries: Default::default(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut bundle = ItemStack::new("minecraft:bundle", 1);
|
||||
bundle.data_mut().bundle_contents = vec![ItemStack::new("minecraft:stone", 32)];
|
||||
@@ -524,6 +525,7 @@ mod tests {
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut stack = ItemStack::new("minecraft:diamond_chestplate", 1);
|
||||
stack.damage = 5;
|
||||
|
||||
@@ -533,6 +533,17 @@ pub fn detail_lines(
|
||||
let display = TooltipDisplay::for_stack(stack);
|
||||
lines.extend(bees_line(stack, &display, translate));
|
||||
lines.extend(honey_line(stack, &display, translate));
|
||||
// InstrumentComponent.addToTooltip: the instrument's description.
|
||||
if display.shows("minecraft:instrument") {
|
||||
if let Some(id) = crate::items::instrument_id(stack) {
|
||||
let (namespace, name) = id.split_once(':').unwrap_or(("minecraft", &id));
|
||||
lines.push(text(&format!("instrument.{namespace}.{name}")));
|
||||
} else if let Some(description) =
|
||||
crate::items::inline_instrument(stack).and_then(|tag| tag.get("description"))
|
||||
{
|
||||
lines.push(crate::entity_nbt::name_text(description).plain());
|
||||
}
|
||||
}
|
||||
let data = stack.data();
|
||||
// PotDecorations.addToTooltip.
|
||||
if let Some(sherds) = data
|
||||
|
||||
@@ -677,9 +677,16 @@ fn serve_login(
|
||||
_ => return Err("expected a login hello".into()),
|
||||
};
|
||||
if !valid_name(&hello.name) {
|
||||
// handleHello's validState throws; Connection.exceptionCaught sends the
|
||||
// exception as the login disconnect.
|
||||
login_disconnect(
|
||||
&mut c,
|
||||
&translatable("multiplayer.disconnect.invalid_name", &[]),
|
||||
&translatable(
|
||||
"disconnect.genericReason",
|
||||
&[
|
||||
"Internal Exception: java.lang.IllegalStateException: Invalid characters in username",
|
||||
],
|
||||
),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -539,8 +539,36 @@ pub enum DamageKind {
|
||||
Freeze,
|
||||
/// `fell_out_of_world`: below the world's floor.
|
||||
OutOfWorld,
|
||||
/// `cramming`: too many entities in one place (`maxEntityCramming`).
|
||||
/// `cramming`: crushed by the mobs around it.
|
||||
Cramming,
|
||||
/// `falling_anvil`: an anvil landing on it (it damages a helmet).
|
||||
FallingAnvil,
|
||||
/// `fireworks`: a firework rocket's burst (an explosion for Blast Protection).
|
||||
Fireworks,
|
||||
/// `generic_kill`: a kill that bypasses invulnerability.
|
||||
GenericKill,
|
||||
/// `lightning_bolt`.
|
||||
LightningBolt,
|
||||
/// `outside_border`: past the world border.
|
||||
OutsideBorder,
|
||||
/// `spit`: a llama's spit.
|
||||
Spit,
|
||||
/// `stalagmite`: a fall onto a stalagmite (a fall for Feather Falling).
|
||||
Stalagmite,
|
||||
/// `starve`: no food left.
|
||||
Starve,
|
||||
/// `sulfur_cube_hot`: a sulfur cube's hot floor (a fire).
|
||||
SulfurCubeHot,
|
||||
/// `wind_charge`: a wind charge's burst (a projectile).
|
||||
WindCharge,
|
||||
/// `wither`: the wither effect.
|
||||
Wither,
|
||||
}
|
||||
impl DamageKind {
|
||||
/// DamageTypeTags.BYPASSES_INVULNERABILITY
|
||||
pub fn bypasses_invulnerability(self) -> bool {
|
||||
matches!(self, DamageKind::OutOfWorld | DamageKind::GenericKill)
|
||||
}
|
||||
}
|
||||
/// A damage source: its type, the attacking entity and where it came from.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -604,6 +632,15 @@ impl Damage {
|
||||
| DamageKind::Generic
|
||||
| DamageKind::InWall
|
||||
| DamageKind::SweetBerryBush
|
||||
| DamageKind::OutOfWorld
|
||||
| DamageKind::FallingBlock
|
||||
| DamageKind::FallingAnvil
|
||||
| DamageKind::Cramming
|
||||
| DamageKind::GenericKill
|
||||
| DamageKind::OutsideBorder
|
||||
| DamageKind::Spit
|
||||
| DamageKind::Stalagmite
|
||||
| DamageKind::Starve
|
||||
)
|
||||
}
|
||||
/// DamageTypeTags.BYPASSES_ARMOR
|
||||
@@ -620,12 +657,21 @@ impl Damage {
|
||||
| DamageKind::Freeze
|
||||
| DamageKind::OutOfWorld
|
||||
| DamageKind::Cramming
|
||||
| DamageKind::GenericKill
|
||||
| DamageKind::OutsideBorder
|
||||
| DamageKind::Stalagmite
|
||||
| DamageKind::Starve
|
||||
| DamageKind::Wither
|
||||
)
|
||||
}
|
||||
pub fn is_fire(&self) -> bool {
|
||||
matches!(
|
||||
self.kind,
|
||||
DamageKind::OnFire | DamageKind::InFire | DamageKind::Lava | DamageKind::Fireball
|
||||
DamageKind::OnFire
|
||||
| DamageKind::InFire
|
||||
| DamageKind::Lava
|
||||
| DamageKind::Fireball
|
||||
| DamageKind::SulfurCubeHot
|
||||
)
|
||||
}
|
||||
/// DamageTypeTags.NO_KNOCKBACK
|
||||
@@ -646,13 +692,20 @@ impl Damage {
|
||||
| DamageKind::InWall
|
||||
| DamageKind::Freeze
|
||||
| DamageKind::OutOfWorld
|
||||
| DamageKind::LightningBolt
|
||||
| DamageKind::Cramming
|
||||
| DamageKind::GenericKill
|
||||
| DamageKind::OutsideBorder
|
||||
| DamageKind::Stalagmite
|
||||
| DamageKind::Starve
|
||||
| DamageKind::SulfurCubeHot
|
||||
| DamageKind::Wither
|
||||
)
|
||||
}
|
||||
pub fn is_projectile(&self) -> bool {
|
||||
matches!(
|
||||
self.kind,
|
||||
DamageKind::Arrow | DamageKind::Thrown | DamageKind::Fireball
|
||||
DamageKind::Arrow | DamageKind::Thrown | DamageKind::Fireball | DamageKind::WindCharge
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2084,7 +2137,11 @@ impl Mob {
|
||||
) -> bool {
|
||||
axolotl::before_damage(self, amount, &source);
|
||||
raider::note_hurt(self);
|
||||
if !self.alive() || self.removed || self.invulnerable_flag {
|
||||
// LivingEntity.isInvulnerableTo: only #bypasses_invulnerability gets past it.
|
||||
if !self.alive()
|
||||
|| self.removed
|
||||
|| (self.invulnerable_flag && !source.kind.bypasses_invulnerability())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Ghast.isReflectedFireball: a fireball a player turned back is
|
||||
@@ -2102,9 +2159,10 @@ impl Mob {
|
||||
if self.species() == Species::Armadillo {
|
||||
amount = armadillo::reduce_damage(self, amount);
|
||||
}
|
||||
// #damages_helmet: falling anvils, blocks and stalactites.
|
||||
if matches!(
|
||||
source.kind,
|
||||
DamageKind::FallingBlock | DamageKind::FallingStalactite
|
||||
DamageKind::FallingBlock | DamageKind::FallingAnvil | DamageKind::FallingStalactite
|
||||
) && !species::body_slot(self.species())
|
||||
&& self.extra.head.is_some()
|
||||
{
|
||||
@@ -2260,6 +2318,10 @@ impl Mob {
|
||||
| DamageKind::OutOfWorld
|
||||
| DamageKind::Cramming
|
||||
| DamageKind::Generic
|
||||
| DamageKind::GenericKill
|
||||
| DamageKind::OutsideBorder
|
||||
| DamageKind::Starve
|
||||
| DamageKind::Wither
|
||||
)
|
||||
}
|
||||
/// `Wolf.actuallyHurt`: the armor loses `ceil(damage)` durability, cracks
|
||||
|
||||
@@ -244,6 +244,8 @@ pub enum ArmPose {
|
||||
CrossbowCharge,
|
||||
/// Raising a trident to throw it (or to launch with Riptide).
|
||||
ThrowTrident,
|
||||
/// Playing a goat horn.
|
||||
TootHorn,
|
||||
/// A spear held out (`HumanoidModel.ArmPose.SPEAR`), at rest or charging.
|
||||
Spear,
|
||||
}
|
||||
@@ -394,6 +396,10 @@ fn pose_arm(p: &mut Poses, pose: ArmPose, right: bool, h: &Hum) {
|
||||
p.xrot(arm, x * 0.5 - PI);
|
||||
p.yrot(arm, 0.0);
|
||||
}
|
||||
ArmPose::TootHorn => {
|
||||
p.xrot(arm, hx.clamp(-1.2, 1.2) - 1.483_529_8);
|
||||
p.yrot(arm, hy + if right { -FRAC_PI_6 } else { FRAC_PI_6 });
|
||||
}
|
||||
ArmPose::CrossbowHold => crossbow_hold(p, hx, hy, right),
|
||||
ArmPose::CrossbowCharge => crossbow_charge(p, h.charge.0, h.charge.1, right),
|
||||
// SpearAnimations.thirdPersonHandUse.
|
||||
@@ -748,6 +754,7 @@ pub fn setup(
|
||||
Some(_) if using_main && m.extra.blocking => ArmPose::Block,
|
||||
Some("minecraft:bow") if using_main => ArmPose::Bow,
|
||||
Some("minecraft:trident") if using_main => ArmPose::ThrowTrident,
|
||||
Some("minecraft:goat_horn") if using_main => ArmPose::TootHorn,
|
||||
Some("minecraft:crossbow") if using_main && !m.extra.charged => {
|
||||
ArmPose::CrossbowCharge
|
||||
}
|
||||
@@ -766,6 +773,11 @@ pub fn setup(
|
||||
ArmPose::Spear
|
||||
} else if m.extra.using_item > 0 && m.extra.using_off_hand && m.extra.blocking {
|
||||
ArmPose::Block
|
||||
} else if m.extra.using_item > 0
|
||||
&& m.extra.using_off_hand
|
||||
&& m.extra.off_hand.as_deref() == Some("minecraft:goat_horn")
|
||||
{
|
||||
ArmPose::TootHorn
|
||||
} else {
|
||||
ArmPose::Item
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ pub fn tear_down(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
/// invulnerability animation and the heart's reaction. Returns whether the
|
||||
/// normal damage path should run.
|
||||
pub fn accepts_damage(m: &mut Mob, source: &Damage) -> bool {
|
||||
if !heart_bound(m) || source.kind == DamageKind::Generic {
|
||||
if !heart_bound(m) || source.kind.bypasses_invulnerability() {
|
||||
return true;
|
||||
}
|
||||
if source.is_fire() || !m.alive() || m.extra.ints[INVULNERABLE] > 0 {
|
||||
|
||||
@@ -389,6 +389,10 @@ pub struct Ctx<'a, L: FinalizeLevel + ?Sized, R: RandomSource> {
|
||||
/// Jockeys and mounts made along the way, finalized, with their riding
|
||||
/// links set (`Extra::mount` / `Extra::rider`).
|
||||
pub created: Vec<Mob>,
|
||||
/// The rider as it was when its new chicken was added to the level, before
|
||||
/// its equipment and attributes: what a generated chunk keeps of it
|
||||
/// (`ProtoChunk.addEntity` saves the chicken with its passenger then).
|
||||
pub jockey_rider: Option<Mob>,
|
||||
}
|
||||
|
||||
// ---- attributes -----------------------------------------------------------
|
||||
@@ -1263,6 +1267,7 @@ impl<L: FinalizeLevel + ?Sized, R: RandomSource> Ctx<'_, L, R> {
|
||||
self.finalize(&mut chicken, Reason::Jockey, None);
|
||||
chicken.extra.jockey = true;
|
||||
self.ride(m, &mut chicken);
|
||||
self.jockey_rider = Some(m.clone());
|
||||
self.created.push(chicken);
|
||||
}
|
||||
}
|
||||
@@ -1614,7 +1619,7 @@ impl<L: FinalizeLevel + ?Sized, R: RandomSource> Ctx<'_, L, R> {
|
||||
S::Frog => {
|
||||
self.variant(m, |r| &r.frog);
|
||||
let jump = self.uniform(100, 140);
|
||||
set_memory(m, "long_jump_cooldown", i64::from(jump), i64::MAX);
|
||||
set_memory(m, "long_jump_cooling_down", i64::from(jump), i64::MAX);
|
||||
self.ageable(m, g)
|
||||
}
|
||||
S::ZombieNautilus => {
|
||||
@@ -1729,9 +1734,9 @@ impl<L: FinalizeLevel + ?Sized, R: RandomSource> Ctx<'_, L, R> {
|
||||
}
|
||||
S::Goat => {
|
||||
let jump = self.uniform(600, 1200);
|
||||
set_memory(m, "long_jump_cooldown", i64::from(jump), i64::MAX);
|
||||
set_memory(m, "long_jump_cooling_down", i64::from(jump), i64::MAX);
|
||||
let ram = self.uniform(600, 6000);
|
||||
set_memory(m, "ram_cooldown", i64::from(ram), i64::MAX);
|
||||
set_memory(m, "ram_cooldown_ticks", i64::from(ram), i64::MAX);
|
||||
m.extra.screaming = self.random.next_double() < 0.02;
|
||||
age_boundary_reached(m);
|
||||
if !m.is_baby() && self.float() < 0.1 {
|
||||
|
||||
@@ -173,16 +173,21 @@ pub fn protection(m: &Mob, kind: DamageKind) -> f32 {
|
||||
protection_points(g, kind).min(20.0)
|
||||
}
|
||||
pub fn protection_points(g: &Gear, kind: DamageKind) -> f32 {
|
||||
if kind == DamageKind::Generic {
|
||||
// Protection's `damage_protection` skips #bypasses_invulnerability.
|
||||
if kind == DamageKind::Generic || kind.bypasses_invulnerability() {
|
||||
return 0.0;
|
||||
}
|
||||
let fire = matches!(
|
||||
kind,
|
||||
DamageKind::OnFire | DamageKind::InFire | DamageKind::Lava | DamageKind::Fireball
|
||||
DamageKind::OnFire
|
||||
| DamageKind::InFire
|
||||
| DamageKind::Lava
|
||||
| DamageKind::Fireball
|
||||
| DamageKind::SulfurCubeHot
|
||||
);
|
||||
let projectile = matches!(
|
||||
kind,
|
||||
DamageKind::Arrow | DamageKind::Thrown | DamageKind::Fireball
|
||||
DamageKind::Arrow | DamageKind::Thrown | DamageKind::Fireball | DamageKind::WindCharge
|
||||
);
|
||||
let mut total = 0.0;
|
||||
for slot in ARMOR {
|
||||
@@ -191,9 +196,17 @@ pub fn protection_points(g: &Gear, kind: DamageKind) -> f32 {
|
||||
total += match &**id {
|
||||
"minecraft:protection" => l,
|
||||
"minecraft:fire_protection" if fire => 2.0 * l,
|
||||
"minecraft:blast_protection" if kind == DamageKind::Explosion => 2.0 * l,
|
||||
"minecraft:blast_protection"
|
||||
if matches!(kind, DamageKind::Explosion | DamageKind::Fireworks) =>
|
||||
{
|
||||
2.0 * l
|
||||
}
|
||||
"minecraft:projectile_protection" if projectile => 2.0 * l,
|
||||
"minecraft:feather_falling" if kind == DamageKind::Fall => 3.0 * l,
|
||||
"minecraft:feather_falling"
|
||||
if matches!(kind, DamageKind::Fall | DamageKind::Stalagmite) =>
|
||||
{
|
||||
3.0 * l
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -535,11 +535,6 @@ pub fn to_nbt(m: &Mob, map: &mut horizon_world::nbt::Compound) {
|
||||
map.remove(&"Owner".into());
|
||||
}
|
||||
map.insert("Sitting".into(), Tag::Byte(0));
|
||||
// AgeableMob and Animal (a water creature is not saved with them above).
|
||||
map.insert("Age".into(), Tag::Int(m.extra.age));
|
||||
map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked)));
|
||||
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
|
||||
map.entry("ForcedAge".into()).or_insert(Tag::Int(0));
|
||||
if zombie(m) {
|
||||
let v = VARIANTS[usize::from(m.extra.variant).min(1)];
|
||||
map.insert(
|
||||
@@ -573,14 +568,6 @@ pub fn to_nbt(m: &Mob, map: &mut horizon_world::nbt::Compound) {
|
||||
}
|
||||
pub fn from_nbt(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
|
||||
use horizon_world::nbt::Tag;
|
||||
let int = |k: &str| match tag.get(k) {
|
||||
Some(Tag::Int(v)) => Some(*v),
|
||||
Some(Tag::Byte(v)) => Some(i32::from(*v)),
|
||||
_ => None,
|
||||
};
|
||||
m.extra.age = int("Age").unwrap_or(0);
|
||||
m.extra.age_locked = int("AgeLocked").unwrap_or(0) != 0;
|
||||
m.extra.in_love = int("InLove").unwrap_or(0);
|
||||
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
|
||||
m.extra.tame = m.extra.owner.is_some();
|
||||
if let Some(Tag::String(v)) = tag.get("variant") {
|
||||
|
||||
@@ -539,18 +539,21 @@ pub fn spawn_aabb(s: Species, x: f64, y: f64, z: f64, width: f32, height: f32) -
|
||||
Vec3::new(x + half, y + f64::from(scale * height), z + half),
|
||||
)
|
||||
}
|
||||
/// `checkSpawnObstruction` of a freshly created mob with the given box.
|
||||
/// `checkSpawnObstruction` of a freshly created mob with the given box, with
|
||||
/// the mobs already `spawned` in the way; None in a generation region, which
|
||||
/// has no entities (`WorldGenRegion.getEntities`).
|
||||
pub(crate) fn spawn_obstruction<L: NaturalLevel>(
|
||||
s: Species,
|
||||
l: &L,
|
||||
bounds: Aabb,
|
||||
spawned: &[Aabb],
|
||||
spawned: Option<&[Aabb]>,
|
||||
x: i32,
|
||||
y: i32,
|
||||
z: i32,
|
||||
) -> bool {
|
||||
let unobstructed =
|
||||
|l: &L| l.unobstructed(bounds) && !spawned.iter().any(|b| b.intersects(bounds));
|
||||
let unobstructed = |l: &L| {
|
||||
spawned.is_none_or(|s| l.unobstructed(bounds) && !s.iter().any(|b| b.intersects(bounds)))
|
||||
};
|
||||
match obstruction(s) {
|
||||
Obstruction::NoLiquidUnobstructed => !contains_any_liquid(l, bounds) && unobstructed(l),
|
||||
Obstruction::Unobstructed => unobstructed(l),
|
||||
@@ -1084,7 +1087,7 @@ pub fn spawn_category_for_position<L: NaturalLevel, R: RandomSource>(
|
||||
let far = distance > despawn && removed_when_far(species);
|
||||
if far
|
||||
|| !mob_spawn_rules(species, l, x, y, z)
|
||||
|| !spawn_obstruction(species, l, bounds, &spawned, x, y, z)
|
||||
|| !spawn_obstruction(species, l, bounds, Some(&spawned), x, y, z)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -1237,8 +1240,8 @@ pub enum GenEvent {
|
||||
Spawn(String, [f64; 3], f32),
|
||||
}
|
||||
/// `NaturalSpawner.spawnMobsForChunkGeneration`: creature packs placed on
|
||||
/// the chunk's surface. `rules` is the level's own random, which the spawn
|
||||
/// predicates draw from.
|
||||
/// the chunk's surface. `rules` is the region's random, which the spawn
|
||||
/// predicates and `spawn` (finalizeSpawn) draw from.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn spawn_mobs_for_chunk_generation<L: NaturalLevel, R: RandomSource, Q: RandomSource>(
|
||||
l: &L,
|
||||
@@ -1246,7 +1249,7 @@ pub fn spawn_mobs_for_chunk_generation<L: NaturalLevel, R: RandomSource, Q: Rand
|
||||
chunk: (i32, i32),
|
||||
random: &mut R,
|
||||
rules: &mut Q,
|
||||
spawn: &mut dyn FnMut(&L, &SpawnRequest) -> Option<Spawned>,
|
||||
spawn: &mut dyn FnMut(&L, &SpawnRequest, &mut Q) -> bool,
|
||||
trace: &mut dyn FnMut(GenEvent),
|
||||
) {
|
||||
if !l.spawn_mobs() {
|
||||
@@ -1269,7 +1272,6 @@ pub fn spawn_mobs_for_chunk_generation<L: NaturalLevel, R: RandomSource, Q: Rand
|
||||
};
|
||||
let count = sample_count(&entry, random);
|
||||
let mut group_index = 0u32;
|
||||
let mut spawned: Vec<Aabb> = Vec::new();
|
||||
let mut x = xo + random.next_int_bound(16).unwrap_or(0);
|
||||
let mut z = zo + random.next_int_bound(16).unwrap_or(0);
|
||||
let (start_x, start_z) = (x, z);
|
||||
@@ -1311,7 +1313,7 @@ pub fn spawn_mobs_for_chunk_generation<L: NaturalLevel, R: RandomSource, Q: Rand
|
||||
let yaw = random.next_float() * 360.0;
|
||||
let bounds = spawn_box(fx, fy, fz, width, height);
|
||||
if mob_spawn_rules(species, l, bx, pos[1], bz)
|
||||
&& spawn_obstruction(species, l, bounds, &spawned, bx, pos[1], bz)
|
||||
&& spawn_obstruction(species, l, bounds, None, bx, pos[1], bz)
|
||||
{
|
||||
let request = SpawnRequest {
|
||||
entity: &entry.entity,
|
||||
@@ -1321,9 +1323,8 @@ pub fn spawn_mobs_for_chunk_generation<L: NaturalLevel, R: RandomSource, Q: Rand
|
||||
group_index,
|
||||
reason: Reason::ChunkGeneration,
|
||||
};
|
||||
if let Some(result) = spawn(l, &request) {
|
||||
if spawn(l, &request, rules) {
|
||||
group_index += 1;
|
||||
spawned.push(result.bounds);
|
||||
trace(GenEvent::Spawn(entry.entity.clone(), [fx, fy, fz], yaw));
|
||||
success = true;
|
||||
}
|
||||
|
||||
@@ -563,7 +563,15 @@ impl SpawnerExt for Spawner {
|
||||
let rules_ok = data.custom_spawn_rules.is_some()
|
||||
|| spawn_selection::mob_spawn_rules(s, l, own[0], own[1], own[2]);
|
||||
if !rules_ok
|
||||
|| !spawn_selection::spawn_obstruction(s, l, bounds, &added, own[0], own[1], own[2])
|
||||
|| !spawn_selection::spawn_obstruction(
|
||||
s,
|
||||
l,
|
||||
bounds,
|
||||
Some(&added),
|
||||
own[0],
|
||||
own[1],
|
||||
own[2],
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1081,7 +1081,15 @@ impl TrialSpawnerExt for TrialSpawner {
|
||||
width * scale,
|
||||
height * scale,
|
||||
);
|
||||
if !spawn_selection::spawn_obstruction(species, l, bounds, &[], own[0], own[1], own[2]) {
|
||||
if !spawn_selection::spawn_obstruction(
|
||||
species,
|
||||
l,
|
||||
bounds,
|
||||
Some(&[]),
|
||||
own[0],
|
||||
own[1],
|
||||
own[2],
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let request = SpawnerSpawn {
|
||||
|
||||
@@ -395,7 +395,7 @@ pub fn anger_level(m: &Mob) -> AngerLevel {
|
||||
|
||||
/// `Warden.isInvulnerableTo`: while digging or emerging.
|
||||
pub fn accepts_damage(m: &Mob, source: &Damage) -> bool {
|
||||
!(digging_or_emerging(m) && source.kind != DamageKind::Generic)
|
||||
!(digging_or_emerging(m) && !source.kind.bypasses_invulnerability())
|
||||
}
|
||||
/// `Warden.hurtServer`'s reaction, handled at the warden's next tick.
|
||||
pub fn hurt(m: &mut Mob, source: &Damage) {
|
||||
|
||||
@@ -388,7 +388,7 @@ pub fn custom_server_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEven
|
||||
/// `WitherBoss.hurtServer` filter: not while charging, no arrows once
|
||||
/// powered, nothing from the undead.
|
||||
pub fn accepts(m: &mut Mob, kind: DamageKind, attacker: Option<Who>, others_undead: bool) -> bool {
|
||||
if m.extra.counter > 0 && kind != DamageKind::Generic {
|
||||
if m.extra.counter > 0 && !kind.bypasses_invulnerability() {
|
||||
return false;
|
||||
}
|
||||
if powered(m) && kind == DamageKind::Arrow {
|
||||
|
||||
@@ -639,6 +639,7 @@ fn configure(
|
||||
}))?
|
||||
}
|
||||
cfg::Clientbound::RegistryData(r) => registries.add(r),
|
||||
cfg::Clientbound::UpdateTags(t) => registries.add_tags(&t),
|
||||
cfg::Clientbound::CodeOfConduct(c) => {
|
||||
let _ = send.send(Event::CodeOfConduct(c.text));
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use horizon_world::{
|
||||
generator::{
|
||||
GeneratorData, GeneratorWorker, OverworldGenerator, ProtoChunk, SurfaceSample,
|
||||
},
|
||||
structure::legacy::PieceRandom,
|
||||
terrain::PreLightChunkStage,
|
||||
},
|
||||
};
|
||||
@@ -221,6 +222,24 @@ impl VanillaTerrain {
|
||||
pub fn set_spawn_conditions(&self, difficulty: u8, day_time: i64) {
|
||||
self.structure_mobs.set_conditions(difficulty, day_time);
|
||||
}
|
||||
/// The random a chunk's generation steps draw from (`WorldGenRegion`).
|
||||
pub fn region_random(&self, cx: i32, cz: i32) -> Box<dyn PieceRandom> {
|
||||
self.generator.region_random(ChunkPos::new(cx, cz))
|
||||
}
|
||||
/// A mob chunk generation spawns, as the chunk saves it
|
||||
/// (`StructureMobFinalizer::spawn_generated`).
|
||||
pub fn spawn_generated(
|
||||
&self,
|
||||
kind: &str,
|
||||
position: horizon_core::geometry::Vec3,
|
||||
yaw: f32,
|
||||
biome: &str,
|
||||
random: &mut dyn PieceRandom,
|
||||
group: &mut Option<crate::mobs::finalize::Group>,
|
||||
) -> Option<horizon_world::nbt::Compound> {
|
||||
self.structure_mobs
|
||||
.spawn_generated(kind, position, yaw, biome, random, group)
|
||||
}
|
||||
/// A proto chunk's blocks, biomes, scheduled ticks and post-processing
|
||||
/// marks as a column.
|
||||
fn column(&self, chunk: &ProtoChunk) -> Chunk {
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::{
|
||||
items::Items,
|
||||
mobs::{
|
||||
Mob, MobType, MobTypes,
|
||||
finalize::{self, Ctx, FinalizeLevel, Reason, SpawnData, SpawnRegistries},
|
||||
finalize::{self, Ctx, FinalizeLevel, Group, Reason, SpawnData, SpawnRegistries},
|
||||
spawning::{LocalDifficulty, MOON_BRIGHTNESS},
|
||||
},
|
||||
};
|
||||
@@ -135,6 +135,8 @@ fn run<R: RandomSource>(
|
||||
difficulty: LocalDifficulty,
|
||||
data: &SpawnData<'_>,
|
||||
m: &mut Mob,
|
||||
reason: Reason,
|
||||
group: &mut Option<Group>,
|
||||
) -> Vec<Mob> {
|
||||
let mut cx = Ctx {
|
||||
level,
|
||||
@@ -142,8 +144,13 @@ fn run<R: RandomSource>(
|
||||
difficulty,
|
||||
data,
|
||||
created: Vec::new(),
|
||||
jockey_rider: None,
|
||||
};
|
||||
cx.finalize(m, Reason::Structure, None);
|
||||
*group = cx.finalize(m, reason, group.take());
|
||||
if let Some(rider) = cx.jockey_rider {
|
||||
debug_assert_eq!(rider.id, m.id);
|
||||
*m = rider;
|
||||
}
|
||||
cx.created
|
||||
}
|
||||
|
||||
@@ -154,6 +161,18 @@ fn tree(mobs: &[(Mob, Option<Tag>)], i: usize, dimension: &str) -> Compound {
|
||||
return Compound::new();
|
||||
};
|
||||
c.remove(&"UUID".into());
|
||||
// Keys in vanilla's templates that no entity reads: the chunk keeps the
|
||||
// entity as its save writes it (ProtoChunk.addEntity), without them.
|
||||
for key in [
|
||||
"CatType",
|
||||
"OwnerUUID",
|
||||
"Riches",
|
||||
"Willing",
|
||||
"food_level",
|
||||
"lastRestock",
|
||||
] {
|
||||
c.remove(&key.into());
|
||||
}
|
||||
entity_nbt::put_brain(&mut c, m, dimension);
|
||||
let riders: Vec<Tag> = (0..mobs.len())
|
||||
.filter(|&j| mobs[j].0.extra.mount == Some(m.id))
|
||||
@@ -173,23 +192,78 @@ impl StructureMobs for StructureMobFinalizer {
|
||||
structures: &[String],
|
||||
random: &mut dyn PieceRandom,
|
||||
) -> bool {
|
||||
let Some(data) = self.data.get_or_init(|| {
|
||||
let data = load(&self.root);
|
||||
if data.is_none() {
|
||||
log::error!("Horizon: no mob data, structure mobs are finalized when loaded");
|
||||
}
|
||||
data
|
||||
}) else {
|
||||
let Some(data) = self.data() else {
|
||||
return false;
|
||||
};
|
||||
let mut raw = Tag::Compound(nbt.clone());
|
||||
if let Tag::Compound(c) = &mut raw {
|
||||
c.remove(&"horizon:finalize".into());
|
||||
}
|
||||
let Some(mut m) = entity_nbt::mob_from_nbt(&raw, &data.types, 1) else {
|
||||
let Some(m) = entity_nbt::mob_from_nbt(&raw, &data.types, 1) else {
|
||||
// Not a mob: vanilla finalizes only Mob instances.
|
||||
return true;
|
||||
};
|
||||
let reason = Reason::Structure;
|
||||
match self.finish(
|
||||
data,
|
||||
m,
|
||||
Some(raw),
|
||||
biome,
|
||||
structures,
|
||||
random,
|
||||
reason,
|
||||
&mut None,
|
||||
) {
|
||||
Some(saved) => {
|
||||
*nbt = saved;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StructureMobFinalizer {
|
||||
fn data(&self) -> Option<&Data> {
|
||||
self.data
|
||||
.get_or_init(|| {
|
||||
let data = load(&self.root);
|
||||
if data.is_none() {
|
||||
log::error!("Horizon: no mob data, structure mobs are finalized when loaded");
|
||||
}
|
||||
data
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
/// A mob chunk generation spawns (`spawnOriginalMobs`): a new one of
|
||||
/// `kind` finalized as a structure mob is, as the chunk saves it; `group`
|
||||
/// carries a pack's group data from one member to the next.
|
||||
pub fn spawn_generated(
|
||||
&self,
|
||||
kind: &str,
|
||||
position: Vec3,
|
||||
yaw: f32,
|
||||
biome: &str,
|
||||
random: &mut dyn PieceRandom,
|
||||
group: &mut Option<Group>,
|
||||
) -> Option<Compound> {
|
||||
let data = self.data()?;
|
||||
let m = Mob::new(1, data.types.get(kind)?.clone(), position, yaw);
|
||||
let reason = Reason::ChunkGeneration;
|
||||
self.finish(data, m, None, biome, &[], random, reason, group)
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn finish(
|
||||
&self,
|
||||
data: &Data,
|
||||
mut m: Mob,
|
||||
raw: Option<Tag>,
|
||||
biome: &str,
|
||||
structures: &[String],
|
||||
random: &mut dyn PieceRandom,
|
||||
reason: Reason,
|
||||
group: &mut Option<Group>,
|
||||
) -> Option<Compound> {
|
||||
let base = self.difficulty.load(Ordering::Relaxed);
|
||||
let day_time = self.day_time.load(Ordering::Relaxed);
|
||||
let mut level = Level {
|
||||
@@ -211,21 +285,20 @@ impl StructureMobs for StructureMobFinalizer {
|
||||
};
|
||||
let any = random.as_any();
|
||||
let created = if let Some(r) = any.downcast_mut::<LegacyRandom>() {
|
||||
run(&mut level, r, difficulty, &spawn, &mut m)
|
||||
run(&mut level, r, difficulty, &spawn, &mut m, reason, group)
|
||||
} else if let Some(r) = any.downcast_mut::<XoroshiroRandom>() {
|
||||
run(&mut level, r, difficulty, &spawn, &mut m)
|
||||
run(&mut level, r, difficulty, &spawn, &mut m, reason, group)
|
||||
} else {
|
||||
log::error!("Horizon: unknown region random, structure mobs are finalized when loaded");
|
||||
return false;
|
||||
return None;
|
||||
};
|
||||
let mut mobs = vec![(m, Some(raw))];
|
||||
let mut mobs = vec![(m, raw)];
|
||||
mobs.extend(created.into_iter().map(|c| (c, None)));
|
||||
let root = mobs
|
||||
.iter()
|
||||
.position(|(m, _)| m.extra.mount.is_none())
|
||||
.unwrap_or(0);
|
||||
*nbt = tree(&mobs, root, &self.dimension);
|
||||
true
|
||||
Some(tree(&mobs, root, &self.dimension))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -260,6 +260,8 @@ pub struct Items {
|
||||
loot: HashMap<String, Value>,
|
||||
/// The `jukebox_song` registry.
|
||||
pub songs: HashMap<String, Song>,
|
||||
/// The `instrument` registry (goat horns).
|
||||
pub instruments: HashMap<String, Instrument>,
|
||||
/// The enchantment registry: loot functions enchant the items they roll
|
||||
/// (`EnchantWithLevelsFunction`, `EnchantRandomlyFunction`), and the
|
||||
/// station menus share this copy.
|
||||
@@ -279,6 +281,76 @@ impl Song {
|
||||
ticks >= self.length_ticks + 20
|
||||
}
|
||||
}
|
||||
/// An `Instrument`: what a goat horn plays.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Instrument {
|
||||
pub sound: String,
|
||||
/// Seconds the horn stays up, and its cooldown.
|
||||
pub use_duration: f32,
|
||||
/// How far it carries, in blocks (`range / 16` is its volume).
|
||||
pub range: f32,
|
||||
}
|
||||
impl Instrument {
|
||||
/// `InstrumentItem.getUseDuration`: `Mth.floor(useDuration * 20)`.
|
||||
pub fn use_ticks(&self) -> i32 {
|
||||
(self.use_duration * 20.0).floor() as i32
|
||||
}
|
||||
}
|
||||
/// The id of a goat horn's instrument (`InstrumentItem.getInstrument`): the
|
||||
/// stack's own `instrument` component, else the item's `ponder_goat_horn`.
|
||||
/// None for an inline instrument (see `inline_instrument`).
|
||||
pub fn instrument_id(stack: &ItemStack) -> Option<String> {
|
||||
if &*stack.item != "minecraft:goat_horn" {
|
||||
return None;
|
||||
}
|
||||
match stack.data().and_then(|d| d.other("minecraft:instrument")) {
|
||||
Some(horizon_world::nbt::Tag::String(id)) => Some(id.to_string_lossy()),
|
||||
Some(horizon_world::nbt::Tag::Compound(_)) => None,
|
||||
_ => Some("minecraft:ponder_goat_horn".to_owned()),
|
||||
}
|
||||
}
|
||||
/// A goat horn's inline `instrument` component (`Instrument.CODEC` also
|
||||
/// takes the instrument itself instead of a registry id).
|
||||
pub fn inline_instrument(stack: &ItemStack) -> Option<&horizon_world::nbt::Tag> {
|
||||
if &*stack.item != "minecraft:goat_horn" {
|
||||
return None;
|
||||
}
|
||||
match stack.data().and_then(|d| d.other("minecraft:instrument")) {
|
||||
Some(tag @ horizon_world::nbt::Tag::Compound(_)) => Some(tag),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `Instrument.DIRECT_CODEC`: a `sound_event` that is an id or `{sound_id}`,
|
||||
/// `use_duration` and `range`.
|
||||
fn parse_instrument(v: &Value) -> Instrument {
|
||||
let sound = match &v["sound_event"] {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other["sound_id"].as_str().unwrap_or("").to_owned(),
|
||||
};
|
||||
Instrument {
|
||||
sound,
|
||||
use_duration: v["use_duration"].as_f64().unwrap_or(0.0) as f32,
|
||||
range: v["range"].as_f64().unwrap_or(0.0) as f32,
|
||||
}
|
||||
}
|
||||
fn load_instruments(root: &Path) -> HashMap<String, Instrument> {
|
||||
let mut instruments = HashMap::new();
|
||||
let Ok(dir) = fs::read_dir(root.join("data/minecraft/instrument")) else {
|
||||
return instruments;
|
||||
};
|
||||
for entry in dir.flatten() {
|
||||
let path = entry.path();
|
||||
let (Some(stem), Ok(bytes)) = (path.file_stem().and_then(|s| s.to_str()), fs::read(&path))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
instruments.insert(format!("minecraft:{stem}"), parse_instrument(&v));
|
||||
}
|
||||
instruments
|
||||
}
|
||||
fn load_songs(root: &Path) -> HashMap<String, Song> {
|
||||
let mut songs = HashMap::new();
|
||||
let Ok(dir) = fs::read_dir(root.join("data/minecraft/jukebox_song")) else {
|
||||
@@ -756,12 +828,23 @@ impl Items {
|
||||
item_tag_order: load_tag_lists(root, "item")?,
|
||||
loot,
|
||||
songs: load_songs(root),
|
||||
instruments: load_instruments(root),
|
||||
enchantments: Arc::new(crate::enchant::Enchantments::load(root)),
|
||||
})
|
||||
}
|
||||
pub fn get(&self, id: &str) -> Option<&ItemInfo> {
|
||||
self.items.get(id)
|
||||
}
|
||||
/// The instrument a goat horn plays: its `instrument` component's
|
||||
/// registry entry or inline instrument.
|
||||
pub fn instrument(&self, stack: &ItemStack) -> Option<Instrument> {
|
||||
if let Some(tag) = inline_instrument(stack) {
|
||||
return Some(parse_instrument(
|
||||
&horizon_client_core::scoreboard::tag_to_json(tag),
|
||||
));
|
||||
}
|
||||
self.instruments.get(&instrument_id(stack)?).cloned()
|
||||
}
|
||||
/// Only the block and item tags (and the enchantment registry, which the
|
||||
/// station rules and loot functions are built from), for tools that need
|
||||
/// tag membership.
|
||||
@@ -775,6 +858,7 @@ impl Items {
|
||||
item_tag_order: load_tag_lists(root, "item")?,
|
||||
loot: HashMap::new(),
|
||||
songs: HashMap::new(),
|
||||
instruments: HashMap::new(),
|
||||
enchantments: Arc::new(crate::enchant::Enchantments::load(root)),
|
||||
})
|
||||
}
|
||||
@@ -793,6 +877,7 @@ impl Items {
|
||||
item_tags,
|
||||
loot: HashMap::new(),
|
||||
songs: HashMap::new(),
|
||||
instruments: HashMap::new(),
|
||||
enchantments: Arc::new(crate::enchant::Enchantments::default()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
//! registries report. Data-driven registries (dimension types, biomes, chat
|
||||
//! types, damage types ...) are numbered in the order the server sends them
|
||||
//! during configuration.
|
||||
use crate::{Error, Result, packets::configuration::RegistryData};
|
||||
use crate::{
|
||||
Error, Result,
|
||||
packets::configuration::{RegistryData, UpdateTags},
|
||||
};
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
@@ -89,8 +92,24 @@ impl Builtin {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Dynamic {
|
||||
pub registries: HashMap<String, Vec<(String, Option<Tag>)>>,
|
||||
/// The `minecraft:dialog` tags the server sent: each tag's entries by id.
|
||||
pub dialog_tags: HashMap<String, Vec<i32>>,
|
||||
}
|
||||
impl Dynamic {
|
||||
/// The dialog tags of a `ClientboundUpdateTagsPacket`.
|
||||
pub fn add_tags(&mut self, packet: &UpdateTags) {
|
||||
for r in packet
|
||||
.registries
|
||||
.iter()
|
||||
.filter(|r| r.registry == "minecraft:dialog")
|
||||
{
|
||||
self.dialog_tags = r
|
||||
.tags
|
||||
.iter()
|
||||
.map(|t| (t.name.clone(), t.ids.clone()))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
pub fn add(&mut self, packet: RegistryData) {
|
||||
let entries = packet.entries.into_iter().map(|e| (e.id, e.data)).collect();
|
||||
self.registries.insert(packet.registry, entries);
|
||||
|
||||
@@ -1334,6 +1334,17 @@ impl<R: RandomSource> Pipeline<R>
|
||||
where
|
||||
R::Positional: Clone + Send + Sync,
|
||||
{
|
||||
/// WorldGenRegion's level random, positioned at the center chunk.
|
||||
fn region_random_at(&self, center: ChunkPos) -> R {
|
||||
self.root
|
||||
.from_hash_of("minecraft:worldgen_region_random")
|
||||
.fork_positional()
|
||||
.at(BlockPos::new(
|
||||
center.x.wrapping_shl(4),
|
||||
0,
|
||||
center.z.wrapping_shl(4),
|
||||
))
|
||||
}
|
||||
/// ChunkGenerator.getBaseHeight: the first free Y above the noise column
|
||||
/// (with aquifers, without terrain adaptation) for a heightmap type.
|
||||
fn base_height(&self, x: i32, z: i32, kind: HeightmapType) -> i32 {
|
||||
@@ -1778,6 +1789,7 @@ trait SeededPipeline: Send + Sync {
|
||||
fn forget_structure_starts(&self);
|
||||
fn ring_positions(&self, worker: &mut WorkerState, set: &str) -> Option<Vec<ChunkPos>>;
|
||||
fn spawn_position(&self) -> Result<[i32; 2], GeneratorError>;
|
||||
fn region_random(&self, center: ChunkPos) -> Box<dyn super::structure::legacy::PieceRandom>;
|
||||
fn locate(
|
||||
&self,
|
||||
worker: &mut WorkerState,
|
||||
@@ -1816,17 +1828,7 @@ where
|
||||
) -> Result<DecorateStats, GeneratorError> {
|
||||
match &self.decoration {
|
||||
Some(decoration) => {
|
||||
let center = region[4].position();
|
||||
// WorldGenRegion's level random, positioned at the center chunk.
|
||||
let mut random = self
|
||||
.root
|
||||
.from_hash_of("minecraft:worldgen_region_random")
|
||||
.fork_positional()
|
||||
.at(BlockPos::new(
|
||||
center.x.wrapping_shl(4),
|
||||
0,
|
||||
center.z.wrapping_shl(4),
|
||||
));
|
||||
let mut random = self.region_random_at(region[4].position());
|
||||
decoration.decorate(
|
||||
&self.data,
|
||||
self.seed,
|
||||
@@ -1915,6 +1917,9 @@ where
|
||||
set,
|
||||
)
|
||||
}
|
||||
fn region_random(&self, center: ChunkPos) -> Box<dyn super::structure::legacy::PieceRandom> {
|
||||
Box::new(self.region_random_at(center))
|
||||
}
|
||||
fn spawn_position(&self) -> Result<[i32; 2], GeneratorError> {
|
||||
if let Some(position) = self.spawn_position.get() {
|
||||
return Ok(*position);
|
||||
@@ -2551,6 +2556,14 @@ impl OverworldGenerator {
|
||||
pub fn spawn_position(&self) -> Result<[i32; 2], GeneratorError> {
|
||||
self.pipeline.spawn_position()
|
||||
}
|
||||
/// A new `WorldGenRegion` random centred on the chunk: what its generation
|
||||
/// steps draw from (`level.getRandom()`).
|
||||
pub fn region_random(
|
||||
&self,
|
||||
center: ChunkPos,
|
||||
) -> Box<dyn super::structure::legacy::PieceRandom> {
|
||||
self.pipeline.region_random(center)
|
||||
}
|
||||
/// Create per-thread state. Workers are cheap; keep one per thread.
|
||||
pub fn worker(&self) -> GeneratorWorker {
|
||||
let data = self.pipeline.data();
|
||||
|
||||
@@ -246,7 +246,7 @@ public final class VanillaFinalizeSpawnOracle {
|
||||
Map.entry("jump_strength", Attributes.JUMP_STRENGTH));
|
||||
|
||||
static final List<Map.Entry<String, MemoryModuleType<?>>> MEMORIES = List.of(
|
||||
Map.entry("long_jump_cooldown", MemoryModuleType.LONG_JUMP_COOLDOWN_TICKS), Map.entry("ram_cooldown", MemoryModuleType.RAM_COOLDOWN_TICKS),
|
||||
Map.entry("long_jump_cooling_down", MemoryModuleType.LONG_JUMP_COOLDOWN_TICKS), Map.entry("ram_cooldown_ticks", MemoryModuleType.RAM_COOLDOWN_TICKS),
|
||||
Map.entry("attack_target_cooldown", MemoryModuleType.ATTACK_TARGET_COOLDOWN), Map.entry("hunted_recently", MemoryModuleType.HUNTED_RECENTLY),
|
||||
Map.entry("dig_cooldown", MemoryModuleType.DIG_COOLDOWN), Map.entry("is_emerging", MemoryModuleType.IS_EMERGING),
|
||||
Map.entry("home", MemoryModuleType.HOME));
|
||||
|
||||
@@ -171,6 +171,8 @@ public final class VanillaNaturalSpawnOracle {
|
||||
int tick = -1;
|
||||
List<String> log;
|
||||
boolean logColumns;
|
||||
// A WorldGenRegion sees no entities (getEntities is empty).
|
||||
boolean generationRegion;
|
||||
int next = 1;
|
||||
@Override public RegistryAccess registryAccess() { return registries; }
|
||||
@Override public Difficulty getDifficulty() { return difficulty; }
|
||||
@@ -281,11 +283,13 @@ public final class VanillaNaturalSpawnOracle {
|
||||
@Override public Iterable<Entity> getAllEntities() { return entities; }
|
||||
@Override public List<Entity> getEntities(Entity except, AABB bb, Predicate<? super Entity> selector) {
|
||||
List<Entity> out = new ArrayList<>();
|
||||
if (generationRegion) return out;
|
||||
for (Entity e : entities) if (e != except && e.getBoundingBox().intersects(bb) && selector.test(e)) out.add(e);
|
||||
return out;
|
||||
}
|
||||
@Override public <T extends Entity> List<T> getEntities(EntityTypeTest<Entity, T> type, AABB bb, Predicate<? super T> selector) {
|
||||
List<T> out = new ArrayList<>();
|
||||
if (generationRegion) return out;
|
||||
for (Entity e : entities) {
|
||||
T t = type.tryCast(e);
|
||||
if (t != null && e.getBoundingBox().intersects(bb) && selector.test(t)) out.add(t);
|
||||
@@ -811,11 +815,13 @@ public final class VanillaNaturalSpawnOracle {
|
||||
vaultCase(level, c, access, blockLookup, mainRandom, log);
|
||||
} else if (mode.equals("gen")) {
|
||||
level.logColumns = true;
|
||||
level.generationRegion = true;
|
||||
int n = level.entities.size();
|
||||
RandomSource random = RandomSource.create(Long.parseLong(c.getOrDefault("gseed", "0")));
|
||||
BlockPos sourcePos = new BlockPos(chunk.at.getMinBlockX(), level.getMaxY(), chunk.at.getMinBlockZ());
|
||||
NaturalSpawner.spawnMobsForChunkGeneration(level, sourcePos, chunk.at, random);
|
||||
level.logColumns = false;
|
||||
level.generationRegion = false;
|
||||
for (Entity e : level.entities.subList(n, level.entities.size()))
|
||||
if (e.getVehicle() == null)
|
||||
log.add("spawn " + BuiltInRegistries.ENTITY_TYPE.getKey(e.getType()) + " " + bits(e.getX()) + " " + bits(e.getY()) + " " + bits(e.getZ()) + " " + yaw(e));
|
||||
|
||||
@@ -115,7 +115,10 @@ def body(item, report, diff, store_path, limit=25):
|
||||
for c, counts in sorted(details["items"].items(), key=lambda kv: -sum(kv[1].values()))[:limit]]
|
||||
if item["kind"] == "facts":
|
||||
lines += [f"- {key}: {a} → {b}" for key, (a, b) in details.items()]
|
||||
where = str(store_path).replace(str(Path.home()), "~")
|
||||
try:
|
||||
where = "~/" + Path(store_path).relative_to(Path.home()).as_posix()
|
||||
except ValueError:
|
||||
where = Path(store_path).as_posix()
|
||||
lines += ["", f"The full diff stays on the maintainer's machine (`{where}`), with the vanilla files it was built "
|
||||
"from: they are never published.", END]
|
||||
text = "\n".join(lines) + "\n"
|
||||
@@ -222,7 +225,7 @@ def apply(actions, report, open_issues):
|
||||
|
||||
def issue(action, name, text, number, new):
|
||||
"""Create or update one issue; its URL."""
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as stream:
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".md", delete=False) as stream:
|
||||
stream.write(text)
|
||||
try:
|
||||
if action == "create":
|
||||
|
||||
Reference in new issue
Block a user