Compare commits

...
Author SHA1 Message Date
Ludovic Marie 76e216cd1f union: #2987 2026-10-29 09:25:25 +01:00
Ludovic Marie 5779d8d909 union: #2807 2026-10-29 09:22:32 +01:00
Ludovic Marie e9c6751a1b Merge main into fix/shield-blocking-pose 2026-10-29 01:14:31 +01:00
Kacpherek 1944dd45e4 Fix display light borrow and format
display_light took &self while text displays were borrowed mutably; it now takes the world. Also rustfmt.
2026-10-27 22:35:25 +01:00
Kacpherek b910cdd257 Draw block and item display entities on clients
A client only had an arm for text displays. DisplayRenderer draws a block display's model or an item display's item under the same billboard and transformation, with the brightness override or the light at the entity.
2026-10-27 22:25:37 +01:00
Kacpherek 3934833369 Send text, block and item display data to clients
Summoned displays reached clients as bare entities, so a text display showed nothing. Vanilla syncs Display's entries 8 to 22 and each type's own (text 23 to 27, block state 23, item and context 23 and 24) from the saved compound.
2026-10-27 22:25:37 +01:00
Kacpherek 621473f60e Send the ominous item spawner to guests and draw its floating item
The spawner existed only on the server, so a client never saw the item grow and turn. Vanilla tracks it with DATA_ITEM; the client draws OminousItemSpawnerRenderer and runs tickClient's particles.
2026-10-27 22:21:29 +01:00
Kacpherek 2b2dc1929f Show tipped and spectral arrows to guests and clients
A server sent every arrow as a plain one. Vanilla sends a spectral arrow as its own type and a tipped arrow's ID_EFFECT_COLOR, and the client draws arrow_spectral.png and sheds the effect swirls (Arrow.tick) and sparkles (SpectralArrow.tick).
2026-10-27 22:20:33 +01:00
levzzzandminimax-m3.1-flash 826147d716 Merge upstream/main into fix/shield-blocking-pose
Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:14:14 +05:00
levzzzandminimax-m3.1-flash 0b4fa473e7 Test the shield's blocking pose and its use duration
A shield's use animation is the BLOCK one: AvatarRenderer.getArmPose
raises the arm for it and Item.getUseDuration gives it 72000 ticks, so
the arm stays up while it is used. The fix (kacpherek's #2310) sets the
blocking flag for the other players and gives the shield its use
duration; these tests pin both ends: the arm goes across the body while
the shield is used, and the shield's use duration is vanilla's 72000
ticks, not the 0 an item without an use animation gets (#2289).

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:13:31 +05:00
levzzzandminimax-m3.1-flash b85ce82160 Merge kacpherek's PR #2310: the shield goes up in first person and other players show the blocking pose
Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:48:33 +05:00
14 changed files with 690 additions and 30 deletions

No files matched your search

+3
View File
@@ -164,6 +164,8 @@ pub struct ArrowState {
/// `AbstractArrow.firedFromWeapon`: the crossbow or bow it was shot from.
pub fired_from_weapon: Option<crate::items::ItemStack>,
pub no_physics: bool,
/// `Arrow.ID_EFFECT_COLOR` as a client has it from its server (-1: none).
pub effect_color: i32,
/// `AbstractArrow.getPierceLevel`.
pub pierce: u8,
/// `piercingIgnoreEntityIds`: the entities a piercing arrow has gone
@@ -214,6 +216,7 @@ impl ArrowState {
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
fired_from_weapon: None,
no_physics: false,
effect_color: -1,
pierce: 0,
pierced: Vec::new(),
fire_ticks: 0,
+1
View File
@@ -8166,6 +8166,7 @@ impl Game<'_> {
self.guest_model_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:evoker_fangs");
self.fangs_geometry(camera, alpha, &mut out);
self.block_item_display_geometry(camera, alpha, &mut out);
let mut see_through = std::mem::take(&mut self.see_through_vertices);
see_through.clear();
self.name_tag_geometry(camera, alpha, &mut out, &mut see_through);
+9 -1
View File
@@ -4647,7 +4647,15 @@ impl Game<'_> {
let Some(model) = self.arrow_model.as_ref() else {
continue;
};
let Some(r) = atlas.sprites.get("entity/projectiles/arrow").copied() else {
// SpectralArrowRenderer, and TippableArrowRenderer's `getColor() > 0`.
let texture = if a.is_spectral_arrow() {
"entity/projectiles/arrow_spectral"
} else if a.arrow.as_ref().is_some_and(|s| s.effect_color > 0) {
"entity/projectiles/arrow_tipped"
} else {
"entity/projectiles/arrow"
};
let Some(r) = atlas.sprites.get(texture).copied() else {
continue;
};
let m = glam::Mat4::from_translation(c)
@@ -458,6 +458,7 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn civil_dates_follow_the_gregorian_calendar() {
@@ -465,4 +466,27 @@ mod tests {
assert_eq!(civil(19_716), (2023, 12, 25));
assert_eq!(civil(11_016), (2000, 2, 29));
}
#[test]
fn a_shield_is_used_for_the_vanilla_s_time() {
// Item.getUseDuration: an item with `blocks_attacks` (a shield) is
// used for 72000 ticks, so its use animation (the arm held up)
// runs instead of ending at once (#2289).
let mut shield = Components::new();
shield.insert("minecraft:blocks_attacks".into(), json!({}));
assert_eq!(use_duration(&shield), 72_000);
// Food is used for as long as it takes to eat it.
let mut food = Components::new();
food.insert(
"minecraft:consumable".into(),
json!({"consume_seconds": 1.6}),
);
assert_eq!(use_duration(&food), 32);
// The bow keeps its own use time.
let mut bow = Components::new();
bow.insert("minecraft:item_model".into(), json!("minecraft:bow"));
assert_eq!(use_duration(&bow), 72_000);
// Anything else is not used at all.
assert_eq!(use_duration(&Components::new()), 0);
}
}
+97 -2
View File
@@ -9,7 +9,7 @@ use horizon_client::lan::{
hub::Hub,
};
use horizon_protocol::entity_data::{Value, serializer};
use horizon_world::nbt::Tag;
use horizon_world::{nbt::Tag, registry::BlockStateId};
fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) -> EntityState {
EntityState {
@@ -258,18 +258,100 @@ impl Game<'_> {
}
out.push(e);
}
// DATA_ITEM, `ItemStack.EMPTY` by default.
for o in self.ominous_spawners.iter().filter(|o| o.id != 0) {
let mut e = state(
o.id,
"minecraft:ominous_item_spawner",
o.position,
0.0,
false,
);
e.meta.push((
8,
serializer::ITEM_STACK,
Value::Item(hub.stack(&Some(o.item.clone()))),
));
out.push(e);
}
self.lan_projectile_states(host_entity, hub, out);
for o in &self.others {
let p = o.position;
let mut e = state(o.id, &o.kind, [p.x, p.y, p.z], o.yaw, false);
e.pitch = o.pitch;
e.head_yaw = o.yaw;
if let Some(tag) = self.persist.raw.get(&o.id) {
self.lan_display_data(tag, hub, &mut e);
}
out.push(e);
}
}
}
impl Game<'_> {
/// The synched data of a text, block or item display kept unsimulated,
/// from its compound (`Display.readAdditionalSaveData`), with the
/// defaults to send when a value goes back to one.
fn lan_display_data(&self, tag: &Tag, hub: &mut Hub, e: &mut EntityState) {
use horizon_client::text_display::{display_data, text_display_data};
let empty = Tag::Compound(Default::default());
let (meta, defaults) = match e.kind.as_str() {
"minecraft:text_display" => (
text_display_data(tag, false),
text_display_data(&empty, true),
),
"minecraft:block_display" => {
// DATA_BLOCK_STATE_ID, air by default.
let state = |t: Option<BlockStateId>| {
(
23,
serializer::BLOCK_STATE,
Value::Id(t.map_or(0, |s| s.0 as i32)),
)
};
let air = self.pack.assets.registry.default_state("minecraft:air");
let shown = tag.get("block_state").and_then(|t| self.state_from_nbt(t));
(vec![state(shown.or(air))], vec![state(air)])
}
"minecraft:item_display" => {
// DATA_ITEM_STACK and DATA_ITEM_DISPLAY_ID (`ItemDisplayContext`).
let item = tag
.get("item")
.and_then(horizon_client::save::item_from_nbt);
let context = match tag.get("item_display") {
Some(Tag::String(s)) => match s.to_string_lossy().as_str() {
"thirdperson_lefthand" => 1,
"thirdperson_righthand" => 2,
"firstperson_lefthand" => 3,
"firstperson_righthand" => 4,
"head" => 5,
"gui" => 6,
"ground" => 7,
"fixed" => 8,
"on_shelf" => 9,
_ => 0,
},
_ => 0,
};
(
vec![
(23, serializer::ITEM_STACK, Value::Item(hub.stack(&item))),
(24, serializer::BYTE, Value::Byte(context)),
],
vec![
(23, serializer::ITEM_STACK, Value::Item(None)),
(24, serializer::BYTE, Value::Byte(0)),
],
)
}
_ => return,
};
e.meta = display_data(tag, false);
e.meta.extend(meta);
e.defaults = display_data(&empty, true);
e.defaults.extend(defaults);
}
/// Gives the entities Horizon keeps without an id one (`Entity.id`, from
/// the same counter as the rest) before they are first tracked.
pub(super) fn lan_assign_ids(&mut self) {
@@ -308,6 +390,9 @@ impl Game<'_> {
self.rockets.iter_mut().for_each(|r| give(&mut r.id));
self.crystals.iter_mut().for_each(|c| give(&mut c.id));
self.bolts.iter_mut().for_each(|b| give(&mut b.id));
self.ominous_spawners
.iter_mut()
.for_each(|o| give(&mut o.id));
self.potions.thrown_ids().for_each(&mut give);
self.end.eye_ids().for_each(&mut give);
self.breath_clouds.iter_mut().for_each(|b| give(&mut b.id));
@@ -359,7 +444,12 @@ impl Game<'_> {
}
for p in self.creatures.projectiles.iter().filter(|p| p.id != 0) {
use horizon_client::projectiles::ProjectileKind as K;
let kind = p.kind.id();
// A spectral arrow is its own entity type.
let kind = if p.is_spectral_arrow() {
"minecraft:spectral_arrow"
} else {
p.kind.id()
};
let mut e = state(p.id, kind, at(p.position), p.yaw, false);
e.pitch = p.pitch;
e.velocity = at(p.velocity);
@@ -378,6 +468,11 @@ impl Game<'_> {
a.map_or(0, |a| a.pierce),
a.is_some_and(|a| a.in_ground) || p.stuck,
));
// Arrow.ID_EFFECT_COLOR, -1 by default.
let color = p.arrow_effect_color();
if color != -1 {
e.meta.push((11, serializer::INT, Value::Int(color)));
}
}
// ThrownTrident.ID_LOYALTY (none for a mob's) and ID_FOIL, on
// AbstractArrow's flags.
@@ -21,6 +21,8 @@ const TICKS_SCALING: f32 = 50.0;
const ROTATION_SPEED: f32 = 40.0;
pub(super) struct OminousItemSpawner {
/// The entity id a server tracks it by (0 until it is tracked).
pub(super) id: u32,
pub(super) item: ItemStack,
pub(super) position: [f64; 3],
pub(super) spawn_item_after: i64,
@@ -65,6 +67,7 @@ impl Game<'_> {
delay: i32,
) {
self.ominous_spawners.push(OminousItemSpawner {
id: 0,
item,
position,
spawn_item_after: i64::from(delay),
@@ -82,6 +85,7 @@ impl Game<'_> {
return;
};
self.ominous_spawners.push(OminousItemSpawner {
id: 0,
item,
position: [at.x, at.y, at.z],
spawn_item_after,
@@ -121,6 +125,32 @@ impl Game<'_> {
kept.append(&mut self.ominous_spawners);
self.ominous_spawners = kept;
}
/// `handleAddEntity` for a spawner a server sent: its item comes with the
/// entity data (`ItemStack.EMPTY` until then, which draws nothing).
pub(super) fn add_remote_ominous_item_spawner(&mut self, id: u32, position: [f64; 3]) {
self.ominous_spawners.push(OminousItemSpawner {
id,
item: ItemStack::new("minecraft:air", 1),
position,
spawn_item_after: 0,
tick_count: 0,
uuid: [0; 4],
raw: None,
});
}
/// `OminousItemSpawner.tick` on a client: `tickCount` and `tickClient`,
/// the particles every 5 game ticks; only the server spawns the item.
pub(super) fn tick_remote_ominous_item_spawners(&mut self) {
let at: Vec<[f64; 3]> = self.ominous_spawners.iter().map(|s| s.position).collect();
for s in &mut self.ominous_spawners {
s.tick_count += 1;
}
if self.world.game_time % 5 == 0 {
for position in at {
self.ominous_spawner_particles(position);
}
}
}
/// `spawnItem`: a projectile shot down, or an item entity; level event
/// 3021 and the entity_place game event.
fn spawn_ominous_item(&mut self, s: &OminousItemSpawner) {
+1 -1
View File
@@ -620,7 +620,7 @@ impl Game<'_> {
*self.persist.uuids.entry(id).or_insert_with(random_uuid)
}
/// `BlockState.CODEC`: a block name or `{id, properties}` (or legacy `{Name, Properties}`).
fn state_from_nbt(&self, tag: &Tag) -> Option<BlockStateId> {
pub(super) fn state_from_nbt(&self, tag: &Tag) -> Option<BlockStateId> {
let registry = self.pack.assets.registry;
let state = match tag.get("Name") {
Some(Tag::String(name)) => BlockState {
+1
View File
@@ -4049,6 +4049,7 @@ impl Game<'_> {
fn tick_remote_entities(&mut self, running: bool) {
if running {
self.tick_text_displays();
self.tick_remote_ominous_item_spawners();
}
// EntityFluidInteraction.update for the entities this window only displays: the
// server never sends the fluid contact, and the renderers read it (cod, salmon and
@@ -35,11 +35,18 @@ impl Game<'_> {
m.extra.leash = Some(Holder::Knot(block));
}
}
} else if kind == "minecraft:text_display" {
} else if let Some(shows) = match kind {
"minecraft:text_display" => Some(super::text_displays::Shows::Text),
"minecraft:block_display" => Some(super::text_displays::Shows::Block),
"minecraft:item_display" => Some(super::text_displays::Shows::Item),
_ => None,
} {
self.text_displays.insert(
id as i32,
super::text_displays::RemoteTextDisplay::new(position, yaw),
super::text_displays::RemoteTextDisplay::new(shows, position, yaw),
);
} else if kind == "minecraft:ominous_item_spawner" {
self.add_remote_ominous_item_spawner(id, position);
} else if let Some(cart) = super::minecarts::CartKind::from_id(kind) {
let mut c = super::minecarts::Minecart::new(id, cart, at);
c.yaw = yaw;
@@ -119,13 +126,33 @@ impl Game<'_> {
/// `handleSetEntityData` for an object: what changes how it looks.
pub(super) fn remote_object_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
if let Some(display) = self.text_displays.get_mut(&id) {
display.metadata(values);
if self.text_displays.contains_key(&id) {
// ItemDisplay.DATA_ITEM_STACK, read with the server's registries.
let item = values
.iter()
.find_map(|(index, _, value)| match (index, value) {
(23, Value::Item(stack)) => Some(self.to_stack(stack)),
_ => None,
});
if let Some(display) = self.text_displays.get_mut(&id) {
display.show(values, item);
display.metadata(values);
}
return;
}
let id = id as u32;
for (index, _, value) in values {
if self.item_frames.iter().any(|f| f.id == id) {
if let (8, Value::Item(stack)) = (index, value)
&& self.ominous_spawners.iter().any(|s| s.id == id)
{
// OminousItemSpawner.DATA_ITEM.
if let (Some(item), Some(s)) = (
self.to_stack(stack),
self.ominous_spawners.iter_mut().find(|s| s.id == id),
) {
s.item = item;
}
} else if self.item_frames.iter().any(|f| f.id == id) {
// HangingEntity.DATA_DIRECTION, ItemFrame.DATA_ITEM and DATA_ROTATION.
let item = match (index, value) {
(9, Value::Item(stack)) => Some(self.to_stack(stack)),
@@ -319,6 +346,7 @@ impl Game<'_> {
/// Every object a server sent (a new connection, level or dimension).
pub(super) fn clear_remote_objects(&mut self) {
self.text_displays.clear();
self.ominous_spawners.clear();
self.remote_knots.clear();
self.carts.clear();
self.boats.clear();
@@ -330,6 +358,7 @@ impl Game<'_> {
pub(super) fn remove_remote_object(&mut self, id: u32) {
self.text_displays.remove(&(id as i32));
self.ominous_spawners.retain(|s| s.id != id);
self.remote_knots.remove(&id);
self.carts.retain(|c| c.id != id);
self.boats.retain(|b| b.id != id);
@@ -3,6 +3,7 @@
//! and `handleSetEntityData`): the server simulates them, the client draws
//! them where it says and interpolates their moves.
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector};
use horizon_client::items::ItemStack;
use horizon_client::projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld};
use horizon_client::{
collisions::SceneCollisions,
@@ -113,6 +114,10 @@ fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
p.previous = p.position;
p.age += 1;
// AbstractArrow.tick: `inGroundTime` counts the ticks in a block.
if let Some(a) = p.arrow.as_mut() {
a.in_ground_time = if p.stuck { a.in_ground_time + 1 } else { 0 };
}
if p.stuck {
return;
}
@@ -154,6 +159,51 @@ pub(super) fn has_smoke_trail(kind: &ProjectileKind) -> bool {
}
impl Game<'_> {
/// `Arrow.tick` and `SpectralArrow.tick` on a client: a tipped arrow
/// sheds two entity-effect swirls in its colour while flying, one every
/// fifth tick in a block; a spectral arrow an effect sparkle at its
/// position while flying.
fn arrow_effect_particles(&mut self) {
// The arrow's box: `EntityType.ARROW` is 0.5 x 0.5.
const SIZE: f64 = 0.5;
for p in &self.creatures.projectiles {
let Some(a) = p.arrow.as_ref().filter(|_| p.is_arrow()) else {
continue;
};
let at = p.position;
if p.is_spectral_arrow() {
if !p.stuck {
let options = ParticleOptions::simple(ParticleType::Effect);
self.particles.add(&options, [at.x, at.y, at.z], [0.0; 3]);
}
continue;
}
let color = a.effect_color;
let count = match (p.stuck, a.in_ground_time % 5) {
(true, 0) => 1,
(true, _) => 0,
(false, _) => 2,
};
if color == -1 {
continue;
}
// ColorParticleOption.create(ENTITY_EFFECT, colour): ARGB as floats.
let channel = |shift: u32| (color as u32 >> shift & 255) as f32 / 255.0;
let options = ParticleOptions::colored(
ParticleType::EntityEffect,
[channel(16), channel(8), channel(0), channel(24)],
);
for _ in 0..count {
// getRandomX(0.5), getRandomY, getRandomZ(0.5).
let r = self.particles.random();
let x = at.x + SIZE * (2.0 * r.next_double() - 1.0) * 0.5;
let y = at.y + SIZE * r.next_double();
let z = at.z + SIZE * (2.0 * r.next_double() - 1.0) * 0.5;
self.particles.add(&options, [x, y, z], [0.0; 3]);
}
}
}
/// `AbstractHurtingProjectile.createParticleTrail`: one smoke puff half a
/// block above each position.
pub(super) fn smoke_trails(&mut self, at: impl IntoIterator<Item = Vec3>) {
@@ -253,6 +303,12 @@ impl Game<'_> {
.llama_spit_burst(position, [velocity.x, velocity.y, velocity.z]);
}
let mut p = Projectile::new(k, at, None);
// SpectralArrow: its own type; its pickup item says so.
if kind == "minecraft:spectral_arrow"
&& let Some(a) = p.arrow.as_mut()
{
a.pickup_item = ItemStack::new("minecraft:spectral_arrow", 1);
}
p.id = id;
p.velocity = velocity;
p.yaw = yaw;
@@ -370,6 +426,12 @@ impl Game<'_> {
(ProjectileKind::Arrow { .. }, 10, Value::Bool(in_ground)) => {
p.stuck = *in_ground;
}
// Arrow.ID_EFFECT_COLOR.
(ProjectileKind::Arrow { .. }, 11, Value::Int(color)) => {
if let Some(a) = p.arrow.as_mut() {
a.effect_color = *color;
}
}
// WitherSkull.DATA_DANGEROUS.
(ProjectileKind::WitherSkull { dangerous }, 8, Value::Bool(d)) => {
*dangerous = *d;
@@ -471,6 +533,7 @@ impl Game<'_> {
}
self.creatures.projectiles = projectiles;
self.tridents = tridents;
self.arrow_effect_particles();
let trails: Vec<Vec3> = self
.creatures
.projectiles
+138 -19
View File
@@ -1,15 +1,34 @@
//! Server text displays: metadata, interpolation, billboard transforms and world text.
use super::Game;
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
hud::{HudBatch, rgba},
items::ItemStack,
mesher::Vertex,
text::Text,
text_display::{Align, TextDisplayData},
};
use horizon_protocol::entity_data::Value;
use horizon_world::registry::BlockStateId;
/// What a display shows besides its transformation: its text (in `data`),
/// a block state (`BlockDisplay`) or an item and how it is displayed
/// (`ItemDisplay`).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Shows {
Text,
Block,
Item,
}
pub(super) struct RemoteTextDisplay {
pub data: TextDisplayData,
shows: Shows,
/// `BlockDisplay.DATA_BLOCK_STATE_ID`.
block: Option<BlockStateId>,
/// `ItemDisplay.DATA_ITEM_STACK` and `DATA_ITEM_DISPLAY_ID`.
item: Option<ItemStack>,
item_display: u8,
from: TextDisplayData,
shown: TextDisplayData,
position: glam::DVec3,
@@ -47,9 +66,13 @@ fn renders(id: u8) -> bool {
}
impl RemoteTextDisplay {
pub fn new(position: [f64; 3], yaw: f32) -> Self {
pub fn new(shows: Shows, position: [f64; 3], yaw: f32) -> Self {
Self {
data: Default::default(),
shows,
block: None,
item: None,
item_display: 0,
from: Default::default(),
shown: Default::default(),
position: position.into(),
@@ -77,6 +100,24 @@ impl RemoteTextDisplay {
(((self.age - self.start) as f32 + partial) / self.duration as f32).clamp(0.0, 1.0)
}
}
/// The entries after `Display`'s: a block state, or an item with its
/// display context (the stack comes converted by the caller).
pub fn show(&mut self, values: &[(u8, i32, Value)], item: Option<Option<ItemStack>>) {
for (id, _, value) in values {
match (self.shows, id, value) {
(Shows::Block, 23, Value::Id(state)) => {
self.block = Some(BlockStateId(u32::try_from(*state).unwrap_or(0)));
}
(Shows::Item, 24, Value::Byte(context)) => self.item_display = *context as u8,
_ => {}
}
}
if let Some(item) = item
&& self.shows == Shows::Item
{
self.item = item;
}
}
pub fn metadata(&mut self, values: &[(u8, i32, Value)]) {
self.start_dirty |= values.iter().any(|v| v.0 == 8);
self.duration_dirty |= values.iter().any(|v| v.0 == 9);
@@ -149,6 +190,96 @@ impl Game<'_> {
display.tick();
}
}
/// The camera's yaw and pitch as `DisplayRenderer` reads them (the
/// front view turns around).
fn display_camera_angles(&self) -> [f32; 2] {
if self.camera_type == super::perspective::CameraType::ThirdPersonFront {
[self.yaw + 180.0, -self.pitch]
} else {
[self.yaw, self.pitch]
}
}
/// `DisplayRenderer.getBlockLightLevel` and `getSkyLightLevel`: the
/// brightness override, else the light at the entity.
fn display_light(
world: &horizon_client::world::World,
d: &TextDisplayData,
position: glam::DVec3,
) -> u32 {
if d.brightness == -1 {
let world_light = world.light(
position.x.floor() as i32,
position.y.floor() as i32,
position.z.floor() as i32,
);
horizon_client::block_entity_render::vertex_light(world_light)
} else {
((d.brightness as u32 >> 20 & 15) * 16) | (((d.brightness as u32 >> 4 & 15) * 16) << 8)
}
}
/// `DisplayRenderer.BlockDisplayRenderer` and `ItemDisplayRenderer`: the
/// block model or the item under the display's transformation, lit like
/// any entity and never tinted by a hurt overlay.
pub(super) fn block_item_display_geometry(
&mut self,
camera: glam::DVec3,
partial: f64,
out: &mut Vec<Vertex>,
) {
let angles = self.display_camera_angles();
let scale = self.view_scale();
let mut displays = std::mem::take(&mut self.text_displays);
for shows in [Shows::Block, Shows::Item] {
let kind = if shows == Shows::Block {
"minecraft:block_display"
} else {
"minecraft:item_display"
};
self.mark_entity(out.len(), kind);
for (id, display) in displays.iter_mut().filter(|(_, d)| d.shows == shows) {
display.last_progress = display.progress(partial as f32);
let d = display.shown.blend(&display.from, display.last_progress);
let position = display.previous.lerp(display.position, partial);
let range = f64::from(d.view_range) * 64.0 * scale;
if position.distance_squared(camera) >= range * range {
continue;
}
let rotation = std::array::from_fn(|i| {
display.old_rotation[i]
+ angle(display.rotation[i] - display.old_rotation[i]) * partial as f32
});
let matrix = glam::Mat4::from_translation((position - camera).as_vec3())
* d.matrix(rotation, angles);
if !matrix.is_finite() {
continue;
}
let light = Self::display_light(&self.world, &d, position);
if let Some(state) = display.block {
// BlockModelResolver.update: model seed 42.
self.block_model_vertices(state, 42, matrix, light, true, out);
} else if let Some(stack) = &display.item {
let context = match display.item_display {
1 => DisplayContext::ThirdPersonLeftHand,
2 => DisplayContext::ThirdPersonRightHand,
3 => DisplayContext::FirstPersonLeftHand,
4 => DisplayContext::FirstPersonRightHand,
5 => DisplayContext::Head,
6 => DisplayContext::Gui,
7 => DisplayContext::Ground,
8 => DisplayContext::Fixed,
9 => DisplayContext::OnShelf,
_ => DisplayContext::None,
};
// updateForNonLiving: seeded by the entity id.
let state = self.resolve_item(stack, context, None, *id);
let pose = matrix * glam::Mat4::from_rotation_y(std::f32::consts::PI);
self.submit_item_world(&state, pose, light, out);
}
}
}
self.mark_entity(out.len(), "");
self.text_displays = displays;
}
pub(super) fn text_display_geometry(
&mut self,
camera: glam::DVec3,
@@ -158,14 +289,12 @@ impl Game<'_> {
) {
self.text_display_vertices = 0;
let atlas = &self.gui.atlas;
let front = self.camera_type == super::perspective::CameraType::ThirdPersonFront;
let angles = if front {
[self.yaw + 180.0, -self.pitch]
} else {
[self.yaw, self.pitch]
};
let angles = self.display_camera_angles();
let scale = self.view_scale();
for display in self.text_displays.values_mut() {
if display.shows != Shows::Text {
continue;
}
display.last_progress = display.progress(partial as f32);
let d = display.shown.blend(&display.from, display.last_progress);
let position = display.previous.lerp(display.position, partial);
@@ -240,17 +369,7 @@ impl Game<'_> {
d.flags & 1 != 0,
);
}
let world_light = self.world.light(
position.x.floor() as i32,
position.y.floor() as i32,
position.z.floor() as i32,
);
let light = if d.brightness == -1 {
horizon_client::block_entity_render::vertex_light(world_light)
} else {
((d.brightness as u32 >> 20 & 15) * 16)
| (((d.brightness as u32 >> 4 & 15) * 16) << 8)
};
let light = Self::display_light(&self.world, &d, position);
let through = d.flags & 2 != 0;
let out = if through {
&mut *see_through
@@ -285,7 +404,7 @@ mod tests {
use super::*;
#[test]
fn metadata_and_position_interpolation_keep_their_separate_clocks() {
let mut d = RemoteTextDisplay::new([0.0; 3], 0.0);
let mut d = RemoteTextDisplay::new(Shows::Text, [0.0; 3], 0.0);
d.tick();
d.metadata(&[
(8, 1, Value::Int(0)),
+60
View File
@@ -893,6 +893,8 @@ pub fn setup(
#[cfg(test)]
mod flight_pose_tests {
use super::*;
use horizon_core::geometry::Vec3;
use std::sync::Arc;
#[test]
fn blocking_arm_follows_the_head_with_limits_in_both_hands() {
@@ -1036,4 +1038,62 @@ mod flight_pose_tests {
humanoid(&mut dry, &state, &Hum::new(ArmPose::Empty, ArmPose::Empty));
assert_eq!(dry.get("head")[3], 30.0f32.to_radians());
}
/// A player: a humanoid model with a shield in its main hand.
fn player() -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let k = Arc::make_mut(&mut kind);
k.anim = Anim::Humanoid { zombie_arms: false };
Mob::new(1, kind, Vec3::ZERO, 0.0)
}
#[test]
fn a_raised_shield_holds_the_arm_across_the_body() {
// AvatarRenderer.getArmPose: the used item's use animation decides
// the arm's pose, and a shield (an item with `blocks_attacks`) uses
// the BLOCK animation, so the arm is held up across the body
// (#2289).
let state = State {
pos: 0.0,
speed: 0.0,
age: 0.0,
y_rot: 0.0,
x_rot: 0.0,
swing: 0.0,
baby: false,
partial: 0.0,
};
let mut p = Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
hidden: Default::default(),
};
let mut m = player();
m.extra.main_hand = Some("minecraft:shield".into());
m.extra.using_item = 20;
m.extra.blocking = true;
setup(&m, &state, &mut p, &HashMap::new());
// HumanoidModel.setupAnim's BLOCK pose.
assert!((p.get("right_arm")[3] + 0.942_477_9).abs() < 1e-6);
assert!((p.get("right_arm")[4] + FRAC_PI_6).abs() < 1e-6);
// An item that does not block attacks leaves the arm holding it.
let mut m = player();
m.extra.main_hand = Some("minecraft:stick".into());
m.extra.using_item = 20;
let mut p = Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
hidden: Default::default(),
};
setup(&m, &state, &mut p, &HashMap::new());
assert!((p.get("right_arm")[3] + PI / 10.0).abs() < 1e-6);
}
}
+32
View File
@@ -269,6 +269,38 @@ impl Projectile {
pub fn is_arrow(&self) -> bool {
matches!(self.kind, ProjectileKind::Arrow { .. })
}
/// Whether it is a `SpectralArrow` (its own entity type): a spectral
/// arrow's pickup item, or the glowing effect a dispenser shoots with.
pub fn is_spectral_arrow(&self) -> bool {
let ProjectileKind::Arrow { effect, .. } = &self.kind else {
return false;
};
effect.is_some_and(|(id, _)| id == "minecraft:glowing")
|| self
.arrow
.as_ref()
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow")
}
/// `Arrow.ID_EFFECT_COLOR` (`updateColor`): the opaque colour of the
/// potion the arrow carries, from its tipped pickup item or the effect a
/// stray, bogged or parched adds; -1 for none.
pub fn arrow_effect_color(&self) -> i32 {
let ProjectileKind::Arrow { effect, .. } = &self.kind else {
return -1;
};
if self.is_spectral_arrow() {
return -1;
}
let rgb = match (effect, &self.arrow) {
(Some((id, _)), _) => crate::brewing::effect(id).map(|e| e.color),
(None, Some(a)) if &*a.pickup_item.item == "minecraft:tipped_arrow" => a
.pickup_item
.potion()
.map(|p| crate::brewing::potion_color(Some(p))),
_ => None,
};
rgb.map_or(-1, |rgb| (rgb | 0xFF00_0000) as i32)
}
pub fn is_fireball(&self) -> bool {
matches!(
self.kind,
+197 -2
View File
@@ -1,7 +1,11 @@
//! Display/TextDisplay synced data (26.3), checked against vanilla accessors
//! and ClientboundSetEntityDataPacket's own stream codec.
use crate::text::Text;
use horizon_protocol::entity_data::Value;
use crate::{
save::{float, number},
text::Text,
};
use horizon_protocol::entity_data::{Value, serializer};
use horizon_world::nbt::Tag;
use serde::Serialize;
use std::sync::Arc;
@@ -154,6 +158,197 @@ impl TextDisplayData {
}
}
/// One synched data entry: kept when it differs from its default, or always
/// with `all` (the defaults a client is sent when a value goes back to one).
fn entry(
out: &mut Vec<(u8, i32, Value)>,
all: bool,
id: u8,
kind: i32,
value: Value,
default: Value,
) {
if all || value != default {
out.push((id, kind, if all { default } else { value }));
}
}
/// `Display.readAdditionalSaveData`: the synched data entries (ids 8 to 22)
/// of a saved display, or with `all` their defaults. A matrix `transformation`
/// (`MATRIX4F`) is not decomposed: it stays the identity.
pub fn display_data(tag: &Tag, all: bool) -> Vec<(u8, i32, Value)> {
let int = |key: &str| number(tag.get(key)).map(|v| v as i32);
let real = |key: &str| float(tag.get(key)).map(|v| v as f32);
let mut out = Vec::new();
let mut set = |id: u8, kind: i32, value: Value, default: Value| {
entry(&mut out, all, id, kind, value, default);
};
let defaults = TextDisplayData::default();
// The start delay, the transformation duration, the teleport duration (0 to 59).
let ints = [
(8, "start_interpolation", 0, i32::MIN, i32::MAX),
(9, "interpolation_duration", 0, i32::MIN, i32::MAX),
(10, "teleport_duration", 0, 0, 59),
(22, "glow_color_override", -1, i32::MIN, i32::MAX),
];
for (id, key, default, low, high) in ints {
let value = int(key).unwrap_or(default).clamp(low, high);
set(id, serializer::INT, Value::Int(value), Value::Int(default));
}
// `Transformation.CODEC`: all four parts, else the identity.
let t = tag.get("transformation").and_then(transformation);
let (translation, left, scale, right) = t.unwrap_or((
defaults.translation,
defaults.left_rotation,
defaults.scale,
defaults.right_rotation,
));
let parts = [
(
11,
serializer::VECTOR3,
translation.to_vec(),
defaults.translation.to_vec(),
),
(
12,
serializer::VECTOR3,
scale.to_vec(),
defaults.scale.to_vec(),
),
(
13,
serializer::QUATERNION,
left.to_vec(),
defaults.left_rotation.to_vec(),
),
(
14,
serializer::QUATERNION,
right.to_vec(),
defaults.right_rotation.to_vec(),
),
];
for (id, kind, value, default) in parts {
set(id, kind, Value::Floats(value), Value::Floats(default));
}
let billboard = match tag.get("billboard") {
Some(Tag::String(s)) => match s.to_string_lossy().as_str() {
"vertical" => 1,
"horizontal" => 2,
"center" => 3,
_ => 0,
},
_ => 0,
};
set(15, serializer::BYTE, Value::Byte(billboard), Value::Byte(0));
// `Brightness.pack`: block light in bits 4 to 7, sky light in bits 20 to 23.
let brightness = tag.get("brightness").and_then(|b| {
let light = |key: &str| number(b.get(key)).filter(|v| (0..=15).contains(v));
Some((light("block")? as i32) << 4 | (light("sky")? as i32) << 20)
});
set(
16,
serializer::INT,
Value::Int(brightness.unwrap_or(-1)),
Value::Int(-1),
);
for (id, key, default) in [
(17, "view_range", 1.0),
(18, "shadow_radius", 0.0),
(19, "shadow_strength", 1.0),
(20, "width", 0.0),
(21, "height", 0.0),
] {
let value = real(key).unwrap_or(default);
set(
id,
serializer::FLOAT,
Value::Float(value),
Value::Float(default),
);
}
out
}
/// `TextDisplay.readAdditionalSaveData`: the text, line width, background,
/// opacity and style flags (ids 23 to 27), or with `all` their defaults.
pub fn text_display_data(tag: &Tag, all: bool) -> Vec<(u8, i32, Value)> {
let int = |key: &str| number(tag.get(key)).map(|v| v as i32);
let mut out = Vec::new();
let mut set = |id: u8, kind: i32, value: Value, default: Value| {
entry(&mut out, all, id, kind, value, default);
};
let empty = Tag::String("".into());
set(
23,
serializer::COMPONENT,
Value::Text(tag.get("text").unwrap_or(&empty).clone()),
Value::Text(empty.clone()),
);
set(
24,
serializer::INT,
Value::Int(int("line_width").unwrap_or(200)),
Value::Int(200),
);
let background = Value::Int(int("background").unwrap_or(0x4000_0000));
set(25, serializer::INT, background, Value::Int(0x4000_0000));
let opacity = Value::Byte(int("text_opacity").unwrap_or(-1) as i8);
set(26, serializer::BYTE, opacity, Value::Byte(-1));
// Shadow 1, see-through 2, default background 4, then the alignment: left 8, right 16.
let flag = |key: &str, bit: i8| {
if int(key).is_some_and(|v| v != 0) {
bit
} else {
0
}
};
let align = match tag.get("alignment") {
Some(Tag::String(s)) => match s.to_string_lossy().as_str() {
"left" => 8,
"right" => 16,
_ => 0,
},
_ => 0,
};
let flags = flag("shadow", 1) | flag("see_through", 2) | flag("default_background", 4) | align;
set(27, serializer::BYTE, Value::Byte(flags), Value::Byte(0));
out
}
/// `Transformation.CODEC`'s translation, left rotation, scale and right
/// rotation; None unless all four are there (the matrix form is not read).
fn transformation(tag: &Tag) -> Option<([f32; 3], [f32; 4], [f32; 3], [f32; 4])> {
let list = |tag: Option<&Tag>| -> Option<Vec<f32>> {
match tag? {
Tag::List(l) => l.iter().map(|v| float(Some(v)).map(|v| v as f32)).collect(),
_ => None,
}
};
let vector = |key: &str| -> Option<[f32; 3]> { list(tag.get(key))?.try_into().ok() };
// `ExtraCodecs.QUATERNIONF`: four components (normalized) or an axis and an angle.
let rotation = |key: &str| -> Option<[f32; 4]> {
let value = tag.get(key)?;
if let Some(q) = list(Some(value)) {
let [x, y, z, w]: [f32; 4] = q.try_into().ok()?;
let length = (x * x + y * y + z * z + w * w).sqrt();
return Some([x / length, y / length, z / length, w / length]);
}
let angle = float(value.get("angle"))? as f32;
let [x, y, z]: [f32; 3] = list(value.get("axis"))?.try_into().ok()?;
let (sin, cos) = (angle * 0.5).sin_cos();
let inverse = 1.0 / (x * x + y * y + z * z).sqrt();
Some([x * inverse * sin, y * inverse * sin, z * inverse * sin, cos])
};
Some((
vector("translation")?,
rotation("left_rotation")?,
vector("scale")?,
rotation("right_rotation")?,
))
}
#[cfg(test)]
mod tests {
use super::*;