Implement cushion placement and synchronize placed cushions
Add cushion entities, shape-based placement and support checks, colored rendering, server tracking, sitting, destruction and save/load support. Compare placement rules against vanilla and exercise host/guest interactions. Co-Authored-By: OpenAI GPT-6.1 Sol <noreply@openai.com>
This commit is contained in:
@@ -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<Aabb> {
|
||||
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<VoxelShape> {
|
||||
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<VoxelShape> {
|
||||
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(())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Aabb>;
|
||||
fn collision_shapes(&self, bounds: Aabb) -> Vec<VoxelShape>;
|
||||
fn suffocating(&self, pos: [i32; 3]) -> bool;
|
||||
}
|
||||
|
||||
pub fn blocks_in(a: Aabb) -> impl Iterator<Item = [i32; 3]> {
|
||||
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
|
||||
}
|
||||
@@ -589,6 +589,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 {
|
||||
|
||||
@@ -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<Cushion> {
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
@@ -1421,6 +1422,8 @@ struct Game<'a> {
|
||||
map_frame_model: frames::FrameModel,
|
||||
glow_map_frame_model: frames::FrameModel,
|
||||
armor_stands: Vec<horizon_client::entities::ArmorStand>,
|
||||
/// `Cushion` decoration entities (game/cushions.rs).
|
||||
cushions: Vec<horizon_client::entities::Cushion>,
|
||||
/// Entities of types Horizon does not simulate.
|
||||
others: Vec<commands::OtherEntity>,
|
||||
armor_stand_assets: armor_stands::ArmorStandAssets,
|
||||
@@ -1436,6 +1439,8 @@ struct Game<'a> {
|
||||
boat_models: boats::BoatModels,
|
||||
/// EndCrystalModel's baked layer.
|
||||
crystal_model: Option<horizon_client::mobs::Part>,
|
||||
/// CushionModel's baked layer.
|
||||
cushion_model: Option<horizon_client::mobs::Part>,
|
||||
carts: Vec<minecarts::Minecart>,
|
||||
/// Songs playing from jukeboxes (tracked sound handles).
|
||||
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
|
||||
@@ -3460,6 +3465,7 @@ impl Game<'_> {
|
||||
fn interact_entity(&mut self) -> Result<bool> {
|
||||
Ok(self.interact_frame()
|
||||
|| self.interact_armor_stand()
|
||||
|| self.interact_cushion()
|
||||
|| self.interact_boat()
|
||||
|| self.interact_cart()
|
||||
|| self.interact_mob()?
|
||||
@@ -3646,6 +3652,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);
|
||||
@@ -4526,6 +4533,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());
|
||||
@@ -7210,6 +7218,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");
|
||||
@@ -7495,6 +7504,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()
|
||||
@@ -8240,6 +8250,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();
|
||||
@@ -12245,6 +12256,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,
|
||||
@@ -12254,6 +12266,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(),
|
||||
@@ -12391,6 +12404,7 @@ struct GameCpuResources {
|
||||
boat_models: boats::BoatModels,
|
||||
cart_models: minecarts::CartModels,
|
||||
crystal_model: Option<horizon_client::mobs::Part>,
|
||||
cushion_model: Option<horizon_client::mobs::Part>,
|
||||
dragon_model: Option<horizon_client::mobs::Part>,
|
||||
arrow_model: Option<horizon_client::mobs::Part>,
|
||||
trident_model: Option<horizon_client::mobs::Part>,
|
||||
@@ -12411,6 +12425,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"),
|
||||
@@ -13751,7 +13766,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();
|
||||
@@ -13771,6 +13786,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());
|
||||
|
||||
@@ -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(());
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
//! 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<Aabb> {
|
||||
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<horizon_core::geometry::VoxelShape> {
|
||||
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
|
||||
}
|
||||
_ 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));
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.place",
|
||||
Some([position.x, position.y, position.z]),
|
||||
0.75,
|
||||
0.8,
|
||||
);
|
||||
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`: a vehicle it cannot seat returns CONSUME.
|
||||
if occupied {
|
||||
self.swing_arm();
|
||||
return true;
|
||||
}
|
||||
// `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<horizon_client::mesher::Vertex>,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -50,6 +50,7 @@ pub(super) struct Parked {
|
||||
paintings: Vec<horizon_client::entities::Painting>,
|
||||
item_frames: Vec<horizon_client::entities::ItemFrame>,
|
||||
armor_stands: Vec<horizon_client::entities::ArmorStand>,
|
||||
cushions: Vec<horizon_client::entities::Cushion>,
|
||||
others: Vec<commands::OtherEntity>,
|
||||
boats: Vec<boats::Boat>,
|
||||
carts: Vec<minecarts::Minecart>,
|
||||
@@ -135,6 +136,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(),
|
||||
@@ -185,6 +187,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);
|
||||
|
||||
@@ -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<u32> = (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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -120,6 +120,8 @@ pub struct ItemInfo {
|
||||
pub crafting_remainder: Option<String>,
|
||||
/// `dye`: the dye colour name (dyes, and anything else with the component).
|
||||
pub dye: Option<String>,
|
||||
/// `cushion/color`: the DyeColor name of a cushion item.
|
||||
pub cushion_color: Option<String>,
|
||||
/// `block_transformer`: the registry entry an axe, hoe or shovel uses on blocks.
|
||||
pub block_transformer: Option<String>,
|
||||
/// Has a default `bundle_contents` (the bundles, `BundleItem`).
|
||||
@@ -599,6 +601,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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<BlockPos, Cell> 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<VoxelShape> getBlockCollisions(Entity e, AABB b) {
|
||||
var result = new ArrayList<VoxelShape>();
|
||||
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<BlockState> p) { throw new AssertionError(); }
|
||||
@Override public void forEach(BiConsumer<BlockPos, BlockState> 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<String>)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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6591,5 +6591,51 @@ CHECKS += ['replant']
|
||||
CHECK_FNS['replant'] = check_replant
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user