Compare commits

...
Author SHA1 Message Date
Ludovic Marie 285ecb6e89 Merge branch 'heads/pt3/2586' into union/pt3-2 2026-10-29 10:30:23 +01:00
Ludovic Marie 5509fe8a5a Merge branch 'heads/pt3/2544' into union/pt3-2 2026-10-29 10:30:23 +01:00
Ludovic Marie 547ea7cdef Merge branch 'heads/pt3/3433' into union/pt3-2 2026-10-29 10:30:23 +01:00
Ludovic Marie 372b701542 Merge branch 'heads/pt3/3417' into union/pt3-2 2026-10-29 10:30:23 +01:00
Ludovic Marie abd2d1742a Merge branch 'heads/pt3/3000' into union/pt3-2 2026-10-29 10:30:23 +01:00
Ludovic Marie b423fa09d2 Merge branch 'heads/pt3/2981' into union/pt3-2 2026-10-29 10:30:23 +01:00
Ludovic Marie f352418364 Merge branch 'heads/pt3/2951' into union/pt3-2 2026-10-29 10:30:23 +01:00
Ludovic Marie 1e57b381ef Merge branch 'heads/pt3/2417' into union/pt3-2 2026-10-29 10:30:23 +01:00
Ludovic MarieandClaude Opus 5.5 0eba0b37b8 Merge main into this branch
game.rs: main's spawn-wait areas are taken back out of streaming.extra right after
the update, then this branch's fresh chunks get their animals.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:30:15 +01:00
Ludovic MarieandClaude Opus 5.5 390794b454 Merge main into this branch
entity_nbt.rs: main's farm animal variant test and this branch's structure mob
tests are both kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:30:09 +01:00
Ludovic MarieandClaude Opus 5.5 54e56ca215 Merge main into this branch
save.rs: main already reads session.lock after the lock is dropped (#3088), so
main's version of that test is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:29:20 +01:00
Ludovic MarieandClaude Opus 5.5 a185086f24 Merge main into this branch
remote.rs: leaving a server clears both the world icon state (this branch) and
main's bed state (InBedChatScreen goes with the level).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:29:19 +01:00
Ludovic MarieandClaude Opus 5.5 d026f1eb37 Merge main into this branch
net.rs: main's configuration loop now hands the code of conduct to the game
(Event::CodeOfConduct) instead of accepting it; this branch's UpdateTags arm is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:28:23 +01:00
Ludovic MarieandClaude Opus 5.5 cdefed7a19 Merge main into this branch
#damages_helmet holds falling_anvil, falling_block and falling_stalactite (26.3's
tag): main added the stalactite, this branch the anvil; both kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:27:54 +01:00
Ludovic MarieandClaude Opus 5.5 29d24f48df Merge main into this branch
Main's handleUseItem sets the guest's known movement (ServerPlayer.getKnownMovement)
before the item use; this branch's horn sound is kept in the same closure.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:27:37 +01:00
Ludovic MarieandClaude Opus 5.5 21ba38e633 Merge main into fix/list-damage-datapack-commands
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:00:37 +01:00
xKumorioandClaude Opus 5.5 a278572906 Three Windows-only test failures in pr_ready
session_lock_is_exclusive read session.lock while its lock was held.
Windows file locks are mandatory, so that read failed with OS error 33.
It now reads the file once the first lock is dropped.

port_publish wrote the store path as str(path) with the home replaced by
"~", which kept Windows backslashes (~\code\...). The path under the home
is now written as ~/ plus its POSIX form, as on Linux. The temporary body
file is opened as UTF-8, so the "→" in a facts line no longer fails to
encode in the Windows code page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 03:44:55 +05:00
xKumorioandClaude Opus 5.5 859b6be783 Kelp stops blinking out far away: animated sprites mipmap over the whole sheet
Vanilla's SpriteContents mipmaps the animation's original image, every
frame of the sheet at once (byMipLevel = {originalImage}, then
MipmapGenerator.generateMipLevels), and AnimatedTexture uploads each frame
from that chain at frameX/frameY >> level. Horizon mipmapped each frame on
its own, so solidify and each level's alpha-to-coverage scale were the
frame's. A 16px frame has a single texel square at its 2x2 level: its
coverage there is 0 or a multiple of 1/16, and the bisection could settle
on a scale that left the whole level under the cutout. Kelp frames 0 and 18
did, so far kelp vanished every time the animation reached them (only with
mipmaps on, as reported).

source_chains stacks the frames into one sheet, runs mipmap::generate over
it and cuts each level back into per-frame chains, so every frame shares
the sheet's coverage scale. A frame alone (source_chain) is unchanged.

Fixes #1703. Fixes #3073.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 03:33:32 +05:00
Hector EcclesandOpenCode a1542cb263 Give a single-player world its icon from the first frame
GameRenderer.tryTakeScreenshotIfNeeded and takeAutoScreenshot: once a
second, a world with no icon.png takes the centre square of its first
fully meshed frame, resampled to 64x64. Only the window's own
single-player world qualifies; joining a server clears it.

The snapshot runs after the hand and screen effects and before the
entity outline and post chain, as vanilla does, so the icon is the
scene rather than the GUI.

Not bit-identical to vanilla: the resize is a block average rather than
STB's filtered one, the PNG is written by the png crate, and the alpha
channel is forced opaque. The crop, size, timing and "only if missing"
rule match, so the icons look the same.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 18:23:21 -04:00
Ludovic MarieandClaude Opus 5.5 f049a08e48 packs: keep the available packs in one id order, as PackRepository does
PackRepository.discoverAvailable puts the packs of every source into a
single TreeMap by id, so the folder's file/ packs sort among the built-in
ones rather than after them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 23:02:52 +01:00
Ludovic MarieandClaude Opus 5.5 f5f5532c19 Merge main into fix/goat-horns
Kept the goat horn use next to main's bundle use in use_item.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:41:36 +01:00
Kacpherek 7711bfcdc5 Fail a dialog whose body or input cannot be read
Dialog.DIRECT_CODEC rejects the whole dialog for an unreadable body or input, a single option with no or two initial entries, or a text default longer than its limit. Horizon dropped the bad part and showed the rest.
2026-10-27 22:26:43 +01:00
Kacpherek 2ac08efcb2 Draw dialog sliders, dropdowns, item bodies and dialog lists
The dialog screen skipped number range and single option inputs, item bodies and the entries of a dialog_list, so a server menu showed only its Back button. Draw them as DialogScreen does: a slider and a cycle button, the item widget with its description, and one button per listed dialog (external title, opens it). The client now keeps the server's dialog tags to resolve a #tag list.
2026-10-27 22:26:43 +01:00
Kacpherek 559b83626f Open links from dialog buttons through the link confirmation
DialogScreen hands an open_url to Screen.clickUrlAction (Web Links, Prompt on Links, ConfirmLinkScreen over the screen the dialog leaves). A suggest_command has no screen to take text; change_page is logged as an error.
2026-10-27 22:26:43 +01:00
Kacpherek c2915208fd List built-in packs before folder packs in the pack screen
PackRepository keeps its sources in order (built-ins, then resourcepacks/), each sorted by id. Horizon sorted every id together.
2026-10-27 22:26:43 +01:00
Kacpherek 04bc30606d Take a texture's .mcmeta only from its own pack or a higher one
FallbackResourceManager ignores a .mcmeta from a pack below the one that supplies the file. Horizon used the top .mcmeta of any pack, so an animation could run on a pack's static PNG.
2026-10-27 22:26:43 +01:00
Kacpherek 6893bcff88 Cap server command suggestions at 1000
ServerCommandSuggestionsProvider keeps the first 1000 suggestions of a request; Horizon sent them all.
2026-10-27 22:26:43 +01:00
Kacpherek aae98d3db5 Send vanilla's error text for an invalid login name
There is no multiplayer.disconnect.invalid_name key. Vanilla's validState throws and Connection.exceptionCaught sends disconnect.genericReason with the exception.
2026-10-27 22:26:43 +01:00
Kacpherek 41f60562fd Add /datapack create
/datapack create <id> <description> fell into the disable branch and replied with an empty unknown-pack key. It now writes <world>/datapacks/<id>/ with a data folder and a pack.mcmeta, and replies with vanilla's success and failure keys (DataPackCommand.createPack).
2026-10-27 21:59:46 +01:00
Kacpherek 136b352e4f Give 15 vanilla damage types their own /damage kind
/damage fell back to the Generic kind for cramming, falling_anvil, falling_block, fireworks, generic_kill, lightning_bolt, out_of_world, outside_border, sonic_boom, spit, stalagmite, starve, sulfur_cube_hot, wind_charge and wither. Each now has its own kind and follows the vanilla damage type tags. Creaking, Warden and Wither ask "bypasses invulnerability" instead of "is Generic".
2026-10-27 21:59:46 +01:00
Kacpherek ff1b01616f List every online player in /list
/list printed a fixed "1 of a max of 8" with only the host. It now lists the host of a single-player or LAN world first, then the placed guests in join order, with the real count and the hub's max, as ListPlayersCommand does. /list uuids shows each name with its UUID. A single-player owner is already a guest of the in-process server and is not listed twice.
2026-10-27 21:59:46 +01:00
Ludovic MarieandClaude Opus 5.5 3246732087 Read the inline instrument through horizon_client_core::scoreboard
#2215 moved items.rs into horizon-items, where scoreboard is not in the
crate root.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:13:32 +01:00
Ludovic MarieandClaude Opus 5.5 c215a48c91 Merge main into fix/goat-horns
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:11:21 +01:00
Ludovic MarieandClaude Opus 5.5 6aabf3f618 Merge main into fix/goat-horns
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:43 +01:00
Ludovic Marie 36ba5c65a6 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/item_tooltip.rs
2026-10-27 13:44:46 +01:00
FusselTVandClaude Opus 5.5 22054eee90 Worldgen: chunk generation spawns its animals as vanilla, ticking or not
NaturalSpawner.spawnMobsForChunkGeneration runs in the generation
pipeline (spawnOriginalMobs), with the WorldGenRegion's random for the
spawn rules and finalizeSpawn. Horizon ran it from the game tick for
chunks that were fresh and lit, so:

- a world generated with ticks frozen, as a seed generator does, got no
  animals at all, and a chunk saved before a tick saw it lost them;
- the rules and finalizeSpawn drew from other randoms, and the reduced
  spawning::finalize gave no variants, babies or group data;
- the spawn list came from the biome's noise cell, not the zoomed biome
  (fullResolutionBiomes) vanilla reads;
- members of a pack were kept apart, though a WorldGenRegion has no
  entities in the way.

A chunk now gets its animals as soon as it is lit, whatever the tick
state. The positions come from the same selection, the rules and the
finalize from the chunk's region random, and each mob is made with its
constructor's state, finalized like a structure mob
(Reason::ChunkGeneration, group data per pack) and loaded from the NBT
generation would save. The natural spawn oracle's generation mode sees
no entities, as a WorldGenRegion.

Goats and frogs keep their long jump and ram cooldowns under vanilla's
memory names (long_jump_cooling_down, ram_cooldown_ticks), and rabbits
save the attack damage attribute Rabbit.setVariant makes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 00:37:18 +01:00
FusselTVandClaude Opus 5.5 1a80673d50 Worldgen: structure mobs save the fields vanilla's chunk keeps
ProtoChunk.addEntity saves a structure's entity as soon as it is placed,
and the chunk loads it again when it becomes full, so vanilla keeps what
each entity's own save writes. Horizon kept the template's NBT with its
model on top, and its model left out some fields:

- the keys in vanilla's templates that no entity reads (food_level,
  lastRestock, CatType, OwnerUUID, ...) are dropped;
- villagers save VillagerDataFinalized and their age (AgeableMob writes
  Age, ForcedAge and AgeLocked whenever canBeABaby, not only for
  creatures; never for a frog, a parrot or a zombie nautilus), and the
  age is read back for the same mobs (a baby villager or hoglin no
  longer grows up on load);
- iron golems and the other neutral mobs save anger_end_time;
- zombies, drowned and piglins save the movement speed attribute that
  their setBaby creates on load;
- a drowned with a nautilus shell keeps drop_chances (offhand 2.0), and
  saved drop chances are read back;
- the rider of a new chicken jockey is saved as it was when the chicken
  was added, before its equipment and attributes, as vanilla's chunk
  saves the chicken with its passenger at that point.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:12:29 +01:00
Kacpherek 75267d2fc6 Read inline goat horn instruments as well as registry ids 2026-10-26 21:43:33 +01:00
Kacpherek 9b88df32d3 Make goat horns play their sound and show the horn pose
A goat horn did nothing when used. It now plays its instrument at the
player, is held up for the instrument's duration, goes on cooldown, posts
the instrument_play game event and counts as used. Other players hear it,
see the arm raised to the head, and the tooltip names the call.
2026-10-26 19:54:15 +01:00
43 changed files with 1645 additions and 258 deletions

No files matched your search

+101 -10
View File
@@ -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;
+122 -13
View File
@@ -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(&current, &next, blend.millis, edge, size),
reference(current, next, blend.millis, edge, size),
"layer {} at tick {tick}",
animation.layer
);
+25 -1
View File
@@ -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!(
+126 -25
View File
@@ -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.
+125 -55
View File
@@ -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)))
}
}
+105 -12
View File
@@ -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"))
+105 -4
View File
@@ -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(),
+61 -18
View File
@@ -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,
}
}
+18 -3
View File
@@ -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).
+189 -5
View File
@@ -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"));
+46 -5
View File
@@ -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
}
}
+41
View File
@@ -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))
+2
View File
@@ -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;
+11
View File
@@ -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
+8 -1
View File
@@ -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(());
}
+67 -5
View File
@@ -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
+12
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+8 -3
View File
@@ -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 {
+18 -5
View File
@@ -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;
}
+9 -1
View File
@@ -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 {
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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 {
+1
View File
@@ -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
View File
@@ -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))
}
}
+85
View File
@@ -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()),
}
}
+20 -1
View File
@@ -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);
+24 -11
View File
@@ -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));
+5 -2
View File
@@ -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":