diff --git a/crates/horizon-client/examples/probe/cushion_placement_probe.rs b/crates/horizon-client/examples/probe/cushion_placement_probe.rs new file mode 100644 index 000000000..45fbdbd72 --- /dev/null +++ b/crates/horizon-client/examples/probe/cushion_placement_probe.rs @@ -0,0 +1,89 @@ +use horizon_client::{ + Result, + cushion::{self, World}, +}; +use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape}; +use serde_json::Value; +use std::{ + collections::HashMap, + fs::File, + io::{BufRead, BufReader, BufWriter, Write}, +}; + +struct Cell { + outline: VoxelShape, + collision: VoxelShape, + suffocating: bool, +} +struct Fixture(HashMap<[i32; 3], Cell>); +impl World for Fixture { + fn outline_bounds(&self, p: [i32; 3]) -> Option { + self.0 + .get(&p)? + .outline + .bounds() + .map(|b| b.move_by(Vec3::new(p[0] as f64, p[1] as f64, p[2] as f64))) + } + fn collision_shapes(&self, _: Aabb) -> Vec { + self.0 + .iter() + .map(|(p, c)| { + c.collision + .moved(Vec3::new(p[0] as f64, p[1] as f64, p[2] as f64)) + }) + .collect() + } + fn suffocating(&self, p: [i32; 3]) -> bool { + self.0.get(&p).is_some_and(|c| c.suffocating) + } +} +fn shape(value: &Value) -> Result { + let mut shape = VoxelShape::empty(); + for b in value.as_array().unwrap() { + let f = |i: usize| b[i].as_f64().unwrap(); + shape = shape.join( + &VoxelShape::from_box(Aabb::new( + Vec3::new(f(0), f(1), f(2)), + Vec3::new(f(3), f(4), f(5)), + ))?, + ShapeOp::OR, + )?; + } + Ok(shape) +} +pub fn main() -> Result<()> { + let args: Vec<_> = crate::args().collect(); + let mut out = BufWriter::new(File::create(&args[2])?); + for line in BufReader::new(File::open(&args[1])?).lines() { + let row: Value = serde_json::from_str(&line?)?; + let mut world = Fixture(HashMap::new()); + for c in row["cells"].as_array().unwrap() { + let p = std::array::from_fn(|i| c[i].as_i64().unwrap() as i32); + world.0.insert( + p, + Cell { + outline: shape(&c[3])?, + collision: shape(&c[4])?, + suffocating: c[5].as_bool().unwrap(), + }, + ); + } + let p = &row["pos"]; + let (x, y, z) = ( + p[0].as_f64().unwrap(), + p[1].as_f64().unwrap(), + p[2].as_f64().unwrap(), + ); + let bounds = Aabb::new( + Vec3::new(x - 0.5, y, z - 0.5), + Vec3::new(x + 0.5, y + 0.25, z + 0.5), + ); + writeln!( + out, + "{} {}", + cushion::can_place(&world, bounds), + cushion::survives(&world, bounds) + )?; + } + Ok(()) +} diff --git a/crates/horizon-client/examples/probe/main.rs b/crates/horizon-client/examples/probe/main.rs index e80088fd0..1e1bc04d9 100644 --- a/crates/horizon-client/examples/probe/main.rs +++ b/crates/horizon-client/examples/probe/main.rs @@ -28,6 +28,7 @@ mod cloud_fixture; mod command_parse_fixture; mod container_screen_probe; mod creative_search_fixture; +mod cushion_placement_probe; mod emitter_particles_probe; mod enchanting_fixture; mod entity_anim_fixture; @@ -107,6 +108,7 @@ probes! { block_transform_fixture, breaking_fixture, camera_probe, + cushion_placement_probe, cauldron_fixture, chat_screen_fixture, cloud_fixture, diff --git a/crates/horizon-client/src/cushion.rs b/crates/horizon-client/src/cushion.rs new file mode 100644 index 000000000..7610a4aab --- /dev/null +++ b/crates/horizon-client/src/cushion.rs @@ -0,0 +1,59 @@ +//! Cushion placement and support checks (`Cushion.canBePlacedAt`/`wouldSurviveAt`). +use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape}; + +pub trait World { + fn outline_bounds(&self, pos: [i32; 3]) -> Option; + fn collision_shapes(&self, bounds: Aabb) -> Vec; + fn suffocating(&self, pos: [i32; 3]) -> bool; +} + +pub fn blocks_in(a: Aabb) -> impl Iterator { + let (lo, hi) = (a.min_position(), a.max_position()); + let (x0, y0, z0) = ( + lo.x.floor() as i32, + lo.y.floor() as i32, + lo.z.floor() as i32, + ); + let (x1, y1, z1) = ( + hi.x.floor() as i32, + hi.y.floor() as i32, + hi.z.floor() as i32, + ); + (y0..=y1).flat_map(move |y| (z0..=z1).flat_map(move |z| (x0..=x1).map(move |x| [x, y, z]))) +} + +pub fn survives(world: &impl World, bounds: Aabb) -> bool { + let (lo, hi) = (bounds.min_position(), bounds.max_position()); + let anchor = Aabb::new( + Vec3::new(lo.x, lo.y - 0.015625, lo.z), + Vec3::new(hi.x.next_down(), lo.y, hi.z.next_down()), + ); + // The extra eighth block expands the scan, not the intersection test. + let supported = blocks_in(anchor.expand_towards(Vec3::new(0.0, -0.125, 0.0))).any(|p| { + world + .outline_bounds(p) + .is_some_and(|shape| shape.intersects(anchor)) + }); + supported && !blocks_in(bounds.next_deflated()).all(|p| world.suffocating(p)) +} + +pub fn can_place(world: &impl World, bounds: Aabb) -> bool { + if !survives(world, bounds) { + return false; + } + let (lo, hi) = (bounds.min_position(), bounds.max_position()); + let slab = Aabb::new(lo, Vec3::new(hi.x, lo.y + 0.015625, hi.z)).next_deflated(); + let Ok(mut remaining) = VoxelShape::from_box(slab) else { + return false; + }; + for shape in world.collision_shapes(slab) { + let Ok(next) = remaining.join(&shape, ShapeOp::ONLY_FIRST) else { + return false; + }; + remaining = next; + if remaining.is_empty() { + return false; + } + } + true +} diff --git a/crates/horizon-client/src/entities.rs b/crates/horizon-client/src/entities.rs index 6e8089e9d..69e053411 100644 --- a/crates/horizon-client/src/entities.rs +++ b/crates/horizon-client/src/entities.rs @@ -591,6 +591,57 @@ impl Painting { ) } } +/// The `DyeColor` ordinal of a colour name (`DyeColor.byName`), white when +/// the name is unknown. +pub fn dye_index(name: &str) -> u8 { + crate::mobs::render::DYE_NAMES + .iter() + .position(|n| *n == name) + .unwrap_or(0) as u8 +} +/// A cushion (`Cushion`, a `BlockAttachedEntity`): a 1 x 0.25 block pillow +/// that seats a player. It sits on the top face of a block, at the clicked +/// height, and is coloured by its `DyeColor`. +#[derive(Clone, Debug)] +pub struct Cushion { + pub id: u32, + /// `Entity.position`: the bottom centre of the cushion. + pub position: Vec3, + /// `BlockAttachedEntity.pos` (the saved `block_pos`). + pub pos: [i32; 3], + pub yaw: f32, + /// `DyeColor` ordinal: white 0, orange 1, ..., black 15. + pub color: u8, + /// BlockAttachedEntity.ticksSinceLastCheck. + pub check_interval: u32, +} +impl Cushion { + /// `Entity.makeBoundingBox`: x and z half a block out, y to y + 0.25. + pub fn bounds(&self) -> ([f64; 3], [f64; 3]) { + let p = self.position; + ( + [p.x - 0.5, p.y, p.z - 0.5], + [p.x + 0.5, p.y + 0.25, p.z + 0.5], + ) + } + /// The `DyeColor` name of the cushion. + pub fn color_name(&self) -> &'static str { + crate::mobs::render::DYE_NAMES[usize::from(self.color.min(15))] + } + /// `Cushion.getCushionItemStackWithData`: the matching coloured item. + pub fn item(&self) -> String { + format!("minecraft:{}_cushion", self.color_name()) + } + /// `Cushion.interact`'s seat: the vehicle's PASSENGER attachment + /// `(0, 0.25, 0)` less the player's `(0, 0.6, 0)`. + pub fn seat(&self) -> Vec3 { + Vec3::new(self.position.x, self.position.y - 0.35, self.position.z) + } + /// `Entity.getDismountLocationForPassenger`: on top of the cushion. + pub fn dismount(&self) -> Vec3 { + Vec3::new(self.position.x, self.position.y + 0.25, self.position.z) + } +} /// An item frame or glow item frame (`ItemFrame`). #[derive(Clone, Debug)] pub struct ItemFrame { diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index 00576ea02..7f3565067 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -6,7 +6,7 @@ //! survive a Horizon save. use crate::{ arrows::Pickup, - entities::{ArmorStand, ExperienceOrb, ItemEntity, ItemFrame, Painting}, + entities::{ArmorStand, Cushion, ExperienceOrb, ItemEntity, ItemFrame, Painting}, mobs::{Mob, MobType, Who}, projectiles::{Projectile, ProjectileKind}, save::{ @@ -1313,6 +1313,79 @@ pub fn painting_from_nbt(tag: &Tag) -> Option<([i32; 3], u8, String)> { let facing = (number(tag.get("facing")).unwrap_or(0) & 3) as u8; Some((pos, facing, text(tag.get("variant"))?)) } +/// Cushion.addAdditionalSaveData: the block it sits on and its colour. +pub fn cushion_to_nbt(c: &Cushion, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { + let base = Base { + id: "minecraft:cushion", + uuid, + position: c.position, + motion: Vec3::ZERO, + rotation: [c.yaw, 0.0], + on_ground: false, + fall_distance: 0.0, + }; + let mut tag = base.tag(raw); + put(&mut tag, "block_pos", Tag::IntArray(c.pos.to_vec())); + put(&mut tag, "color", string(c.color_name())); + tag +} +/// The position, block position, yaw and colour of a saved cushion. +pub fn cushion_from_nbt(tag: &Tag, id: u32) -> Option { + let position = position(tag)?; + let pos = match tag.get("block_pos") { + Some(Tag::IntArray(v)) if v.len() == 3 => [v[0], v[1], v[2]], + _ => [ + position.x.floor() as i32, + position.y.floor() as i32, + position.z.floor() as i32, + ], + }; + let yaw = match tag.get("Rotation") { + Some(Tag::List(l)) => l + .first() + .and_then(|v| crate::save::float(Some(v))) + .unwrap_or(0.0) as f32, + _ => 0.0, + }; + let color = + crate::entities::dye_index(&text(tag.get("color")).unwrap_or_else(|| "white".into())); + Some(Cushion { + id, + position, + pos, + yaw, + color, + check_interval: 0, + }) +} + +#[cfg(test)] +mod cushion_tests { + use super::*; + + #[test] + fn cushion_save_preserves_color_anchor_rotation_and_unknown_data() { + let cushion = Cushion { + id: 7, + position: Vec3::new(-1.5, 64.5, 3.5), + pos: [-2, 64, 3], + yaw: 270.0, + color: 14, + check_interval: 55, + }; + let raw = Tag::compound([("CustomName", string("Seat"))]); + let tag = cushion_to_nbt(&cushion, [1, 2, 3, 4], Some(&raw)); + assert_eq!(text(tag.get("CustomName")).as_deref(), Some("Seat")); + let loaded = cushion_from_nbt(&tag, 8).unwrap(); + assert_eq!(loaded.id, 8); + assert_eq!(loaded.position, cushion.position); + assert_eq!(loaded.pos, cushion.pos); + assert_eq!(loaded.yaw, cushion.yaw); + assert_eq!(loaded.color_name(), "red"); + assert_eq!(loaded.check_interval, 0); + assert_eq!(loaded.bounds(), cushion.bounds()); + } +} /// ItemFrame.addAdditionalSaveData. pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { let (lo, hi) = f.bounds(); diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 719846e70..69734f9e0 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -33,6 +33,7 @@ mod copper_golems; mod creaking; mod creatures; mod crossbow; +pub(crate) mod cushions; mod debug_keys; mod decorated_pot; mod dedicated; @@ -1431,6 +1432,8 @@ struct Game<'a> { map_frame_model: frames::FrameModel, glow_map_frame_model: frames::FrameModel, armor_stands: Vec, + /// `Cushion` decoration entities (game/cushions.rs). + cushions: Vec, /// Entities of types Horizon does not simulate. others: Vec, armor_stand_assets: armor_stands::ArmorStandAssets, @@ -1446,6 +1449,8 @@ struct Game<'a> { boat_models: boats::BoatModels, /// EndCrystalModel's baked layer. crystal_model: Option, + /// CushionModel's baked layer. + cushion_model: Option, carts: Vec, /// Songs playing from jukeboxes (tracked sound handles). jukebox_sounds: HashMap<(i32, i32, i32), u64>, @@ -3483,6 +3488,7 @@ impl Game<'_> { fn interact_entity(&mut self) -> Result { Ok(self.interact_frame() || self.interact_armor_stand() + || self.interact_cushion() || self.interact_boat() || self.interact_cart() || self.interact_mob()? @@ -3669,6 +3675,7 @@ impl Game<'_> { || self.place_painting(hit, &stack) || self.place_item_frame(hit, &stack) || self.place_armor_stand(hit, &stack) + || self.place_cushion(hit, &stack) || self.place_end_crystal(hit, &stack)) { return Ok(true); @@ -4554,6 +4561,7 @@ impl Game<'_> { horizon_client::spans::time("tick_paintings", || self.tick_paintings()); horizon_client::spans::time("tick_item_frames", || self.tick_item_frames()); horizon_client::spans::time("tick_armor_stands", || self.tick_armor_stands()); + horizon_client::spans::time("tick_cushions", || self.tick_cushions()); horizon_client::spans::time("tick_boats", || self.tick_boats()); horizon_client::spans::time("tick_carts", || self.tick_carts()); horizon_client::spans::time("tick_thunder", || self.tick_thunder()); @@ -7260,6 +7268,7 @@ impl Game<'_> { self.frame_geometry(camera, &mut out); self.mark_entity(out.len(), "minecraft:armor_stand"); self.armor_stand_geometry(camera, alpha, &mut out); + self.cushion_geometry(camera, &mut out); self.mark_entity(out.len(), "minecraft:oak_boat"); self.boat_geometry(camera, alpha, &mut out); self.mark_entity(out.len(), "minecraft:minecart"); @@ -7556,6 +7565,7 @@ impl Game<'_> { .map_or(0.0, |i| i.attack_damage); if self.attack_frame() || self.attack_armor_stand() + || self.attack_cushion() || self.attack_boat(hand_damage) || self.attack_cart(hand_damage) || self.attack_dragon() @@ -8300,6 +8310,7 @@ impl Game<'_> { self.boats.clear(); self.carts.clear(); self.armor_stands.clear(); + self.cushions.clear(); self.item_frames.clear(); self.paintings.clear(); self.others.clear(); @@ -12304,6 +12315,7 @@ impl<'a> Game<'a> { map_frame_model: prepared.map_frame_model, glow_map_frame_model: prepared.glow_map_frame_model, armor_stands: Vec::new(), + cushions: Vec::new(), others: Vec::new(), armor_stand_assets: prepared.armor_stand_assets, waypoint_styles: prepared.waypoint_styles, @@ -12313,6 +12325,7 @@ impl<'a> Game<'a> { boats: Vec::new(), boat_models: prepared.boat_models, crystal_model: prepared.crystal_model, + cushion_model: prepared.cushion_model, carts: Vec::new(), jukebox_sounds: HashMap::new(), bolts: Vec::new(), @@ -12451,6 +12464,7 @@ struct GameCpuResources { boat_models: boats::BoatModels, cart_models: minecarts::CartModels, crystal_model: Option, + cushion_model: Option, dragon_model: Option, arrow_model: Option, trident_model: Option, @@ -12471,6 +12485,7 @@ impl GameCpuResources { boat_models: boats::BoatModels::load(document.as_ref()), cart_models: minecarts::CartModels::load(document.as_ref()), crystal_model: model("minecraft:end_crystal#main"), + cushion_model: model("minecraft:cushion#main"), dragon_model: model("minecraft:ender_dragon#main"), arrow_model: model("minecraft:arrow#main"), trident_model: model("minecraft:trident#main"), @@ -13826,7 +13841,7 @@ mod startup_cpu_tests { r#"{ "minecraft:end_crystal#main": {}, "minecraft:ender_dragon#main": {}, "minecraft:arrow#main": {}, "minecraft:trident#main": {}, - "minecraft:armor_stand#main": {} + "minecraft:armor_stand#main": {}, "minecraft:cushion#main": {} }"#, ) .unwrap(); @@ -13846,6 +13861,7 @@ mod startup_cpu_tests { .unwrap(); std::fs::remove_dir_all(&root).unwrap(); assert!(prepared.crystal_model.is_some()); + assert!(prepared.cushion_model.is_some()); assert!(prepared.dragon_model.is_some()); assert!(prepared.arrow_model.is_some()); assert!(prepared.trident_model.is_some()); diff --git a/crates/horizon-client/src/game/boats.rs b/crates/horizon-client/src/game/boats.rs index df7158d9a..40dd1b7eb 100644 --- a/crates/horizon-client/src/game/boats.rs +++ b/crates/horizon-client/src/game/boats.rs @@ -587,6 +587,9 @@ impl Game<'_> { } /// The player's tick while riding: steer, tick the boat, sit in it. pub(super) fn tick_riding(&mut self, keys: Keys) -> horizon_client::Result<()> { + if self.tick_cushion_riding(keys) { + return Ok(()); + } let Some(i) = self.boats.iter().position(|b| Some(b.id) == self.riding) else { if self.tick_cart_riding(keys) || self.tick_mount_riding(keys) { return Ok(()); diff --git a/crates/horizon-client/src/game/cushions.rs b/crates/horizon-client/src/game/cushions.rs new file mode 100644 index 000000000..0847321d6 --- /dev/null +++ b/crates/horizon-client/src/game/cushions.rs @@ -0,0 +1,494 @@ +//! Cushions: `CushionItem.useOn` placement on the top face of a block, +//! `Cushion.canBePlacedAt`/`survives`, `Cushion.interact` (sit), +//! `BlockAttachedEntity.hurtServer` (break, dropping the matching colour), +//! `BlockAttachedEntity.tick` and `CushionRenderer`. +//! +//! A cushion is a `BlockAttachedEntity`, not a block: it has no block state, +//! only an entity whose `Pos` is the click point's height. Placement uses +//! outline bounds, collision shapes and suffocation predicates. +use super::{Game, Hit, view_vector}; +use horizon_client::entities::{Cushion, dye_index}; +use horizon_client::items::ItemStack; +use horizon_client::mobs::anim::Poses; +use horizon_core::geometry::{Aabb, Vec3}; +use winit::keyboard::KeyCode; + +/// `Direction.fromYRot(yaw).toYRot()`: the nearest 90 degree step. +pub(crate) fn snap_yaw(yaw: f32) -> f32 { + (((f64::from(yaw) / 90.0 + 0.5).floor() as i64) & 3) as f32 * 90.0 +} + +fn aabb(bounds: ([f64; 3], [f64; 3])) -> Aabb { + Aabb::new( + Vec3::new(bounds.0[0], bounds.0[1], bounds.0[2]), + Vec3::new(bounds.1[0], bounds.1[1], bounds.1[2]), + ) +} + +fn overlaps(a: Aabb, b: Aabb) -> bool { + a.intersects(b) +} +use horizon_client::cushion::blocks_in; + +struct PlacementWorld<'a, 'r>(&'a Game<'r>); +impl horizon_client::cushion::World for PlacementWorld<'_, '_> { + fn outline_bounds(&self, p: [i32; 3]) -> Option { + let boxes = self.0.outline_boxes(p); + let mut shape = *boxes.first()?; + for b in &boxes[1..] { + for k in 0..3 { + shape[k] = shape[k].min(b[k]); + shape[k + 3] = shape[k + 3].max(b[k + 3]); + } + } + Some(Aabb::new( + Vec3::new(shape[0], shape[1], shape[2]), + Vec3::new(shape[3], shape[4], shape[5]), + )) + } + + fn collision_shapes(&self, bounds: Aabb) -> Vec { + let binding = self.0.res.collision(); + let mut world = horizon_client::collisions::SceneCollisions { + world: &self.0.world, + binding, + subject: None, + }; + // Include neighbour blocks whose collision shape can reach outside their cell. + blocks_in(bounds.inflate(Vec3::new(1.0, 1.0, 1.0))) + .filter_map(|p| { + let state = self.0.world.block(p[0], p[1], p[2]); + binding + .collision_shape(&mut world, state, p) + .ok() + .map(|s| s.moved(Vec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2])))) + }) + .collect() + } + fn suffocating(&self, p: [i32; 3]) -> bool { + let state = self.0.world.block(p[0], p[1], p[2]); + let facts = self.0.res.facts().get(state); + let name = facts.name.as_ref(); + // Blocks' explicit predicates, then the default causes_suffocation/full-shape rule. + match name { + "minecraft:dirt_path" + | "minecraft:farmland" + | "minecraft:mud" + | "minecraft:soul_sand" => true, + "minecraft:piston" | "minecraft:sticky_piston" => self + .0 + .pack + .assets + .registry + .state(state) + .is_some_and(|s| s.properties.get("extended").is_some_and(|p| p == "false")), + "minecraft:mangrove_roots" | "minecraft:moving_piston" => false, + _ if name.ends_with("_leaves") + || name.ends_with("glass") + || name.ends_with("copper_grate") => + { + false + } + // ShulkerBoxBlock: suffocating while closed; a box seen open + // here is rare enough to count as closed. + _ if name.ends_with("shulker_box") => true, + _ => { + facts.full_collision + && self + .0 + .res + .items() + .block_in_tag("minecraft:causes_suffocation", name) + } + } + } +} + +impl Game<'_> { + /// The cushion under the crosshair, its distance and the hit point. + pub(super) fn cushion_hit(&self) -> Option<(usize, f64, [f64; 3])> { + let eye = self.eye_position()?; + let dir = view_vector(self.yaw, self.pitch); + let reach: f64 = if self.creative { 5.0 } else { 3.0 }; + let mut best: Option<(usize, f64, [f64; 3])> = None; + for (i, c) in self.cushions.iter().enumerate() { + let Some(d) = super::ray_box(eye, dir, reach, aabb(c.bounds())) else { + continue; + }; + if best.is_none_or(|b| d < b.1) { + best = Some((i, d, std::array::from_fn(|k| eye[k] + dir[k] * d))); + } + } + best + } + + pub(super) fn cushion_survives(&self, c: &Cushion) -> bool { + horizon_client::cushion::survives(&PlacementWorld(self), aabb(c.bounds())) + } + + /// `Cushion.destroyIfInFire`: a fire-tagged block inside the deflated box. + fn cushion_in_fire(&self, c: &Cushion) -> bool { + let bounds = aabb(c.bounds()).next_deflated(); + blocks_in(bounds).any(|p| { + let facts = self.res.facts().get(self.world.block(p[0], p[1], p[2])); + self.res.items().block_in_tag("minecraft:fire", &facts.name) + }) + } + + /// `CushionItem.useOn`: on the top face only, spawning at the click's + /// height above the clicked block (or the replaceable block itself). + pub(super) fn place_cushion(&mut self, hit: &Hit, stack: &ItemStack) -> bool { + let Some(info) = self.res.items().get(&stack.item).cloned() else { + return false; + }; + let Some(default_color) = info.cushion_color.as_deref() else { + return false; + }; + let color_override = stack + .data() + .and_then(|d| d.other("minecraft:cushion/color")) + .and_then(|tag| match tag { + horizon_world::nbt::Tag::String(s) => Some(s.to_string_lossy()), + _ => None, + }); + let color = dye_index(color_override.as_deref().unwrap_or(default_color)); + // CushionItem.recalculateContextForSpecialCollisionShapes: open containers + // (cauldrons, hoppers and composters) use the collision ray's actual height. + let mut adjusted = Hit { + pos: hit.pos, + face: hit.face, + point: hit.point, + }; + let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]); + if self.res.items().block_in_tag( + "minecraft:cushion_uses_collision_shape", + &self.res.facts().get(state).name, + ) && let Some(eye) = self.eye_position() + { + let start = Vec3::new(eye[0], eye[1], eye[2]); + let point = Vec3::new(hit.point[0], hit.point[1], hit.point[2]); + let end = point.add_vector(point.subtract(start).normalize().scale(0.001)); + let binding = self.res.collision(); + let mut world = horizon_client::collisions::SceneCollisions { + world: &self.world, + binding, + subject: None, + }; + if let Ok(shape) = binding.collision_shape(&mut world, state, hit.pos) + && let Some(result) = shape.clip( + start, + end, + horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]), + ) + { + adjusted.face = result.direction.normal(); + adjusted.point = [result.location.x, result.location.y, result.location.z]; + } + } + let hit = &adjusted; + if hit.face != [0, 1, 0] { + return false; + } + // BlockPlaceContext: a replaceable clicked block is replaced. + let clicked = self + .res + .facts() + .get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2])); + let pos = if clicked.replaceable { + hit.pos + } else { + std::array::from_fn(|i| hit.pos[i] + hit.face[i]) + }; + // `Vec3.atCenterOfWithY(clickedPos, clickLocation.y)`. + let y = hit.point[1]; + let position = Vec3::new(f64::from(pos[0]) + 0.5, y, f64::from(pos[2]) + 0.5); + let bounds = aabb(( + [position.x - 0.5, position.y, position.z - 0.5], + [position.x + 0.5, position.y + 0.25, position.z + 0.5], + )); + if !horizon_client::cushion::can_place(&PlacementWorld(self), bounds) { + return false; + } + // `getEntitiesOfClass(Cushion.class, spawnAABB).isEmpty()`. + if self + .cushions + .iter() + .any(|c| overlaps(aabb(c.bounds()), bounds)) + { + return false; + } + self.next_entity += 1; + let id = self.next_entity; + // `BlockAttachedEntity.setPos`: `BlockPos.containing(position)`. + let attached = [ + position.x.floor() as i32, + position.y.floor() as i32, + position.z.floor() as i32, + ]; + self.cushions.push(Cushion { + id, + position, + pos: attached, + yaw: snap_yaw(self.yaw), + color, + check_interval: 0, + }); + self.world + .modified + .insert((attached[0] >> 4, attached[2] >> 4)); + // `destroyIfInFire`: placed into fire, it breaks at once. + let placed = self.cushions.len() - 1; + if self.cushion_in_fire(&self.cushions[placed]) { + self.break_cushion(placed, false); + } + self.audio.play( + "minecraft:entity.cushion.place", + Some([position.x, position.y, position.z]), + 0.75, + 0.8, + ); + let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None); + self.post_game_event( + "minecraft:entity_place", + [position.x, position.y, position.z], + ctx, + ); + if !self.creative { + self.consume_held(); + } + true + } + + /// `Cushion.interact`: sit down unless sneaking or already occupied. + pub(super) fn interact_cushion(&mut self) -> bool { + let Some((i, d, _)) = self.cushion_hit() else { + return false; + }; + if !self.before_block(d) { + return false; + } + // isSecondaryUseActive. + if self.keys.contains(&KeyCode::ShiftLeft) { + return false; + } + let id = self.cushions[i].id; + let occupied = self.riding == Some(id) + || self + .lan + .as_ref() + .is_some_and(|h| h.hub.guests.iter().any(|g| g.vehicle == Some(id))); + // `Cushion.interact`: an occupied cushion (`isVehicle`) passes. + if occupied { + return false; + } + // `Player.startRiding` gives up the vehicle it already has; the + // client never sends that click, so pass instead. + if self.riding.is_some() { + return false; + } + self.riding = Some(id); + let p = self.cushions[i].position; + self.audio.play( + "minecraft:entity.cushion.sit", + Some([p.x, p.y, p.z]), + 1.0, + 1.0, + ); + self.swing_arm(); + true + } + + /// `Cushion.hurtServer` from a player: killed, then the item dropped. + pub(super) fn attack_cushion(&mut self) -> bool { + let Some((i, d, _)) = self.cushion_hit() else { + return false; + }; + if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) { + return false; + } + // `skipAttackInteraction` when `mayInteract` is false. + if !self.may_build() { + return true; + } + self.break_cushion(i, true); + true + } + + /// `Cushion.dropItem`: the break sound and particles, then the coloured + /// item unless drops are off or the breaker has infinite materials. + pub(super) fn break_cushion(&mut self, index: usize, by_player: bool) { + let c = self.cushions.remove(index); + let p = c.position; + self.world.modified.insert((c.pos[0] >> 4, c.pos[2] >> 4)); + if self.riding == Some(c.id) { + self.riding = None; + } + self.audio.play( + "minecraft:entity.cushion.break", + Some([p.x, p.y, p.z]), + 1.0, + 1.0, + ); + self.cushion_break_particles(&c); + if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) { + return; + } + let stack = ItemStack { + item: std::sync::Arc::from(c.item()), + count: 1, + damage: 0, + extra: None, + }; + self.spawn_item(stack, [p.x, p.y, p.z], [0.0; 3], 10); + } + + /// `Cushion.showBreakingParticles`: ten of the matching wool block. + fn cushion_break_particles(&mut self, c: &Cushion) { + let name = format!("minecraft:{}_wool", c.color_name()); + let Some(state) = self.pack.assets.registry.default_state(&name) else { + return; + }; + let at = [ + c.position.x.floor() as i32, + c.position.y.floor() as i32, + c.position.z.floor() as i32, + ]; + let Some((sprite, tint)) = self.particle_sprite(state, at) else { + return; + }; + let options = horizon_client::particles::ParticleOptions::block( + horizon_client::particles::ParticleType::Block, + sprite, + tint, + ); + let p = c.position; + self.particles.send_particles( + &options, + [p.x, p.y + 0.666_666_7, p.z], + [0.25, 0.0625, 0.25], + 0.05, + 10, + ); + } + + /// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100. + pub(super) fn tick_cushions(&mut self) { + let ticking = self.entity_ticking_area(); + let mut i = 0; + while i < self.cushions.len() { + let p = self.cushions[i].position; + if !ticking(p.x.floor() as i32, p.z.floor() as i32) { + i += 1; + continue; + } + let previous = self.cushions[i].check_interval; + self.cushions[i].check_interval += 1; + if previous < 100 { + i += 1; + continue; + } + self.cushions[i].check_interval = 0; + let c = self.cushions[i].clone(); + if self.cushion_in_fire(&c) || !self.cushion_survives(&c) { + self.break_cushion(i, false); + continue; + } + i += 1; + } + } + + /// `Player.rideTick` on a cushion: hold the seat, and get up on sneak. + pub(super) fn tick_cushion_riding(&mut self, keys: super::Keys) -> bool { + let Some(c) = self + .cushions + .iter() + .find(|c| Some(c.id) == self.riding) + .cloned() + else { + return false; + }; + if keys.shift { + let at = c.dismount(); + self.riding = None; + if let Some(p) = self.player.as_mut() { + self.previous_position = p.position; + p.position = at; + p.velocity = Vec3::ZERO; + p.fall_distance = 0.0; + } + // `Cushion.removePassenger`: the get-up sound for a rider that + // left rather than one whose cushion was killed. + self.audio.play( + "minecraft:entity.cushion.get_up", + Some([c.position.x, c.position.y, c.position.z]), + 1.0, + 1.0, + ); + return true; + } + let seat = c.seat(); + if let Some(p) = self.player.as_mut() { + self.previous_position = p.position; + p.position = seat; + p.velocity = Vec3::ZERO; + p.fall_distance = 0.0; + } + true + } + + /// `CushionRenderer`: the coloured model, rotated to the nearest + /// direction, with its bottom on the cushion's position. + pub(super) fn cushion_geometry( + &self, + camera: glam::DVec3, + out: &mut Vec, + ) { + let Some(model) = self.cushion_model.as_ref() else { + return; + }; + let sprites = &self.gui.atlas.sprites; + for c in &self.cushions { + let entity_start = out.len(); + let a = aabb(c.bounds()); + let (lo, hi) = (a.min_position(), a.max_position()); + let (lo, hi) = ( + glam::DVec3::new(lo.x, lo.y, lo.z), + glam::DVec3::new(hi.x, hi.y, hi.z), + ); + let range = + super::entity_culling::default_range(hi - lo, self.settings.entity_distance); + if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) { + continue; + } + let Some(uv) = sprites + .get(&format!("entity/cushion/{}_cushion", c.color_name())) + .map(|r| [r.u0, r.v0, r.u1, r.v1]) + else { + continue; + }; + let p = glam::DVec3::new(c.position.x, c.position.y, c.position.z) - camera; + let base = glam::Mat4::from_translation(p.as_vec3()) + * glam::Mat4::from_rotation_y((180.0 - snap_yaw(c.yaw)).to_radians()) + * glam::Mat4::from_rotation_x(std::f32::consts::PI) + * glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0)); + // `EntityRenderer.getPackedLightCoords` probes at the eye height. + let l = self.world.light( + c.position.x.floor() as i32, + (c.position.y + 0.2125).floor() as i32, + c.position.z.floor() as i32, + ); + let packed = (u32::from(l >> 4) * 16) | ((u32::from(l & 15) * 16) << 8); + horizon_client::mobs::render::draw( + model, + base, + &Poses::new(model), + uv, + 0xFFFF_FFFF, + packed, + 0, + out, + ); + self.cull_entity_vertices(out, entity_start); + } + } +} diff --git a/crates/horizon-client/src/game/hand.rs b/crates/horizon-client/src/game/hand.rs index a7218ff3c..d91d3b979 100644 --- a/crates/horizon-client/src/game/hand.rs +++ b/crates/horizon-client/src/game/hand.rs @@ -151,6 +151,7 @@ impl Game<'_> { before(self.painting_hit().map(|h| h.1)) || before(self.frame_hit().map(|h| h.1)) || before(self.armor_stand_hit().map(|h| h.1)) + || before(self.cushion_hit().map(|h| h.1)) || before(self.boat_hit().map(|h| h.1)) || before(self.cart_hit().map(|h| h.1)) || before(self.dragon_hit().map(|h| h.1)) diff --git a/crates/horizon-client/src/game/lan_entities.rs b/crates/horizon-client/src/game/lan_entities.rs index cd4ba2fb8..776466c25 100644 --- a/crates/horizon-client/src/game/lan_entities.rs +++ b/crates/horizon-client/src/game/lan_entities.rs @@ -245,6 +245,16 @@ impl Game<'_> { } out.push(e); } + for c in &self.cushions { + let p = c.position; + let mut e = state(c.id, "minecraft:cushion", [p.x, p.y, p.z], c.yaw, false); + // Cushion.DATA_COLOR, set unless it is the default white. + if c.color != 0 { + e.meta + .push((8, serializer::DYE_COLOR, Value::Id(i32::from(c.color)))); + } + out.push(e); + } self.lan_projectile_states(host_entity, hub, out); for o in &self.others { let p = o.position; diff --git a/crates/horizon-client/src/game/lan_vehicles.rs b/crates/horizon-client/src/game/lan_vehicles.rs index f16a1e37d..00795aa62 100644 --- a/crates/horizon-client/src/game/lan_vehicles.rs +++ b/crates/horizon-client/src/game/lan_vehicles.rs @@ -56,6 +56,13 @@ impl Game<'_> { let (lo, hi) = corners(a.bounds()); return Some((lo, hi, at(a.position))); } + if let Some(c) = self.cushions.iter().find(|c| c.id == id) { + let (lo, hi) = corners(Aabb::new( + Vec3::new(c.position.x - 0.5, c.position.y, c.position.z - 0.5), + Vec3::new(c.position.x + 0.5, c.position.y + 0.25, c.position.z + 0.5), + )); + return Some((lo, hi, at(c.position))); + } if let Some(f) = self.item_frames.iter().find(|f| f.id == id) { let (lo, hi) = f.bounds(); return Some((lo, hi, centre(f.pos))); @@ -98,9 +105,10 @@ impl Game<'_> { let boat = self.boats.iter().position(|b| b.id == vehicle); let cart = self.carts.iter().any(|c| c.id == vehicle); let mount = self.mobs.iter().any(|m| m.id == vehicle); + let cushion = self.cushions.iter().any(|c| c.id == vehicle); // The vehicle is gone (broken, in another level) or the rider died: // its client drops it with the entity or the death screen. - if dead || (boat.is_none() && !cart && !mount) { + if dead || (boat.is_none() && !cart && !mount && !cushion) { let g = &mut host.hub.guests[gi]; (g.vehicle, g.vehicle_good) = (None, None); return; @@ -115,6 +123,17 @@ impl Game<'_> { // Getting out goes through leave_guest, which tells the client and // teleports it (ServerPlayer.dismountTo). let seat = self.as_guest(host, conn, |game| { + // A cushion does not move: the rider sits on it until it sneaks + // (`Cushion.removePassenger` plays the get-up sound). + if game.cushions.iter().any(|c| Some(c.id) == game.riding) { + if keys.shift { + game.tick_cushion_riding(keys); + return None; + } + let c = game.cushions.iter().find(|c| Some(c.id) == game.riding)?; + let p = c.seat(); + return Some([p.x, p.y, p.z]); + } if let Some(i) = game.boats.iter().position(|b| Some(b.id) == game.riding) { if keys.shift { game.dismount(i); @@ -168,6 +187,12 @@ impl Game<'_> { .iter() .find(|c| c.id == vehicle) .map(|c| (c.position, c.position.add_vector(Vec3::new(0.0, SEAT, 0.0)))) + }) + .or_else(|| { + self.cushions + .iter() + .find(|c| c.id == vehicle) + .map(|c| (c.position, c.seat())) }); if let Some((p, seat)) = at { let g = &mut host.hub.guests[gi]; diff --git a/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index bfc739cee..9137a26d1 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -50,6 +50,7 @@ pub(super) struct Parked { paintings: Vec, item_frames: Vec, armor_stands: Vec, + cushions: Vec, others: Vec, boats: Vec, carts: Vec, @@ -136,6 +137,7 @@ impl Game<'_> { paintings: Vec::new(), item_frames: Vec::new(), armor_stands: Vec::new(), + cushions: Vec::new(), others: Vec::new(), boats: Vec::new(), carts: Vec::new(), @@ -187,6 +189,7 @@ impl Game<'_> { swap(&mut self.paintings, &mut p.paintings); swap(&mut self.item_frames, &mut p.item_frames); swap(&mut self.armor_stands, &mut p.armor_stands); + swap(&mut self.cushions, &mut p.cushions); swap(&mut self.others, &mut p.others); swap(&mut self.boats, &mut p.boats); swap(&mut self.carts, &mut p.carts); diff --git a/crates/horizon-client/src/game/persist.rs b/crates/horizon-client/src/game/persist.rs index 45ef8801f..0c053d34e 100644 --- a/crates/horizon-client/src/game/persist.rs +++ b/crates/horizon-client/src/game/persist.rs @@ -314,6 +314,13 @@ impl Game<'_> { self.item_frames.push(f); } } + "minecraft:cushion" => { + self.next_entity += 1; + if let Some(c) = entity_nbt::cushion_from_nbt(&tag, self.next_entity) { + self.remember(c.id, uuid, tag); + self.cushions.push(c); + } + } "minecraft:painting" => { if let Some((pos, facing, variant)) = entity_nbt::painting_from_nbt(&tag) && let Some(v) = self.painting_variants.get(&variant).cloned() @@ -776,6 +783,18 @@ impl Game<'_> { out.entry(chunk_of(at)).or_default().push(tag); } } + for c in &self.cushions { + if keys.contains(&chunk_of(c.position)) { + let uuid = self + .persist + .uuids + .get(&c.id) + .copied() + .unwrap_or_else(random_uuid); + let tag = entity_nbt::cushion_to_nbt(c, uuid, self.persist.raw.get(&c.id)); + out.entry(chunk_of(c.position)).or_default().push(tag); + } + } for p in &self.paintings { let at = painting_center(p); if keys.contains(&chunk_of(at)) { @@ -868,6 +887,13 @@ impl Game<'_> { } keep }); + self.cushions.retain(|c| { + let keep = !gone(c.position); + if !keep { + removed.push(c.id); + } + keep + }); // Riders leave with their root vehicle, a boat's with the boat. let leaving: HashSet = (0..self.mobs.len()) .filter(|&i| { @@ -934,6 +960,7 @@ impl Game<'_> { .chain(self.boats.iter().map(|e| e.id)) .chain(self.carts.iter().map(|e| e.id)) .chain(self.armor_stands.iter().map(|e| e.id)) + .chain(self.cushions.iter().map(|e| e.id)) .chain(self.item_frames.iter().map(|e| e.id)) .chain(self.paintings.iter().map(|e| e.id)) .chain(self.others.iter().map(|e| e.id)) diff --git a/crates/horizon-client/src/game/remote_objects.rs b/crates/horizon-client/src/game/remote_objects.rs index 251d277f3..05b1a2b9e 100644 --- a/crates/horizon-client/src/game/remote_objects.rs +++ b/crates/horizon-client/src/game/remote_objects.rs @@ -3,7 +3,7 @@ //! `handleSetEquipment`): the server simulates them, the client places them //! where it says and interpolates their moves like a mob's. use super::Game; -use horizon_client::entities::{ArmorStand, ItemFrame, Painting}; +use horizon_client::entities::{ArmorStand, Cushion, ItemFrame, Painting}; use horizon_core::geometry::Vec3; use horizon_protocol::entity_data::Value; use std::sync::Arc; @@ -57,6 +57,16 @@ impl Game<'_> { invisible: false, check_interval: 0, }); + } else if kind == "minecraft:cushion" { + // BlockAttachedEntity.pos: `BlockPos.containing` of the spawn. + self.cushions.push(Cushion { + id, + position: at, + pos: block, + yaw: super::cushions::snap_yaw(yaw), + color: 0, + check_interval: 0, + }); } else if kind == "minecraft:lightning_bolt" { self.spawn_remote_bolt(id, position); } else if kind == "minecraft:painting" { @@ -152,6 +162,11 @@ impl Game<'_> { } _ => {} } + } else if let Some(c) = self.cushions.iter_mut().find(|c| c.id == id) { + // Cushion.DATA_COLOR (DyeColor, a byte-backed id). + if let (8, Value::Id(color)) = (index, value) { + c.color = (*color).clamp(0, 15) as u8; + } } else if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) { // VehicleEntity's hurt, direction and damage; the paddles. match (index, value) { @@ -267,6 +282,12 @@ impl Game<'_> { out.push(json!({"id": a.id, "kind": "minecraft:armor_stand", "position": at(a.position), "yaw": a.yaw, "small": a.small, "arms": a.show_arms, "no_base_plate": a.no_base_plate, "equipment": items})); } + for c in &self.cushions { + out.push( + json!({"id": c.id, "kind": "minecraft:cushion", "position": at(c.position), + "yaw": c.yaw, "color": c.color_name()}), + ); + } for f in &self.item_frames { out.push( json!({"id": f.id, "kind": f.kind(), "position": f.pos, "facing": f.facing, @@ -286,6 +307,7 @@ impl Game<'_> { self.carts.clear(); self.boats.clear(); self.armor_stands.clear(); + self.cushions.clear(); self.item_frames.clear(); self.paintings.clear(); } @@ -295,6 +317,7 @@ impl Game<'_> { self.carts.retain(|c| c.id != id); self.boats.retain(|b| b.id != id); self.armor_stands.retain(|a| a.id != id); + self.cushions.retain(|c| c.id != id); self.item_frames.retain(|f| f.id != id); self.paintings.retain(|p| p.id != id); self.bolts.retain(|b| b.id != id); diff --git a/crates/horizon-client/src/game/remote_vehicles.rs b/crates/horizon-client/src/game/remote_vehicles.rs index 620aff97c..c991ca89a 100644 --- a/crates/horizon-client/src/game/remote_vehicles.rs +++ b/crates/horizon-client/src/game/remote_vehicles.rs @@ -56,6 +56,11 @@ impl Game<'_> { let p = a.position; hits.push((a.id as i32, d, [p.x, p.y, p.z])); } + if let Some((i, d, _)) = self.cushion_hit() { + let c = &self.cushions[i]; + let p = c.position; + hits.push((c.id as i32, d, [p.x, p.y, p.z])); + } if let Some((i, d)) = self.boat_hit() { let p = self.boats[i].position; hits.push((self.boats[i].id as i32, d, [p.x, p.y, p.z])); @@ -138,7 +143,10 @@ impl Game<'_> { self.position_rider(); return true; } - let seat = if let Some(i) = self.boats.iter().position(|b| b.id == id) { + let seat = if let Some(c) = self.cushions.iter().find(|c| c.id == id) { + // The seat does not move; the rider sits on the cushion. + Some(c.seat()) + } else if let Some(i) = self.boats.iter().position(|b| b.id == id) { // AbstractBoat.tick on the controlling client: steer and move. let mut b = self.boats[i].clone(); b.input = [keys.left, keys.right, keys.forward, keys.backward]; diff --git a/crates/horizon-client/src/items.rs b/crates/horizon-client/src/items.rs index db39f67b8..e34471966 100644 --- a/crates/horizon-client/src/items.rs +++ b/crates/horizon-client/src/items.rs @@ -120,6 +120,8 @@ pub struct ItemInfo { pub crafting_remainder: Option, /// `dye`: the dye colour name (dyes, and anything else with the component). pub dye: Option, + /// `cushion/color`: the DyeColor name of a cushion item. + pub cushion_color: Option, /// `block_transformer`: the registry entry an axe, hoe or shovel uses on blocks. pub block_transformer: Option, /// Has a default `bundle_contents` (the bundles, `BundleItem`). @@ -603,6 +605,7 @@ impl Items { .map(str::to_owned), jukebox_song: c["minecraft:jukebox_playable"].as_str().map(str::to_owned), dye: c["minecraft:dye"].as_str().map(str::to_owned), + cushion_color: c["minecraft:cushion/color"].as_str().map(str::to_owned), block_transformer: c["minecraft:block_transformer"].as_str().map(str::to_owned), bundle: c.get("minecraft:bundle_contents").is_some(), crafting_remainder: CRAFT_REMAINDERS diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs index 6bf40d1f0..ebfbdea58 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -50,6 +50,7 @@ pub mod cooldowns; pub mod crafting; pub mod crash; pub mod creative; +pub mod cushion; pub mod data_cmd; pub mod datapack; pub mod debug_chart; diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCushionPlacementOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCushionPlacementOracle.java new file mode 100644 index 000000000..212a415c1 --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCushionPlacementOracle.java @@ -0,0 +1,91 @@ +package dev.horizonlab.parity; + +import com.google.gson.*; +import java.io.PrintWriter; +import java.nio.file.*; +import java.util.*; +import java.util.function.*; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.*; +import net.minecraft.util.Continuation; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.decoration.Cushion; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.*; +import net.minecraft.world.level.blockscan.*; +import net.minecraft.world.phys.*; +import net.minecraft.world.phys.shapes.*; + +public final class VanillaCushionPlacementOracle { + record Cell(VoxelShape outline, VoxelShape collision, boolean suffocating) {} + static final Cell AIR = new Cell(Shapes.empty(), Shapes.empty(), false); + static final class FixtureBlock extends Block { + FixtureBlock() { super(BlockBehaviour.Properties.of().dynamicShape() + .setId(ResourceKey.create(Registries.BLOCK, Identifier.parse("horizon:cushion_fixture"))) + .isSuffocating((s, w, p) -> ((World)w).cell(p).suffocating())); } + @Override protected VoxelShape getShape(BlockState s, BlockGetter w, BlockPos p, CollisionContext c) { + return w instanceof World f ? f.cell(p).outline() : Shapes.empty(); + } + } + static BlockState state; + static final class World extends ClientLevel { + Map cells; + World() { super(null, null, null, null, 0, 0, null, false, 0, 0); } + Cell cell(BlockPos p) { return cells.getOrDefault(p, AIR); } + @Override public BlockState getBlockState(BlockPos p) { return state; } + @Override public Iterable getBlockCollisions(Entity e, AABB b) { + var result = new ArrayList(); + cells.forEach((p, c) -> result.add(c.collision().move(p))); + return result; + } + @Override public BlockMatcher findBlocksIn(AABB bounds) { + return new BlockMatcher(this) { + @Override public boolean atLeastMatched(int n) { throw new AssertionError(); } + @Override protected boolean anyMatched(Predicate p) { throw new AssertionError(); } + @Override public void forEach(BiConsumer c) { throw new AssertionError(); } + @Override public boolean forEachUntil(BlockStateConsumer consumer) { + for (var p : BlockPos.betweenClosed(bounds)) { + if (consumer.apply(p, state) == Continuation.ABORT) return true; + } + return false; + } + }; + } + } + static VoxelShape shape(JsonElement v) { + var result = Shapes.empty(); + for (var e : v.getAsJsonArray()) { + var b = e.getAsJsonArray(); + result = Shapes.or(result, Shapes.box(b.get(0).getAsDouble(), b.get(1).getAsDouble(), b.get(2).getAsDouble(), b.get(3).getAsDouble(), b.get(4).getAsDouble(), b.get(5).getAsDouble())); + } + return result; + } + public static void main(String[] args) throws Exception { + net.minecraft.SharedConstants.tryDetectVersion(); net.minecraft.server.Bootstrap.bootStrap(); + // Supply one unregistered fixture block without changing the checks under test. + var registry = net.minecraft.core.registries.BuiltInRegistries.BLOCK; + var holders = net.minecraft.core.MappedRegistry.class.getDeclaredField("unregisteredIntrusiveHolders"); + holders.setAccessible(true); holders.set(registry, new IdentityHashMap<>()); + var frozen = net.minecraft.core.MappedRegistry.class.getDeclaredField("frozen"); + frozen.setAccessible(true); frozen.setBoolean(registry, false); + state = new FixtureBlock().defaultBlockState(); + var u = Class.forName("sun.misc.Unsafe"); var f = u.getDeclaredField("theUnsafe"); f.setAccessible(true); + var unsafe = f.get(null); var allocate = u.getMethod("allocateInstance", Class.class); + try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]))); var lines = Files.lines(Path.of(args[0]))) { + for (var line : (Iterable)lines::iterator) { + var row = JsonParser.parseString(line).getAsJsonObject(); + var world = (World)allocate.invoke(unsafe, World.class); world.cells = new HashMap<>(); + for (var e : row.getAsJsonArray("cells")) { + var c = e.getAsJsonArray(); + world.cells.put(new BlockPos(c.get(0).getAsInt(), c.get(1).getAsInt(), c.get(2).getAsInt()), new Cell(shape(c.get(3)), shape(c.get(4)), c.get(5).getAsBoolean())); + } + var p = row.getAsJsonArray("pos"); double x=p.get(0).getAsDouble(), y=p.get(1).getAsDouble(), z=p.get(2).getAsDouble(); + var bounds = new AABB(x-.5, y, z-.5, x+.5, y+.25, z+.5); + out.println(Cushion.canBePlacedAt(world, bounds) + " " + Cushion.wouldSurviveAt(world, bounds)); + } + } + } +} diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 6dabe6dd7..866602c9b 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -6955,5 +6955,51 @@ CHECKS += ['sonic'] CHECK_FNS['sonic'] = check_sonic +def check_cushions(host, guest, out): + """#1935: place a colored cushion, consume it, track it, sit, dismount and break it.""" + seen = {} + for label, player, color in (('host', host, 'red'), ('guest', guest, 'blue')): + x, y, z = (int(v // 1) for v in pos_of(player)) + at = (x + 0.5, y, z + 2.5) + host('command', command=f'fill {x - 1} {y} {z + 1} {x + 1} {y + 3} {z + 3} minecraft:air') + host('command', command=f'setblock {x} {y - 1} {z + 2} minecraft:stone') + player('command', command='gamemode survival') + player('command', command=f'item replace entity @s weapon.mainhand with minecraft:{color}_cushion') + player.wait(10) + player.raw('look-at', *at) + player.raw('mouse', 'right', 'click') + player.wait(15) + def cushions(game): + return [o for o in game('status')['objects'] if o['kind'] == 'minecraft:cushion' + and abs(o['position'][0] - at[0]) < .1 and abs(o['position'][2] - at[2]) < .1] + server, client = cushions(host), cushions(player) + seen[label] = {'server': server, 'client': client, + 'remaining': player('inventory')['counts'].get(f'minecraft:{color}_cushion', 0)} + assert len(server) == len(client) == 1, seen + assert server[0]['color'] == client[0]['color'] == color, seen + assert seen[label]['remaining'] == 0, seen + player.shot(out, 'cushion-placed') + player.raw('look-at', at[0], at[1] + .125, at[2]) + player.raw('mouse', 'right', 'click') + player.wait(10) + seen[label]['riding'] = player('status').get('riding') + assert seen[label]['riding'] == server[0]['id'], seen + player.raw('key', 'shift', 'down') + player.wait(5) + player.raw('key', 'shift', 'up') + player.wait(5) + assert player('status').get('riding') is None, seen + player('command', command='gamemode creative') + player.raw('look-at', at[0], at[1] + .125, at[2]) + player.raw('mouse', 'left', 'click') + player.wait(10) + assert not cushions(host) and not cushions(player), seen + return seen + + +CHECKS += ['cushions'] +CHECK_FNS['cushions'] = check_cushions + + if __name__ == '__main__': main() diff --git a/tools/test_cushion_placement_parity.py b/tools/test_cushion_placement_parity.py new file mode 100644 index 000000000..6b9d5ae0a --- /dev/null +++ b/tools/test_cushion_placement_parity.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Compare cushion support, burial and coverage against vanilla's real checks.""" +import json +import random + +try: + from . import lab, oracle_harness as harness +except ImportError: + import lab, oracle_harness as harness + + +def make_input(build): + full = [[0, 0, 0, 1, 1, 1]] + shapes = [[], full, [[0, 0, 0, 1, .5, 1]], [[0, .5, 0, 1, 1, 1]], + [[.375, 0, .375, .625, 1.5, .625]], [[0, 0, 0, .5, 1, 1]], + [[0, 0, 0, .5, 1, 1], [.5, 0, 0, 1, 1, 1]]] + rows = [] + for x, z in ((0, 0), (-2, -3), (17, 31)): + for y in (0, .125, .5, .875, 1, 1.00000001, 1.015625, 1.125): + for outline in shapes: + for collision in shapes: + for suffocating in (False, True): + rows.append(dict(pos=[x + .5, y, z + .5], + cells=[[x, 0, z, outline, collision, suffocating]])) + rng = random.Random(1935) + for _ in range(500): + cells = [] + for y in (-1, 0, 1): + for x in (-1, 0, 1): + for z in (-1, 0, 1): + if rng.random() < .3: + cells.append([x, y, z, rng.choice(shapes), rng.choice(shapes), bool(rng.randrange(2))]) + rows.append(dict(pos=[rng.choice((0, .5, 1)), rng.choice((0, .5, 1, 1.125)), rng.choice((0, .5, 1))], cells=cells)) + path = build / 'inputs.jsonl' + path.write_text(''.join(json.dumps(row) + '\n' for row in rows)) + return path, len(rows) + + +def main(): + harness.differential( + 'rust-cushion-placement-parity', 'VanillaCushionPlacementOracle', + 'cushion_placement_probe', make_input, + 'Actual Cushion.canBePlacedAt and wouldSurviveAt, with controlled outline, collision ' + 'and suffocation queries. Includes partial supports, burial, complete coverage, ' + 'overhanging outlines, negative coordinates and boundary heights. Item use, ' + 'entity tracking, interaction and persistence are checked separately.', + sources=[lab.ROOT / 'crates/horizon-client/src/cushion.rs']) + + +if __name__ == '__main__': + main()